Compare commits

..

18 commits

Author SHA1 Message Date
55794f5801 Update Keycloak packages to v26.7.1
All checks were successful
/ Verify (pull_request) Successful in 1m8s
2026-08-05 16:02:06 +00:00
b5c83f7210 default-to-user-email (#30)
All checks were successful
/ Verify (push) Successful in 55s
Add 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

Reviewed-on: #30
Reviewed-by: June <june@noreply.git.hamburg.ccc.de>
2026-07-21 22:17:49 +02:00
c6f7d2f335 Add exporting individual attributes and a map endpoint (#26)
All checks were successful
/ Verify (push) Successful in 51s
Based off #32

Also:
* rework README.md
* improve configuration error messages

Closes #22

Reviewed-on: #26
Reviewed-by: June <june@noreply.git.hamburg.ccc.de>
2026-07-21 21:47:24 +02:00
510c045070 Formatting
All checks were successful
/ Verify (pull_request) Successful in 52s
/ Verify (push) Successful in 46s
2026-07-21 21:20:15 +02:00
218ee1dd57 Add just the refactoring from #26.
All checks were successful
/ Verify (pull_request) Successful in 45s
This is based off #31, so that needs to be merged first.
2026-07-19 13:21:57 +02:00
f204ab7f28 Add just the testing setup from #26.
All checks were successful
/ Verify (pull_request) Successful in 1m12s
/ Verify (push) Successful in 53s
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.
2026-07-19 10:27:51 +02:00
6cd678b414 Spell option correctly
All checks were successful
/ Verify (push) Successful in 45s
2026-07-18 21:41:42 +02:00
89ce38f6c1 Be less verbose when building
Some checks failed
/ Verify (push) Failing after 17s
2026-07-18 21:40:39 +02:00
0a84d2ddf9 Only build when pushing to main
All checks were successful
/ Verify (push) Successful in 46s
or on a pull request
2026-07-18 21:38:50 +02:00
stb
94eeb9e757 Merge pull request 'Add integration test for ssh key export' (#28) from integration-testing into main
All checks were successful
/ Verify (push) Successful in 41s
Reviewed-on: #28
Reviewed-by: kritzl <kritzl@noreply.git.hamburg.ccc.de>
2026-07-18 15:09:37 +02:00
f586e5feee Integration test to demonstrate ssh key export
All checks were successful
/ Verify (push) Successful in 45s
/ Verify (pull_request) Successful in 45s
2026-07-18 15:02:12 +02:00
5e279f8c71 Assign roles to service account user, not the client 2026-07-18 15:01:48 +02:00
b292fd8023 Give a meaningful error message when the necessary role is missing 2026-07-18 15:01:26 +02:00
stb
46622825f4 Merge pull request 'Update actions/checkout action to v7' (#21) from renovate/actions-checkout-7.x into main
All checks were successful
/ Verify (push) Successful in 45s
Reviewed-on: #21
2026-07-18 14:17:23 +02:00
stb
6b12072145 Merge branch 'main' into renovate/actions-checkout-7.x
All checks were successful
/ Verify (push) Successful in 54s
/ Verify (pull_request) Successful in 48s
2026-07-18 14:17:02 +02:00
stb
23ecf682d6 Merge pull request 'Update maven plugins' (#19) from renovate/maven-plugins into main
Some checks failed
/ Verify (push) Has been cancelled
Reviewed-on: #19
2026-07-18 14:16:50 +02:00
58ffc634ba Update actions/checkout action to v7
All checks were successful
/ Verify (pull_request) Successful in 44s
/ Verify (push) Successful in 46s
2026-06-18 15:02:15 +00:00
afbda2a52f Update maven plugins
All checks were successful
/ Verify (push) Successful in 47s
/ Verify (pull_request) Successful in 47s
2026-05-27 20:47:03 +00:00
13 changed files with 739 additions and 250 deletions

View file

@ -1,17 +1,19 @@
on: on:
pull_request: pull_request:
push: push:
branches:
- main
jobs: jobs:
ansible-lint: ansible-lint:
name: Verify name: Verify
runs-on: docker runs-on: docker
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@v7
- name: Install maven - name: Install maven
run: | run: |
apt update apt update
apt install -y maven apt install -y maven
- name: Run maven verify - name: Run maven verify
run: | run: |
mvn -f attribute-endpoints-provider verify mvn -f attribute-endpoints-provider --batch-mode --no-transfer-progress verify

View file

@ -1,6 +1,12 @@
install:
mvn -f attribute-endpoints-provider install
verify: verify:
mvn -f attribute-endpoints-provider verify mvn -f attribute-endpoints-provider verify
test:
mvn -f attribute-endpoints-provider test
clean: clean:
mvn -f attribute-endpoints-provider clean mvn -f attribute-endpoints-provider clean

118
README.md
View file

@ -1,16 +1,39 @@
# Attribute Endpoints Provider # Attribute Endpoints Provider - Export User Attributes
This is a Keycloak Provider that exports an anonymized list of user profile attribute values. This is a Keycloak Provider that exports user profile attribute values.
For this it will provide API endpoints for every configured attribute-group. The selection of attributes and authorization is managed through an endpoint configuration in the Keycloak admin website.
The configuration of the provider is possible via an admin page.
Every endpoint responds with a list of all attribute values, that: ## Usage
- are in the attribute group matching `attribute-group`
- match an optional RegEx Pattern `attribute-regex` The provider adds an object type "Attribute Endpoints" to the Keycloak admin website.
- belong to a user with a role matching `match-role` You configure the provider by creating one or more endpoint configurations.
- are non-empty
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
- 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.
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 ## Building
@ -20,79 +43,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. 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 ## 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. 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.

View file

@ -17,7 +17,7 @@
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding> <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<maven.compiler.source>17</maven.compiler.source> <maven.compiler.source>17</maven.compiler.source>
<maven.compiler.target>17</maven.compiler.target> <maven.compiler.target>17</maven.compiler.target>
<keycloak.version>26.7.0</keycloak.version> <keycloak.version>26.7.1</keycloak.version>
</properties> </properties>
<dependencies> <dependencies>
@ -28,6 +28,19 @@
<scope>test</scope> <scope>test</scope>
</dependency> </dependency>
<dependency>
<groupId>org.mockito</groupId>
<artifactId>mockito-core</artifactId>
<version>5.23.0</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.jboss.resteasy</groupId>
<artifactId>resteasy-core</artifactId>
<scope>test</scope>
</dependency>
<dependency> <dependency>
<groupId>org.keycloak</groupId> <groupId>org.keycloak</groupId>
<artifactId>keycloak-server-spi-private</artifactId> <artifactId>keycloak-server-spi-private</artifactId>
@ -86,7 +99,7 @@
</plugin> </plugin>
<plugin> <plugin>
<artifactId>maven-surefire-plugin</artifactId> <artifactId>maven-surefire-plugin</artifactId>
<version>3.5.5</version> <version>3.5.6</version>
</plugin> </plugin>
<plugin> <plugin>
<artifactId>maven-jar-plugin</artifactId> <artifactId>maven-jar-plugin</artifactId>
@ -104,7 +117,7 @@
https://maven.apache.org/ref/current/maven-core/lifecycles.html#site_Lifecycle --> https://maven.apache.org/ref/current/maven-core/lifecycles.html#site_Lifecycle -->
<plugin> <plugin>
<artifactId>maven-site-plugin</artifactId> <artifactId>maven-site-plugin</artifactId>
<version>3.21.0</version> <version>3.22.0</version>
</plugin> </plugin>
<plugin> <plugin>
<artifactId>maven-project-info-reports-plugin</artifactId> <artifactId>maven-project-info-reports-plugin</artifactId>

View file

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

View file

@ -1,21 +1,12 @@
package de.ccc.hamburg.keycloak.attribute_endpoints; package de.ccc.hamburg.keycloak.attribute_endpoints;
import java.util.Collection; import jakarta.ws.rs.*;
import java.util.List; import jakarta.ws.rs.core.MediaType;
import java.util.Map; import jakarta.ws.rs.core.Response;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.stream.Stream;
import org.jboss.logging.Logger; import org.jboss.logging.Logger;
import org.keycloak.component.ComponentModel; import org.keycloak.component.ComponentModel;
import org.keycloak.models.ClientModel; import org.keycloak.models.*;
import org.keycloak.models.KeycloakContext; import org.keycloak.representations.userprofile.config.UPAttribute;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.RealmModel;
import org.keycloak.models.RoleModel;
import org.keycloak.models.UserModel;
import org.keycloak.models.UserProvider;
import org.keycloak.representations.userprofile.config.UPConfig; import org.keycloak.representations.userprofile.config.UPConfig;
import org.keycloak.services.managers.AppAuthManager; import org.keycloak.services.managers.AppAuthManager;
import org.keycloak.services.managers.Auth; import org.keycloak.services.managers.Auth;
@ -23,16 +14,12 @@ import org.keycloak.services.managers.AuthenticationManager.AuthResult;
import org.keycloak.services.resource.RealmResourceProvider; import org.keycloak.services.resource.RealmResourceProvider;
import org.keycloak.userprofile.UserProfileProvider; import org.keycloak.userprofile.UserProfileProvider;
import jakarta.ws.rs.ForbiddenException; import java.util.Collection;
import jakarta.ws.rs.GET; import java.util.List;
import jakarta.ws.rs.NotAuthorizedException; import java.util.Map;
import jakarta.ws.rs.NotFoundException; import java.util.regex.Pattern;
import jakarta.ws.rs.Path; import java.util.stream.Collectors;
import jakarta.ws.rs.PathParam; import java.util.stream.Stream;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.ServerErrorException;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
public class AttributeEndpointsResourceProvider implements RealmResourceProvider { public class AttributeEndpointsResourceProvider implements RealmResourceProvider {
private static final Logger LOG = Logger.getLogger(AttributeEndpointsResourceProvider.class); private static final Logger LOG = Logger.getLogger(AttributeEndpointsResourceProvider.class);
@ -51,100 +38,156 @@ public class AttributeEndpointsResourceProvider implements RealmResourceProvider
public void close() { 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 @GET
@Path("export/{slug}") @Path("export/{slug}")
@Produces(MediaType.APPLICATION_JSON) @Produces(MediaType.APPLICATION_JSON)
public Response exportAttributeValues(@PathParam("slug") String slug) { public Response exportAttributeValues(@PathParam("slug") String attributeGroupName) {
KeycloakContext context = session.getContext(); AttributeExportContext ctx = new AttributeExportContext(attributeGroupName);
RealmModel realm = context.getRealm();
List<ComponentModel> componentList = realm.getComponentsStream() List<String> attribute_list = ctx.users
.filter(c -> c.getProviderId().equals(AdminUiPage.PROVIDER_ID)) .map(user -> ctx.attributeNames.stream()
.filter(c -> c.getConfig().getFirst("slug").equals(slug)) .map(attributeName -> ctx.MapUserAttribute(user, attributeName).toList())
.toList(); .flatMap(Collection::stream)
.filter(attribute -> !attribute.isEmpty()).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<String> attributeNames = upconfig.getAttributes()
.stream()
.filter(a -> a.getGroup() != null && a.getGroup().equals(configAttributeGroup))
.map(a -> a.getName())
.toList();
UserProvider userProvider = session.users();
Stream<UserModel> users = userProvider.searchForUserStream(realm, Map.of())
.filter(user -> user.hasRole(matchRole));
List<String> attribute_list = users
.map(user -> {
Stream<String> attributeStream = attributeNames.stream()
.map(attributeName -> user.getAttributeStream(attributeName).toList())
.flatMap(Collection::stream);
return attributeStream
.filter(attribute -> !attribute.isEmpty())
.toList();
})
.flatMap(List::stream) .flatMap(List::stream)
.filter(attribute -> { .filter(ctx.filter::matches)
if (regexIsBlank) {
return true;
}
final Pattern pattern = Pattern.compile(configAttributeRegex);
final Matcher matcher = pattern.matcher(attribute);
return matcher.find();
})
.toList(); .toList();
return Response.ok(attribute_list).build(); return Response.ok(attribute_list).build();
}
/**
* Return a map of lists of attribute values. For each attribute in the named attribute
* group, add an entry in the resulting map with the attribute name as the key, and the
* values as a list as the values.
*
* @param attributeGroupName attribute group name
* @return a map of attribute names and their values
*/
@GET
@Path("export/{attributeGroupName}/map")
@Produces(MediaType.APPLICATION_JSON)
public Response exportAttributeValuesMap(@PathParam("attributeGroupName") String attributeGroupName) {
AttributeExportContext ctx = new AttributeExportContext(attributeGroupName);
List<UserModel> userList = ctx.users.toList();
Map<String, List<String>> attributeMap = ctx.attributeNames.stream()
.collect(Collectors.toMap(
attributeName -> attributeName,
attributeName -> userList.stream()
.flatMap(user -> ctx.MapUserAttribute(user, attributeName))
.filter(attribute -> !attribute.isEmpty())
.filter(ctx.filter::matches)
.toList()));
return Response.ok(attributeMap).build();
}
/**
* Resolves and validates the configuration and request state needed to export attribute
* values for a given slug, exposing the results as member variables.
*/
private class AttributeExportContext {
final Pattern validEmail = Pattern.compile("@");
List<String> attributeNames;
UserModel authUser;
String configAttributeGroup;
boolean defaultToUserEmail;
RegExFilter filter;
UPConfig upconfig;
Stream<UserModel> users;
AttributeExportContext(String slug) {
RealmModel realm = session.getContext().getRealm();
List<ComponentModel> 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("Attribute Endpoint " + slug + " not found");
}
if (componentList.size() > 1) {
throw new NotFoundException(
"Attribute Endpoint Configuration Error - Multiple configurations exist for " + slug);
}
ComponentModel component = componentList.get(0);
RoleModel authRole = realm.getRole(component.getConfig().getFirst("auth-role"));
if (authRole == null) {
throw new ServerErrorException("Attribute Endpoint " + slug + " Configuration Error - auth-role does not exist.", 500);
}
RoleModel matchRole = realm.getRole(component.getConfig().getFirst("match-role"));
if (matchRole == null) {
throw new ServerErrorException("Attribute Endpoint " + slug + " Configuration Error - match-role does not exist.", 500);
}
upconfig = session.getProvider(UserProfileProvider.class).getConfiguration();
configAttributeGroup = component.getConfig().getFirst("attribute-group");
if (upconfig.getGroups().stream().noneMatch(g -> g.getName().equals(configAttributeGroup)) &&
upconfig.getAttributes().stream().noneMatch(a -> a.getName().equals(configAttributeGroup))) {
throw new ServerErrorException("Attribute Endpoint " + slug + " Configuration Error - no attribute or attribute group named " + configAttributeGroup + " found", 500);
}
try {
filter = new RegExFilter(component.getConfig().getFirst("attribute-regex"));
} catch (Exception e) {
throw new ServerErrorException(
"Attribute Endpoint " + slug + " 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.getName().equals(configAttributeGroup)
|| (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));
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<String> MapUserAttribute(UserModel user, String attributeName) {
if (defaultToUserEmail) {
// need to load everything otherwise we can't check for non-existent attribute
Map<String, List<String>> attrs = user.getAttributes();
return attrs.getOrDefault(attributeName,
List.of(user.getEmail())).stream().filter(v -> !validEmail.matcher(v).matches());
}
return user.getAttributeStream(attributeName);
}
} }
private static Auth getAuth(KeycloakSession session) { private static Auth getAuth(KeycloakSession session) {
@ -155,8 +198,25 @@ public class AttributeEndpointsResourceProvider implements RealmResourceProvider
} }
RealmModel realm = session.getContext().getRealm(); RealmModel realm = session.getContext().getRealm();
ClientModel client = auth.getClient(); ClientModel client = auth.client();
return new Auth(realm, auth.getToken(), auth.getUser(), client, auth.getSession(), false); 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();
}
} }
} }

View file

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

View file

@ -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.*;
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<String, RoleModel> 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<ComponentModel> components = List.of(
attributeEndpointComponent("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,
true),
attributeEndpointComponent("mailing-list-addresses-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", "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", "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", "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);
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());
}
}
@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@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@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<ComponentModel> duplicateComponents = List.of(
attributeEndpointComponent("duplicate-slug",
"export-dooris-ssh-keys", "dooris-authorized", "dooris-ssh-keys",
false),
attributeEndpointComponent("duplicate-slug",
"export-dooris-ssh-keys", "dooris-authorized", "dooris-ssh-keys",
false));
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<ComponentModel> components = List.of(
attributeEndpointComponent("bad-auth-role",
"nonexistent-role", "dooris-authorized", "dooris-ssh-keys",
false));
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<ComponentModel> components = List.of(
attributeEndpointComponent("bad-match-role",
"export-dooris-ssh-keys", "nonexistent-role", "dooris-ssh-keys",
false));
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",
false);
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<Response> call) {
UserModel serviceAccount = mock(UserModel.class);
when(serviceAccount.hasRole(roles.get(requiredRole))).thenReturn(true);
try (MockedConstruction<AppAuthManager.BearerTokenAuthenticator> 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, 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;
}
/**
* 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, String email, Map<String, List<String>> attributes, List<String> 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 -> value.stream()));
when(user.hasRole(any(RoleModel.class))).thenReturn(false);
roleNames.forEach(name -> when(user.hasRole(roles.get(name))).thenReturn(true));
return user;
}
}

View file

@ -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`. 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/. 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`. 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`. The realm export is in `import/testing.json`.
It will be imported automatically when Keycloak starts. 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. If you want to make changes to the testing realm and persist them, you need to export the realm.
@ -23,22 +25,56 @@ The copying of the database is necessary to avoid locking errors.
docker compose exec -it keycloak sh -c "cp -rp /opt/keycloak/data/h2 /tmp && env -i -- /opt/keycloak/bin/kc.sh export --db dev-file --db-url 'jdbc:h2:file:/tmp/h2/keycloakdb;NON_KEYWORDS=VALUE' --file /opt/keycloak/data/import/testing.json --realm testing" docker compose exec -it keycloak sh -c "cp -rp /opt/keycloak/data/h2 /tmp && env -i -- /opt/keycloak/bin/kc.sh export --db dev-file --db-url 'jdbc:h2:file:/tmp/h2/keycloakdb;NON_KEYWORDS=VALUE' --file /opt/keycloak/data/import/testing.json --realm testing"
``` ```
## Testing Realm Configuration ## Running Integration Tests
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@example.com",
"tester+chaos@example.com"
]
intern@
[
"hacker+intern@example.net"
]
```
## Realm `testing` Configuration
The realm contains these objects: The realm contains these objects:
* Clients: * Clients:
* `export-dooris-ssh-keys` with secret `export-dooris-ssh-keys-secret` and role `export-dooris-ssh-keys` * `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: * Realm Roles:
* `dooris-authorized` * `dooris-authorized` to assign to users whose SSH keys should be exported
* `mailing-list-chaos-member` * `mailing-list-chaos-member` to assign to users who are a member of the chaos mailing list
* `mailing-list-intern-member` * `mailing-list-intern-member` to assign to users who are a member of the intern mailing list
* `export-dooris-ssh-keys` * `export-dooris-ssh-keys` to assign to service account users that should be allowed to use the dooris endpoint config
* `export-mailing-list-addresses` * `export-mailing-list-addresses` to assign to service account users that should be allowed to use the mailing list endpoint configs
* Groups: * Groups:
* `chaos` with role `mailing-list-chaos-member` * `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: * Users:
* `tester` (Tony Tester), email `tester@example.com`, member of group `chaos` * `tester` (Tony Tester), email `tester@example.com`, member of group `chaos`
* Mailing List Addresses: * Mailing List Addresses:
@ -54,3 +90,34 @@ The realm contains these objects:
* Dooris SSH Keys: * Dooris SSH Keys:
* SSH Key 1: `hacker-ssh-key-1` * SSH Key 1: `hacker-ssh-key-1`
* SSH Key 2: `hacker-ssh-key-2` * SSH Key 2: `hacker-ssh-key-2`
* `service-account-export-dooris-ssh-keys`
* Role `export-dooris-ssh-keys`
* `service-account-export-mailing-list-addresses`
* Role `export-mailing-list-addresses`
* Realm Settings:
* User Profile:
* Attribute Groups:
* `dooris-ssh-keys`
* `mailing-list-addresses`
* Attributes:
* `mailing-list-address-chaos`
* Attribute Group `mailing-list-addresses`
* `mailing-list-address-intern`
* Attribute Group `mailing-list-addresses`
* `ssh-key-1`
* Attribute Group `dooris-ssh-keys`
* `ssh-key-2`
* Attribute Group `dooris-ssh-keys`
* Custom Attributes:
* Slug `dooris-ssh-keys`
* Attribute Group `dooris-ssh-keys`
* Match Role `dooris-authorized`
* Auth Role `export-dooris-ssh-keys`
* Slug `mailing-list-addresses-chaos`
* Attribute Group `mailing-list-addresses`
* Match Role `mailing-list-chaos-member`
* Auth Role `export-mailing-list-addresses`
* Slug `mailing-list-addresses-intern`
* Attribute Group `mailing-list-addresses`
* Match Role `mailing-list-intern-member`
* Auth Role `export-mailing-list-addresses`

View file

@ -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-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 . -
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 . -

View file

@ -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 . -

View file

@ -1,6 +1,6 @@
services: services:
keycloak: keycloak:
image: quay.io/keycloak/keycloak:26.7.0 image: quay.io/keycloak/keycloak:26.7.1
pull_policy: always pull_policy: always
command: "start-dev --features=declarative-ui --import-realm" command: "start-dev --features=declarative-ui --import-realm"
environment: environment:
@ -12,6 +12,17 @@ services:
ports: ports:
- "8080:8080" - "8080:8080"
- "8081:8081" - "8081:8081"
networks:
- keycloak
volumes: volumes:
- ./import:/opt/keycloak/data/import/ - ./import:/opt/keycloak/data/import/
- ../attribute-endpoints-provider/target/attribute-endpoints-provider-1.0-SNAPSHOT.jar:/opt/keycloak/providers/attribute-endpoints-provider.jar - ../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

View file

@ -447,8 +447,7 @@
"attributes" : { "attributes" : {
"ssh-key-1" : [ "hacker-ssh-key-1" ], "ssh-key-1" : [ "hacker-ssh-key-1" ],
"mailing-list-address-intern" : [ "hacker+intern@example.net" ], "mailing-list-address-intern" : [ "hacker+intern@example.net" ],
"ssh-key-2" : [ "hacker-ssh-key-2" ], "ssh-key-2" : [ "hacker-ssh-key-2" ]
"mailing-list-address-chaos" : [ "hacker+chaos@example.net" ]
}, },
"enabled" : true, "enabled" : true,
"createdTimestamp" : 1784376038850, "createdTimestamp" : 1784376038850,
@ -470,7 +469,7 @@
"credentials" : [ ], "credentials" : [ ],
"disableableCredentialTypes" : [ ], "disableableCredentialTypes" : [ ],
"requiredActions" : [ ], "requiredActions" : [ ],
"realmRoles" : [ "default-roles-testing" ], "realmRoles" : [ "export-dooris-ssh-keys", "default-roles-testing" ],
"notBefore" : 0, "notBefore" : 0,
"groups" : [ ] "groups" : [ ]
}, { }, {
@ -484,7 +483,7 @@
"credentials" : [ ], "credentials" : [ ],
"disableableCredentialTypes" : [ ], "disableableCredentialTypes" : [ ],
"requiredActions" : [ ], "requiredActions" : [ ],
"realmRoles" : [ "default-roles-testing" ], "realmRoles" : [ "export-mailing-list-addresses", "default-roles-testing" ],
"notBefore" : 0, "notBefore" : 0,
"groups" : [ ] "groups" : [ ]
}, { }, {
@ -1478,7 +1477,7 @@
"subType" : "authenticated", "subType" : "authenticated",
"subComponents" : { }, "subComponents" : { },
"config" : { "config" : {
"allowed-protocol-mapper-types" : [ "saml-user-property-mapper", "saml-user-attribute-mapper", "oidc-usermodel-attribute-mapper", "oidc-address-mapper", "oidc-full-name-mapper", "saml-role-list-mapper", "oidc-sha256-pairwise-sub-mapper", "oidc-usermodel-property-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", "id" : "a49de9bf-462b-4c61-bb52-0373732f4b1b",
@ -1538,7 +1537,7 @@
"subType" : "anonymous", "subType" : "anonymous",
"subComponents" : { }, "subComponents" : { },
"config" : { "config" : {
"allowed-protocol-mapper-types" : [ "oidc-sha256-pairwise-sub-mapper", "oidc-full-name-mapper", "oidc-address-mapper", "saml-role-list-mapper", "oidc-usermodel-attribute-mapper", "saml-user-property-mapper", "oidc-usermodel-property-mapper", "saml-user-attribute-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", "id" : "dd4024e9-f080-4319-aeb7-7f9906c345c5",
@ -1605,8 +1604,9 @@
"subComponents" : { }, "subComponents" : { },
"config" : { "config" : {
"match-role" : [ "mailing-list-intern-member" ], "match-role" : [ "mailing-list-intern-member" ],
"default-to-user-email" : [ "true" ],
"auth-role" : [ "export-mailing-list-addresses" ], "auth-role" : [ "export-mailing-list-addresses" ],
"attribute-group" : [ "mailing-list-addresses" ], "attribute-group" : [ "mailing-list-address-intern" ],
"slug" : [ "mailing-list-addresses-intern" ] "slug" : [ "mailing-list-addresses-intern" ]
} }
}, { }, {
@ -1615,8 +1615,8 @@
"subComponents" : { }, "subComponents" : { },
"config" : { "config" : {
"match-role" : [ "dooris-authorized" ], "match-role" : [ "dooris-authorized" ],
"auth-role" : [ "export-dooris-ssh-keys" ],
"attribute-group" : [ "dooris-ssh-keys" ], "attribute-group" : [ "dooris-ssh-keys" ],
"auth-role" : [ "export-dooris-ssh-keys" ],
"slug" : [ "dooris-ssh-keys" ] "slug" : [ "dooris-ssh-keys" ]
} }
}, { }, {
@ -1625,7 +1625,8 @@
"subComponents" : { }, "subComponents" : { },
"config" : { "config" : {
"match-role" : [ "mailing-list-chaos-member" ], "match-role" : [ "mailing-list-chaos-member" ],
"attribute-group" : [ "mailing-list-addresses" ], "default-to-user-email" : [ "true" ],
"attribute-group" : [ "mailing-list-address-chaos" ],
"auth-role" : [ "export-mailing-list-addresses" ], "auth-role" : [ "export-mailing-list-addresses" ],
"slug" : [ "mailing-list-addresses-chaos" ] "slug" : [ "mailing-list-addresses-chaos" ]
} }