Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion js/libs/keycloak-admin-client/openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -162,10 +162,16 @@ paths:
/admin/api/{realmName}/clients/{version}:
get:
summary: Get all clients
description: Returns a list of all clients in the realm
description: "Returns a list of clients in the realm, optionally filtered by\
\ a query expression"
operationId: getClients
tags:
- Clients (v2)
parameters:
- name: q
in: query
schema:
type: string
responses:
"200":
description: OK
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
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.Response;

Expand All @@ -28,14 +29,18 @@
@Extension(name = KeycloakOpenAPI.Profiles.ADMIN, value = "")
public interface ClientsApi {

default Stream<BaseClientRepresentation> getClients() {
return getClients((String) null);
}

@GET
@Produces(MediaType.APPLICATION_JSON)
@Consumes(MediaType.APPLICATION_JSON)
@Operation(summary = "Get all clients", description = "Returns a list of all clients in the realm")
@Operation(summary = "Get all clients", description = "Returns a list of clients in the realm, optionally filtered by a query expression")
@APIResponses(value = {
@APIResponse(responseCode = "200", content = @Content(schema = @Schema(type = SchemaType.ARRAY, implementation = BaseClientRepresentation.class)))
})
Stream<BaseClientRepresentation> getClients();
Stream<BaseClientRepresentation> getClients(@QueryParam("q") String query);

@POST
@Consumes(MediaType.APPLICATION_JSON)
Expand Down
25 changes: 25 additions & 0 deletions rest/admin-v2/services/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,17 @@
<groupId>org.keycloak</groupId>
<artifactId>keycloak-admin-v2-api</artifactId>
</dependency>
<dependency>
<groupId>org.antlr</groupId>
<artifactId>antlr4-runtime</artifactId>
</dependency>

<!-- Test dependencies -->
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
<!-- used by OAS Filter during OpenAPI spec generation -->
<dependency>
<groupId>io.smallrye</groupId>
Expand All @@ -72,6 +83,20 @@

<build>
<plugins>
<plugin>
<groupId>org.antlr</groupId>
<artifactId>antlr4-maven-plugin</artifactId>
<configuration>
<visitor>true</visitor>
</configuration>
<executions>
<execution>
<goals>
<goal>antlr4</goal>
</goals>
</execution>
</executions>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
lexer grammar ClientQueryLexer;

COLON : ':';
DOT : '.';
LBRACKET : '[' -> pushMode(LIST_MODE);

QUOTED_STRING : '"' ~["]* '"';

BAREWORD : [a-zA-Z0-9_-]+;

WS : [ \t]+ -> skip;

mode LIST_MODE;
LIST_RBRACKET : ']' -> popMode;
LIST_COMMA : ',';
LIST_WS : [ \t]+ -> skip;
LIST_ENTRY : ~[ \t\],[]+;
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
parser grammar ClientQueryParser;

options { tokenVocab = ClientQueryLexer; }

query : expression+ EOF;

expression : fieldPath COLON value;

fieldPath : BAREWORD (DOT BAREWORD)*;

value
: BAREWORD # BareValue
| QUOTED_STRING # QuotedValue
| list # ListValue
;

list : LBRACKET listEntry (LIST_COMMA listEntry)* LIST_RBRACKET;

listEntry : LIST_ENTRY;
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,12 @@

import jakarta.annotation.Nonnull;
import jakarta.validation.Valid;
import jakarta.ws.rs.BadRequestException;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.QueryParam;
import jakarta.ws.rs.core.Response;

import org.keycloak.admin.api.client.ClientApi;
Expand All @@ -17,6 +19,7 @@
import org.keycloak.representations.admin.v2.BaseClientRepresentation;
import org.keycloak.services.client.ClientService;
import org.keycloak.services.client.ClientServiceHelper;
import org.keycloak.services.client.query.ClientQueryException;
import org.keycloak.services.resources.admin.RealmAdminResource;
import org.keycloak.services.resources.admin.fgap.AdminPermissionEvaluator;

Expand All @@ -43,8 +46,13 @@ public DefaultClientsApi(@Nonnull KeycloakSession session,

@GET
@Override
public Stream<BaseClientRepresentation> getClients() {
return clientService.getClients(realm);
public Stream<BaseClientRepresentation> getClients(@QueryParam("q") String query) {
try {
var searchOptions = query != null ? new ClientService.ClientSearchOptions(query) : null;
return clientService.getClients(realm, null, searchOptions, null);
} catch (ClientQueryException e) {
throw new BadRequestException(e.getMessage());
}
}

@POST
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,15 @@
public interface ClientService extends Service {

class ClientSearchOptions {
// TODO
private final String query;

public ClientSearchOptions(String query) {
this.query = query;
}

public String getQuery() {
return query;
}
}

class ClientProjectionOptions {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@
import org.keycloak.representations.idm.RoleRepresentation;
import org.keycloak.services.PatchType;
import org.keycloak.services.ServiceException;
import org.keycloak.services.client.query.ClientQueryEvaluator;
import org.keycloak.services.client.query.QueryParseUtils;
import org.keycloak.services.managers.ClientManager;
import org.keycloak.services.managers.RealmManager;
import org.keycloak.services.resources.admin.AdminEventBuilder;
Expand Down Expand Up @@ -103,10 +105,21 @@ public Optional<BaseClientRepresentation> getClient(RealmModel realm, String cli
public Stream<BaseClientRepresentation> getClients(RealmModel realm, ClientProjectionOptions projectionOptions,
ClientSearchOptions searchOptions, ClientSortAndSliceOptions sortAndSliceOptions) {
// TODO: is the access map on the representation needed
return clientsResource.getClientModels(null, true, false, null, null, null)
.filter(model -> model.getProtocol() != null) // Skip clients with null protocol
Stream<BaseClientRepresentation> stream = clientsResource.getClientModels(null, true, false, null, null, null)
.filter(model -> model.getProtocol() != null)
.map(model -> getMapper(model.getProtocol()).fromModel(model))
.filter(java.util.Objects::nonNull);

return applySearchFilter(stream, searchOptions);
}

protected Stream<BaseClientRepresentation> applySearchFilter(Stream<BaseClientRepresentation> stream, ClientSearchOptions searchOptions) {
if (searchOptions != null && searchOptions.getQuery() != null && !searchOptions.getQuery().isBlank()) {
var queryCtx = QueryParseUtils.parse(searchOptions.getQuery());
QueryParseUtils.validate(queryCtx);
return stream.filter(client -> ClientQueryEvaluator.matches(queryCtx, client));
}
return stream;
}

@Override
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -103,11 +103,13 @@ public Stream<BaseClientRepresentation> getClients(@Nonnull RealmModel realm,
// When disabled, we fall back to in-memory filtering by VIEW_CLIENTS role.
boolean canView = AdminPermissionsSchema.SCHEMA.isAdminPermissionsEnabled(realm) || permissions.clients().canView();
try {
return realm.getClientsStream()
Stream<BaseClientRepresentation> stream = realm.getClientsStream()
.filter(client -> canView || permissions.clients().canView(client))
.filter(client -> client.getProtocol() != null)
.map(client -> getMapper(client.getProtocol()).fromModel(client))
.filter(java.util.Objects::nonNull);

return applySearchFilter(stream, searchOptions);
} catch (ModelException e) {
throw new ServiceException(e.getMessage(), Response.Status.BAD_REQUEST);
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
package org.keycloak.services.client.query;

import java.util.Collection;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.stream.Collectors;

import org.keycloak.representations.admin.v2.BaseClientRepresentation;

public class ClientQueryEvaluator extends ClientQueryParserBaseVisitor<Boolean> {

private final BaseClientRepresentation client;

private ClientQueryEvaluator(BaseClientRepresentation client) {
this.client = client;
}

public static boolean matches(ClientQueryParser.QueryContext query, BaseClientRepresentation client) {
return new ClientQueryEvaluator(client).visit(query);
}

@Override
public Boolean visitQuery(ClientQueryParser.QueryContext ctx) {
return ctx.expression().stream().allMatch(this::visitExpression);
}

@Override
public Boolean visitExpression(ClientQueryParser.ExpressionContext ctx) {
String fieldPath = QueryParseUtils.extractFieldPath(ctx);

Object fieldValue = FieldResolver.resolve(fieldPath, client);
if (fieldValue == null) {
return false;
}

return matchValue(fieldValue, ctx.value());
}

private boolean matchValue(Object fieldValue, ClientQueryParser.ValueContext valueCtx) {
if (valueCtx instanceof ClientQueryParser.BareValueContext bare) {
return matchScalar(fieldValue, bare.BAREWORD().getText());
} else if (valueCtx instanceof ClientQueryParser.QuotedValueContext quoted) {
String text = quoted.QUOTED_STRING().getText();
return matchScalar(fieldValue, text.substring(1, text.length() - 1));
} else if (valueCtx instanceof ClientQueryParser.ListValueContext listCtx) {
return matchList(fieldValue, listCtx.list());
}
return false;
}

private boolean matchScalar(Object fieldValue, String queryValue) {
if (fieldValue instanceof Collection<?>) {
return ((Collection<?>) fieldValue).stream()
.anyMatch(item -> Objects.equals(item.toString(), queryValue));
}
return Objects.equals(fieldValue.toString(), queryValue);
}

private boolean matchList(Object fieldValue, ClientQueryParser.ListContext listCtx) {
List<String> entries = listCtx.listEntry().stream()
.map(e -> e.LIST_ENTRY().getText())
.toList();

boolean mapMode = entries.get(0).contains(":");

if (fieldValue instanceof Map<?, ?> map) {
if (mapMode) {
return entries.stream().allMatch(entry -> {
int colonIdx = entry.indexOf(':');
String key = entry.substring(0, colonIdx);
String value = entry.substring(colonIdx + 1);
return Objects.equals(Objects.toString(map.get(key), null), value);
});
} else {
return entries.stream().allMatch(entry -> map.containsKey(entry));
}
}

if (fieldValue instanceof Collection<?> collection) {
var stringValues = collection.stream()
.map(Object::toString)
.collect(Collectors.toSet());
return entries.stream().allMatch(stringValues::contains);
}

return false;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package org.keycloak.services.client.query;

public class ClientQueryException extends RuntimeException {

public ClientQueryException(String message) {
super(message);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
package org.keycloak.services.client.query;

import java.util.Map;
import java.util.function.Function;

import org.keycloak.representations.admin.v2.BaseClientRepresentation;
import org.keycloak.representations.admin.v2.OIDCClientRepresentation;

public class FieldResolver {

private static final Map<String, Function<BaseClientRepresentation, Object>> FIELDS = Map.ofEntries(
Map.entry("clientId", BaseClientRepresentation::getClientId),
Map.entry("displayName", BaseClientRepresentation::getDisplayName),
Map.entry("description", BaseClientRepresentation::getDescription),
Map.entry("enabled", BaseClientRepresentation::getEnabled),
Map.entry("protocol", BaseClientRepresentation::getProtocol),
Map.entry("appUrl", BaseClientRepresentation::getAppUrl),
Map.entry("redirectUris", BaseClientRepresentation::getRedirectUris),
Map.entry("roles", BaseClientRepresentation::getRoles),
Map.entry("loginFlows", client -> client instanceof OIDCClientRepresentation oidc ? oidc.getLoginFlows() : null),
Map.entry("auth.method", client -> {
if (client instanceof OIDCClientRepresentation oidc && oidc.getAuth() != null) {
return oidc.getAuth().getMethod();
}
return null;
}),
Map.entry("webOrigins", client -> client instanceof OIDCClientRepresentation oidc ? oidc.getWebOrigins() : null),
Map.entry("serviceAccountRoles", client -> client instanceof OIDCClientRepresentation oidc ? oidc.getServiceAccountRoles() : null)
);

public static boolean isKnownField(String fieldPath) {
return FIELDS.containsKey(fieldPath);
}

public static Object resolve(String fieldPath, BaseClientRepresentation client) {
Function<BaseClientRepresentation, Object> accessor = FIELDS.get(fieldPath);
if (accessor == null) {
throw new ClientQueryException("Unknown query field: " + fieldPath);
}
return accessor.apply(client);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
package org.keycloak.services.client.query;

import java.util.ArrayList;
import java.util.List;

import org.antlr.v4.runtime.BaseErrorListener;
import org.antlr.v4.runtime.RecognitionException;
import org.antlr.v4.runtime.Recognizer;

public class QueryErrorListener extends BaseErrorListener {

private boolean hasErrors = false;
private final List<String> errorMessages = new ArrayList<>();

@Override
public void syntaxError(Recognizer<?, ?> recognizer, Object offendingSymbol,
int line, int charPositionInLine,
String msg, RecognitionException e) {
hasErrors = true;
errorMessages.add(String.format("position %d: %s", charPositionInLine, msg));
}

public boolean hasErrors() {
return hasErrors;
}

public List<String> getErrorMessages() {
return errorMessages;
}
}
Loading