8000 Correcting SCIM filter by hypery2k · Pull Request #55059 · keycloak/keycloak · GitHub
[go: up one dir, main page]

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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,15 @@ A comparison such as `attribute eq "value"` or `attribute ne "value"` does not m
has no value. Use `not (attribute pr)` to find them. For a multi-valued attribute, `attribute ne "value"` matches
resources that have at least one other value.

For a single-valued attribute, the negated comparison `not (attribute eq "value")` behaves like `attribute ne "value"`
and also does not match resources where the attribute has no value. For a multi-valued attribute,
`not (attribute eq "value")` matches resources that do not have the given value, including resources where the
attribute has no value.

User attribute values longer than 255 characters can only be compared using the `eq`, `ne`, and `pr` operators.
For single-valued attributes, other operators and their negations do not match such values.
For multi-valued attributes, a resource containing only long values still matches a negated comparison using another operator.

The `eq` and `ne` operators accept the `null` literal, but the comparison never matches, so it cannot be used to find
resources without a value.

Expand Down
8000
Original file line number Diff line number Diff line change
Expand Up @@ -2,40 +2,56 @@

import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.function.BiPredicate;

import jakarta.persistence.criteria.CriteriaBuilder;
import jakarta.persistence.criteria.CriteriaQuery;
import jakarta.persistence.criteria.Expression;
import jakarta.persistence.criteria.Join;
import jakarta.persistence.criteria.Path;
import jakarta.persistence.criteria.Predicate;
import jakarta.persistence.criteria.Root;
import jakarta.persistence.criteria.Subquery;

import org.keycloak.common.util.TriFunction;
import org.keycloak.models.jpa.entities.UserAttributeEntity;
import org.keycloak.scim.filter.ScimFilterException;
import org.keycloak.scim.resource.schema.ModelSchema;
import org.keycloak.scim.resource.schema.attribute.Attribute;
import org.keycloak.scim.resource.schema.attribute.ScimDateTimeUtil;
import org.keycloak.scim.resource.spi.ScimResourceTypeProvider;
import org.keycloak.storage.jpa.JpaHashUtils;

import org.jboss.logging.Logger;

/**
* Creates JPA predicates for SCIM filter operators. Handles both direct root entity fields and collection-backed
* attributes - custom attributes stored in an associated "attributes" collection, and resource-specific relations
* such as groups/members/roles resolved via {@link ScimAttributeJpaExpressionResolver}. Collection-backed attributes
* are evaluated using a correlated {@code EXISTS} subquery rather than a shared {@code JOIN}, so that conjunction
* across independent values and negation are evaluated per-resource rather than per-joined-row (see
* <a href="https://github.com/keycloak/keycloak/issues/51805">#51805</a>). Also handles necessary type conversions
* for temporal fields.
* such as groups/members/roles resolved via {@link ScimAttributeJpaExpressionResolver}. Multivalued collection-backed
* attributes are evaluated using a correlated {@code EXISTS} subquery rather than a shared {@code JOIN}, so that
* conjunction across independent values and negation are evaluated per-resource rather than per-joined-row (see
* <a href="https://github.com/keycloak/keycloak/issues/51805">#51805</a>). Single-valued attributes stored in the
* "attributes" collection evaluate to SQL {@code UNKNOWN} when the resource has no value, matching the semantics of
* a direct field (see <a href="https://github.com/keycloak/keycloak/issues/53260">#53260</a>).
* Also handles necessary type conversions for temporal fields.
*
* @author <a href="mailto:sguilhen@redhat.com">Stefan Guilhen</a>
*/
public class ScimJPAPredicateProvider {

private static final Logger logger = Logger.getLogger(ScimJPAPredicateProvider.class);

/**
* Values longer than this are stored in the {@code LONG_VALUE} column (see {@link UserAttributeEntity#setValue(String)}).
*/
private static final int MAX_SHORT_VALUE_LENGTH = 255;

/**
* Operators that can be evaluated against values stored in the {@code LONG_VALUE} column, using the hash of the value.
*/
private static final Set<String> LONG_VALUE_OPERATORS = Set.of("eq", "ne", "pr");

private final ScimResourceTypeProvider resourceTypeProvider;
private final List<ModelSchema<?, ?>> schemas;
private final CriteriaBuilder cb;
Expand Down Expand Up @@ -145,7 +161,9 @@ private Object normalizeValue(Attribute<?,?> attrInfo, String value) {
* matters for two SCIM filtering scenarios that a plain {@code JOIN} cannot express correctly: conjunction across
* independent values of the same multivalued attribute (each side must be allowed to match a different value/row),
* and negation (a {@code NOT} must apply to the resource as a whole, including resources with no values at all,
* not to a single joined row).
* not to a single joined row). For a single-valued attribute stored in the "attributes" collection, a resource
* without a value evaluates to SQL {@code UNKNOWN} (see {@link #unknownIfAbsent(String)}), so that it matches
* neither a comparison nor its negation, as with direct fields.
*
* @param attrInfo the attribute metadata to determine how to build the predicate
* @param operation the comparison operator (eq, ne, gt, ge, lt, le, co, sw, ew)
Expand Down Expand Up @@ -195,16 +213,106 @@ private Predicate getAttributePredicate(Attribute<?,?> attrInfo, String operatio
}
}

Join<?, ?> attributesJoin = null;

if (expression == null) {
Join<?, ?> join = subquery.correlate(root).join("attributes");
expression = join.get("value");
correlation = cb.equal(join.get("name"), modelAttributeName);
attributesJoin = subquery.correlate(root).join("attributes");
expression = attributesJoin.get("value");
correlation = cb.equal(attributesJoin.get("name"), modelAttributeName);
}

Predicate operatorPredicate = buildOperatorPredicate(expression, attrInfo, operation, value);

boolean longValuesComparable = false;

if (attributesJoin != null && hasLongValues(attributesJoin)) {
longValuesComparable = LONG_VALUE_OPERATORS.contains(operation);
if (longValuesComparable) {
operatorPredicate = cb.or(operatorPredicate, buildLongValuePredicate(attributesJoin, attrInfo, operation, value));
}
}

subquery.where(correlation != null ? cb.and(correlation, operatorPredicate) : operatorPredicate);

return cb.exists(subquery);
Predicate exists = cb.exists(subquery);

if (attributesJoin != null && !attrInfo.isMultivalued() && !"pr".equals(operation)) {
// a resource without a value for a single-valued attribute must evaluate to UNKNOWN rather than FALSE,
// so that "attr ne x" and "not (attr eq x)" both exclude it, consistent with attributes stored directly
// on the root entity (see https://github.com/keycloak/keycloak/issues/53260). The comparison itself is
// still evaluated by the EXISTS subquery, so matching is unchanged for resources with a value. Presence
// ("pr") is never unknown, so that "not (attr pr)" still finds resources without a value.
return cb.or(exists, unknownIfAbsent(modelAttributeName, longValuesComparable));
}

return exists;
}

/**
* Whether the entity behind the given "attributes" join stores values longer than {@value #MAX_SHORT_VALUE_LENGTH}
* characters in a separate {@code LONG_VALUE} column, together with hashes of the value, instead of in the
* {@code VALUE} column (see {@link UserAttributeEntity#setValue(String)}).
*/
private static boolean hasLongValues(Join<?, ?> attributesJoin) {
return UserAttributeEntity.class.isAssignableFrom(attributesJoin.getJavaType());
}

/**
* Build a predicate that compares values stored in the {@code LONG_VALUE} column. The column is a {@code CLOB}
* that cannot be compared portably, so the comparison is done on the hash of the value - the same approach as the
* user search of the JPA store. Only the operators in {@link #LONG_VALUE_OPERATORS} can be evaluated this way.
*
* @param attributesJoin the join on the "attributes" collection
* @param attrInfo the attribute metadata to determine case sensitivity
* @param operation the comparison operator (eq, ne, pr)
* @param value the value to compare against, already normalized to the correct type
* @return the predicate matching resources whose long value satisfies the comparison
*/
private Predicate buildLongValuePredicate(Join<?, ?> attributesJoin, Attribute<?, ?> attrInfo, String operation, Object value) {
boolean caseInsensitive = attrInfo.isStoredLowerCase() || !attrInfo.isCaseExact();
Path<byte[]> hash = attributesJoin.get(caseInsensitive ? "longValueHashLowerCase" : "longValueHash");

if ("pr".equals(operation)) {
return cb.isNotNull(hash);
}
if (!(value instanceof String stringValue)) {
// only string values can be long
return cb.disjunction();
}

byte[] valueHash = caseInsensitive ? JpaHashUtils.hashForAttributeValueLowerCase(stringValue) : JpaHashUtils.hashForAttributeValue(stringValue);

if ("eq".equals(operation)) {
return cb.equal(hash, valueHash);
}

return cb.and(cb.isNotNull(hash), cb.notEqual(hash, valueHash));
}

/**
* Build a predicate that is SQL {@code UNKNOWN} if the resource has no value for the given attribute that the
* comparison can be evaluated against, and {@code FALSE} otherwise. OR-ing it with a comparison leaves the
* comparison unchanged for resources with such a value, and makes it {@code UNKNOWN} - which neither the
* comparison nor its negation matches - for resources without one.
* <p>
* If the comparison can be evaluated against values stored in the {@code LONG_VALUE} column, any entry for the
* attribute counts as a value. Otherwise, only an entry with a value in the {@code VALUE} column does, so that a
* long value is matched by neither the comparison nor its negation.
*
* @param modelAttributeName the name of the attribute in the "attributes" collection
* @param longValuesComparable whether the comparison can be evaluated against values stored in {@code LONG_VALUE}
* @return the predicate
*/
private Predicate unknownIfAbsent(String modelAttributeName, boolean longValuesComparable) {
// (SELECT MAX(name) ... WHERE name = :name) is either :name (present) or NULL (absent), so comparing it
// with "<>" yields FALSE or UNKNOWN respectively
Subquery<String> subquery = query.subquery(String.class);
Join<?, ?> join = subquery.correlate(root).join("attributes");
Path<String> name = join.get("name");
subquery.select(cb.greatest(name));
Predicate restriction = cb.equal(name, modelAttributeName);
subquery.where(longValuesComparable ? restriction : cb.and(restriction, cb.isNotNull(join.get("value"))));
return cb.notEqual(subquery, modelAttributeName);
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -633,6 +633,77 @@ public void testFilterMultivaluedCustomAttributeConjunctionAndNegation() {
assertTrue(negationResults.getResources().stream().anyMatch(u -> u.getId().equals(controlUser.getId())));
}

@Test
public void testFilterSingleValuedAttributeWithoutValueNegation() {
// Regression test for https://github.com/keycloak/keycloak/issues/53260: single-valued attributes stored
// as user attributes are evaluated with an EXISTS subquery, which used to make "not (attr eq x)" match users
// without a value while "attr ne x" did not. Both must exclude users without a value, as direct fields do.
addOrReplaceUPAttribute("title");
String customSchema = "urn:test:custom:" + KeycloakModelUtils.generateId();
String customAttr = customSchema + ":singleValuedAttr";
addOrReplaceUPAttribute(customSchema, "singleValuedAttr");

User engineer = createUser("engineer");
User manager = createUser("manager");
User none = createUser("none");
setAttributes(engineer, Map.of("scim.title", List.of("Engineer"), "scim.singleValuedAttr", List.of("Engineer")));
setAttributes(manager, Map.of("scim.title", List.of("Manager"), "scim.singleValuedAttr", List.of("Manager")));

for (String attr : List.of("title", customAttr)) {
assertSingleResult(client.users().getAll(ResourceFilter.filter().ne(attr, "Engineer").build()), manager.getUserName());
assertSingleResult(client.users().getAll(ResourceFilter.filter()
.not().lparen().eq(attr, "Engineer").rparen().build()), manager.getUserName());
assertSingleResult(client.users().getAll(ResourceFilter.filter()
.not().lparen().co(attr, "gine").rparen().build()), manager.getUserName());

ListResponse<User> present = client.users().getAll(ResourceFilter.filter().pr(attr).build());
assertThat(present.getTotalResults(), is(2));
assertSingleResult(client.users().getAll(ResourceFilter.filter()
.not().lparen().pr(attr).rparen().build()), none.getUserName());
}
}

@Test
public void testFilterSingleValuedAttributeWithLongValueNegation() {
// values longer than 255 characters are stored in a separate column of the user attribute table; the user
// still has a value for the attribute, so "ne" and a negated "eq" must both match it
String customSchema = "urn:test:custom:" + KeycloakModelUtils.generateId();
String customAttr = customSchema + ":singleValuedAttr";
addOrReplaceUPAttribute(customSchema, "singleValuedAttr");

String longAttrValue = "x".repeat(300);
User engineer = createUser("engineer");
User longValue = createUser("long");
User none = createUser("none");
setAttributes(engineer, Map.of("scim.singleValuedAttr", List.of("Engineer")));
setAttributes(longValue, Map.of("scim.singleValuedAttr", List.of(longAttrValue)));

assertSingleResult(client.users().getAll(ResourceFilter.filter().ne(customAttr, "Engineer").build()), longValue.getUserName());
assertSingleResult(client.users().getAll(ResourceFilter.filter()
.not().lparen().eq(customAttr, "Engineer").rparen().build()), longValue.getUserName());

assertSingleResult(client.users().getAll(ResourceFilter.filter().eq(customAttr, longAttrValue).build()), longValue.getUserName());
assertSingleResult(client.users().getAll(ResourceFilter.filter().ne(customAttr, longAttrValue).build()), engineer.getUserName());
assertSingleResult(client.users().getAll(ResourceFilter.filter()
.not().lparen().eq(customAttr, longAttrValue).rparen().build()), engineer.getUserName());

assertThat(client.users().getAll(ResourceFilter.filter().pr(customAttr).build()).getTotalResults(), is(2));
assertSingleResult(client.users().getAll(ResourceFilter.filter()
.not().lparen().pr(customAttr).rparen().build()), none.getUserName());

// operators other than eq, ne and pr cannot be evaluated against long values, so a long value matches
// neither the comparison nor its negation
assertNoResults(client.users().getAll(ResourceFilter.filter().co(customAttr, "xxx").build()));
assertSingleResult(client.users().getAll(ResourceFilter.filter()
.not().lparen().co(customAttr, "xxx").rparen().build()), engineer.getUserName());
}

private void setAttributes(User user, Map<String, List<String>> attributes) {
UserRepresentation rep = realm.admin().users().get(user.getId()).toRepresentation();
rep.setAttributes(attributes);
realm.admin().users().get(user.getId()).update(rep);
}

@Test
public void testFilterByEmail() {
User bob = createUser("bob", "Robert", "Johnson", "bob@keycloak.org", true);
Expand Down
Loading
0