From e88be5dbfe6805da516fc73873b5d611d652074e Mon Sep 17 00:00:00 2001 From: Khalid Qarryzada Date: Fri, 2 Oct 2026 12:00:35 -0700 Subject: [PATCH 1/2] Support alternate patch operations without a path This commit adds support for patch operations with an empty "path" field and nested attribute targets within its "value" field. An example JSON has been added to the CHANGELOG. Although this form of patch operation is not defined by the standard, it is used by some SCIM provisioners. Applications that handle requests from these SCIM provisioners may now accept these requests and apply them to SCIM resource objects. This update includes a new maximum array size for the target of a patch operation. The value is 20,000 by default, but may be modified with the "com.unboundid.scim2.common.utils.JsonUtils.maxPatchArraySize" property. Reviewer: dougbulkley Reviewer: vyhhuang JiraIssue: DS-52017 --- CHANGELOG.md | 21 ++ .../java/com/unboundid/scim2/common/Path.java | 20 +- .../scim2/common/messages/PatchOperation.java | 58 ++-- .../scim2/common/utils/FilterEvaluator.java | 5 +- .../scim2/common/utils/JsonUtils.java | 145 +++++++--- .../scim2/common/utils/StaticUtils.java | 28 ++ .../unboundid/scim2/common/DiffTestCase.java | 20 +- .../scim2/common/PatchOpTestCase.java | 255 ++++++++++++++++++ 8 files changed, 466 insertions(+), 86 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8e48b8e8..7590c5a5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 20,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 diff --git a/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/Path.java b/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/Path.java index 3a3f3249..eb887411 100644 --- a/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/Path.java +++ b/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/Path.java @@ -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; @@ -112,9 +113,9 @@ *
  • {@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. - *
  • {@code urn:ietf:ext:example:2.0:User.manager.name}: Targets the "name" + *
  • {@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}). * *

    @@ -179,7 +180,7 @@ *
  • {@link #getElement(int)}: Fetches a specific element within the path. *
  • {@link #getLastElement()}: Fetches the last element within the path. *
  • {@link #replace}: Updates a specific element within the path. - *
  • {@link #subPath}: Removes elements at and after the provided index. + *
  • {@link #subPath}: Extracts elements up to the provided index. *
  • {@link #withoutFilters()}: Returns the path with all filters removed. * */ @@ -447,6 +448,19 @@ public Iterator 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 stream() + { + return elements.stream(); + } + /** * Retrieves the path element at the specified index. * diff --git a/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/messages/PatchOperation.java b/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/messages/PatchOperation.java index 4e8caa62..2c644aea 100644 --- a/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/messages/PatchOperation.java +++ b/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/messages/PatchOperation.java @@ -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 @@ -885,7 +888,6 @@ public int hashCode() } - /** * Create a new patch operation. * @@ -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; } @@ -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. diff --git a/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/utils/FilterEvaluator.java b/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/utils/FilterEvaluator.java index d3d51120..29b33ad4 100644 --- a/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/utils/FilterEvaluator.java +++ b/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/utils/FilterEvaluator.java @@ -417,10 +417,9 @@ private Iterable getCandidateNodes(@NotNull final Path path, { return jsonNode; } - if (jsonNode.isObject()) + if (jsonNode instanceof ObjectNode objectNode) { - List nodes = - JsonUtils.findMatchingPaths(path, (ObjectNode) jsonNode); + List nodes = JsonUtils.findMatchingPaths(path, objectNode); ArrayList flattenedNodes = new ArrayList<>(nodes.size()); for (JsonNode node : nodes) { diff --git a/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/utils/JsonUtils.java b/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/utils/JsonUtils.java index 69f31d29..acd9dd07 100644 --- a/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/utils/JsonUtils.java +++ b/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/utils/JsonUtils.java @@ -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", 20_000); + /** * This represents the base class for handling SCIM JSON data. Subclasses * of this abstract class support actions such as filtering and updating @@ -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; @@ -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 @@ -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 field : valueObject.properties()) { - // Go through the fields of both objects and merge them. - for (Map.Entry 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 diff --git a/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/utils/StaticUtils.java b/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/utils/StaticUtils.java index 9a9faed1..a2c93d44 100644 --- a/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/utils/StaticUtils.java +++ b/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/utils/StaticUtils.java @@ -318,4 +318,32 @@ else if ("false".equalsIgnoreCase(property)) return defaultValue; } + + + /** + * Obtains the value of the provided JVM system property name as an integer. + * If the system property is not defined or cannot be obtained (e.g., due to + * security manager protections), the default value will be used. + * + * @param name The name of the system property. + * @param defaultValue The value that should be used if the property is not + * defined or cannot be fetched. + * + * @return The property value as a integer, if it exists and is obtainable. + */ + public static int getIntProperty(@NotNull final String name, + final int defaultValue) + { + Integer property = null; + try + { + property = Integer.getInteger(name); + } + catch (Throwable t) + { + Debug.debugException(t); + } + + return (property == null) ? defaultValue : property; + } } diff --git a/scim2-sdk-common/src/test/java/com/unboundid/scim2/common/DiffTestCase.java b/scim2-sdk-common/src/test/java/com/unboundid/scim2/common/DiffTestCase.java index 8064b684..66b5942e 100644 --- a/scim2-sdk-common/src/test/java/com/unboundid/scim2/common/DiffTestCase.java +++ b/scim2-sdk-common/src/test/java/com/unboundid/scim2/common/DiffTestCase.java @@ -54,7 +54,6 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; -import static org.assertj.core.api.AssertionsForClassTypes.fail; import static org.testng.Assert.assertEquals; import static org.testng.Assert.assertTrue; @@ -322,24 +321,7 @@ public void testDiffMultiValuedAttribute() throws Exception // matter. JsonNode sourcePhotos = source.remove("photos"); JsonNode targetPhotos = target.remove("photos"); - assertEquals(sourcePhotos.size(), targetPhotos.size()); - for (JsonNode sourceValue : sourcePhotos) - { - boolean found = false; - for (JsonNode targetValue : targetPhotos) - { - if (sourceValue.equals(targetValue)) - { - found = true; - break; - } - } - if (!found) - { - fail("Source photo value " + sourceValue + - " not in target photo array " + targetPhotos); - } - } + assertThat(sourcePhotos).containsExactlyElementsOf(targetPhotos); assertThat(source).isEqualTo(target); } diff --git a/scim2-sdk-common/src/test/java/com/unboundid/scim2/common/PatchOpTestCase.java b/scim2-sdk-common/src/test/java/com/unboundid/scim2/common/PatchOpTestCase.java index cdb451ed..646d91ac 100644 --- a/scim2-sdk-common/src/test/java/com/unboundid/scim2/common/PatchOpTestCase.java +++ b/scim2-sdk-common/src/test/java/com/unboundid/scim2/common/PatchOpTestCase.java @@ -59,6 +59,7 @@ import java.util.List; import static com.unboundid.scim2.common.exceptions.BadRequestException.INVALID_PATH; +import static com.unboundid.scim2.common.utils.JsonUtils.MAX_PATCH_ARRAY_SIZE; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatCode; import static org.assertj.core.api.Assertions.assertThatThrownBy; @@ -782,6 +783,7 @@ public void testEmptyValues() throws Exception PatchOperation.create(PatchOpType.REPLACE, "myArray", EMPTY_ARRAY); } + /** * This test validates the behavior of patch operations when the value is an * empty array. When a patch operations sets the {@code value} field to an @@ -990,6 +992,7 @@ public void testVarArgs() throws Exception assertEquals(operation, operation2); } + /** * Tests for {@link PatchOperation#applyToResource} and * {@link PatchRequest#applyToResource}. @@ -1066,4 +1069,256 @@ public void testApplyingScimResources() throws Exception .isEqualTo(complexUser) .isEqualTo(unchangedAgain2); } + + + /** + * Tests the behavior of patch operations that satisfy two pieces of criteria: + * operations that do not contain a path, and also includes nested paths + * within its {@code value} such as {@code name.familyName}. + */ + @Test + public void testNestedFieldNoPath() throws Exception + { + // This test creates many patch requests with complex JSON values. From a + // test readability perspective, it is better to create these with strings. + var patchReader = JsonUtils.getObjectReader().forType(PatchRequest.class); + + PatchRequest basicCase = patchReader.readValue(""" + { + "schemas": [ "urn:ietf:params:scim:api:messages:2.0:PatchOp" ], + "Operations": [ { + "op": "add", + "value": { + "userName": "smile", + "name.familyName": "Memory", + "name.givenName": "Favorite" + } + } ] + }"""); + + // Verify the patch request result with the object node to be certain that + // the values are nested. + var gsr = basicCase.applyToResource(new GenericScimResource()); + assertThat(gsr.getObjectNode().path("name").path("givenName").asString()) + .isEqualTo("Favorite"); + assertThat(gsr.getObjectNode().path("name").path("familyName").asString()) + .isEqualTo("Memory"); + assertThat(gsr.getStringValue("userName")).isEqualTo("smile"); + + // Apply the same patch request against a UserResource object. + UserResource updatedUser = basicCase.applyToResource(new UserResource()); + assertThat(updatedUser.getUserName()).isEqualTo("smile"); + assertThat(updatedUser.getName()) + .isEqualTo(new Name().setGivenName("Favorite").setFamilyName("Memory")); + + // Attempt the same patch request against a user with some pre-filled data. + UserResource existingUser = new UserResource() + .setName(new Name().setFamilyName("Melody")); + UserResource existingUpdated = basicCase.applyToResource(existingUser); + assertThat(existingUpdated.getUserName()).isEqualTo("smile"); + assertThat(existingUpdated.getName()) + .isEqualTo(new Name().setGivenName("Favorite").setFamilyName("Memory")); + + // Create a patch request with multiple fields of nesting. Constructing this + // instance is permitted, but applying it is not. + PatchRequest tooDeep = patchReader.readValue(""" + { + "schemas": [ "urn:ietf:params:scim:api:messages:2.0:PatchOp" ], + "Operations": [ { + "op": "replace", + "value": { + "one.two.three.four": "turn" + } + } ] + }"""); + + // Apply the update to an empty resource. + assertThatThrownBy(() -> tooDeep.applyToResource(new GenericScimResource())) + .isInstanceOf(BadRequestException.class) + .hasMessageContaining("Path cannot target sub-attributes more than") + .hasMessageContaining("one level deep"); + + // Test the case where the patch request is applied on an invalid resource. + PatchRequest nested = patchReader.readValue(""" + { + "schemas": [ "urn:ietf:params:scim:api:messages:2.0:PatchOp" ], + "Operations": [ { + "op": "replace", + "value": { + "both.know": "this" + } + } ] + }"""); + String resourceJson = """ + { + "both": [ "This field should be an object." ] + }"""; + GenericScimResource invalidResource = JsonUtils.getObjectReader() + .forType(GenericScimResource.class).readValue(resourceJson); + assertThatThrownBy(() -> nested.applyToResource(invalidResource)) + .isInstanceOf(BadRequestException.class) + .hasMessage("The 'both.know' path led to a nested non-object node."); + + // Using a filter for a path within 'value' should not be permitted. + PatchRequest filteredPath = patchReader.readValue(""" + { + "schemas": [ "urn:ietf:params:scim:api:messages:2.0:PatchOp" ], + "Operations": [ { + "op": "add", + "value": { + "on[type eq \\"show\\"].for": "you" + } + } ] + }"""); + assertThatThrownBy(() -> filteredPath.applyToResource(new UserResource())) + .isInstanceOf(BadRequestException.class) + .hasMessage("Paths declared in the 'value' field cannot have filters."); + + // A filter should also not be allowed for the second element in a path. + PatchRequest filteredPath2 = patchReader.readValue(""" + { + "schemas": [ "urn:ietf:params:scim:api:messages:2.0:PatchOp" ], + "Operations": [ { + "op": "replace", + "value": { + "it.goes[value eq \\"hundred\\"]": "four" + } + } ] + }"""); + assertThatThrownBy(() -> filteredPath2.applyToResource(new UserResource())) + .isInstanceOf(BadRequestException.class) + .hasMessage("Paths declared in the 'value' field cannot have filters."); + + // Ensure the previous case is still supported properly on an explicit path. + PatchRequest nestedRequest = patchReader.readValue(""" + { + "schemas": [ "urn:ietf:params:scim:api:messages:2.0:PatchOp" ], + "Operations": [ { + "path": "it.goes[value eq \\"hundred\\"]", + "op": "replace", + "value": "four" + } ] + }"""); + String nestedResourceJson = """ + { + "it": { + "goes": [ "one", "two", "three", "hundred" ] + } + }"""; + GenericScimResource nestedResource = JsonUtils.getObjectReader() + .forType(GenericScimResource.class).readValue(nestedResourceJson); + nestedResource = nestedRequest.applyToResource(nestedResource); + assertThat(nestedResource.getStringValueList("it.goes")) + .contains("four") + .doesNotContain("hundred"); + + // Ensure an extension object is still processed normally. + PatchRequest qualifiedPath = patchReader.readValue(""" + { + "schemas": [ "urn:ietf:params:scim:api:messages:2.0:PatchOp" ], + "Operations": [ { + "op": "add", + "value": { + "urn:ietf:watch:all": { + "extObject": "face" + } + } + } ] + }"""); + UserResource expectedUser = JsonUtils.getObjectReader() + .forType(UserResource.class).readValue(""" + { + "schemas": [ + "urn:ietf:params:scim:schemas:core:2.0:User", + "urn:ietf:watch:all" + ], + "urn:ietf:watch:all": { + "extObject": "face" + } + }"""); + assertThat(qualifiedPath.applyToResource(new UserResource())) + .isEqualTo(expectedUser); + + // Ensure that a replace operation with a 'null' value properly deletes the + // parent node if all subordinate fields were removed. + UserResource existing = new UserResource() + .setName(new Name().setGivenName("Melody")); + PatchRequest replaceNull = patchReader.readValue(""" + { + "schemas": [ "urn:ietf:params:scim:api:messages:2.0:PatchOp" ], + "Operations": [ { + "op": "replace", + "value": { + "name.givenName": null + } + } ] + }"""); + assertThat(replaceNull.applyToResource(existing)) + .isEqualTo(new UserResource()); + } + + + /** + * Tests that adding an excessive number of elements to an array is not + * permitted past a defined threshold. + */ + @Test + public void testAppendLargeArray() throws Exception + { + final int limit = MAX_PATCH_ARRAY_SIZE; + + // Initialize a resource with an existing array almost at max capacity. + String resourceJson = """ + { + "arrayField": [] + }"""; + GenericScimResource resource = JsonUtils.getObjectReader() + .forType(GenericScimResource.class).readValue(resourceJson); + var node = JsonUtils.getJsonNodeFactory().arrayNode(); + for (int i = 0; i < limit - 1; i++) + { + node.add("newValue"); + } + resource.replaceValue("arrayField", node); + assertThat(resource.getValue("arrayField")).hasSize(limit - 1); + + // Apply a patch request that adds one field. This should be permitted. + String json = """ + { + "schemas": [ "urn:ietf:params:scim:api:messages:2.0:PatchOp" ], + "Operations": [ { + "op": "add", + "value": { + "arrayField": [ "lastValue" ] + } + } ] + }"""; + PatchRequest request = JsonUtils.getObjectReader() + .forType(PatchRequest.class).readValue(json); + request.apply(resource); + assertThat(resource.getValue("arrayField")).hasSize(limit); + + // Attempt to exceed the threshold by re-attempting the request. This should + // throw an exception even for a value that is already on the resource. + assertThatThrownBy(() -> request.apply(resource)) + .isInstanceOf(BadRequestException.class) + .hasMessageContaining("update would have exceeded the max array size"); + + // Attempt to exceed the threshold again by using a patch operation that has + // an explicit path value. + var opWithPath = PatchOperation.addStringValues("arrayField", "exceeds"); + assertThatThrownBy(() -> opWithPath.applyToResource(resource)) + .isInstanceOf(BadRequestException.class) + .hasMessageContaining("update would have exceeded the max array size"); + + // Replace operations should still apply here for consistency across all + // PATCH operations. If a replace operation were always permitted, then any + // subsequent ADD operation to the array would be rejected. + node.add("anotherString1").add("anotherString2"); + assertThat(node).hasSizeGreaterThan(limit); + PatchOperation replace = PatchOperation.replace("arrayField", node); + assertThatThrownBy(() -> replace.applyToResource(new GenericScimResource())) + .isInstanceOf(BadRequestException.class) + .hasMessageContaining("update would have exceeded the max array size"); + } } From 127aa16afc7885b8eb14ca72a956865186b3c4cc Mon Sep 17 00:00:00 2001 From: Khalid Qarryzada Date: Fri, 2 Oct 2026 13:40:05 -0700 Subject: [PATCH 2/2] Reset limit. --- CHANGELOG.md | 2 +- .../main/java/com/unboundid/scim2/common/utils/JsonUtils.java | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7590c5a5..07123077 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -85,7 +85,7 @@ SCIM provisioning engines. For example: ``` Added a maximum array size threshold for the target of a patch operation. Now, PATCH updates that -would result in over 20,000 elements within an array will throw a `BadRequestException`. This +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. diff --git a/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/utils/JsonUtils.java b/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/utils/JsonUtils.java index acd9dd07..f06637d6 100644 --- a/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/utils/JsonUtils.java +++ b/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/utils/JsonUtils.java @@ -83,7 +83,7 @@ public class JsonUtils * @since 6.1.0 */ public static int MAX_PATCH_ARRAY_SIZE = StaticUtils.getIntProperty( - "com.unboundid.scim2.common.utils.JsonUtils.maxPatchArraySize", 20_000); + "com.unboundid.scim2.common.utils.JsonUtils.maxPatchArraySize", 25_000); /** * This represents the base class for handling SCIM JSON data. Subclasses