From 0a7fcf43fd8ffa65d06e9a03f99ca5310a8b91e3 Mon Sep 17 00:00:00 2001 From: rmartinc Date: Thu, 27 Jul 2023 11:21:15 +0200 Subject: [PATCH] Initial pagination in the admin REST API for identity providers Closes https://github.com/keycloak/keycloak/issues/21073 --- .../resource/IdentityProvidersResource.java | 6 ++ .../models/utils/ModelToRepresentation.java | 10 ++- .../admin/IdentityProvidersResource.java | 80 +++++++++++++++---- .../testsuite/admin/IdentityProviderTest.java | 28 ++++++- 4 files changed, 105 insertions(+), 19 deletions(-) diff --git a/integration/admin-client-jee/src/main/java/org/keycloak/admin/client/resource/IdentityProvidersResource.java b/integration/admin-client-jee/src/main/java/org/keycloak/admin/client/resource/IdentityProvidersResource.java index ebc31339c8..08eccdc2e9 100755 --- a/integration/admin-client-jee/src/main/java/org/keycloak/admin/client/resource/IdentityProvidersResource.java +++ b/integration/admin-client-jee/src/main/java/org/keycloak/admin/client/resource/IdentityProvidersResource.java @@ -25,6 +25,7 @@ import javax.ws.rs.POST; import javax.ws.rs.Path; import javax.ws.rs.PathParam; import javax.ws.rs.Produces; +import javax.ws.rs.QueryParam; import javax.ws.rs.core.MediaType; import javax.ws.rs.core.Response; import java.util.List; @@ -43,6 +44,11 @@ public interface IdentityProvidersResource { @Produces(MediaType.APPLICATION_JSON) List findAll(); + @GET + @Path("instances") + @Produces(MediaType.APPLICATION_JSON) + List find(@QueryParam("search") String search, @QueryParam("briefRepresentation") Boolean briefRepresentation, @QueryParam("first") Integer firstResult, @QueryParam("max") Integer maxResults); + @POST @Path("instances") @Consumes(MediaType.APPLICATION_JSON) diff --git a/server-spi-private/src/main/java/org/keycloak/models/utils/ModelToRepresentation.java b/server-spi-private/src/main/java/org/keycloak/models/utils/ModelToRepresentation.java index d26f554e94..52009911e5 100755 --- a/server-spi-private/src/main/java/org/keycloak/models/utils/ModelToRepresentation.java +++ b/server-spi-private/src/main/java/org/keycloak/models/utils/ModelToRepresentation.java @@ -795,14 +795,22 @@ public class ModelToRepresentation { return rep; } - public static IdentityProviderRepresentation toRepresentation(RealmModel realm, IdentityProviderModel identityProviderModel) { + public static IdentityProviderRepresentation toBriefRepresentation(RealmModel realm, IdentityProviderModel identityProviderModel) { IdentityProviderRepresentation providerRep = new IdentityProviderRepresentation(); + // brief representation means IDs, names and enabled providerRep.setInternalId(identityProviderModel.getInternalId()); providerRep.setProviderId(identityProviderModel.getProviderId()); providerRep.setAlias(identityProviderModel.getAlias()); providerRep.setDisplayName(identityProviderModel.getDisplayName()); providerRep.setEnabled(identityProviderModel.isEnabled()); + + return providerRep; + } + + public static IdentityProviderRepresentation toRepresentation(RealmModel realm, IdentityProviderModel identityProviderModel) { + IdentityProviderRepresentation providerRep = toBriefRepresentation(realm, identityProviderModel); + providerRep.setLinkOnly(identityProviderModel.isLinkOnly()); providerRep.setStoreToken(identityProviderModel.isStoreToken()); providerRep.setTrustEmail(identityProviderModel.isTrustEmail()); diff --git a/services/src/main/java/org/keycloak/services/resources/admin/IdentityProvidersResource.java b/services/src/main/java/org/keycloak/services/resources/admin/IdentityProvidersResource.java index bdf47ede5f..3b9ff801b1 100755 --- a/services/src/main/java/org/keycloak/services/resources/admin/IdentityProvidersResource.java +++ b/services/src/main/java/org/keycloak/services/resources/admin/IdentityProvidersResource.java @@ -42,6 +42,8 @@ import org.keycloak.representations.idm.IdentityProviderRepresentation; import org.keycloak.services.ErrorResponse; import org.keycloak.services.resources.KeycloakOpenAPI; import org.keycloak.services.resources.admin.permissions.AdminPermissionEvaluator; +import org.keycloak.utils.ReservedCharValidator; +import org.keycloak.utils.StringUtil; import jakarta.ws.rs.BadRequestException; import jakarta.ws.rs.Consumes; @@ -50,17 +52,20 @@ import jakarta.ws.rs.POST; import jakarta.ws.rs.Path; import jakarta.ws.rs.PathParam; import jakarta.ws.rs.Produces; +import jakarta.ws.rs.QueryParam; import jakarta.ws.rs.core.MediaType; import jakarta.ws.rs.core.MultivaluedMap; import jakarta.ws.rs.core.Response; import java.io.IOException; import java.io.InputStream; +import java.util.Comparator; import java.util.Map; import java.util.Objects; +import java.util.function.Function; +import java.util.function.Predicate; import java.util.stream.Stream; import static jakarta.ws.rs.core.Response.Status.BAD_REQUEST; -import org.keycloak.utils.ReservedCharValidator; /** * @resource Identity Providers @@ -71,8 +76,8 @@ public class IdentityProvidersResource { private final RealmModel realm; private final KeycloakSession session; - private AdminPermissionEvaluator auth; - private AdminEventBuilder adminEvent; + private final AdminPermissionEvaluator auth; + private final AdminEventBuilder adminEvent; public IdentityProvidersResource(RealmModel realm, KeycloakSession session, AdminPermissionEvaluator auth, AdminEventBuilder adminEvent) { this.realm = realm; @@ -82,7 +87,7 @@ public class IdentityProvidersResource { } /** - * Get identity providers + * Get the identity provider factory for a provider id. * * @param providerId Provider id * @return @@ -92,20 +97,19 @@ public class IdentityProvidersResource { @NoCache @Produces(MediaType.APPLICATION_JSON) @Tag(name = KeycloakOpenAPI.Admin.Tags.IDENTITY_PROVIDERS) - @Operation( summary = "Get identity providers") - public Response getIdentityProviders(@Parameter(description = "Provider id") @PathParam("provider_id") String providerId) { + @Operation( summary = "Get the identity provider factory for that provider id") + public IdentityProviderFactory getIdentityProviderFactory(@Parameter(description = "The provider id to get the factory") @PathParam("provider_id") String providerId) { this.auth.realm().requireViewIdentityProviders(); IdentityProviderFactory providerFactory = getProviderFactoryById(providerId); if (providerFactory != null) { - return Response.ok(providerFactory).build(); + return providerFactory; } - return Response.status(BAD_REQUEST).build(); + throw new BadRequestException(); } /** * Import identity provider from uploaded JSON file * - * @param input * @return * @throws IOException */ @@ -166,21 +170,58 @@ public class IdentityProvidersResource { } /** - * Get identity providers + * List identity providers. * - * @return + * @param search Filter to search specific providers by name. Search can be prefixed (name*), contains (*name*) or exact (\"name\"). Default prefixed. + * @param briefRepresentation Boolean which defines whether brief representations are returned (default: false) + * @param firstResult Pagination offset + * @param maxResults Maximum results size (defaults to 100) + * @return The list of providers. */ @GET @Path("instances") @NoCache @Produces(MediaType.APPLICATION_JSON) @Tag(name = KeycloakOpenAPI.Admin.Tags.IDENTITY_PROVIDERS) - @Operation( summary = "Get identity providers") - public Stream getIdentityProviders() { + @Operation(summary = "List identity providers") + public Stream getIdentityProviders( + @Parameter(description = "Filter specific providers by name. Search can be prefix (name*), contains (*name*) or exact (\"name\"). Default prefixed.") @QueryParam("search") String search, + @Parameter(description = "Boolean which defines whether brief representations are returned (default: false)") @QueryParam("briefRepresentation") Boolean briefRepresentation, + @Parameter(description = "Pagination offset") @QueryParam("first") Integer firstResult, + @Parameter(description = "Maximum results size (defaults to 100)") @QueryParam("max") Integer maxResults) { this.auth.realm().requireViewIdentityProviders(); - return realm.getIdentityProvidersStream() - .map(provider -> StripSecretsUtils.strip(ModelToRepresentation.toRepresentation(realm, provider))); + if (maxResults == null) { + maxResults = 100; // always set a maximum of 100 + } + + Function toRepresentation = briefRepresentation != null && briefRepresentation + ? m -> ModelToRepresentation.toBriefRepresentation(realm, m) + : m -> StripSecretsUtils.strip(ModelToRepresentation.toRepresentation(realm, m)); + + Stream stream = realm.getIdentityProvidersStream().sorted(new IdPComparator()); + if (!StringUtil.isBlank(search)) { + stream = stream.filter(predicateByName(search)); + } + if (firstResult != null) { + stream = stream.skip(firstResult); + } + return stream.limit(maxResults).map(toRepresentation); + } + + private Predicate predicateByName(final String search) { + if (search.startsWith("\"") && search.endsWith("\"")) { + final String name = search.substring(1, search.length() - 1); + return (m) -> m.getAlias().equals(name); + } else if (search.startsWith("*") && search.endsWith("*")) { + final String name = search.substring(1, search.length() - 1); + return (m) -> m.getAlias().contains(name); + } else if (search.endsWith("*")) { + final String name = search.substring(0, search.length() - 1); + return (m) -> m.getAlias().startsWith(name); + } else { + return (m) -> m.getAlias().startsWith(search); + } } /** @@ -243,4 +284,13 @@ public class IdentityProvidersResource { return Streams.concat(session.getKeycloakSessionFactory().getProviderFactoriesStream(IdentityProvider.class), session.getKeycloakSessionFactory().getProviderFactoriesStream(SocialIdentityProvider.class)); } + + // TODO: for the moment just sort the identity provider list. But the + // idea is modifying the Model API to get the result already ordered. + private static class IdPComparator implements Comparator { + @Override + public int compare(IdentityProviderModel idp1, IdentityProviderModel idp2) { + return idp1.getAlias().compareTo(idp2.getAlias()); + } + } } diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/admin/IdentityProviderTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/admin/IdentityProviderTest.java index e87be8cb2e..83936beac0 100755 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/admin/IdentityProviderTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/admin/IdentityProviderTest.java @@ -77,6 +77,7 @@ import java.security.cert.CertificateEncodingException; import java.security.cert.X509Certificate; import java.util.Arrays; import java.util.Base64; +import java.util.Collections; import java.util.HashMap; import java.util.HashSet; import java.util.List; @@ -131,12 +132,33 @@ public class IdentityProviderTest extends AbstractAdminTest { + "LXrAUVcsR73oTngrhRfwUSmPrjjK0kjcRb6HL9V/+wh3R/6mEd59U08ExT8N38rhmn0CI3ehMdebReprP7U8="; @Test - public void testFindAll() { + public void testFind() { + create(createRep("twitter", "twitter", true, Collections.singletonMap("key1", "value1"))); + create(createRep("linkedin", "linkedin")); create(createRep("google", "google")); - + create(createRep("github", "github")); create(createRep("facebook", "facebook")); - Assert.assertNames(realm.identityProviders().findAll(), "google", "facebook"); + Assert.assertNames(realm.identityProviders().findAll(), "facebook", "github", "google", "linkedin", "twitter"); + + Assert.assertNames(realm.identityProviders().find(null, true, 0, 2), "facebook", "github"); + Assert.assertNames(realm.identityProviders().find(null, true, 2, 2), "google", "linkedin"); + Assert.assertNames(realm.identityProviders().find(null, true, 4, 2), "twitter"); + + Assert.assertNames(realm.identityProviders().find("g", true, 0, 5), "github", "google"); + + Assert.assertNames(realm.identityProviders().find("g*", true, 0, 5), "github", "google"); + Assert.assertNames(realm.identityProviders().find("g*", true, 0, 1), "github"); + Assert.assertNames(realm.identityProviders().find("g*", true, 1, 1), "google"); + + Assert.assertNames(realm.identityProviders().find("*oo*", true, 0, 5), "google", "facebook"); + + List results = realm.identityProviders().find("\"twitter\"", true, 0, 5); + Assert.assertNames(results, "twitter"); + Assert.assertTrue("Result is not in brief representation", results.iterator().next().getConfig().isEmpty()); + results = realm.identityProviders().find("\"twitter\"", null, 0, 5); + Assert.assertNames(results, "twitter"); + Assert.assertFalse("Config should be present in full representation", results.iterator().next().getConfig().isEmpty()); } @Test