diff --git a/CHANGELOG.md b/CHANGELOG.md index a843c7a2..337461e3 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. @@ -37,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 @@ -598,4 +609,3 @@ Make sure the Response is always closed after a SCIM operation. ## v1.2.9 - 2016-07-08 Initial Public Release - 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/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..8b92e657 --- /dev/null +++ b/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/types/devices/BleDeviceExtension.java @@ -0,0 +1,542 @@ +/* + * 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.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.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") +@JsonPropertyOrder({"versionSupport", "deviceMacAddress"}) +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 deviceMacAddress The device's MAC address. + * @param versionSupport The BLE versions supported by this device. + */ + @JsonCreator + public BleDeviceExtension( + @NotNull @JsonProperty(value = "deviceMacAddress", required = true) + final String deviceMacAddress, + @NotNull @JsonProperty(value = "versionSupport", required = true) + final List versionSupport) + { + this.deviceMacAddress = Objects.requireNonNull(deviceMacAddress); + this.versionSupport = List.copyOf(versionSupport); + } + + /** + * 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(deviceMacAddress, StaticUtils.toList(versionSupport, versions)); + } + + /** + * 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, as this 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 pairingMethod The pairing method extension to set. + * @return This Bluetooth Low Energy extension. + */ + @NotNull + public BleDeviceExtension setPairingExtension( + @NotNull final BlePairingMethod 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. 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. + * @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..e57d246c --- /dev/null +++ b/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/types/devices/BlePairingMethod.java @@ -0,0 +1,57 @@ +/* + * 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.NotNull; +import com.unboundid.scim2.common.utils.JsonUtils; + + +/** + * 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. + */ +public abstract class BlePairingMethod +{ + @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/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..35571d59 --- /dev/null +++ b/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/types/devices/BlePairingOutOfBand.java @@ -0,0 +1,211 @@ +/* + * 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": "retrievedKey",
+ *     "randomNumber": 238796813516896
+ *   }
+ * 
+ * + * This value can be set on a device extension with the following Java code: + *

+ *   bleDeviceExtension.setPairingExtension(
+ *       new BlePairingOutOfBand("retrievedKey", 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) + @JsonProperty("randomNumber") + 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 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..9887a206 --- /dev/null +++ b/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/types/devices/CertificateInfo.java @@ -0,0 +1,186 @@ +/* + * 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.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.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. + * This field is required. + *
+ * + * For example JSON structure and Java code, see {@link EndpointAppResource}. + *

+ * + * 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. + *
+ */ +@JsonPropertyOrder({"rootCA", "subjectName"}) +public class CertificateInfo +{ + @Nullable + @Attribute(description = "The certificate authority for the endpoint app.", + isRequired = false) + private String rootCA; + + @NotNull + @Attribute(description = + "The subject name for the endpoint application certificate.", + 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. + * + * @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. + */ + @NotNull + public String getSubjectName() + { + return subjectName; + } + + /** + * 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..a165decc --- /dev/null +++ b/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/types/devices/DeviceExtension.java @@ -0,0 +1,74 @@ +/* + * 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 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() + { + 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/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..f6aca330 --- /dev/null +++ b/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/types/devices/EndpointAppResource.java @@ -0,0 +1,342 @@ +/* + * 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("CN=EX1,O=Example,C=US")
+ *           .setRootCA("d2hhdHNVcA=="));
+ *   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)); + } + + /** + * 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..4354bf0e --- /dev/null +++ b/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/types/devices/EthernetMabDeviceExtension.java @@ -0,0 +1,160 @@ +/* + * 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 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, + 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..04f34e60 --- /dev/null +++ b/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/types/devices/ZigbeeDeviceExtension.java @@ -0,0 +1,218 @@ +/* + * 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.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, + 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, + 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..09b98560 --- /dev/null +++ b/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/types/devices/package-info.java @@ -0,0 +1,40 @@ +/* + * 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}. + * + * @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 f43f7da9..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 @@ -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 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. + *

+ * + * 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); + } + + /** + * 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. + * + * @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..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 @@ -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; } 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..79beea21 --- /dev/null +++ b/scim2-sdk-common/src/test/java/com/unboundid/scim2/common/types/devices/DevicesTest.java @@ -0,0 +1,893 @@ +/* + * 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 java.util.UUID; + +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": "SGV5U3RvcERlY29kaW5nTWU=", + "subjectName": "www.example.com" + } + }"""; + String expectedJson = JsonUtils.getObjectReader().readTree(json) + .toPrettyString(); + + CertificateInfo cert = new CertificateInfo("www.example.com") + .setRootCA("SGV5U3RvcERlY29kaW5nTWU="); + + 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.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); + + // 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("CN=EX1, O=Example, C=US") + .setRootCA("SGV5U3RvcERlY29kaW5nTWU="); + + 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)); + + // 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"); + + // 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()); + } + + /** + * 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": "retrievedKey", + "randomNumber": 987654 + }"""; + String expectedJson = JsonUtils.getObjectReader().readTree(json) + .toPrettyString(); + + BlePairingOutOfBand figure6 = new BlePairingOutOfBand( + "retrievedKey", 987654); + + assertThat(figure6.getKey()).isEqualTo("retrievedKey"); + 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( + "retrievedKey", 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); + } + + /** + * 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}. + */ + @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(UUID.fromString("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("OU=People , DC=Example, DC=Com"), + 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..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 @@ -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,97 @@ 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.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