mirror of
https://github.com/keycloak/keycloak.git
synced 2026-05-26 13:50:48 +00:00
Add support for filtering on SCIM endpoints
Closes #46221 Signed-off-by: Stefan Guilhen <sguilhen@redhat.com>
This commit is contained in:
@@ -13,7 +13,7 @@ import static java.util.Objects.requireNonNull;
|
||||
|
||||
public abstract class AbstractScimResourceClient<R extends ResourceTypeRepresentation> implements AutoCloseable {
|
||||
|
||||
private final ScimClient client;
|
||||
protected final ScimClient client;
|
||||
private final Class<R> resourceTypeClass;
|
||||
|
||||
public AbstractScimResourceClient(ScimClient client, Class<R> resourceType) {
|
||||
@@ -55,6 +55,7 @@ public abstract class AbstractScimResourceClient<R extends ResourceTypeRepresent
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
protected ListResponse<R> doFilter(ResourceFilter filter) {
|
||||
SimpleHttpRequest request = doGet("");
|
||||
String query = filter.build();
|
||||
|
||||
@@ -1,19 +1,112 @@
|
||||
package org.keycloak.scim.client;
|
||||
|
||||
/**
|
||||
* Fluent builder for SCIM filter expressions. Supports all SCIM filter operators and logical combinations.
|
||||
*
|
||||
* @author <a href="mailto:sguilhen@redhat.com">Stefan Guilhen</a>
|
||||
*/
|
||||
public class ResourceFilter {
|
||||
|
||||
public static ResourceFilter filter() {
|
||||
return new ResourceFilter();
|
||||
}
|
||||
|
||||
private StringBuilder filter = new StringBuilder();
|
||||
private final StringBuilder filter = new StringBuilder();
|
||||
|
||||
// Comparison operators
|
||||
|
||||
public ResourceFilter eq(String property, String value) {
|
||||
filter.append(property).append(" ").append("eq").append(" ").append("\"").append(value).append("\"");
|
||||
append(property + " eq " + quote(value));
|
||||
return this;
|
||||
}
|
||||
|
||||
public ResourceFilter ne(String property, String value) {
|
||||
append(property + " ne " + quote(value));
|
||||
return this;
|
||||
}
|
||||
|
||||
public ResourceFilter co(String property, String value) {
|
||||
append(property + " co " + quote(value));
|
||||
return this;
|
||||
}
|
||||
|
||||
public ResourceFilter sw(String property, String value) {
|
||||
append(property + " sw " + quote(value));
|
||||
return this;
|
||||
}
|
||||
|
||||
public ResourceFilter ew(String property, String value) {
|
||||
append(property + " ew " + quote(value));
|
||||
return this;
|
||||
}
|
||||
|
||||
public ResourceFilter gt(String property, Object value) {
|
||||
append(property + " gt " + value);
|
||||
return this;
|
||||
}
|
||||
|
||||
public ResourceFilter ge(String property, Object value) {
|
||||
append(property + " ge " + value);
|
||||
return this;
|
||||
}
|
||||
|
||||
public ResourceFilter lt(String property, Object value) {
|
||||
append(property + " lt " + value);
|
||||
return this;
|
||||
}
|
||||
|
||||
public ResourceFilter le(String property, Object value) {
|
||||
append(property + " le " + value);
|
||||
return this;
|
||||
}
|
||||
|
||||
public ResourceFilter pr(String property) {
|
||||
append(property + " pr");
|
||||
return this;
|
||||
}
|
||||
|
||||
// Logical operators
|
||||
|
||||
public ResourceFilter and() {
|
||||
filter.append(" and ");
|
||||
return this;
|
||||
}
|
||||
|
||||
public ResourceFilter or() {
|
||||
filter.append(" or ");
|
||||
return this;
|
||||
}
|
||||
|
||||
public ResourceFilter not() {
|
||||
append("not ");
|
||||
return this;
|
||||
}
|
||||
|
||||
// Grouping
|
||||
|
||||
public ResourceFilter lparen() {
|
||||
filter.append("(");
|
||||
return this;
|
||||
}
|
||||
|
||||
public ResourceFilter rparen() {
|
||||
filter.append(")");
|
||||
return this;
|
||||
}
|
||||
|
||||
public String build() {
|
||||
return filter.toString();
|
||||
}
|
||||
|
||||
private void append(String s) {
|
||||
if (!filter.isEmpty() && !filter.toString().endsWith("(") && !filter.toString().endsWith("not ")) {
|
||||
filter.append(" ");
|
||||
}
|
||||
filter.append(s);
|
||||
}
|
||||
|
||||
private String quote(String value) {
|
||||
// Escape backslashes and quotes
|
||||
return "\"" + value.replace("\\", "\\\\").replace("\"", "\\\"") + "\"";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -114,6 +114,11 @@ public final class ScimClient implements AutoCloseable {
|
||||
.header(HttpHeaders.CONTENT_TYPE, APPLICATION_SCIM_JSON);
|
||||
}
|
||||
|
||||
SimpleHttpRequest doPost(Class<? extends ResourceTypeRepresentation> resourceType, String path) {
|
||||
return beforeRequest(http.doPost(baseUrl + getResourceTypePath(resourceType) + path))
|
||||
.header(HttpHeaders.CONTENT_TYPE, APPLICATION_SCIM_JSON);
|
||||
}
|
||||
|
||||
private String getResourceTypePath(Class<? extends ResourceTypeRepresentation> resourceType) {
|
||||
String path = "/" + resourceType.getSimpleName();
|
||||
|
||||
|
||||
@@ -1,15 +1,33 @@
|
||||
package org.keycloak.scim.client;
|
||||
|
||||
import org.keycloak.scim.protocol.response.ListResponse;
|
||||
import org.keycloak.scim.resource.group.Group;
|
||||
|
||||
import static java.util.Objects.requireNonNull;
|
||||
|
||||
public class ScimGroupsClient extends AbstractScimResourceClient<Group> {
|
||||
|
||||
public ScimGroupsClient(ScimClient client) {
|
||||
super(client, Group.class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all groups matching the specified filter.
|
||||
*
|
||||
* @param filterExpression SCIM filter expression (e.g., "displayName eq \"mygroup\"")
|
||||
* @return list response containing matching users
|
||||
*/
|
||||
public ListResponse<Group> getAll(String filterExpression) {
|
||||
requireNonNull(filterExpression, "filterExpression must not be null");
|
||||
return doFilter(new ResourceFilter() {
|
||||
@Override
|
||||
public String build() {
|
||||
return filterExpression;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() {
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package org.keycloak.scim.client;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import org.keycloak.scim.protocol.request.SearchRequest;
|
||||
import org.keycloak.scim.protocol.response.ListResponse;
|
||||
import org.keycloak.scim.resource.user.User;
|
||||
|
||||
@@ -15,6 +16,64 @@ public class ScimUsersClient extends AbstractScimResourceClient<User> {
|
||||
super(client, User.class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all users without filtering.
|
||||
*
|
||||
* @return list response containing all users
|
||||
*/
|
||||
public ListResponse<User> getAll() {
|
||||
return doFilter(filter().pr("userName"));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all users matching the specified filter.
|
||||
*
|
||||
* @param filterExpression SCIM filter expression (e.g., "userName eq \"john\"")
|
||||
* @return list response containing matching users
|
||||
*/
|
||||
public ListResponse<User> getAll(String filterExpression) {
|
||||
requireNonNull(filterExpression, "filterExpression must not be null");
|
||||
return doFilter(new ResourceFilter() {
|
||||
@Override
|
||||
public String build() {
|
||||
return filterExpression;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Search for users using the POST /.search endpoint.
|
||||
* This is useful for complex filters that may exceed URL length limits.
|
||||
*
|
||||
* @param filterExpression SCIM filter expression (e.g., "userName eq \"john\"")
|
||||
* @return list response containing matching users
|
||||
*/
|
||||
public ListResponse<User> search(String filterExpression) {
|
||||
return this.search(filterExpression, null, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Search for users using the POST /.search endpoint.
|
||||
* This is useful for complex filters that may exceed URL length limits.
|
||||
*
|
||||
* @param filterExpression SCIM filter expression (e.g., "userName eq \"john\"")
|
||||
* @param startIndex optional index of the first result to return (for pagination)
|
||||
* if null, the server will use its default value (usually 1)
|
||||
* @param count optional maximum number of results to return (for pagination)
|
||||
* if null, the server will use its default value
|
||||
* @return list response containing matching users
|
||||
*/
|
||||
@SuppressWarnings("unchecked")
|
||||
public ListResponse<User> search(String filterExpression, Integer startIndex, Integer count) {
|
||||
requireNonNull(filterExpression, "filterExpression must not be null");
|
||||
SearchRequest searchRequest = SearchRequest.builder()
|
||||
.withFilter(filterExpression)
|
||||
.withStartIndex(startIndex)
|
||||
.withCount(count).build();
|
||||
return client.execute(client.doPost(User.class, "/.search").json(searchRequest), ListResponse.class);
|
||||
}
|
||||
|
||||
|
||||
public User getByUsername(String userName) {
|
||||
requireNonNull(userName, "userName must not be null");
|
||||
|
||||
|
||||
@@ -28,6 +28,10 @@
|
||||
<groupId>org.keycloak</groupId>
|
||||
<artifactId>keycloak-server-spi-private</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.antlr</groupId>
|
||||
<artifactId>antlr4-runtime</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- Test dependencies -->
|
||||
<dependency>
|
||||
@@ -36,4 +40,23 @@
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
<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>
|
||||
</plugins>
|
||||
</build>
|
||||
</project>
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
lexer grammar ScimFilterLexer;
|
||||
|
||||
// Logical operators (case-insensitive)
|
||||
AND : [aA][nN][dD];
|
||||
OR : [oO][rR];
|
||||
NOT : [nN][oO][tT];
|
||||
|
||||
// Comparison operators (case-insensitive)
|
||||
EQ : [eE][qQ];
|
||||
NE : [nN][eE];
|
||||
CO : [cC][oO];
|
||||
SW : [sS][wW];
|
||||
EW : [eE][wW];
|
||||
GT : [gG][tT];
|
||||
GE : [gG][eE];
|
||||
LT : [lL][tT];
|
||||
LE : [lL][eE];
|
||||
PR : [pP][rR];
|
||||
|
||||
// Grouping
|
||||
LPAREN : '(';
|
||||
RPAREN : ')';
|
||||
|
||||
// Literals
|
||||
TRUE : [tT][rR][uU][eE];
|
||||
FALSE : [fF][aA][lL][sS][eE];
|
||||
NULL : [nN][uU][lL][lL];
|
||||
|
||||
// String literal (JSON string with escaping)
|
||||
STRING : '"' ( ESC | SAFECODEPOINT )* '"';
|
||||
fragment ESC : '\\' (["\\/bfnrt] | UNICODE);
|
||||
fragment UNICODE : 'u' HEX HEX HEX HEX;
|
||||
fragment HEX : [0-9a-fA-F];
|
||||
fragment SAFECODEPOINT : ~["\\\u0000-\u001F];
|
||||
|
||||
// Number (JSON number format)
|
||||
NUMBER : '-'? INT ('.' DIGIT+)? EXP?;
|
||||
fragment INT : '0' | [1-9] DIGIT*;
|
||||
fragment DIGIT : [0-9];
|
||||
fragment EXP : [Ee] [+\-]? DIGIT+;
|
||||
|
||||
// Attribute path: [schema:]attributeName[.subAttribute]
|
||||
// Supports full URN schema syntax (e.g., urn:ietf:params:scim:schemas:core:2.0:User:userName)
|
||||
ATTRPATH : (SCHEMA_URI COLON)? ATTRNAME (DOT ATTRNAME)* (LBRACKET NUMBER RBRACKET DOT ATTRNAME)?;
|
||||
|
||||
// Schema URI: matches URN format like "urn:ietf:params:scim:schemas:core:2.0:User"
|
||||
// or URL format like "http://example.com/schemas/User"
|
||||
fragment SCHEMA_URI
|
||||
: URN_SCHEMA
|
||||
| URL_SCHEMA
|
||||
;
|
||||
|
||||
// URN format: urn:ietf:params:scim:schemas:core:2.0:User
|
||||
fragment URN_SCHEMA : 'urn' COLON [a-zA-Z0-9]+ (COLON [a-zA-Z0-9._-]+)+ ;
|
||||
|
||||
// URL format: http://example.com/schemas/User or https://example.com/schemas/User
|
||||
fragment URL_SCHEMA : ('http' | 'https') COLON SLASHSLASH [a-zA-Z0-9:/.@#_-]+ ;
|
||||
|
||||
fragment ATTRNAME : ALPHA (ALPHA | DIGIT | '-' | '_')*;
|
||||
fragment ALPHA : [a-zA-Z];
|
||||
fragment DOT : '.';
|
||||
fragment COLON : ':';
|
||||
fragment SLASHSLASH : '//';
|
||||
fragment LBRACKET : '[';
|
||||
fragment RBRACKET : ']';
|
||||
|
||||
// Whitespace
|
||||
WS : [ \t\r\n]+ -> skip;
|
||||
@@ -0,0 +1,41 @@
|
||||
parser grammar ScimFilterParser;
|
||||
|
||||
options { tokenVocab = ScimFilterLexer; }
|
||||
|
||||
// Entry point
|
||||
filter : expression EOF;
|
||||
|
||||
// Logical expressions (precedence: OR < AND < NOT)
|
||||
expression
|
||||
: expression OR andExpression
|
||||
| andExpression
|
||||
;
|
||||
|
||||
andExpression
|
||||
: andExpression AND notExpression
|
||||
| notExpression
|
||||
;
|
||||
|
||||
notExpression
|
||||
: NOT notExpression
|
||||
| atom
|
||||
;
|
||||
|
||||
atom
|
||||
: LPAREN expression RPAREN
|
||||
| attributeExpression
|
||||
;
|
||||
|
||||
// Attribute comparison expressions
|
||||
attributeExpression
|
||||
: ATTRPATH PR # PresentExpression
|
||||
| ATTRPATH compareOp compValue # ComparisonExpression
|
||||
;
|
||||
|
||||
compareOp
|
||||
: EQ | NE | CO | SW | EW | GT | GE | LT | LE
|
||||
;
|
||||
|
||||
compValue
|
||||
: FALSE | NULL | TRUE | NUMBER | STRING
|
||||
;
|
||||
@@ -0,0 +1,35 @@
|
||||
package org.keycloak.scim.filter;
|
||||
|
||||
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;
|
||||
|
||||
/**
|
||||
* ANTLR error listener that collects syntax errors during filter parsing.
|
||||
*
|
||||
* @author <a href="mailto:sguilhen@redhat.com">Stefan Guilhen</a>
|
||||
*/
|
||||
public class ErrorListener 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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package org.keycloak.scim.filter;
|
||||
|
||||
import org.antlr.v4.runtime.CharStream;
|
||||
import org.antlr.v4.runtime.CharStreams;
|
||||
import org.antlr.v4.runtime.CommonTokenStream;
|
||||
|
||||
|
||||
/**
|
||||
* Utility class for parsing SCIM filter expressions using ANTLR.
|
||||
*
|
||||
* @author <a href="mailto:sguilhen@redhat.com">Stefan Guilhen</a>
|
||||
*/
|
||||
public class FilterUtils {
|
||||
|
||||
/**
|
||||
* Parses a SCIM filter expression string into an abstract syntax tree.
|
||||
*
|
||||
* @param filterExpression the filter expression to parse (RFC 7644 section 3.4.2.2)
|
||||
* @return the parsed filter context (AST root)
|
||||
* @throws ScimFilterException if the filter expression has syntax errors
|
||||
*/
|
||||
public static ScimFilterParser.FilterContext parseFilter(String filterExpression) {
|
||||
if (filterExpression == null || filterExpression.trim().isEmpty()) {
|
||||
throw new ScimFilterException("Filter expression cannot be null or empty");
|
||||
}
|
||||
|
||||
CharStream charStream = CharStreams.fromString(filterExpression);
|
||||
ScimFilterLexer lexer = new ScimFilterLexer(charStream);
|
||||
CommonTokenStream tokens = new CommonTokenStream(lexer);
|
||||
ScimFilterParser parser = new ScimFilterParser(tokens);
|
||||
|
||||
// Custom error listener
|
||||
ErrorListener errorListener = new ErrorListener();
|
||||
parser.removeErrorListeners();
|
||||
parser.addErrorListener(errorListener);
|
||||
|
||||
ScimFilterParser.FilterContext context = parser.filter();
|
||||
|
||||
if (errorListener.hasErrors()) {
|
||||
String errors = String.join(", ", errorListener.getErrorMessages());
|
||||
throw new ScimFilterException("Invalid filter syntax: " + errors);
|
||||
}
|
||||
|
||||
return context;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
/*
|
||||
* Copyright 2025 Red Hat, Inc. and/or its affiliates
|
||||
* and other contributors as indicated by the @author tags.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package org.keycloak.scim.filter;
|
||||
|
||||
import org.keycloak.models.ModelValidationException;
|
||||
|
||||
/**
|
||||
* Exception thrown when an SCIM filter expression is invalid or cannot be processed.
|
||||
*
|
||||
* @author <a href="mailto:sguilhen@redhat.com">Stefan Guilhen</a>
|
||||
*/
|
||||
public class ScimFilterException extends ModelValidationException {
|
||||
|
||||
public ScimFilterException(String message) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
package org.keycloak.scim.protocol.request;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
|
||||
/**
|
||||
* SCIM Search Request for POST /.search endpoint (RFC 7644 section 3.4.3).
|
||||
* This allows clients to submit complex search requests that may exceed URL length limits.
|
||||
*
|
||||
* @author <a href="mailto:sguilhen@redhat.com">Stefan Guilhen</a>
|
||||
*/
|
||||
public class SearchRequest {
|
||||
|
||||
@JsonProperty("schemas")
|
||||
private Set<String> schemas = Set.of("urn:ietf:params:scim:api:messages:2.0:SearchRequest");
|
||||
|
||||
@JsonProperty("attributes")
|
||||
private List<String> attributes;
|
||||
|
||||
@JsonProperty("excludedAttributes")
|
||||
private List<String> excludedAttributes;
|
||||
|
||||
@JsonProperty("filter")
|
||||
private String filter;
|
||||
|
||||
@JsonProperty("sortBy")
|
||||
private String sortBy;
|
||||
|
||||
@JsonProperty("sortOrder")
|
||||
private String sortOrder;
|
||||
|
||||
@JsonProperty("startIndex")
|
||||
private Integer startIndex;
|
||||
|
||||
@JsonProperty("count")
|
||||
private Integer count;
|
||||
|
||||
// Getters and setters
|
||||
|
||||
public Set<String> getSchemas() {
|
||||
return schemas;
|
||||
}
|
||||
|
||||
public void setSchemas(Set<String> schemas) {
|
||||
this.schemas = schemas;
|
||||
}
|
||||
|
||||
public List<String> getAttributes() {
|
||||
return attributes;
|
||||
}
|
||||
|
||||
public void setAttributes(List<String> attributes) {
|
||||
this.attributes = attributes;
|
||||
}
|
||||
|
||||
public List<String> getExcludedAttributes() {
|
||||
return excludedAttributes;
|
||||
}
|
||||
|
||||
public void setExcludedAttributes(List<String> excludedAttributes) {
|
||||
this.excludedAttributes = excludedAttributes;
|
||||
}
|
||||
|
||||
public String getFilter() {
|
||||
return filter;
|
||||
}
|
||||
|
||||
public void setFilter(String filter) {
|
||||
this.filter = filter;
|
||||
}
|
||||
|
||||
public String getSortBy() {
|
||||
return sortBy;
|
||||
}
|
||||
|
||||
public void setSortBy(String sortBy) {
|
||||
this.sortBy = sortBy;
|
||||
}
|
||||
|
||||
public String getSortOrder() {
|
||||
return sortOrder;
|
||||
}
|
||||
|
||||
public void setSortOrder(String sortOrder) {
|
||||
this.sortOrder = sortOrder;
|
||||
}
|
||||
|
||||
public Integer getStartIndex() {
|
||||
return startIndex;
|
||||
}
|
||||
|
||||
public void setStartIndex(Integer startIndex) {
|
||||
this.startIndex = startIndex;
|
||||
}
|
||||
|
||||
public Integer getCount() {
|
||||
return count;
|
||||
}
|
||||
|
||||
public void setCount(Integer count) {
|
||||
this.count = count;
|
||||
}
|
||||
|
||||
public static Builder builder() {
|
||||
return new Builder();
|
||||
}
|
||||
|
||||
public static class Builder {
|
||||
|
||||
private final SearchRequest searchRequest;
|
||||
|
||||
private Builder() {
|
||||
this.searchRequest = new SearchRequest();
|
||||
}
|
||||
|
||||
public Builder withAttributes(List<String> attributes) {
|
||||
searchRequest.setAttributes(attributes);
|
||||
return this;
|
||||
}
|
||||
|
||||
public Builder withExcludedAttributes(List<String> excludedAttributes) {
|
||||
searchRequest.setExcludedAttributes(excludedAttributes);
|
||||
return this;
|
||||
}
|
||||
|
||||
public Builder withFilter(String filter) {
|
||||
searchRequest.setFilter(filter);
|
||||
return this;
|
||||
}
|
||||
|
||||
public Builder withSortBy(String sortBy) {
|
||||
searchRequest.setSortBy(sortBy);
|
||||
return this;
|
||||
}
|
||||
|
||||
public Builder withSortOrder(String sortOrder) {
|
||||
searchRequest.setSortOrder(sortOrder);
|
||||
return this;
|
||||
}
|
||||
|
||||
public Builder withStartIndex(Integer startIndex) {
|
||||
searchRequest.setStartIndex(startIndex);
|
||||
return this;
|
||||
}
|
||||
|
||||
public Builder withCount(Integer count) {
|
||||
searchRequest.setCount(count);
|
||||
return this;
|
||||
}
|
||||
|
||||
public SearchRequest build() {
|
||||
return searchRequest;
|
||||
}
|
||||
}
|
||||
}
|
||||
+17
-3
@@ -10,6 +10,7 @@ import org.keycloak.models.KeycloakContext;
|
||||
import org.keycloak.models.KeycloakSession;
|
||||
import org.keycloak.models.Model;
|
||||
import org.keycloak.scim.protocol.ForbiddenException;
|
||||
import org.keycloak.scim.protocol.request.SearchRequest;
|
||||
import org.keycloak.scim.resource.ResourceTypeRepresentation;
|
||||
import org.keycloak.scim.resource.schema.ModelSchema;
|
||||
|
||||
@@ -83,8 +84,8 @@ public abstract class AbstractScimResourceTypeProvider<M extends Model, R extend
|
||||
}
|
||||
|
||||
@Override
|
||||
public Stream<R> getAll() {
|
||||
return getModels().map(m -> {
|
||||
public Stream<R> getAll(SearchRequest searchRequest) {
|
||||
return getModels(searchRequest).map(m -> {
|
||||
if (AdminPermissionsSchema.SCHEMA.isAdminPermissionsEnabled(session.getContext().getRealm())) {
|
||||
return get(m.getId());
|
||||
}
|
||||
@@ -124,7 +125,7 @@ public abstract class AbstractScimResourceTypeProvider<M extends Model, R extend
|
||||
|
||||
protected abstract boolean onDelete(String id);
|
||||
|
||||
protected abstract Stream<M> getModels();
|
||||
protected abstract Stream<M> getModels(SearchRequest searchRequest);
|
||||
|
||||
protected abstract M getModel(String id);
|
||||
|
||||
@@ -138,6 +139,19 @@ public abstract class AbstractScimResourceTypeProvider<M extends Model, R extend
|
||||
}
|
||||
}
|
||||
|
||||
protected String[] splitScimAttribute(String scimAttrPath) {
|
||||
// first split the attribute path into schema and attribute name. If no schema is specified, use the core user schema by default
|
||||
String schemaName;
|
||||
int lastColon = scimAttrPath.lastIndexOf(':');
|
||||
if (lastColon > 0 && (scimAttrPath.contains("://") || scimAttrPath.startsWith("urn:"))) {
|
||||
schemaName = scimAttrPath.substring(0, lastColon);
|
||||
scimAttrPath = scimAttrPath.substring(lastColon + 1);
|
||||
} else {
|
||||
schemaName = this.schema.getName();
|
||||
}
|
||||
return new String[] {schemaName, scimAttrPath};
|
||||
}
|
||||
|
||||
private R createResourceTypeInstance() {
|
||||
try {
|
||||
return (R) getResourceType().getDeclaredConstructor().newInstance();
|
||||
|
||||
+2
-1
@@ -4,6 +4,7 @@ import java.util.List;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
import org.keycloak.provider.Provider;
|
||||
import org.keycloak.scim.protocol.request.SearchRequest;
|
||||
import org.keycloak.scim.resource.ResourceTypeRepresentation;
|
||||
|
||||
/**
|
||||
@@ -89,7 +90,7 @@ public interface ScimResourceTypeProvider<R extends ResourceTypeRepresentation>
|
||||
*
|
||||
* @return a stream of all resources of this type
|
||||
*/
|
||||
Stream<R> getAll();
|
||||
Stream<R> getAll(SearchRequest searchRequest);
|
||||
|
||||
/**
|
||||
* Deletes a resource of this type by its identifier. This method is invoked when a client requests the deletion of a specific resource,
|
||||
|
||||
+2
-1
@@ -3,6 +3,7 @@ package org.keycloak.scim.resource.spi;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
import org.keycloak.models.ModelValidationException;
|
||||
import org.keycloak.scim.protocol.request.SearchRequest;
|
||||
import org.keycloak.scim.resource.ResourceTypeRepresentation;
|
||||
|
||||
/**
|
||||
@@ -36,7 +37,7 @@ public interface SingletonResourceTypeProvider<R extends ResourceTypeRepresentat
|
||||
}
|
||||
|
||||
@Override
|
||||
default Stream<R> getAll() {
|
||||
default Stream<R> getAll(SearchRequest searchRequest) {
|
||||
return Stream.of(getSingleton());
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
package org.keycloak.scim.filter;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
/**
|
||||
* Unit tests for SCIM filter parsing.
|
||||
*
|
||||
* @author <a href="mailto:sguilhen@redhat.com">Stefan Guilhen</a>
|
||||
*/
|
||||
public class FilterUtilsTest {
|
||||
|
||||
@Test
|
||||
public void testSimpleEqualityFilter() {
|
||||
ScimFilterParser.FilterContext ctx = FilterUtils.parseFilter("userName eq \"john\"");
|
||||
assertNotNull(ctx);
|
||||
assertNotNull(ctx.expression());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testCaseInsensitiveOperators() {
|
||||
// Test all case variations
|
||||
assertDoesNotThrow(() -> FilterUtils.parseFilter("userName EQ \"john\""));
|
||||
assertDoesNotThrow(() -> FilterUtils.parseFilter("userName eq \"john\""));
|
||||
assertDoesNotThrow(() -> FilterUtils.parseFilter("userName Eq \"john\""));
|
||||
|
||||
assertDoesNotThrow(() -> FilterUtils.parseFilter("userName NE \"john\""));
|
||||
assertDoesNotThrow(() -> FilterUtils.parseFilter("userName CO \"john\""));
|
||||
assertDoesNotThrow(() -> FilterUtils.parseFilter("userName SW \"john\""));
|
||||
assertDoesNotThrow(() -> FilterUtils.parseFilter("userName EW \"john\""));
|
||||
assertDoesNotThrow(() -> FilterUtils.parseFilter("age GT 30"));
|
||||
assertDoesNotThrow(() -> FilterUtils.parseFilter("age GE 30"));
|
||||
assertDoesNotThrow(() -> FilterUtils.parseFilter("age LT 30"));
|
||||
assertDoesNotThrow(() -> FilterUtils.parseFilter("age LE 30"));
|
||||
assertDoesNotThrow(() -> FilterUtils.parseFilter("userName PR"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testLogicalOperators() {
|
||||
// AND
|
||||
assertDoesNotThrow(() -> FilterUtils.parseFilter("userName eq \"john\" and active eq true"));
|
||||
assertDoesNotThrow(() -> FilterUtils.parseFilter("userName eq \"john\" AND active eq true"));
|
||||
|
||||
// OR
|
||||
assertDoesNotThrow(() -> FilterUtils.parseFilter("userName eq \"john\" or userName eq \"jane\""));
|
||||
assertDoesNotThrow(() -> FilterUtils.parseFilter("userName eq \"john\" OR userName eq \"jane\""));
|
||||
|
||||
// NOT
|
||||
assertDoesNotThrow(() -> FilterUtils.parseFilter("not (userName eq \"john\")"));
|
||||
assertDoesNotThrow(() -> FilterUtils.parseFilter("NOT (userName eq \"john\")"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testComplexFilter() {
|
||||
String filter = "(userName eq \"john\" or userName eq \"jane\") and active eq true";
|
||||
ScimFilterParser.FilterContext ctx = FilterUtils.parseFilter(filter);
|
||||
assertNotNull(ctx);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testPresentOperator() {
|
||||
ScimFilterParser.FilterContext ctx = FilterUtils.parseFilter("userName pr");
|
||||
assertNotNull(ctx);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testStringComparison() {
|
||||
assertDoesNotThrow(() -> FilterUtils.parseFilter("userName co \"oh\""));
|
||||
assertDoesNotThrow(() -> FilterUtils.parseFilter("userName sw \"j\""));
|
||||
assertDoesNotThrow(() -> FilterUtils.parseFilter("userName ew \"n\""));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testNumericComparison() {
|
||||
assertDoesNotThrow(() -> FilterUtils.parseFilter("age gt 30"));
|
||||
assertDoesNotThrow(() -> FilterUtils.parseFilter("age ge 30"));
|
||||
assertDoesNotThrow(() -> FilterUtils.parseFilter("age lt 50"));
|
||||
assertDoesNotThrow(() -> FilterUtils.parseFilter("age le 50"));
|
||||
assertDoesNotThrow(() -> FilterUtils.parseFilter("age eq 42"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testBooleanLiterals() {
|
||||
assertDoesNotThrow(() -> FilterUtils.parseFilter("active eq true"));
|
||||
assertDoesNotThrow(() -> FilterUtils.parseFilter("active eq false"));
|
||||
assertDoesNotThrow(() -> FilterUtils.parseFilter("active eq TRUE"));
|
||||
assertDoesNotThrow(() -> FilterUtils.parseFilter("active eq FALSE"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testNullLiteral() {
|
||||
assertDoesNotThrow(() -> FilterUtils.parseFilter("middleName eq null"));
|
||||
assertDoesNotThrow(() -> FilterUtils.parseFilter("middleName eq NULL"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testNestedAttributes() {
|
||||
assertDoesNotThrow(() -> FilterUtils.parseFilter("name.givenName eq \"John\""));
|
||||
assertDoesNotThrow(() -> FilterUtils.parseFilter("name.familyName eq \"Doe\""));
|
||||
assertDoesNotThrow(() -> FilterUtils.parseFilter("emails[0].value eq \"john@example.com\""));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testSchemaPrefix() {
|
||||
// Full URN schema prefix support
|
||||
assertDoesNotThrow(() -> FilterUtils.parseFilter(
|
||||
"urn:ietf:params:scim:schemas:core:2.0:User:userName eq \"john\""));
|
||||
|
||||
// HTTP schema prefix
|
||||
assertDoesNotThrow(() -> FilterUtils.parseFilter(
|
||||
"http://example.com/schemas/User:userName eq \"john\""));
|
||||
|
||||
// HTTPS schema prefix
|
||||
assertDoesNotThrow(() -> FilterUtils.parseFilter(
|
||||
"https://example.com/schemas/User:userName eq \"john\""));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testOperatorPrecedence() {
|
||||
// NOT has highest precedence
|
||||
assertDoesNotThrow(() -> FilterUtils.parseFilter("not userName eq \"john\" and active eq true"));
|
||||
|
||||
// AND has higher precedence than OR
|
||||
assertDoesNotThrow(() -> FilterUtils.parseFilter(
|
||||
"userName eq \"john\" and active eq true or userName eq \"jane\""));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testParenthesesGrouping() {
|
||||
assertDoesNotThrow(() -> FilterUtils.parseFilter(
|
||||
"(userName eq \"john\" or userName eq \"jane\") and active eq true"));
|
||||
|
||||
assertDoesNotThrow(() -> FilterUtils.parseFilter(
|
||||
"userName eq \"john\" or (userName eq \"jane\" and active eq true)"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testEscapedStrings() {
|
||||
// JSON string escaping
|
||||
assertDoesNotThrow(() -> FilterUtils.parseFilter("userName eq \"john\\\"doe\""));
|
||||
assertDoesNotThrow(() -> FilterUtils.parseFilter("path eq \"c:\\\\users\\\\john\""));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testInvalidSyntax() {
|
||||
// Missing value
|
||||
ScimFilterException e1 = assertThrows(ScimFilterException.class,
|
||||
() -> FilterUtils.parseFilter("userName eq"));
|
||||
assertTrue(e1.getMessage().contains("Invalid filter syntax"));
|
||||
|
||||
// Invalid operator
|
||||
ScimFilterException e2 = assertThrows(ScimFilterException.class,
|
||||
() -> FilterUtils.parseFilter("userName invalid \"john\""));
|
||||
assertTrue(e2.getMessage().contains("Invalid filter syntax"));
|
||||
|
||||
// Mismatched parentheses
|
||||
ScimFilterException e3 = assertThrows(ScimFilterException.class,
|
||||
() -> FilterUtils.parseFilter("(userName eq \"john\""));
|
||||
assertTrue(e3.getMessage().contains("Invalid filter syntax"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testEmptyOrNullFilter() {
|
||||
assertThrows(ScimFilterException.class, () -> FilterUtils.parseFilter(null));
|
||||
assertThrows(ScimFilterException.class, () -> FilterUtils.parseFilter(""));
|
||||
assertThrows(ScimFilterException.class, () -> FilterUtils.parseFilter(" "));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testComplexRealWorldFilters() {
|
||||
// Complex filter from SCIM spec
|
||||
assertDoesNotThrow(() -> FilterUtils.parseFilter(
|
||||
"userType eq \"Employee\" and (emails co \"example.com\" or emails.value co \"example.org\")"));
|
||||
|
||||
// Multiple conditions
|
||||
assertDoesNotThrow(() -> FilterUtils.parseFilter(
|
||||
"userName sw \"J\" and active eq true and meta.created gt \"2024-01-01T00:00:00Z\""));
|
||||
|
||||
// Negation with complex expression
|
||||
assertDoesNotThrow(() -> FilterUtils.parseFilter(
|
||||
"not (userName eq \"admin\" or userName eq \"root\")"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testWhitespaceHandling() {
|
||||
// Extra whitespace should be handled correctly
|
||||
assertDoesNotThrow(() -> FilterUtils.parseFilter(" userName eq \"john\" "));
|
||||
assertDoesNotThrow(() -> FilterUtils.parseFilter("userName eq \"john\"and active eq true"));
|
||||
assertDoesNotThrow(() -> FilterUtils.parseFilter("userName eq \"john\" and active eq true"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testAttributePathWithArray() {
|
||||
assertDoesNotThrow(() -> FilterUtils.parseFilter("emails[0].value eq \"john@example.com\""));
|
||||
assertDoesNotThrow(() -> FilterUtils.parseFilter("phoneNumbers[0].value pr"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testDateTimeComparison() {
|
||||
assertDoesNotThrow(() -> FilterUtils.parseFilter(
|
||||
"meta.created gt \"2024-01-01T00:00:00Z\""));
|
||||
assertDoesNotThrow(() -> FilterUtils.parseFilter(
|
||||
"meta.lastModified le \"2024-12-31T23:59:59Z\""));
|
||||
}
|
||||
}
|
||||
@@ -20,5 +20,13 @@
|
||||
<groupId>org.keycloak</groupId>
|
||||
<artifactId>keycloak-scim-core</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.keycloak</groupId>
|
||||
<artifactId>keycloak-model-jpa</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>jakarta.persistence</groupId>
|
||||
<artifactId>jakarta.persistence-api</artifactId>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
</project>
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
package org.keycloak.scim.model.filter;
|
||||
|
||||
import java.util.Objects;
|
||||
|
||||
public class AttributeInfo {
|
||||
|
||||
private final String keycloakName;
|
||||
private final boolean primary; // true = belong to the main resource entity, false = belong to a related entity (e.g. user attributes)
|
||||
private final String attributeType;
|
||||
|
||||
public AttributeInfo(String keycloakName, boolean primary, String attributeType) {
|
||||
this.keycloakName = keycloakName;
|
||||
this.primary = primary;
|
||||
this.attributeType = attributeType;
|
||||
}
|
||||
|
||||
public String getKeycloakName() {
|
||||
return keycloakName;
|
||||
}
|
||||
|
||||
public boolean isPrimary() {
|
||||
return primary;
|
||||
}
|
||||
|
||||
public boolean isTimestamp() {
|
||||
return Objects.equals(attributeType, "timestamp");
|
||||
}
|
||||
|
||||
public boolean isBoolean() {
|
||||
return Objects.equals(attributeType, "boolean");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package org.keycloak.scim.model.filter;
|
||||
|
||||
/**
|
||||
* Resolves SCIM attribute paths to Keycloak attribute names.
|
||||
*
|
||||
* @author <a href="mailto:sguilhen@redhat.com">Stefan Guilhen</a>
|
||||
*/
|
||||
public interface AttributeNameResolver {
|
||||
|
||||
AttributeInfo resolve(String scimAttrPath);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package org.keycloak.scim.model.filter;
|
||||
|
||||
import jakarta.persistence.criteria.Predicate;
|
||||
|
||||
/**
|
||||
* A record that encapsulates the result of evaluating a filter expression, including the generated JPA Predicate and a flag
|
||||
* indicating whether the filter is unsupported (e.g., due to unrecognized attributes). This allows the visitor to gracefully
|
||||
* handle unsupported filters.
|
||||
*
|
||||
* @param predicate the JPA Predicate generated from the filter expression
|
||||
* @param unsupported a flag indicating whether the filter is unsupported (true if unsupported, false if valid)
|
||||
*
|
||||
* @author <a href="mailto:sguilhen@redhat.com">Stefan Guilhen</a>
|
||||
*/
|
||||
public record JPAFilterResult(Predicate predicate, boolean unsupported) {
|
||||
|
||||
public static JPAFilterResult valid(Predicate p) {
|
||||
return new JPAFilterResult(p, false);
|
||||
}
|
||||
|
||||
public static JPAFilterResult unsupported(Predicate p) {
|
||||
return new JPAFilterResult(p, true);
|
||||
}
|
||||
}
|
||||
+120
@@ -0,0 +1,120 @@
|
||||
package org.keycloak.scim.model.filter;
|
||||
|
||||
import jakarta.persistence.criteria.CriteriaBuilder;
|
||||
import jakarta.persistence.criteria.CriteriaQuery;
|
||||
import jakarta.persistence.criteria.Root;
|
||||
|
||||
import org.keycloak.scim.filter.ScimFilterParser;
|
||||
import org.keycloak.scim.filter.ScimFilterParserBaseVisitor;
|
||||
|
||||
/**
|
||||
* Visitor that converts an SCIM filter AST into a JPA Predicate.
|
||||
*
|
||||
* @author <a href="mailto:sguilhen@redhat.com">Stefan Guilhen</a>
|
||||
*/
|
||||
public class ScimJPAPredicateEvaluator extends ScimFilterParserBaseVisitor<JPAFilterResult> {
|
||||
|
||||
private final CriteriaBuilder cb;
|
||||
private final ScimJPAPredicateProvider predicateProvider;
|
||||
|
||||
public ScimJPAPredicateEvaluator(AttributeNameResolver resolver, CriteriaBuilder cb, CriteriaQuery<?> query, Root<?> root) {
|
||||
this.cb = cb;
|
||||
this.predicateProvider = new ScimJPAPredicateProvider(resolver, cb, query, root);
|
||||
}
|
||||
|
||||
@Override
|
||||
public JPAFilterResult visitFilter(ScimFilterParser.FilterContext ctx) {
|
||||
return visit(ctx.expression());
|
||||
}
|
||||
|
||||
@Override
|
||||
public JPAFilterResult visitExpression(ScimFilterParser.ExpressionContext ctx) {
|
||||
if (ctx.OR() != null) {
|
||||
JPAFilterResult left = visit(ctx.expression());
|
||||
JPAFilterResult right = visit(ctx.andExpression());
|
||||
|
||||
// Logical OR: If one side is unsupported (False), the result is just the other side
|
||||
if (left.unsupported()) return right;
|
||||
if (right.unsupported()) return left;
|
||||
|
||||
return JPAFilterResult.valid(cb.or(left.predicate(), right.predicate()));
|
||||
}
|
||||
return visit(ctx.andExpression());
|
||||
}
|
||||
|
||||
@Override
|
||||
public JPAFilterResult visitAndExpression(ScimFilterParser.AndExpressionContext ctx) {
|
||||
if (ctx.AND() != null) {
|
||||
JPAFilterResult left = visit(ctx.andExpression());
|
||||
JPAFilterResult right = visit(ctx.notExpression());
|
||||
|
||||
// If either side is unsupported, the whole AND is unsupported (False)
|
||||
if (left.unsupported() || right.unsupported()) {
|
||||
return JPAFilterResult.unsupported(cb.disjunction());
|
||||
}
|
||||
return JPAFilterResult.valid(cb.and(left.predicate(), right.predicate()));
|
||||
}
|
||||
return visit(ctx.notExpression());
|
||||
}
|
||||
|
||||
@Override
|
||||
public JPAFilterResult visitNotExpression(ScimFilterParser.NotExpressionContext ctx) {
|
||||
if (ctx.NOT() != null) {
|
||||
JPAFilterResult child = visit(ctx.notExpression());
|
||||
// If the child is a disjunction caused by an unsupported attribute, per RFC 7644, 'not (unknownAttr pr)' MUST still be an empty set.
|
||||
if (child.unsupported()) {
|
||||
return child;
|
||||
}
|
||||
return JPAFilterResult.valid(cb.not(child.predicate()));
|
||||
}
|
||||
return visit(ctx.atom());
|
||||
}
|
||||
|
||||
@Override
|
||||
public JPAFilterResult visitAtom(ScimFilterParser.AtomContext ctx) {
|
||||
if (ctx.attributeExpression() != null) {
|
||||
return visit(ctx.attributeExpression());
|
||||
}
|
||||
return visit(ctx.expression());
|
||||
}
|
||||
|
||||
@Override
|
||||
public JPAFilterResult visitPresentExpression(ScimFilterParser.PresentExpressionContext ctx) {
|
||||
String scimAttrPath = ctx.ATTRPATH().getText();
|
||||
return predicateProvider.createPresentPredicate(scimAttrPath);
|
||||
}
|
||||
|
||||
@Override
|
||||
public JPAFilterResult visitComparisonExpression(ScimFilterParser.ComparisonExpressionContext ctx) {
|
||||
String scimAttrPath = ctx.ATTRPATH().getText();
|
||||
String operator = ctx.compareOp().getText().toLowerCase();
|
||||
String value = extractValue(ctx.compValue());
|
||||
|
||||
return predicateProvider.createComparisonPredicate(scimAttrPath, operator, value);
|
||||
}
|
||||
|
||||
private String extractValue(ScimFilterParser.CompValueContext ctx) {
|
||||
if (ctx.STRING() != null) {
|
||||
// Remove quotes and unescape per JSON string rules
|
||||
String raw = ctx.STRING().getText();
|
||||
return unescapeJsonString(raw.substring(1, raw.length() - 1));
|
||||
}
|
||||
if (ctx.TRUE() != null) return "true";
|
||||
if (ctx.FALSE() != null) return "false";
|
||||
if (ctx.NULL() != null) return null;
|
||||
if (ctx.NUMBER() != null) return ctx.NUMBER().getText();
|
||||
return null;
|
||||
}
|
||||
|
||||
private String unescapeJsonString(String s) {
|
||||
return s.replace("\\\"", "\"")
|
||||
.replace("\\\\", "\\")
|
||||
.replace("\\/", "/")
|
||||
.replace("\\b", "\b")
|
||||
.replace("\\f", "\f")
|
||||
.replace("\\n", "\n")
|
||||
.replace("\\r", "\r")
|
||||
.replace("\\t", "\t");
|
||||
// Note: Unicode escape sequences (backslash-u followed by 4 hex digits) are handled by ANTLR lexer
|
||||
}
|
||||
}
|
||||
+174
@@ -0,0 +1,174 @@
|
||||
package org.keycloak.scim.model.filter;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.time.format.DateTimeParseException;
|
||||
|
||||
import jakarta.persistence.criteria.CriteriaBuilder;
|
||||
import jakarta.persistence.criteria.CriteriaQuery;
|
||||
import jakarta.persistence.criteria.Expression;
|
||||
import jakarta.persistence.criteria.Join;
|
||||
import jakarta.persistence.criteria.JoinType;
|
||||
import jakarta.persistence.criteria.Root;
|
||||
|
||||
import org.keycloak.scim.filter.ScimFilterException;
|
||||
|
||||
/**
|
||||
* Creates JPA predicates for SCIM filter operators. Handles both direct root entity fields and custom attributes stored
|
||||
* in an associated "attributes" collection. Also handles necessary type conversions for temporal fields.
|
||||
*
|
||||
* @author <a href="mailto:sguilhen@redhat.com">Stefan Guilhen</a>
|
||||
*/
|
||||
public class ScimJPAPredicateProvider {
|
||||
|
||||
private final CriteriaBuilder cb;
|
||||
private final CriteriaQuery<?> query;
|
||||
private final Root<?> root;
|
||||
private final AttributeNameResolver nameResolver;
|
||||
|
||||
// Cache joins to avoid creating duplicate joins for the same filter
|
||||
private Join<?, ?> attributeJoin;
|
||||
|
||||
public ScimJPAPredicateProvider(AttributeNameResolver resolver, CriteriaBuilder cb, CriteriaQuery<?> query, Root<?> root) {
|
||||
this.cb = cb;
|
||||
this.query = query;
|
||||
this.root = root;
|
||||
this.nameResolver = resolver;
|
||||
}
|
||||
|
||||
public JPAFilterResult createPresentPredicate(String scimAttrPath) {
|
||||
AttributeInfo attrInfo = nameResolver.resolve(scimAttrPath);
|
||||
if (attrInfo == null) {
|
||||
return JPAFilterResult.unsupported(cb.disjunction());
|
||||
}
|
||||
|
||||
if (attrInfo.isPrimary()) {
|
||||
// Direct field: check not null
|
||||
return JPAFilterResult.valid(cb.isNotNull(root.get(attrInfo.getKeycloakName())));
|
||||
} else {
|
||||
// Custom attribute: must exist in attributes collection with non-null value
|
||||
Join<?, ?> join = getOrCreateAttributeJoin();
|
||||
return JPAFilterResult.valid(cb.and(
|
||||
cb.equal(join.get("name"), attrInfo.getKeycloakName()),
|
||||
cb.isNotNull(join.get("value"))
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
public JPAFilterResult createComparisonPredicate(String scimAttrPath, String operator, String value) {
|
||||
AttributeInfo attrInfo = nameResolver.resolve(scimAttrPath);
|
||||
if (attrInfo == null) {
|
||||
return JPAFilterResult.unsupported(cb.disjunction());
|
||||
}
|
||||
|
||||
// Determine if this is a temporal (timestamp) field that needs date conversion
|
||||
boolean isTimestampField = attrInfo.isTimestamp();
|
||||
boolean isBooleanField = attrInfo.isBoolean();
|
||||
|
||||
switch (operator.toLowerCase()) {
|
||||
case "eq":
|
||||
if (isTimestampField) {
|
||||
Long timestamp = parseDateTime(value);
|
||||
return JPAFilterResult.valid(cb.equal(root.get(attrInfo.getKeycloakName()), timestamp));
|
||||
} else if (isBooleanField) {
|
||||
Boolean boolValue = Boolean.parseBoolean(value);
|
||||
return JPAFilterResult.valid(cb.equal(root.get(attrInfo.getKeycloakName()), boolValue));
|
||||
} else {
|
||||
Expression<String> attrExpr = getAttributeExpression(attrInfo);
|
||||
return JPAFilterResult.valid(cb.equal(attrExpr, value));
|
||||
}
|
||||
case "ne":
|
||||
if (isTimestampField) {
|
||||
Long timestamp = parseDateTime(value);
|
||||
return JPAFilterResult.valid(cb.notEqual(root.get(attrInfo.getKeycloakName()), timestamp));
|
||||
} else if (isBooleanField) {
|
||||
Boolean boolValue = Boolean.parseBoolean(value);
|
||||
return JPAFilterResult.valid(cb.notEqual(root.get(attrInfo.getKeycloakName()), boolValue));
|
||||
} else {
|
||||
Expression<String> attrExpr = getAttributeExpression(attrInfo);
|
||||
return JPAFilterResult.valid(cb.notEqual(attrExpr, value));
|
||||
}
|
||||
case "co":
|
||||
return JPAFilterResult.valid(cb.like(getAttributeExpression(attrInfo), "%" + escapeLike(value) + "%", '\\'));
|
||||
case "sw":
|
||||
return JPAFilterResult.valid(cb.like(getAttributeExpression(attrInfo), escapeLike(value) + "%", '\\'));
|
||||
case "ew":
|
||||
return JPAFilterResult.valid(cb.like(getAttributeExpression(attrInfo), "%" + escapeLike(value), '\\'));
|
||||
case "gt":
|
||||
if (isTimestampField) {
|
||||
Long timestamp = parseDateTime(value);
|
||||
return JPAFilterResult.valid(cb.greaterThan(root.get(attrInfo.getKeycloakName()), timestamp));
|
||||
} else {
|
||||
return JPAFilterResult.valid(cb.greaterThan(getAttributeExpression(attrInfo), value));
|
||||
}
|
||||
case "ge":
|
||||
if (isTimestampField) {
|
||||
Long timestamp = parseDateTime(value);
|
||||
return JPAFilterResult.valid(cb.greaterThanOrEqualTo(root.get(attrInfo.getKeycloakName()), timestamp));
|
||||
} else {
|
||||
return JPAFilterResult.valid(cb.greaterThanOrEqualTo(getAttributeExpression(attrInfo), value));
|
||||
}
|
||||
case "lt":
|
||||
if (isTimestampField) {
|
||||
Long timestamp = parseDateTime(value);
|
||||
return JPAFilterResult.valid(cb.lessThan(root.get(attrInfo.getKeycloakName()), timestamp));
|
||||
} else {
|
||||
return JPAFilterResult.valid(cb.lessThan(getAttributeExpression(attrInfo), value));
|
||||
}
|
||||
case "le":
|
||||
if (isTimestampField) {
|
||||
Long timestamp = parseDateTime(value);
|
||||
return JPAFilterResult.valid(cb.lessThanOrEqualTo(root.get(attrInfo.getKeycloakName()), timestamp));
|
||||
} else {
|
||||
return JPAFilterResult.valid(cb.lessThanOrEqualTo(getAttributeExpression(attrInfo), value));
|
||||
}
|
||||
default:
|
||||
throw new ScimFilterException("Unknown operator: " + operator);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse ISO 8601 date/time string to Long timestamp (milliseconds since epoch).
|
||||
* SCIM uses ISO 8601 format (e.g., "2011-05-13T04:42:34Z") while Keycloak stores
|
||||
* timestamps as Long (milliseconds).
|
||||
*/
|
||||
private Long parseDateTime(String dateTimeString) {
|
||||
try {
|
||||
Instant instant = Instant.parse(dateTimeString);
|
||||
return instant.toEpochMilli();
|
||||
} catch (DateTimeParseException e) {
|
||||
// If not a valid ISO 8601 date, try parsing as number (might be timestamp already)
|
||||
try {
|
||||
return Long.parseLong(dateTimeString);
|
||||
} catch (NumberFormatException nfe) {
|
||||
throw new ScimFilterException(
|
||||
"Invalid date/time format: " + dateTimeString +
|
||||
". Expected ISO 8601 format (e.g., 2011-05-13T04:42:34Z) or timestamp");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private Expression<String> getAttributeExpression(AttributeInfo attrInfo) {
|
||||
if (attrInfo.isPrimary()) {
|
||||
return root.get(attrInfo.getKeycloakName());
|
||||
} else {
|
||||
Join<?, ?> join = getOrCreateAttributeJoin();
|
||||
// Add name filter to join
|
||||
query.where(cb.equal(join.get("name"), attrInfo.getKeycloakName()));
|
||||
return join.get("value");
|
||||
}
|
||||
}
|
||||
|
||||
private Join<?, ?> getOrCreateAttributeJoin() {
|
||||
if (attributeJoin == null) {
|
||||
attributeJoin = root.join("attributes", JoinType.LEFT);
|
||||
}
|
||||
return attributeJoin;
|
||||
}
|
||||
|
||||
private String escapeLike(String value) {
|
||||
// Escape SQL LIKE special characters
|
||||
return value.replace("\\", "\\\\")
|
||||
.replace("%", "\\%")
|
||||
.replace("_", "\\_");
|
||||
}
|
||||
}
|
||||
+57
-2
@@ -12,12 +12,31 @@ package org.keycloak.scim.model.group;
|
||||
|
||||
import java.util.stream.Stream;
|
||||
|
||||
import jakarta.persistence.EntityManager;
|
||||
import jakarta.persistence.criteria.CriteriaBuilder;
|
||||
import jakarta.persistence.criteria.CriteriaQuery;
|
||||
import jakarta.persistence.criteria.Predicate;
|
||||
import jakarta.persistence.criteria.Root;
|
||||
|
||||
import org.keycloak.authorization.fgap.AdminPermissionsSchema;
|
||||
import org.keycloak.connections.jpa.JpaConnectionProvider;
|
||||
import org.keycloak.models.GroupModel;
|
||||
import org.keycloak.models.KeycloakSession;
|
||||
import org.keycloak.models.RealmModel;
|
||||
import org.keycloak.models.jpa.GroupAdapter;
|
||||
import org.keycloak.models.jpa.entities.GroupEntity;
|
||||
import org.keycloak.scim.filter.FilterUtils;
|
||||
import org.keycloak.scim.filter.ScimFilterParser;
|
||||
import org.keycloak.scim.model.filter.AttributeInfo;
|
||||
import org.keycloak.scim.model.filter.ScimJPAPredicateEvaluator;
|
||||
import org.keycloak.scim.protocol.request.SearchRequest;
|
||||
import org.keycloak.scim.resource.Scim;
|
||||
import org.keycloak.scim.resource.group.Group;
|
||||
import org.keycloak.scim.resource.spi.AbstractScimResourceTypeProvider;
|
||||
import org.keycloak.utils.StringUtil;
|
||||
|
||||
import static org.keycloak.models.jpa.PaginationUtils.paginateQuery;
|
||||
import static org.keycloak.utils.StreamsUtil.closing;
|
||||
|
||||
public class GroupResourceTypeProvider extends AbstractScimResourceTypeProvider<GroupModel, Group> {
|
||||
|
||||
@@ -50,9 +69,45 @@ public class GroupResourceTypeProvider extends AbstractScimResourceTypeProvider<
|
||||
}
|
||||
|
||||
@Override
|
||||
protected Stream<GroupModel> getModels() {
|
||||
protected Stream<GroupModel> getModels(SearchRequest searchRequest) {
|
||||
RealmModel realm = session.getContext().getRealm();
|
||||
return session.groups().getTopLevelGroupsStream(realm);
|
||||
Integer firstResult = searchRequest.getStartIndex() != null ? searchRequest.getStartIndex() - 1 : null;
|
||||
Integer maxResults = searchRequest.getCount();
|
||||
|
||||
if (StringUtil.isNotBlank(searchRequest.getFilter())) {
|
||||
// Parse filter into AST
|
||||
ScimFilterParser.FilterContext filterContext = FilterUtils.parseFilter(searchRequest.getFilter());
|
||||
|
||||
// Execute JPA query with filter
|
||||
EntityManager em = session.getProvider(JpaConnectionProvider.class).getEntityManager();
|
||||
CriteriaBuilder cb = em.getCriteriaBuilder();
|
||||
CriteriaQuery<GroupEntity> query = cb.createQuery(GroupEntity.class);
|
||||
Root<GroupEntity> root = query.from(GroupEntity.class);
|
||||
|
||||
// Create filter predicate using the same query and root that will be used for execution
|
||||
ScimJPAPredicateEvaluator evaluator = new ScimJPAPredicateEvaluator(scimAttrPath -> {
|
||||
// first split the attribute path into schema and attribute name. If no schema is specified, use the core user schema by default
|
||||
String[] splitAttrPath = splitScimAttribute(scimAttrPath);
|
||||
|
||||
if (Scim.GROUP_CORE_SCHEMA.equals(splitAttrPath[0]) && "displayName".equalsIgnoreCase(splitAttrPath[1])) {;
|
||||
return new AttributeInfo("name", true, null);
|
||||
}
|
||||
return null;
|
||||
}, cb, query, root);
|
||||
Predicate filterPredicate = evaluator.visit(filterContext).predicate();
|
||||
|
||||
// Apply realm restriction
|
||||
Predicate realmPredicate = cb.equal(root.get("realm"), realm.getId());
|
||||
|
||||
// Combine with filter predicate
|
||||
query.where(cb.and(realmPredicate, filterPredicate));
|
||||
|
||||
// Execute query and convert to UserModel stream
|
||||
return closing(paginateQuery(em.createQuery(query), firstResult, maxResults).getResultStream()
|
||||
.map(entity -> new GroupAdapter(session, realm, em, entity)));
|
||||
} else {
|
||||
return session.groups().getTopLevelGroupsStream(realm, firstResult, maxResults);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
+2
-2
@@ -6,6 +6,7 @@ import java.util.Objects;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
import org.keycloak.models.KeycloakSession;
|
||||
import org.keycloak.scim.protocol.request.SearchRequest;
|
||||
import org.keycloak.scim.resource.ResourceTypeRepresentation;
|
||||
import org.keycloak.scim.resource.config.ServiceProviderConfig;
|
||||
import org.keycloak.scim.resource.resourcetype.ResourceType;
|
||||
@@ -24,7 +25,6 @@ public class ResourceTypeProvider implements ScimResourceTypeProvider<ResourceTy
|
||||
|
||||
@Override
|
||||
public void close() {
|
||||
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -48,7 +48,7 @@ public class ResourceTypeProvider implements ScimResourceTypeProvider<ResourceTy
|
||||
}
|
||||
|
||||
@Override
|
||||
public Stream<ResourceType> getAll() {
|
||||
public Stream<ResourceType> getAll(SearchRequest searchRequest) {
|
||||
return session.getKeycloakSessionFactory().getProviderFactoriesStream(ScimResourceTypeProvider.class)
|
||||
.map(ScimResourceTypeProviderFactory.class::cast)
|
||||
.map(this::toRepresentation)
|
||||
|
||||
+91
-2
@@ -2,20 +2,43 @@ package org.keycloak.scim.model.user;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
import jakarta.persistence.EntityManager;
|
||||
import jakarta.persistence.criteria.CriteriaBuilder;
|
||||
import jakarta.persistence.criteria.CriteriaQuery;
|
||||
import jakarta.persistence.criteria.Predicate;
|
||||
import jakarta.persistence.criteria.Root;
|
||||
|
||||
import org.keycloak.authorization.fgap.AdminPermissionsSchema;
|
||||
import org.keycloak.connections.jpa.JpaConnectionProvider;
|
||||
import org.keycloak.models.KeycloakSession;
|
||||
import org.keycloak.models.ModelValidationException;
|
||||
import org.keycloak.models.RealmModel;
|
||||
import org.keycloak.models.UserModel;
|
||||
import org.keycloak.models.jpa.UserAdapter;
|
||||
import org.keycloak.models.jpa.entities.UserEntity;
|
||||
import org.keycloak.scim.filter.FilterUtils;
|
||||
import org.keycloak.scim.filter.ScimFilterParser;
|
||||
import org.keycloak.scim.model.filter.AttributeInfo;
|
||||
import org.keycloak.scim.model.filter.AttributeNameResolver;
|
||||
import org.keycloak.scim.model.filter.ScimJPAPredicateEvaluator;
|
||||
import org.keycloak.scim.protocol.request.SearchRequest;
|
||||
import org.keycloak.scim.resource.spi.AbstractScimResourceTypeProvider;
|
||||
import org.keycloak.scim.resource.user.User;
|
||||
import org.keycloak.userprofile.Attributes;
|
||||
import org.keycloak.userprofile.UserProfile;
|
||||
import org.keycloak.userprofile.UserProfileContext;
|
||||
import org.keycloak.userprofile.UserProfileProvider;
|
||||
import org.keycloak.userprofile.ValidationException;
|
||||
import org.keycloak.userprofile.ValidationException.Error;
|
||||
import org.keycloak.utils.StringUtil;
|
||||
|
||||
import static org.keycloak.models.jpa.PaginationUtils.paginateQuery;
|
||||
import static org.keycloak.scim.model.user.AbstractUserModelSchema.ANNOTATION_SCIM_SCHEMA;
|
||||
import static org.keycloak.scim.model.user.AbstractUserModelSchema.ANNOTATION_SCIM_SCHEMA_ATTRIBUTE;
|
||||
import static org.keycloak.utils.StreamsUtil.closing;
|
||||
|
||||
public class UserResourceTypeProvider extends AbstractScimResourceTypeProvider<UserModel, User> {
|
||||
|
||||
@@ -66,9 +89,37 @@ public class UserResourceTypeProvider extends AbstractScimResourceTypeProvider<U
|
||||
}
|
||||
|
||||
@Override
|
||||
protected Stream<UserModel> getModels() {
|
||||
protected Stream<UserModel> getModels(SearchRequest searchRequest) {
|
||||
RealmModel realm = session.getContext().getRealm();
|
||||
return session.users().searchForUserStream(realm, Map.of());
|
||||
Integer firstResult = searchRequest.getStartIndex() != null ? searchRequest.getStartIndex() - 1 : null;
|
||||
Integer maxResults = searchRequest.getCount();
|
||||
|
||||
if (StringUtil.isNotBlank(searchRequest.getFilter())) {
|
||||
// Parse filter into AST
|
||||
ScimFilterParser.FilterContext filterContext = FilterUtils.parseFilter(searchRequest.getFilter());
|
||||
|
||||
// Execute JPA query with filter
|
||||
EntityManager em = session.getProvider(JpaConnectionProvider.class).getEntityManager();
|
||||
CriteriaBuilder cb = em.getCriteriaBuilder();
|
||||
CriteriaQuery<UserEntity> query = cb.createQuery(UserEntity.class);
|
||||
Root<UserEntity> root = query.from(UserEntity.class);
|
||||
|
||||
// Create filter predicate using the same query and root that will be used for execution
|
||||
ScimJPAPredicateEvaluator evaluator = new ScimJPAPredicateEvaluator(new UserAttributeNameResolver(session, this), cb, query, root);
|
||||
Predicate filterPredicate = evaluator.visit(filterContext).predicate();
|
||||
|
||||
// Apply realm restriction
|
||||
Predicate realmPredicate = cb.equal(root.get("realmId"), realm.getId());
|
||||
|
||||
// Combine with filter predicate
|
||||
query.where(cb.and(realmPredicate, filterPredicate));
|
||||
|
||||
// Execute query and convert to UserModel stream
|
||||
return closing(paginateQuery(em.createQuery(query), firstResult, maxResults).getResultStream()
|
||||
.map(entity -> new UserAdapter(session, realm, em, entity)));
|
||||
} else {
|
||||
return session.users().searchForUserStream(realm, Map.of(), firstResult, maxResults);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -101,4 +152,42 @@ public class UserResourceTypeProvider extends AbstractScimResourceTypeProvider<U
|
||||
|
||||
return exception;
|
||||
}
|
||||
|
||||
private static class UserAttributeNameResolver implements AttributeNameResolver {
|
||||
|
||||
private KeycloakSession session;
|
||||
private UserResourceTypeProvider provider;
|
||||
|
||||
public UserAttributeNameResolver(KeycloakSession session, UserResourceTypeProvider provider) {
|
||||
this.session = session;
|
||||
this.provider = provider;
|
||||
}
|
||||
|
||||
@Override
|
||||
public AttributeInfo resolve(String scimAttrPath) {
|
||||
|
||||
// first split the attribute path into schema and attribute name. If no schema is specified, use the core user schema by default
|
||||
String[] splitAttrPath = provider.splitScimAttribute(scimAttrPath);
|
||||
|
||||
// iterate through user profile attributes, finding one whose scim.schema.attribute annotation matches the given scimAttrPath
|
||||
Attributes attributes = session.getProvider(UserProfileProvider.class).create(UserProfileContext.SCIM, Map.of()).getAttributes();
|
||||
Set<String> allAttrNames = attributes.toMap().keySet();
|
||||
for (String attrName : allAttrNames) {
|
||||
var annotations = attributes.getMetadata(attrName).getAnnotations();
|
||||
if (annotations != null) {
|
||||
String scimAttr = (String) annotations.get(ANNOTATION_SCIM_SCHEMA_ATTRIBUTE);
|
||||
String scimAttrSchema = (String) annotations.get(ANNOTATION_SCIM_SCHEMA);
|
||||
if (splitAttrPath[0].equals(scimAttrSchema) && splitAttrPath[1].equals(scimAttr)) {
|
||||
// we found the attribute with the matching SCIM attribute path and schema, so return it
|
||||
boolean primary = Boolean.parseBoolean((String) annotations.get("primary"));
|
||||
String attrType = (String) annotations.get("type");
|
||||
return new AttributeInfo(attrName, primary, attrType);
|
||||
}
|
||||
}
|
||||
}
|
||||
// haven't found the attribute in the user profile, so return null to indicate that this is an unknown attribute.
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -24,8 +24,6 @@
|
||||
<groupId>org.keycloak</groupId>
|
||||
<artifactId>keycloak-scim-model</artifactId>
|
||||
</dependency>
|
||||
|
||||
|
||||
<dependency>
|
||||
<groupId>org.keycloak</groupId>
|
||||
<artifactId>keycloak-server-spi</artifactId>
|
||||
|
||||
+40
-6
@@ -19,6 +19,7 @@ import jakarta.ws.rs.PUT;
|
||||
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.HttpHeaders;
|
||||
import jakarta.ws.rs.core.MediaType;
|
||||
import jakarta.ws.rs.core.Response;
|
||||
@@ -28,7 +29,9 @@ import jakarta.ws.rs.core.UriBuilder;
|
||||
import org.keycloak.common.util.Time;
|
||||
import org.keycloak.models.KeycloakSession;
|
||||
import org.keycloak.models.ModelValidationException;
|
||||
import org.keycloak.scim.filter.ScimFilterException;
|
||||
import org.keycloak.scim.protocol.ForbiddenException;
|
||||
import org.keycloak.scim.protocol.request.SearchRequest;
|
||||
import org.keycloak.scim.protocol.response.ErrorResponse;
|
||||
import org.keycloak.scim.protocol.response.ListResponse;
|
||||
import org.keycloak.scim.resource.ResourceTypeRepresentation;
|
||||
@@ -83,8 +86,36 @@ public class ScimResourceTypeResource<R extends ResourceTypeRepresentation> {
|
||||
|
||||
@GET
|
||||
@Produces(APPLICATION_SCIM_JSON)
|
||||
public Response getAll() {
|
||||
Stream<R> stream = resourceTypeProvider.getAll().peek((r -> setMetadata(r, r.getCreatedTimestamp())));
|
||||
public Response getAll(@QueryParam("filter") String filterExpression,
|
||||
@QueryParam("attributes") String attributes,
|
||||
@QueryParam("excludedAttributes") String excludedAttributes,
|
||||
@QueryParam("sortBy") String sortBy,
|
||||
@QueryParam("sortOrder") String sortOrder,
|
||||
@QueryParam("startIndex") Integer startIndex,
|
||||
@QueryParam("count") Integer count) {
|
||||
// Delegate to common search logic
|
||||
return search(SearchRequest.builder().withFilter(filterExpression)
|
||||
.withAttributes(attributes != null ? List.of(attributes.split(",")) : null)
|
||||
.withExcludedAttributes(excludedAttributes != null ? List.of(excludedAttributes.split(",")) : null)
|
||||
.withSortBy(sortBy)
|
||||
.withSortOrder(sortOrder)
|
||||
.withStartIndex(startIndex)
|
||||
.withCount(count).build());
|
||||
}
|
||||
|
||||
@Path(".search")
|
||||
@POST
|
||||
@Consumes({APPLICATION_SCIM_JSON, MediaType.APPLICATION_JSON})
|
||||
@Produces(APPLICATION_SCIM_JSON)
|
||||
public Response search(SearchRequest searchRequest) {
|
||||
|
||||
Stream<R> stream;
|
||||
try {
|
||||
stream = resourceTypeProvider.getAll(searchRequest)
|
||||
.peek(r -> setMetadata(r, r.getCreatedTimestamp()));
|
||||
} catch (ScimFilterException e) {
|
||||
return badRequest(e.getMessage(), "invalidFilter");
|
||||
}
|
||||
|
||||
if (resourceTypeProvider instanceof SingletonResourceTypeProvider<R>) {
|
||||
return Response.ok().entity(stream
|
||||
@@ -94,12 +125,8 @@ public class ScimResourceTypeResource<R extends ResourceTypeRepresentation> {
|
||||
}
|
||||
|
||||
List<R> resources = stream.toList();
|
||||
|
||||
ListResponse<R> response = new ListResponse<>();
|
||||
|
||||
response.setResources(resources);
|
||||
|
||||
// TODO: need to implement pagination and filtering, for now we just return all resources and set totalResults accordingly
|
||||
response.setTotalResults(response.getResources().size());
|
||||
|
||||
return Response.ok().entity(response).build();
|
||||
@@ -139,6 +166,7 @@ public class ScimResourceTypeResource<R extends ResourceTypeRepresentation> {
|
||||
(rScimResourceTypeProvider, r) -> resourceTypeProvider.update(r));
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private R parseResourceTypePayload(InputStream is) {
|
||||
try {
|
||||
return (R) JsonSerialization.readValue(is, resourceTypeClazz);
|
||||
@@ -207,6 +235,12 @@ public class ScimResourceTypeResource<R extends ResourceTypeRepresentation> {
|
||||
return errorResponse(Status.BAD_REQUEST, message);
|
||||
}
|
||||
|
||||
private Response badRequest(String message, String scimType) {
|
||||
ErrorResponse error = new ErrorResponse(message, Status.BAD_REQUEST.getStatusCode());
|
||||
error.setScimType(scimType);
|
||||
return Response.status(Status.BAD_REQUEST).entity(error).build();
|
||||
}
|
||||
|
||||
private Response errorResponse(Status status, String message) {
|
||||
return Response.status(status).entity(new ErrorResponse(message, status.getStatusCode())).build();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,611 @@
|
||||
package org.keycloak.tests.scim.tck;
|
||||
|
||||
import java.util.Iterator;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
|
||||
import org.keycloak.models.UserModel;
|
||||
import org.keycloak.models.utils.KeycloakModelUtils;
|
||||
import org.keycloak.representations.userprofile.config.UPAttribute;
|
||||
import org.keycloak.representations.userprofile.config.UPConfig;
|
||||
import org.keycloak.scim.client.ResourceFilter;
|
||||
import org.keycloak.scim.protocol.response.ListResponse;
|
||||
import org.keycloak.scim.resource.common.Email;
|
||||
import org.keycloak.scim.resource.common.Name;
|
||||
import org.keycloak.scim.resource.group.Group;
|
||||
import org.keycloak.scim.resource.user.User;
|
||||
import org.keycloak.testframework.annotations.KeycloakIntegrationTest;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.hamcrest.MatcherAssert.assertThat;
|
||||
import static org.hamcrest.Matchers.greaterThanOrEqualTo;
|
||||
import static org.hamcrest.Matchers.is;
|
||||
import static org.hamcrest.Matchers.not;
|
||||
import static org.hamcrest.Matchers.nullValue;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
|
||||
/**
|
||||
* Comprehensive integration tests for SCIM filter functionality covering all operators and complex combinations.
|
||||
*
|
||||
* @author <a href="mailto:sguilhen@redhat.com">Stefan Guilhen</a>
|
||||
*/
|
||||
@KeycloakIntegrationTest(config = ScimServerConfig.class)
|
||||
public class FilterTest extends AbstractScimTest {
|
||||
|
||||
@BeforeEach
|
||||
public void onBefore() {
|
||||
UPConfig upConfig = realm.admin().users().userProfile().getConfiguration();
|
||||
upConfig.getAttribute(UserModel.FIRST_NAME).setRequired(null);
|
||||
upConfig.getAttribute(UserModel.LAST_NAME).setRequired(null);
|
||||
upConfig.getAttribute(UserModel.EMAIL).setRequired(null);
|
||||
Iterator<UPAttribute> iterator = upConfig.getAttributes().iterator();
|
||||
while (iterator.hasNext()) {
|
||||
UPAttribute attribute = iterator.next();
|
||||
if (Set.of(UserModel.USERNAME, UserModel.FIRST_NAME, UserModel.LAST_NAME, UserModel.EMAIL).contains(attribute.getName())) {
|
||||
continue;
|
||||
}
|
||||
iterator.remove();
|
||||
}
|
||||
realm.admin().users().userProfile().update(upConfig);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testEqualityFilter() {
|
||||
User john = new User();
|
||||
john.setUserName("john_" + KeycloakModelUtils.generateId());
|
||||
john = client.users().create(john);
|
||||
|
||||
User jane = new User();
|
||||
jane.setUserName("jane_" + KeycloakModelUtils.generateId());
|
||||
jane = client.users().create(jane);
|
||||
|
||||
String filter = ResourceFilter.filter().eq("userName", john.getUserName()).build();
|
||||
ListResponse<User> response = client.users().getAll(filter);
|
||||
|
||||
assertThat(response, is(not(nullValue())));
|
||||
assertThat(response.getTotalResults(), is(1));
|
||||
assertThat(response.getResources().get(0).getUserName(), is(john.getUserName()));
|
||||
|
||||
// create a couple of groups to test group filtering as well
|
||||
Group groupA = new Group();
|
||||
groupA.setDisplayName("GroupA");
|
||||
groupA = client.groups().create(groupA);
|
||||
assertNotNull(groupA);
|
||||
|
||||
Group groupB = new Group();
|
||||
groupB.setDisplayName("GroupB");
|
||||
groupB = client.groups().create(groupB);
|
||||
assertNotNull(groupB);
|
||||
|
||||
String groupFilter = ResourceFilter.filter().eq("displayName", groupA.getDisplayName()).build();
|
||||
ListResponse<Group> groupResponse = client.groups().getAll(groupFilter);
|
||||
assertThat(groupResponse, is(not(nullValue())));
|
||||
assertThat(groupResponse.getTotalResults(), is(1));
|
||||
assertThat(groupResponse.getResources().get(0).getDisplayName(), is(groupA.getDisplayName()));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testNotEqualFilter() {
|
||||
User user1 = new User();
|
||||
user1.setUserName("testne1_" + KeycloakModelUtils.generateId());
|
||||
user1 = client.users().create(user1);
|
||||
final String user1Name = user1.getUserName();
|
||||
|
||||
User user2 = new User();
|
||||
user2.setUserName("testne2_" + KeycloakModelUtils.generateId());
|
||||
user2 = client.users().create(user2);
|
||||
final String user2Name = user2.getUserName();
|
||||
|
||||
String filter = ResourceFilter.filter().ne("userName", user1Name).build();
|
||||
ListResponse<User> response = client.users().getAll(filter);
|
||||
|
||||
assertThat(response, is(not(nullValue())));
|
||||
assertThat(response.getTotalResults(), greaterThanOrEqualTo(1));
|
||||
assertThat(response.getResources().stream().noneMatch(u -> u.getUserName().equals(user1Name)), is(true));
|
||||
assertThat(response.getResources().stream().anyMatch(u -> u.getUserName().equals(user2Name)), is(true));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testStartsWithFilter() {
|
||||
User user = new User();
|
||||
user.setUserName("testswuser_" + KeycloakModelUtils.generateId());
|
||||
user = client.users().create(user);
|
||||
final String expectedUsername = user.getUserName();
|
||||
|
||||
String filter = ResourceFilter.filter().sw("userName", "testswuser_").build();
|
||||
ListResponse<User> response = client.users().getAll(filter);
|
||||
|
||||
assertThat(response, is(not(nullValue())));
|
||||
assertThat(response.getTotalResults(), greaterThanOrEqualTo(1));
|
||||
assertThat(response.getResources().stream().anyMatch(u -> u.getUserName().equals(expectedUsername)), is(true));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testContainsFilter() {
|
||||
User user = new User();
|
||||
user.setUserName("test_contains_xyz_" + KeycloakModelUtils.generateId());
|
||||
user = client.users().create(user);
|
||||
final String expectedUsername = user.getUserName();
|
||||
|
||||
String filter = ResourceFilter.filter().co("userName", "contains_xyz").build();
|
||||
ListResponse<User> response = client.users().getAll(filter);
|
||||
|
||||
assertThat(response, is(not(nullValue())));
|
||||
assertThat(response.getTotalResults(), greaterThanOrEqualTo(1));
|
||||
assertThat(response.getResources().stream().anyMatch(u -> u.getUserName().equals(expectedUsername)), is(true));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testEndsWithFilter() {
|
||||
String suffix = "_endstest_" + KeycloakModelUtils.generateId();
|
||||
User user = new User();
|
||||
user.setUserName("user" + suffix);
|
||||
user = client.users().create(user);
|
||||
final String expectedUsername = user.getUserName();
|
||||
|
||||
String filter = ResourceFilter.filter().ew("userName", suffix).build();
|
||||
ListResponse<User> response = client.users().getAll(filter);
|
||||
|
||||
assertThat(response, is(not(nullValue())));
|
||||
assertThat(response.getTotalResults(), greaterThanOrEqualTo(1));
|
||||
assertThat(response.getResources().stream().anyMatch(u -> u.getUserName().equals(expectedUsername)), is(true));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testPresentFilter() {
|
||||
User user = new User();
|
||||
user.setUserName("test_pr_" + KeycloakModelUtils.generateId());
|
||||
user = client.users().create(user);
|
||||
final String expectedUsername = user.getUserName();
|
||||
|
||||
String filter = ResourceFilter.filter().pr("userName").build();
|
||||
ListResponse<User> response = client.users().getAll(filter);
|
||||
|
||||
assertThat(response, is(not(nullValue())));
|
||||
assertThat(response.getTotalResults(), greaterThanOrEqualTo(1));
|
||||
assertThat(response.getResources().stream().anyMatch(u -> u.getUserName().equals(expectedUsername)), is(true));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testBooleanFilter() {
|
||||
User activeUser = new User();
|
||||
activeUser.setUserName("activetrue_" + KeycloakModelUtils.generateId());
|
||||
activeUser.setActive(true);
|
||||
activeUser = client.users().create(activeUser);
|
||||
|
||||
User inactiveUser = new User();
|
||||
inactiveUser.setUserName("activefalse_" + KeycloakModelUtils.generateId());
|
||||
inactiveUser.setActive(false);
|
||||
inactiveUser = client.users().create(inactiveUser);
|
||||
|
||||
String filter = ResourceFilter.filter().eq("active", "true").build();
|
||||
ListResponse<User> response = client.users().getAll(filter);
|
||||
|
||||
assertThat(response, is(not(nullValue())));
|
||||
assertThat(response.getResources().stream().allMatch(User::getActive), is(true));
|
||||
|
||||
filter = ResourceFilter.filter().eq("active", "false").build();
|
||||
response = client.users().getAll(filter);
|
||||
|
||||
assertThat(response, is(not(nullValue())));
|
||||
assertThat(response.getResources().stream().noneMatch(User::getActive), is(true));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testLogicalAndFilter() {
|
||||
User user = new User();
|
||||
user.setUserName("testuser_and_" + KeycloakModelUtils.generateId());
|
||||
user.setActive(true);
|
||||
user = client.users().create(user);
|
||||
|
||||
String filter = ResourceFilter.filter()
|
||||
.eq("userName", user.getUserName())
|
||||
.and()
|
||||
.eq("active", "true")
|
||||
.build();
|
||||
ListResponse<User> response = client.users().getAll(filter);
|
||||
|
||||
assertThat(response, is(not(nullValue())));
|
||||
assertThat(response.getTotalResults(), is(1));
|
||||
assertThat(response.getResources().get(0).getUserName(), is(user.getUserName()));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testLogicalOrFilter() {
|
||||
User user1 = new User();
|
||||
user1.setUserName("testor1_" + KeycloakModelUtils.generateId());
|
||||
user1 = client.users().create(user1);
|
||||
final String user1Name = user1.getUserName();
|
||||
|
||||
User user2 = new User();
|
||||
user2.setUserName("testor2_" + KeycloakModelUtils.generateId());
|
||||
user2 = client.users().create(user2);
|
||||
final String user2Name = user2.getUserName();
|
||||
|
||||
User user3 = new User();
|
||||
user3.setUserName("testor3_" + KeycloakModelUtils.generateId());
|
||||
user3 = client.users().create(user3);
|
||||
final String user3Name = user3.getUserName();
|
||||
|
||||
String filter = ResourceFilter.filter()
|
||||
.eq("userName", user1Name)
|
||||
.or()
|
||||
.eq("userName", user2Name)
|
||||
.build();
|
||||
ListResponse<User> response = client.users().getAll(filter);
|
||||
|
||||
assertThat(response, is(not(nullValue())));
|
||||
assertThat(response.getTotalResults(), is(2));
|
||||
assertThat(response.getResources().stream().anyMatch(u -> u.getUserName().equals(user1Name)), is(true));
|
||||
assertThat(response.getResources().stream().anyMatch(u -> u.getUserName().equals(user2Name)), is(true));
|
||||
assertThat(response.getResources().stream().noneMatch(u -> u.getUserName().equals(user3Name)), is(true));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testNotOperator() {
|
||||
User user = new User();
|
||||
user.setUserName("testnot_" + KeycloakModelUtils.generateId());
|
||||
user = client.users().create(user);
|
||||
final String userName = user.getUserName();
|
||||
|
||||
String filter = ResourceFilter.filter()
|
||||
.not()
|
||||
.lparen()
|
||||
.eq("userName", userName)
|
||||
.rparen()
|
||||
.build();
|
||||
ListResponse<User> response = client.users().getAll(filter);
|
||||
|
||||
assertThat(response, is(not(nullValue())));
|
||||
assertThat(response.getResources().stream().noneMatch(u -> u.getUserName().equals(userName)), is(true));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testComplexAndOrCombination() {
|
||||
String prefix = "complex_" + KeycloakModelUtils.generateId() + "_";
|
||||
|
||||
User user1 = new User();
|
||||
user1.setUserName(prefix + "active");
|
||||
user1.setActive(true);
|
||||
user1 = client.users().create(user1);
|
||||
final String user1Name = user1.getUserName();
|
||||
|
||||
User user2 = new User();
|
||||
user2.setUserName(prefix + "inactive");
|
||||
user2.setActive(false);
|
||||
user2 = client.users().create(user2);
|
||||
final String user2Name = user2.getUserName();
|
||||
|
||||
User user3 = new User();
|
||||
user3.setUserName("other_" + KeycloakModelUtils.generateId());
|
||||
user3.setActive(true);
|
||||
user3 = client.users().create(user3);
|
||||
|
||||
String filter = ResourceFilter.filter()
|
||||
.lparen()
|
||||
.sw("userName", prefix)
|
||||
.and()
|
||||
.eq("active", "true")
|
||||
.rparen()
|
||||
.or()
|
||||
.lparen()
|
||||
.sw("userName", prefix)
|
||||
.and()
|
||||
.eq("active", "false")
|
||||
.rparen()
|
||||
.build();
|
||||
ListResponse<User> response = client.users().getAll(filter);
|
||||
|
||||
assertThat(response, is(not(nullValue())));
|
||||
assertThat(response.getTotalResults(), is(2));
|
||||
assertThat(response.getResources().stream().anyMatch(u -> u.getUserName().equals(user1Name)), is(true));
|
||||
assertThat(response.getResources().stream().anyMatch(u -> u.getUserName().equals(user2Name)), is(true));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testNotWithAndCombination() {
|
||||
String prefix = "notand_" + KeycloakModelUtils.generateId() + "_";
|
||||
|
||||
User user1 = new User();
|
||||
user1.setUserName(prefix + "user1");
|
||||
user1.setActive(true);
|
||||
user1 = client.users().create(user1);
|
||||
|
||||
User user2 = new User();
|
||||
user2.setUserName(prefix + "user2");
|
||||
user2.setActive(false);
|
||||
user2 = client.users().create(user2);
|
||||
|
||||
String filter = ResourceFilter.filter()
|
||||
.sw("userName", prefix)
|
||||
.and()
|
||||
.not()
|
||||
.lparen()
|
||||
.eq("active", "true")
|
||||
.rparen()
|
||||
.build();
|
||||
ListResponse<User> response = client.users().getAll(filter);
|
||||
|
||||
assertThat(response, is(not(nullValue())));
|
||||
assertThat(response.getTotalResults(), is(1));
|
||||
assertThat(response.getResources().get(0).getUserName(), is(user2.getUserName()));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testMultipleAndConditions() {
|
||||
String uniqueId = KeycloakModelUtils.generateId();
|
||||
User user = new User();
|
||||
user.setUserName("multiand_" + uniqueId);
|
||||
user.setActive(true);
|
||||
user = client.users().create(user);
|
||||
final String userName = user.getUserName();
|
||||
|
||||
String filter = ResourceFilter.filter()
|
||||
.sw("userName", "multiand_")
|
||||
.and()
|
||||
.co("userName", uniqueId.substring(0, 8))
|
||||
.and()
|
||||
.eq("active", "true")
|
||||
.build();
|
||||
ListResponse<User> response = client.users().getAll(filter);
|
||||
|
||||
assertThat(response, is(not(nullValue())));
|
||||
assertThat(response.getTotalResults(), greaterThanOrEqualTo(1));
|
||||
assertThat(response.getResources().stream().anyMatch(u -> u.getUserName().equals(userName)), is(true));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testNestedParentheses() {
|
||||
String prefix1 = "nested1_" + KeycloakModelUtils.generateId() + "_";
|
||||
String prefix2 = "nested2_" + KeycloakModelUtils.generateId() + "_";
|
||||
|
||||
User user1 = new User();
|
||||
user1.setUserName(prefix1 + "active");
|
||||
user1.setActive(true);
|
||||
user1 = client.users().create(user1);
|
||||
final String user1Name = user1.getUserName();
|
||||
|
||||
User user2 = new User();
|
||||
user2.setUserName(prefix2 + "active");
|
||||
user2.setActive(true);
|
||||
user2 = client.users().create(user2);
|
||||
final String user2Name = user2.getUserName();
|
||||
|
||||
User user3 = new User();
|
||||
user3.setUserName(prefix1 + "inactive");
|
||||
user3.setActive(false);
|
||||
user3 = client.users().create(user3);
|
||||
|
||||
String filter = ResourceFilter.filter()
|
||||
.lparen()
|
||||
.sw("userName", prefix1)
|
||||
.or()
|
||||
.sw("userName", prefix2)
|
||||
.rparen()
|
||||
.and()
|
||||
.eq("active", "true")
|
||||
.build();
|
||||
ListResponse<User> response = client.users().getAll(filter);
|
||||
|
||||
assertThat(response, is(not(nullValue())));
|
||||
assertThat(response.getTotalResults(), is(2));
|
||||
assertThat(response.getResources().stream().anyMatch(u -> u.getUserName().equals(user1Name)), is(true));
|
||||
assertThat(response.getResources().stream().anyMatch(u -> u.getUserName().equals(user2Name)), is(true));
|
||||
}
|
||||
|
||||
// Tests with rich user objects
|
||||
|
||||
@Test
|
||||
public void testFilterByGivenName() {
|
||||
User user = new User();
|
||||
user.setUserName("nametest_" + KeycloakModelUtils.generateId());
|
||||
Name name = new Name();
|
||||
name.setGivenName("Alice");
|
||||
name.setFamilyName("Smith");
|
||||
user.setName(name);
|
||||
user = client.users().create(user);
|
||||
final String userName = user.getUserName();
|
||||
|
||||
String filter = ResourceFilter.filter().eq("name.givenName", "Alice").build();
|
||||
ListResponse<User> response = client.users().getAll(filter);
|
||||
|
||||
assertThat(response, is(not(nullValue())));
|
||||
assertThat(response.getTotalResults(), greaterThanOrEqualTo(1));
|
||||
assertThat(response.getResources().stream().anyMatch(u -> u.getUserName().equals(userName)), is(true));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testFilterByFamilyName() {
|
||||
User user = new User();
|
||||
user.setUserName("familytest_" + KeycloakModelUtils.generateId());
|
||||
Name name = new Name();
|
||||
name.setGivenName("Bob");
|
||||
name.setFamilyName("Johnson");
|
||||
user.setName(name);
|
||||
user = client.users().create(user);
|
||||
final String userName = user.getUserName();
|
||||
|
||||
String filter = ResourceFilter.filter().eq("name.familyName", "Johnson").build();
|
||||
ListResponse<User> response = client.users().getAll(filter);
|
||||
|
||||
assertThat(response, is(not(nullValue())));
|
||||
assertThat(response.getTotalResults(), greaterThanOrEqualTo(1));
|
||||
assertThat(response.getResources().stream().anyMatch(u -> u.getUserName().equals(userName)), is(true));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testFilterByEmail() {
|
||||
String emailValue = "test." + KeycloakModelUtils.generateId() + "@example.com";
|
||||
User user = new User();
|
||||
user.setUserName("emailtest_" + KeycloakModelUtils.generateId());
|
||||
Email email = new Email();
|
||||
email.setValue(emailValue);
|
||||
email.setPrimary(true);
|
||||
user.setEmails(List.of(email));
|
||||
user = client.users().create(user);
|
||||
final String userName = user.getUserName();
|
||||
|
||||
String filter = ResourceFilter.filter().eq("emails[0].value", emailValue).build();
|
||||
ListResponse<User> response = client.users().getAll(filter);
|
||||
|
||||
assertThat(response, is(not(nullValue())));
|
||||
assertThat(response.getTotalResults(), greaterThanOrEqualTo(1));
|
||||
assertThat(response.getResources().stream().anyMatch(u -> u.getUserName().equals(userName)), is(true));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testFilterByUnknownAttribute() {
|
||||
String emailValue = "test@example.com";
|
||||
User user = new User();
|
||||
user.setUserName("testuser");
|
||||
Email email = new Email();
|
||||
email.setValue(emailValue);
|
||||
email.setPrimary(true);
|
||||
user.setEmails(List.of(email));
|
||||
user = client.users().create(user);
|
||||
final String userName = user.getUserName();
|
||||
|
||||
// using a filter with an unknown attribute should not match any users if combined with 'and' since the unknown attribute condition cannot be satisfied
|
||||
String filter = ResourceFilter.filter().eq("userName", userName).and().pr("unkonwn").build();
|
||||
ListResponse<User> response = client.users().getAll(filter);
|
||||
|
||||
assertThat(response, is(not(nullValue())));
|
||||
assertThat(response.getTotalResults(), is(0));
|
||||
|
||||
// using a filter with 'or' where one side has an unknown attribute should still return the user since the other side matches
|
||||
filter = ResourceFilter.filter().eq("userName", userName).or().pr("unkonwn").build();
|
||||
response = client.users().getAll(filter);
|
||||
assertThat(response.getTotalResults(), is(1));
|
||||
assertThat(response.getResources().get(0).getUserName(), is(userName));
|
||||
|
||||
// using 'not' with an unknown attribute should not match any users since the condition inside 'not' cannot be satisfied
|
||||
filter = ResourceFilter.filter().not().pr("unkonwn").build();
|
||||
response = client.users().getAll(filter);
|
||||
assertThat(response, is(not(nullValue())));
|
||||
assertThat(response.getTotalResults(), is(0));
|
||||
}
|
||||
|
||||
|
||||
@Test
|
||||
public void testComplexFilterWithNames() {
|
||||
String uniqueId = KeycloakModelUtils.generateId();
|
||||
|
||||
User alice = new User();
|
||||
alice.setUserName("alice_" + uniqueId);
|
||||
Name aliceName = new Name();
|
||||
aliceName.setGivenName("Alice");
|
||||
aliceName.setFamilyName("Anderson");
|
||||
alice.setName(aliceName);
|
||||
alice.setActive(true);
|
||||
alice = client.users().create(alice);
|
||||
final String aliceName1 = alice.getUserName();
|
||||
|
||||
User bob = new User();
|
||||
bob.setUserName("bob_" + uniqueId);
|
||||
Name bobName = new Name();
|
||||
bobName.setGivenName("Bob");
|
||||
bobName.setFamilyName("Anderson");
|
||||
bob.setName(bobName);
|
||||
bob.setActive(false);
|
||||
bob = client.users().create(bob);
|
||||
|
||||
// Filter: name.familyName eq "Anderson" AND active eq true
|
||||
// Should match Alice but not Bob
|
||||
String filter = ResourceFilter.filter()
|
||||
.eq("name.familyName", "Anderson")
|
||||
.and()
|
||||
.eq("active", "true")
|
||||
.build();
|
||||
ListResponse<User> response = client.users().getAll(filter);
|
||||
|
||||
assertThat(response, is(not(nullValue())));
|
||||
assertThat(response.getTotalResults(), greaterThanOrEqualTo(1));
|
||||
assertThat(response.getResources().stream().anyMatch(u -> u.getUserName().equals(aliceName1)), is(true));
|
||||
}
|
||||
|
||||
// Tests for POST /.search endpoint
|
||||
|
||||
@Test
|
||||
public void testPostSearchEndpointEq() {
|
||||
User user = new User();
|
||||
user.setUserName("postsearch_" + KeycloakModelUtils.generateId());
|
||||
user = client.users().create(user);
|
||||
final String userName = user.getUserName();
|
||||
|
||||
// Use search() which calls POST /.search
|
||||
String filter = ResourceFilter.filter().eq("userName", userName).build();
|
||||
ListResponse<User> response = client.users().search(filter);
|
||||
|
||||
assertThat(response, is(not(nullValue())));
|
||||
assertThat(response.getTotalResults(), is(1));
|
||||
assertThat(response.getResources().get(0).getUserName(), is(userName));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testPostSearchEndpointComplex() {
|
||||
String prefix = "postcomplex_" + KeycloakModelUtils.generateId() + "_";
|
||||
|
||||
User user1 = new User();
|
||||
user1.setUserName(prefix + "user1");
|
||||
Name name1 = new Name();
|
||||
name1.setGivenName("Charlie");
|
||||
name1.setFamilyName("Davis");
|
||||
user1.setName(name1);
|
||||
user1.setActive(true);
|
||||
user1 = client.users().create(user1);
|
||||
final String user1Name = user1.getUserName();
|
||||
|
||||
User user2 = new User();
|
||||
user2.setUserName(prefix + "user2");
|
||||
Name name2 = new Name();
|
||||
name2.setGivenName("David");
|
||||
name2.setFamilyName("Davis");
|
||||
user2.setName(name2);
|
||||
user2.setActive(false);
|
||||
user2 = client.users().create(user2);
|
||||
final String user2Name = user2.getUserName();
|
||||
|
||||
// Complex filter: (userName sw prefix AND active eq true) OR (name.givenName eq "David")
|
||||
// Should match both users
|
||||
String filter = ResourceFilter.filter()
|
||||
.lparen()
|
||||
.sw("userName", prefix)
|
||||
.and()
|
||||
.eq("active", "true")
|
||||
.rparen()
|
||||
.or()
|
||||
.lparen()
|
||||
.eq("name.givenName", "David")
|
||||
.rparen()
|
||||
.build();
|
||||
ListResponse<User> response = client.users().search(filter);
|
||||
|
||||
assertThat(response, is(not(nullValue())));
|
||||
assertThat(response.getTotalResults(), is(2));
|
||||
assertThat(response.getResources().stream().anyMatch(u -> u.getUserName().equals(user1Name)), is(true));
|
||||
assertThat(response.getResources().stream().anyMatch(u -> u.getUserName().equals(user2Name)), is(true));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testPostSearchWithEmailFilter() {
|
||||
String emailValue = "postemailtest." + KeycloakModelUtils.generateId() + "@example.com";
|
||||
User user = new User();
|
||||
user.setUserName("postemailuser_" + KeycloakModelUtils.generateId());
|
||||
Email email = new Email();
|
||||
email.setValue(emailValue);
|
||||
email.setPrimary(true);
|
||||
user.setEmails(List.of(email));
|
||||
user = client.users().create(user);
|
||||
final String userName = user.getUserName();
|
||||
|
||||
// Test POST search with email contains filter
|
||||
String filter = ResourceFilter.filter().co("emails[0].value", "postemailtest").build();
|
||||
ListResponse<User> response = client.users().search(filter);
|
||||
|
||||
assertThat(response, is(not(nullValue())));
|
||||
assertThat(response.getTotalResults(), greaterThanOrEqualTo(1));
|
||||
assertThat(response.getResources().stream().anyMatch(u -> u.getUserName().equals(userName)), is(true));
|
||||
}
|
||||
}
|
||||
@@ -107,9 +107,9 @@ org.keycloak.securityprofile.SecurityProfileSpi
|
||||
org.keycloak.logging.MappedDiagnosticContextSpi
|
||||
org.keycloak.models.workflow.WorkflowConditionSpi
|
||||
org.keycloak.models.workflow.WorkflowEventSpi
|
||||
org.keycloak.models.workflow.WorkflowStateSpi
|
||||
org.keycloak.models.workflow.WorkflowStepSpi
|
||||
org.keycloak.models.workflow.WorkflowSpi
|
||||
org.keycloak.protocol.oidc.rar.AuthorizationDetailsProcessorSpi
|
||||
org.keycloak.cache.AlternativeLookupSPI
|
||||
org.keycloak.cache.LocalCacheSPI
|
||||
org.keycloak.models.workflow.WorkflowStateSpi
|
||||
@@ -50,6 +50,7 @@ public interface UserModel extends RoleMapperModel, Model {
|
||||
String DISABLED_REASON = "disabledReason";
|
||||
//attribute name used to mark a temporary admin user/service account as temporary
|
||||
String IS_TEMP_ADMIN_ATTR_NAME = "is_temporary_admin";
|
||||
String CREATED_TIMESTAMP = "createdTimestamp";
|
||||
|
||||
Comparator<UserModel> COMPARE_BY_USERNAME = Comparator.comparing(UserModel::getUsername, String.CASE_INSENSITIVE_ORDER);
|
||||
|
||||
|
||||
@@ -284,10 +284,6 @@
|
||||
<groupId>org.keycloak</groupId>
|
||||
<artifactId>keycloak-config-api</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.keycloak</groupId>
|
||||
<artifactId>keycloak-scim-services</artifactId>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
<build>
|
||||
<plugins>
|
||||
|
||||
+44
-43
@@ -55,8 +55,6 @@ import org.keycloak.provider.ProviderConfigurationBuilder;
|
||||
import org.keycloak.representations.userprofile.config.UPAttribute;
|
||||
import org.keycloak.representations.userprofile.config.UPAttributePermissions;
|
||||
import org.keycloak.representations.userprofile.config.UPConfig;
|
||||
import org.keycloak.scim.resource.Scim;
|
||||
import org.keycloak.scim.resource.user.User;
|
||||
import org.keycloak.services.messages.Messages;
|
||||
import org.keycloak.userprofile.config.UPConfigUtils;
|
||||
import org.keycloak.userprofile.validator.BlankAttributeValidator;
|
||||
@@ -80,8 +78,6 @@ import org.jspecify.annotations.NonNull;
|
||||
import static java.util.Optional.ofNullable;
|
||||
|
||||
import static org.keycloak.common.util.ObjectUtil.isBlank;
|
||||
import static org.keycloak.scim.model.user.AbstractUserModelSchema.ANNOTATION_SCIM_SCHEMA;
|
||||
import static org.keycloak.scim.model.user.AbstractUserModelSchema.ANNOTATION_SCIM_SCHEMA_ATTRIBUTE;
|
||||
import static org.keycloak.userprofile.DefaultAttributes.READ_ONLY_ATTRIBUTE_KEY;
|
||||
import static org.keycloak.userprofile.UserProfileContext.ACCOUNT;
|
||||
import static org.keycloak.userprofile.UserProfileContext.IDP_REVIEW;
|
||||
@@ -141,20 +137,18 @@ public class DeclarativeUserProfileProviderFactory implements UserProfileProvide
|
||||
KeycloakContext context = session.getContext();
|
||||
RealmModel realm = context.getRealm();
|
||||
|
||||
switch (c.getContext()) {
|
||||
case REGISTRATION:
|
||||
case IDP_REVIEW:
|
||||
return !realm.isRegistrationEmailAsUsername();
|
||||
case UPDATE_PROFILE:
|
||||
return switch (c.getContext()) {
|
||||
case REGISTRATION, IDP_REVIEW -> !realm.isRegistrationEmailAsUsername();
|
||||
case UPDATE_PROFILE -> {
|
||||
if (realm.isRegistrationEmailAsUsername()) {
|
||||
return false;
|
||||
yield false;
|
||||
}
|
||||
return realm.isEditUsernameAllowed();
|
||||
case UPDATE_EMAIL:
|
||||
return false;
|
||||
}
|
||||
yield realm.isEditUsernameAllowed();
|
||||
}
|
||||
case UPDATE_EMAIL -> false;
|
||||
default -> true;
|
||||
};
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private static boolean editEmailCondition(AttributeContext c) {
|
||||
@@ -186,11 +180,7 @@ public class DeclarativeUserProfileProviderFactory implements UserProfileProvide
|
||||
return !(UPDATE_PROFILE.equals(c.getContext()) || ACCOUNT.equals(c.getContext()));
|
||||
}
|
||||
|
||||
if (!isNewUser(c) && realm.isRegistrationEmailAsUsername() && !realm.isEditUsernameAllowed()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
return isNewUser(c) || !realm.isRegistrationEmailAsUsername() || realm.isEditUsernameAllowed();
|
||||
}
|
||||
|
||||
private static boolean readEmailCondition(AttributeContext c) {
|
||||
@@ -273,7 +263,7 @@ public class DeclarativeUserProfileProviderFactory implements UserProfileProvide
|
||||
}
|
||||
|
||||
addContextualProfileMetadata(configureUserProfile(createBrokeringProfile(readOnlyValidator)));
|
||||
addContextualProfileMetadata(configureUserProfile(createAccountProfile(ACCOUNT, readOnlyValidator)));
|
||||
addContextualProfileMetadata(configureUserProfile(createAccountProfile(readOnlyValidator)));
|
||||
addContextualProfileMetadata(configureUserProfile(createDefaultProfile(UPDATE_PROFILE, readOnlyValidator)));
|
||||
if (Profile.isFeatureEnabled(Profile.Feature.UPDATE_EMAIL)) {
|
||||
addContextualProfileMetadata(configureUserProfile(createDefaultProfile(UPDATE_EMAIL, readOnlyValidator)));
|
||||
@@ -285,27 +275,39 @@ public class DeclarativeUserProfileProviderFactory implements UserProfileProvide
|
||||
|
||||
private @NonNull UserProfileMetadata createScimProfile(AttributeValidatorMetadata readOnlyValidator) {
|
||||
UserProfileMetadata metadata = createDefaultProfile(SCIM, readOnlyValidator);
|
||||
String coreSchema = Scim.getCoreSchema(User.class);
|
||||
String coreSchema = "urn:ietf:params:scim:schemas:core:2.0:User";
|
||||
|
||||
metadata.getAttribute(UserModel.USERNAME).get(0)
|
||||
.addAnnotations(Map.of(ANNOTATION_SCIM_SCHEMA, coreSchema,
|
||||
ANNOTATION_SCIM_SCHEMA_ATTRIBUTE, "userName"));
|
||||
.addAnnotations(Map.of("scim.schema", coreSchema,
|
||||
"scim.schema.attribute", "userName",
|
||||
"primary", "true"));
|
||||
metadata.getAttribute(UserModel.EMAIL).get(0)
|
||||
.addAnnotations(Map.of(ANNOTATION_SCIM_SCHEMA, coreSchema,
|
||||
ANNOTATION_SCIM_SCHEMA_ATTRIBUTE, "emails[0].value"));
|
||||
.addAnnotations(Map.of("scim.schema", coreSchema,
|
||||
"scim.schema.attribute", "emails[0].value",
|
||||
"primary", "true"));
|
||||
metadata.addAttribute(UserModel.FIRST_NAME, -1, AttributeMetadata.ALWAYS_TRUE, AttributeMetadata.ALWAYS_TRUE)
|
||||
.addAnnotations(Map.of(ANNOTATION_SCIM_SCHEMA, coreSchema,
|
||||
ANNOTATION_SCIM_SCHEMA_ATTRIBUTE, "name.givenName"));
|
||||
.addAnnotations(Map.of("scim.schema", coreSchema,
|
||||
"scim.schema.attribute", "name.givenName",
|
||||
"primary", "true"));
|
||||
metadata.addAttribute(UserModel.LAST_NAME, -1, AttributeMetadata.ALWAYS_TRUE, AttributeMetadata.ALWAYS_TRUE)
|
||||
.addAnnotations(Map.of(ANNOTATION_SCIM_SCHEMA, coreSchema,
|
||||
ANNOTATION_SCIM_SCHEMA_ATTRIBUTE, "name.familyName"));
|
||||
.addAnnotations(Map.of("scim.schema", coreSchema,
|
||||
"scim.schema.attribute", "name.familyName",
|
||||
"primary", "true"));
|
||||
metadata.addAttribute(UserModel.ENABLED, -1, AttributeMetadata.ALWAYS_TRUE, AttributeMetadata.ALWAYS_TRUE)
|
||||
.addAnnotations(Map.of(ANNOTATION_SCIM_SCHEMA, coreSchema,
|
||||
ANNOTATION_SCIM_SCHEMA_ATTRIBUTE, "active"));
|
||||
.addAnnotations(Map.of("scim.schema", coreSchema,
|
||||
"scim.schema.attribute", "active",
|
||||
"primary", "true",
|
||||
"type", "boolean"));
|
||||
metadata.addAttribute(UserModel.CREATED_TIMESTAMP, -1, AttributeMetadata.ALWAYS_FALSE, AttributeMetadata.ALWAYS_TRUE)
|
||||
.setRequired(AttributeMetadata.ALWAYS_FALSE)
|
||||
.addAnnotations(Map.of("scim.schema", coreSchema,
|
||||
"scim.schema.attribute", "meta.created",
|
||||
"primary", "true",
|
||||
"type", "timestamp"));
|
||||
metadata.addAttribute(UserModel.LOCALE, -1, DeclarativeUserProfileProviderFactory::isInternationalizationEnabled, DeclarativeUserProfileProviderFactory::isInternationalizationEnabled)
|
||||
.setRequired(AttributeMetadata.ALWAYS_FALSE)
|
||||
.addAnnotations(Map.of(ANNOTATION_SCIM_SCHEMA, coreSchema,
|
||||
ANNOTATION_SCIM_SCHEMA_ATTRIBUTE, "locale"))
|
||||
.addAnnotations(Map.of("scim.schema", coreSchema,
|
||||
"scim.schema.attribute", "locale"))
|
||||
.setSelector(c -> {
|
||||
RealmModel realm = c.getSession().getContext().getRealm();
|
||||
return realm.isInternationalizationEnabled();
|
||||
@@ -468,7 +470,7 @@ public class DeclarativeUserProfileProviderFactory implements UserProfileProvide
|
||||
metadata.addAttribute(UserModel.LOCALE, -1, DeclarativeUserProfileProviderFactory::isInternationalizationEnabled, DeclarativeUserProfileProviderFactory::isInternationalizationEnabled)
|
||||
.setRequired(AttributeMetadata.ALWAYS_FALSE);
|
||||
|
||||
addAttributeUserDid(UserModel.DID, metadata);
|
||||
addAttributeUserDid(metadata);
|
||||
|
||||
return metadata;
|
||||
}
|
||||
@@ -541,18 +543,18 @@ public class DeclarativeUserProfileProviderFactory implements UserProfileProvide
|
||||
.setAttributeDisplayName("${termsAndConditionsUserAttribute}")
|
||||
.setRequired(AttributeMetadata.ALWAYS_FALSE);
|
||||
|
||||
addAttributeUserDid(UserModel.DID, metadata);
|
||||
addAttributeUserDid(metadata);
|
||||
|
||||
return metadata;
|
||||
}
|
||||
|
||||
private UserProfileMetadata createAccountProfile(UserProfileContext context, AttributeValidatorMetadata readOnlyValidator) {
|
||||
UserProfileMetadata defaultProfile = createDefaultProfile(context, readOnlyValidator);
|
||||
private UserProfileMetadata createAccountProfile(AttributeValidatorMetadata readOnlyValidator) {
|
||||
UserProfileMetadata defaultProfile = createDefaultProfile(UserProfileContext.ACCOUNT, readOnlyValidator);
|
||||
|
||||
defaultProfile.addAttribute(UserModel.LOCALE, -1, DeclarativeUserProfileProviderFactory::isInternationalizationEnabled, DeclarativeUserProfileProviderFactory::isInternationalizationEnabled)
|
||||
.setRequired(AttributeMetadata.ALWAYS_FALSE);
|
||||
|
||||
addAttributeUserDid(UserModel.DID, defaultProfile);
|
||||
addAttributeUserDid(defaultProfile);
|
||||
|
||||
return defaultProfile;
|
||||
}
|
||||
@@ -636,17 +638,16 @@ public class DeclarativeUserProfileProviderFactory implements UserProfileProvide
|
||||
|
||||
private static boolean isVerifiableCredentialsEnabled(AttributeContext context) {
|
||||
RealmModel realm = context.getSession().getContext().getRealm();
|
||||
boolean oid4vciEnabled = realm.isVerifiableCredentialsEnabled();
|
||||
return oid4vciEnabled;
|
||||
return realm.isVerifiableCredentialsEnabled();
|
||||
}
|
||||
|
||||
private void addAttributeUserDid(String name, UserProfileMetadata metadata) {
|
||||
private void addAttributeUserDid(UserProfileMetadata metadata) {
|
||||
Predicate<AttributeContext> required = AttributeMetadata.ALWAYS_FALSE;
|
||||
Predicate<AttributeContext> selector = DeclarativeUserProfileProviderFactory::isVerifiableCredentialsEnabled;
|
||||
Predicate<AttributeContext> readWriteAllowed = DeclarativeUserProfileProviderFactory::isVerifiableCredentialsEnabled;
|
||||
AttributeValidatorMetadata validatorMetadata = new AttributeValidatorMetadata(PatternValidator.ID, new ValidatorConfig(Map.of(
|
||||
"pattern", "^(did:[a-z0-9]+:.+)?$", // simplified pattern
|
||||
"error-message", "Value must start with 'did:scheme:'")));
|
||||
metadata.addAttribute(name, 10, List.of(validatorMetadata), selector, readWriteAllowed, required, readWriteAllowed).setAttributeDisplayName("${did}");
|
||||
metadata.addAttribute(UserModel.DID, 10, List.of(validatorMetadata), selector, readWriteAllowed, required, readWriteAllowed).setAttributeDisplayName("${did}");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user