Skip to content
Merged
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
21 changes: 21 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,27 @@ these, there is no change to make.
JAX-B was removed in 5.0.0, but GMT support was kept with a system property for legacy uses. For
consistency, we intend to remove this property, as UTC has been in use by default since 6.0.0.

Added support for applying patch operations that a) do not contain a `path`, and b) also include
nested paths within its `value`. These are not defined by the standard, but are still used by some
SCIM provisioning engines. For example:
```json
{
"schemas": [ "urn:ietf:params:scim:api:messages:2.0:PatchOp" ],
"Operations": [ {
"op": "add",
"value": {
"name.givenName": "Melody"
}
} ]
}
```

Added a maximum array size threshold for the target of a patch operation. Now, PATCH updates that
would result in over 25,000 elements within an array will throw a `BadRequestException`. This
value is controlled by the `com.unboundid.scim2.common.utils.JsonUtils.maxPatchArraySize` property.

Added a `Path.stream()` method to allow access to elements of a path with the Java Stream API.

## 6.0.0 - 2026-May-11
The UnboundID SCIM SDK has been updated to use version 3 of the Jackson library (this release ships
with v3.1.3). This change aligns the SCIM SDK with HTTP libraries such as Spring Framework 7/Spring
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@
import java.util.Iterator;
import java.util.List;
import java.util.Objects;
import java.util.stream.Stream;

import static com.unboundid.scim2.common.utils.StaticUtils.toLowerCase;

Expand Down Expand Up @@ -112,9 +113,9 @@
* <li> {@code name.givenName}: Targets the "givenName" sub-attribute that is
* nested beneath the complex "name" attribute. This is a path with two
* elements and no filter values.
* <li> {@code urn:ietf:ext:example:2.0:User.manager.name}: Targets the "name"
* <li> {@code urn:ietf:ext:example:2.0:User:manager.name}: Targets the "name"
* sub-attribute stored within the schema extension. Note that schema
* extensions are not accounted for path sizes, so this is a path with
* extensions are considered part of the "root", so this is a path with
* two elements ({@code manager} and {@code name}).
* </ul>
* <br><br>
Expand Down Expand Up @@ -179,7 +180,7 @@
* <li> {@link #getElement(int)}: Fetches a specific element within the path.
* <li> {@link #getLastElement()}: Fetches the last element within the path.
* <li> {@link #replace}: Updates a specific element within the path.
* <li> {@link #subPath}: Removes elements at and after the provided index.
* <li> {@link #subPath}: Extracts elements up to the provided index.
* <li> {@link #withoutFilters()}: Returns the path with all filters removed.
* </ul>
*/
Expand Down Expand Up @@ -447,6 +448,19 @@ public Iterator<Element> iterator()
return elements.iterator();
}

/**
* Returns a stream of all elements within this path reference. Note that
* this does not contain the root node.
*
* @return A stream containing all elements within this path.
* @since 6.1.0
*/
@NotNull
public Stream<Element> stream()
{
return elements.stream();
}

/**
* Retrieves the path element at the specified index.
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,9 @@ public abstract class PatchOperation
@Nullable
private final Path path;

@NotNull
private static final Path VALUE = Path.of("value");


/**
* This class represents a SCIM 2 {@code add} patch operation as defined by
Expand Down Expand Up @@ -885,7 +888,6 @@ public int hashCode()
}



/**
* Create a new patch operation.
*
Expand All @@ -894,29 +896,7 @@ public int hashCode()
*/
PatchOperation(@Nullable final Path path) throws ScimException
{
if (path != null)
{
if (path.size() > 2)
{
throw BadRequestException.invalidPath(
"Path cannot target sub-attributes more than one level deep");
}

if (path.size() == 2)
{
Filter valueFilter = path.getElement(1).getValueFilter();
// Allow use of the special case "value" path to reference the value
// itself. Any other value filter is for a sub-attribute, which is not
// permitted.
if (valueFilter != null &&
!valueFilter.getAttributePath().getElement(0)
.getAttribute().equals("value"))
{
throw BadRequestException.invalidPath(
"Path cannot include a value filter on sub-attributes");
}
}
}
validateOperationPath(path);
this.path = path;
}

Expand Down Expand Up @@ -2391,6 +2371,36 @@ public static PatchOperation create(@NotNull final PatchOpType opType,
}


/**
* Validates an attribute path that targets a piece of JSON data.
*
* @param path The provided path.
* @throws ScimException If the path is invalid.
*/
public static void validateOperationPath(@Nullable final Path path)
throws ScimException
{
if (path == null || path.size() <= 1)
{
return;
}
if (path.size() > 2)
{
throw BadRequestException.invalidPath(
"Path cannot target sub-attributes more than one level deep");
}

// Allow the "value" path to reference the value itself. Any other filter
// targets a sub-attribute. See FilterEvaluator#getCandidateNodes.
Filter valueFilter = path.getElement(1).getValueFilter();
if (valueFilter != null && !VALUE.equals(valueFilter.getAttributePath()))
{
throw BadRequestException.invalidPath(
"Path cannot include a value filter on sub-attributes");
}
}


/**
* Validates the {@code value} of a patch operation when the operation is
* constructed.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -417,10 +417,9 @@ private Iterable<JsonNode> getCandidateNodes(@NotNull final Path path,
{
return jsonNode;
}
if (jsonNode.isObject())
if (jsonNode instanceof ObjectNode objectNode)
{
List<JsonNode> nodes =
JsonUtils.findMatchingPaths(path, (ObjectNode) jsonNode);
List<JsonNode> nodes = JsonUtils.findMatchingPaths(path, objectNode);
ArrayList<JsonNode> flattenedNodes = new ArrayList<>(nodes.size());
for (JsonNode node : nodes)
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,17 @@ public class JsonUtils
@NotNull
private static JsonMapper SDK_OBJECT_MAPPER = createJsonMapper();

/**
* This property defines the maximum array size that is permitted on a SCIM
* resource when processing a PATCH request. In addition to connection
* timeouts, this property helps avoid excessive time computing uniqueness
* when adding new fields to an array stored on a SCIM resource.
*
* @since 6.1.0
*/
public static int MAX_PATCH_ARRAY_SIZE = StaticUtils.getIntProperty(
"com.unboundid.scim2.common.utils.JsonUtils.maxPatchArraySize", 25_000);

/**
* This represents the base class for handling SCIM JSON data. Subclasses
* of this abstract class support actions such as filtering and updating
Expand Down Expand Up @@ -410,10 +421,10 @@ else if (isDelete && attributeValue.isObject())
// If we have pruned all remaining values on the field, then we should
// completely delete the attribute. For example, if a resource only has a
// single email that is deleted, the 'emails' field in the JsonNode should
// explicitly be set to null.
// explicitly be removed.
if (array.isEmpty())
{
updateNode(parent, field, NullNode.getInstance());
parent.remove(field);
}

return nodeUpdated;
Expand All @@ -426,10 +437,14 @@ else if (isDelete && attributeValue.isObject())
* @param parent The container node.
* @param key The key of the field to update.
* @param value The update value.
*
* @throws ScimException If an error occurred when processing the update,
* such as an invalid field name being used.
*/
protected void updateNode(@NotNull final ObjectNode parent,
@Nullable final String key,
@Nullable final JsonNode value)
throws ScimException
{
// RFC 7643 section 2.5 states:
// "Unassigned attributes, the null value, or empty array (in the case of
Expand All @@ -450,56 +465,112 @@ protected void updateNode(@NotNull final ObjectNode parent,

// When key is null, the node to update is the parent itself.
JsonNode node = key == null ? parent : parent.path(key);
if (node instanceof ObjectNode targetObject)
if (node instanceof ObjectNode targetObject
&& value instanceof ObjectNode valueObject)
{
if (value instanceof ObjectNode valueObject)
// Go through the fields of both objects and merge them.
for (Map.Entry<String, JsonNode> field : valueObject.properties())
{
// Go through the fields of both objects and merge them.
for (Map.Entry<String, JsonNode> field : valueObject.properties())
{
updateNode(targetObject, field.getKey(), field.getValue());
}
}
else
{
// Replace the field.
parent.set(key, value);
updateWithNesting(targetObject, field.getKey(), field.getValue());
}
}
else if (node instanceof ArrayNode targetArray)
else if (appendValues && node instanceof ArrayNode targetArray
&& value instanceof ArrayNode valueArray)
{
if (appendValues && value instanceof ArrayNode valueArray)
// Merge the two arrays without a set to avoid GC. Avoid too many
// uniqueness evaluations, even if the valueArray has duplicates.
validatePatchArraySize(targetArray, valueArray);
for (JsonNode valueNode : valueArray)
{
// Append the new values to the existing ones.
for (JsonNode valueNode : valueArray)
if (targetArray.valueStream().noneMatch(valueNode::equals))
{
boolean valueFound = false;
for (JsonNode targetNode : targetArray)
{
if (valueNode.equals(targetNode))
{
valueFound = true;
break;
}
}
if (!valueFound)
{
targetArray.add(valueNode);
}
targetArray.add(valueNode);
}
}
else
{
// Replace the field.
parent.set(key, value);
}
}
else
{
// Replace the field.
// For all other scenarios, replace the field directly.
validatePatchArraySize(value);
parent.set(key, value);
}
}

/**
* Updates the target object with the provided field and value. In addition
* to the standard case where the {@code field} is a simple attribute path
* (e.g., {@code userName}), this method handles the case in which the field
* represents a nested path (e.g., {@code name.familyName}).
*
* @param targetObject The JsonNode that should be updated.
* @param field The field name, which represents the attribute path.
* @param value The value that should be assigned to the field.
*
* @throws ScimException If an error occurred when processing the update.
*/
private void updateWithNesting(@NotNull final ObjectNode targetObject,
@NotNull final String field,
@NotNull final JsonNode value)
throws ScimException
{
Path path = Path.fromString(field);
PatchOperation.validateOperationPath(path);

// When path data comes from 'value', do not attempt to support filters.
if (path.stream().anyMatch(e -> e.getValueFilter() != null))
{
throw BadRequestException.invalidValue(
"Paths declared in the 'value' field cannot have filters.");
}

if (path.size() <= 1)
{
// Handle the update normally.
updateNode(targetObject, field, value);
return;
}

// Create the parent node if it does not exist (e.g., the patch operation
// targets a completely empty resource).
String parentAttr = path.getElement(0).getAttribute();
if (targetObject.path(parentAttr).isMissingNode())
{
targetObject.set(parentAttr, getJsonNodeFactory().objectNode());
}

// Fetch the parent node (e.g., the "name" from a "name.familyName" path).
JsonNode parentNode = targetObject.path(parentAttr);
if (!(parentNode instanceof ObjectNode parent))
{
throw BadRequestException.invalidValue(
"The '" + field + "' path led to a nested non-object node.");
}

updateNode(parent, path.getLastElement().getAttribute(), value);

// After the update, prune the parent node if it is empty.
if (targetObject.path(parentAttr).isEmpty())
{
targetObject.remove(parentAttr);
}
}

private void validatePatchArraySize(@NotNull final JsonNode... nodes)
throws ScimException
{
int arraySize = 0;
for (JsonNode node : nodes)
{
arraySize += (node.isArray()) ? node.size() : 0;
}

if (arraySize > MAX_PATCH_ARRAY_SIZE)
{
throw BadRequestException.invalidValue(
"The update would have exceeded the max array size: "
+ MAX_PATCH_ARRAY_SIZE);
}
}
}

private static class PathExistsVisitor extends NodeVisitor
Expand Down
Loading
Loading