Add support for filtering on SCIM endpoints

Closes #46221

Signed-off-by: Stefan Guilhen <sguilhen@redhat.com>
This commit is contained in:
Stefan Guilhen
2026-02-27 12:20:01 -03:00
committed by GitHub
parent d2dc582b1c
commit 857b0e6925
32 changed files with 2028 additions and 71 deletions
@@ -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");
+23
View File
@@ -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;
}
}
}
@@ -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();
@@ -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,
@@ -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\""));
}
}
+8
View File
@@ -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);
}
}
@@ -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
}
}
@@ -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("_", "\\_");
}
}
@@ -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
@@ -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)
@@ -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;
}
}
}
-2
View File
@@ -24,8 +24,6 @@
<groupId>org.keycloak</groupId>
<artifactId>keycloak-scim-model</artifactId>
</dependency>
<dependency>
<groupId>org.keycloak</groupId>
<artifactId>keycloak-server-spi</artifactId>
@@ -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);
-4
View File
@@ -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>
@@ -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}");
}
}