From 03ac46d6ed8d2393d6bdbd559ff84e29822182e1 Mon Sep 17 00:00:00 2001 From: Khalid Qarryzada Date: Fri, 11 Sep 2026 16:42:25 -0500 Subject: [PATCH 1/4] Support Device Schema Extensions (RFC 9944) Added model classes, extensions, and new fields as defined by RFC 9944. This RFC describes an update to the SCIM standard for managing user IoT devices, and the bulk of these changes have been placed in a new, dedicated "devices" package. The DeviceResource class-level Javadoc contains an introduction to device resources and how to leverage them with this SDK. This change includes support in scim2-sdk-server for evaluating device resource and related object compliance with the SchemaChecker. Note that explicit client changes were not required, as the DeviceResource class can leverage the existing client APIs like any ScimResource object. The release version has been set to 6.1.0-SNAPSHOT. Reviewer: dougbulkley Reviewer: vyhhuang JiraIssue: DS-51992 --- pom.xml | 2 +- scim2-assembly/pom.xml | 2 +- scim2-sdk-client/pom.xml | 2 +- scim2-sdk-common/pom.xml | 2 +- .../scim2/common/BaseScimResource.java | 40 +- .../scim2/common/GenericScimResource.java | 9 - .../unboundid/scim2/common/ScimResource.java | 7 +- .../scim2/common/annotations/Attribute.java | 8 + .../common/types/AttributeDefinition.java | 112 ++- .../unboundid/scim2/common/types/Group.java | 41 +- .../scim2/common/types/GroupResource.java | 9 +- .../unboundid/scim2/common/types/Manager.java | 2 +- .../unboundid/scim2/common/types/Member.java | 4 +- .../types/devices/BleDeviceExtension.java | 539 +++++++++++ .../types/devices/BlePairingJustWorks.java | 114 +++ .../types/devices/BlePairingMethod.java | 70 ++ .../common/types/devices/BlePairingNull.java | 87 ++ .../types/devices/BlePairingOutOfBand.java | 223 +++++ .../types/devices/BlePairingPassKey.java | 169 ++++ .../common/types/devices/CertificateInfo.java | 184 ++++ .../common/types/devices/DeviceExtension.java | 65 ++ .../common/types/devices/DeviceResource.java | 424 +++++++++ .../types/devices/DppDeviceExtension.java | 392 ++++++++ .../devices/EndpointAppDeviceExtension.java | 358 +++++++ .../types/devices/EndpointAppReference.java | 201 ++++ .../types/devices/EndpointAppResource.java | 356 +++++++ .../devices/EthernetMabDeviceExtension.java | 164 ++++ .../types/devices/FdoDeviceExtension.java | 194 ++++ .../types/devices/ZigbeeDeviceExtension.java | 224 +++++ .../common/types/devices/package-info.java | 38 + .../scim2/common/utils/JsonUtils.java | 57 ++ .../scim2/common/utils/SchemaUtils.java | 83 +- .../common/types/AttributeDefinitionTest.java | 18 +- .../common/types/devices/DevicesTest.java | 871 ++++++++++++++++++ scim2-sdk-server/pom.xml | 2 +- .../server/utils/ResourceTypeDefinition.java | 1 - .../scim2/server/utils/SchemaChecker.java | 107 +-- .../scim2/server/DeviceResourceEndpoint.java | 101 ++ .../server/EndpointAppResourceEndpoint.java | 86 ++ .../scim2/server/EndpointTestCase.java | 106 ++- .../utils/Rfc9944SchemaCheckerTestCase.java | 394 ++++++++ .../server/utils/SchemaCheckerTestCase.java | 5 + scim2-ubid-extensions/pom.xml | 2 +- 43 files changed, 5710 insertions(+), 165 deletions(-) create mode 100644 scim2-sdk-common/src/main/java/com/unboundid/scim2/common/types/devices/BleDeviceExtension.java create mode 100644 scim2-sdk-common/src/main/java/com/unboundid/scim2/common/types/devices/BlePairingJustWorks.java create mode 100644 scim2-sdk-common/src/main/java/com/unboundid/scim2/common/types/devices/BlePairingMethod.java create mode 100644 scim2-sdk-common/src/main/java/com/unboundid/scim2/common/types/devices/BlePairingNull.java create mode 100644 scim2-sdk-common/src/main/java/com/unboundid/scim2/common/types/devices/BlePairingOutOfBand.java create mode 100644 scim2-sdk-common/src/main/java/com/unboundid/scim2/common/types/devices/BlePairingPassKey.java create mode 100644 scim2-sdk-common/src/main/java/com/unboundid/scim2/common/types/devices/CertificateInfo.java create mode 100644 scim2-sdk-common/src/main/java/com/unboundid/scim2/common/types/devices/DeviceExtension.java create mode 100644 scim2-sdk-common/src/main/java/com/unboundid/scim2/common/types/devices/DeviceResource.java create mode 100644 scim2-sdk-common/src/main/java/com/unboundid/scim2/common/types/devices/DppDeviceExtension.java create mode 100644 scim2-sdk-common/src/main/java/com/unboundid/scim2/common/types/devices/EndpointAppDeviceExtension.java create mode 100644 scim2-sdk-common/src/main/java/com/unboundid/scim2/common/types/devices/EndpointAppReference.java create mode 100644 scim2-sdk-common/src/main/java/com/unboundid/scim2/common/types/devices/EndpointAppResource.java create mode 100644 scim2-sdk-common/src/main/java/com/unboundid/scim2/common/types/devices/EthernetMabDeviceExtension.java create mode 100644 scim2-sdk-common/src/main/java/com/unboundid/scim2/common/types/devices/FdoDeviceExtension.java create mode 100644 scim2-sdk-common/src/main/java/com/unboundid/scim2/common/types/devices/ZigbeeDeviceExtension.java create mode 100644 scim2-sdk-common/src/main/java/com/unboundid/scim2/common/types/devices/package-info.java create mode 100644 scim2-sdk-common/src/test/java/com/unboundid/scim2/common/types/devices/DevicesTest.java create mode 100644 scim2-sdk-server/src/test/java/com/unboundid/scim2/server/DeviceResourceEndpoint.java create mode 100644 scim2-sdk-server/src/test/java/com/unboundid/scim2/server/EndpointAppResourceEndpoint.java create mode 100644 scim2-sdk-server/src/test/java/com/unboundid/scim2/server/utils/Rfc9944SchemaCheckerTestCase.java diff --git a/pom.xml b/pom.xml index f3e3fdc4..d2a8f860 100644 --- a/pom.xml +++ b/pom.xml @@ -33,7 +33,7 @@ 4.0.0 com.unboundid.product.scim2 scim2-parent - 6.0.1-SNAPSHOT + 6.1.0-SNAPSHOT pom UnboundID SCIM2 SDK Parent diff --git a/scim2-assembly/pom.xml b/scim2-assembly/pom.xml index 8b6fb9ca..4f748348 100644 --- a/scim2-assembly/pom.xml +++ b/scim2-assembly/pom.xml @@ -34,7 +34,7 @@ com.unboundid.product.scim2 scim2-parent - 6.0.1-SNAPSHOT + 6.1.0-SNAPSHOT ../pom.xml scim2-assembly diff --git a/scim2-sdk-client/pom.xml b/scim2-sdk-client/pom.xml index 635dcaf5..ae7d9c22 100644 --- a/scim2-sdk-client/pom.xml +++ b/scim2-sdk-client/pom.xml @@ -34,7 +34,7 @@ com.unboundid.product.scim2 scim2-parent - 6.0.1-SNAPSHOT + 6.1.0-SNAPSHOT ../pom.xml scim2-sdk-client diff --git a/scim2-sdk-common/pom.xml b/scim2-sdk-common/pom.xml index 3970218e..03679406 100644 --- a/scim2-sdk-common/pom.xml +++ b/scim2-sdk-common/pom.xml @@ -34,7 +34,7 @@ com.unboundid.product.scim2 scim2-parent - 6.0.1-SNAPSHOT + 6.1.0-SNAPSHOT ../pom.xml scim2-sdk-common diff --git a/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/BaseScimResource.java b/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/BaseScimResource.java index 5205f3a4..0e4a2e5d 100644 --- a/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/BaseScimResource.java +++ b/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/BaseScimResource.java @@ -49,6 +49,7 @@ import tools.jackson.databind.JsonNode; import tools.jackson.databind.node.ObjectNode; +import java.util.ArrayList; import java.util.Collection; import java.util.HashMap; import java.util.LinkedHashSet; @@ -57,8 +58,6 @@ import java.util.Objects; import java.util.Set; -import static com.unboundid.scim2.common.utils.StaticUtils.toList; - /** *

The base SCIM object. This object contains all of the * attributes required of SCIM objects.

@@ -223,15 +222,6 @@ public void setSchemaUrns(@NotNull final Collection schemaUrns) this.schemaUrns = new LinkedHashSet<>(schemaUrns); } - /** - * {@inheritDoc} - */ - public void setSchemaUrns(@NotNull final String schemaUrn, - @Nullable final String... schemaUrns) - { - setSchemaUrns(toList(schemaUrn, schemaUrns)); - } - /** * This method is used by Jackson when deserializing JSON data into a Java * object. This will be called for schema extensions and any unknown fields @@ -388,6 +378,34 @@ public T getExtension(@NotNull final Class clazz) return (ext == null) ? null : JsonUtils.nodeToValue(ext, clazz); } + /** + * This utility method extracts the requested classes from the extension + * object node, and returns each existing reference as a POJO. + * + * @param The data type of the elements in the return list. + * @param clazzList The list of classes to try obtaining from the ObjectNode. + * All classes should have the {@link Schema} annotation. + * + * @return The requested list of Java objects stored on the extension node. + */ + @NotNull + protected List getClassesFromExtension( + @NotNull final List> clazzList) + { + List extensions = new ArrayList<>(); + for (var clazz : clazzList) + { + // Calling get() on the extension object node is a map lookup. + T extension = getExtension(clazz); + if (extension != null) + { + extensions.add(extension); + } + } + + return extensions; + } + /** * Sets a SCIM extension to the given value based on the annotations * of the class provided. The value will be set for an extension named diff --git a/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/GenericScimResource.java b/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/GenericScimResource.java index 6be225d2..02ac45e6 100644 --- a/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/GenericScimResource.java +++ b/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/GenericScimResource.java @@ -294,15 +294,6 @@ public void setSchemaUrns(@NotNull final Collection schemaUrns) } } - /** - * {@inheritDoc} - */ - public void setSchemaUrns(@NotNull final String schemaUrn, - @Nullable final String... schemaUrns) - { - setSchemaUrns(toList(schemaUrn, schemaUrns)); - } - /** * {@inheritDoc} */ diff --git a/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/ScimResource.java b/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/ScimResource.java index ef6c02c9..3b5fd516 100644 --- a/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/ScimResource.java +++ b/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/ScimResource.java @@ -35,6 +35,7 @@ import com.unboundid.scim2.common.annotations.NotNull; import com.unboundid.scim2.common.annotations.Nullable; import com.unboundid.scim2.common.types.Meta; +import com.unboundid.scim2.common.utils.StaticUtils; import java.util.Collection; @@ -140,7 +141,11 @@ public interface ScimResource * @param schemaUrns An optional parameter for additional schema URNs. Any * {@code null} values will be ignored. */ - void setSchemaUrns(@NotNull String schemaUrn, @Nullable String... schemaUrns); + default void setSchemaUrns(@NotNull String schemaUrn, + @Nullable String... schemaUrns) + { + setSchemaUrns(StaticUtils.toList(schemaUrn, schemaUrns)); + } /** * Returns the GenericScimResource representation of this ScimResource. If diff --git a/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/annotations/Attribute.java b/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/annotations/Attribute.java index 8b72cde9..98d42cad 100644 --- a/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/annotations/Attribute.java +++ b/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/annotations/Attribute.java @@ -124,4 +124,12 @@ AttributeDefinition.Mutability mutability() * @return For a multi-valued attribute, the type of the child object. */ @NotNull Class multiValueClass() default NullType.class; + + /** + * An optional regular expression that string-typed values must match. + * + * @return The regular expression, or an empty string for no constraint. + * @since 6.1.0 + */ + @NotNull String pattern() default ""; } diff --git a/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/types/AttributeDefinition.java b/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/types/AttributeDefinition.java index 4836bde0..dd6f66a2 100644 --- a/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/types/AttributeDefinition.java +++ b/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/types/AttributeDefinition.java @@ -40,6 +40,8 @@ import com.unboundid.scim2.common.annotations.Nullable; import com.unboundid.scim2.common.exceptions.BadRequestException; import com.unboundid.scim2.common.messages.SearchRequest; +import com.unboundid.scim2.common.types.devices.DeviceResource; +import com.unboundid.scim2.common.types.devices.EndpointAppDeviceExtension; import com.unboundid.scim2.common.utils.JsonUtils; import java.util.Arrays; @@ -161,7 +163,7 @@ public enum Type DECIMAL("decimal"), /** - * Integer datatype. + * A numeric integer value. This can be more than 32 bits. */ INTEGER("integer"), @@ -432,7 +434,31 @@ public enum Uniqueness /** * Indicates that this attribute's value must be globally unique. */ - GLOBAL("global"); + GLOBAL("global"), + + /** + * Indicates that this attribute's value is enforced as unique by a device + * manufacturer. This is used in the context of a {@link DeviceResource}. + * + * @since 6.1.0 + */ + MANUFACTURER("manufacturer"), + + /** + * Indicates that this attribute's value is enforced as unique by a separate + * system. This is generally an identity gateway or provisioning service + * that is linked to a SCIM service. For example, an identity platform may + * wish to manage their users in a SCIM cloud service, but store passwords + * and other sensitive data in separate on-premise infrastructure. This + * data's uniqueness can be managed outside the SCIM service. + *

+ * + * This value is generally used with an {@link EndpointAppDeviceExtension}. + * + * @since 6.1.0 + */ + ENTERPRISE("enterprise"), + ; @NotNull private final String name; @@ -502,6 +528,22 @@ public static Uniqueness fromName(@Nullable final String name) uniqueness = AttributeDefinition.Uniqueness.NONE) private final Type type; + /** + * An optional regular expression that all values of this attribute must + * match. This is only used for string-typed attributes. For example, for an + * expression of {@code ^[1-9]}, the value of the string attribute must begin + * with a digit 1 through 9. + */ + @Nullable + @Attribute(description = + "An optional regex that describes the form of the attribute value.", + isRequired = false, + isCaseExact = true, + mutability = AttributeDefinition.Mutability.READ_ONLY, + returned = AttributeDefinition.Returned.DEFAULT, + uniqueness = AttributeDefinition.Uniqueness.NONE) + private final String pattern; + @Nullable @Attribute(description = "When an attribute is of type \"complex\", " + "\"subAttributes\" defines set of sub-attributes.", @@ -686,6 +728,12 @@ public static class Builder @Nullable private Collection referenceTypes; + /** + * An optional regular expression constraint for string-typed attributes. + */ + @Nullable + private String pattern; + /** * Create a new builder. */ @@ -879,6 +927,21 @@ public Builder addReferenceTypes(@Nullable final String... referenceTypes) return this; } + /** + * Sets the regular expression constraint for string-typed attributes. + * + * @param pattern The regular expression, or {@code null} for no constraint. + * @return This builder instance. + * + * @since 6.1.0 + */ + @NotNull + public Builder setPattern(@Nullable final String pattern) + { + this.pattern = (pattern == null || pattern.isEmpty()) ? null : pattern; + return this; + } + /** * Clears all values in this builder back to the default. * @@ -900,6 +963,7 @@ public Builder clear() description = null; canonicalValues = null; referenceTypes = null; + this.pattern = null; return this; } @@ -924,7 +988,8 @@ public AttributeDefinition build() mutability, returned, uniqueness, - referenceTypes); + referenceTypes, + pattern); } } @@ -947,6 +1012,7 @@ public AttributeDefinition build() * @param uniqueness This field represents the uniqueness constraints of this * attribute. {@link Uniqueness#NONE} is the default value. * @param refTypes The reference type of this attribute. + * @param pattern An optional regex pattern that string values must match. */ @JsonCreator AttributeDefinition( @@ -973,8 +1039,23 @@ public AttributeDefinition build() @Nullable @JsonProperty(value = "uniqueness") final Uniqueness uniqueness, @Nullable @JsonProperty(value = "referenceTypes") - final Collection refTypes) + final Collection refTypes, + @Nullable @JsonProperty(value = "pattern") + final String pattern) { + // Default values as described by RFC 7643 Section 2.2. + this.type = type == null ? Type.STRING : type; + this.mutability = mutability == null ? Mutability.READ_WRITE : mutability; + this.returned = returned == null ? Returned.DEFAULT : returned; + this.uniqueness = uniqueness == null ? Uniqueness.NONE : uniqueness; + + if (pattern != null && this.type != Type.STRING) + { + throw new IllegalStateException( + "Cannot set the 'pattern' of an attribute for non-string types."); + } + this.pattern = pattern; + this.name = name; this.subAttributes = subAttrs == null ? null : List.copyOf(subAttrs); this.multiValued = multiValued; @@ -983,12 +1064,6 @@ public AttributeDefinition build() this.canonicalValues = canonicals == null ? null : List.copyOf(canonicals); this.caseExact = caseExact; this.referenceTypes = refTypes == null ? null : List.copyOf(refTypes); - - // Default values as described by RFC 7643 Section 2.2. - this.type = type == null ? Type.STRING : type; - this.mutability = mutability == null ? Mutability.READ_WRITE : mutability; - this.returned = returned == null ? Returned.DEFAULT : returned; - this.uniqueness = uniqueness == null ? Uniqueness.NONE : uniqueness; } /** @@ -1136,6 +1211,18 @@ public Collection getReferenceTypes() return referenceTypes; } + /** + * Fetches the regular expression constraint for this attribute. If defined, + * string-typed values must match this pattern. + * + * @return The regular expression, or {@code null} if no constraint is set. + */ + @Nullable + public String getPattern() + { + return pattern; + } + /** * Retrieves a string representation of this attribute definition. * @@ -1177,7 +1264,8 @@ public boolean equals(@Nullable final Object o) && Objects.equals(returned, that.returned) && Objects.equals(subAttributes, that.subAttributes) && Objects.equals(type, that.type) - && Objects.equals(uniqueness, that.uniqueness); + && Objects.equals(uniqueness, that.uniqueness) + && Objects.equals(pattern, that.pattern); } /** @@ -1190,6 +1278,6 @@ public int hashCode() { return Objects.hash(caseExact, multiValued, required, canonicalValues, description, mutability, name, referenceTypes, returned, subAttributes, - type, uniqueness); + type, uniqueness, pattern); } } diff --git a/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/types/Group.java b/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/types/Group.java index 845ad157..184d522b 100644 --- a/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/types/Group.java +++ b/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/types/Group.java @@ -36,6 +36,7 @@ import com.unboundid.scim2.common.annotations.Attribute; import com.unboundid.scim2.common.annotations.NotNull; import com.unboundid.scim2.common.annotations.Nullable; +import com.unboundid.scim2.common.utils.JsonUtils; import java.net.URI; import java.util.Objects; @@ -126,7 +127,7 @@ public String getValue() * Specifies the identifier of the User's group. * * @param value The identifier of the User's group. - * @return This object. + * @return This group membership identifier. */ @NotNull public Group setValue(@Nullable final String value) @@ -152,9 +153,8 @@ public URI getRef() * Specifies the URI of the corresponding Group resource to which the user * belongs. * - * @param ref The URI of the corresponding Group resource to which the user - * belongs. - * @return This object. + * @param ref The URI of the {@link GroupResource} being referenced. + * @return This group membership identifier. */ @NotNull public Group setRef(@Nullable final URI ref) @@ -163,6 +163,22 @@ public Group setRef(@Nullable final URI ref) return this; } + /** + * Alternate version of {@link #setRef(URI)} that accepts a string. + * + * @param ref The URI of the {@link GroupResource} being referenced. + * @return This group membership identifier. + * @throws IllegalArgumentException If the value was not a valid URI. + * + * @since 6.1.0 + */ + @NotNull + public Group setRef(@Nullable final String ref) + throws IllegalArgumentException + { + return setRef((ref == null) ? null : URI.create(ref)); + } + /** * Specifies the display name, primarily used for display purposes. * @@ -178,7 +194,7 @@ public String getDisplay() * Specifies the display name, primarily used for display purposes. * * @param display The display name. - * @return This object. + * @return This group membership identifier. */ @NotNull public Group setDisplay(@Nullable final String display) @@ -202,7 +218,7 @@ public String getType() * Specifies the label indicating the attribute's function. * * @param type The label indicating the attribute's function. - * @return This object. + * @return This group membership identifier. */ @NotNull public Group setType(@Nullable final String type) @@ -244,4 +260,17 @@ public int hashCode() { return Objects.hash(value, ref, display, type); } + + /** + * Retrieves a string representation of this group membership identifier. + * + * @return A string representation of this group membership identifier. + */ + @Override + @NotNull + public String toString() + { + return JsonUtils.getObjectWriter().withDefaultPrettyPrinter() + .writeValueAsString(this); + } } diff --git a/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/types/GroupResource.java b/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/types/GroupResource.java index bdc2c19e..0afe5080 100644 --- a/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/types/GroupResource.java +++ b/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/types/GroupResource.java @@ -36,6 +36,7 @@ import com.unboundid.scim2.common.annotations.NotNull; import com.unboundid.scim2.common.annotations.Nullable; import com.unboundid.scim2.common.annotations.Schema; +import com.unboundid.scim2.common.types.devices.DeviceResource; import java.util.List; import java.util.Objects; @@ -48,8 +49,8 @@ * RFC 7643 section 4.2. A group resource, or "group", is a collection of * other resources, which helps organize user accounts. Groups often contain * {@link UserResource} objects, but can contain other resource types, including - * other group resources. Groups contained within other groups are referred to - * as "nested" groups. + * {@link DeviceResource} or even other group resources. Groups contained within + * other groups are referred to as "nested" groups. *

* * Groups help enable role-based access control, such as allowing a group of @@ -76,7 +77,7 @@ * "displayName": "Example Group With One Member", * "members": [{ * "value": "cab1e", - * "type": "DIRECT" + * "type": "User" * }] * } * @@ -85,7 +86,7 @@ *

  *   GroupResource group = new GroupResource()
  *       .setDisplayName("Example Group With One Member")
- *       .setMembers(new Member().setValue("cab1e").setType("DIRECT"));
+ *       .setMembers(new Member().setValue("cab1e").setType("User"));
  *   group.setId("8e4d749e-6dde-420a-8d71-00faf8d57510");
  * 
*/ diff --git a/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/types/Manager.java b/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/types/Manager.java index c456367f..dee605c2 100644 --- a/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/types/Manager.java +++ b/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/types/Manager.java @@ -59,7 +59,7 @@ public class Manager @Nullable @Attribute(description = "The URI of the SCIM resource representing " + "the User's manager.", - isRequired = true, + isRequired = false, isCaseExact = false, mutability = AttributeDefinition.Mutability.READ_WRITE, returned = AttributeDefinition.Returned.DEFAULT, diff --git a/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/types/Member.java b/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/types/Member.java index ea8d88ec..24db2aeb 100644 --- a/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/types/Member.java +++ b/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/types/Member.java @@ -56,7 +56,7 @@ public class Member @Nullable @Attribute(description = "A label indicating the type of resource, e.g.," + " 'User' or 'Group'", - canonicalValues = { "User", "Group" }, + canonicalValues = { "User", "Group", "Device", "EndpointApp" }, isCaseExact = false, mutability = AttributeDefinition.Mutability.IMMUTABLE, returned = AttributeDefinition.Returned.DEFAULT, @@ -67,7 +67,7 @@ public class Member @Nullable @Attribute(description = "The URI of the member resource.", isRequired = false, - referenceTypes = { "User", "Group" }, + referenceTypes = { "User", "Group", "Device", "EndpointApp" }, mutability = AttributeDefinition.Mutability.IMMUTABLE, returned = AttributeDefinition.Returned.DEFAULT, uniqueness = AttributeDefinition.Uniqueness.NONE) diff --git a/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/types/devices/BleDeviceExtension.java b/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/types/devices/BleDeviceExtension.java new file mode 100644 index 00000000..0edf2ca1 --- /dev/null +++ b/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/types/devices/BleDeviceExtension.java @@ -0,0 +1,539 @@ +/* + * Copyright 2026 Ping Identity Corporation + * + * 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. + */ +/* + * Copyright 2026 Ping Identity Corporation + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License (GPLv2 only) + * or the terms of the GNU Lesser General Public License (LGPLv2.1 only) + * as published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, see . + */ + +package com.unboundid.scim2.common.types.devices; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonIgnore; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.unboundid.scim2.common.annotations.Attribute; +import com.unboundid.scim2.common.annotations.NotNull; +import com.unboundid.scim2.common.annotations.Nullable; +import com.unboundid.scim2.common.annotations.Schema; +import com.unboundid.scim2.common.types.AttributeDefinition; +import com.unboundid.scim2.common.utils.JsonUtils; +import com.unboundid.scim2.common.utils.SchemaUtils; +import com.unboundid.scim2.common.utils.StaticUtils; + +import java.util.List; +import java.util.Objects; +import java.util.TreeSet; + + +/** + * This class represents the Bluetooth Low Energy (BLE) device extension defined + * by + * RFC 9944 Section 7.1. For more background on devices, see the + * {@link DeviceResource} class. + *

+ * + * The following fields are defined on this extension: + *
    + *
  • {@code deviceMacAddress}: A string value that represents a public MAC + * address assigned by the manufacturer. + *
  • {@code irk}: The Identity Resolving Key unique to each + * device and used to resolve a random address + * received from a peer device. + *
  • {@code isRandom}: Indicates whether the device's MAC address + * is public (false) or random (true). A random + * address is either a private address (if an + * IRK is present) or a static random address. + *
  • {@code mobility}: Indicates BLE device mobility. If set to + * {@code true}, the device can be expected to + * move within a network of Access Points. + *
  • {@code versionSupport}: A set of strings that specifies the BLE + * versions supported by the device. + *
  • {@code pairingMethods}: A set of strings that specifies pairing + * methods associated with the BLE device. + *
  • {@code separateBroadcastAddress}: Represents an address used for + * broadcasts. This value MUST NOT be set when an IRK is provided. + *
+ *

+ * + * The following JSON represents a device with a BLE extension: + *
+ * {
+ *   "schemas": [
+ *       "urn:ietf:params:scim:schemas:core:2.0:Device",
+ *       "urn:ietf:params:scim:schemas:extension:ble:2.0:Device"
+ *   ],
+ *   "id": "e9e30dba",
+ *   "meta": {
+ *     "resourceType": "Device",
+ *     "location": "https://example.com/v2/Devices/e9e30dba"
+ *   },
+ *   "displayName": "BLE Example Monitor",
+ *   "active": true,
+ *   "urn:ietf:params:scim:schemas:extension:ble:2.0:Device": {
+ *     "versionSupport": [ "5.4" ],
+ *     "deviceMacAddress": "2C:54:91:88:C9:E2",
+ *     "isRandom": false,
+ *     "mobility": true,
+ *     "separateBroadcastAddress": [ "AA:BB:88:77:22:11", "AA:BB:88:77:22:12" ],
+ *     "pairingMethods": [
+ *         "urn:ietf:params:scim:schemas:extension:pairingPassKey:2.0:Device"
+ *     ],
+ *     "urn:ietf:params:scim:schemas:extension:pairingPassKey:2.0:Device": {
+ *       "key": 123456
+ *     }
+ *   }
+ * }
+ * 
+ *

+ * + * This device can be created with the following Java code: + *

+ *   // Create the device extension.
+ *   BleDeviceExtension ble =
+ *       new BleDeviceExtension("2C:54:91:88:C9:E2", "5.4")
+ *           .setIsRandom(false)
+ *           .setMobility(true)
+ *           .setPairingExtension(new BlePairingPassKey(123456));
+ *
+ *   // Create the device.
+ *   DeviceResource device = new DeviceResource()
+ *       .setDisplayName("BLE Example Monitor")
+ *       .setActive(true)
+ *       .setDeviceExtension(ble);
+ *   device.setId("e9e30dba");
+ *   device.setMeta(new Meta()
+ *       .setResourceType("Device")
+ *       .setLocationString("https://example.com/v2/Devices/e9e30dba"));
+ * 
+ *

+ * + * Bluetooth Low Energy SCIM data is noteworthy in that it contains a nested + * extension schema value. These nested values represent the type of "pairing + * method" that is compatible with the device. For example, some Bluetooth + * devices display a numeric code during the connection process. Pairing methods + * are defined as subclasses of {@link BlePairingMethod}, and include: + *
    + *
  • {@link BlePairingJustWorks} + *
  • {@link BlePairingNull} + *
  • {@link BlePairingOutOfBand} + *
  • {@link BlePairingPassKey} + *
+ * + * When these classes are passed to {@link #setPairingExtension}, the value of + * the {@code pairingMethods} property will automatically be updated, so that + * array value does not need to be handled manually. + */ +@Schema(id = "urn:ietf:params:scim:schemas:extension:ble:2.0:Device", + name = "BLE Extension", + description = "BLE extension for a Device resource") +public class BleDeviceExtension extends DeviceExtension +{ + @NotNull + @Attribute(description = "The BLE versions supported by this device.", + isRequired = true, + isCaseExact = false, + multiValueClass = String.class) + private final List versionSupport; + + @NotNull + @Attribute(description = "A string value that represents a public MAC" + + " address assigned by the manufacturer.", + isRequired = true, + isCaseExact = false, + uniqueness = AttributeDefinition.Uniqueness.MANUFACTURER, + pattern = MAC_PATTERN) + private final String deviceMacAddress; + + @Attribute(description = + "Indicates whether the device MAC address is a random address.", + isRequired = false) + private boolean isRandom = false; + + @Nullable + @Attribute(description = """ + The Identity Resolving Key (IRK) for this BLE device. This is a \ + cryptographic secret that is never returned in responses.""", + isRequired = false, + isCaseExact = false, + mutability = AttributeDefinition.Mutability.WRITE_ONLY, + returned = AttributeDefinition.Returned.NEVER, + uniqueness = AttributeDefinition.Uniqueness.MANUFACTURER) + private String irk; + + @Nullable + @Attribute(description = "Indicates whether a device supports BLE mobility.", + isRequired = false) + private Boolean mobility; + + @NotNull + @Attribute(description = + "One or more separate broadcast addresses used by this BLE device.", + isRequired = false, + isCaseExact = false, + multiValueClass = String.class, + pattern = MAC_PATTERN) + @JsonInclude(JsonInclude.Include.NON_EMPTY) + private List separateBroadcastAddress = List.of(); + + @NotNull + @Attribute(description = + "BLE pairing methods supported by this device, expressed as schema URNs.", + isRequired = true, + isCaseExact = true, + multiValueClass = String.class) + private final TreeSet pairingMethods = new TreeSet<>(); + + /** + * Creates a new Bluetooth Low Energy device extension. + * + * @param versionSupport The BLE versions supported by this device. + * @param deviceMacAddress The device's MAC address. + */ + @JsonCreator + public BleDeviceExtension( + @NotNull @JsonProperty(value = "versionSupport", required = true) + final List versionSupport, + @NotNull @JsonProperty(value = "deviceMacAddress", required = true) + final String deviceMacAddress) + { + this.versionSupport = List.copyOf(Objects.requireNonNull(versionSupport)); + this.deviceMacAddress = Objects.requireNonNull(deviceMacAddress); + } + + /** + * Creates a new Bluetooth Low Energy device extension. + * + * @param deviceMacAddress The device's MAC address. + * @param versionSupport A non-null BLE version supported by this device. + * @param versions An optional argument for additional versions. + */ + public BleDeviceExtension(@NotNull final String deviceMacAddress, + @NotNull final String versionSupport, + @Nullable final String... versions) + { + this(StaticUtils.toList(versionSupport, versions), deviceMacAddress); + } + + /** + * Fetches the Bluetooth device MAC address. + * + * @return The MAC address. + */ + @NotNull + public String getDeviceMacAddress() + { + return deviceMacAddress; + } + + /** + * Fetches whether the device MAC address is a random address. + * + * @return {@code true} if random, or {@code false} if not. + */ + public boolean getIsRandom() + { + return isRandom; + } + + /** + * Specifies whether the device MAC address is a random address. + * + * @param isRandom The boolean value indicating if the MAC address is random. + * @return This Bluetooth Low Energy extension. + */ + @NotNull + public BleDeviceExtension setIsRandom(final boolean isRandom) + { + this.isRandom = isRandom; + return this; + } + + /** + * Fetches the list of separate broadcast addresses for this BLE device. + * + * @return The list of separate broadcast addresses. + */ + @NotNull + public List getSeparateBroadcastAddress() + { + return separateBroadcastAddress; + } + + /** + * Specifies the list of separate broadcast addresses for this BLE device. + * + * @param separateBroadcastAddress The list of separate broadcast addresses. + * @return This Bluetooth Low Energy extension. + * @throws IllegalStateException If the IRK is also set. + */ + @NotNull + public BleDeviceExtension setSeparateBroadcastAddress( + @Nullable final List separateBroadcastAddress) + throws IllegalStateException + { + validate(this.irk, separateBroadcastAddress); + this.separateBroadcastAddress = (separateBroadcastAddress == null) + ? List.of() : List.copyOf(separateBroadcastAddress); + return this; + } + + /** + * Alternate version of {@link #setSeparateBroadcastAddress(List)}. + * + * @param address A non-null address. + * @param addresses An optional set of additional arguments. Any + * {@code null} values will be ignored. + * @return This Bluetooth Low Energy extension. + * @throws IllegalStateException If the IRK is also set. + */ + @NotNull + public BleDeviceExtension setSeparateBroadcastAddress( + @NotNull final String address, + @Nullable final String... addresses) + throws IllegalStateException + { + return setSeparateBroadcastAddress(StaticUtils.toList(address, addresses)); + } + + /** + * Fetches the Identity Resolving Key for this BLE device. + * + * @return The IRK. + */ + @Nullable + public String getIrk() + { + return irk; + } + + /** + * Specifies the Identity Resolving Key for this BLE device. + * + * @param irk The IRK. + * @return This Bluetooth Low Energy extension. + * @throws IllegalStateException If the separateBroadcastAddress is also set. + */ + @NotNull + public BleDeviceExtension setIrk(@Nullable final String irk) + throws IllegalStateException + { + validate(irk, this.separateBroadcastAddress); + this.irk = irk; + return this; + } + + /** + * Fetches whether this device supports BLE mobility. + * + * @return A boolean indicating whether mobility is supported, or {@code null} + * if the value is not set. + */ + @Nullable + public Boolean getMobility() + { + return mobility; + } + + /** + * Specifies whether this device supports BLE mobility. + * + * @param mobility {@code true} if mobility is supported. + * @return This Bluetooth Low Energy extension. + */ + @NotNull + public BleDeviceExtension setMobility(@Nullable final Boolean mobility) + { + this.mobility = mobility; + return this; + } + + /** + * Fetches the BLE versions supported by this device. + * + * @return The list of supported BLE versions. + */ + @NotNull + public List getVersionSupport() + { + return versionSupport; + } + + /** + * Fetches the BLE pairing methods supported by this device. + * + * @return The list of pairing method schema URNs. + */ + @NotNull + public List getPairingMethods() + { + return List.copyOf(pairingMethods); + } + + /** + * Manually sets the BLE {@code pairingMethods} field. + *

+ * + * In general, methods like {@link #setPairingExtension(BlePairingMethod)} + * should be used instead, which will automatically set the appropriate + * fields. + * + * @param pairingMethods The list of pairing methods. + */ + public void setPairingMethods(@NotNull final List pairingMethods) + { + this.pairingMethods.clear(); + this.pairingMethods.addAll(pairingMethods); + } + + /** + * Sets a BLE pairing method extension on this device extension, and adds + * its schema URN to the {@code pairingMethods} field. + * + * @param The data type of the pairing method extension. + * @param pairingMethod The pairing method extension to set. + * @return This Bluetooth Low Energy extension. + */ + @NotNull + public BleDeviceExtension setPairingExtension( + @NotNull final T pairingMethod) + { + setExtension(pairingMethod); + + // setExtension() adds to the schema URNs, which should always be empty for + // this class. + getSchemaUrns().clear(); + + String schema = SchemaUtils.getSchemaUrn(pairingMethod.getClass()); + pairingMethods.add(schema); + return this; + } + + /** + * Removes a BLE pairing method extension from this device extension, and + * removes its schema URN from the {@code pairingMethods} field. + * + * @param The data type of the pairing method extension. + * @param pairingClass The class of the pairing method extension to remove. + * @return This Bluetooth Low Energy extension. + */ + @NotNull + public BleDeviceExtension removePairingExtension( + @NotNull final Class pairingClass) + { + removeExtension(pairingClass); + String schema = SchemaUtils.getSchemaUrn(pairingClass); + pairingMethods.remove(schema); + return this; + } + + /** + * Obtains all compatible pairing method objects from this device extension. + * + * @return The list of pairing method objects. + */ + @NotNull + @JsonIgnore + public List getPairingMethodExtensions() + { + List> pairingClasses = List.of( + BlePairingJustWorks.class, + BlePairingNull.class, + BlePairingOutOfBand.class, + BlePairingPassKey.class); + + return getClassesFromExtension(pairingClasses); + } + + /** + * Ensures that the {@code irk} and {@code separateBroadcastAddress} fields + * are not both set. + * + * @throws IllegalStateException If both fields are set. + */ + private void validate(@Nullable final String irkValue, + @Nullable final List addresses) + { + if (irkValue != null && addresses != null && !addresses.isEmpty()) + { + throw new IllegalStateException("The 'separateBroadcastAddress' and" + + " 'irk' fields cannot both be set on a BleDeviceExtension."); + } + } + + /** + * Indicates whether the provided object is equal to this BLE device + * extension. + * + * @param o The object to compare. + * @return {@code true} if the provided object is equal to this extension, + * or {@code false} if not. + */ + @Override + public boolean equals(@Nullable final Object o) + { + if (this == o) + { + return true; + } + + return o instanceof BleDeviceExtension that + && super.equals(o) + && versionSupport.equals(that.versionSupport) + && Objects.equals(deviceMacAddress, that.deviceMacAddress) + && isRandom == that.isRandom + && Objects.equals(irk, that.irk) + && Objects.equals(mobility, that.mobility) + && separateBroadcastAddress.equals(that.separateBroadcastAddress) + && pairingMethods.equals(that.pairingMethods); + } + + /** + * Retrieves a hash code for this BLE device extension. + * + * @return A hash code for this BLE device extension. + */ + @Override + public int hashCode() + { + return Objects.hash(super.hashCode(), versionSupport, deviceMacAddress, + isRandom, irk, mobility, separateBroadcastAddress, pairingMethods); + } + + /** + * Retrieves a string representation of this BLE device extension, with the + * {@code irk} value redacted. + * + * @return A string representation of this BLE device extension. + */ + @Override + @NotNull + public String toString() + { + return JsonUtils.toRedactedString(this, "irk"); + } +} diff --git a/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/types/devices/BlePairingJustWorks.java b/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/types/devices/BlePairingJustWorks.java new file mode 100644 index 00000000..cca1d6bf --- /dev/null +++ b/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/types/devices/BlePairingJustWorks.java @@ -0,0 +1,114 @@ +/* + * Copyright 2026 Ping Identity Corporation + * + * 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. + */ +/* + * Copyright 2026 Ping Identity Corporation + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License (GPLv2 only) + * or the terms of the GNU Lesser General Public License (LGPLv2.1 only) + * as published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, see . + */ + +package com.unboundid.scim2.common.types.devices; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.unboundid.scim2.common.annotations.Attribute; +import com.unboundid.scim2.common.annotations.Nullable; +import com.unboundid.scim2.common.annotations.Schema; +import com.unboundid.scim2.common.types.AttributeDefinition; + + +/** + * This class represents a {@link BlePairingMethod} subtype corresponding to the + * Bluetooth Low Energy "Just Works" method. This pairing method does not + * require a key to pair devices, and its pairing key is always set to + * {@code null}. For more background on how this object interacts with a device + * extension, see {@link BleDeviceExtension}. + *

+ * + * The following JSON represents the form for this pairing method as it would + * appear inside a {@link BleDeviceExtension}. This pairing method contains only + * the {@code key} field, which is always {@code null}. + *
+ *   "urn:ietf:params:scim:schemas:extension:pairingJustWorks:2.0:Device": {
+ *     "key": null
+ *   }
+ * 
+ * + * This value can be set on a device extension with the following Java code: + *

+ *   bleDeviceExtension.setPairingExtension(new BlePairingJustWorks());
+ * 
+ */ +@Schema( + id = "urn:ietf:params:scim:schemas:extension:pairingJustWorks:2.0:Device", + name = "Just Works Auth BLE", + description = "BLE Just Works pairing method") +public class BlePairingJustWorks extends BlePairingMethod +{ + @Nullable + @Attribute(description = """ + The pairing key for the "Just Works" method. The value is always null.""", + isRequired = true, + mutability = AttributeDefinition.Mutability.IMMUTABLE) + @JsonInclude(JsonInclude.Include.ALWAYS) + private final Integer key = null; + + /** + * Obtains the pairing key of a BLE Just Works extension. This method will + * always return {@code null} and primarily exists for Jackson serialization. + * + * @return The pairing key's value, which is always {@code null}. + */ + @Nullable + public Integer getKey() + { + return key; + } + + /** + * Indicates whether the provided object is equal to this BLE Just Works + * pairing method. + * + * @param o The object to compare. + * @return {@code true} if the provided object is equal to this object, + * or {@code false} if not. + */ + @Override + public boolean equals(@Nullable final Object o) + { + return o instanceof BlePairingJustWorks; + } + + /** + * Retrieves a hash code for this BLE Just Works pairing method. + * + * @return A hash code for this object. + */ + @Override + public int hashCode() + { + return getClass().hashCode(); + } +} diff --git a/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/types/devices/BlePairingMethod.java b/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/types/devices/BlePairingMethod.java new file mode 100644 index 00000000..1003f1c8 --- /dev/null +++ b/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/types/devices/BlePairingMethod.java @@ -0,0 +1,70 @@ +/* + * Copyright 2026 Ping Identity Corporation + * + * 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. + */ +/* + * Copyright 2026 Ping Identity Corporation + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License (GPLv2 only) + * or the terms of the GNU Lesser General Public License (LGPLv2.1 only) + * as published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, see . + */ + +package com.unboundid.scim2.common.types.devices; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.unboundid.scim2.common.BaseScimResource; +import com.unboundid.scim2.common.annotations.NotNull; + +import java.util.Collection; +import java.util.List; + + +/** + * This class represents a supertype for "pairing methods" that define protocols + * for establishing a Bluetooth connection. This class allows BLE pairing method + * objects to be nested within a {@link BleDeviceExtension} with a common parent + * class type. + *

+ * + * For more background on devices, see the {@link DeviceResource} class. + */ +@JsonIgnoreProperties("schemas") +public abstract class BlePairingMethod extends BaseScimResource +{ + /** + * Create a new Bluetooth Low-Energy Pairing Method. + */ + protected BlePairingMethod() + { + super.setSchemaUrns(List.of()); + } + + @Override + public void setSchemaUrns(@NotNull final Collection schemaUrns) + throws UnsupportedOperationException + { + throw new UnsupportedOperationException( + "Cannot set the 'schemas' value of a BLE Pairing Method."); + } +} diff --git a/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/types/devices/BlePairingNull.java b/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/types/devices/BlePairingNull.java new file mode 100644 index 00000000..e1de7160 --- /dev/null +++ b/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/types/devices/BlePairingNull.java @@ -0,0 +1,87 @@ +/* + * Copyright 2026 Ping Identity Corporation + * + * 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. + */ +/* + * Copyright 2026 Ping Identity Corporation + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License (GPLv2 only) + * or the terms of the GNU Lesser General Public License (LGPLv2.1 only) + * as published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, see . + */ + +package com.unboundid.scim2.common.types.devices; + +import com.unboundid.scim2.common.annotations.Nullable; +import com.unboundid.scim2.common.annotations.Schema; + + +/** + * This class represents a {@link BlePairingMethod} subtype corresponding to the + * Bluetooth Low Energy "Null" method. For more background on how this object + * interacts with a device extension, see {@link BleDeviceExtension}. + *

+ * + * This pairing method JSON object is unique in that it is empty: + *
+ *   "urn:ietf:params:scim:schemas:extension:pairingNull:2.0:Device": {}
+ * 
+ * + * Instead, the presence of this value in the {@code pairingMethods} list of a + * {@link BleDeviceExtension} indicates that no pairing is required. This value + * can be set on a device extension with the following Java code: + *

+ *   bleDeviceExtension.setPairingExtension(new BlePairingNull());
+ * 
+ */ +@Schema( + id = "urn:ietf:params:scim:schemas:extension:pairingNull:2.0:Device", + name = "Pairing Null", + description = "BLE Null pairing method — no pairing required") +public class BlePairingNull extends BlePairingMethod +{ + /** + * Indicates whether the provided object is equal to this BLE pairing null + * bean. + * + * @param o The object to compare. + * @return {@code true} if the provided object is equal to this bean, + * or {@code false} if not. + */ + @Override + public boolean equals(@Nullable final Object o) + { + return o instanceof BlePairingNull; + } + + /** + * Retrieves a hash code for this BLE pairing null bean. + * + * @return A hash code for this bean. + */ + @Override + public int hashCode() + { + return getClass().hashCode(); + } +} diff --git a/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/types/devices/BlePairingOutOfBand.java b/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/types/devices/BlePairingOutOfBand.java new file mode 100644 index 00000000..3da5facf --- /dev/null +++ b/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/types/devices/BlePairingOutOfBand.java @@ -0,0 +1,223 @@ +/* + * Copyright 2026 Ping Identity Corporation + * + * 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. + */ +/* + * Copyright 2026 Ping Identity Corporation + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License (GPLv2 only) + * or the terms of the GNU Lesser General Public License (LGPLv2.1 only) + * as published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, see . + */ + +package com.unboundid.scim2.common.types.devices; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonIgnore; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.unboundid.scim2.common.annotations.Attribute; +import com.unboundid.scim2.common.annotations.NotNull; +import com.unboundid.scim2.common.annotations.Nullable; +import com.unboundid.scim2.common.annotations.Schema; +import com.unboundid.scim2.common.utils.JsonUtils; +import tools.jackson.databind.node.NumericNode; + +import java.util.Objects; + + +/** + * This class represents a {@link BlePairingMethod} subtype corresponding to the + * Bluetooth Low Energy "Out-of-Band" method. For more background on how this + * object interacts with a device extension, see {@link BleDeviceExtension}. + *

+ * + * The following fields are defined: + *
    + *
  • {@code key}: A value received from out-of-band sources, + * such as Near Field Communication (NFC). + *
  • {@code randomNumber}: A number that represents a cryptographic + * "number-used-once" added to the key. + *
  • {@code confirmationNumber}: An optional number that some solutions + * require in a RESTful message exchange. + *
+ * + * The following JSON represents the form for this pairing method as it would + * appear inside a {@link BleDeviceExtension}: + *
+ *   "urn:ietf:params:scim:schemas:extension:pairingOOB:2.0:Device": {
+ *     "key": "TheKeyValueRetrievedFromOOB",
+ *     "randomNumber": 238796813516896
+ *   }
+ * 
+ * + * This value can be set on a device extension with the following Java code: + *

+ *   bleDeviceExtension.setPairingExtension(new BlePairingOutOfBand()
+ *       .setKey("TheKeyValueRetrievedFromOOB")
+ *       .setRandomNumber(238796813516896L));
+ * 
+ */ +@Schema(id = "urn:ietf:params:scim:schemas:extension:pairingOOB:2.0:Device", + name = "Out-of-Band Pairing for BLE", + description = "BLE Out-of-Band pairing method") +public class BlePairingOutOfBand extends BlePairingMethod +{ + @NotNull + @Attribute(description = "The OOB pairing key.", + isRequired = true, + isCaseExact = true) + private final String key; + + // Stored as a JsonNode for flexible equivalency evaluation. + @NotNull + @Attribute(description = "The random number used in OOB pairing.", + isRequired = true) + private final NumericNode randomNumber; + + @Nullable + @Attribute(description = "The confirmation number used in OOB pairing.") + private Integer confirmationNumber; + + /** + * Creates a Bluetooth Low Energy out-of-band pairing method. + * + * @param key The key. + * @param randomNumber The cryptographic number-used-once value. + * @param confirmationNumber The optional confirmation number. + */ + @JsonCreator + public BlePairingOutOfBand( + @NotNull @JsonProperty(value = "key", required = true) + final String key, + @JsonProperty(value = "randomNumber", required = true) + final long randomNumber, + @Nullable @JsonProperty(value = "confirmationNumber") + final Integer confirmationNumber) + { + this.key = Objects.requireNonNull(key); + this.randomNumber = JsonUtils.asNumericNode(randomNumber); + this.confirmationNumber = confirmationNumber; + } + + /** + * Creates a Bluetooth Low Energy out-of-band pairing method. + * + * @param key The key. + * @param randomNumber The cryptographic number-used-once value. + */ + public BlePairingOutOfBand(@NotNull final String key, final long randomNumber) + { + this(key, randomNumber, null); + } + + /** + * Fetches the OOB pairing key. + * + * @return The OOB pairing key. + */ + @NotNull + public String getKey() + { + return key; + } + + /** + * Fetches the random number used in OOB pairing. + * + * @return The random number. + */ + @JsonIgnore + public long getRandomNumber() + { + return randomNumber.longValue(); + } + + /** + * Fetches the random number as a JsonNode for Jackson processing. + * + * @return The random number. + */ + @NotNull + @JsonProperty("randomNumber") + protected NumericNode getRandomNumberNode() + { + return randomNumber; + } + + /** + * Fetches the confirmation number used in OOB pairing. + * + * @return The confirmation number. + */ + @Nullable + public Integer getConfirmationNumber() + { + return confirmationNumber; + } + + /** + * Specifies the confirmation number used in OOB pairing. + * + * @param confirmationNumber The confirmation number. + * @return This object. + */ + @NotNull + public BlePairingOutOfBand setConfirmationNumber( + @Nullable final Integer confirmationNumber) + { + this.confirmationNumber = confirmationNumber; + return this; + } + + /** + * Indicates whether the provided object is equal to this BLE pairing object. + * + * @param o The object to compare. + * @return {@code true} if the provided object is equal to this object, + * or {@code false} if not. + */ + @Override + public boolean equals(@Nullable final Object o) + { + if (this == o) + { + return true; + } + + return o instanceof BlePairingOutOfBand that + && randomNumber.equals(that.randomNumber) + && Objects.equals(key, that.key) + && Objects.equals(confirmationNumber, that.confirmationNumber); + } + + /** + * Retrieves a hash code for this BLE OOB pairing object. + * + * @return A hash code for this object. + */ + @Override + public int hashCode() + { + return Objects.hash(key, randomNumber, confirmationNumber); + } +} diff --git a/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/types/devices/BlePairingPassKey.java b/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/types/devices/BlePairingPassKey.java new file mode 100644 index 00000000..9c5d47ab --- /dev/null +++ b/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/types/devices/BlePairingPassKey.java @@ -0,0 +1,169 @@ +/* + * Copyright 2026 Ping Identity Corporation + * + * 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. + */ +/* + * Copyright 2026 Ping Identity Corporation + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License (GPLv2 only) + * or the terms of the GNU Lesser General Public License (LGPLv2.1 only) + * as published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, see . + */ + +package com.unboundid.scim2.common.types.devices; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonIgnore; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.unboundid.scim2.common.annotations.Attribute; +import com.unboundid.scim2.common.annotations.NotNull; +import com.unboundid.scim2.common.annotations.Nullable; +import com.unboundid.scim2.common.annotations.Schema; + +import java.util.Objects; + + +/** + * This class represents a {@link BlePairingMethod} subtype corresponding to the + * Bluetooth Low Energy "Pass Key" method. The passkey pairing method requires a + * six-digit key to pair devices. For more background on how this object + * interacts with a device extension, see {@link BleDeviceExtension}. + *

+ * + * The following JSON represents the form for this pairing method as it would + * appear inside a {@link BleDeviceExtension}. This contains a mandatory key + * that takes a numeric value up to six digits. + *
+ *   "urn:ietf:params:scim:schemas:extension:pairingPassKey:2.0:Device": {
+ *     "key": 123456
+ *   }
+ * 
+ *

+ * + * This JSON value can be created with the following Java code: + *

+ *   bleDeviceExtension.setPairingExtension(new BlePairingPassKey(123456));
+ * 
+ *

+ * + * In JSON form, this integer value will not have six digits if the integer + * value is less than {@code 100,000}, as leading zeroes are not allowed in JSON + * integer values. If a six-digit string value is required, use + * {@link #getKeyAsString()}. + */ +@Schema(id = "urn:ietf:params:scim:schemas:extension:pairingPassKey:2.0:Device", + name = "Passkey Pairing for BLE", + description = "BLE Pass Key pairing method") +public class BlePairingPassKey extends BlePairingMethod +{ + @Attribute(description = + "The six-digit passkey (000000-999999) used for BLE pairing.", + isRequired = true) + private final int key; + + /** + * Creates a new BLE Passkey pairing method. + * + * @param key The six-digit passkey (000000–999999). + * + * @throws IllegalArgumentException If the key is not a valid 6 digit value. + */ + @JsonCreator + public BlePairingPassKey( + @JsonProperty(value = "key", required = true) final int key) + throws IllegalArgumentException + { + if (key < 0 || key > 999_999) + { + throw new IllegalArgumentException( + "The provided key was not a six digit value: " + key); + } + + this.key = key; + } + + /** + * Alternate constructor that accepts a string input. + * + * @param key The six-digit passkey as a string. + * + * @throws IllegalArgumentException If the key is not a valid 6 digit value. + */ + public BlePairingPassKey(@NotNull final String key) + throws IllegalArgumentException + { + this(Integer.parseInt(Objects.requireNonNull(key))); + } + + /** + * Retrieves the passkey as an integer. + * + * @return The six-digit passkey. + */ + public int getKey() + { + return key; + } + + /** + * Retrieves the passkey as a six-digit string value. + * + * @return The six-digit passkey. + */ + @NotNull + @JsonIgnore + public String getKeyAsString() + { + return "%06d".formatted(key); + } + + + /** + * Indicates whether the provided object is equal to this BLE Passkey object. + * + * @param o The object to compare. + * @return {@code true} if the provided object is equal to this object, + * or {@code false} if not. + */ + @Override + public boolean equals(@Nullable final Object o) + { + if (this == o) + { + return true; + } + + return o instanceof BlePairingPassKey that && key == that.key; + } + + /** + * Retrieves a hash code for this BLE Passkey object. + * + * @return A hash code for this object. + */ + @Override + public int hashCode() + { + return Objects.hash(key); + } +} diff --git a/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/types/devices/CertificateInfo.java b/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/types/devices/CertificateInfo.java new file mode 100644 index 00000000..5b47db79 --- /dev/null +++ b/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/types/devices/CertificateInfo.java @@ -0,0 +1,184 @@ +/* + * Copyright 2026 Ping Identity Corporation + * + * 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. + */ +/* + * Copyright 2026 Ping Identity Corporation + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License (GPLv2 only) + * or the terms of the GNU Lesser General Public License (LGPLv2.1 only) + * as published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, see . + */ + +package com.unboundid.scim2.common.types.devices; + +import com.unboundid.scim2.common.annotations.Attribute; +import com.unboundid.scim2.common.annotations.NotNull; +import com.unboundid.scim2.common.annotations.Nullable; +import com.unboundid.scim2.common.utils.JsonUtils; + +import java.util.Objects; + +/** + * This class represents a complex type for public key data as defined by + * + * RFC 9944 Section 6.3.1. + *

+ * + * In SCIM, a {@code CertificateInfo} is associated with an + * {@link EndpointAppResource}. It contains values from an X.509 certificate: + *
    + *
  • {@code rootCA}: The root Certificate Authority (the top-level + * trust anchor). This will be base64-encoded. + *
  • {@code subjectName}: This string represents either the subjectName or + * the subjectAlternateName of the certificate. + *
+ * + * For example JSON structure and Java code, see {@link EndpointAppResource}. + *

+ * + * The fields on {@code CertificateInfo} are optional, and the SCIM SDK stores + * these string values without attempting to validate their format. As stated + * above, {@code rootCA} will be a {@link java.util.Base64} value. For + * {@code subjectName}, if the field represents a subjectAlternateName, it will + * be a DNS name such as {@code www.example.com}. Otherwise, it will represent a + * DN (distinguished name) such as {@code CN=EX1,O=Example,C=US}. + *

+ * + * SCIM applications should consider the following mandates with regard to a + * certificateInfo object and client tokens on an EndpointAppResource: + *
    + *
  • If the SCIM service accepts both a provided certificateInfo and a + * client token, then control/telemetry services MUST validate both. + *
  • If the SCIM service accepts a certificateInfo object, it MUST return + * that object in the response to the client. + *
  • If the SCIM service does not accept a certificateInfo and does not + * generate a clientToken, then an external authentication method, such + * as OAuth 2, MUST be pre-arranged. + *
+ */ +public class CertificateInfo +{ + @Nullable + @Attribute(description = "The certificate authority for the endpoint app.", + isRequired = false) + private String rootCA; + + @Nullable + @Attribute(description = + "The subject name for the endpoint application certificate.", + isRequired = false) + private String subjectName; + + /** + * Retrieves the root certificate authority for the endpoint application. + * + * @return The root CA. + */ + @Nullable + public String getRootCA() + { + return rootCA; + } + + /** + * Specifies the root certificate authority for the endpoint application. + * + * @param rootCA The root CA. + * @return This CertificateInfo. + */ + @NotNull + public CertificateInfo setRootCA(@Nullable final String rootCA) + { + this.rootCA = rootCA; + return this; + } + + /** + * Retrieves the subject name for the endpoint application certificate. + * + * @return The subject name. + */ + @Nullable + public String getSubjectName() + { + return subjectName; + } + + /** + * Specifies the subject name for the endpoint application certificate. + * + * @param subjectName The subject name. + * @return This CertificateInfo. + */ + @NotNull + public CertificateInfo setSubjectName(@Nullable final String subjectName) + { + this.subjectName = subjectName; + return this; + } + + /** + * Indicates whether the provided object is equal to this certificate info. + * + * @param o The object to compare. + * @return {@code true} if the provided object is equal to this + * certificate info, or {@code false} if not. + */ + @Override + public boolean equals(@Nullable final Object o) + { + if (this == o) + { + return true; + } + + return o instanceof CertificateInfo that + && Objects.equals(rootCA, that.rootCA) + && Objects.equals(subjectName, that.subjectName); + } + + /** + * Retrieves a string representation of this certificate info. + * + * @return A string representation of this certificate info. + */ + @Override + @NotNull + public String toString() + { + return JsonUtils.getObjectWriter().withDefaultPrettyPrinter() + .writeValueAsString(this); + } + + /** + * Retrieves a hash code for this certificate info. + * + * @return A hash code for this certificate info. + */ + @Override + public int hashCode() + { + return Objects.hash(rootCA, subjectName); + } +} diff --git a/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/types/devices/DeviceExtension.java b/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/types/devices/DeviceExtension.java new file mode 100644 index 00000000..8abd0b31 --- /dev/null +++ b/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/types/devices/DeviceExtension.java @@ -0,0 +1,65 @@ +/* + * Copyright 2026 Ping Identity Corporation + * + * 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. + */ +/* + * Copyright 2026 Ping Identity Corporation + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License (GPLv2 only) + * or the terms of the GNU Lesser General Public License (LGPLv2.1 only) + * as published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, see . + */ + +package com.unboundid.scim2.common.types.devices; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.unboundid.scim2.common.BaseScimResource; +import com.unboundid.scim2.common.annotations.NotNull; + +import java.util.List; + + +/** + * This class represents a supertype for all device extension objects described + * in {@link DeviceResource}. This structure allows device extension objects to + * be nested within a {@link DeviceResource} without printing a {@code schemas} + * value of their own. + */ +@JsonIgnoreProperties("schemas") +public abstract class DeviceExtension extends BaseScimResource +{ + /** + * The regex matching MAC addresses as defined by RFC 9944. + */ + @NotNull + public static final String MAC_PATTERN = + "^[0-9A-Fa-f]{2}(:[0-9A-Fa-f]{2}){5}$"; + + /** + * Creates a new device extension. + */ + protected DeviceExtension() + { + setSchemaUrns(List.of()); + } +} diff --git a/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/types/devices/DeviceResource.java b/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/types/devices/DeviceResource.java new file mode 100644 index 00000000..8d759129 --- /dev/null +++ b/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/types/devices/DeviceResource.java @@ -0,0 +1,424 @@ +/* + * Copyright 2026 Ping Identity Corporation + * + * 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. + */ +/* + * Copyright 2026 Ping Identity Corporation + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License (GPLv2 only) + * or the terms of the GNU Lesser General Public License (LGPLv2.1 only) + * as published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, see . + */ + +package com.unboundid.scim2.common.types.devices; + +import com.fasterxml.jackson.annotation.JsonIgnore; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.unboundid.scim2.common.BaseScimResource; +import com.unboundid.scim2.common.annotations.Attribute; +import com.unboundid.scim2.common.annotations.NotNull; +import com.unboundid.scim2.common.annotations.Nullable; +import com.unboundid.scim2.common.annotations.Schema; +import com.unboundid.scim2.common.types.AttributeDefinition; +import com.unboundid.scim2.common.types.Group; +import com.unboundid.scim2.common.utils.StaticUtils; + +import java.net.URI; +import java.util.List; +import java.util.Objects; + + +/** + * This class represents the {@code device} resource type as described by + * RFC 9944. + * Management of IoT (Internet of Things) devices was added to SCIM to handle + * device provisioning for authentication use cases. These use cases include + * both user-based authentication and OAuth/certificate authentication. + *

+ * + * In SCIM, a device resource can represent many types of devices that are + * managed by users. These include Bluetooth, Wi-Fi Easy Connect (DPP), Zigbee + * low-power devices, and more. The following fields are defined on a device: + *
    + *
  • {@code displayName}: The human-readable name of the device. + *
  • {@code active}: Indicates whether the device is in use. + *
  • {@code mudUrl}: An optional Manufacturer Usage Description URL + * for this device as defined by RFC 8520. + *
  • {@code groups}: An optional list of groups that this device + * belongs to. Membership may be given directly, + * through a nested group, or dynamically computed. + *
+ *

+ * + * In addition to the above fields, a device resource generally contains more + * standard-specific data that is stored in a schema extension. For example, + * consider the following Zigbee device resource in JSON form: + *
+ *   {
+ *     "schemas": [
+ *       "urn:ietf:params:scim:schemas:core:2.0:Device",
+ *       "urn:ietf:params:scim:schemas:extension:zigbee:2.0:Device"
+ *     ],
+ *     "id": "e9e30dba",
+ *     "meta": {
+ *       "resourceType": "Device",
+ *       "created": "1970-01-23T04:56:22Z",
+ *       "lastModified": "1970-05-13T04:42:34Z",
+ *       "location": "https://example.com/v2/Devices/e9e30dba"
+ *     },
+ *     "displayName": "Zigbee Example Monitor",
+ *     "active": true,
+ *     "mudUrl": "https://example.com/lightbulbs/colour/v1",
+ *     "urn:ietf:params:scim:schemas:extension:zigbee:2.0:Device": {
+ *       "versionSupport": [ "3.0" ],
+ *       "deviceEui64Address": "50:32:5F:FF:FE:E7:67:28"
+ *     }
+ *   }
+ * 
+ * + * This device resource may be created with the following Java code. Values for + * metadata use constants below, but will generally come from a database. + *

+ *   // Create the device extension.
+ *   ZigbeeDeviceExtension zigbee = new ZigbeeDeviceExtension(
+ *       "50:32:5F:FF:FE:E7:67:28", "3.0");
+ *
+ *   // Create the device.
+ *   DeviceResource device = new DeviceResource()
+ *       .setDisplayName("Zigbee Example Monitor")
+ *       .setActive(true)
+ *       .setDeviceExtension(zigbee);
+ *   device.setId("e9e30dba");
+ *   device.setMeta(new Meta()
+ *       .setResourceType("Device")
+ *       .setCreatedMillis(1918582000L)
+ *       .setLastModifiedMillis(11421754000L)
+ *       .setLocationString("https://example.com/v2/Devices/e9e30dba"));
+ * 
+ *

+ * + * Data specific to the Zigbee device is stored under the relevant extension + * schema. This structure organizes standard-specific data, since Zigbee + * information (e.g., the unique EUI 64 address) is not applicable to other + * device types. It also allows consumers to differentiate between device types, + * which each have their own URN identifier listed below. Note that all + * extension classes descend from the {@link DeviceExtension} class. + *
    + *
  • {@link BleDeviceExtension}: + * urn:ietf:params:scim:schemas:extension:ble:2.0:Device + *
  • {@link DppDeviceExtension}: + * urn:ietf:params:scim:schemas:extension:dpp:2.0:Device + *
  • {@link EndpointAppDeviceExtension}: + * urn:ietf:params:scim:schemas:extension:endpointAppsExt:2.0:Device + *
  • {@link EthernetMabDeviceExtension}: + * urn:ietf:params:scim:schemas:extension:ethernet-mab:2.0:Device + *
  • {@link FdoDeviceExtension}: + * urn:ietf:params:scim:schemas:extension:fido-device-onboard:2.0:Device + *
  • {@link ZigbeeDeviceExtension}: + * urn:ietf:params:scim:schemas:extension:zigbee:2.0:Device + *
+ *

+ * + * To fetch the device extension(s) stored on a device resource, use the + * {@link #getDeviceExtensions()} method and the {@code instanceof} keyword: + *

+ *   for (DeviceExtension extension : device.getDeviceExtensions())
+ *   {
+ *     if (extension instanceof BleDeviceExtension ble)
+ *     {
+ *       exampleBluetoothHandle(device, ble);
+ *     }
+ *     else if (extension instanceof DppDeviceExtension wifi)
+ *     {
+ *       exampleWifiHandle(device, wifi);
+ *     }
+ *     // ...
+ *   }
+ * 
+ *

+ * + *

Cryptographic Secrets

+ * By their nature, devices sometimes rely on cryptography to establish trusted + * connections and verification. As a result, device extension JSON objects from + * a client may contain secret values that should not be exposed, particularly: + *
    + *
  • The Identity Resolving Key (IRK) of a {@link BleDeviceExtension}. + *
  • The bootstrap key of a {@link DppDeviceExtension}. + *
  • The voucher of an {@link FdoDeviceExtension}. + *
+ * + * When these extension objects are converted to JSON, the SCIM SDK prints such + * secret values if they are present. This behavior is necessary when a client + * application sends this information to a SCIM service. However, SCIM services + * should ensure that they are not returned to a client under any circumstances. + * The scim2-sdk-server's {@code ResourcePreparer} class may be used to handle + * these values. + *

+ * + * Although some device extensions use strong encryption, the use of a device + * extension does not guarantee this. For example, MAC Authenticated Bypass used + * by {@link EthernetMabDeviceExtension} is specifically considered a legacy + * form of weak authentication, so it is important to be aware of different + * authentication types. + * + * @since 6.1.0 + */ +@Schema(id = "urn:ietf:params:scim:schemas:core:2.0:Device", + name = "Core Device Schema", + description = "Device") +public class DeviceResource extends BaseScimResource +{ + @Nullable + @Attribute(description = "A human-readable name for the device, suitable" + + " for display to end-users.", + isCaseExact = false) + private String displayName; + + @Attribute(description = "A Boolean value indicating whether the device" + + " is currently active.", + isRequired = true) + private boolean active; + + @Nullable + @Attribute(description = "A URI pointing to the Manufacturer Usage" + + " Description (MUD) file for this device, as defined by RFC 8520.", + isCaseExact = true, + referenceTypes = "external") + private URI mudUrl; + + @NotNull + @Attribute(description = "A list of groups to which the device belongs.", + isCaseExact = true, + mutability = AttributeDefinition.Mutability.READ_ONLY, + multiValueClass = Group.class) + @JsonInclude(JsonInclude.Include.NON_EMPTY) + private List groups = List.of(); + + /** + * Retrieves the human-readable display name of this device. + * + * @return The display name of this device. + */ + @Nullable + public String getDisplayName() + { + return displayName; + } + + /** + * Specifies the human-readable display name of this device. + * + * @param displayName The display name of this device. + * @return This device resource. + */ + @NotNull + public DeviceResource setDisplayName(@Nullable final String displayName) + { + this.displayName = displayName; + return this; + } + + /** + * Retrieves whether this device is currently active. + * + * @return The device's active status. + */ + public boolean getActive() + { + return active; + } + + /** + * Specifies whether this device is currently active. + * + * @param active The device's active status. + * @return This device resource. + */ + @NotNull + public DeviceResource setActive(final boolean active) + { + this.active = active; + return this; + } + + /** + * Retrieves the Manufacturer Usage Description URL for this device. + * + * @return The MUD URL for this device. + */ + @Nullable + public URI getMudUrl() + { + return mudUrl; + } + + /** + * Retrieves the Manufacturer Usage Description URL as a string. + * + * @return The MUD URL for this device. + */ + @Nullable + @JsonIgnore + public String getMudUrlString() + { + return (mudUrl == null) ? null : mudUrl.toString(); + } + + /** + * Specifies the Manufacturer Usage Description URL for this device. + * + * @param mudUrl The MUD URL for this device. + * @return This device resource. + */ + @NotNull + public DeviceResource setMudUrl(@Nullable final URI mudUrl) + { + this.mudUrl = mudUrl; + return this; + } + + /** + * Alternate version of {@link #setMudUrl(URI)} that accepts a string. + * + * @param mudUrl The MUD URL for this device. + * @return This device resource. + * + * @throws IllegalArgumentException If the string was not a valid URI. + */ + @NotNull + public DeviceResource setMudUrl(@Nullable final String mudUrl) + throws IllegalArgumentException + { + return setMudUrl((mudUrl == null) ? null : URI.create(mudUrl)); + } + + /** + * Retrieves the list of groups to which this device belongs. + * + * @return The list of groups to which this device belongs. + */ + @NotNull + public List getGroups() + { + return groups; + } + + /** + * Specifies the list of groups to which this device belongs. + * + * @param groups The list of groups to which this device belongs. + * @return This device resource. + */ + @NotNull + public DeviceResource setGroups(@Nullable final List groups) + { + this.groups = (groups == null) ? List.of() : groups; + return this; + } + + /** + * Alternate version of {@link #setGroups(List)}. + * + * @param group A non-null group. + * @param groups An optional set of additional arguments. Any + * {@code null} values will be ignored. + * @return This device resource. + */ + @NotNull + public DeviceResource setGroups(@NotNull final Group group, + @Nullable final Group... groups) + { + return setGroups(StaticUtils.toList(group, groups)); + } + + /** + * Fetches the device extensions attached to this device resource. This can + * contain multiple values, particularly if the device contains an + * {@link EndpointAppDeviceExtension}. + * + * @return The device extensions. + */ + @NotNull + @JsonIgnore + public List getDeviceExtensions() + { + List> extensionClasses = List.of( + BleDeviceExtension.class, + ZigbeeDeviceExtension.class, + DppDeviceExtension.class, + FdoDeviceExtension.class, + EthernetMabDeviceExtension.class, + EndpointAppDeviceExtension.class); + + return getClassesFromExtension(extensionClasses); + } + + /** + * Attaches a device extension (e.g., {@link ZigbeeDeviceExtension}) to this + * resource. This is equivalent to calling {@link #setExtension(Object)}, + * though it returns the DeviceResource for builder pattern calls. + * + * @param ext The device extension to attach. + * @return This device resource. + */ + @NotNull + public DeviceResource setDeviceExtension(@NotNull final DeviceExtension ext) + { + setExtension(ext); + return this; + } + + /** + * Indicates whether the provided object is equal to this device resource. + * + * @param o The object to compare. + * @return {@code true} if the provided object is equal to this device + * resource, or {@code false} if not. + */ + @Override + public boolean equals(@Nullable final Object o) + { + if (this == o) + { + return true; + } + + return o instanceof DeviceResource that + && active == that.active + && Objects.equals(displayName, that.displayName) + && Objects.equals(mudUrl, that.mudUrl) + && Objects.equals(groups, that.groups) + && super.equals(o); + } + + /** + * Retrieves a hash code for this device resource. + * + * @return A hash code for this device resource. + */ + @Override + public int hashCode() + { + return Objects.hash(super.hashCode(), active, displayName, mudUrl, groups); + } +} diff --git a/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/types/devices/DppDeviceExtension.java b/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/types/devices/DppDeviceExtension.java new file mode 100644 index 00000000..e3578ebb --- /dev/null +++ b/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/types/devices/DppDeviceExtension.java @@ -0,0 +1,392 @@ +/* + * Copyright 2026 Ping Identity Corporation + * + * 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. + */ +/* + * Copyright 2026 Ping Identity Corporation + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License (GPLv2 only) + * or the terms of the GNU Lesser General Public License (LGPLv2.1 only) + * as published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, see . + */ + +package com.unboundid.scim2.common.types.devices; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.annotation.JsonPropertyOrder; +import com.unboundid.scim2.common.annotations.Attribute; +import com.unboundid.scim2.common.annotations.NotNull; +import com.unboundid.scim2.common.annotations.Nullable; +import com.unboundid.scim2.common.annotations.Schema; +import com.unboundid.scim2.common.types.AttributeDefinition; +import com.unboundid.scim2.common.utils.JsonUtils; +import com.unboundid.scim2.common.utils.StaticUtils; + +import java.util.List; +import java.util.Objects; + + +/** + * This class represents a Device Provisioning Protocol extension for a device, + * which allows enabling Wi-Fi Easy Connect in SCIM as defined by + * RFC 9944. For + * more background on devices, see the {@link DeviceResource} class. + *

+ * + * The following fields are defined on this extension: + *
    + *
  • {@code dppVersion}: An integer that represents the version of + * DPP the device supports. + *
  • {@code bootstrapKey}: An Elliptic Curve Diffie-Hellman (ECDH) + * public key used for bootstrapping. + *
  • {@code deviceMacAddress}: A MAC address stored as a string. + *
  • {@code serialNumber}: An alphanumeric serial number that may + * also be passed as bootstrap information. + *
  • {@code classChannel}: Values representing the global operating + * class and channel for bootstrap info. + *
  • {@code bootstrappingMethod}: Values representing the bootstrap methods + * available on the enrollee device. + *
+ *

+ * + * The following JSON represents a device with a DPP extension: + *
+ * {
+ *   "schemas": [
+ *       "urn:ietf:params:scim:schemas:core:2.0:Device",
+ *       "urn:ietf:params:scim:schemas:extension:dpp:2.0:Device"
+ *   ],
+ *   "id": "e9e30dba",
+ *   "meta": {
+ *     "resourceType": "Device",
+ *     "created": "1970-01-23T04:56:22Z",
+ *     "lastModified": "1970-05-13T04:42:34Z",
+ *     "location": "https://example.com/v2/Devices/e9e30dba"
+ *   },
+ *   "displayName": "Wi-Fi Example Monitor",
+ *   "active": true,
+ *   "urn:ietf:params:scim:schemas:extension:dpp:2.0:Device": {
+ *     "dppVersion": 2,
+ *     "bootstrappingMethod": [ "QR" ],
+ *     "bootstrapKey": "MDkwEwYHKoZIzj0CAQ...",
+ *     "deviceMacAddress": "2C:54:91:88:C9:F2",
+ *     "classChannel": [ "81/1", "115/36" ],
+ *     "serialNumber": "4774LH2b4044"
+ *   }
+ * }
+ * 
+ *

+ * + * This JSON value can be created with the following Java code: + *

+ *   // Create the device extension.
+ *   DppDeviceExtension dpp = new DppDeviceExtension(2, "MDkwEwYHKoZIzj0CAQ...")
+ *       .setBootstrappingMethod("QR")
+ *       .setDeviceMacAddress("2C:54:91:88:C9:F2")
+ *       .setClassChannel("81/1", "115/36")
+ *       .setSerialNumber("4774LH2b4044");
+ *
+ *   // Create the device.
+ *   DeviceResource device = new DeviceResource()
+ *       .setDisplayName("Wi-Fi Example Monitor")
+ *       .setActive(true)
+ *       .setDeviceExtension(dpp);
+ *   device.setId("e9e30dba");
+ *   device.setMeta(new Meta()
+ *       .setResourceType("Device")
+ *       .setCreatedMillis(1918582000L)
+ *       .setLastModifiedMillis(11421754000L)
+ *       .setLocationString("https://example.com/v2/Devices/e9e30dba"));
+ * 
+ */ +@Schema(id = "urn:ietf:params:scim:schemas:extension:dpp:2.0:Device", + name = "Wi-Fi Easy Connect", + description = "DPP extension for a Device resource") +@JsonPropertyOrder({"dppVersion", "bootstrappingMethod", "bootstrapKey"}) +public class DppDeviceExtension extends DeviceExtension +{ + @Attribute(description = "The DPP protocol version supported by this" + + " device.", + isRequired = true) + private final int dppVersion; + + @NotNull + @Attribute(description = + "The bootstrapping methods supported by this DPP device.", + isCaseExact = false, + multiValueClass = String.class) + @JsonInclude(JsonInclude.Include.NON_EMPTY) + private List bootstrappingMethod = List.of(); + + @NotNull + @Attribute(description = "The DPP bootstrapping public key for this" + + " device. This is a cryptographic secret that is never returned.", + isRequired = true, + isCaseExact = true, + mutability = AttributeDefinition.Mutability.WRITE_ONLY, + returned = AttributeDefinition.Returned.NEVER, + uniqueness = AttributeDefinition.Uniqueness.NONE) + private final String bootstrapKey; + + /** + * Creates a new Device Provisioning Protocol (DPP) extension. + * + * @param dppVersion The DPP version supported by this device. + * @param bootstrapKey The DPP bootstrapping public key for this device. + */ + @JsonCreator + public DppDeviceExtension( + @NotNull @JsonProperty(value = "dppVersion", required = true) + final Integer dppVersion, + @NotNull @JsonProperty(value = "bootstrapKey", required = true) + final String bootstrapKey) + { + this.dppVersion = Objects.requireNonNull(dppVersion); + this.bootstrapKey = Objects.requireNonNull(bootstrapKey); + } + + @Nullable + @Attribute(description = "The MAC address of this DPP device.", + isCaseExact = false, + uniqueness = AttributeDefinition.Uniqueness.MANUFACTURER, + pattern = MAC_PATTERN) + private String deviceMacAddress; + + @NotNull + @Attribute(description = + "The class/channel pairs supported by this DPP device.", + isCaseExact = false, + multiValueClass = String.class) + @JsonInclude(JsonInclude.Include.NON_EMPTY) + private List classChannel = List.of(); + + @Nullable + @Attribute(description = "The serial number of this DPP device.", + isCaseExact = false) + private String serialNumber; + + /** + * Fetches the Device Provisioning Protocol version of this device. + * + * @return The DPP version. + */ + public int getDppVersion() + { + return dppVersion; + } + + /** + * Fetches the DPP bootstrapping public key. + * + * @return The bootstrap key. + */ + @NotNull + public String getBootstrapKey() + { + return bootstrapKey; + } + + /** + * Fetches the MAC address of this DPP device. + * + * @return The MAC address. + */ + @Nullable + public String getDeviceMacAddress() + { + return deviceMacAddress; + } + + /** + * Specifies the MAC address of this DPP device. + * + * @param deviceMacAddress The MAC address. + * @return This DPP extension. + */ + @NotNull + public DppDeviceExtension setDeviceMacAddress( + @Nullable final String deviceMacAddress) + { + this.deviceMacAddress = deviceMacAddress; + return this; + } + + /** + * Fetches the serial number of this DPP device. + * + * @return The serial number. + */ + @Nullable + public String getSerialNumber() + { + return serialNumber; + } + + /** + * Specifies the serial number of this DPP device. + * + * @param serialNumber The serial number. + * @return This DPP extension. + */ + @NotNull + public DppDeviceExtension setSerialNumber(@Nullable final String serialNumber) + { + this.serialNumber = serialNumber; + return this; + } + + /** + * Fetches the bootstrapping methods supported by this DPP device. The name of + * this attribute is singular, but a DPP device may define multiple methods. + * + * @return The list of bootstrapping methods. + */ + @NotNull + public List getBootstrappingMethod() + { + return bootstrappingMethod; + } + + /** + * Specifies the bootstrapping methods supported by this DPP device. + * + * @param bootstrappingMethod The list of bootstrapping methods. + * @return This DPP extension. + */ + @NotNull + public DppDeviceExtension setBootstrappingMethod( + @Nullable final List bootstrappingMethod) + { + this.bootstrappingMethod = + (bootstrappingMethod == null) ? List.of() : bootstrappingMethod; + return this; + } + + /** + * Alternate version of {@link #setBootstrappingMethod(List)}. + * + * @param method A non-null bootstrapping method. + * @param methods An optional set of additional arguments. Any + * {@code null} values will be ignored. + * @return This DPP extension. + */ + @NotNull + public DppDeviceExtension setBootstrappingMethod( + @NotNull final String method, + @Nullable final String... methods) + { + return setBootstrappingMethod(StaticUtils.toList(method, methods)); + } + + /** + * Fetches the class/channel pairs supported by this DPP device. + * + * @return The list of class/channel pairs. + */ + @NotNull + public List getClassChannel() + { + return classChannel; + } + + /** + * Specifies the class/channel pairs supported by this DPP device. + * + * @param classChannel The list of class/channel pairs. + * @return This DPP extension. + */ + @NotNull + public DppDeviceExtension setClassChannel( + @Nullable final List classChannel) + { + this.classChannel = (classChannel == null) ? List.of() : classChannel; + return this; + } + + /** + * Alternate version of {@link #setClassChannel(List)}. + * + * @param channel A non-null class/channel string. + * @param channels An optional set of additional arguments. Any + * {@code null} values will be ignored. + * @return This DPP extension. + */ + @NotNull + public DppDeviceExtension setClassChannel(@NotNull final String channel, + @Nullable final String... channels) + { + return setClassChannel(StaticUtils.toList(channel, channels)); + } + + /** + * Indicates whether the provided object is equal to this DPP device + * extension. + * + * @param o The object to compare. + * @return {@code true} if the provided object is equal to this extension, + * or {@code false} if not. + */ + @Override + public boolean equals(@Nullable final Object o) + { + if (this == o) + { + return true; + } + + return o instanceof DppDeviceExtension that + && Objects.equals(dppVersion, that.dppVersion) + && Objects.equals(bootstrapKey, that.bootstrapKey) + && Objects.equals(deviceMacAddress, that.deviceMacAddress) + && Objects.equals(serialNumber, that.serialNumber) + && Objects.equals(bootstrappingMethod, that.bootstrappingMethod) + && Objects.equals(classChannel, that.classChannel); + } + + /** + * Retrieves a hash code for this DPP device extension. + * + * @return A hash code for this DPP device extension. + */ + @Override + public int hashCode() + { + return Objects.hash(dppVersion, bootstrapKey, deviceMacAddress, + serialNumber, bootstrappingMethod, classChannel); + } + + /** + * Retrieves a string representation of this DPP device extension, with the + * {@code bootstrapKey} value redacted. + * + * @return A string representation of this DPP device extension. + */ + @Override + @NotNull + public String toString() + { + return JsonUtils.toRedactedString(this, "bootstrapKey"); + } +} diff --git a/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/types/devices/EndpointAppDeviceExtension.java b/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/types/devices/EndpointAppDeviceExtension.java new file mode 100644 index 00000000..07cb4a2a --- /dev/null +++ b/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/types/devices/EndpointAppDeviceExtension.java @@ -0,0 +1,358 @@ +/* + * Copyright 2026 Ping Identity Corporation + * + * 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. + */ +/* + * Copyright 2026 Ping Identity Corporation + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License (GPLv2 only) + * or the terms of the GNU Lesser General Public License (LGPLv2.1 only) + * as published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, see . + */ + +package com.unboundid.scim2.common.types.devices; + +import com.fasterxml.jackson.annotation.JsonIgnore; +import com.unboundid.scim2.common.annotations.Attribute; +import com.unboundid.scim2.common.annotations.NotNull; +import com.unboundid.scim2.common.annotations.Nullable; +import com.unboundid.scim2.common.annotations.Schema; +import com.unboundid.scim2.common.types.AttributeDefinition; +import com.unboundid.scim2.common.utils.StaticUtils; + +import java.net.URI; +import java.util.List; +import java.util.Objects; + + +/** + * This class represents an endpoint application extension for a device per + * + * RFC 9944 Section 7.6. + *

+ * + * Some device types (e.g., Bluetooth and Zigbee) require an application gateway + * interface to manage them. This class represents the list of applications that + * connect to an external system. Ultimately, this extension links a device to + * the {@link EndpointAppResource} instances that manage it. For more background + * on devices, see the {@link DeviceResource} class. + *

+ * + * The following fields are defined on this extension: + *
    + *
  • {@code deviceControlEnterpriseEndpoint}: The URL of the enterprise + * endpoint used to reach the enterprise gateway for device control. + *
  • {@code telemetryEnterpriseEndpoint}: The URL of the enterprise + * endpoint used to reach the enterprise gateway for telemetry. + *
  • {@code applications}: A list of {@link EndpointAppReference} + * entries, each identifying an associated {@link EndpointAppResource} + * via a resource ID value and a {@code $ref} URL. + *
+ *

+ * + * The following JSON represents a device with a BLE extension and an + * endpoint applications extension: + *
+ * {
+ *   "schemas": [
+ *       "urn:ietf:params:scim:schemas:core:2.0:Device",
+ *       "urn:ietf:params:scim:schemas:extension:ble:2.0:Device",
+ *       "urn:ietf:params:scim:schemas:extension:endpointAppsExt:2.0:Device"
+ *   ],
+ *   "id": "e9e30dba",
+ *   "meta": {
+ *     "resourceType": "Device",
+ *     "location": "https://example.com/v2/Devices/e9e30dba"
+ *   },
+ *   "displayName": "BLE Example Monitor",
+ *   "active": true,
+ *   "urn:ietf:params:scim:schemas:extension:ble:2.0:Device": {
+ *     "versionSupport": [ "5.4" ],
+ *     "deviceMacAddress": "2C:54:91:88:C9:E2",
+ *     "isRandom": false,
+ *     "mobility": false,
+ *     "separateBroadcastAddress": [ "AA:BB:88:77:22:11", "AA:BB:88:77:22:12" ],
+ *     "pairingMethods": [
+ *         "urn:ietf:params:scim:schemas:extension:pairingPassKey:2.0:Device"
+ *     ],
+ *     "urn:ietf:params:scim:schemas:extension:pairingPassKey:2.0:Device": {
+ *       "key": 123456
+ *     }
+ *   },
+ *   "urn:ietf:params:scim:schemas:extension:endpointAppsExt:2.0:Device": {
+ *     "deviceControlEnterpriseEndpoint": "https://example.com/ctrl_endpoint",
+ *     "telemetryEnterpriseEndpoint": "mqtts://example.com/telementry",
+ *     "applications": [ {
+ *         "value": "a1b2c3d4",
+ *         "$ref": "https://example.com/v2/EndpointApps/a1b2c3d4"
+ *       }, {
+ *         "value": "a1b2c3d5",
+ *         "$ref": "https://example.com/v2/EndpointApps/a1b2c3d5"
+ *       }
+ *     ]
+ *   }
+ * }
+ * 
+ *

+ * + * To create the EndpointAppReference entries embedded within the response + * above, use the following Java code: + *

+ *   // Create the endpoint applications extension.
+ *   EndpointAppReference app1 = new EndpointAppReference()
+ *       .setValue("a1b2c3d4")
+ *       .setRef("https://example.com/v2/EndpointApps/a1b2c3d4");
+ *   EndpointAppReference app2 = new EndpointAppReference()
+ *       .setValue("a1b2c3d5")
+ *       .setRef("https://example.com/v2/EndpointApps/a1b2c3d5");
+ *
+ *   EndpointAppDeviceExtension apps = new EndpointAppDeviceExtension()
+ *     .setApplications(app1, app2)
+ *     .setDeviceControlEnterpriseEndpoint("https://example.com/ctrl_endpoint")
+ *     .setTelemetryEnterpriseEndpoint("mqtts://example.com/telementry");
+ * 
+ */ +@Schema( + id = "urn:ietf:params:scim:schemas:extension:endpointAppsExt:2.0:Device", + name = "Application Endpoint Extension", + description = "Endpoint applications extension for a Device resource") +public class EndpointAppDeviceExtension extends DeviceExtension +{ + // Like $ref fields, this is marked as not required since clients will not set + // this value in requests they send to SCIM services. + @Nullable + @Attribute(description = + "The URI of the enterprise device-control endpoint application.", + isRequired = false, + isCaseExact = true, + mutability = AttributeDefinition.Mutability.READ_ONLY, + uniqueness = AttributeDefinition.Uniqueness.ENTERPRISE, + referenceTypes = "external") + private URI deviceControlEnterpriseEndpoint; + + @Nullable + @Attribute(description = + "The URI of the enterprise telemetry endpoint application.", + isRequired = false, + isCaseExact = true, + mutability = AttributeDefinition.Mutability.READ_ONLY, + uniqueness = AttributeDefinition.Uniqueness.ENTERPRISE, + referenceTypes = "external") + private URI telemetryEnterpriseEndpoint; + + @NotNull + @Attribute(description = + "The list of EndpointApp resources associated with this device.", + isRequired = true, + multiValueClass = EndpointAppReference.class) + private List applications = List.of(); + + /** + * Retrieves the URI of the enterprise device-control endpoint. + * + * @return The device-control endpoint URI. + */ + @Nullable + public URI getDeviceControlEnterpriseEndpoint() + { + return deviceControlEnterpriseEndpoint; + } + + /** + * Retrieves the URI of the enterprise device-control endpoint as a string. + * + * @return The device-control endpoint URI. + */ + @Nullable + @JsonIgnore + public String getDeviceControlEnterpriseEndpointString() + { + URI controlEndpoint = getDeviceControlEnterpriseEndpoint(); + return (controlEndpoint == null) ? null : controlEndpoint.toString(); + } + + /** + * Specifies the URI of the enterprise device-control endpoint. + * + * @param deviceControlEnterpriseEndpoint The device-control endpoint URI. + * @return This endpoint application extension. + */ + @NotNull + public EndpointAppDeviceExtension setDeviceControlEnterpriseEndpoint( + @Nullable final URI deviceControlEnterpriseEndpoint) + { + this.deviceControlEnterpriseEndpoint = deviceControlEnterpriseEndpoint; + return this; + } + + /** + * Alternate version of {@link #setDeviceControlEnterpriseEndpoint(URI)} + * that accepts a string. + * + * @param endpoint The device-control endpoint URI. + * @return This endpoint application extension. + * + * @throws IllegalArgumentException If the string was not a valid URI. + */ + @NotNull + public EndpointAppDeviceExtension setDeviceControlEnterpriseEndpoint( + @Nullable final String endpoint) + throws IllegalArgumentException + { + return setDeviceControlEnterpriseEndpoint( + (endpoint == null) ? null : URI.create(endpoint)); + } + + /** + * Retrieves the URI of the enterprise telemetry endpoint. + * + * @return The telemetry endpoint URI. + */ + @Nullable + public URI getTelemetryEnterpriseEndpoint() + { + return telemetryEnterpriseEndpoint; + } + + /** + * Retrieves the URI of the enterprise telemetry endpoint as a string. + * + * @return The telemetry endpoint URI. + */ + @Nullable + @JsonIgnore + public String getTelemetryEnterpriseEndpointString() + { + URI telemetryEndpoint = getTelemetryEnterpriseEndpoint(); + return (telemetryEndpoint == null) ? null : telemetryEndpoint.toString(); + } + + /** + * Specifies the URI of the enterprise telemetry endpoint. + * + * @param telemetryEnterpriseEndpoint The telemetry endpoint URI. + * @return This endpoint application extension. + */ + @NotNull + public EndpointAppDeviceExtension setTelemetryEnterpriseEndpoint( + @Nullable final URI telemetryEnterpriseEndpoint) + { + this.telemetryEnterpriseEndpoint = telemetryEnterpriseEndpoint; + return this; + } + + /** + * Alternate version of {@link #setTelemetryEnterpriseEndpoint(URI)} that + * accepts a string. + * + * @param endpoint The telemetry endpoint URI. + * @return This endpoint application extension. + * + * @throws IllegalArgumentException If the string was not a valid URI. + */ + @NotNull + public EndpointAppDeviceExtension setTelemetryEnterpriseEndpoint( + @Nullable final String endpoint) + throws IllegalArgumentException + { + return setTelemetryEnterpriseEndpoint( + (endpoint == null) ? null : URI.create(endpoint)); + } + + /** + * Retrieves the list of EndpointApp resources associated with this device. + * + * @return The list of endpoint application references. + */ + @NotNull + public List getApplications() + { + return applications; + } + + /** + * Specifies the list of EndpointApp resources associated with this device. + * + * @param applications The list of endpoint application references. + * @return This endpoint application extension. + */ + @NotNull + public EndpointAppDeviceExtension setApplications( + @Nullable final List applications) + { + this.applications = (applications == null) ? List.of() : applications; + return this; + } + + /** + * Alternate version of {@link #setApplications(List)}. + * + * @param app A non-null application reference. + * @param apps An optional set of additional arguments. + * + * @return This endpoint application extension. + */ + @NotNull + public EndpointAppDeviceExtension setApplications( + @NotNull final EndpointAppReference app, + @Nullable final EndpointAppReference... apps) + { + return setApplications(StaticUtils.toList(app, apps)); + } + + /** + * Indicates whether the provided object is equal to this endpoint application + * extension. + * + * @param o The object to compare. + * @return {@code true} if the provided object is equal to this extension, + * or {@code false} if not. + */ + @Override + public boolean equals(@Nullable final Object o) + { + if (this == o) + { + return true; + } + + return o instanceof EndpointAppDeviceExtension that + && Objects.equals(deviceControlEnterpriseEndpoint, + that.deviceControlEnterpriseEndpoint) + && Objects.equals(telemetryEnterpriseEndpoint, + that.telemetryEnterpriseEndpoint) + && Objects.equals(applications, that.applications); + } + + /** + * Retrieves a hash code for this endpoint application extension. + * + * @return A hash code for this endpoint extension. + */ + @Override + public int hashCode() + { + return Objects.hash(deviceControlEnterpriseEndpoint, + telemetryEnterpriseEndpoint, applications); + } +} diff --git a/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/types/devices/EndpointAppReference.java b/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/types/devices/EndpointAppReference.java new file mode 100644 index 00000000..4732e241 --- /dev/null +++ b/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/types/devices/EndpointAppReference.java @@ -0,0 +1,201 @@ +/* + * Copyright 2026 Ping Identity Corporation + * + * 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. + */ +/* + * Copyright 2026 Ping Identity Corporation + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License (GPLv2 only) + * or the terms of the GNU Lesser General Public License (LGPLv2.1 only) + * as published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, see . + */ + +package com.unboundid.scim2.common.types.devices; + +import com.fasterxml.jackson.annotation.JsonIgnore; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.unboundid.scim2.common.annotations.Attribute; +import com.unboundid.scim2.common.annotations.NotNull; +import com.unboundid.scim2.common.annotations.Nullable; +import com.unboundid.scim2.common.types.AttributeDefinition; +import com.unboundid.scim2.common.utils.JsonUtils; + +import java.net.URI; +import java.util.Objects; +import java.util.UUID; + + +/** + * A reference to an {@link EndpointAppResource} within an + * {@link EndpointAppDeviceExtension}, as defined by RFC 9944. For more + * background on devices, see the {@link DeviceResource} class. + */ +public class EndpointAppReference +{ + @Nullable + @Attribute(description = "The identifier of the EndpointApp resource.", + isRequired = true, + isCaseExact = false) + private String value; + + @Nullable + @Attribute(description = "The URI of the EndpointApp resource.", + isRequired = false, + isCaseExact = true, + referenceTypes = { "EndpointApp" }, + mutability = AttributeDefinition.Mutability.READ_ONLY) + @JsonProperty("$ref") + private URI ref; + + /** + * Retrieves the identifier of the EndpointApp resource. + * + * @return The EndpointApp identifier. + */ + @Nullable + public String getValue() + { + return value; + } + + /** + * Specifies the identifier of the EndpointApp resource. This should generally + * be a {@link UUID}. + * + * @param value The EndpointApp identifier. + * @return This object. + */ + @NotNull + public EndpointAppReference setValue(@Nullable final String value) + { + this.value = value; + return this; + } + + /** + * Alternate method that accepts a UUID object as a value. + * + * @param value The EndpointApp identifier. + * @return This object. + */ + @NotNull + public EndpointAppReference setValue(@Nullable final UUID value) + { + return setValue(value == null ? null : value.toString()); + } + + /** + * Retrieves the URI of the EndpointApp resource. + * + * @return The URI. + */ + @Nullable + public URI getRef() + { + return ref; + } + + /** + * Retrieves the URI of the EndpointApp resource. + * + * @return The URI. + */ + @Nullable + @JsonIgnore + public String getRefString() + { + return (getRef() == null) ? null : getRef().toString(); + } + + /** + * Specifies the URI of the EndpointApp resource. + * + * @param ref The URI. + * @return This object. + */ + @NotNull + public EndpointAppReference setRef(@Nullable final URI ref) + { + this.ref = ref; + return this; + } + + /** + * Specifies a string URI of the EndpointApp resource. + * + * @param ref The URI. + * @return This object. + * + * @throws IllegalArgumentException If the provided string was not a URI. + */ + @NotNull + public EndpointAppReference setRef(@Nullable final String ref) + throws IllegalArgumentException + { + return setRef((ref == null) ? null : URI.create(ref)); + } + + /** + * Indicates whether the provided object is equal to this application entry. + * + * @param o The object to compare. + * @return {@code true} if the provided object is equal to this entry, + * or {@code false} if not. + */ + @Override + public boolean equals(@Nullable final Object o) + { + if (this == o) + { + return true; + } + + return o instanceof EndpointAppReference that + && Objects.equals(value, that.value) + && Objects.equals(ref, that.ref); + } + + /** + * Retrieves a string representation of this application entry. + * + * @return A string representation of this application entry. + */ + @Override + @NotNull + public String toString() + { + return JsonUtils.getObjectWriter().withDefaultPrettyPrinter() + .writeValueAsString(this); + } + + /** + * Retrieves a hash code for this application entry. + * + * @return A hash code for this application entry. + */ + @Override + public int hashCode() + { + return Objects.hash(value, ref); + } +} diff --git a/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/types/devices/EndpointAppResource.java b/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/types/devices/EndpointAppResource.java new file mode 100644 index 00000000..d0a592b7 --- /dev/null +++ b/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/types/devices/EndpointAppResource.java @@ -0,0 +1,356 @@ +/* + * Copyright 2026 Ping Identity Corporation + * + * 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. + */ +/* + * Copyright 2026 Ping Identity Corporation + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License (GPLv2 only) + * or the terms of the GNU Lesser General Public License (LGPLv2.1 only) + * as published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, see . + */ + +package com.unboundid.scim2.common.types.devices; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.unboundid.scim2.common.BaseScimResource; +import com.unboundid.scim2.common.annotations.Attribute; +import com.unboundid.scim2.common.annotations.NotNull; +import com.unboundid.scim2.common.annotations.Nullable; +import com.unboundid.scim2.common.annotations.Schema; +import com.unboundid.scim2.common.types.AttributeDefinition; +import com.unboundid.scim2.common.types.Group; +import com.unboundid.scim2.common.utils.StaticUtils; + +import java.util.List; +import java.util.Objects; + + +/** + * This class represents an "endpoint application" resource type as defined by + * RFC 9944 + * Section 6. An EndpointApp resource represents an external application + * that interacts with a device. The following types of applications are + * defined in SCIM: + *
    + *
  • deviceControl: Sends commands to control a device. + *
  • telemetry: Receives data from a device, generally for observability. + *
+ *

+ * + * An endpoint application resource contains the following fields: + *
    + *
  • {@code applicationType}: The type of application. Must be either + * {@code deviceControl} or {@code telemetry}. + *
  • {@code applicationName}: A human-readable name for the application. + *
  • {@code clientToken}: A read-only token used by the endpoint + * application to authenticate to the service. + *
  • {@link CertificateInfo}: Certificate info for the endpoint app. + *
  • {@code groups}: A list of groups to which the endpoint + * application belongs. + *
+ *

+ * + * The following example JSON object represents an EndpointApp resource: + *
+ * {
+ *   "schemas": [ "urn:ietf:params:scim:schemas:core:2.0:EndpointApp" ],
+ *   "id": "e9e30dba",
+ *   "meta": {
+ *     "resourceType": "EndpointApp",
+ *     "location": "https://example.com/v2/EndpointApps/e9e30dba"
+ *   },
+ *   "applicationType": "deviceControl",
+ *   "applicationName": "Device Control App 1",
+ *   "certificateInfo": {
+ *       "rootCA": "d2hhdHNVcA==",
+ *       "subjectName": "CN=EX1,O=Example,C=US"
+ *   }
+ * }
+ * 
+ *

+ * + * This JSON value can be created with the following Java code: + *

+ *   EndpointAppResource app = new EndpointAppResource()
+ *       .setApplicationType("deviceControl")
+ *       .setApplicationName("Device Control App 1")
+ *       .setCertificateInfo(new CertificateInfo()
+ *           .setRootCA("d2hhdHNVcA==")
+ *           .setSubjectName("CN=EX1,O=Example,C=US"));
+ *   app.setId("e9e30dba");
+ *   app.setMeta(new Meta()
+ *       .setResourceType("EndpointApp")
+ *       .setLocationString("https://example.com/v2/EndpointApps/e9e30dba"));
+ * 
+ *

+ * + * See {@link CertificateInfo} for more details on how SCIM services should + * handle client certificate data. + * + * @since 6.1.0 + */ +@Schema(id = "urn:ietf:params:scim:schemas:core:2.0:EndpointApp", + name = "Endpoint Application", + description = "Endpoint Application") +public class EndpointAppResource extends BaseScimResource +{ + /** + * The maximum length of a client token as defined by RFC 9944 Section 6.2. + */ + public static final int MAX_TOKEN_LENGTH = 500; + + @Nullable + @Attribute(description = "The type of the endpoint application.", + isRequired = true, + isCaseExact = false, + canonicalValues = {"deviceControl", "telemetry"}, + mutability = AttributeDefinition.Mutability.IMMUTABLE) + private String applicationType; + + @Nullable + @Attribute(description = "The human-readable name of the endpoint " + + "application.", + isRequired = true, + isCaseExact = false) + private String applicationName; + + @Nullable + @Attribute(description = """ + A read-only token used by the endpoint application to authenticate to the + service. The token is generated by the server and MUST NOT exceed 500 + characters in length (RFC 9944 Section 6.2).""", + isCaseExact = true, + mutability = AttributeDefinition.Mutability.READ_ONLY) + private String clientToken; + + @Nullable + @Attribute(description = "Certificate info for the endpoint application.") + private CertificateInfo certificateInfo; + + @NotNull + @Attribute(description = + "A list of groups to which the endpoint application belongs.", + mutability = AttributeDefinition.Mutability.READ_ONLY, + multiValueClass = Group.class) + @JsonInclude(JsonInclude.Include.NON_EMPTY) + private List groups = List.of(); + + /** + * Retrieves the application type of this endpoint application. + * + * @return The application type. + */ + @Nullable + public String getApplicationType() + { + return applicationType; + } + + /** + * Specifies the application type of this endpoint application. This + * attribute is immutable after creation. + * + * @param type The application type. + * @return This endpoint application resource. + */ + @NotNull + public EndpointAppResource setApplicationType(@Nullable final String type) + { + applicationType = type; + return this; + } + + /** + * Retrieves the human-readable name of this endpoint application. + * + * @return The application name. + */ + @Nullable + public String getApplicationName() + { + return applicationName; + } + + /** + * Specifies the human-readable name of this endpoint application. + * + * @param name The application name. + * @return This endpoint application resource. + */ + @NotNull + public EndpointAppResource setApplicationName(@Nullable final String name) + { + applicationName = name; + return this; + } + + /** + * Retrieves the client token for this endpoint application. + * + * @return The client token. + */ + @Nullable + public String getClientToken() + { + return clientToken; + } + + + /** + * Specifies the client token for this endpoint application. + * + * @param clientToken The client token. + * @return This endpoint application resource. + * + * @throws IllegalArgumentException If the provided token is longer than the + * {@link #MAX_TOKEN_LENGTH}. + */ + @NotNull + public EndpointAppResource setClientToken(@Nullable final String clientToken) + throws IllegalArgumentException + { + if (clientToken != null && clientToken.length() > MAX_TOKEN_LENGTH) + { + throw new IllegalArgumentException( + "Client token lengths cannot be greater than " + MAX_TOKEN_LENGTH); + } + + this.clientToken = clientToken; + return this; + } + + /** + * Retrieves the certificate information for this endpoint application. + * + * @return The certificate information. + */ + @Nullable + public CertificateInfo getCertificateInfo() + { + return certificateInfo; + } + + /** + * Specifies the certificate information for this endpoint application. + * + * @param certificateInfo The certificate information. + * @return This endpoint application resource. + */ + @NotNull + public EndpointAppResource setCertificateInfo( + @Nullable final CertificateInfo certificateInfo) + { + this.certificateInfo = certificateInfo; + return this; + } + + /** + * Retrieves the list of groups to which this endpoint application belongs. + * + * @return The list of groups. + */ + @NotNull + public List getGroups() + { + return groups; + } + + /** + * Specifies the list of groups to which this endpoint application belongs. + * + * @param groups The list of groups. + * @return This endpoint application resource. + */ + @NotNull + public EndpointAppResource setGroups(@Nullable final List groups) + { + this.groups = (groups == null) ? List.of() : groups; + return this; + } + + /** + * Alternate version of {@link #setGroups(List)}. + * + * @param group The first group object. + * @param groups An optional set of additional arguments. + * + * @return This endpoint application resource. + */ + @NotNull + public EndpointAppResource setGroups(@NotNull final Group group, + @Nullable final Group... groups) + { + return setGroups(StaticUtils.toList(group, groups)); + } + + /** + * Verifies whether the {@code certificateInfo} field is {@code null} or + * contains no data. This can be used by SCIM applications to check if a + * certificateInfo has fields that should be validated. + * + * @return {@code true} if the certificateInfo is {@code null} or empty. + */ + public boolean hasEmptyCertificateInfo() + { + return certificateInfo == null || (certificateInfo.getRootCA() == null + && certificateInfo.getSubjectName() == null); + } + + /** + * Indicates whether the provided object is equal to this endpoint app + * resource. + * + * @param o The object to compare. + * @return {@code true} if the provided object is equal to this endpoint + * app resource, or {@code false} if not. + */ + @Override + public boolean equals(@Nullable final Object o) + { + if (this == o) + { + return true; + } + + return o instanceof EndpointAppResource that + && super.equals(o) + && Objects.equals(applicationType, that.applicationType) + && Objects.equals(applicationName, that.applicationName) + && Objects.equals(clientToken, that.clientToken) + && Objects.equals(certificateInfo, that.certificateInfo) + && Objects.equals(groups, that.groups); + } + + /** + * Retrieves a hash code for this endpoint app resource. + * + * @return A hash code for this endpoint app resource. + */ + @Override + public int hashCode() + { + return Objects.hash(super.hashCode(), applicationType, applicationName, + clientToken, certificateInfo, groups); + } +} diff --git a/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/types/devices/EthernetMabDeviceExtension.java b/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/types/devices/EthernetMabDeviceExtension.java new file mode 100644 index 00000000..8fe73dd6 --- /dev/null +++ b/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/types/devices/EthernetMabDeviceExtension.java @@ -0,0 +1,164 @@ +/* + * Copyright 2026 Ping Identity Corporation + * + * 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. + */ +/* + * Copyright 2026 Ping Identity Corporation + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License (GPLv2 only) + * or the terms of the GNU Lesser General Public License (LGPLv2.1 only) + * as published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, see . + */ + +package com.unboundid.scim2.common.types.devices; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.unboundid.scim2.common.annotations.Attribute; +import com.unboundid.scim2.common.annotations.NotNull; +import com.unboundid.scim2.common.annotations.Nullable; +import com.unboundid.scim2.common.annotations.Schema; +import com.unboundid.scim2.common.types.AttributeDefinition; + +import java.util.Objects; + + +/** + * This class represents the Ethernet MAC Authentication Bypass (MAB) extension + * per + * RFC 9944 Section 7.3. This extension enables a legacy means of weak + * authentication that is supported in many wired Ethernet solutions. For more + * background on devices, see the {@link DeviceResource} class. + *

+ * + * The following JSON represents this extension as it appears on a device + * resource. This extension contains a single value, {@code deviceMacAddress}, + * which represents the Ethernet address to be provisioned onto the network. + *
+ * {
+ *   "schemas": [
+ *       "urn:ietf:params:scim:schemas:core:2.0:Device",
+ *       "urn:ietf:params:scim:schemas:extension:ethernet-mab:2.0:Device"
+ *   ],
+ *   "id": "e9e30dba",
+ *   "meta": {
+ *     "resourceType": "Device",
+ *     "created": "1970-01-23T04:56:22Z",
+ *     "lastModified": "1970-05-13T04:42:34Z",
+ *     "location": "https://example.com/v2/Devices/e9e30dba"
+ *   },
+ *   "displayName": "Example Ethernet Device",
+ *   "active": true,
+ *   "urn:ietf:params:scim:schemas:extension:ethernet-mab:2.0:Device": {
+ *     "deviceMacAddress": "2C:54:91:88:C9:E2"
+ *   }
+ * }
+ * 
+ *

+ * + * This extension can be added to a device with the following Java code: + *

+ *   DeviceResource device = new DeviceResource()
+ *       .setDisplayName("Example Ethernet Device")
+ *       .setActive(true)
+ *       .setDeviceExtension(new EthernetMabDeviceExtension("2C:54:91:88:C9:E2")
+ *   );
+ *   device.setId("e9e30dba");
+ *   device.setMeta(new Meta()
+ *       .setResourceType("Device")
+ *       .setCreatedMillis(1918582000L)
+ *       .setLastModifiedMillis(11421754000L)
+ *       .setLocationString("https://example.com/v2/Devices/e9e30dba"));
+ * 
+ */ +@Schema( + id = "urn:ietf:params:scim:schemas:extension:ethernet-mab:2.0:Device", + name = "Ethernet MAB", + description = "Ethernet MAB extension for a Device resource") +public class EthernetMabDeviceExtension extends DeviceExtension +{ + @NotNull + @Attribute(description = "The Ethernet MAC address of this device.", + isRequired = true, + isCaseExact = false, + mutability = AttributeDefinition.Mutability.READ_WRITE, + returned = AttributeDefinition.Returned.DEFAULT, + uniqueness = AttributeDefinition.Uniqueness.NONE, + pattern = MAC_PATTERN) + private final String deviceMacAddress; + + /** + * Creates a new Ethernet MAC Authentication Bypass (MAB) extension. + * + * @param deviceMacAddress The Ethernet MAC address of this device. + */ + @JsonCreator + public EthernetMabDeviceExtension( + @NotNull @JsonProperty(value = "deviceMacAddress", required = true) + final String deviceMacAddress) + { + this.deviceMacAddress = Objects.requireNonNull(deviceMacAddress); + } + + /** + * Retrieves the Ethernet MAC address of this device. + * + * @return The MAC address. + */ + @NotNull + public String getDeviceMacAddress() + { + return deviceMacAddress; + } + + /** + * Indicates whether the provided object is equal to this Ethernet MAB + * extension. + * + * @param o The object to compare. + * @return {@code true} if the provided object is equal to this extension, + * or {@code false} if not. + */ + @Override + public boolean equals(@Nullable final Object o) + { + if (this == o) + { + return true; + } + + return o instanceof EthernetMabDeviceExtension that + && Objects.equals(deviceMacAddress, that.deviceMacAddress); + } + + /** + * Retrieves a hash code for this Ethernet MAB extension. + * + * @return A hash code for this Ethernet MAB extension. + */ + @Override + public int hashCode() + { + return Objects.hash(deviceMacAddress); + } +} diff --git a/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/types/devices/FdoDeviceExtension.java b/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/types/devices/FdoDeviceExtension.java new file mode 100644 index 00000000..240cd7e7 --- /dev/null +++ b/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/types/devices/FdoDeviceExtension.java @@ -0,0 +1,194 @@ +/* + * Copyright 2026 Ping Identity Corporation + * + * 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. + */ +/* + * Copyright 2026 Ping Identity Corporation + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License (GPLv2 only) + * or the terms of the GNU Lesser General Public License (LGPLv2.1 only) + * as published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, see . + */ + +package com.unboundid.scim2.common.types.devices; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.unboundid.scim2.common.annotations.Attribute; +import com.unboundid.scim2.common.annotations.NotNull; +import com.unboundid.scim2.common.annotations.Nullable; +import com.unboundid.scim2.common.annotations.Schema; +import com.unboundid.scim2.common.types.AttributeDefinition; +import com.unboundid.scim2.common.utils.JsonUtils; + +import java.util.Objects; + + +/** + * This class represents a FIDO Device Onboard (FDO) device extension defined by + * RFC 9944. + * FIDO Device Onboard is an automated service that detects devices connected to + * a network, and a device management service establishes secure connections to + * these devices by leveraging public key credentials. For more background on + * devices, see the {@link DeviceResource} class. + *

+ * + * This object contains a single field, {@code fdoVoucher}. This is a + * cryptographic ownership voucher that is stored as a PEM-encoded object. It is + * declared as write-only and must never appear in SCIM service responses. + * Furthermore, the SCIM service MUST know how to process the voucher, either + * directly or by forwarding it along to an owner process. + *

+ * + * The following JSON represents a device with an FDO extension: + *
+ * {
+ *   "schemas": [
+ *     "urn:ietf:params:scim:schemas:core:2.0:Device",
+ *     "urn:ietf:params:scim:schemas:extension:fido-device-onboard:2.0:Device"
+ *   ],
+ *   "id": "e9e30dba",
+ *   "meta": {
+ *     "resourceType": "Device",
+ *     "location": "https://example.com/v2/Devices/e9e30dba"
+ *   },
+ *   "displayName": "Custom FDO Device",
+ *   "active": true,
+ *   "urn:ietf:params:scim:schemas:extension:fido-device-onboard:2.0:Device": {
+ *     "fdoVoucher": "pemEncodedVoucherValue"
+ *   }
+ * }
+ * 
+ *

+ * + * This device can be created with the following Java code: + *

+ *   String voucher = getPemEncodedVoucher();
+ *
+ *   DeviceResource device = new DeviceResource()
+ *       .setDisplayName("Custom FDO Device")
+ *       .setActive(true)
+ *       .setDeviceExtension(new FdoDeviceExtension(voucher));
+ *   device.setId("e9e30dba");
+ *   device.setMeta(new Meta()
+ *       .setResourceType("Device")
+ *       .setLocationString("https://example.com/v2/Devices/e9e30dba"));
+ * 
+ */ +@Schema( + id="urn:ietf:params:scim:schemas:extension:fido-device-onboard:2.0:Device", + name = "FIDO Device Onboard", + description = "FIDO Device Onboard extension for a Device resource") +public class FdoDeviceExtension extends DeviceExtension +{ + @NotNull + @Attribute(description = "The FDO ownership voucher for this device. This is" + + " a cryptographic secret that should never be returned in responses.", + isRequired = true, + isCaseExact = false, + mutability = AttributeDefinition.Mutability.WRITE_ONLY, + returned = AttributeDefinition.Returned.NEVER, + uniqueness = AttributeDefinition.Uniqueness.NONE) + private String fdoVoucher = ""; + + + /** + * Creates a FIDO Device Onboard (FDO) extension object. + * + * @param fdoVoucher The PEM-encoded voucher. + */ + @JsonCreator + public FdoDeviceExtension( + @NotNull @JsonProperty(value = "fdoVoucher", required = true) + final String fdoVoucher) + { + setFdoVoucher(fdoVoucher); + } + + /** + * Retrieves the FDO ownership voucher. + * + * @return The FDO voucher. + */ + @NotNull + public String getFdoVoucher() + { + return fdoVoucher; + } + + /** + * Specifies the PEM-encoded FDO ownership voucher. + * + * @param voucher The FDO voucher. + * @return This FDO extension. + */ + @NotNull + public FdoDeviceExtension setFdoVoucher(@NotNull final String voucher) + { + this.fdoVoucher = Objects.requireNonNull(voucher); + return this; + } + + /** + * Indicates whether the provided object is equal to this FDO extension. + * + * @param o The object to compare. + * @return {@code true} if the provided object is equal to this extension, + * or {@code false} if not. + */ + @Override + public boolean equals(@Nullable final Object o) + { + if (this == o) + { + return true; + } + + return o instanceof FdoDeviceExtension that + && Objects.equals(fdoVoucher, that.fdoVoucher); + } + + /** + * Retrieves a hash code for this FDO extension. + * + * @return A hash code for this FDO extension. + */ + @Override + public int hashCode() + { + return Objects.hash(fdoVoucher); + } + + /** + * Retrieves a string representation of this FDO extension, with the + * {@code fdoVoucher} value redacted. + * + * @return A string representation of this FDO extension. + */ + @Override + @NotNull + public String toString() + { + return JsonUtils.toRedactedString(this, "fdoVoucher"); + } +} diff --git a/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/types/devices/ZigbeeDeviceExtension.java b/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/types/devices/ZigbeeDeviceExtension.java new file mode 100644 index 00000000..add99d92 --- /dev/null +++ b/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/types/devices/ZigbeeDeviceExtension.java @@ -0,0 +1,224 @@ +/* + * Copyright 2026 Ping Identity Corporation + * + * 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. + */ +/* + * Copyright 2026 Ping Identity Corporation + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License (GPLv2 only) + * or the terms of the GNU Lesser General Public License (LGPLv2.1 only) + * as published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, see . + */ + +package com.unboundid.scim2.common.types.devices; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.unboundid.scim2.common.annotations.Attribute; +import com.unboundid.scim2.common.annotations.NotNull; +import com.unboundid.scim2.common.annotations.Nullable; +import com.unboundid.scim2.common.annotations.Schema; +import com.unboundid.scim2.common.types.AttributeDefinition; +import com.unboundid.scim2.common.utils.StaticUtils; + +import java.util.List; +import java.util.Objects; + + +/** + * This class represents a Zigbee device extension for a device as defined by + * RFC 9944. Zigbee + * is a suite of protocols that are generally used for low-power hardware + * devices. For more background on devices, see {@link DeviceResource}. + *

+ * + * The following fields are defined: + *
    + *
  • {@code deviceEui64Address}: A 64-bit Extended Unique Identifier + * (EUI-64) device address. + *
  • {@code versionSupport}: One or more strings of all the Zigbee + * versions supported by the device. + *
+ *

+ * + * Consider the following device with a Zigbee extension: + *
+ * {
+ *   "schemas": [
+ *       "urn:ietf:params:scim:schemas:core:2.0:Device",
+ *       "urn:ietf:params:scim:schemas:extension:zigbee:2.0:Device"
+ *   ],
+ *   "id": "e9e30dba",
+ *   "meta": {
+ *     "resourceType": "Device",
+ *     "created": "1970-01-23T04:56:22Z",
+ *     "lastModified": "1970-05-13T04:42:34Z",
+ *     "location": "https://example.com/v2/Devices/e9e30dba"
+ *   },
+ *   "displayName": "Zigbee Smart Light",
+ *   "active": true,
+ *   "urn:ietf:params:scim:schemas:extension:zigbee:2.0:Device": {
+ *     "versionSupport": [ "3.0" ],
+ *     "deviceEui64Address": "50:32:5F:FF:FE:E7:67:28"
+ *   }
+ * }
+ * 
+ *

+ * + * This JSON value can be created with the following Java code: + *

+ *   // Create the device extension.
+ *   ZigbeeDeviceExtension zigbee = new ZigbeeDeviceExtension(
+ *       "50:32:5F:FF:FE:E7:67:28", "3.0");
+ *
+ *   // Create the device.
+ *   DeviceResource device = new DeviceResource()
+ *       .setDisplayName("Zigbee Smart Light")
+ *       .setActive(true)
+ *       .setDeviceExtension(zigbee);
+ *   device.setId("e9e30dba");
+ *   device.setMeta(new Meta()
+ *       .setResourceType("Device")
+ *       .setCreatedMillis(1918582000L)
+ *       .setLastModifiedMillis(11421754000L)
+ *       .setLocationString("https://example.com/v2/Devices/e9e30dba"));
+ * 
+ */ +@Schema(id = "urn:ietf:params:scim:schemas:extension:zigbee:2.0:Device", + name = "Zigbee", + description = "Zigbee extension for a Device resource") +public class ZigbeeDeviceExtension extends DeviceExtension +{ + /** + * The regex matching Extended Unique Identifiers as defined by RFC 9944. + */ + @NotNull + public static final String EUI64_PATTERN = + "^[0-9A-Fa-f]{2}(:[0-9A-Fa-f]{2}){7}$"; + + @NotNull + @Attribute(description = "The Zigbee versions supported by this device.", + isRequired = true, + isCaseExact = false, + mutability = AttributeDefinition.Mutability.READ_WRITE, + returned = AttributeDefinition.Returned.DEFAULT, + multiValueClass = String.class) + private final List versionSupport; + + @NotNull + @Attribute(description = "The IEEE EUI-64 address of this Zigbee device." + + " Must match the pattern: " + EUI64_PATTERN, + isRequired = true, + isCaseExact = false, + mutability = AttributeDefinition.Mutability.READ_WRITE, + returned = AttributeDefinition.Returned.DEFAULT, + uniqueness = AttributeDefinition.Uniqueness.NONE, + pattern = EUI64_PATTERN) + private final String deviceEui64Address; + + /** + * Creates a new Zigbee device extension. + * + * @param versionSupport The Zigbee versions supported by this device. + * @param deviceEui64Address The 64-bit Extended Unique Identifier of this + * Zigbee device. + */ + @JsonCreator + public ZigbeeDeviceExtension( + @NotNull @JsonProperty(value = "versionSupport", required = true) + final List versionSupport, + @NotNull @JsonProperty(value = "deviceEui64Address", required = true) + final String deviceEui64Address) + { + this.versionSupport = List.copyOf(versionSupport); + this.deviceEui64Address = Objects.requireNonNull(deviceEui64Address); + } + + /** + * Creates a new Zigbee device extension. + * + * @param deviceEui64Address The 64-bit Extended Unique Identifier. + * @param version A Zigbee version supported by this device. + * @param versions Additional versions supported by this device. + */ + public ZigbeeDeviceExtension(@NotNull final String deviceEui64Address, + @NotNull final String version, + @Nullable final String... versions) + { + this(StaticUtils.toList(version, versions), deviceEui64Address); + } + + /** + * Fetches the 64-bit Extended Unique Identifier of this Zigbee device. + * + * @return The EUI-64 address. + */ + @NotNull + public String getDeviceEui64Address() + { + return deviceEui64Address; + } + + /** + * Fetches the Zigbee versions supported by this device. + * + * @return The list of supported Zigbee versions. + */ + @NotNull + public List getVersionSupport() + { + return versionSupport; + } + + /** + * Indicates whether the provided object is equal to this Zigbee device + * extension. + * + * @param o The object to compare. + * @return {@code true} if the provided object is equal to this extension, + * or {@code false} if not. + */ + @Override + public boolean equals(@Nullable final Object o) + { + if (this == o) + { + return true; + } + + return o instanceof ZigbeeDeviceExtension that + && Objects.equals(deviceEui64Address, that.deviceEui64Address) + && Objects.equals(versionSupport, that.versionSupport); + } + + /** + * Retrieves a hash code for this Zigbee device extension. + * + * @return A hash code for this Zigbee device extension. + */ + @Override + public int hashCode() + { + return Objects.hash(deviceEui64Address, versionSupport); + } +} diff --git a/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/types/devices/package-info.java b/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/types/devices/package-info.java new file mode 100644 index 00000000..c9092dd0 --- /dev/null +++ b/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/types/devices/package-info.java @@ -0,0 +1,38 @@ +/* + * Copyright 2026 Ping Identity Corporation + * + * 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. + */ +/* + * Copyright 2026 Ping Identity Corporation + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License (GPLv2 only) + * or the terms of the GNU Lesser General Public License (LGPLv2.1 only) + * as published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, see . + */ + +/** + * This package contains resource and extension model classes for RFC 9944, + * which defines device resource types. For more information, see + * {@link com.unboundid.scim2.common.types.devices.DeviceResource}. + */ +package com.unboundid.scim2.common.types.devices; 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 f43f7da9..6c5a3021 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 @@ -33,6 +33,7 @@ package com.unboundid.scim2.common.utils; import com.unboundid.scim2.common.Path; +import com.unboundid.scim2.common.ScimResource; import com.unboundid.scim2.common.annotations.NotNull; import com.unboundid.scim2.common.annotations.Nullable; import com.unboundid.scim2.common.exceptions.BadRequestException; @@ -46,8 +47,11 @@ import tools.jackson.databind.ObjectWriter; import tools.jackson.databind.json.JsonMapper; import tools.jackson.databind.node.ArrayNode; +import tools.jackson.databind.node.IntNode; import tools.jackson.databind.node.JsonNodeFactory; +import tools.jackson.databind.node.LongNode; import tools.jackson.databind.node.NullNode; +import tools.jackson.databind.node.NumericNode; import tools.jackson.databind.node.ObjectNode; import tools.jackson.databind.node.StringNode; import tools.jackson.databind.type.CollectionType; @@ -1099,6 +1103,59 @@ else if (element.isObject() || element.isArray()) } } + /** + * Converts a primitive value to a numeric JsonNode. + *

+ * + * This method is needed for consistent behavior for Java classes that store + * {@code long} values in ObjectNodes, generally within an extension schema. + * During deserialization, integer-like values are only stored as a long if + * the value requires more than 32 bits. This can cause problems when another + * object is instantiated in code with a long, and the objects are compared + * with {@code equals()}. Since the 64-bit data type is different from the + * 32-bit one, this will always return false even if the actual numbers + * represented are identical. + *

+ * + * To avoid these problems, this utility method mimics this behavior, and + * returns long values as a 32-bit numeric node unless 64 bits are necessary. + * + * @param value The number to convert to a numeric node. + * @return The numeric node. + */ + @NotNull + public static NumericNode asNumericNode(final long value) + { + return (value > Integer.MAX_VALUE || value < Integer.MIN_VALUE) + ? LongNode.valueOf(value) : IntNode.valueOf((int) value); + } + + /** + * Serializes an object to a pretty-printed JSON string, overriding values + * of top-level field names that match the provided list. + * + * @param resource The resource to serialize. + * @param redactedFields The names of the fields to redact. + * + * @return A pretty-printed JSON string with the specified fields redacted. + */ + @NotNull + public static String toRedactedString(@NotNull final ScimResource resource, + @NotNull final String... redactedFields) + { + ObjectNode node = valueToNode(resource); + for (String field : redactedFields) + { + if (node.has(field)) + { + node.put(field, "--REDACTED--"); + } + } + + return getObjectWriter().withDefaultPrettyPrinter() + .writeValueAsString(node); + } + /** * Factory method for constructing a SCIM compatible Jackson * {@link ObjectReader} with default settings. Note that the resulting diff --git a/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/utils/SchemaUtils.java b/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/utils/SchemaUtils.java index a5bb4455..c2da6e96 100644 --- a/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/utils/SchemaUtils.java +++ b/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/utils/SchemaUtils.java @@ -254,6 +254,7 @@ private static Collection getAttributes( addMutability(attributeBuilder, schemaProperty); addMultiValued(attributeBuilder, propertyDescriptor, schemaProperty); addCanonicalValues(attributeBuilder, schemaProperty); + addPattern(attributeBuilder, schemaProperty); Class propertyCls = propertyDescriptor.getPropertyType(); @@ -292,10 +293,8 @@ private static Collection getAttributes( * @param propertyDescriptor property descriptor for the field to build * the attribute for. * @param jsonProperty the Jackson JsonProperty annotation for the field. - * @return this. */ - @NotNull - private static AttributeDefinition.Builder addName( + private static void addName( @NotNull final AttributeDefinition.Builder attributeBuilder, @NotNull final PropertyDescriptor propertyDescriptor, @Nullable final JsonProperty jsonProperty) @@ -309,8 +308,6 @@ private static AttributeDefinition.Builder addName( { attributeBuilder.setName(propertyDescriptor.getName()); } - - return attributeBuilder; } /** @@ -322,10 +319,8 @@ private static AttributeDefinition.Builder addName( * the attribute for. * @param schemaProperty the schema property annotation for the field * to build an attribute for. - * @return this. */ - @NotNull - private static AttributeDefinition.Builder addMultiValued( + private static void addMultiValued( @NotNull final AttributeDefinition.Builder attributeBuilder, @NotNull final PropertyDescriptor propertyDescriptor, @NotNull final Attribute schemaProperty) @@ -354,8 +349,6 @@ private static AttributeDefinition.Builder addMultiValued( } attributeBuilder.setMultiValued(multiValued); - - return attributeBuilder; } /** @@ -365,10 +358,8 @@ private static AttributeDefinition.Builder addMultiValued( * @param attributeBuilder builder for a scim attribute. * @param schemaProperty the schema property annotation for the field * to build an attribute for. - * @return this. */ - @NotNull - private static AttributeDefinition.Builder addDescription( + private static void addDescription( @NotNull final AttributeDefinition.Builder attributeBuilder, @Nullable final Attribute schemaProperty) { @@ -376,8 +367,6 @@ private static AttributeDefinition.Builder addDescription( { attributeBuilder.setDescription(schemaProperty.description()); } - - return attributeBuilder; } /** @@ -387,10 +376,8 @@ private static AttributeDefinition.Builder addDescription( * @param attributeBuilder builder for a scim attribute. * @param schemaProperty the schema property annotation for the field * to build an attribute for. - * @return this. */ - @NotNull - private static AttributeDefinition.Builder addCaseExact( + private static void addCaseExact( @NotNull final AttributeDefinition.Builder attributeBuilder, @Nullable final Attribute schemaProperty) { @@ -398,8 +385,6 @@ private static AttributeDefinition.Builder addCaseExact( { attributeBuilder.setCaseExact(schemaProperty.isCaseExact()); } - - return attributeBuilder; } /** @@ -409,10 +394,8 @@ private static AttributeDefinition.Builder addCaseExact( * @param attributeBuilder builder for a scim attribute. * @param schemaProperty the schema property annotation for the field * to build an attribute for. - * @return this. */ - @NotNull - private static AttributeDefinition.Builder addRequired( + private static void addRequired( @NotNull final AttributeDefinition.Builder attributeBuilder, @Nullable final Attribute schemaProperty) { @@ -420,8 +403,6 @@ private static AttributeDefinition.Builder addRequired( { attributeBuilder.setRequired(schemaProperty.isRequired()); } - - return attributeBuilder; } /** @@ -431,10 +412,8 @@ private static AttributeDefinition.Builder addRequired( * @param attributeBuilder builder for a scim attribute. * @param schemaProperty the schema property annotation for the field * to build an attribute for. - * @return this. */ - @NotNull - private static AttributeDefinition.Builder addCanonicalValues( + private static void addCanonicalValues( @NotNull final AttributeDefinition.Builder attributeBuilder, @Nullable final Attribute schemaProperty) { @@ -442,8 +421,6 @@ private static AttributeDefinition.Builder addCanonicalValues( { attributeBuilder.addCanonicalValues(schemaProperty.canonicalValues()); } - - return attributeBuilder; } /** @@ -453,10 +430,8 @@ private static AttributeDefinition.Builder addCanonicalValues( * @param attributeBuilder builder for a scim attribute. * @param schemaProperty the schema property annotation for the field * to build an attribute for. - * @return this. */ - @NotNull - private static AttributeDefinition.Builder addReturned( + private static void addReturned( @NotNull final AttributeDefinition.Builder attributeBuilder, @Nullable final Attribute schemaProperty) { @@ -464,8 +439,6 @@ private static AttributeDefinition.Builder addReturned( { attributeBuilder.setReturned(schemaProperty.returned()); } - - return attributeBuilder; } /** @@ -475,10 +448,8 @@ private static AttributeDefinition.Builder addReturned( * @param attributeBuilder builder for a scim attribute. * @param schemaProperty the schema property annotation for the field * to build an attribute for. - * @return this. */ - @NotNull - private static AttributeDefinition.Builder addUniqueness( + private static void addUniqueness( @NotNull final AttributeDefinition.Builder attributeBuilder, @Nullable final Attribute schemaProperty) { @@ -486,8 +457,6 @@ private static AttributeDefinition.Builder addUniqueness( { attributeBuilder.setUniqueness(schemaProperty.uniqueness()); } - - return attributeBuilder; } /** @@ -497,10 +466,8 @@ private static AttributeDefinition.Builder addUniqueness( * @param attributeBuilder builder for a scim attribute. * @param schemaProperty the schema property annotation for the field * to build an attribute for. - * @return this. */ - @NotNull - private static AttributeDefinition.Builder addReferenceTypes( + private static void addReferenceTypes( @NotNull final AttributeDefinition.Builder attributeBuilder, @Nullable final Attribute schemaProperty) { @@ -508,8 +475,24 @@ private static AttributeDefinition.Builder addReferenceTypes( { attributeBuilder.addReferenceTypes(schemaProperty.referenceTypes()); } + } - return attributeBuilder; + /** + * This method will find the regular expression constraint for the attribute, + * and add it to the builder if the annotation specifies a non-empty pattern. + * + * @param attributeBuilder builder for a scim attribute. + * @param schemaProperty the schema property annotation for the field + * to build an attribute for. + */ + private static void addPattern( + @NotNull final AttributeDefinition.Builder attributeBuilder, + @Nullable final Attribute schemaProperty) + { + if (schemaProperty != null && !schemaProperty.pattern().isEmpty()) + { + attributeBuilder.setPattern(schemaProperty.pattern()); + } } /** @@ -520,10 +503,8 @@ private static AttributeDefinition.Builder addReferenceTypes( * @param attributeBuilder builder for a scim attribute. * @param schemaProperty the schema property annotation for the field * to build an attribute for. - * @return this. */ - @NotNull - private static AttributeDefinition.Builder addMutability( + private static void addMutability( @NotNull final AttributeDefinition.Builder attributeBuilder, @Nullable final Attribute schemaProperty) { @@ -535,8 +516,6 @@ private static AttributeDefinition.Builder addMutability( { attributeBuilder.setMutability(AttributeDefinition.Mutability.READ_WRITE); } - - return attributeBuilder; } /** @@ -551,8 +530,8 @@ private static AttributeDefinition.Builder addMutability( private static AttributeDefinition.Type getAttributeType( @NotNull final Class cls) { - if ((cls == Integer.class) || - (cls == int.class)) + if (cls == Integer.class || cls == int.class + || cls == Long.class || cls == long.class) { return AttributeDefinition.Type.INTEGER; } @@ -737,7 +716,7 @@ public static String getSchemaUrn(@NotNull final Class cls) // The 'schemaId' is the URN. Make sure it begins with the "urn:" prefix. // The 'name' field is a human-friendly name for the object. String schemaId = - SchemaUtils.getSchemaIdFromAnnotation(cls); + SchemaUtils.getSchemaIdFromAnnotation(cls); if ((schemaId == null) || (schemaId.isEmpty())) { diff --git a/scim2-sdk-common/src/test/java/com/unboundid/scim2/common/types/AttributeDefinitionTest.java b/scim2-sdk-common/src/test/java/com/unboundid/scim2/common/types/AttributeDefinitionTest.java index df6ff459..253ddee1 100644 --- a/scim2-sdk-common/src/test/java/com/unboundid/scim2/common/types/AttributeDefinitionTest.java +++ b/scim2-sdk-common/src/test/java/com/unboundid/scim2/common/types/AttributeDefinitionTest.java @@ -116,7 +116,8 @@ public void testAttributeSerialization() String minimalJson = """ { "name": "attrName", - "multiValued": false + "multiValued": false, + "pattern": "[a-z]+" }"""; AttributeDefinition deserializedMinimal = reader.readValue(minimalJson); @@ -128,6 +129,7 @@ public void testAttributeSerialization() .setReturned(AttributeDefinition.Returned.DEFAULT) .setUniqueness(AttributeDefinition.Uniqueness.NONE) .setCaseExact(false) + .setPattern("[a-z]+") .build(); assertThat(deserializedMinimal).isEqualTo(expectedMinimal); @@ -145,6 +147,20 @@ public void testAttributeSerialization() }"""; assertThatThrownBy(() -> reader.readValue(noMultiValued)) .isInstanceOf(JacksonException.class); + + // Attributes cannot use patterns on non-string types. + String invalidPattern = """ + { + "name": "attrName", + "type": "boolean", + "multiValued": false, + "pattern": "*" + }"""; + assertThatThrownBy(() -> reader.readValue(invalidPattern)) + .isInstanceOf(JacksonException.class) + .hasMessageContaining("Cannot set the 'pattern' of an attribute for") + .hasMessageContaining("non-string types"); + } /** diff --git a/scim2-sdk-common/src/test/java/com/unboundid/scim2/common/types/devices/DevicesTest.java b/scim2-sdk-common/src/test/java/com/unboundid/scim2/common/types/devices/DevicesTest.java new file mode 100644 index 00000000..de7c07c5 --- /dev/null +++ b/scim2-sdk-common/src/test/java/com/unboundid/scim2/common/types/devices/DevicesTest.java @@ -0,0 +1,871 @@ +/* + * Copyright 2026 Ping Identity Corporation + * + * 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. + */ +/* + * Copyright 2026 Ping Identity Corporation + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License (GPLv2 only) + * or the terms of the GNU Lesser General Public License (LGPLv2.1 only) + * as published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, see . + */ + +package com.unboundid.scim2.common.types.devices; + +import com.unboundid.scim2.common.types.Group; +import com.unboundid.scim2.common.types.Meta; +import com.unboundid.scim2.common.utils.JsonUtils; +import org.testng.annotations.Test; + +import java.net.URI; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.assertj.core.api.Assertions.assertThat; + + +/** + * This class contains tests for {@link DeviceResource}, device extensions such + * as {@link BleDeviceExtension}, and more of the {@code devices} package. + */ +@Test +public class DevicesTest +{ + /** + * Tests the base {@link DeviceResource} class. + */ + @Test + public void testDeviceResource() + { + String json = """ + { + "schemas": [ "urn:ietf:params:scim:schemas:core:2.0:Device" ], + "id": "e9e30dba-f08f-4109-8486-d5c6a3316111", + "displayName": "BLE Example Monitor", + "active": true + }"""; + String expectedJson = JsonUtils.getObjectReader().readTree(json) + .toPrettyString(); + + DeviceResource device = new DeviceResource() + .setDisplayName("BLE Example Monitor") + .setActive(true); + device.setId("e9e30dba-f08f-4109-8486-d5c6a3316111"); + + assertThat(device.getId()) + .isEqualTo("e9e30dba-f08f-4109-8486-d5c6a3316111"); + assertThat(device.getDisplayName()).isEqualTo("BLE Example Monitor"); + assertThat(device.getActive()).isTrue(); + assertThat(device.getMudUrlString()).isNull(); + assertThat(device.getGroups()).isNotNull().isEmpty(); + assertThat(device.getActive()).isTrue(); + assertThat(device.toString()).isEqualTo(expectedJson); + + String serialized = JsonUtils.getObjectWriter().writeValueAsString(device); + DeviceResource deserialized = JsonUtils.getObjectReader() + .forType(DeviceResource.class).readValue(serialized); + assertThat(device).isEqualTo(deserialized); + + Group group = new Group() + .setValue("beef") + .setRef("https://example.com/Groups/beef") + .setDisplay("Admins") + .setType("direct"); + + DeviceResource device2 = new DeviceResource() + .setDisplayName("BLE Example Monitor") + .setActive(false) + .setMudUrl("https://example.com/mud/device.json") + .setGroups(group); + device2.setId("e9e30dba-f08f-4109-8486-d5c6a3316111"); + + assertThat(device2.getDisplayName()).isEqualTo("BLE Example Monitor"); + assertThat(device2.getActive()).isFalse(); + assertThat(device2.getMudUrl()) + .isEqualTo(URI.create("https://example.com/mud/device.json")); + assertThat(device2.getMudUrlString()) + .isEqualTo("https://example.com/mud/device.json"); + assertThat(device2.getGroups()).containsExactly(group); + assertThat(device2.toString()).contains("https://example.com/Groups/beef"); + assertThat(group.toString()).contains("https://example.com/Groups/beef"); + } + + /** + * Tests for {@link EndpointAppResource}. + */ + @Test + public void testEndpointAppResource() + { + String json = """ + { + "schemas": [ "urn:ietf:params:scim:schemas:core:2.0:EndpointApp" ], + "id": "e9e30dba-f08f-4109-8486-d5c6a3316212", + "applicationType": "deviceControl", + "applicationName": "Device Control App 1", + "certificateInfo": { + "rootCA": "MIIBIjAN...", + "subjectName": "www.example.com" + } + }"""; + String expectedJson = JsonUtils.getObjectReader().readTree(json) + .toPrettyString(); + + CertificateInfo cert = new CertificateInfo() + .setRootCA("MIIBIjAN...") + .setSubjectName("www.example.com"); + + EndpointAppResource app = new EndpointAppResource() + .setApplicationType("deviceControl") + .setApplicationName("Device Control App 1") + .setCertificateInfo(cert); + app.setId("e9e30dba-f08f-4109-8486-d5c6a3316212"); + + assertThat(app.getId()).isEqualTo("e9e30dba-f08f-4109-8486-d5c6a3316212"); + assertThat(app.getApplicationType()).isEqualTo("deviceControl"); + assertThat(app.getApplicationName()).isEqualTo("Device Control App 1"); + assertThat(app.getCertificateInfo()).isEqualTo(cert); + assertThat(app.hasEmptyCertificateInfo()).isFalse(); + assertThat(app.toString()).isEqualTo(expectedJson); + + String serialized = JsonUtils.getObjectWriter().writeValueAsString(app); + EndpointAppResource deserialized = JsonUtils.getObjectReader() + .forType(EndpointAppResource.class).readValue(serialized); + assertThat(app).isEqualTo(deserialized); + + Group group = new Group().setValue("group1").setType("direct"); + EndpointAppResource rich = new EndpointAppResource() + .setApplicationType("telemetry") + .setApplicationName("Telemetry App") + .setClientToken("tok-secret") + .setCertificateInfo(cert) + .setGroups(group); + rich.setId("e9e30dba-f08f-4109-8486-d5c6a3316212"); + + assertThat(rich.getClientToken()).isEqualTo("tok-secret"); + assertThat(rich.getGroups()).containsExactly(group); + + String richSerialized = JsonUtils.getObjectWriter().writeValueAsString(rich); + EndpointAppResource richDeserialized = JsonUtils.getObjectReader() + .forType(EndpointAppResource.class).readValue(richSerialized); + assertThat(rich).isEqualTo(richDeserialized); + + // Validate utility methods. + EndpointAppResource emptyApp = new EndpointAppResource(); + assertThat(emptyApp.hasEmptyCertificateInfo()).isTrue(); + emptyApp = emptyApp.setCertificateInfo(new CertificateInfo()); + assertThat(emptyApp.hasEmptyCertificateInfo()).isTrue(); + + // Ensure max length restrictions on client tokens. + assertThatThrownBy(() -> new EndpointAppResource() + .setClientToken("a".repeat(501))) + .isInstanceOf(IllegalArgumentException.class); + } + + /** + * Tests for {@link CertificateInfo}. + */ + @Test + public void testCertificateInfo() + { + // The subjectName DN is printed in the form typically seen in certs. + String json = """ + { + "rootCA": "SGV5U3RvcERlY29kaW5nTWU=", + "subjectName": "CN=EX1, O=Example, C=US" + }"""; + String expectedJson = JsonUtils.getObjectReader().readTree(json) + .toPrettyString(); + + CertificateInfo cert = new CertificateInfo() + .setRootCA("SGV5U3RvcERlY29kaW5nTWU=") + .setSubjectName("CN=EX1, O=Example, C=US"); + + assertThat(cert.getRootCA()).isEqualTo("SGV5U3RvcERlY29kaW5nTWU="); + assertThat(cert.getSubjectName()).isEqualTo("CN=EX1, O=Example, C=US"); + assertThat(cert.toString()).isEqualTo(expectedJson); + + String serialized = JsonUtils.getObjectWriter().writeValueAsString(cert); + CertificateInfo deserialized = JsonUtils.getObjectReader() + .forType(CertificateInfo.class).readValue(serialized); + assertThat(cert).isEqualTo(deserialized); + } + + /** + * Tests for {@link BleDeviceExtension}. + */ + @Test + public void testBleDeviceExtension() + { + String json = """ + { + "schemas": [ + "urn:ietf:params:scim:schemas:core:2.0:Device", + "urn:ietf:params:scim:schemas:extension:ble:2.0:Device" + ], + "id": "e9e30dba-f08f-4109-8486-d5c6a3316111", + "displayName": "Bluetooth Low Energy Sample Device", + "active": true, + "urn:ietf:params:scim:schemas:extension:ble:2.0:Device": { + "versionSupport": [ "5.4" ], + "deviceMacAddress": "2C:54:91:88:C9:E2", + "isRandom": false, + "mobility": true, + "separateBroadcastAddress": [ + "AA:BB:88:77:22:11", + "AA:BB:88:77:22:12" + ], + "pairingMethods": [ + "urn:ietf:params:scim:schemas:extension:pairingJustWorks:2.0:Device", + "urn:ietf:params:scim:schemas:extension:pairingNull:2.0:Device", + "urn:ietf:params:scim:schemas:extension:pairingOOB:2.0:Device", + "urn:ietf:params:scim:schemas:extension:pairingPassKey:2.0:Device" + ], + "urn:ietf:params:scim:schemas:extension:pairingJustWorks:2.0:Device": { + "key": null + }, + "urn:ietf:params:scim:schemas:extension:pairingNull:2.0:Device": { }, + "urn:ietf:params:scim:schemas:extension:pairingOOB:2.0:Device": { + "key": "OOB_Key", + "randomNumber": 1828, + "confirmationNumber": 3 + }, + "urn:ietf:params:scim:schemas:extension:pairingPassKey:2.0:Device": { + "key": 12 + } + } + }"""; + String expectedJson = JsonUtils.getObjectReader().readTree(json) + .toPrettyString(); + + // Obtain the device and its nested extension in object form. + DeviceResource deserialized = JsonUtils.getObjectReader() + .forType(DeviceResource.class).readValue(expectedJson); + BleDeviceExtension extension = + deserialized.getExtension(BleDeviceExtension.class); + assertThat(extension).isNotNull(); + + // Evaluate the individual fields of the extension first. + assertThat(extension.getDeviceMacAddress()).isEqualTo("2C:54:91:88:C9:E2"); + assertThat(extension.getIrk()).isNull(); + assertThat(extension.getIsRandom()).isFalse(); + assertThat(extension.getSeparateBroadcastAddress()) + .containsExactly("AA:BB:88:77:22:11", "AA:BB:88:77:22:12"); + assertThat(extension.getMobility()).isTrue(); + assertThat(extension.getVersionSupport()).containsExactly("5.4"); + assertThat(extension.getPairingMethods()).containsExactly( + "urn:ietf:params:scim:schemas:extension:pairingJustWorks:2.0:Device", + "urn:ietf:params:scim:schemas:extension:pairingNull:2.0:Device", + "urn:ietf:params:scim:schemas:extension:pairingOOB:2.0:Device", + "urn:ietf:params:scim:schemas:extension:pairingPassKey:2.0:Device" + ); + + // Fetching the elements in object form should result in a list in the same + // order. + assertThat(extension.getPairingMethodExtensions()).containsExactly( + new BlePairingJustWorks(), + new BlePairingNull(), + new BlePairingOutOfBand("OOB_Key", 1828).setConfirmationNumber(3), + new BlePairingPassKey("000012")); + + // Instantiate an object equivalent to the JSON. + BleDeviceExtension ble = + new BleDeviceExtension("2C:54:91:88:C9:E2", "5.4") + .setIsRandom(false) + .setSeparateBroadcastAddress( + "AA:BB:88:77:22:11", "AA:BB:88:77:22:12") + .setMobility(true) + .setPairingExtension(new BlePairingPassKey(12)) + .setPairingExtension(new BlePairingOutOfBand("OOB_Key", 1828, 3)) + .setPairingExtension(new BlePairingNull()) + .setPairingExtension(new BlePairingJustWorks()); + + DeviceResource device = new DeviceResource() + .setDisplayName("Bluetooth Low Energy Sample Device") + .setActive(true) + .setDeviceExtension(ble); + device.setId("e9e30dba-f08f-4109-8486-d5c6a3316111"); + assertThat(device).isEqualTo(deserialized); + + // Check the overall JSON form of the object when it is serialized. + assertThat(device.toString()).isEqualTo(expectedJson); + + // Check the order for pairing method URNs and objects. These were added in + // non-alphabetical order, but should still return a consistent order. + assertThat(ble.getPairingMethods()) + .containsExactlyElementsOf(extension.getPairingMethods()); + assertThat(ble.getPairingMethodExtensions()) + .containsExactlyElementsOf(extension.getPairingMethodExtensions()); + + // Try obtaining a field stored on a nested BlePairingMethod object. + assertThat(ble.getPairingMethodExtensions()) + .filteredOn(p -> p instanceof BlePairingOutOfBand) + .hasSize(1) + .first() + .isInstanceOfSatisfying(BlePairingOutOfBand.class, + oob -> assertThat(oob.getRandomNumber()).isEqualTo(1828)); + + // Test removing the pairing method from the device. + int originalSize = ble.getPairingMethodExtensions().size(); + assertThat(ble.getPairingMethodExtensions()).hasSize(originalSize); + assertThat(ble.getPairingMethods()).hasSize(originalSize); + assertThat(ble).isEqualTo(deserialized.getExtension(BleDeviceExtension.class)); + + ble = ble.removePairingExtension(BlePairingOutOfBand.class); + assertThat(ble.getPairingMethodExtensions()) + .doesNotHaveAnyElementsOfTypes(BlePairingOutOfBand.class); + assertThat(ble.getPairingMethodExtensions()).hasSize(originalSize - 1); + assertThat(ble.getPairingMethods()).hasSize(originalSize - 1); + assertThat(ble) + .isNotEqualTo(deserialized.getExtension(BleDeviceExtension.class)); + + // Test manually setting the pairing methods. + ble.setPairingMethods(List.of("urn:customValue")); + assertThat(ble.getPairingMethods()).containsOnly("urn:customValue"); + + // Ensure the irk field can be set. + BleDeviceExtension withIrk = + new BleDeviceExtension("2C:54:91:88:C9:E2", "5.4") + .setIsRandom(false) + .setIrk("resolvingKey") + .setMobility(true); + + assertThat(withIrk.getIrk()).isEqualTo("resolvingKey"); + + // Calling toString() should not print the IRK to avoid leaking the + // value in log messages. + String serialized = JsonUtils.getObjectWriter().writeValueAsString(withIrk); + assertThat(serialized).contains(withIrk.getIrk()); + assertThat(withIrk.toString()).contains("--REDACTED--") + .doesNotContain(withIrk.getIrk()); + } + + /** + * Tests for {@link BlePairingJustWorks}. + */ + @Test + public void testBlePairingJustWorks() + { + // The SCIM SDK generally does not print null values. However, printing null + // is explicitly mandated for the Just Works pairing method. + String json = """ + { + "key": null + }"""; + String expectedJson = JsonUtils.getObjectReader().readTree(json) + .toPrettyString(); + + String serialized = new BlePairingJustWorks().toString(); + assertThat(serialized).isEqualTo(expectedJson); + + BlePairingJustWorks deserialized = JsonUtils.getObjectReader() + .forType(BlePairingJustWorks.class).readValue(json); + assertThat(deserialized).isEqualTo(new BlePairingJustWorks()); + + // Setting schema URNs should fail for any BLE pairing method. + assertThatThrownBy(() -> deserialized.setSchemaUrns(List.of())) + .isInstanceOf(UnsupportedOperationException.class); + } + + /** + * Tests for {@link BlePairingNull}. + */ + @Test + public void testBlePairingNull() + { + String json = "{}"; + BlePairingNull object = new BlePairingNull(); + + String serialized = JsonUtils.getObjectWriter().withDefaultPrettyPrinter() + .writeValueAsString(object); + String expectedJson = JsonUtils.getObjectReader().readTree(json) + .toPrettyString(); + assertThat(serialized).isEqualTo(expectedJson); + + BlePairingNull deserialized = JsonUtils.getObjectReader() + .forType(BlePairingNull.class).readValue(serialized); + assertThat(object).isEqualTo(deserialized); + } + + /** + * Tests for {@link BlePairingOutOfBand}. + */ + @Test + public void testBlePairingOOB() + { + String json = """ + { + "key": "TheKeyvalueRetrievedFromOOB", + "randomNumber": 987654 + }"""; + String expectedJson = JsonUtils.getObjectReader().readTree(json) + .toPrettyString(); + + BlePairingOutOfBand figure6 = new BlePairingOutOfBand( + "TheKeyvalueRetrievedFromOOB", 987654); + + assertThat(figure6.getKey()).isEqualTo("TheKeyvalueRetrievedFromOOB"); + assertThat(figure6.getRandomNumber()).isEqualTo(987654); + assertThat(figure6.toString()).isEqualTo(expectedJson); + + String serialized = JsonUtils.getObjectWriter().writeValueAsString(figure6); + BlePairingOutOfBand deserialized = JsonUtils.getObjectReader() + .forType(BlePairingOutOfBand.class).readValue(serialized); + assertThat(figure6).isEqualTo(deserialized); + + BlePairingOutOfBand withConfirmation = new BlePairingOutOfBand( + "TheKeyvalueRetrievedFromOOB", 987654) + .setConfirmationNumber(111222); + + assertThat(withConfirmation.getConfirmationNumber()).isEqualTo(111222); + + String fullSerialized = + JsonUtils.getObjectWriter().writeValueAsString(withConfirmation); + BlePairingOutOfBand fullDeserialized = JsonUtils.getObjectReader() + .forType(BlePairingOutOfBand.class).readValue(fullSerialized); + assertThat(withConfirmation).isEqualTo(fullDeserialized); + } + + /** + * Tests for {@link BlePairingPassKey}. + */ + @Test + public void testBlePairingPassKey() + { + // Leading zeroes are not permitted in JSON integers. This passkey's value + // is actually "003456". + String json = """ + { + "key": 3456 + }"""; + String expectedJson = JsonUtils.getObjectReader().readTree(json) + .toPrettyString(); + + BlePairingPassKey passKey = new BlePairingPassKey(3456); + + assertThat(passKey.getKey()).isEqualTo(3456); + assertThat(passKey.toString()).isEqualTo(expectedJson); + + String serialized = JsonUtils.getObjectWriter().writeValueAsString(passKey); + BlePairingPassKey deserialized = JsonUtils.getObjectReader() + .forType(BlePairingPassKey.class).readValue(serialized); + assertThat(passKey).isEqualTo(deserialized); + + assertThat(passKey.getKeyAsString()).isEqualTo("003456"); + + // Attempt using values that are out of bounds. + assertThatThrownBy(() -> new BlePairingPassKey(-1)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("The provided key was not a six digit value"); + assertThatThrownBy(() -> new BlePairingPassKey(1_000_000)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("The provided key was not a six digit value"); + } + + /** + * Tests for {@link DppDeviceExtension}. + */ + @Test + public void testDppDeviceExtension() + { + String json = """ + { + "schemas": [ + "urn:ietf:params:scim:schemas:core:2.0:Device", + "urn:ietf:params:scim:schemas:extension:dpp:2.0:Device" + ], + "id": "e9e30dba-f08f-4109-8486-d5c6a3316111", + "displayName": "WiFi Example Monitor", + "active": true, + "urn:ietf:params:scim:schemas:extension:dpp:2.0:Device": { + "dppVersion": 2, + "bootstrappingMethod": [ "QR" ], + "bootstrapKey": "V2F5IGJleW9uZCBjcmF6eSwgU2hhZHkgZ29uZQ==", + "deviceMacAddress": "2C:54:91:88:C9:F2", + "classChannel": [ "81/1", "115/36" ], + "serialNumber": "4774LH2b4044" + } + }"""; + String expectedJson = JsonUtils.getObjectReader().readTree(json) + .toPrettyString(); + + DppDeviceExtension dpp = + new DppDeviceExtension(2, "V2F5IGJleW9uZCBjcmF6eSwgU2hhZHkgZ29uZQ==") + .setDeviceMacAddress("2C:54:91:88:C9:F2") + .setSerialNumber("4774LH2b4044") + .setBootstrappingMethod("QR") + .setClassChannel("81/1", "115/36"); + + DeviceResource device = new DeviceResource() + .setDisplayName("WiFi Example Monitor") + .setActive(true) + .setDeviceExtension(dpp); + device.setId("e9e30dba-f08f-4109-8486-d5c6a3316111"); + + assertThat(device.getDeviceExtensions()).containsOnly(dpp); + assertThat(dpp.getDppVersion()).isEqualTo(2); + assertThat(dpp.getBootstrapKey()) + .isEqualTo("V2F5IGJleW9uZCBjcmF6eSwgU2hhZHkgZ29uZQ=="); + assertThat(dpp.getDeviceMacAddress()).isEqualTo("2C:54:91:88:C9:F2"); + assertThat(dpp.getSerialNumber()).isEqualTo("4774LH2b4044"); + assertThat(dpp.getBootstrappingMethod()).containsExactly("QR"); + assertThat(dpp.getClassChannel()).containsExactly("81/1", "115/36"); + assertThat(device.toString()).isEqualTo(expectedJson); + + String serialized = JsonUtils.getObjectWriter().writeValueAsString(device); + DeviceResource deserialized = JsonUtils.getObjectReader() + .forType(DeviceResource.class).readValue(serialized); + assertThat(device).isEqualTo(deserialized); + assertThat(deserialized.getDeviceExtensions()).containsOnly(dpp); + + // Calling toString() should not print the secret key to avoid leaking the + // value in log messages. + assertThat(serialized).contains(dpp.getBootstrapKey()); + assertThat(dpp.toString()).contains("--REDACTED--") + .doesNotContain(dpp.getBootstrapKey()); + } + + /** + * Tests for {@link EthernetMabDeviceExtension}. + */ + @Test + public void testEthernetMabDeviceExtension() + { + String json = """ + { + "schemas": [ + "urn:ietf:params:scim:schemas:core:2.0:Device", + "urn:ietf:params:scim:schemas:extension:ethernet-mab:2.0:Device" + ], + "id": "e9e30dba-f08f-4109-8486-d5c6a3316111", + "displayName": "Example Ethernet Device", + "active": true, + "urn:ietf:params:scim:schemas:extension:ethernet-mab:2.0:Device": { + "deviceMacAddress": "2C:54:91:88:C9:E2" + } + }"""; + String expectedJson = JsonUtils.getObjectReader().readTree(json) + .toPrettyString(); + + EthernetMabDeviceExtension mab = + new EthernetMabDeviceExtension("2C:54:91:88:C9:E2"); + + DeviceResource device = new DeviceResource() + .setDisplayName("Example Ethernet Device") + .setActive(true) + .setDeviceExtension(mab); + device.setId("e9e30dba-f08f-4109-8486-d5c6a3316111"); + + assertThat(device.getDeviceExtensions()).containsOnly(mab); + assertThat(mab.getDeviceMacAddress()).isEqualTo("2C:54:91:88:C9:E2"); + assertThat(device.toString()).isEqualTo(expectedJson); + + String serialized = JsonUtils.getObjectWriter().writeValueAsString(device); + DeviceResource deserialized = JsonUtils.getObjectReader() + .forType(DeviceResource.class).readValue(serialized); + assertThat(device).isEqualTo(deserialized); + assertThat(deserialized.getDeviceExtensions()).containsOnly(mab); + } + + /** + * Tests for {@link FdoDeviceExtension}. + */ + @Test + public void testFidoDeviceOnboardExtension() + { + // fdoVoucher, like other secret fields, can be present in client requests, + // but will be removed in server responses by scim2-sdk-server's + // ResourcePreparer, or by other alternative means. + String json = """ + { + "schemas": [ + "urn:ietf:params:scim:schemas:core:2.0:Device", + "urn:ietf:params:scim:schemas:extension:fido-device-onboard:2.0:Device" + ], + "id": "e9e30dba-f08f-4109-8486-d5c6a3316111", + "displayName": "Example Ethernet Device", + "active": true, + "urn:ietf:params:scim:schemas:extension:fido-device-onboard:2.0:Device": { + "fdoVoucher": "voucher" + } + }"""; + String expectedJson = JsonUtils.getObjectReader().readTree(json) + .toPrettyString(); + + FdoDeviceExtension fdo = new FdoDeviceExtension("voucher"); + + DeviceResource device = new DeviceResource() + .setDisplayName("Example Ethernet Device") + .setActive(true) + .setDeviceExtension(fdo); + device.setId("e9e30dba-f08f-4109-8486-d5c6a3316111"); + + assertThat(device.getDeviceExtensions()).containsOnly(fdo); + assertThat(fdo.getFdoVoucher()).isEqualTo("voucher"); + assertThat(device.toString()).isEqualTo(expectedJson); + + fdo = fdo.setFdoVoucher("newVoucher"); + assertThat(fdo.getFdoVoucher()).isEqualTo("newVoucher"); + + // Reset the voucher to the original value for the next phase of the test. + fdo = fdo.setFdoVoucher("voucher"); + assertThat(fdo.getFdoVoucher()).isEqualTo("voucher"); + + String serialized = JsonUtils.getObjectWriter().writeValueAsString(device); + DeviceResource deserialized = JsonUtils.getObjectReader() + .forType(DeviceResource.class).readValue(serialized); + assertThat(device).isEqualTo(deserialized); + assertThat(deserialized.getDeviceExtensions()).containsOnly(fdo); + + // Calling toString() should not print the secret key to avoid leaking the + // value in log messages. + assertThat(serialized).contains(fdo.getFdoVoucher()); + assertThat(fdo.toString()).contains("--REDACTED--") + .doesNotContain(fdo.getFdoVoucher()); + } + + /** + * Tests for {@link ZigbeeDeviceExtension}. + */ + @Test + public void testZigbeeDeviceExtension() + { + String json = """ + { + "schemas": [ + "urn:ietf:params:scim:schemas:core:2.0:Device", + "urn:ietf:params:scim:schemas:extension:zigbee:2.0:Device" + ], + "id": "e9e30dba", + "meta": { + "resourceType": "Device", + "created": "1970-01-23T04:56:22Z", + "lastModified": "1970-05-13T04:42:34Z", + "location": "https://example.com/v2/Devices/e9e30dba" + }, + "displayName": "Zigbee Example Monitor", + "active": true, + "mudUrl": "https://example.com/lightbulbs/colour/v1", + "urn:ietf:params:scim:schemas:extension:zigbee:2.0:Device": { + "versionSupport": [ "3.0" ], + "deviceEui64Address": "50:32:5F:FF:FE:E7:67:28" + } + }"""; + String expectedJson = JsonUtils.getObjectReader().readTree(json) + .toPrettyString(); + + ZigbeeDeviceExtension zigbee = + new ZigbeeDeviceExtension("50:32:5F:FF:FE:E7:67:28", "3.0"); + + DeviceResource device = new DeviceResource() + .setDisplayName("Zigbee Example Monitor") + .setActive(true) + .setMudUrl("https://example.com/lightbulbs/colour/v1") + .setDeviceExtension(zigbee); + device.setId("e9e30dba"); + device.setMeta(new Meta() + .setResourceType("Device") + .setCreatedMillis(1918582000L) + .setLastModifiedMillis(11421754000L) + .setLocationString("https://example.com/v2/Devices/e9e30dba")); + + assertThat(device.getDeviceExtensions()).containsOnly(zigbee); + assertThat(zigbee.getDeviceEui64Address()).isEqualTo("50:32:5F:FF:FE:E7:67:28"); + assertThat(zigbee.getVersionSupport()).containsExactly("3.0"); + assertThat(device.toString()).isEqualTo(expectedJson); + + String serialized = JsonUtils.getObjectWriter().writeValueAsString(device); + DeviceResource deserialized = JsonUtils.getObjectReader() + .forType(DeviceResource.class).readValue(serialized); + assertThat(device).isEqualTo(deserialized); + assertThat(deserialized.getDeviceExtensions()).containsOnly(zigbee); + } + + /** + * Tests for {@link EndpointAppReference}. + */ + @Test + public void testEndpointAppReference() + { + String json = """ + { + "value": "e9e30dba", + "$ref": "https://example.com/v2/EndpointApps/e9e30dba" + }"""; + String expectedJson = JsonUtils.getObjectReader().readTree(json) + .toPrettyString(); + + EndpointAppReference app = new EndpointAppReference() + .setValue("e9e30dba") + .setRef("https://example.com/v2/EndpointApps/e9e30dba"); + + assertThat(app.getValue()) + .isEqualTo("e9e30dba"); + assertThat(app.getRefString()) + .isEqualTo("https://example.com/v2/EndpointApps/e9e30dba"); + + String serialized = JsonUtils.getObjectWriter().writeValueAsString(app); + assertThat(app.toString()).isEqualTo(expectedJson); + + EndpointAppReference deserialized = JsonUtils.getObjectReader() + .forType(EndpointAppReference.class).readValue(serialized); + assertThat(app).isEqualTo(deserialized); + } + + /** + * Tests for {@link EndpointAppDeviceExtension}. + */ + @Test + public void testEndpointAppsDeviceExtension() + { + String json = """ + { + "schemas": [ + "urn:ietf:params:scim:schemas:core:2.0:Device", + "urn:ietf:params:scim:schemas:extension:endpointAppsExt:2.0:Device" + ], + "id": "e9e30dba-f08f-4109-8486-d5c6a3316111", + "displayName": "BLE Example Monitor", + "active": true, + "urn:ietf:params:scim:schemas:extension:endpointAppsExt:2.0:Device": { + "deviceControlEnterpriseEndpoint": + "https://example.com/device_control_app_endpoint/", + "telemetryEnterpriseEndpoint": + "mqtts://example.com/telemetry_app_endpoint/", + "applications": [ + { + "value": "e9e30dba-f08f-4109-8486-d5c6a3316212", + "$ref": "https://example.com/v2/EndpointApps/e9e30dba-f08f-4109-8486-d5c6a3316212" + }, + { + "value": "e9e30dba-f08f-4109-8486-d5c6a3316333", + "$ref": "https://example.com/v2/EndpointApps/e9e30dba-f08f-4109-8486-d5c6a3316333" + } + ] + } + }"""; + String expectedJson = JsonUtils.getObjectReader().readTree(json) + .toPrettyString(); + + EndpointAppReference app1 = new EndpointAppReference() + .setValue("e9e30dba-f08f-4109-8486-d5c6a3316212") + .setRef("https://example.com/v2/EndpointApps/" + + "e9e30dba-f08f-4109-8486-d5c6a3316212"); + EndpointAppReference app2 = new EndpointAppReference() + .setValue("e9e30dba-f08f-4109-8486-d5c6a3316333") + .setRef("https://example.com/v2/EndpointApps/" + + "e9e30dba-f08f-4109-8486-d5c6a3316333"); + + EndpointAppDeviceExtension ext = new EndpointAppDeviceExtension() + .setDeviceControlEnterpriseEndpoint( + "https://example.com/device_control_app_endpoint/") + .setTelemetryEnterpriseEndpoint( + "mqtts://example.com/telemetry_app_endpoint/") + .setApplications(app1, app2); + + DeviceResource device = new DeviceResource() + .setDisplayName("BLE Example Monitor") + .setActive(true) + .setDeviceExtension(ext); + device.setId("e9e30dba-f08f-4109-8486-d5c6a3316111"); + + assertThat(device.getDeviceExtensions()).containsOnly(ext); + assertThat(ext.getDeviceControlEnterpriseEndpointString()) + .isEqualTo("https://example.com/device_control_app_endpoint/"); + assertThat(ext.getTelemetryEnterpriseEndpointString()) + .isEqualTo("mqtts://example.com/telemetry_app_endpoint/"); + assertThat(ext.getApplications()).containsExactly(app1, app2); + assertThat(device.toString()).isEqualTo(expectedJson); + + String serialized = JsonUtils.getObjectWriter().writeValueAsString(device); + DeviceResource deserialized = JsonUtils.getObjectReader() + .forType(DeviceResource.class).readValue(serialized); + assertThat(device).isEqualTo(deserialized); + assertThat(deserialized.getDeviceExtensions()).containsOnly(ext); + } + + /** + * Tests RFC 9944 Section 7.1.1 mutual-exclusion constraint between + * {@code irk} and {@code separateBroadcastAddress}. + */ + @Test + public void testBleIrkAndSeparateBroadcastAddressMutualExclusion() + { + // Setting only irk is accepted. + BleDeviceExtension onlyIrk = + new BleDeviceExtension("2C:54:91:88:C9:E2", "5.4") + .setIrk("secretirk"); + assertThat(onlyIrk.getIrk()).isEqualTo("secretirk"); + + // Setting only separateBroadcastAddress is accepted. + BleDeviceExtension onlySba = + new BleDeviceExtension("2C:54:91:88:C9:E2", "5.4") + .setSeparateBroadcastAddress("AA:BB:88:77:22:11"); + assertThat(onlySba.getSeparateBroadcastAddress()) + .containsExactly("AA:BB:88:77:22:11"); + + // Setting irk when separateBroadcastAddress is already set must throw. + assertThatThrownBy(() -> + new BleDeviceExtension("2C:54:91:88:C9:E2", "5.4") + .setSeparateBroadcastAddress("AA:BB:88:77:22:11") + .setIrk("secretirk")) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("fields cannot both be set"); + + // Setting separateBroadcastAddress when irk is already set must throw. + assertThatThrownBy(() -> + new BleDeviceExtension("2C:54:91:88:C9:E2", "5.4") + .setIrk("secretirk") + .setSeparateBroadcastAddress("AA:BB:88:77:22:11")) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("fields cannot both be set"); + } + + /** + * Test valid {@code hashCode()} implementations in the devices package by + * ensuring the classes do not have conflicting values. + */ + @Test + public void testHashCode() + { + List instances = List.of( + new DeviceResource(), + new EndpointAppResource(), + new CertificateInfo(), + new BleDeviceExtension("2C:54:91:88:C9:E2", "5.4"), + new BlePairingJustWorks(), + new BlePairingNull(), + new BlePairingOutOfBand("key", 1), + new BlePairingPassKey(1), + new DppDeviceExtension(2, "key"), + new EthernetMabDeviceExtension("2C:54:91:88:C9:E2"), + new FdoDeviceExtension("voucher"), + new ZigbeeDeviceExtension("50:32:5F:FF:FE:E7:67:28", "3.0"), + new EndpointAppReference(), + new EndpointAppDeviceExtension()); + + Set set = new HashSet<>(instances); + assertThat(set).hasSameSizeAs(instances); + } +} diff --git a/scim2-sdk-server/pom.xml b/scim2-sdk-server/pom.xml index 324203ae..3ed18850 100644 --- a/scim2-sdk-server/pom.xml +++ b/scim2-sdk-server/pom.xml @@ -34,7 +34,7 @@ com.unboundid.product.scim2 scim2-parent - 6.0.1-SNAPSHOT + 6.1.0-SNAPSHOT ../pom.xml scim2-sdk-server diff --git a/scim2-sdk-server/src/main/java/com/unboundid/scim2/server/utils/ResourceTypeDefinition.java b/scim2-sdk-server/src/main/java/com/unboundid/scim2/server/utils/ResourceTypeDefinition.java index 1e3881b8..930e28de 100644 --- a/scim2-sdk-server/src/main/java/com/unboundid/scim2/server/utils/ResourceTypeDefinition.java +++ b/scim2-sdk-server/src/main/java/com/unboundid/scim2/server/utils/ResourceTypeDefinition.java @@ -57,7 +57,6 @@ */ public final class ResourceTypeDefinition { - @Nullable private final String id; diff --git a/scim2-sdk-server/src/main/java/com/unboundid/scim2/server/utils/SchemaChecker.java b/scim2-sdk-server/src/main/java/com/unboundid/scim2/server/utils/SchemaChecker.java index daf75bef..657ad76c 100644 --- a/scim2-sdk-server/src/main/java/com/unboundid/scim2/server/utils/SchemaChecker.java +++ b/scim2-sdk-server/src/main/java/com/unboundid/scim2/server/utils/SchemaChecker.java @@ -48,6 +48,7 @@ import com.unboundid.scim2.common.utils.SchemaUtils; import com.unboundid.scim2.common.utils.StaticUtils; import tools.jackson.databind.JsonNode; +import tools.jackson.databind.node.ArrayNode; import tools.jackson.databind.node.ObjectNode; import tools.jackson.databind.node.StringNode; @@ -710,7 +711,7 @@ private void checkResource(@NotNull final String prefix, // Iterate through the schemas JsonNode schemas = objectNode.get( SchemaUtils.SCHEMAS_ATTRIBUTE_DEFINITION.getName()); - if (schemas != null && schemas.isArray()) + if (schemas instanceof ArrayNode) { boolean coreFound = false; for (JsonNode schema : schemas) @@ -769,7 +770,7 @@ private void checkResource(@NotNull final String prefix, checkObjectNode(prefix, Path.root(schema.asString()), extensionDefinition.getKey().getAttributes(), - (ObjectNode) extensionNode, results, currentObjectNode, + extensionNode.asObject(), results, currentObjectNode, isReplace, false, isReplace); } @@ -787,15 +788,8 @@ private void checkResource(@NotNull final String prefix, { if (schemaExtension.getValue()) { - boolean found = false; - for (JsonNode schema : schemas) - { - if (schema.asString().equals(schemaExtension.getKey().getId())) - { - found = true; - break; - } - } + boolean found = schemas.valueStream().anyMatch( + s -> s.asString().equals(schemaExtension.getKey().getId())); if (!found) { results.syntaxIssues.add(prefix + "Value for attribute schemas " + @@ -1039,52 +1033,53 @@ private void checkAttributeValue( // Check the node type. switch (attribute.getType()) { - case STRING: - case DATETIME: - case REFERENCE: + case STRING, DATETIME, REFERENCE -> + { if (!node.isString()) { results.syntaxIssues.add(prefix + "Value for attribute " + path + " must be a JSON string"); return; } - break; - case BOOLEAN: + } + case BOOLEAN -> + { if (!node.isBoolean()) { results.syntaxIssues.add(prefix + "Value for attribute " + path + " must be a JSON boolean"); return; } - break; - case DECIMAL: - case INTEGER: + } + case DECIMAL, INTEGER -> + { if (!node.isNumber()) { results.syntaxIssues.add(prefix + "Value for attribute " + path + " must be a JSON number"); return; } - break; - case COMPLEX: + } + case COMPLEX -> + { if (!node.isObject()) { results.syntaxIssues.add(prefix + "Value for attribute " + path + " must be a JSON object"); return; } - break; - case BINARY: + } + case BINARY -> + { if (!node.isString() && !node.isBinary()) { results.syntaxIssues.add(prefix + "Value for attribute " + path + " must be a JSON string"); return; } - break; - default: - throw new RuntimeException( - "Unexpected attribute type " + attribute.getType()); + } + default -> throw new RuntimeException( + "Unexpected attribute type " + attribute.getType()); } // If the node type checks out, check the actual value. @@ -1143,46 +1138,37 @@ private void checkAttributeValue( break; case STRING: // Check for canonical values + String value = node.asString(); if (attribute.getCanonicalValues() != null) { - boolean found = false; - for (String canonicalValue : attribute.getCanonicalValues()) - { - if (attribute.isCaseExact() ? - canonicalValue.equals(node.asString()) : - StaticUtils.toLowerCase(canonicalValue).equals( - StaticUtils.toLowerCase(node.asString()))) - { - found = true; - break; - } - } - if (!found) + boolean caseExact = attribute.isCaseExact(); + if (attribute.getCanonicalValues().stream().noneMatch( + v -> caseExact ? v.equals(value) : v.equalsIgnoreCase(value))) { - results.syntaxIssues.add(prefix + "Value " + node.asString() + + results.syntaxIssues.add(prefix + "Value " + value + " is not valid for attribute " + path + " because it " + "is not one of the canonical types: " + - StaticUtils.collectionToString( - attribute.getCanonicalValues(), ", ")); + String.join(", ", attribute.getCanonicalValues())); } } + + // Evaluate the regex pattern constraint, if it exists. + String pattern = attribute.getPattern(); + if (pattern != null && !value.matches(pattern)) + { + results.syntaxIssues.add(prefix + "Value " + value + + " is not valid for attribute " + path + + " because it does not match the required pattern: " + pattern); + } } // Special checking of the schemas attribute to ensure that // no undefined schemas are listed. - if (attribute.equals(SchemaUtils.SCHEMAS_ATTRIBUTE_DEFINITION) && - path.size() == 1) + AttributeDefinition schemas = SchemaUtils.SCHEMAS_ATTRIBUTE_DEFINITION; + if (path.size() == 1 && attribute.equals(schemas)) { - boolean found = false; - for (SchemaResource schemaExtension : - resourceType.getSchemaExtensions().keySet()) - { - if (node.asString().equals(schemaExtension.getId())) - { - found = true; - break; - } - } + boolean found = resourceType.getSchemaExtensions().keySet().stream() + .anyMatch(ext -> node.asString().equals(ext.getId())); if (!found) { found = node.asString().equals(resourceType.getCoreSchema().getId()); @@ -1255,7 +1241,16 @@ private void checkObjectNode( for (i = objectNode.properties().iterator(); i.hasNext();) { String undefinedAttribute = i.next().getKey(); - if (parentPath.size() == 0) + + if ("Device".equalsIgnoreCase(resourceType.getName()) + && SchemaUtils.isUrn(undefinedAttribute)) + { + // Devices can have nested schema URN values, which should not count as + // an undefined attribute. + Debug.debug(Level.FINE, DebugType.OTHER, + "Skipping schema URN: " + undefinedAttribute); + } + else if (parentPath.size() == 0) { if (!enabledOptions.contains(Option.ALLOW_UNDEFINED_ATTRIBUTES)) { diff --git a/scim2-sdk-server/src/test/java/com/unboundid/scim2/server/DeviceResourceEndpoint.java b/scim2-sdk-server/src/test/java/com/unboundid/scim2/server/DeviceResourceEndpoint.java new file mode 100644 index 00000000..5a4f3b30 --- /dev/null +++ b/scim2-sdk-server/src/test/java/com/unboundid/scim2/server/DeviceResourceEndpoint.java @@ -0,0 +1,101 @@ +/* + * Copyright 2026 Ping Identity Corporation + * + * 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. + */ +/* + * Copyright 2026 Ping Identity Corporation + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License (GPLv2 only) + * or the terms of the GNU Lesser General Public License (LGPLv2.1 only) + * as published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, see . + */ + +package com.unboundid.scim2.server; + +import com.unboundid.scim2.common.exceptions.ScimException; +import com.unboundid.scim2.common.types.devices.BleDeviceExtension; +import com.unboundid.scim2.common.types.devices.DeviceResource; +import com.unboundid.scim2.common.types.devices.DppDeviceExtension; +import com.unboundid.scim2.common.types.devices.EndpointAppDeviceExtension; +import com.unboundid.scim2.common.types.devices.EthernetMabDeviceExtension; +import com.unboundid.scim2.common.types.devices.FdoDeviceExtension; +import com.unboundid.scim2.common.types.devices.ZigbeeDeviceExtension; +import com.unboundid.scim2.server.annotations.ResourceType; +import com.unboundid.scim2.server.utils.ResourceTypeDefinition; +import com.unboundid.scim2.server.utils.SimpleSearchResults; + +import jakarta.ws.rs.GET; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.core.Context; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.UriInfo; + +import java.util.Objects; + +import static com.unboundid.scim2.common.utils.ApiConstants.MEDIA_TYPE_SCIM; +import static com.unboundid.scim2.server.utils.ResourceTypeDefinition.fromJaxRsResource; + +/** + * A JAX-RS test endpoint for {@link DeviceResource}. + */ +@ResourceType( + description = "Device resource (RFC 9944)", + name = "Device", + schema = DeviceResource.class, + optionalSchemaExtensions = { + BleDeviceExtension.class, + DppDeviceExtension.class, + EndpointAppDeviceExtension.class, + EthernetMabDeviceExtension.class, + FdoDeviceExtension.class, + ZigbeeDeviceExtension.class + }) +@Path("/Devices") +public class DeviceResourceEndpoint +{ + private static final ResourceTypeDefinition RESOURCE_TYPE_DEFINITION = + Objects.requireNonNull(fromJaxRsResource(DeviceResourceEndpoint.class)); + + /** + * A SCIM search endpoint returning a single device. + * + * @param info The UriInfo. + * @return The results. + */ + @GET + @Produces({MEDIA_TYPE_SCIM, MediaType.APPLICATION_JSON}) + public SimpleSearchResults search(@Context final UriInfo info) + throws ScimException + { + SimpleSearchResults results = + new SimpleSearchResults<>(RESOURCE_TYPE_DEFINITION, info); + DeviceResource device = new DeviceResource() + .setActive(true) + .setDisplayName("Test Device"); + device.setId("dev-1"); + results.add(device); + + return results; + } +} diff --git a/scim2-sdk-server/src/test/java/com/unboundid/scim2/server/EndpointAppResourceEndpoint.java b/scim2-sdk-server/src/test/java/com/unboundid/scim2/server/EndpointAppResourceEndpoint.java new file mode 100644 index 00000000..86196915 --- /dev/null +++ b/scim2-sdk-server/src/test/java/com/unboundid/scim2/server/EndpointAppResourceEndpoint.java @@ -0,0 +1,86 @@ +/* + * Copyright 2026 Ping Identity Corporation + * + * 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. + */ +/* + * Copyright 2026 Ping Identity Corporation + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License (GPLv2 only) + * or the terms of the GNU Lesser General Public License (LGPLv2.1 only) + * as published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, see . + */ + +package com.unboundid.scim2.server; + +import com.unboundid.scim2.common.exceptions.ScimException; +import com.unboundid.scim2.server.annotations.ResourceType; +import com.unboundid.scim2.common.types.devices.EndpointAppResource; +import com.unboundid.scim2.server.utils.ResourceTypeDefinition; +import com.unboundid.scim2.server.utils.SimpleSearchResults; + +import jakarta.ws.rs.GET; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.core.Context; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.UriInfo; + +import static com.unboundid.scim2.common.utils.ApiConstants.MEDIA_TYPE_SCIM; +import static com.unboundid.scim2.server.utils.ResourceTypeDefinition.fromJaxRsResource; +import static java.util.Objects.requireNonNull; + +/** + * A JAX-RS test endpoint for {@link EndpointAppResource}. + */ +@ResourceType( + description = "Endpoint Application resource (RFC 9944)", + name = "EndpointApp", + schema = EndpointAppResource.class) +@Path("/EndpointApps") +public class EndpointAppResourceEndpoint +{ + private static final ResourceTypeDefinition RESOURCE_TYPE_DEFINITION = + requireNonNull(fromJaxRsResource(EndpointAppResourceEndpoint.class)); + + /** + * A SCIM search endpoint returning a single endpoint application. + * + * @param uriInfo The UriInfo. + * @return The results. + */ + @GET + @Produces({MEDIA_TYPE_SCIM, MediaType.APPLICATION_JSON}) + public SimpleSearchResults search( + @Context final UriInfo uriInfo) throws ScimException + { + SimpleSearchResults results = + new SimpleSearchResults<>(RESOURCE_TYPE_DEFINITION, uriInfo); + EndpointAppResource app = new EndpointAppResource() + .setApplicationType("deviceControl") + .setApplicationName("Test App"); + app.setId("app-1"); + results.add(app); + + return results; + } +} diff --git a/scim2-sdk-server/src/test/java/com/unboundid/scim2/server/EndpointTestCase.java b/scim2-sdk-server/src/test/java/com/unboundid/scim2/server/EndpointTestCase.java index b2edc877..81ba9f4e 100644 --- a/scim2-sdk-server/src/test/java/com/unboundid/scim2/server/EndpointTestCase.java +++ b/scim2-sdk-server/src/test/java/com/unboundid/scim2/server/EndpointTestCase.java @@ -63,6 +63,8 @@ import com.unboundid.scim2.common.types.SchemaResource; import com.unboundid.scim2.common.types.ServiceProviderConfigResource; import com.unboundid.scim2.common.types.UserResource; +import com.unboundid.scim2.common.types.devices.DeviceResource; +import com.unboundid.scim2.common.types.devices.EndpointAppResource; import com.unboundid.scim2.common.utils.ApiConstants; import com.unboundid.scim2.common.utils.JsonUtils; import com.unboundid.scim2.common.utils.SchemaUtils; @@ -153,6 +155,10 @@ protected Application configure() config.register(TestResourceEndpoint.class); config.register(new TestSingletonResourceEndpoint()); + // RFC 9944 endpoints. + config.register(DeviceResourceEndpoint.class); + config.register(EndpointAppResourceEndpoint.class); + return config; } @@ -257,7 +263,9 @@ public void testGetSchemas() throws ScimException final ListResponse returnedSchemas = new ScimService(target()).getSchemas(); - assertThat(returnedSchemas.getTotalResults()).isEqualTo(3); + // In addition to the User and Bulk schemas, other resource types such as + // devices contribute their core schemas and their optional extensions. + assertThat(returnedSchemas.getTotalResults()).isEqualTo(11); assertThat(returnedSchemas) .contains(userSchema) .contains(enterpriseSchema); @@ -310,7 +318,8 @@ public void testGetResourceTypes() throws ScimException final ListResponse returnedResourceTypes = new ScimService(target()).getResourceTypes(); - assertThat(returnedResourceTypes.getTotalResults()).isEqualTo(4); + // This value should be updated when a new JAX-RS resource type is added. + assertThat(returnedResourceTypes.getTotalResults()).isEqualTo(6); assertThat(returnedResourceTypes) .contains(resourceType) .contains(singletonResourceType); @@ -1298,7 +1307,6 @@ public void testAcceptExceptions() .isInstanceOf(IllegalArgumentException.class); } - /** * This test performs basic validation for bulk requests and responses in a * Jakarta-RS environment. Handling for these {@code /Bulk} calls come from @@ -1482,6 +1490,98 @@ public void testBulkRequestJsonProcessing() throws Exception } + /** + * This test performs basic validation of the client request path for the + * RFC 9944 {@code /Devices} endpoint. The response is defined in + * {@link DeviceResourceEndpoint#search}. + */ + @Test + public void testGetDevices() throws ScimException + { + final ScimService scimService = new ScimService(target()); + + final ListResponse devices = + scimService.searchRequest("/Devices").invoke(DeviceResource.class); + + assertThat(devices.getTotalResults()).isEqualTo(1); + assertThat(devices.getResources()).hasSize(1); + + final DeviceResource device = devices.getResources().get(0); + assertThat(device.getId()).isEqualTo("dev-1"); + assertThat(device.getActive()).isTrue(); + assertThat(device.getDisplayName()).isEqualTo("Test Device"); + + // The endpoint does not populate the mudUrl or groups attributes. + assertThat(device.getMudUrl()).isNull(); + assertThat(device.getGroups()).isEmpty(); + + // The metadata should have been updated by the ResourcePreparer. + assertThat(device.getMeta()).isNotNull(); + assertThat(device.getMeta().getResourceType()).isEqualTo("Device"); + assertThat(device.getMeta().getLocation()).isEqualTo( + UriBuilder.fromUri(getBaseUri()).path("Devices").path("dev-1") + .build()); + + // The Device resource type should also be discoverable via /ResourceTypes, + // and should declare its RFC 9944 Sec. 7 optional schema extensions. + final ResourceTypeResource deviceResourceType = + scimService.getResourceType("Device"); + assertThat(deviceResourceType.getSchema()).hasToString( + "urn:ietf:params:scim:schemas:core:2.0:Device"); + assertThat(deviceResourceType.getSchemaExtensions()) + .extracting(ext -> ext.getSchema().toString()) + .containsExactlyInAnyOrder( + "urn:ietf:params:scim:schemas:extension:ble:2.0:Device", + "urn:ietf:params:scim:schemas:extension:dpp:2.0:Device", + "urn:ietf:params:scim:schemas:extension:endpointAppsExt:2.0:Device", + "urn:ietf:params:scim:schemas:extension:ethernet-mab:2.0:Device", + "urn:ietf:params:scim:schemas:extension:fido-device-onboard:2.0:Device", + "urn:ietf:params:scim:schemas:extension:zigbee:2.0:Device"); + assertThat(deviceResourceType.getSchemaExtensions()) + .allMatch(ext -> !ext.isRequired()); + } + + /** + * This test performs basic validation of the client request path for + * {@code /EndpointApps}. The response is defined in + * {@link EndpointAppResourceEndpoint#search}. + */ + @Test + public void testGetEndpointApps() throws ScimException + { + final ScimService scimService = new ScimService(target()); + + final ListResponse apps = + scimService.searchRequest("/EndpointApps") + .invoke(EndpointAppResource.class); + + assertThat(apps.getTotalResults()).isEqualTo(1); + assertThat(apps.getResources()).hasSize(1); + + final EndpointAppResource app = apps.getResources().get(0); + assertThat(app.getId()).isEqualTo("app-1"); + assertThat(app.getApplicationType()).isEqualTo("deviceControl"); + assertThat(app.getApplicationName()).isEqualTo("Test App"); + assertThat(app.getClientToken()).isNull(); + assertThat(app.getCertificateInfo()).isNull(); + assertThat(app.hasEmptyCertificateInfo()).isTrue(); + assertThat(app.getGroups()).isEmpty(); + + assertThat(app.getMeta()).isNotNull(); + assertThat(app.getMeta().getResourceType()).isEqualTo("EndpointApp"); + assertThat(app.getMeta().getLocation()) + .isEqualTo(UriBuilder.fromUri(getBaseUri()).path("EndpointApps") + .path("app-1").build()); + + // The resource type should also be discoverable via /ResourceTypes. + final ResourceTypeResource endpointAppResourceType = + scimService.getResourceType("EndpointApp"); + assertThat(endpointAppResourceType.getSchema()).hasToString( + "urn:ietf:params:scim:schemas:core:2.0:EndpointApp"); + assertThat(endpointAppResourceType.getSchemaExtensions()).isNull(); + } + + private void setMeta(Class resourceClass, ScimResource scimResource) { var definition = ResourceTypeDefinition.fromJaxRsResource(resourceClass); diff --git a/scim2-sdk-server/src/test/java/com/unboundid/scim2/server/utils/Rfc9944SchemaCheckerTestCase.java b/scim2-sdk-server/src/test/java/com/unboundid/scim2/server/utils/Rfc9944SchemaCheckerTestCase.java new file mode 100644 index 00000000..8e2ecc7f --- /dev/null +++ b/scim2-sdk-server/src/test/java/com/unboundid/scim2/server/utils/Rfc9944SchemaCheckerTestCase.java @@ -0,0 +1,394 @@ +/* + * Copyright 2026 Ping Identity Corporation + * + * 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. + */ +/* + * Copyright 2026 Ping Identity Corporation + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License (GPLv2 only) + * or the terms of the GNU Lesser General Public License (LGPLv2.1 only) + * as published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, see . + */ + +package com.unboundid.scim2.server.utils; + +import com.unboundid.scim2.common.GenericScimResource; +import com.unboundid.scim2.common.Path; +import com.unboundid.scim2.common.messages.PatchOperation; +import com.unboundid.scim2.common.types.AttributeDefinition; +import com.unboundid.scim2.common.types.SchemaResource; +import com.unboundid.scim2.common.types.devices.BleDeviceExtension; +import com.unboundid.scim2.common.types.devices.BlePairingNull; +import com.unboundid.scim2.common.types.devices.BlePairingOutOfBand; +import com.unboundid.scim2.common.types.devices.DeviceResource; +import com.unboundid.scim2.common.types.devices.DppDeviceExtension; +import com.unboundid.scim2.common.types.devices.EndpointAppResource; +import com.unboundid.scim2.common.types.devices.EndpointAppDeviceExtension; +import com.unboundid.scim2.common.types.devices.FdoDeviceExtension; +import com.unboundid.scim2.common.types.devices.ZigbeeDeviceExtension; +import com.unboundid.scim2.common.utils.JsonUtils; +import com.unboundid.scim2.common.utils.SchemaUtils; +import org.testng.annotations.BeforeClass; +import org.testng.annotations.DataProvider; +import org.testng.annotations.Test; +import tools.jackson.databind.node.ObjectNode; +import tools.jackson.databind.node.StringNode; + +import java.net.URI; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + + +/** + * Validate schema constraints of device resources. + */ +@Test +public class Rfc9944SchemaCheckerTestCase +{ + private ResourceTypeDefinition deviceResourceTypeDef; + private ResourceTypeDefinition endpointAppResourceTypeDef; + + /** + * Set up resource type definitions backed by generated schemas. + */ + @BeforeClass + public void setUp() throws Exception + { + SchemaResource deviceSchema = SchemaUtils.getSchema(DeviceResource.class); + SchemaResource bleSchema = SchemaUtils.getSchema(BleDeviceExtension.class); + SchemaResource dppSchema = SchemaUtils.getSchema(DppDeviceExtension.class); + SchemaResource fdoSchema = SchemaUtils.getSchema(FdoDeviceExtension.class); + SchemaResource endpointAppsSchema = + SchemaUtils.getSchema(EndpointAppDeviceExtension.class); + SchemaResource zigbeeSchema = + SchemaUtils.getSchema(ZigbeeDeviceExtension.class); + + deviceResourceTypeDef = new ResourceTypeDefinition.Builder( + "Device", "/Devices") + .setCoreSchema(deviceSchema) + .addOptionalSchemaExtension(bleSchema) + .addOptionalSchemaExtension(dppSchema) + .addOptionalSchemaExtension(fdoSchema) + .addOptionalSchemaExtension(endpointAppsSchema) + .addOptionalSchemaExtension(zigbeeSchema) + .build(); + + SchemaResource appSchema = + SchemaUtils.getSchema(EndpointAppResource.class); + endpointAppResourceTypeDef = new ResourceTypeDefinition.Builder( + "EndpointApp", "/EndpointApps") + .setCoreSchema(appSchema) + .build(); + } + + /** + * Valid MAC addresses must pass SchemaChecker. + * + * @param mac A valid MAC address string. + */ + @Test(dataProvider = "validMacAddresses") + public void testValidMacAddressPassesSchemaChecker(final String mac) + throws Exception + { + SchemaChecker checker = new SchemaChecker(deviceResourceTypeDef); + BleDeviceExtension ble = new BleDeviceExtension(mac, "5.0") + .setPairingExtension(new BlePairingNull()); + DeviceResource device = new DeviceResource() + .setActive(true) + .setDeviceExtension(ble); + + ObjectNode resource = device.asGenericScimResource().getObjectNode(); + SchemaChecker.Results results = checker.checkCreate(resource); + assertThat(results.getSyntaxIssues()).isEmpty(); + } + + /** + * Invalid MAC addresses must produce a syntax issue. + * + * @param mac An invalid MAC address string. + */ + @Test(dataProvider = "invalidMacAddresses") + public void testInvalidMacAddressFailsSchemaChecker(final String mac) + throws Exception + { + SchemaChecker checker = new SchemaChecker(deviceResourceTypeDef); + BleDeviceExtension ble = new BleDeviceExtension(mac, "5.0") + .setPairingExtension(new BlePairingNull()); + DeviceResource device = new DeviceResource() + .setActive(true) + .setDeviceExtension(ble); + + ObjectNode resource = device.asGenericScimResource().getObjectNode(); + SchemaChecker.Results results = checker.checkCreate(resource); + assertThat(results.getSyntaxIssues()).isNotEmpty(); + } + + /** + * Valid EUI-64 addresses must pass SchemaChecker. + * + * @param eui64 A valid EUI-64 address string. + */ + @Test(dataProvider = "validEui64Addresses") + public void testValidEui64PassesSchemaChecker(final String eui64) + throws Exception + { + SchemaChecker checker = new SchemaChecker(deviceResourceTypeDef); + ZigbeeDeviceExtension zigbee = new ZigbeeDeviceExtension(eui64, "3.0"); + DeviceResource device = new DeviceResource() + .setActive(true) + .setDeviceExtension(zigbee); + + ObjectNode resource = device.asGenericScimResource().getObjectNode(); + SchemaChecker.Results results = checker.checkCreate(resource); + assertThat(results.getSyntaxIssues()).isEmpty(); + } + + /** + * Invalid EUI-64 addresses must produce a syntax issue. + * + * @param eui64 An invalid EUI-64 address string. + */ + @Test(dataProvider = "invalidEui64Addresses") + public void testInvalidEui64FailsSchemaChecker(final String eui64) + throws Exception + { + SchemaChecker checker = new SchemaChecker(deviceResourceTypeDef); + ZigbeeDeviceExtension zigbee = new ZigbeeDeviceExtension(eui64, "3.0"); + DeviceResource device = new DeviceResource() + .setActive(true) + .setDeviceExtension(zigbee); + + ObjectNode resource = device.asGenericScimResource().getObjectNode(); + SchemaChecker.Results results = checker.checkCreate(resource); + assertThat(results.getSyntaxIssues()).isNotEmpty(); + } + + /** + * Ensure a nested schema extension does not cause an error during + * SchemaChecker analysis of a device resource. + */ + @Test + public void testNestedPairingPassKeyExtensionAccepted() throws Exception + { + SchemaChecker checker = new SchemaChecker(deviceResourceTypeDef); + ObjectNode resource = JsonUtils.getObjectReader() + .forType(ObjectNode.class).readValue(""" + { + "schemas": [ + "urn:ietf:params:scim:schemas:core:2.0:Device", + "urn:ietf:params:scim:schemas:extension:ble:2.0:Device" + ], + "active": true, + "urn:ietf:params:scim:schemas:extension:ble:2.0:Device": { + "versionSupport": ["5.4"], + "deviceMacAddress": "2C:54:91:88:C9:E2", + "pairingMethods": [ + "urn:ietf:params:scim:schemas:extension:pairingPassKey:2.0:Device" + ], + "urn:ietf:params:scim:schemas:extension:pairingPassKey:2.0:Device": { + "key": 123456 + } + } + }"""); + SchemaChecker.Results results = checker.checkCreate(resource); + assertThat(results.getSyntaxIssues()).isEmpty(); + } + + /** + * BlePairingOutOfBand should be able to generate its schema like any other + * class, even though it exposes a {@code long} for the {@code randomNumber} + * field's getter method. + */ + @Test + public void testBlePairingOutOfBandSchemaGeneration() throws Exception + { + SchemaResource schema = SchemaUtils.getSchema(BlePairingOutOfBand.class); + assertThat(schema).isNotNull(); + + AttributeDefinition randomNumber = schema.getAttributes().stream() + .filter(attribute -> "randomNumber".equals(attribute.getName())) + .findFirst() + .orElseThrow(() -> new RuntimeException( + "Expected a randomNumber attribute in the generated schema.")); + assertThat(randomNumber.getType()) + .isEqualTo(AttributeDefinition.Type.INTEGER); + } + + /** + * Cryptographic secret fields such as {@code irk} should be stripped from + * responses that are handled by {@code ResourcePreparer}. + */ + @Test + public void testSecretFieldsAreStrippedFromResponse() throws Exception + { + GenericScimResource resource = new GenericScimResource( + JsonUtils.getObjectReader() + .forType(ObjectNode.class).readValue(""" + { + "schemas": [ + "urn:ietf:params:scim:schemas:core:2.0:Device", + "urn:ietf:params:scim:schemas:extension:ble:2.0:Device", + "urn:ietf:params:scim:schemas:extension:dpp:2.0:Device", + "urn:ietf:params:scim:schemas:extension:fido-device-onboard:2.0:Device" + ], + "id": "dev-1", + "active": true, + "urn:ietf:params:scim:schemas:extension:ble:2.0:Device": { + "deviceMacAddress": "AA:BB:CC:DD:EE:FF", + "irk": "secret-irk", + "versionSupport": ["5.0"], + "pairingMethods": ["urn:ietf:params:scim:schemas:extension:pairingNull:2.0:Device"] + }, + "urn:ietf:params:scim:schemas:extension:dpp:2.0:Device": { + "dppVersion": 2, + "bootstrapKey": "secret-bootstrap-key" + }, + "urn:ietf:params:scim:schemas:extension:fido-device-onboard:2.0:Device": { + "fdoVoucher": "secret-fdo-voucher" + } + }""")); + + ResourcePreparer preparer = + new ResourcePreparer<>(deviceResourceTypeDef, + null, null, URI.create("https://example.com/scim")); + GenericScimResource trimmed = preparer.trimRetrievedResource(resource); + + ObjectNode trimmedNode = trimmed.getObjectNode(); + + String extName = "urn:ietf:params:scim:schemas:extension:ble:2.0:Device"; + assertThat(containsField(trimmedNode, extName, "irk")).isFalse(); + + extName = "urn:ietf:params:scim:schemas:extension:dpp:2.0:Device"; + assertThat(containsField(trimmedNode, extName, "bootstrapKey")).isFalse(); + + extName = "urn:ietf:params:scim:schemas:extension" + + ":fido-device-onboard:2.0:Device"; + assertThat(containsField(trimmedNode, extName, "fdoVoucher")).isFalse(); + } + + /** + * Test an endpointApp's field that must be settable on create. + */ + @Test + public void testApplicationTypeAllowedOnCreate() throws Exception + { + SchemaChecker checker = new SchemaChecker(endpointAppResourceTypeDef); + ObjectNode resource = JsonUtils.getObjectReader() + .forType(ObjectNode.class).readValue(""" + { + "schemas": [ "urn:ietf:params:scim:schemas:core:2.0:EndpointApp" ], + "applicationType": "deviceControl", + "applicationName": "My App" + }"""); + SchemaChecker.Results results = checker.checkCreate(resource); + assertThat(results.getMutabilityIssues()).isEmpty(); + + // Updating the field must be rejected on a replace operation. + PatchOperation op = PatchOperation.replace(Path.of("applicationType"), + StringNode.valueOf("telemetry")); + results = checker.checkModify(List.of(op), resource); + assertThat(results.getMutabilityIssues()).isNotEmpty(); + } + + /** + * Valid MAC address strings. + * + * @return Test data. + */ + @DataProvider + public Object[][] validMacAddresses() + { + return new Object[][] { + { "AA:BB:CC:DD:EE:FF" }, + { "aa:bb:cc:dd:ee:ff" }, + { "00:11:22:33:44:55" }, + { "0A:1B:2C:3D:4E:5F" }, + }; + } + + /** + * Invalid MAC address strings. + * + * @return Test data. + */ + @DataProvider + public Object[][] invalidMacAddresses() + { + return new Object[][] { + { "AA:BB:CC:DD:EE" }, + { "AA:BB:CC:DD:EE:GG" }, + { "AABBCCDDEEFF" }, + { "AA:BB:CC:DD:EE:FF:00" }, + { "" }, + }; + } + + /** + * Valid EUI-64 address strings. + * + * @return Test data. + */ + @DataProvider + public Object[][] validEui64Addresses() + { + return new Object[][] { + { "00:11:22:33:44:55:66:77" }, + { "AA:BB:CC:DD:EE:FF:00:11" }, + { "0a:1b:2c:3d:4e:5f:6a:7b" }, + }; + } + + /** + * Invalid EUI-64 address strings. + * + * @return Test data. + */ + @DataProvider + public Object[][] invalidEui64Addresses() + { + return new Object[][] { + { "00:11:22:33:44:55:66" }, + { "00:11:22:33:44:55:66:GG" }, + { "0011223344556677" }, + { "00:11:22:33:44:55:66:77:88" }, + { "" }, + }; + } + + private boolean containsField(final ObjectNode root, + final String namespace, + final String field) + { + if (root == null) + { + return false; + } + else if (root.has(namespace) && root.get(namespace).has(field)) + { + return true; + } + + return root.has(field); + } +} diff --git a/scim2-sdk-server/src/test/java/com/unboundid/scim2/server/utils/SchemaCheckerTestCase.java b/scim2-sdk-server/src/test/java/com/unboundid/scim2/server/utils/SchemaCheckerTestCase.java index c943413b..48a0401a 100644 --- a/scim2-sdk-server/src/test/java/com/unboundid/scim2/server/utils/SchemaCheckerTestCase.java +++ b/scim2-sdk-server/src/test/java/com/unboundid/scim2/server/utils/SchemaCheckerTestCase.java @@ -1690,6 +1690,11 @@ public void testAllowUndefinedAttributeOptions() "urn:ietf:params:scim:schemas:extension:enterprise:2.0:User:manager.undefined eq \"value\"")); assertEquals(results.getFilterIssues().size(), 0, results.getFilterIssues().toString()); + + undefinedAttributesChecker + .disable(SchemaChecker.Option.ALLOW_UNDEFINED_ATTRIBUTES); + undefinedSubAttributesChecker + .disable(SchemaChecker.Option.ALLOW_UNDEFINED_SUB_ATTRIBUTES); } /** diff --git a/scim2-ubid-extensions/pom.xml b/scim2-ubid-extensions/pom.xml index 8ad69ead..48defddb 100644 --- a/scim2-ubid-extensions/pom.xml +++ b/scim2-ubid-extensions/pom.xml @@ -34,7 +34,7 @@ com.unboundid.product.scim2 scim2-parent - 6.0.1-SNAPSHOT + 6.1.0-SNAPSHOT ../pom.xml scim2-ubid-extensions From 0a1c2b83caa53156468451a895887688956766f9 Mon Sep 17 00:00:00 2001 From: Khalid Qarryzada Date: Fri, 11 Sep 2026 16:42:29 -0500 Subject: [PATCH 2/4] Update CHANGELOG. --- CHANGELOG.md | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a843c7a2..6679c740 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,15 @@ All notable changes to this project will be documented in this file. This project adheres to [Semantic Versioning](https://semver.org/). -## 6.0.1 - TBD +## 6.1.0 - TBD +Added support for [RFC 9944](https://datatracker.ietf.org/doc/html/rfc9944), an update to the SCIM +standard describing device management with SCIM. For an overview on leveraging device management +with the SCIM SDK, see the `DeviceResource` class-level Javadoc. This update includes: +* A new `com.unboundid.scim2.common.types.devices` package that contains model classes for resources + such as `EndpointAppResource`, as well as extension objects such as `BleDeviceExtension`. +* Updated `AttributeDefinition` with new fields such as `pattern`, as well as the addition of + `MANUFACTURER` and `ENTERPRISE` uniqueness constants. + Fixed an issue with deserializing a GenericScimResource object when it was embedded within a list response. @@ -598,4 +606,3 @@ Make sure the Response is always closed after a SCIM operation. ## v1.2.9 - 2016-07-08 Initial Public Release - From 79b01f5706e510761ef9cbd4d47689910439c769 Mon Sep 17 00:00:00 2001 From: Khalid Qarryzada Date: Mon, 21 Sep 2026 08:39:35 -0700 Subject: [PATCH 3/4] Review feedback and cleanup. --- CHANGELOG.md | 3 ++ README.md | 2 +- .../types/devices/BleDeviceExtension.java | 39 ++++++++++-------- .../types/devices/BlePairingMethod.java | 25 +++-------- .../types/devices/BlePairingOutOfBand.java | 20 ++------- .../common/types/devices/DeviceExtension.java | 11 ++++- .../devices/EthernetMabDeviceExtension.java | 4 -- .../types/devices/ZigbeeDeviceExtension.java | 10 +---- .../common/types/devices/package-info.java | 2 + .../scim2/common/utils/JsonUtils.java | 6 +-- .../scim2/common/utils/SchemaUtils.java | 2 +- .../common/types/devices/DevicesTest.java | 41 ++++++++++++++++--- 12 files changed, 89 insertions(+), 76 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6679c740..337461e3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -45,6 +45,9 @@ all other SCIM objects in the library. Updated `DateTimeUtils.parse()` to support date strings without timestamps (e.g., "1970-01-01"). The time values will always be set to the start of the day. +Updated the `@Attribute` annotation of all `$ref` fields to label them as not required for +consistency across groups, devices, and other model classes. + ## 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/README.md b/README.md index 7dcab50e..8c2af81f 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,7 @@ The UnboundID SCIM 2 SDK provides many strong benefits for applications that nee with SCIM 2.0 clients or servers: * Full support for the SCIM 2.0 protocol as defined by the latest specification. This includes - support for PATCH operations, filter processing, bulk operations, and cursor-based pagination. + support for bulk operations, cursor-based pagination (RFC 9865), and device management (RFC 9944). * A simple and intuitive Jackson-based API that facilitates SCIM workflows, minimizing the amount of code you need to write for tasks such as performing CRUD operations on resources. * `@NotNull` and `@Nullable` annotations are documented for all library input parameters, member diff --git a/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/types/devices/BleDeviceExtension.java b/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/types/devices/BleDeviceExtension.java index 0edf2ca1..8b92e657 100644 --- a/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/types/devices/BleDeviceExtension.java +++ b/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/types/devices/BleDeviceExtension.java @@ -36,6 +36,7 @@ import com.fasterxml.jackson.annotation.JsonIgnore; import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.annotation.JsonPropertyOrder; import com.unboundid.scim2.common.annotations.Attribute; import com.unboundid.scim2.common.annotations.NotNull; import com.unboundid.scim2.common.annotations.Nullable; @@ -114,11 +115,10 @@ * This device can be created with the following Java code: *

  *   // Create the device extension.
- *   BleDeviceExtension ble =
- *       new BleDeviceExtension("2C:54:91:88:C9:E2", "5.4")
- *           .setIsRandom(false)
- *           .setMobility(true)
- *           .setPairingExtension(new BlePairingPassKey(123456));
+ *   BleDeviceExtension ble = new BleDeviceExtension("2C:54:91:88:C9:E2", "5.4")
+ *       .setIsRandom(false)
+ *       .setMobility(true)
+ *       .setPairingExtension(new BlePairingPassKey(123456));
  *
  *   // Create the device.
  *   DeviceResource device = new DeviceResource()
@@ -151,6 +151,7 @@
 @Schema(id = "urn:ietf:params:scim:schemas:extension:ble:2.0:Device",
     name = "BLE Extension",
     description = "BLE extension for a Device resource")
+@JsonPropertyOrder({"versionSupport", "deviceMacAddress"})
 public class BleDeviceExtension extends DeviceExtension
 {
   @NotNull
@@ -211,18 +212,18 @@ The Identity Resolving Key (IRK) for this BLE device. This is a \
   /**
    * Creates a new Bluetooth Low Energy device extension.
    *
-   * @param versionSupport    The BLE versions supported by this device.
-   * @param deviceMacAddress  The device's MAC address.
+   * @param deviceMacAddress The device's MAC address.
+   * @param versionSupport   The BLE versions supported by this device.
    */
   @JsonCreator
   public BleDeviceExtension(
-      @NotNull @JsonProperty(value = "versionSupport", required = true)
-      final List versionSupport,
       @NotNull @JsonProperty(value = "deviceMacAddress", required = true)
-      final String deviceMacAddress)
+      final String deviceMacAddress,
+      @NotNull @JsonProperty(value = "versionSupport", required = true)
+      final List versionSupport)
   {
-    this.versionSupport = List.copyOf(Objects.requireNonNull(versionSupport));
     this.deviceMacAddress = Objects.requireNonNull(deviceMacAddress);
+    this.versionSupport = List.copyOf(versionSupport);
   }
 
   /**
@@ -236,7 +237,7 @@ public BleDeviceExtension(@NotNull final String deviceMacAddress,
                             @NotNull final String versionSupport,
                             @Nullable final String... versions)
   {
-    this(StaticUtils.toList(versionSupport, versions), deviceMacAddress);
+    this(deviceMacAddress, StaticUtils.toList(versionSupport, versions));
   }
 
   /**
@@ -294,7 +295,7 @@ public List getSeparateBroadcastAddress()
   @NotNull
   public BleDeviceExtension setSeparateBroadcastAddress(
       @Nullable final List separateBroadcastAddress)
-      throws IllegalStateException
+          throws IllegalStateException
   {
     validate(this.irk, separateBroadcastAddress);
     this.separateBroadcastAddress = (separateBroadcastAddress == null)
@@ -399,7 +400,7 @@ public List getPairingMethods()
    * 

* * In general, methods like {@link #setPairingExtension(BlePairingMethod)} - * should be used instead, which will automatically set the appropriate + * should be used instead, as this will automatically set the appropriate * fields. * * @param pairingMethods The list of pairing methods. @@ -414,13 +415,12 @@ public void setPairingMethods(@NotNull final List pairingMethods) * Sets a BLE pairing method extension on this device extension, and adds * its schema URN to the {@code pairingMethods} field. * - * @param The data type of the pairing method extension. * @param pairingMethod The pairing method extension to set. * @return This Bluetooth Low Energy extension. */ @NotNull - public BleDeviceExtension setPairingExtension( - @NotNull final T pairingMethod) + public BleDeviceExtension setPairingExtension( + @NotNull final BlePairingMethod pairingMethod) { setExtension(pairingMethod); @@ -435,7 +435,10 @@ public BleDeviceExtension setPairingExtension( /** * Removes a BLE pairing method extension from this device extension, and - * removes its schema URN from the {@code pairingMethods} field. + * removes its schema URN from the {@code pairingMethods} field. For example: + *

+   *   bleObject.removePairingExtension(BlePairingPassKey.class);
+   * 
* * @param The data type of the pairing method extension. * @param pairingClass The class of the pairing method extension to remove. diff --git a/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/types/devices/BlePairingMethod.java b/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/types/devices/BlePairingMethod.java index 1003f1c8..e57d246c 100644 --- a/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/types/devices/BlePairingMethod.java +++ b/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/types/devices/BlePairingMethod.java @@ -32,12 +32,8 @@ package com.unboundid.scim2.common.types.devices; -import com.fasterxml.jackson.annotation.JsonIgnoreProperties; -import com.unboundid.scim2.common.BaseScimResource; import com.unboundid.scim2.common.annotations.NotNull; - -import java.util.Collection; -import java.util.List; +import com.unboundid.scim2.common.utils.JsonUtils; /** @@ -49,22 +45,13 @@ * * For more background on devices, see the {@link DeviceResource} class. */ -@JsonIgnoreProperties("schemas") -public abstract class BlePairingMethod extends BaseScimResource +public abstract class BlePairingMethod { - /** - * Create a new Bluetooth Low-Energy Pairing Method. - */ - protected BlePairingMethod() - { - super.setSchemaUrns(List.of()); - } - @Override - public void setSchemaUrns(@NotNull final Collection schemaUrns) - throws UnsupportedOperationException + @NotNull + public String toString() { - throw new UnsupportedOperationException( - "Cannot set the 'schemas' value of a BLE Pairing Method."); + return JsonUtils.getObjectWriter().withDefaultPrettyPrinter() + .writeValueAsString(this); } } diff --git a/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/types/devices/BlePairingOutOfBand.java b/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/types/devices/BlePairingOutOfBand.java index 3da5facf..35571d59 100644 --- a/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/types/devices/BlePairingOutOfBand.java +++ b/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/types/devices/BlePairingOutOfBand.java @@ -65,16 +65,15 @@ * appear inside a {@link BleDeviceExtension}: *
  *   "urn:ietf:params:scim:schemas:extension:pairingOOB:2.0:Device": {
- *     "key": "TheKeyValueRetrievedFromOOB",
+ *     "key": "retrievedKey",
  *     "randomNumber": 238796813516896
  *   }
  * 
* * This value can be set on a device extension with the following Java code: *

- *   bleDeviceExtension.setPairingExtension(new BlePairingOutOfBand()
- *       .setKey("TheKeyValueRetrievedFromOOB")
- *       .setRandomNumber(238796813516896L));
+ *   bleDeviceExtension.setPairingExtension(
+ *       new BlePairingOutOfBand("retrievedKey", 238796813516896L));
  * 
*/ @Schema(id = "urn:ietf:params:scim:schemas:extension:pairingOOB:2.0:Device", @@ -92,6 +91,7 @@ public class BlePairingOutOfBand extends BlePairingMethod @NotNull @Attribute(description = "The random number used in OOB pairing.", isRequired = true) + @JsonProperty("randomNumber") private final NumericNode randomNumber; @Nullable @@ -152,18 +152,6 @@ public long getRandomNumber() return randomNumber.longValue(); } - /** - * Fetches the random number as a JsonNode for Jackson processing. - * - * @return The random number. - */ - @NotNull - @JsonProperty("randomNumber") - protected NumericNode getRandomNumberNode() - { - return randomNumber; - } - /** * Fetches the confirmation number used in OOB pairing. * diff --git a/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/types/devices/DeviceExtension.java b/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/types/devices/DeviceExtension.java index 8abd0b31..a165decc 100644 --- a/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/types/devices/DeviceExtension.java +++ b/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/types/devices/DeviceExtension.java @@ -36,6 +36,7 @@ import com.unboundid.scim2.common.BaseScimResource; import com.unboundid.scim2.common.annotations.NotNull; +import java.util.Collection; import java.util.List; @@ -60,6 +61,14 @@ public abstract class DeviceExtension extends BaseScimResource */ protected DeviceExtension() { - setSchemaUrns(List.of()); + super.setSchemaUrns(List.of()); + } + + @Override + public void setSchemaUrns(@NotNull final Collection schemaUrns) + throws UnsupportedOperationException + { + throw new UnsupportedOperationException( + "Cannot set the 'schemas' value of a device extension."); } } diff --git a/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/types/devices/EthernetMabDeviceExtension.java b/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/types/devices/EthernetMabDeviceExtension.java index 8fe73dd6..4354bf0e 100644 --- a/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/types/devices/EthernetMabDeviceExtension.java +++ b/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/types/devices/EthernetMabDeviceExtension.java @@ -38,7 +38,6 @@ import com.unboundid.scim2.common.annotations.NotNull; import com.unboundid.scim2.common.annotations.Nullable; import com.unboundid.scim2.common.annotations.Schema; -import com.unboundid.scim2.common.types.AttributeDefinition; import java.util.Objects; @@ -101,9 +100,6 @@ public class EthernetMabDeviceExtension extends DeviceExtension @Attribute(description = "The Ethernet MAC address of this device.", isRequired = true, isCaseExact = false, - mutability = AttributeDefinition.Mutability.READ_WRITE, - returned = AttributeDefinition.Returned.DEFAULT, - uniqueness = AttributeDefinition.Uniqueness.NONE, pattern = MAC_PATTERN) private final String deviceMacAddress; diff --git a/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/types/devices/ZigbeeDeviceExtension.java b/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/types/devices/ZigbeeDeviceExtension.java index add99d92..04f34e60 100644 --- a/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/types/devices/ZigbeeDeviceExtension.java +++ b/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/types/devices/ZigbeeDeviceExtension.java @@ -38,7 +38,6 @@ import com.unboundid.scim2.common.annotations.NotNull; import com.unboundid.scim2.common.annotations.Nullable; import com.unboundid.scim2.common.annotations.Schema; -import com.unboundid.scim2.common.types.AttributeDefinition; import com.unboundid.scim2.common.utils.StaticUtils; import java.util.List; @@ -88,8 +87,8 @@ * This JSON value can be created with the following Java code: *

  *   // Create the device extension.
- *   ZigbeeDeviceExtension zigbee = new ZigbeeDeviceExtension(
- *       "50:32:5F:FF:FE:E7:67:28", "3.0");
+ *   ZigbeeDeviceExtension zigbee =
+ *       new ZigbeeDeviceExtension("50:32:5F:FF:FE:E7:67:28", "3.0");
  *
  *   // Create the device.
  *   DeviceResource device = new DeviceResource()
@@ -120,8 +119,6 @@ public class ZigbeeDeviceExtension extends DeviceExtension
   @Attribute(description = "The Zigbee versions supported by this device.",
       isRequired = true,
       isCaseExact = false,
-      mutability = AttributeDefinition.Mutability.READ_WRITE,
-      returned = AttributeDefinition.Returned.DEFAULT,
       multiValueClass = String.class)
   private final List versionSupport;
 
@@ -130,9 +127,6 @@ public class ZigbeeDeviceExtension extends DeviceExtension
       + " Must match the pattern: " + EUI64_PATTERN,
       isRequired = true,
       isCaseExact = false,
-      mutability = AttributeDefinition.Mutability.READ_WRITE,
-      returned = AttributeDefinition.Returned.DEFAULT,
-      uniqueness = AttributeDefinition.Uniqueness.NONE,
       pattern = EUI64_PATTERN)
   private final String deviceEui64Address;
 
diff --git a/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/types/devices/package-info.java b/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/types/devices/package-info.java
index c9092dd0..09b98560 100644
--- a/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/types/devices/package-info.java
+++ b/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/types/devices/package-info.java
@@ -34,5 +34,7 @@
  * This package contains resource and extension model classes for RFC 9944,
  * which defines device resource types. For more information, see
  * {@link com.unboundid.scim2.common.types.devices.DeviceResource}.
+ *
+ * @since 6.1.0
  */
 package com.unboundid.scim2.common.types.devices;
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 6c5a3021..69f31d29 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
@@ -1111,7 +1111,7 @@ else if (element.isObject() || element.isArray())
    * {@code long} values in ObjectNodes, generally within an extension schema.
    * During deserialization, integer-like values are only stored as a long if
    * the value requires more than 32 bits. This can cause problems when another
-   * object is instantiated in code with a long, and the objects are compared
+   * object is instantiated in code using a long, and the objects are compared
    * with {@code equals()}. Since the 64-bit data type is different from the
    * 32-bit one, this will always return false even if the actual numbers
    * represented are identical.
@@ -1131,8 +1131,8 @@ public static NumericNode asNumericNode(final long value)
   }
 
   /**
-   * Serializes an object to a pretty-printed JSON string, overriding values
-   * of top-level field names that match the provided list.
+   * Prints an object as a pretty-printed JSON string, overriding values
+   * of top-level string fields that match the provided list.
    *
    * @param resource        The resource to serialize.
    * @param redactedFields  The names of the fields to redact.
diff --git a/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/utils/SchemaUtils.java b/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/utils/SchemaUtils.java
index c2da6e96..7a3e3cd6 100644
--- a/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/utils/SchemaUtils.java
+++ b/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/utils/SchemaUtils.java
@@ -716,7 +716,7 @@ public static String getSchemaUrn(@NotNull final Class cls)
     // The 'schemaId' is the URN. Make sure it begins with the "urn:" prefix.
     // The 'name' field is a human-friendly name for the object.
     String schemaId =
-          SchemaUtils.getSchemaIdFromAnnotation(cls);
+        SchemaUtils.getSchemaIdFromAnnotation(cls);
 
     if ((schemaId == null) || (schemaId.isEmpty()))
     {
diff --git a/scim2-sdk-common/src/test/java/com/unboundid/scim2/common/types/devices/DevicesTest.java b/scim2-sdk-common/src/test/java/com/unboundid/scim2/common/types/devices/DevicesTest.java
index de7c07c5..b7507a05 100644
--- a/scim2-sdk-common/src/test/java/com/unboundid/scim2/common/types/devices/DevicesTest.java
+++ b/scim2-sdk-common/src/test/java/com/unboundid/scim2/common/types/devices/DevicesTest.java
@@ -41,6 +41,7 @@
 import java.util.HashSet;
 import java.util.List;
 import java.util.Set;
+import java.util.UUID;
 
 import static org.assertj.core.api.Assertions.assertThatThrownBy;
 import static org.assertj.core.api.Assertions.assertThat;
@@ -340,6 +341,13 @@ public void testBleDeviceExtension()
     assertThat(ble)
         .isNotEqualTo(deserialized.getExtension(BleDeviceExtension.class));
 
+    // Try the same call again. This tests removal of a pairing method object
+    // that is not present on a BLE extension. This should be a no-op.
+    var list = List.copyOf(ble.getPairingMethodExtensions());
+    ble.removePairingExtension(BlePairingOutOfBand.class);
+    assertThat(ble.getPairingMethodExtensions()).isEqualTo(list);
+    assertThat(ble.getPairingMethods()).hasSameSizeAs(list);
+
     // Test manually setting the pairing methods.
     ble.setPairingMethods(List.of("urn:customValue"));
     assertThat(ble.getPairingMethods()).containsOnly("urn:customValue");
@@ -382,10 +390,6 @@ public void testBlePairingJustWorks()
     BlePairingJustWorks deserialized = JsonUtils.getObjectReader()
         .forType(BlePairingJustWorks.class).readValue(json);
     assertThat(deserialized).isEqualTo(new BlePairingJustWorks());
-
-    // Setting schema URNs should fail for any BLE pairing method.
-    assertThatThrownBy(() -> deserialized.setSchemaUrns(List.of()))
-        .isInstanceOf(UnsupportedOperationException.class);
   }
 
   /**
@@ -702,6 +706,33 @@ public void testZigbeeDeviceExtension()
     assertThat(deserialized.getDeviceExtensions()).containsOnly(zigbee);
   }
 
+  /**
+   * Ensure that device extensions are explicitly forbidden from having a value
+   * for {@code schemas}.
+   */
+  @Test
+  public void testNoSchemasForDeviceExtensions()
+  {
+    EthernetMabDeviceExtension mab = new EthernetMabDeviceExtension("address");
+    assertThatThrownBy(() -> mab.setSchemaUrns("urn:invalidUrn"))
+        .isInstanceOf(UnsupportedOperationException.class)
+        .hasMessage("Cannot set the 'schemas' value of a device extension.");
+
+    // Any 'schemas' JSON value should be dropped, as it is invalid.
+    String wifiJson = """
+        {
+          "schemas": [ "urn:this:should:be:unused" ],
+          "dppVersion": 3,
+          "bootstrappingMethod": [ "QR" ],
+          "bootstrapKey": "secretKey",
+          "deviceMacAddress": "00:11:22:33:44:55"
+        }""";
+
+    DppDeviceExtension wifiExtension = JsonUtils.getObjectReader()
+        .forType(DppDeviceExtension.class).readValue(wifiJson);
+    assertThat(wifiExtension.getSchemaUrns()).isEmpty();
+  }
+
   /**
    * Tests for {@link EndpointAppReference}.
    */
@@ -769,7 +800,7 @@ public void testEndpointAppsDeviceExtension()
         .toPrettyString();
 
     EndpointAppReference app1 = new EndpointAppReference()
-        .setValue("e9e30dba-f08f-4109-8486-d5c6a3316212")
+        .setValue(UUID.fromString("e9e30dba-f08f-4109-8486-d5c6a3316212"))
         .setRef("https://example.com/v2/EndpointApps/"
                 + "e9e30dba-f08f-4109-8486-d5c6a3316212");
     EndpointAppReference app2 = new EndpointAppReference()

From d349d58adff70ea619d2c50ed8f0ce453cc3b196 Mon Sep 17 00:00:00 2001
From: Khalid Qarryzada 
Date: Mon, 21 Sep 2026 09:17:59 -0700
Subject: [PATCH 4/4] Mark subjectName as required.

---
 .../common/types/devices/CertificateInfo.java | 42 ++++++++++---------
 .../types/devices/EndpointAppResource.java    | 18 +-------
 .../common/types/devices/DevicesTest.java     | 37 +++++++---------
 .../scim2/server/EndpointTestCase.java        |  1 -
 4 files changed, 38 insertions(+), 60 deletions(-)

diff --git a/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/types/devices/CertificateInfo.java b/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/types/devices/CertificateInfo.java
index 5b47db79..9887a206 100644
--- a/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/types/devices/CertificateInfo.java
+++ b/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/types/devices/CertificateInfo.java
@@ -32,6 +32,8 @@
 
 package com.unboundid.scim2.common.types.devices;
 
+import com.fasterxml.jackson.annotation.JsonProperty;
+import com.fasterxml.jackson.annotation.JsonPropertyOrder;
 import com.unboundid.scim2.common.annotations.Attribute;
 import com.unboundid.scim2.common.annotations.NotNull;
 import com.unboundid.scim2.common.annotations.Nullable;
@@ -52,14 +54,13 @@
  *                             trust anchor). This will be base64-encoded.
  *   
  • {@code subjectName}: This string represents either the subjectName or * the subjectAlternateName of the certificate. + * This field is required. * * * For example JSON structure and Java code, see {@link EndpointAppResource}. *

    * - * The fields on {@code CertificateInfo} are optional, and the SCIM SDK stores - * these string values without attempting to validate their format. As stated - * above, {@code rootCA} will be a {@link java.util.Base64} value. For + * As stated above, {@code rootCA} will be a {@link java.util.Base64} value. For * {@code subjectName}, if the field represents a subjectAlternateName, it will * be a DNS name such as {@code www.example.com}. Otherwise, it will represent a * DN (distinguished name) such as {@code CN=EX1,O=Example,C=US}. @@ -77,6 +78,7 @@ * as OAuth 2, MUST be pre-arranged. * */ +@JsonPropertyOrder({"rootCA", "subjectName"}) public class CertificateInfo { @Nullable @@ -84,11 +86,24 @@ public class CertificateInfo isRequired = false) private String rootCA; - @Nullable + @NotNull @Attribute(description = "The subject name for the endpoint application certificate.", - isRequired = false) - private String subjectName; + isRequired = true) + @JsonProperty + private final String subjectName; + + /** + * Creates a new CertificateInfo. + * + * @param subjectName The Distinguished Name or DNS name. + */ + public CertificateInfo( + @NotNull @JsonProperty(value = "subjectName", required = true) + final String subjectName) + { + this.subjectName = Objects.requireNonNull(subjectName); + } /** * Retrieves the root certificate authority for the endpoint application. @@ -119,25 +134,12 @@ public CertificateInfo setRootCA(@Nullable final String rootCA) * * @return The subject name. */ - @Nullable + @NotNull public String getSubjectName() { return subjectName; } - /** - * Specifies the subject name for the endpoint application certificate. - * - * @param subjectName The subject name. - * @return This CertificateInfo. - */ - @NotNull - public CertificateInfo setSubjectName(@Nullable final String subjectName) - { - this.subjectName = subjectName; - return this; - } - /** * Indicates whether the provided object is equal to this certificate info. * diff --git a/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/types/devices/EndpointAppResource.java b/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/types/devices/EndpointAppResource.java index d0a592b7..f6aca330 100644 --- a/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/types/devices/EndpointAppResource.java +++ b/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/types/devices/EndpointAppResource.java @@ -95,9 +95,8 @@ * EndpointAppResource app = new EndpointAppResource() * .setApplicationType("deviceControl") * .setApplicationName("Device Control App 1") - * .setCertificateInfo(new CertificateInfo() - * .setRootCA("d2hhdHNVcA==") - * .setSubjectName("CN=EX1,O=Example,C=US")); + * .setCertificateInfo(new CertificateInfo("CN=EX1,O=Example,C=US") + * .setRootCA("d2hhdHNVcA==")); * app.setId("e9e30dba"); * app.setMeta(new Meta() * .setResourceType("EndpointApp") @@ -304,19 +303,6 @@ public EndpointAppResource setGroups(@NotNull final Group group, return setGroups(StaticUtils.toList(group, groups)); } - /** - * Verifies whether the {@code certificateInfo} field is {@code null} or - * contains no data. This can be used by SCIM applications to check if a - * certificateInfo has fields that should be validated. - * - * @return {@code true} if the certificateInfo is {@code null} or empty. - */ - public boolean hasEmptyCertificateInfo() - { - return certificateInfo == null || (certificateInfo.getRootCA() == null - && certificateInfo.getSubjectName() == null); - } - /** * Indicates whether the provided object is equal to this endpoint app * resource. diff --git a/scim2-sdk-common/src/test/java/com/unboundid/scim2/common/types/devices/DevicesTest.java b/scim2-sdk-common/src/test/java/com/unboundid/scim2/common/types/devices/DevicesTest.java index b7507a05..79beea21 100644 --- a/scim2-sdk-common/src/test/java/com/unboundid/scim2/common/types/devices/DevicesTest.java +++ b/scim2-sdk-common/src/test/java/com/unboundid/scim2/common/types/devices/DevicesTest.java @@ -126,16 +126,15 @@ public void testEndpointAppResource() "applicationType": "deviceControl", "applicationName": "Device Control App 1", "certificateInfo": { - "rootCA": "MIIBIjAN...", + "rootCA": "SGV5U3RvcERlY29kaW5nTWU=", "subjectName": "www.example.com" } }"""; String expectedJson = JsonUtils.getObjectReader().readTree(json) .toPrettyString(); - CertificateInfo cert = new CertificateInfo() - .setRootCA("MIIBIjAN...") - .setSubjectName("www.example.com"); + CertificateInfo cert = new CertificateInfo("www.example.com") + .setRootCA("SGV5U3RvcERlY29kaW5nTWU="); EndpointAppResource app = new EndpointAppResource() .setApplicationType("deviceControl") @@ -147,7 +146,6 @@ public void testEndpointAppResource() assertThat(app.getApplicationType()).isEqualTo("deviceControl"); assertThat(app.getApplicationName()).isEqualTo("Device Control App 1"); assertThat(app.getCertificateInfo()).isEqualTo(cert); - assertThat(app.hasEmptyCertificateInfo()).isFalse(); assertThat(app.toString()).isEqualTo(expectedJson); String serialized = JsonUtils.getObjectWriter().writeValueAsString(app); @@ -172,12 +170,6 @@ public void testEndpointAppResource() .forType(EndpointAppResource.class).readValue(richSerialized); assertThat(rich).isEqualTo(richDeserialized); - // Validate utility methods. - EndpointAppResource emptyApp = new EndpointAppResource(); - assertThat(emptyApp.hasEmptyCertificateInfo()).isTrue(); - emptyApp = emptyApp.setCertificateInfo(new CertificateInfo()); - assertThat(emptyApp.hasEmptyCertificateInfo()).isTrue(); - // Ensure max length restrictions on client tokens. assertThatThrownBy(() -> new EndpointAppResource() .setClientToken("a".repeat(501))) @@ -199,9 +191,8 @@ public void testCertificateInfo() String expectedJson = JsonUtils.getObjectReader().readTree(json) .toPrettyString(); - CertificateInfo cert = new CertificateInfo() - .setRootCA("SGV5U3RvcERlY29kaW5nTWU=") - .setSubjectName("CN=EX1, O=Example, C=US"); + CertificateInfo cert = new CertificateInfo("CN=EX1, O=Example, C=US") + .setRootCA("SGV5U3RvcERlY29kaW5nTWU="); assertThat(cert.getRootCA()).isEqualTo("SGV5U3RvcERlY29kaW5nTWU="); assertThat(cert.getSubjectName()).isEqualTo("CN=EX1, O=Example, C=US"); @@ -420,16 +411,16 @@ public void testBlePairingOOB() { String json = """ { - "key": "TheKeyvalueRetrievedFromOOB", + "key": "retrievedKey", "randomNumber": 987654 }"""; String expectedJson = JsonUtils.getObjectReader().readTree(json) .toPrettyString(); BlePairingOutOfBand figure6 = new BlePairingOutOfBand( - "TheKeyvalueRetrievedFromOOB", 987654); + "retrievedKey", 987654); - assertThat(figure6.getKey()).isEqualTo("TheKeyvalueRetrievedFromOOB"); + assertThat(figure6.getKey()).isEqualTo("retrievedKey"); assertThat(figure6.getRandomNumber()).isEqualTo(987654); assertThat(figure6.toString()).isEqualTo(expectedJson); @@ -439,7 +430,7 @@ public void testBlePairingOOB() assertThat(figure6).isEqualTo(deserialized); BlePairingOutOfBand withConfirmation = new BlePairingOutOfBand( - "TheKeyvalueRetrievedFromOOB", 987654) + "retrievedKey", 987654) .setConfirmationNumber(111222); assertThat(withConfirmation.getConfirmationNumber()).isEqualTo(111222); @@ -846,8 +837,8 @@ public void testBleIrkAndSeparateBroadcastAddressMutualExclusion() // Setting only irk is accepted. BleDeviceExtension onlyIrk = new BleDeviceExtension("2C:54:91:88:C9:E2", "5.4") - .setIrk("secretirk"); - assertThat(onlyIrk.getIrk()).isEqualTo("secretirk"); + .setIrk("secretIRK"); + assertThat(onlyIrk.getIrk()).isEqualTo("secretIRK"); // Setting only separateBroadcastAddress is accepted. BleDeviceExtension onlySba = @@ -860,14 +851,14 @@ public void testBleIrkAndSeparateBroadcastAddressMutualExclusion() assertThatThrownBy(() -> new BleDeviceExtension("2C:54:91:88:C9:E2", "5.4") .setSeparateBroadcastAddress("AA:BB:88:77:22:11") - .setIrk("secretirk")) + .setIrk("secretIRK")) .isInstanceOf(IllegalStateException.class) .hasMessageContaining("fields cannot both be set"); // Setting separateBroadcastAddress when irk is already set must throw. assertThatThrownBy(() -> new BleDeviceExtension("2C:54:91:88:C9:E2", "5.4") - .setIrk("secretirk") + .setIrk("secretIRK") .setSeparateBroadcastAddress("AA:BB:88:77:22:11")) .isInstanceOf(IllegalStateException.class) .hasMessageContaining("fields cannot both be set"); @@ -883,7 +874,7 @@ public void testHashCode() List instances = List.of( new DeviceResource(), new EndpointAppResource(), - new CertificateInfo(), + new CertificateInfo("OU=People , DC=Example, DC=Com"), new BleDeviceExtension("2C:54:91:88:C9:E2", "5.4"), new BlePairingJustWorks(), new BlePairingNull(), diff --git a/scim2-sdk-server/src/test/java/com/unboundid/scim2/server/EndpointTestCase.java b/scim2-sdk-server/src/test/java/com/unboundid/scim2/server/EndpointTestCase.java index 81ba9f4e..d0d8be9e 100644 --- a/scim2-sdk-server/src/test/java/com/unboundid/scim2/server/EndpointTestCase.java +++ b/scim2-sdk-server/src/test/java/com/unboundid/scim2/server/EndpointTestCase.java @@ -1564,7 +1564,6 @@ public void testGetEndpointApps() throws ScimException assertThat(app.getApplicationName()).isEqualTo("Test App"); assertThat(app.getClientToken()).isNull(); assertThat(app.getCertificateInfo()).isNull(); - assertThat(app.hasEmptyCertificateInfo()).isTrue(); assertThat(app.getGroups()).isEmpty(); assertThat(app.getMeta()).isNotNull();