From 436c73e3974b4196aa1ba3f9400964b8509d5c30 Mon Sep 17 00:00:00 2001 From: kaviska Date: Sat, 11 Jul 2026 19:32:56 +0530 Subject: [PATCH 1/8] Add device management component (org.wso2.carbon.identity.device.mgt) Introduce the standalone device registration/management backend bundle: - New module components/device-mgt/org.wso2.carbon.identity.device.mgt with service, DAO, cache, audit logging and OSGi component. - Register the module in the root and device-mgt aggregator POMs (7.11.153-SNAPSHOT). - Add IDN_DEVICE and IDN_USER_DEVICE DDL to all central dbscripts. --- .../pom.xml | 173 ++++ .../device/mgt/api/constant/ErrorMessage.java | 100 +++ .../exception/DeviceMgtClientException.java | 51 ++ .../mgt/api/exception/DeviceMgtException.java | 78 ++ .../exception/DeviceMgtServerException.java | 51 ++ .../identity/device/mgt/api/model/Device.java | 272 +++++++ .../model/DeviceRegistrationInitiation.java | 60 ++ .../api/service/DeviceManagementService.java | 130 +++ .../cache/DeviceRegistrationCache.java | 46 ++ .../cache/DeviceRegistrationCacheEntry.java | 52 ++ .../cache/DeviceRegistrationCacheKey.java | 72 ++ .../cache/DeviceRegistrationContext.java | 77 ++ .../component/DeviceMgtServiceComponent.java | 76 ++ .../constant/DeviceMgtSQLConstants.java | 154 ++++ .../mgt/internal/dao/DeviceManagementDAO.java | 117 +++ .../dao/impl/DeviceManagementDAOImpl.java | 360 +++++++++ .../impl/DeviceManagementServiceImpl.java | 286 +++++++ .../util/DeviceManagementAuditLogger.java | 196 +++++ .../DeviceManagementExceptionHandler.java | 93 +++ .../mgt/dao/DeviceManagementDAOImplTest.java | 311 ++++++++ .../DeviceManagementServiceImplTest.java | 372 +++++++++ .../DeviceManagementExceptionHandlerTest.java | 99 +++ .../src/test/resources/dbscripts/h2.sql | 22 + .../test/resources/repository/conf/carbon.xml | 686 ++++++++++++++++ .../repository/conf/identity/identity.xml | 743 ++++++++++++++++++ .../src/test/resources/testng.xml | 19 + components/device-mgt/pom.xml | 56 ++ .../resources/dbscripts/db2.sql | 26 + .../resources/dbscripts/h2.sql | 24 + .../resources/dbscripts/mssql.sql | 24 + .../resources/dbscripts/mysql-cluster.sql | 24 + .../resources/dbscripts/mysql.sql | 24 + .../resources/dbscripts/oracle.sql | 26 + .../resources/dbscripts/oracle_rac.sql | 26 + .../resources/dbscripts/postgresql.sql | 24 + pom.xml | 1 + 36 files changed, 4951 insertions(+) create mode 100644 components/device-mgt/org.wso2.carbon.identity.device.mgt/pom.xml create mode 100644 components/device-mgt/org.wso2.carbon.identity.device.mgt/src/main/java/org/wso2/carbon/identity/device/mgt/api/constant/ErrorMessage.java create mode 100644 components/device-mgt/org.wso2.carbon.identity.device.mgt/src/main/java/org/wso2/carbon/identity/device/mgt/api/exception/DeviceMgtClientException.java create mode 100644 components/device-mgt/org.wso2.carbon.identity.device.mgt/src/main/java/org/wso2/carbon/identity/device/mgt/api/exception/DeviceMgtException.java create mode 100644 components/device-mgt/org.wso2.carbon.identity.device.mgt/src/main/java/org/wso2/carbon/identity/device/mgt/api/exception/DeviceMgtServerException.java create mode 100644 components/device-mgt/org.wso2.carbon.identity.device.mgt/src/main/java/org/wso2/carbon/identity/device/mgt/api/model/Device.java create mode 100644 components/device-mgt/org.wso2.carbon.identity.device.mgt/src/main/java/org/wso2/carbon/identity/device/mgt/api/model/DeviceRegistrationInitiation.java create mode 100644 components/device-mgt/org.wso2.carbon.identity.device.mgt/src/main/java/org/wso2/carbon/identity/device/mgt/api/service/DeviceManagementService.java create mode 100644 components/device-mgt/org.wso2.carbon.identity.device.mgt/src/main/java/org/wso2/carbon/identity/device/mgt/internal/cache/DeviceRegistrationCache.java create mode 100644 components/device-mgt/org.wso2.carbon.identity.device.mgt/src/main/java/org/wso2/carbon/identity/device/mgt/internal/cache/DeviceRegistrationCacheEntry.java create mode 100644 components/device-mgt/org.wso2.carbon.identity.device.mgt/src/main/java/org/wso2/carbon/identity/device/mgt/internal/cache/DeviceRegistrationCacheKey.java create mode 100644 components/device-mgt/org.wso2.carbon.identity.device.mgt/src/main/java/org/wso2/carbon/identity/device/mgt/internal/cache/DeviceRegistrationContext.java create mode 100644 components/device-mgt/org.wso2.carbon.identity.device.mgt/src/main/java/org/wso2/carbon/identity/device/mgt/internal/component/DeviceMgtServiceComponent.java create mode 100644 components/device-mgt/org.wso2.carbon.identity.device.mgt/src/main/java/org/wso2/carbon/identity/device/mgt/internal/constant/DeviceMgtSQLConstants.java create mode 100644 components/device-mgt/org.wso2.carbon.identity.device.mgt/src/main/java/org/wso2/carbon/identity/device/mgt/internal/dao/DeviceManagementDAO.java create mode 100644 components/device-mgt/org.wso2.carbon.identity.device.mgt/src/main/java/org/wso2/carbon/identity/device/mgt/internal/dao/impl/DeviceManagementDAOImpl.java create mode 100644 components/device-mgt/org.wso2.carbon.identity.device.mgt/src/main/java/org/wso2/carbon/identity/device/mgt/internal/service/impl/DeviceManagementServiceImpl.java create mode 100644 components/device-mgt/org.wso2.carbon.identity.device.mgt/src/main/java/org/wso2/carbon/identity/device/mgt/internal/util/DeviceManagementAuditLogger.java create mode 100644 components/device-mgt/org.wso2.carbon.identity.device.mgt/src/main/java/org/wso2/carbon/identity/device/mgt/internal/util/DeviceManagementExceptionHandler.java create mode 100644 components/device-mgt/org.wso2.carbon.identity.device.mgt/src/test/java/org/wso2/carbon/identity/device/mgt/dao/DeviceManagementDAOImplTest.java create mode 100644 components/device-mgt/org.wso2.carbon.identity.device.mgt/src/test/java/org/wso2/carbon/identity/device/mgt/service/DeviceManagementServiceImplTest.java create mode 100644 components/device-mgt/org.wso2.carbon.identity.device.mgt/src/test/java/org/wso2/carbon/identity/device/mgt/util/DeviceManagementExceptionHandlerTest.java create mode 100644 components/device-mgt/org.wso2.carbon.identity.device.mgt/src/test/resources/dbscripts/h2.sql create mode 100644 components/device-mgt/org.wso2.carbon.identity.device.mgt/src/test/resources/repository/conf/carbon.xml create mode 100644 components/device-mgt/org.wso2.carbon.identity.device.mgt/src/test/resources/repository/conf/identity/identity.xml create mode 100644 components/device-mgt/org.wso2.carbon.identity.device.mgt/src/test/resources/testng.xml create mode 100644 components/device-mgt/pom.xml diff --git a/components/device-mgt/org.wso2.carbon.identity.device.mgt/pom.xml b/components/device-mgt/org.wso2.carbon.identity.device.mgt/pom.xml new file mode 100644 index 000000000000..3840940472c3 --- /dev/null +++ b/components/device-mgt/org.wso2.carbon.identity.device.mgt/pom.xml @@ -0,0 +1,173 @@ + + + + + + + org.wso2.carbon.identity.framework + device-mgt + 7.11.153-SNAPSHOT + ../pom.xml + + + 4.0.0 + org.wso2.carbon.identity.device.mgt + bundle + WSO2 Carbon - Device Management Component + Generic device registration management backend component + http://wso2.org + + + + + org.eclipse.platform + org.eclipse.osgi + + + org.eclipse.platform + org.eclipse.osgi.services + + + + org.wso2.carbon.utils + org.wso2.carbon.database.utils + + + + org.wso2.carbon.identity.framework + org.wso2.carbon.identity.core + + + + org.wso2.carbon.identity.framework + org.wso2.carbon.identity.central.log.mgt + + + + org.json.wso2 + json + + + + commons-logging + commons-logging + + + + org.testng + testng + test + + + org.mockito + mockito-core + test + + + org.mockito + mockito-testng + test + + + com.h2database + h2 + test + + + org.wso2.carbon.identity.framework + org.wso2.carbon.identity.testutil + test + + + + + + + + org.apache.felix + maven-bundle-plugin + true + + + ${project.artifactId} + ${project.artifactId} + + org.wso2.carbon.identity.device.mgt.internal.* + + + !org.wso2.carbon.identity.device.mgt.internal.*, + org.wso2.carbon.identity.device.mgt.api.*; + version="${carbon.identity.package.export.version}" + + + org.apache.commons.logging; + version="${import.package.version.commons.logging}", + org.osgi.framework; + version="${osgi.framework.imp.pkg.version.range}", + org.osgi.service.component; + version="${osgi.service.component.imp.pkg.version.range}", + org.osgi.service.component.annotations; + version="${osgi.service.component.imp.pkg.version.range}", + org.wso2.carbon.database.utils.jdbc; + version="${org.wso2.carbon.database.utils.version.range}", + org.wso2.carbon.database.utils.jdbc.exceptions; + version="${org.wso2.carbon.database.utils.version.range}", + org.wso2.carbon.identity.core.cache; + version="${carbon.identity.package.import.version.range}", + org.wso2.carbon.identity.core.util; + version="${carbon.identity.package.import.version.range}", + org.wso2.carbon.identity.central.log.mgt.utils; + version="${carbon.identity.package.import.version.range}", + org.json.*; version="${json.wso2.version.range}", + org.wso2.carbon.context; + version="${carbon.kernel.package.import.version.range}", + org.wso2.carbon.utils; + version="${carbon.kernel.package.import.version.range}" + + + + + + org.apache.maven.plugins + maven-surefire-plugin + ${maven.surefire.plugin.version} + + + src/test/resources/testng.xml + + + + + com.github.spotbugs + spotbugs-maven-plugin + + ../../../spotbugs-exclude.xml + Max + High + true + + + + + + + diff --git a/components/device-mgt/org.wso2.carbon.identity.device.mgt/src/main/java/org/wso2/carbon/identity/device/mgt/api/constant/ErrorMessage.java b/components/device-mgt/org.wso2.carbon.identity.device.mgt/src/main/java/org/wso2/carbon/identity/device/mgt/api/constant/ErrorMessage.java new file mode 100644 index 000000000000..635d925ebc26 --- /dev/null +++ b/components/device-mgt/org.wso2.carbon.identity.device.mgt/src/main/java/org/wso2/carbon/identity/device/mgt/api/constant/ErrorMessage.java @@ -0,0 +1,100 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (http://www.wso2.com). + * + * WSO2 LLC. licenses this file to you 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. + */ + +package org.wso2.carbon.identity.device.mgt.api.constant; + +/** + * Device management error messages. + */ +public enum ErrorMessage { + + ERROR_DEVICE_NOT_FOUND("DM-60001", "Device not found.", + "No registered device found for the given device id: %s."), + ERROR_INVALID_DEVICE_FIELD("DM-60002", "Invalid request.", + "%s is empty or invalid."), + ERROR_DEVICE_ALREADY_REGISTERED("DM-60003", "Device already registered.", + "A device with the same public key is already registered for user: %s."), + ERROR_REGISTRATION_CONTEXT_NOT_FOUND("DM-60004", "Registration context not found.", + "No pending registration found for registration ID: %s. The context may have expired."), + ERROR_INVALID_DEVICE_SIGNATURE("DM-60005", "Invalid device signature.", + "The device signature verification failed for registration ID: %s."), + + ERROR_USER_NOT_IDENTIFIED("DM-60006", "User not identified.", + "Cannot initiate device registration: no authenticated user found in the flow context."), + ERROR_DEVICE_POLICY_NOT_COMPLIANT("DM-60007", "Device not compliant.", + "Device does not comply with policy '%s'. Failed fields: %s."), + ERROR_DEVICE_DATA_REQUIRED("DM-60008", "Device data required.", + "A compliance policy is configured for this executor but no device data was submitted. " + + "Send device attributes as a JSON object under the 'deviceData' key."), + + ERROR_WHILE_REGISTERING_DEVICE("DM-65001", "Error while registering device.", + "Error while persisting device registration in the system."), + ERROR_WHILE_RETRIEVING_DEVICE("DM-65002", "Error while retrieving device.", + "Error while retrieving device from the system."), + ERROR_WHILE_UPDATING_DEVICE("DM-65003", "Error while updating device.", + "Error while updating device in the system."), + ERROR_WHILE_DELETING_DEVICE("DM-65004", "Error while deleting device.", + "Error while deleting device from the system."), + ERROR_WHILE_VERIFYING_SIGNATURE("DM-65005", "Error while verifying device signature.", + "An unexpected error occurred during signature verification for registration ID: %s."), + ERROR_WHILE_EVALUATING_POLICY("DM-65006", "Error while evaluating device policy.", + "An error occurred while evaluating policy '%s'."), + ERROR_USER_ID_REQUIRED("DM-65007", "User identifier required.", + "Cannot persist device: a valid user identifier (userId) was not set before persistence."); + + private final String code; + private final String message; + private final String description; + + ErrorMessage(String code, String message, String description) { + + this.code = code; + this.message = message; + this.description = description; + } + + /** + * Returns the error code. + * + * @return Error code. + */ + public String getCode() { + + return code; + } + + /** + * Returns the high-level error message. + * + * @return Error message. + */ + public String getMessage() { + + return message; + } + + /** + * Returns the detailed error description. + * + * @return Error description. + */ + public String getDescription() { + + return description; + } +} diff --git a/components/device-mgt/org.wso2.carbon.identity.device.mgt/src/main/java/org/wso2/carbon/identity/device/mgt/api/exception/DeviceMgtClientException.java b/components/device-mgt/org.wso2.carbon.identity.device.mgt/src/main/java/org/wso2/carbon/identity/device/mgt/api/exception/DeviceMgtClientException.java new file mode 100644 index 000000000000..dff81dcabd27 --- /dev/null +++ b/components/device-mgt/org.wso2.carbon.identity.device.mgt/src/main/java/org/wso2/carbon/identity/device/mgt/api/exception/DeviceMgtClientException.java @@ -0,0 +1,51 @@ +/* +* Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com) All Rights Reserved. +* +* WSO2 LLC. licenses this file to you 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. +*/ + +package org.wso2.carbon.identity.device.mgt.api.exception; + +/** + * Client-side validation exception for device management. + */ +public class DeviceMgtClientException extends DeviceMgtException { + + /** + * Creates a new client exception. + * + * @param message Error message. + * @param description Error description. + * @param errorCode Error code. + */ + public DeviceMgtClientException(String message, String description, String errorCode) { + + super(message, description, errorCode); + } + + /** + * Creates a new client exception with the cause. + * + * @param message Error message. + * @param description Error description. + * @param errorCode Error code. + * @param cause Root cause. + */ + public DeviceMgtClientException(String message, String description, String errorCode, Throwable cause) { + + super(message, description, errorCode, cause); + } +} + diff --git a/components/device-mgt/org.wso2.carbon.identity.device.mgt/src/main/java/org/wso2/carbon/identity/device/mgt/api/exception/DeviceMgtException.java b/components/device-mgt/org.wso2.carbon.identity.device.mgt/src/main/java/org/wso2/carbon/identity/device/mgt/api/exception/DeviceMgtException.java new file mode 100644 index 000000000000..e809651157cd --- /dev/null +++ b/components/device-mgt/org.wso2.carbon.identity.device.mgt/src/main/java/org/wso2/carbon/identity/device/mgt/api/exception/DeviceMgtException.java @@ -0,0 +1,78 @@ +/* +* Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com) All Rights Reserved. +* +* WSO2 LLC. licenses this file to you 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. +*/ + +package org.wso2.carbon.identity.device.mgt.api.exception; + +/** + * Base checked exception for device management. + */ +public class DeviceMgtException extends Exception { + + private final String errorCode; + private final String description; + + /** + * Creates a new device management exception. + * + * @param message Error message. + * @param description Error description. + * @param errorCode Error code. + */ + public DeviceMgtException(String message, String description, String errorCode) { + + super(message); + this.errorCode = errorCode; + this.description = description; + } + + /** + * Creates a new device management exception with the cause. + * + * @param message Error message. + * @param description Error description. + * @param errorCode Error code. + * @param cause Root cause. + */ + public DeviceMgtException(String message, String description, String errorCode, Throwable cause) { + + super(message, cause); + this.errorCode = errorCode; + this.description = description; + } + + /** + * Returns the error code. + * + * @return Error code. + */ + public String getErrorCode() { + + return errorCode; + } + + /** + * Returns the error description. + * + * @return Error description. + */ + public String getDescription() { + + return description; + } +} + diff --git a/components/device-mgt/org.wso2.carbon.identity.device.mgt/src/main/java/org/wso2/carbon/identity/device/mgt/api/exception/DeviceMgtServerException.java b/components/device-mgt/org.wso2.carbon.identity.device.mgt/src/main/java/org/wso2/carbon/identity/device/mgt/api/exception/DeviceMgtServerException.java new file mode 100644 index 000000000000..2666235742f3 --- /dev/null +++ b/components/device-mgt/org.wso2.carbon.identity.device.mgt/src/main/java/org/wso2/carbon/identity/device/mgt/api/exception/DeviceMgtServerException.java @@ -0,0 +1,51 @@ +/* +* Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com) All Rights Reserved. +* +* WSO2 LLC. licenses this file to you 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. +*/ + +package org.wso2.carbon.identity.device.mgt.api.exception; + +/** + * Server-side exception for device management failures. + */ +public class DeviceMgtServerException extends DeviceMgtException { + + /** + * Creates a new server exception. + * + * @param message Error message. + * @param description Error description. + * @param errorCode Error code. + */ + public DeviceMgtServerException(String message, String description, String errorCode) { + + super(message, description, errorCode); + } + + /** + * Creates a new server exception with the cause. + * + * @param message Error message. + * @param description Error description. + * @param errorCode Error code. + * @param cause Root cause. + */ + public DeviceMgtServerException(String message, String description, String errorCode, Throwable cause) { + + super(message, description, errorCode, cause); + } +} + diff --git a/components/device-mgt/org.wso2.carbon.identity.device.mgt/src/main/java/org/wso2/carbon/identity/device/mgt/api/model/Device.java b/components/device-mgt/org.wso2.carbon.identity.device.mgt/src/main/java/org/wso2/carbon/identity/device/mgt/api/model/Device.java new file mode 100644 index 000000000000..460c7c2a2a36 --- /dev/null +++ b/components/device-mgt/org.wso2.carbon.identity.device.mgt/src/main/java/org/wso2/carbon/identity/device/mgt/api/model/Device.java @@ -0,0 +1,272 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (http://www.wso2.com). + * + * WSO2 LLC. licenses this file to you 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. + */ + +package org.wso2.carbon.identity.device.mgt.api.model; + +import java.sql.Timestamp; + +/** + * Immutable model for a registered device. + */ +public class Device { + + private final String id; + private final String userId; + private final String deviceName; + private final String deviceModel; + private final String publicKey; + private final String status; + private final Timestamp registeredAt; + private final String metadata; + + private Device(Builder builder) { + + this.id = builder.id; + this.userId = builder.userId; + this.deviceName = builder.deviceName; + this.deviceModel = builder.deviceModel; + this.publicKey = builder.publicKey; + this.status = builder.status; + this.registeredAt = builder.registeredAt; + this.metadata = builder.metadata; + } + + /** + * Returns the device identifier. + * + * @return Device identifier. + */ + public String getId() { + + return id; + } + + /** + * Returns the user identifier. + * + * @return User identifier. + */ + public String getUserId() { + + return userId; + } + + /** + * Returns the display name of the device. + * + * @return Device name. + */ + public String getDeviceName() { + + return deviceName; + } + + /** + * Returns the hardware model. + * + * @return Device model. + */ + public String getDeviceModel() { + + return deviceModel; + } + + /** + * Returns the registered public key. + * + * @return Public key. + */ + public String getPublicKey() { + + return publicKey; + } + + /** + * Returns the current device status. + * + * @return Device status. + */ + public String getStatus() { + + return status; + } + + /** + * Returns the registration timestamp. + * + * @return Registration timestamp. + */ + public Timestamp getRegisteredAt() { + + return registeredAt; + } + + /** + * Returns the metadata payload. + * + * @return Metadata string. + */ + public String getMetadata() { + + return metadata; + } + + /** + * Builder for {@link Device}. + */ + public static class Builder { + + private String id; + private String userId; + private String deviceName; + private String deviceModel; + private String publicKey; + private String status = "ACTIVE"; + private Timestamp registeredAt; + private String metadata; + + /** + * Creates an empty builder. + */ + public Builder() { + } + + /** + * Creates a builder pre-populated with the fields of an existing device. + * + * @param device Source device to copy fields from. + */ + public Builder(Device device) { + + this.id = device.id; + this.userId = device.userId; + this.deviceName = device.deviceName; + this.deviceModel = device.deviceModel; + this.publicKey = device.publicKey; + this.status = device.status; + this.registeredAt = device.registeredAt; + this.metadata = device.metadata; + } + + /** + * Sets the device identifier. + * + * @param id Device identifier. + * @return Builder instance. + */ + public Builder id(String id) { + + this.id = id; + return this; + } + + /** + * Sets the user identifier. + * + * @param userId User identifier. + * @return Builder instance. + */ + public Builder userId(String userId) { + + this.userId = userId; + return this; + } + + /** + * Sets the device name. + * + * @param deviceName Device name. + * @return Builder instance. + */ + public Builder deviceName(String deviceName) { + + this.deviceName = deviceName; + return this; + } + + /** + * Sets the device model. + * + * @param deviceModel Device model. + * @return Builder instance. + */ + public Builder deviceModel(String deviceModel) { + + this.deviceModel = deviceModel; + return this; + } + + /** + * Sets the public key. + * + * @param publicKey Public key. + * @return Builder instance. + */ + public Builder publicKey(String publicKey) { + + this.publicKey = publicKey; + return this; + } + + /** + * Sets the device status. + * + * @param status Device status. + * @return Builder instance. + */ + public Builder status(String status) { + + this.status = status; + return this; + } + + /** + * Sets the registration timestamp. + * + * @param registeredAt Registration timestamp. + * @return Builder instance. + */ + public Builder registeredAt(Timestamp registeredAt) { + + this.registeredAt = registeredAt; + return this; + } + + /** + * Sets metadata. + * + * @param metadata Metadata value. + * @return Builder instance. + */ + public Builder metadata(String metadata) { + + this.metadata = metadata; + return this; + } + + /** + * Builds a registered device instance. + * + * @return Registered device. + */ + public Device build() { + + return new Device(this); + } + } +} diff --git a/components/device-mgt/org.wso2.carbon.identity.device.mgt/src/main/java/org/wso2/carbon/identity/device/mgt/api/model/DeviceRegistrationInitiation.java b/components/device-mgt/org.wso2.carbon.identity.device.mgt/src/main/java/org/wso2/carbon/identity/device/mgt/api/model/DeviceRegistrationInitiation.java new file mode 100644 index 000000000000..9226f94c4307 --- /dev/null +++ b/components/device-mgt/org.wso2.carbon.identity.device.mgt/src/main/java/org/wso2/carbon/identity/device/mgt/api/model/DeviceRegistrationInitiation.java @@ -0,0 +1,60 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (http://www.wso2.com). + * + * WSO2 LLC. licenses this file to you 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. + */ + +package org.wso2.carbon.identity.device.mgt.api.model; + +/** + * Response model returned when a device registration is initiated. + */ +public class DeviceRegistrationInitiation { + + private final String registrationId; + private final String challenge; + + /** + * Creates an initiation response. + * + * @param registrationId Registration identifier. + * @param challenge Base64url encoded challenge. + */ + public DeviceRegistrationInitiation(String registrationId, String challenge) { + + this.registrationId = registrationId; + this.challenge = challenge; + } + + /** + * Returns the registration identifier. + * + * @return Registration identifier. + */ + public String getRegistrationId() { + + return registrationId; + } + + /** + * Returns the challenge. + * + * @return Challenge. + */ + public String getChallenge() { + + return challenge; + } +} diff --git a/components/device-mgt/org.wso2.carbon.identity.device.mgt/src/main/java/org/wso2/carbon/identity/device/mgt/api/service/DeviceManagementService.java b/components/device-mgt/org.wso2.carbon.identity.device.mgt/src/main/java/org/wso2/carbon/identity/device/mgt/api/service/DeviceManagementService.java new file mode 100644 index 000000000000..e7232c1b96c0 --- /dev/null +++ b/components/device-mgt/org.wso2.carbon.identity.device.mgt/src/main/java/org/wso2/carbon/identity/device/mgt/api/service/DeviceManagementService.java @@ -0,0 +1,130 @@ +package org.wso2.carbon.identity.device.mgt.api.service; + +import org.wso2.carbon.identity.device.mgt.api.exception.DeviceMgtException; +import org.wso2.carbon.identity.device.mgt.api.model.Device; +import org.wso2.carbon.identity.device.mgt.api.model.DeviceRegistrationInitiation; + +import java.util.List; + +/** + * Service interface for device management operations. + */ +public interface DeviceManagementService { + + /** + * Phase 1 of the two-phase registration protocol. + * Generates a cryptographically random challenge and stores it in the distributed cache + * keyed by the returned registrationId. The client SDK must sign the challenge with its + * private key and return the signature in Phase 2. + * + * @param username Username of the registering user. + * @param tenantDomain Tenant domain. + * @return DeviceRegistrationInitiation containing registrationId and challenge (base64url). + */ + DeviceRegistrationInitiation initiateDeviceRegistration(String username, String tenantDomain) + throws DeviceMgtException; + + /** + * Verifies the device registration challenge-response without persisting to the database. + * Used during registration flows where the user does not yet have a provisioned userId — + * the caller stores the returned object in the flow context and defers the DB write to + * {@link #persistDevice(Device, String)} once UserProvisioningExecutor has run. + * + * @param registrationId Opaque token returned by initiateDeviceRegistration. + * @param publicKey Base64-encoded EC public key (X.509/SubjectPublicKeyInfo DER). + * @param signature Base64-encoded ECDSA signature over the challenge bytes. + * @param deviceName Human-readable name for the device. + * @param deviceModel Hardware model string (nullable). + * @param metadata Optional JSON string for extensible attributes (nullable). + * @param tenantDomain Tenant domain. + * @return A Device whose userId is unset — caller must set the real userId before + * calling {@link #persistDevice(Device, String)}. + */ + Device verifyDeviceRegistration( + String registrationId, + String publicKey, + String signature, + String deviceName, + String deviceModel, + String metadata, + String tenantDomain) throws DeviceMgtException; + + /** + * Persists a pre-verified {@link Device} to the database. + * Counterpart to {@link #verifyDeviceRegistration}: call this after replacing the placeholder + * userId with the real provisioned userId. + * + * @param device The verified device to persist. + * @param tenantDomain Tenant domain. + */ + void persistDevice(Device device, String tenantDomain) throws DeviceMgtException; + + /** + * Retrieves a device by its UUID. + * + * @param deviceId UUID of the device (IDN_DEVICE.ID). + * @param tenantDomain Tenant domain. + * @return The Device, or null if not found. + */ + Device getDeviceById(String deviceId, String tenantDomain) + throws DeviceMgtException; + + /** + * Retrieves all ACTIVE devices registered by a user. + * + * @param userId WSO2 user identifier. + * @param tenantDomain Tenant domain. + * @return List of active Device objects. Empty list if none found. + */ + List getDevicesByUserId(String userId, String tenantDomain) + throws DeviceMgtException; + + /** + * Retrieves all devices registered in the tenant. + * + * @param tenantDomain Tenant domain. + * @return List of all Device objects. Empty list if none found. + */ + List getAllDevices(String tenantDomain) + throws DeviceMgtException; + + /** + * Retrieves a page of devices registered in the tenant, ordered by registration time (newest first). + * + * @param tenantDomain Tenant domain. + * @param offset Number of records to skip. + * @param limit Maximum number of records to return. + * @return Page of Device objects. Empty list if none found. + */ + List getDevices(String tenantDomain, int offset, int limit) + throws DeviceMgtException; + + /** + * Counts all devices registered in the tenant. + * + * @param tenantDomain Tenant domain. + * @return Total number of devices in the tenant. + */ + int getDeviceCount(String tenantDomain) + throws DeviceMgtException; + + /** + * Updates the display name of a device. + * + * @param deviceId UUID of the device. + * @param deviceName New name for the device. + * @param tenantDomain Tenant domain. + * @return The updated Device. + */ + Device updateDeviceName(String deviceId, String deviceName, String tenantDomain) + throws DeviceMgtException; + + /** + * Deletes (hard delete) a device registration record. + * + * @param deviceId UUID of the device. + * @param tenantDomain Tenant domain. + */ + void deleteDevice(String deviceId, String tenantDomain) + throws DeviceMgtException; +} diff --git a/components/device-mgt/org.wso2.carbon.identity.device.mgt/src/main/java/org/wso2/carbon/identity/device/mgt/internal/cache/DeviceRegistrationCache.java b/components/device-mgt/org.wso2.carbon.identity.device.mgt/src/main/java/org/wso2/carbon/identity/device/mgt/internal/cache/DeviceRegistrationCache.java new file mode 100644 index 000000000000..8818335c9dba --- /dev/null +++ b/components/device-mgt/org.wso2.carbon.identity.device.mgt/src/main/java/org/wso2/carbon/identity/device/mgt/internal/cache/DeviceRegistrationCache.java @@ -0,0 +1,46 @@ +/* +* Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com) All Rights Reserved. +* +* WSO2 LLC. licenses this file to you 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. +*/ + +package org.wso2.carbon.identity.device.mgt.internal.cache; + +import org.wso2.carbon.identity.core.cache.BaseCache; + +/** + * Distributed cache for temporary registration contexts. + */ +public class DeviceRegistrationCache + extends BaseCache { + + private static final String CACHE_NAME = "DeviceRegistrationCache"; + private static final DeviceRegistrationCache INSTANCE = new DeviceRegistrationCache(); + + private DeviceRegistrationCache() { + super(CACHE_NAME); + } + + /** + * Returns the cache singleton instance. + * + * @return Cache singleton. + */ + public static DeviceRegistrationCache getInstance() { + + return INSTANCE; + } +} + diff --git a/components/device-mgt/org.wso2.carbon.identity.device.mgt/src/main/java/org/wso2/carbon/identity/device/mgt/internal/cache/DeviceRegistrationCacheEntry.java b/components/device-mgt/org.wso2.carbon.identity.device.mgt/src/main/java/org/wso2/carbon/identity/device/mgt/internal/cache/DeviceRegistrationCacheEntry.java new file mode 100644 index 000000000000..8c5807d1c457 --- /dev/null +++ b/components/device-mgt/org.wso2.carbon.identity.device.mgt/src/main/java/org/wso2/carbon/identity/device/mgt/internal/cache/DeviceRegistrationCacheEntry.java @@ -0,0 +1,52 @@ +/* +* Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com) All Rights Reserved. +* +* WSO2 LLC. licenses this file to you 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. +*/ + +package org.wso2.carbon.identity.device.mgt.internal.cache; + +import org.wso2.carbon.identity.core.cache.CacheEntry; + +/** + * Cache entry that wraps a registration context. + */ +public class DeviceRegistrationCacheEntry extends CacheEntry { + + private static final long serialVersionUID = 1L; + + private final DeviceRegistrationContext context; + + /** + * Creates a cache entry. + * + * @param context Registration context. + */ + public DeviceRegistrationCacheEntry(DeviceRegistrationContext context) { + + this.context = context; + } + + /** + * Returns the cached context. + * + * @return Registration context. + */ + public DeviceRegistrationContext getContext() { + + return context; + } +} + diff --git a/components/device-mgt/org.wso2.carbon.identity.device.mgt/src/main/java/org/wso2/carbon/identity/device/mgt/internal/cache/DeviceRegistrationCacheKey.java b/components/device-mgt/org.wso2.carbon.identity.device.mgt/src/main/java/org/wso2/carbon/identity/device/mgt/internal/cache/DeviceRegistrationCacheKey.java new file mode 100644 index 000000000000..31b940ba984f --- /dev/null +++ b/components/device-mgt/org.wso2.carbon.identity.device.mgt/src/main/java/org/wso2/carbon/identity/device/mgt/internal/cache/DeviceRegistrationCacheKey.java @@ -0,0 +1,72 @@ +/* +* Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com) All Rights Reserved. +* +* WSO2 LLC. licenses this file to you 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. +*/ + +package org.wso2.carbon.identity.device.mgt.internal.cache; + +import org.wso2.carbon.identity.core.cache.CacheKey; + +/** + * Cache key wrapper for a registration identifier. + */ +public class DeviceRegistrationCacheKey extends CacheKey { + + private static final long serialVersionUID = 1L; + + private final String registrationId; + + /** + * Creates a cache key with the registration identifier. + * + * @param registrationId Registration identifier. + */ + public DeviceRegistrationCacheKey(String registrationId) { + + this.registrationId = registrationId; + } + + /** + * Returns the registration identifier. + * + * @return Registration identifier. + */ + public String getRegistrationId() { + + return registrationId; + } + + @Override + public boolean equals(Object obj) { + + if (this == obj) { + return true; + } + if (!(obj instanceof DeviceRegistrationCacheKey)) { + return false; + } + DeviceRegistrationCacheKey other = (DeviceRegistrationCacheKey) obj; + return registrationId != null && registrationId.equals(other.registrationId); + } + + @Override + public int hashCode() { + + return registrationId != null ? registrationId.hashCode() : 0; + } +} + + diff --git a/components/device-mgt/org.wso2.carbon.identity.device.mgt/src/main/java/org/wso2/carbon/identity/device/mgt/internal/cache/DeviceRegistrationContext.java b/components/device-mgt/org.wso2.carbon.identity.device.mgt/src/main/java/org/wso2/carbon/identity/device/mgt/internal/cache/DeviceRegistrationContext.java new file mode 100644 index 000000000000..fa560f400e6d --- /dev/null +++ b/components/device-mgt/org.wso2.carbon.identity.device.mgt/src/main/java/org/wso2/carbon/identity/device/mgt/internal/cache/DeviceRegistrationContext.java @@ -0,0 +1,77 @@ +/* +* Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com) All Rights Reserved. +* +* WSO2 LLC. licenses this file to you 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. +*/ + +package org.wso2.carbon.identity.device.mgt.internal.cache; + +import java.io.Serializable; + +/** + * Context stored between registration initiation and completion. + */ +public class DeviceRegistrationContext implements Serializable { + + private static final long serialVersionUID = 1L; + + private final String username; + private final String challenge; + private final String tenantDomain; + + /** + * Creates a registration context. + * + * @param username Username of the registering user. + * @param challenge Registration challenge. + * @param tenantDomain Tenant domain. + */ + public DeviceRegistrationContext(String username, String challenge, String tenantDomain) { + this.username = username; + this.challenge = challenge; + this.tenantDomain = tenantDomain; + } + + /** + * Returns the username. + * + * @return Username. + */ + public String getUsername() { + + return username; + } + + /** + * Returns the challenge. + * + * @return Challenge. + */ + public String getChallenge() { + + return challenge; + } + + /** + * Returns the tenant domain. + * + * @return Tenant domain. + */ + public String getTenantDomain() { + + return tenantDomain; + } +} + diff --git a/components/device-mgt/org.wso2.carbon.identity.device.mgt/src/main/java/org/wso2/carbon/identity/device/mgt/internal/component/DeviceMgtServiceComponent.java b/components/device-mgt/org.wso2.carbon.identity.device.mgt/src/main/java/org/wso2/carbon/identity/device/mgt/internal/component/DeviceMgtServiceComponent.java new file mode 100644 index 000000000000..333cec30cf2c --- /dev/null +++ b/components/device-mgt/org.wso2.carbon.identity.device.mgt/src/main/java/org/wso2/carbon/identity/device/mgt/internal/component/DeviceMgtServiceComponent.java @@ -0,0 +1,76 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (http://www.wso2.com). + * + * WSO2 LLC. licenses this file to you 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. + */ + +package org.wso2.carbon.identity.device.mgt.internal.component; + +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; +import org.osgi.framework.BundleContext; +import org.osgi.service.component.ComponentContext; +import org.osgi.service.component.annotations.Activate; +import org.osgi.service.component.annotations.Component; +import org.osgi.service.component.annotations.Deactivate; +import org.wso2.carbon.identity.device.mgt.api.service.DeviceManagementService; +import org.wso2.carbon.identity.device.mgt.internal.service.impl.DeviceManagementServiceImpl; + +/** + * OSGi component that registers the device management service. + */ +@Component( + name = "device.mgt.service.component", + immediate = true +) +public class DeviceMgtServiceComponent { + + private static final Log LOG = LogFactory.getLog(DeviceMgtServiceComponent.class); + + /** + * Activates the component and registers the service. + * + * @param context OSGi component context. + */ + @Activate + protected void activate(ComponentContext context) { + + try { + BundleContext bundleCtx = context.getBundleContext(); + bundleCtx.registerService( + DeviceManagementService.class.getName(), + DeviceManagementServiceImpl.getInstance(), + null); + if (LOG.isDebugEnabled()) { + LOG.debug("Device management bundle is activated."); + } + } catch (Throwable e) { + LOG.error("Error while initializing device management service component.", e); + } + } + + /** + * Deactivates the component. + * + * @param context OSGi component context. + */ + @Deactivate + protected void deactivate(ComponentContext context) { + + if (LOG.isDebugEnabled()) { + LOG.debug("Device management bundle is deactivated."); + } + } +} diff --git a/components/device-mgt/org.wso2.carbon.identity.device.mgt/src/main/java/org/wso2/carbon/identity/device/mgt/internal/constant/DeviceMgtSQLConstants.java b/components/device-mgt/org.wso2.carbon.identity.device.mgt/src/main/java/org/wso2/carbon/identity/device/mgt/internal/constant/DeviceMgtSQLConstants.java new file mode 100644 index 000000000000..32162faf11aa --- /dev/null +++ b/components/device-mgt/org.wso2.carbon.identity.device.mgt/src/main/java/org/wso2/carbon/identity/device/mgt/internal/constant/DeviceMgtSQLConstants.java @@ -0,0 +1,154 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (http://www.wso2.com). + * + * WSO2 LLC. licenses this file to you 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. + */ + +package org.wso2.carbon.identity.device.mgt.internal.constant; + +/** + * SQL constants used by the device management DAO layer. + */ +public final class DeviceMgtSQLConstants { + + private DeviceMgtSQLConstants() { + } + + /** + * Column and named-parameter names. + */ + public static final class Column { + + public static final String ID = "ID"; + public static final String DEVICE_ID = "DEVICE_ID"; + public static final String USER_ID = "USER_ID"; + public static final String DEVICE_NAME = "DEVICE_NAME"; + public static final String DEVICE_MODEL = "DEVICE_MODEL"; + public static final String PUBLIC_KEY = "PUBLIC_KEY"; + public static final String STATUS = "STATUS"; + public static final String REGISTERED_AT = "REGISTERED_AT"; + public static final String METADATA = "METADATA"; + public static final String TENANT_ID = "TENANT_ID"; + + public static final String LIMIT = "LIMIT"; + public static final String OFFSET = "OFFSET"; + public static final String LOWER_BOUND = "LOWER_BOUND"; + public static final String UPPER_BOUND = "UPPER_BOUND"; + + private Column() { + } + } + + /** + * SQL query definitions. + */ + public static final class Query { + + public static final String REGISTER_DEVICE = + "INSERT INTO IDN_DEVICE " + + "(ID, DEVICE_NAME, DEVICE_MODEL, PUBLIC_KEY, STATUS, REGISTERED_AT, " + + "TENANT_ID, METADATA) " + + "VALUES (:ID;, :DEVICE_NAME;, :DEVICE_MODEL;, :PUBLIC_KEY;, :STATUS;, " + + ":REGISTERED_AT;, :TENANT_ID;, :METADATA;)"; + + public static final String ADD_USER_DEVICE = + "INSERT INTO IDN_USER_DEVICE " + + "(DEVICE_ID, USER_ID, TENANT_ID) " + + "VALUES (:DEVICE_ID;, :USER_ID;, :TENANT_ID;)"; + + public static final String GET_DEVICE_BY_ID = + "SELECT D.ID, UD.USER_ID, D.DEVICE_NAME, D.DEVICE_MODEL, D.PUBLIC_KEY, D.STATUS, " + + "D.REGISTERED_AT, D.METADATA, D.TENANT_ID " + + "FROM IDN_DEVICE D " + + "INNER JOIN IDN_USER_DEVICE UD ON D.ID = UD.DEVICE_ID AND D.TENANT_ID = UD.TENANT_ID " + + "WHERE D.ID = :ID; AND D.TENANT_ID = :TENANT_ID;"; + + public static final String GET_DEVICES_BY_USER_ID = + "SELECT D.ID, UD.USER_ID, D.DEVICE_NAME, D.DEVICE_MODEL, D.PUBLIC_KEY, D.STATUS, " + + "D.REGISTERED_AT, D.METADATA, D.TENANT_ID " + + "FROM IDN_DEVICE D " + + "INNER JOIN IDN_USER_DEVICE UD ON D.ID = UD.DEVICE_ID AND D.TENANT_ID = UD.TENANT_ID " + + "WHERE UD.USER_ID = :USER_ID; AND D.STATUS = 'ACTIVE' AND D.TENANT_ID = :TENANT_ID; " + + "ORDER BY D.REGISTERED_AT DESC"; + + public static final String UPDATE_DEVICE_NAME = + "UPDATE IDN_DEVICE SET DEVICE_NAME = :DEVICE_NAME; " + + "WHERE ID = :ID; AND TENANT_ID = :TENANT_ID;"; + + public static final String GET_ALL_DEVICES = + "SELECT D.ID, UD.USER_ID, D.DEVICE_NAME, D.DEVICE_MODEL, D.PUBLIC_KEY, D.STATUS, " + + "D.REGISTERED_AT, D.METADATA, D.TENANT_ID " + + "FROM IDN_DEVICE D " + + "INNER JOIN IDN_USER_DEVICE UD ON D.ID = UD.DEVICE_ID AND D.TENANT_ID = UD.TENANT_ID " + + "WHERE D.TENANT_ID = :TENANT_ID; ORDER BY D.REGISTERED_AT DESC"; + + // Paginated tenant-wide device listing. Pagination syntax differs per database, so a variant + // is selected at runtime based on the detected database type. + + // Default: H2, MySQL, MariaDB, PostgreSQL. + public static final String GET_ALL_DEVICES_PAGINATED = + "SELECT D.ID, UD.USER_ID, D.DEVICE_NAME, D.DEVICE_MODEL, D.PUBLIC_KEY, D.STATUS, " + + "D.REGISTERED_AT, D.METADATA, D.TENANT_ID " + + "FROM IDN_DEVICE D " + + "INNER JOIN IDN_USER_DEVICE UD ON D.ID = UD.DEVICE_ID AND D.TENANT_ID = UD.TENANT_ID " + + "WHERE D.TENANT_ID = :TENANT_ID; ORDER BY D.REGISTERED_AT DESC " + + "LIMIT :LIMIT; OFFSET :OFFSET;"; + + // MS SQL Server. + public static final String GET_ALL_DEVICES_PAGINATED_MSSQL = + "SELECT D.ID, UD.USER_ID, D.DEVICE_NAME, D.DEVICE_MODEL, D.PUBLIC_KEY, D.STATUS, " + + "D.REGISTERED_AT, D.METADATA, D.TENANT_ID " + + "FROM IDN_DEVICE D " + + "INNER JOIN IDN_USER_DEVICE UD ON D.ID = UD.DEVICE_ID AND D.TENANT_ID = UD.TENANT_ID " + + "WHERE D.TENANT_ID = :TENANT_ID; ORDER BY D.REGISTERED_AT DESC " + + "OFFSET :OFFSET; ROWS FETCH NEXT :LIMIT; ROWS ONLY"; + + // Oracle. + public static final String GET_ALL_DEVICES_PAGINATED_ORACLE = + "SELECT ID, USER_ID, DEVICE_NAME, DEVICE_MODEL, PUBLIC_KEY, STATUS, REGISTERED_AT, " + + "METADATA, TENANT_ID FROM (SELECT ID, USER_ID, DEVICE_NAME, DEVICE_MODEL, PUBLIC_KEY, " + + "STATUS, REGISTERED_AT, METADATA, TENANT_ID, rownum AS rnum FROM " + + "(SELECT D.ID, UD.USER_ID, D.DEVICE_NAME, D.DEVICE_MODEL, D.PUBLIC_KEY, D.STATUS, " + + "D.REGISTERED_AT, D.METADATA, D.TENANT_ID FROM IDN_DEVICE D " + + "INNER JOIN IDN_USER_DEVICE UD ON D.ID = UD.DEVICE_ID AND D.TENANT_ID = UD.TENANT_ID " + + "WHERE D.TENANT_ID = :TENANT_ID; ORDER BY D.REGISTERED_AT DESC) " + + "WHERE rownum <= :UPPER_BOUND;) WHERE rnum > :OFFSET;"; + + // DB2. + public static final String GET_ALL_DEVICES_PAGINATED_DB2 = + "SELECT ID, USER_ID, DEVICE_NAME, DEVICE_MODEL, PUBLIC_KEY, STATUS, REGISTERED_AT, " + + "METADATA, TENANT_ID FROM (SELECT ROW_NUMBER() OVER(ORDER BY D.REGISTERED_AT DESC) AS rn, " + + "D.ID, UD.USER_ID, D.DEVICE_NAME, D.DEVICE_MODEL, D.PUBLIC_KEY, D.STATUS, " + + "D.REGISTERED_AT, D.METADATA, D.TENANT_ID FROM IDN_DEVICE D " + + "INNER JOIN IDN_USER_DEVICE UD ON D.ID = UD.DEVICE_ID AND D.TENANT_ID = UD.TENANT_ID " + + "WHERE D.TENANT_ID = :TENANT_ID;) WHERE rn BETWEEN :LOWER_BOUND; AND :UPPER_BOUND;"; + + public static final String GET_DEVICES_COUNT = + "SELECT COUNT(*) FROM IDN_DEVICE D " + + "INNER JOIN IDN_USER_DEVICE UD ON D.ID = UD.DEVICE_ID AND D.TENANT_ID = UD.TENANT_ID " + + "WHERE D.TENANT_ID = :TENANT_ID;"; + + public static final String DELETE_USER_DEVICE = + "DELETE FROM IDN_USER_DEVICE " + + "WHERE DEVICE_ID = :DEVICE_ID; AND TENANT_ID = :TENANT_ID;"; + + public static final String DELETE_DEVICE = + "DELETE FROM IDN_DEVICE " + + "WHERE ID = :ID; AND TENANT_ID = :TENANT_ID;"; + + private Query() { + } + } +} diff --git a/components/device-mgt/org.wso2.carbon.identity.device.mgt/src/main/java/org/wso2/carbon/identity/device/mgt/internal/dao/DeviceManagementDAO.java b/components/device-mgt/org.wso2.carbon.identity.device.mgt/src/main/java/org/wso2/carbon/identity/device/mgt/internal/dao/DeviceManagementDAO.java new file mode 100644 index 000000000000..484b18d3c632 --- /dev/null +++ b/components/device-mgt/org.wso2.carbon.identity.device.mgt/src/main/java/org/wso2/carbon/identity/device/mgt/internal/dao/DeviceManagementDAO.java @@ -0,0 +1,117 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (http://www.wso2.com). + * + * WSO2 LLC. licenses this file to you 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. + */ + +package org.wso2.carbon.identity.device.mgt.internal.dao; + +import org.wso2.carbon.identity.device.mgt.api.exception.DeviceMgtException; +import org.wso2.carbon.identity.device.mgt.api.model.Device; + +import java.util.List; + +/** + * DAO contract for registered device persistence. + */ +public interface DeviceManagementDAO { + + /** + * Persists a new device. + * + * @param device Device to persist. + * @param tenantId Tenant identifier. + * @return Persisted device. + * @throws DeviceMgtException If persistence fails. + */ + Device registerDevice(Device device, int tenantId) + throws DeviceMgtException; + + /** + * Finds a device by id. + * + * @param deviceId Device identifier. + * @param tenantId Tenant identifier. + * @return Device or {@code null}. + * @throws DeviceMgtException If retrieval fails. + */ + Device getDeviceById(String deviceId, int tenantId) + throws DeviceMgtException; + + /** + * Finds all active devices by user id. + * + * @param userId User identifier. + * @param tenantId Tenant identifier. + * @return Active devices. + * @throws DeviceMgtException If retrieval fails. + */ + List getDevicesByUserId(String userId, int tenantId) + throws DeviceMgtException; + + /** + * Finds all devices registered in the tenant. + * + * @param tenantId Tenant identifier. + * @return All devices in the tenant. + * @throws DeviceMgtException If retrieval fails. + */ + List getAllDevices(int tenantId) + throws DeviceMgtException; + + /** + * Finds a page of devices registered in the tenant, ordered by registration time (newest first). + * + * @param tenantId Tenant identifier. + * @param offset Number of records to skip. + * @param limit Maximum number of records to return. + * @return Page of devices in the tenant. + * @throws DeviceMgtException If retrieval fails. + */ + List getDevices(int tenantId, int offset, int limit) + throws DeviceMgtException; + + /** + * Counts all devices registered in the tenant. + * + * @param tenantId Tenant identifier. + * @return Total number of devices in the tenant. + * @throws DeviceMgtException If the count fails. + */ + int getDeviceCount(int tenantId) + throws DeviceMgtException; + + /** + * Updates the name of a device. + * + * @param deviceId Device identifier. + * @param deviceName Device name. + * @param tenantId Tenant identifier. + * @return Updated device. + * @throws DeviceMgtException If update fails. + */ + Device updateDeviceName(String deviceId, String deviceName, int tenantId) + throws DeviceMgtException; + + /** + * Deletes a device. + * + * @param deviceId Device identifier. + * @param tenantId Tenant identifier. + * @throws DeviceMgtException If deletion fails. + */ + void deleteDevice(String deviceId, int tenantId) + throws DeviceMgtException; +} diff --git a/components/device-mgt/org.wso2.carbon.identity.device.mgt/src/main/java/org/wso2/carbon/identity/device/mgt/internal/dao/impl/DeviceManagementDAOImpl.java b/components/device-mgt/org.wso2.carbon.identity.device.mgt/src/main/java/org/wso2/carbon/identity/device/mgt/internal/dao/impl/DeviceManagementDAOImpl.java new file mode 100644 index 000000000000..9516fad0df86 --- /dev/null +++ b/components/device-mgt/org.wso2.carbon.identity.device.mgt/src/main/java/org/wso2/carbon/identity/device/mgt/internal/dao/impl/DeviceManagementDAOImpl.java @@ -0,0 +1,360 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (http://www.wso2.com). + * + * WSO2 LLC. licenses this file to you 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. + */ + +package org.wso2.carbon.identity.device.mgt.internal.dao.impl; + +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; +import org.wso2.carbon.database.utils.jdbc.NamedJdbcTemplate; +import org.wso2.carbon.database.utils.jdbc.NamedPreparedStatement; +import org.wso2.carbon.database.utils.jdbc.exceptions.DataAccessException; +import org.wso2.carbon.database.utils.jdbc.exceptions.TransactionException; +import org.wso2.carbon.identity.core.util.IdentityDatabaseUtil; +import org.wso2.carbon.identity.core.util.JdbcUtils; +import org.wso2.carbon.identity.device.mgt.api.constant.ErrorMessage; +import org.wso2.carbon.identity.device.mgt.api.exception.DeviceMgtException; +import org.wso2.carbon.identity.device.mgt.api.model.Device; +import org.wso2.carbon.identity.device.mgt.internal.constant.DeviceMgtSQLConstants; +import org.wso2.carbon.identity.device.mgt.internal.dao.DeviceManagementDAO; +import org.wso2.carbon.identity.device.mgt.internal.util.DeviceManagementExceptionHandler; + +import java.sql.SQLException; +import java.util.Collections; +import java.util.List; + +/** + * JDBC implementation for registered device persistence. + */ +public class DeviceManagementDAOImpl implements DeviceManagementDAO { + + private static final Log LOG = LogFactory.getLog(DeviceManagementDAOImpl.class); + + @Override + public Device registerDevice(Device device, int tenantId) + throws DeviceMgtException { + + NamedJdbcTemplate jdbcTemplate = new NamedJdbcTemplate(IdentityDatabaseUtil.getDataSource()); + + try { + jdbcTemplate.withTransaction(template -> { + template.executeInsert( + DeviceMgtSQLConstants.Query.REGISTER_DEVICE, + preparedStatement -> { + preparedStatement.setString(DeviceMgtSQLConstants.Column.ID, device.getId()); + preparedStatement.setString( + DeviceMgtSQLConstants.Column.DEVICE_NAME, device.getDeviceName()); + preparedStatement.setString( + DeviceMgtSQLConstants.Column.DEVICE_MODEL, device.getDeviceModel()); + preparedStatement.setString(DeviceMgtSQLConstants.Column.PUBLIC_KEY, device.getPublicKey()); + preparedStatement.setString(DeviceMgtSQLConstants.Column.STATUS, device.getStatus()); + preparedStatement.setObject( + DeviceMgtSQLConstants.Column.REGISTERED_AT, device.getRegisteredAt()); + preparedStatement.setInt(DeviceMgtSQLConstants.Column.TENANT_ID, tenantId); + preparedStatement.setString(DeviceMgtSQLConstants.Column.METADATA, device.getMetadata()); + }, + device, + false); + template.executeInsert( + DeviceMgtSQLConstants.Query.ADD_USER_DEVICE, + preparedStatement -> { + preparedStatement.setString(DeviceMgtSQLConstants.Column.DEVICE_ID, device.getId()); + preparedStatement.setString(DeviceMgtSQLConstants.Column.USER_ID, device.getUserId()); + preparedStatement.setInt(DeviceMgtSQLConstants.Column.TENANT_ID, tenantId); + }, + device, + false); + return null; + }); + + } catch (TransactionException e) { + throw DeviceManagementExceptionHandler.handleServerException( + ErrorMessage.ERROR_WHILE_REGISTERING_DEVICE, e); + } + + if (LOG.isDebugEnabled()) { + LOG.debug("Device registered successfully with ID: " + device.getId()); + } + return device; + } + + @Override + public Device getDeviceById(String deviceId, int tenantId) + throws DeviceMgtException { + + NamedJdbcTemplate jdbcTemplate = new NamedJdbcTemplate(IdentityDatabaseUtil.getDataSource()); + + try { + return jdbcTemplate.withTransaction( + template -> template.fetchSingleRecord( + DeviceMgtSQLConstants.Query.GET_DEVICE_BY_ID, + (resultSet, rowNumber) -> new Device.Builder() + .id(resultSet.getString(DeviceMgtSQLConstants.Column.ID)) + .userId(resultSet.getString(DeviceMgtSQLConstants.Column.USER_ID)) + .deviceName(resultSet.getString(DeviceMgtSQLConstants.Column.DEVICE_NAME)) + .deviceModel(resultSet.getString(DeviceMgtSQLConstants.Column.DEVICE_MODEL)) + .publicKey(resultSet.getString(DeviceMgtSQLConstants.Column.PUBLIC_KEY)) + .status(resultSet.getString(DeviceMgtSQLConstants.Column.STATUS)) + .registeredAt(resultSet.getTimestamp(DeviceMgtSQLConstants.Column.REGISTERED_AT)) + .metadata(resultSet.getString(DeviceMgtSQLConstants.Column.METADATA)) + .build(), + preparedStatement -> { + preparedStatement.setString(DeviceMgtSQLConstants.Column.ID, deviceId); + preparedStatement.setInt(DeviceMgtSQLConstants.Column.TENANT_ID, tenantId); + })); + + } catch (TransactionException e) { + throw DeviceManagementExceptionHandler.handleServerException( + ErrorMessage.ERROR_WHILE_RETRIEVING_DEVICE, e); + } + } + + @Override + public List getDevicesByUserId(String userId, int tenantId) + throws DeviceMgtException { + + NamedJdbcTemplate jdbcTemplate = new NamedJdbcTemplate(IdentityDatabaseUtil.getDataSource()); + + try { + return jdbcTemplate., RuntimeException>withTransaction( + template -> template.executeQuery( + DeviceMgtSQLConstants.Query.GET_DEVICES_BY_USER_ID, + (resultSet, rowNumber) -> new Device.Builder() + .id(resultSet.getString(DeviceMgtSQLConstants.Column.ID)) + .userId(resultSet.getString(DeviceMgtSQLConstants.Column.USER_ID)) + .deviceName(resultSet.getString(DeviceMgtSQLConstants.Column.DEVICE_NAME)) + .deviceModel(resultSet.getString(DeviceMgtSQLConstants.Column.DEVICE_MODEL)) + .publicKey(resultSet.getString(DeviceMgtSQLConstants.Column.PUBLIC_KEY)) + .status(resultSet.getString(DeviceMgtSQLConstants.Column.STATUS)) + .registeredAt(resultSet.getTimestamp(DeviceMgtSQLConstants.Column.REGISTERED_AT)) + .metadata(resultSet.getString(DeviceMgtSQLConstants.Column.METADATA)) + .build(), + preparedStatement -> { + preparedStatement.setString(DeviceMgtSQLConstants.Column.USER_ID, userId); + preparedStatement.setInt(DeviceMgtSQLConstants.Column.TENANT_ID, tenantId); + })); + + } catch (TransactionException e) { + throw DeviceManagementExceptionHandler.handleServerException( + ErrorMessage.ERROR_WHILE_RETRIEVING_DEVICE, e); + } + } + + @Override + public List getAllDevices(int tenantId) throws DeviceMgtException { + + NamedJdbcTemplate jdbcTemplate = new NamedJdbcTemplate(IdentityDatabaseUtil.getDataSource()); + + try { + return jdbcTemplate., RuntimeException>withTransaction( + template -> template.executeQuery( + DeviceMgtSQLConstants.Query.GET_ALL_DEVICES, + (resultSet, rowNumber) -> new Device.Builder() + .id(resultSet.getString(DeviceMgtSQLConstants.Column.ID)) + .userId(resultSet.getString(DeviceMgtSQLConstants.Column.USER_ID)) + .deviceName(resultSet.getString(DeviceMgtSQLConstants.Column.DEVICE_NAME)) + .deviceModel(resultSet.getString(DeviceMgtSQLConstants.Column.DEVICE_MODEL)) + .publicKey(resultSet.getString(DeviceMgtSQLConstants.Column.PUBLIC_KEY)) + .status(resultSet.getString(DeviceMgtSQLConstants.Column.STATUS)) + .registeredAt(resultSet.getTimestamp(DeviceMgtSQLConstants.Column.REGISTERED_AT)) + .metadata(resultSet.getString(DeviceMgtSQLConstants.Column.METADATA)) + .build(), + preparedStatement -> preparedStatement.setInt( + DeviceMgtSQLConstants.Column.TENANT_ID, tenantId))); + + } catch (TransactionException e) { + throw DeviceManagementExceptionHandler.handleServerException( + ErrorMessage.ERROR_WHILE_RETRIEVING_DEVICE, e); + } + } + + @Override + public List getDevices(int tenantId, int offset, int limit) throws DeviceMgtException { + + // FETCH NEXT 0 ROWS (MS SQL) is invalid and an empty page is meaningless, so short-circuit. + if (limit <= 0) { + return Collections.emptyList(); + } + int safeOffset = Math.max(offset, 0); + + NamedJdbcTemplate jdbcTemplate = new NamedJdbcTemplate(IdentityDatabaseUtil.getDataSource()); + try { + PaginationStyle style = resolvePaginationStyle(); + String query = resolvePaginatedQuery(style); + List devices = jdbcTemplate., RuntimeException>withTransaction( + template -> template.executeQuery( + query, + (resultSet, rowNumber) -> new Device.Builder() + .id(resultSet.getString(DeviceMgtSQLConstants.Column.ID)) + .userId(resultSet.getString(DeviceMgtSQLConstants.Column.USER_ID)) + .deviceName(resultSet.getString(DeviceMgtSQLConstants.Column.DEVICE_NAME)) + .deviceModel(resultSet.getString(DeviceMgtSQLConstants.Column.DEVICE_MODEL)) + .publicKey(resultSet.getString(DeviceMgtSQLConstants.Column.PUBLIC_KEY)) + .status(resultSet.getString(DeviceMgtSQLConstants.Column.STATUS)) + .registeredAt(resultSet.getTimestamp(DeviceMgtSQLConstants.Column.REGISTERED_AT)) + .metadata(resultSet.getString(DeviceMgtSQLConstants.Column.METADATA)) + .build(), + preparedStatement -> { + preparedStatement.setInt(DeviceMgtSQLConstants.Column.TENANT_ID, tenantId); + bindPaginationParams(preparedStatement, style, safeOffset, limit); + })); + return devices != null ? devices : Collections.emptyList(); + } catch (TransactionException | DataAccessException e) { + throw DeviceManagementExceptionHandler.handleServerException( + ErrorMessage.ERROR_WHILE_RETRIEVING_DEVICE, e); + } + } + + @Override + public int getDeviceCount(int tenantId) throws DeviceMgtException { + + NamedJdbcTemplate jdbcTemplate = new NamedJdbcTemplate(IdentityDatabaseUtil.getDataSource()); + try { + Integer count = jdbcTemplate.withTransaction( + template -> template.fetchSingleRecord( + DeviceMgtSQLConstants.Query.GET_DEVICES_COUNT, + (resultSet, rowNumber) -> resultSet.getInt(1), + preparedStatement -> preparedStatement.setInt( + DeviceMgtSQLConstants.Column.TENANT_ID, tenantId))); + return count != null ? count : 0; + } catch (TransactionException e) { + throw DeviceManagementExceptionHandler.handleServerException( + ErrorMessage.ERROR_WHILE_RETRIEVING_DEVICE, e); + } + } + + /** + * Supported database-specific pagination dialects. + */ + private enum PaginationStyle { + DEFAULT, MSSQL, ORACLE, DB2 + } + + private PaginationStyle resolvePaginationStyle() throws DataAccessException { + + // H2, MySQL, MariaDB and PostgreSQL all accept the LIMIT ... OFFSET ... syntax (DEFAULT). + if (JdbcUtils.isOracleDB()) { + return PaginationStyle.ORACLE; + } + if (JdbcUtils.isDB2DB()) { + return PaginationStyle.DB2; + } + if (JdbcUtils.isMSSqlDB()) { + return PaginationStyle.MSSQL; + } + return PaginationStyle.DEFAULT; + } + + private String resolvePaginatedQuery(PaginationStyle style) { + + switch (style) { + case ORACLE: + return DeviceMgtSQLConstants.Query.GET_ALL_DEVICES_PAGINATED_ORACLE; + case DB2: + return DeviceMgtSQLConstants.Query.GET_ALL_DEVICES_PAGINATED_DB2; + case MSSQL: + return DeviceMgtSQLConstants.Query.GET_ALL_DEVICES_PAGINATED_MSSQL; + default: + return DeviceMgtSQLConstants.Query.GET_ALL_DEVICES_PAGINATED; + } + } + + private void bindPaginationParams(NamedPreparedStatement preparedStatement, PaginationStyle style, + int offset, int limit) throws SQLException { + + switch (style) { + case ORACLE: + preparedStatement.setInt(DeviceMgtSQLConstants.Column.UPPER_BOUND, offset + limit); + preparedStatement.setInt(DeviceMgtSQLConstants.Column.OFFSET, offset); + break; + case DB2: + preparedStatement.setInt(DeviceMgtSQLConstants.Column.LOWER_BOUND, offset + 1); + preparedStatement.setInt(DeviceMgtSQLConstants.Column.UPPER_BOUND, offset + limit); + break; + case MSSQL: + preparedStatement.setInt(DeviceMgtSQLConstants.Column.OFFSET, offset); + preparedStatement.setInt(DeviceMgtSQLConstants.Column.LIMIT, limit); + break; + default: + preparedStatement.setInt(DeviceMgtSQLConstants.Column.LIMIT, limit); + preparedStatement.setInt(DeviceMgtSQLConstants.Column.OFFSET, offset); + } + } + + @Override + public Device updateDeviceName(String deviceId, String deviceName, int tenantId) + throws DeviceMgtException { + + NamedJdbcTemplate jdbcTemplate = new NamedJdbcTemplate(IdentityDatabaseUtil.getDataSource()); + + try { + jdbcTemplate.withTransaction(template -> { + template.executeUpdate( + DeviceMgtSQLConstants.Query.UPDATE_DEVICE_NAME, + preparedStatement -> { + preparedStatement.setString(DeviceMgtSQLConstants.Column.DEVICE_NAME, deviceName); + preparedStatement.setString(DeviceMgtSQLConstants.Column.ID, deviceId); + preparedStatement.setInt(DeviceMgtSQLConstants.Column.TENANT_ID, tenantId); + }); + return null; + }); + + } catch (TransactionException e) { + throw DeviceManagementExceptionHandler.handleServerException( + ErrorMessage.ERROR_WHILE_UPDATING_DEVICE, e); + } + + if (LOG.isDebugEnabled()) { + LOG.debug("Device name updated for device ID: " + deviceId); + } + + return getDeviceById(deviceId, tenantId); + } + + @Override + public void deleteDevice(String deviceId, int tenantId) + throws DeviceMgtException { + + NamedJdbcTemplate jdbcTemplate = new NamedJdbcTemplate(IdentityDatabaseUtil.getDataSource()); + + try { + jdbcTemplate.withTransaction(template -> { + template.executeUpdate( + DeviceMgtSQLConstants.Query.DELETE_USER_DEVICE, + preparedStatement -> { + preparedStatement.setString(DeviceMgtSQLConstants.Column.DEVICE_ID, deviceId); + preparedStatement.setInt(DeviceMgtSQLConstants.Column.TENANT_ID, tenantId); + }); + template.executeUpdate( + DeviceMgtSQLConstants.Query.DELETE_DEVICE, + preparedStatement -> { + preparedStatement.setString(DeviceMgtSQLConstants.Column.ID, deviceId); + preparedStatement.setInt(DeviceMgtSQLConstants.Column.TENANT_ID, tenantId); + }); + return null; + }); + + } catch (TransactionException e) { + throw DeviceManagementExceptionHandler.handleServerException( + ErrorMessage.ERROR_WHILE_DELETING_DEVICE, e); + } + + if (LOG.isDebugEnabled()) { + LOG.debug("Device deleted with ID: " + deviceId); + } + } +} diff --git a/components/device-mgt/org.wso2.carbon.identity.device.mgt/src/main/java/org/wso2/carbon/identity/device/mgt/internal/service/impl/DeviceManagementServiceImpl.java b/components/device-mgt/org.wso2.carbon.identity.device.mgt/src/main/java/org/wso2/carbon/identity/device/mgt/internal/service/impl/DeviceManagementServiceImpl.java new file mode 100644 index 000000000000..b15a930bb13b --- /dev/null +++ b/components/device-mgt/org.wso2.carbon.identity.device.mgt/src/main/java/org/wso2/carbon/identity/device/mgt/internal/service/impl/DeviceManagementServiceImpl.java @@ -0,0 +1,286 @@ +/* +* Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com) All Rights Reserved. +* +* WSO2 LLC. licenses this file to you 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. +*/ + +package org.wso2.carbon.identity.device.mgt.internal.service.impl; + +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; +import org.wso2.carbon.identity.core.util.IdentityTenantUtil; +import org.wso2.carbon.identity.device.mgt.api.constant.ErrorMessage; +import org.wso2.carbon.identity.device.mgt.api.exception.DeviceMgtClientException; +import org.wso2.carbon.identity.device.mgt.api.exception.DeviceMgtException; +import org.wso2.carbon.identity.device.mgt.api.model.Device; +import org.wso2.carbon.identity.device.mgt.api.model.DeviceRegistrationInitiation; +import org.wso2.carbon.identity.device.mgt.api.service.DeviceManagementService; +import org.wso2.carbon.identity.device.mgt.internal.cache.DeviceRegistrationCache; +import org.wso2.carbon.identity.device.mgt.internal.cache.DeviceRegistrationCacheEntry; +import org.wso2.carbon.identity.device.mgt.internal.cache.DeviceRegistrationCacheKey; +import org.wso2.carbon.identity.device.mgt.internal.cache.DeviceRegistrationContext; +import org.wso2.carbon.identity.device.mgt.internal.dao.DeviceManagementDAO; +import org.wso2.carbon.identity.device.mgt.internal.dao.impl.DeviceManagementDAOImpl; +import org.wso2.carbon.identity.device.mgt.internal.util.DeviceManagementAuditLogger; +import org.wso2.carbon.identity.device.mgt.internal.util.DeviceManagementExceptionHandler; + +import java.security.InvalidKeyException; +import java.security.KeyFactory; +import java.security.NoSuchAlgorithmException; +import java.security.PublicKey; +import java.security.SecureRandom; +import java.security.Signature; +import java.security.SignatureException; +import java.security.spec.InvalidKeySpecException; +import java.security.spec.X509EncodedKeySpec; +import java.sql.Timestamp; +import java.time.Instant; +import java.util.Base64; +import java.util.List; +import java.util.UUID; + +/** + * Default implementation of {@link DeviceManagementService}. + */ +public class DeviceManagementServiceImpl implements DeviceManagementService { + + private static final Log LOG = LogFactory.getLog(DeviceManagementServiceImpl.class); + private static final DeviceManagementServiceImpl INSTANCE = new DeviceManagementServiceImpl(); + private static final SecureRandom SECURE_RANDOM = new SecureRandom(); + private static final DeviceManagementAuditLogger AUDIT_LOGGER = new DeviceManagementAuditLogger(); + private final DeviceManagementDAO deviceManagementDAO; + + private DeviceManagementServiceImpl() { + deviceManagementDAO = new DeviceManagementDAOImpl(); + } + + /** + * Returns the service singleton instance. + * + * @return Service singleton. + */ + public static DeviceManagementServiceImpl getInstance() { + return INSTANCE; + } + + @Override + public DeviceRegistrationInitiation initiateDeviceRegistration(String username, String tenantDomain) + throws DeviceMgtException { + + validateRequiredField(username, "username"); + validateRequiredField(tenantDomain, "tenantDomain"); + + // Generate 32 random bytes as the challenge, encoded as base64url without padding. + byte[] challengeBytes = new byte[32]; + SECURE_RANDOM.nextBytes(challengeBytes); + String challenge = Base64.getUrlEncoder().withoutPadding().encodeToString(challengeBytes); + + String registrationId = UUID.randomUUID().toString(); + DeviceRegistrationContext context = new DeviceRegistrationContext(username, challenge, tenantDomain); + DeviceRegistrationCacheKey cacheKey = new DeviceRegistrationCacheKey(registrationId); + DeviceRegistrationCacheEntry cacheEntry = new DeviceRegistrationCacheEntry(context); + + DeviceRegistrationCache.getInstance().addToCache(cacheKey, cacheEntry, tenantDomain); + + if (LOG.isDebugEnabled()) { + LOG.debug("Device registration initiated for user: " + username + + " in tenant: " + tenantDomain + + " with registrationId: " + registrationId); + } + return new DeviceRegistrationInitiation(registrationId, challenge); + } + + @Override + public Device verifyDeviceRegistration( + String registrationId, + String publicKey, + String signature, + String deviceName, + String deviceModel, + String metadata, + String tenantDomain) throws DeviceMgtException { + + validateRequiredField(registrationId, "registrationId"); + validateRequiredField(publicKey, "publicKey"); + validateRequiredField(signature, "signature"); + validateRequiredField(deviceName, "deviceName"); + + DeviceRegistrationCacheKey cacheKey = new DeviceRegistrationCacheKey(registrationId); + DeviceRegistrationCacheEntry cacheEntry = + DeviceRegistrationCache.getInstance().getValueFromCache(cacheKey, tenantDomain); + + if (cacheEntry == null) { + throw DeviceManagementExceptionHandler.handleClientException( + ErrorMessage.ERROR_REGISTRATION_CONTEXT_NOT_FOUND, registrationId); + } + + DeviceRegistrationContext context = cacheEntry.getContext(); + verifySignature(registrationId, context.getChallenge(), publicKey, signature); + + DeviceRegistrationCache.getInstance().clearCacheEntry(cacheKey, tenantDomain); + + if (LOG.isDebugEnabled()) { + LOG.debug("Device registration verified (not yet persisted) for user: " + context.getUsername() + + " in tenant: " + tenantDomain); + } + return new Device.Builder() + .id(registrationId) + .deviceName(deviceName) + .deviceModel(deviceModel) + .publicKey(publicKey) + .status("ACTIVE") + .registeredAt(Timestamp.from(Instant.now())) + .metadata(metadata) + .build(); + } + + @Override + public void persistDevice(Device device, String tenantDomain) throws DeviceMgtException { + + if (device.getUserId() == null || device.getUserId().trim().isEmpty()) { + throw DeviceManagementExceptionHandler.handleServerException( + ErrorMessage.ERROR_USER_ID_REQUIRED); + } + deviceManagementDAO.registerDevice(device, IdentityTenantUtil.getTenantId(tenantDomain)); + + AUDIT_LOGGER.printAuditLog(DeviceManagementAuditLogger.Operation.REGISTER, device); + + if (LOG.isDebugEnabled()) { + LOG.debug("Device persisted for user: " + device.getUserId() + + " in tenant: " + tenantDomain + " with device ID: " + device.getId()); + } + } + + @Override + public Device getDeviceById(String deviceId, String tenantDomain) + throws DeviceMgtException { + + validateRequiredField(deviceId, "deviceId"); + return deviceManagementDAO.getDeviceById( + deviceId, IdentityTenantUtil.getTenantId(tenantDomain)); + } + + @Override + public List getDevicesByUserId(String userId, String tenantDomain) + throws DeviceMgtException { + + validateRequiredField(userId, "userId"); + return deviceManagementDAO.getDevicesByUserId( + userId, IdentityTenantUtil.getTenantId(tenantDomain)); + } + + @Override + public List getAllDevices(String tenantDomain) throws DeviceMgtException { + + return deviceManagementDAO.getAllDevices(IdentityTenantUtil.getTenantId(tenantDomain)); + } + + @Override + public List getDevices(String tenantDomain, int offset, int limit) throws DeviceMgtException { + + return deviceManagementDAO.getDevices(IdentityTenantUtil.getTenantId(tenantDomain), offset, limit); + } + + @Override + public int getDeviceCount(String tenantDomain) throws DeviceMgtException { + + return deviceManagementDAO.getDeviceCount(IdentityTenantUtil.getTenantId(tenantDomain)); + } + + @Override + public Device updateDeviceName(String deviceId, String deviceName, String tenantDomain) + throws DeviceMgtException { + + validateRequiredField(deviceId, "deviceId"); + validateRequiredField(deviceName, "deviceName"); + validateDeviceExists(deviceId, tenantDomain); + + Device updated = deviceManagementDAO.updateDeviceName( + deviceId, deviceName, IdentityTenantUtil.getTenantId(tenantDomain)); + + AUDIT_LOGGER.printAuditLog(DeviceManagementAuditLogger.Operation.UPDATE, updated); + return updated; + } + + @Override + public void deleteDevice(String deviceId, String tenantDomain) + throws DeviceMgtException { + + validateRequiredField(deviceId, "deviceId"); + validateDeviceExists(deviceId, tenantDomain); + + deviceManagementDAO.deleteDevice( + deviceId, IdentityTenantUtil.getTenantId(tenantDomain)); + + AUDIT_LOGGER.printAuditLog(DeviceManagementAuditLogger.Operation.DELETE, deviceId); + + if (LOG.isDebugEnabled()) { + LOG.debug("Device deleted with ID: " + deviceId + " in tenant: " + tenantDomain); + } + } + + private void verifySignature(String registrationId, String challenge, + String publicKeyBase64, String signatureBase64) throws DeviceMgtException { + + try { + byte[] publicKeyBytes = Base64.getDecoder().decode(publicKeyBase64); + KeyFactory keyFactory = KeyFactory.getInstance("EC"); + PublicKey publicKey = keyFactory.generatePublic(new X509EncodedKeySpec(publicKeyBytes)); + + byte[] challengeBytes = Base64.getUrlDecoder().decode(challenge); + byte[] signatureBytes = Base64.getDecoder().decode(signatureBase64); + + Signature sig = Signature.getInstance("SHA256withECDSA"); + sig.initVerify(publicKey); + sig.update(challengeBytes); + boolean valid = sig.verify(signatureBytes); + + if (!valid) { + throw DeviceManagementExceptionHandler.handleClientException( + ErrorMessage.ERROR_INVALID_DEVICE_SIGNATURE, registrationId); + } + } catch (NoSuchAlgorithmException | InvalidKeySpecException | + InvalidKeyException | SignatureException e) { + if (e instanceof SignatureException && e.getMessage() != null + && e.getMessage().contains("verification failed")) { + throw DeviceManagementExceptionHandler.handleClientException( + ErrorMessage.ERROR_INVALID_DEVICE_SIGNATURE, e, registrationId); + } + throw DeviceManagementExceptionHandler.handleServerException( + ErrorMessage.ERROR_WHILE_VERIFYING_SIGNATURE, e, registrationId); + } + } + + private void validateDeviceExists(String deviceId, String tenantDomain) + throws DeviceMgtException { + + Device existing = deviceManagementDAO.getDeviceById( + deviceId, IdentityTenantUtil.getTenantId(tenantDomain)); + if (existing == null) { + throw DeviceManagementExceptionHandler.handleClientException( + ErrorMessage.ERROR_DEVICE_NOT_FOUND, deviceId); + } + } + + private void validateRequiredField(String value, String fieldName) + throws DeviceMgtClientException { + + if (value == null || value.trim().isEmpty()) { + throw DeviceManagementExceptionHandler.handleClientException( + ErrorMessage.ERROR_INVALID_DEVICE_FIELD, fieldName); + } + } +} + diff --git a/components/device-mgt/org.wso2.carbon.identity.device.mgt/src/main/java/org/wso2/carbon/identity/device/mgt/internal/util/DeviceManagementAuditLogger.java b/components/device-mgt/org.wso2.carbon.identity.device.mgt/src/main/java/org/wso2/carbon/identity/device/mgt/internal/util/DeviceManagementAuditLogger.java new file mode 100644 index 000000000000..7e520d49ad6f --- /dev/null +++ b/components/device-mgt/org.wso2.carbon.identity.device.mgt/src/main/java/org/wso2/carbon/identity/device/mgt/internal/util/DeviceManagementAuditLogger.java @@ -0,0 +1,196 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (http://www.wso2.com). + * + * WSO2 LLC. licenses this file to you 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. + */ + +package org.wso2.carbon.identity.device.mgt.internal.util; + +import org.json.JSONObject; +import org.wso2.carbon.context.CarbonContext; +import org.wso2.carbon.identity.central.log.mgt.utils.LoggerUtils; +import org.wso2.carbon.identity.core.util.IdentityUtil; +import org.wso2.carbon.identity.device.mgt.api.model.Device; +import org.wso2.carbon.utils.AuditLog; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.Base64; + +import static org.wso2.carbon.identity.central.log.mgt.utils.LoggerUtils.jsonObjectToMap; +import static org.wso2.carbon.identity.central.log.mgt.utils.LoggerUtils.triggerAuditLogEvent; + +/** + * Audit logger for device management state changes. + * Emits an audit record whenever a device credential is registered, renamed or deleted so the + * "who changed which device" trail is preserved. Read operations are intentionally not audited. + */ +public class DeviceManagementAuditLogger { + + private static final String DEVICE_TARGET_TYPE = "Device"; + private static final String FINGERPRINT_UNAVAILABLE = "unavailable"; + + /** + * Print an audit log for an operation that carries the full device state. + * + * @param operation Operation associated with the state change. + * @param device Device to be logged. + */ + public void printAuditLog(Operation operation, Device device) { + + JSONObject data = createAuditLogEntry(device); + buildAuditLog(device.getId(), operation, data); + } + + /** + * Print an audit log for an operation identified only by the device ID (e.g. delete). + * + * @param operation Operation associated with the state change. + * @param deviceId ID of the device to be logged. + */ + public void printAuditLog(Operation operation, String deviceId) { + + JSONObject data = new JSONObject(); + data.put(LogConstants.ID_FIELD, deviceId != null ? deviceId : JSONObject.NULL); + buildAuditLog(deviceId, operation, data); + } + + /** + * Build and trigger the audit log event. + * + * @param targetId Target device ID. + * @param operation Operation to be logged. + * @param data Data to be logged. + */ + private void buildAuditLog(String targetId, Operation operation, JSONObject data) { + + String initiatorId = getInitiatorId(); + AuditLog.AuditLogBuilder auditLogBuilder = new AuditLog.AuditLogBuilder( + initiatorId, + LoggerUtils.getInitiatorType(initiatorId), + targetId, + DEVICE_TARGET_TYPE, + operation.getLogAction()) + .data(jsonObjectToMap(data)); + triggerAuditLogEvent(auditLogBuilder); + } + + /** + * Create audit log data for the given device. + * The device owner is masked and the public key is reduced to a fingerprint; the raw public key + * and free-form metadata are never logged. + * + * @param device Device to be logged. + * @return Audit log data as a JSONObject. + */ + private JSONObject createAuditLogEntry(Device device) { + + JSONObject data = new JSONObject(); + data.put(LogConstants.ID_FIELD, device.getId() != null ? device.getId() : JSONObject.NULL); + data.put(LogConstants.DEVICE_NAME_FIELD, + device.getDeviceName() != null ? device.getDeviceName() : JSONObject.NULL); + data.put(LogConstants.DEVICE_MODEL_FIELD, + device.getDeviceModel() != null ? device.getDeviceModel() : JSONObject.NULL); + data.put(LogConstants.STATUS_FIELD, device.getStatus() != null ? device.getStatus() : JSONObject.NULL); + data.put(LogConstants.REGISTERED_AT_FIELD, + device.getRegisteredAt() != null ? String.valueOf(device.getRegisteredAt()) : JSONObject.NULL); + data.put(LogConstants.USER_ID_FIELD, device.getUserId() != null + ? LoggerUtils.getMaskedContent(device.getUserId()) : JSONObject.NULL); + data.put(LogConstants.PUBLIC_KEY_FINGERPRINT_FIELD, device.getPublicKey() != null + ? fingerprint(device.getPublicKey()) : JSONObject.NULL); + return data; + } + + /** + * Resolve the initiator of the audit event from the carbon context, falling back to System. + * + * @return Initiator ID. + */ + private String getInitiatorId() { + + CarbonContext carbonContext = CarbonContext.getThreadLocalCarbonContext(); + String username = carbonContext.getUsername(); + String tenantDomain = carbonContext.getTenantDomain(); + + if (username == null || username.trim().isEmpty()) { + return LoggerUtils.Initiator.System.name(); + } + String initiator = null; + if (tenantDomain != null && !tenantDomain.trim().isEmpty()) { + initiator = IdentityUtil.getInitiatorId(username, tenantDomain); + } + return initiator != null && !initiator.trim().isEmpty() + ? initiator : LoggerUtils.getMaskedContent(username); + } + + /** + * Produce a short, non-reversible fingerprint of the device public key for correlation. + * + * @param publicKey Base64 encoded public key. + * @return Truncated SHA-256 fingerprint, or a placeholder if hashing is unavailable. + */ + private String fingerprint(String publicKey) { + + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + byte[] hash = digest.digest(publicKey.getBytes(StandardCharsets.UTF_8)); + String encoded = Base64.getUrlEncoder().withoutPadding().encodeToString(hash); + return encoded.length() > 16 ? encoded.substring(0, 16) : encoded; + } catch (NoSuchAlgorithmException e) { + return FINGERPRINT_UNAVAILABLE; + } + } + + /** + * Device management operations to be audited. + */ + public enum Operation { + REGISTER("register-device"), + UPDATE("update-device"), + DELETE("delete-device"); + + private final String logAction; + + Operation(String logAction) { + + this.logAction = logAction; + } + + /** + * Get the log action associated with the operation. + * + * @return Log action string. + */ + public String getLogAction() { + + return this.logAction; + } + } + + /** + * Device management audit log field constants. + */ + private static class LogConstants { + + private static final String ID_FIELD = "Id"; + private static final String DEVICE_NAME_FIELD = "DeviceName"; + private static final String DEVICE_MODEL_FIELD = "DeviceModel"; + private static final String STATUS_FIELD = "Status"; + private static final String REGISTERED_AT_FIELD = "RegisteredAt"; + private static final String USER_ID_FIELD = "UserId"; + private static final String PUBLIC_KEY_FINGERPRINT_FIELD = "PublicKeyFingerprint"; + } +} diff --git a/components/device-mgt/org.wso2.carbon.identity.device.mgt/src/main/java/org/wso2/carbon/identity/device/mgt/internal/util/DeviceManagementExceptionHandler.java b/components/device-mgt/org.wso2.carbon.identity.device.mgt/src/main/java/org/wso2/carbon/identity/device/mgt/internal/util/DeviceManagementExceptionHandler.java new file mode 100644 index 000000000000..e39cd12e2694 --- /dev/null +++ b/components/device-mgt/org.wso2.carbon.identity.device.mgt/src/main/java/org/wso2/carbon/identity/device/mgt/internal/util/DeviceManagementExceptionHandler.java @@ -0,0 +1,93 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (http://www.wso2.com). + * + * WSO2 LLC. licenses this file to you 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. + */ + +package org.wso2.carbon.identity.device.mgt.internal.util; + +import org.wso2.carbon.identity.device.mgt.api.constant.ErrorMessage; +import org.wso2.carbon.identity.device.mgt.api.exception.DeviceMgtClientException; +import org.wso2.carbon.identity.device.mgt.api.exception.DeviceMgtServerException; + +/** + * Utility class for constructing device management exceptions with the standard + * (message, description, code[, cause]) shape backed by ErrorMessage entries. + */ +public class DeviceManagementExceptionHandler { + + private DeviceManagementExceptionHandler() { + + } + + /** + * Builds a client exception from the given error. + * + * @param error Error message entry. + * @param data Optional values to format into the description. + * @return Device management client exception. + */ + public static DeviceMgtClientException handleClientException(ErrorMessage error, String... data) { + + return new DeviceMgtClientException(error.getMessage(), formatDescription(error, data), error.getCode()); + } + + /** + * Builds a client exception from the given error and root cause. + * + * @param error Error message entry. + * @param e Root cause. + * @param data Optional values to format into the description. + * @return Device management client exception. + */ + public static DeviceMgtClientException handleClientException(ErrorMessage error, Throwable e, String... data) { + + return new DeviceMgtClientException(error.getMessage(), formatDescription(error, data), error.getCode(), e); + } + + /** + * Builds a server exception from the given error and root cause. + * + * @param error Error message entry. + * @param e Root cause. + * @param data Optional values to format into the description. + * @return Device management server exception. + */ + public static DeviceMgtServerException handleServerException(ErrorMessage error, Throwable e, String... data) { + + return new DeviceMgtServerException(error.getMessage(), formatDescription(error, data), error.getCode(), e); + } + + /** + * Builds a server exception from the given error. + * + * @param error Error message entry. + * @param data Optional values to format into the description. + * @return Device management server exception. + */ + public static DeviceMgtServerException handleServerException(ErrorMessage error, String... data) { + + return new DeviceMgtServerException(error.getMessage(), formatDescription(error, data), error.getCode()); + } + + private static String formatDescription(ErrorMessage error, String... data) { + + String description = error.getDescription(); + if (data != null && data.length > 0) { + description = String.format(description, (Object[]) data); + } + return description; + } +} diff --git a/components/device-mgt/org.wso2.carbon.identity.device.mgt/src/test/java/org/wso2/carbon/identity/device/mgt/dao/DeviceManagementDAOImplTest.java b/components/device-mgt/org.wso2.carbon.identity.device.mgt/src/test/java/org/wso2/carbon/identity/device/mgt/dao/DeviceManagementDAOImplTest.java new file mode 100644 index 000000000000..bf130777fdf6 --- /dev/null +++ b/components/device-mgt/org.wso2.carbon.identity.device.mgt/src/test/java/org/wso2/carbon/identity/device/mgt/dao/DeviceManagementDAOImplTest.java @@ -0,0 +1,311 @@ +/* +* Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com) All Rights Reserved. +* +* WSO2 LLC. licenses this file to you 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. +*/ + +package org.wso2.carbon.identity.device.mgt.dao; + +import org.testng.Assert; +import org.testng.annotations.Test; +import org.wso2.carbon.identity.common.testng.WithCarbonHome; +import org.wso2.carbon.identity.common.testng.WithH2Database; +import org.wso2.carbon.identity.device.mgt.api.exception.DeviceMgtException; +import org.wso2.carbon.identity.device.mgt.api.model.Device; +import org.wso2.carbon.identity.device.mgt.internal.dao.impl.DeviceManagementDAOImpl; + +import java.sql.Timestamp; +import java.time.Instant; +import java.util.List; +import java.util.UUID; + +/** + * Unit tests for {@link DeviceManagementDAOImpl}. + */ +@WithH2Database(files = {"dbscripts/h2.sql"}) +@WithCarbonHome +public class DeviceManagementDAOImplTest { + + private static final int TENANT_ID = -1234; + private static final int OTHER_TENANT_ID = -5678; + private static final String TEST_USER_ID = "alice@example.com"; + private static final String SECOND_USER_ID = "carol@example.com"; + + DeviceManagementDAOImpl deviceManagementDAO = new DeviceManagementDAOImpl(); + private String createdDeviceId; + + /** + * Tests registering a device. + * + * @throws DeviceMgtException If the DAO operation fails. + */ + @Test(priority = 1) + public void testRegisterDevice() throws DeviceMgtException { + + Device device = buildDevice(UUID.randomUUID().toString(), "Alice Phone", "ACTIVE"); + Device result = deviceManagementDAO.registerDevice(device, TENANT_ID); + + Assert.assertNotNull(result); + Assert.assertEquals(result.getId(), device.getId()); + Assert.assertEquals(result.getDeviceName(), "Alice Phone"); + Assert.assertEquals(result.getStatus(), "ACTIVE"); + + createdDeviceId = result.getId(); + } + + /** + * Tests retrieving a device by id. + * + * @throws DeviceMgtException If the DAO operation fails. + */ + @Test(priority = 2, dependsOnMethods = {"testRegisterDevice"}) + public void testGetDeviceById() throws DeviceMgtException { + + Device result = deviceManagementDAO.getDeviceById(createdDeviceId, TENANT_ID); + + Assert.assertNotNull(result); + Assert.assertEquals(result.getId(), createdDeviceId); + Assert.assertEquals(result.getUserId(), TEST_USER_ID); + } + + /** + * Tests retrieving a non-existing device by id. + * + * @throws DeviceMgtException If the DAO operation fails. + */ + @Test(priority = 3) + public void testGetDeviceByIdNotFound() throws DeviceMgtException { + + Device result = deviceManagementDAO.getDeviceById(UUID.randomUUID().toString(), TENANT_ID); + Assert.assertNull(result); + } + + /** + * Tests that getDevicesByUserId returns only ACTIVE devices. + * + * @throws DeviceMgtException If the DAO operation fails. + */ + @Test(priority = 4, dependsOnMethods = {"testRegisterDevice"}) + public void testGetDevicesByUserIdOnlyActive() throws DeviceMgtException { + + Device revokedDevice = buildDevice(UUID.randomUUID().toString(), "Old Device", "REVOKED"); + deviceManagementDAO.registerDevice(revokedDevice, TENANT_ID); + + List devices = deviceManagementDAO.getDevicesByUserId(TEST_USER_ID, TENANT_ID); + + Assert.assertEquals(devices.size(), 1); + Assert.assertEquals(devices.get(0).getStatus(), "ACTIVE"); + Assert.assertEquals(devices.get(0).getId(), createdDeviceId); + } + + /** + * Tests updating a device name. + * + * @throws DeviceMgtException If the DAO operation fails. + */ + @Test(priority = 5, dependsOnMethods = {"testRegisterDevice"}) + public void testUpdateDeviceName() throws DeviceMgtException { + + Device updated = deviceManagementDAO.updateDeviceName(createdDeviceId, "Alice Updated", TENANT_ID); + + Assert.assertNotNull(updated); + Assert.assertEquals(updated.getDeviceName(), "Alice Updated"); + } + + /** + * Tests deleting a device. + * + * @throws DeviceMgtException If the DAO operation fails. + */ + @Test(priority = 6, dependsOnMethods = {"testRegisterDevice"}) + public void testDeleteDevice() throws DeviceMgtException { + + deviceManagementDAO.deleteDevice(createdDeviceId, TENANT_ID); + Device afterDelete = deviceManagementDAO.getDeviceById(createdDeviceId, TENANT_ID); + + Assert.assertNull(afterDelete); + } + + /** + * Tests that all fields survive a register + getDeviceById round-trip. + * + * @throws DeviceMgtException If the DAO operation fails. + */ + @Test(priority = 7) + public void testRegisterDeviceFullFieldRoundTrip() throws DeviceMgtException { + + String id = UUID.randomUUID().toString(); + Device device = new Device.Builder() + .id(id) + .userId(SECOND_USER_ID) + .deviceName("Carol Phone") + .deviceModel("Pixel 8 Pro") + .publicKey("pk-full-" + id) + .status("ACTIVE") + .registeredAt(Timestamp.from(Instant.now())) + .metadata("{\"env\":\"test\"}") + .build(); + + deviceManagementDAO.registerDevice(device, TENANT_ID); + Device result = deviceManagementDAO.getDeviceById(id, TENANT_ID); + + Assert.assertNotNull(result); + Assert.assertEquals(result.getId(), id); + Assert.assertEquals(result.getUserId(), SECOND_USER_ID); + Assert.assertEquals(result.getDeviceName(), "Carol Phone"); + Assert.assertEquals(result.getDeviceModel(), "Pixel 8 Pro"); + Assert.assertEquals(result.getPublicKey(), "pk-full-" + id); + Assert.assertEquals(result.getStatus(), "ACTIVE"); + Assert.assertNotNull(result.getRegisteredAt()); + Assert.assertEquals(result.getMetadata(), "{\"env\":\"test\"}"); + } + + /** + * Tests that a device registered under one tenant is not visible under another. + * + * @throws DeviceMgtException If the DAO operation fails. + */ + @Test(priority = 8) + public void testTenantIsolation() throws DeviceMgtException { + + String id = UUID.randomUUID().toString(); + deviceManagementDAO.registerDevice(buildDevice(id, "Tenant Device", "ACTIVE"), TENANT_ID); + + Device fromOtherTenant = deviceManagementDAO.getDeviceById(id, OTHER_TENANT_ID); + Assert.assertNull(fromOtherTenant); + + List allOtherTenant = deviceManagementDAO.getAllDevices(OTHER_TENANT_ID); + long found = allOtherTenant.stream().filter(d -> d.getId().equals(id)).count(); + Assert.assertEquals(found, 0); + } + + /** + * Tests that getAllDevices returns every device registered under the tenant. + * + * @throws DeviceMgtException If the DAO operation fails. + */ + @Test(priority = 9) + public void testGetAllDevices() throws DeviceMgtException { + + String userId = "dave@example.com"; + String id1 = UUID.randomUUID().toString(); + String id2 = UUID.randomUUID().toString(); + deviceManagementDAO.registerDevice(buildDeviceForUser(id1, "Dave Phone 1", userId, "ACTIVE"), OTHER_TENANT_ID); + deviceManagementDAO.registerDevice(buildDeviceForUser(id2, "Dave Phone 2", userId, "ACTIVE"), OTHER_TENANT_ID); + + List all = deviceManagementDAO.getAllDevices(OTHER_TENANT_ID); + + Assert.assertEquals(all.size(), 2); + long count = all.stream().filter(d -> d.getUserId().equals(userId)).count(); + Assert.assertEquals(count, 2); + } + + /** + * Tests that getDevicesByUserId returns all active devices for a user. + * + * @throws DeviceMgtException If the DAO operation fails. + */ + @Test(priority = 10) + public void testGetDevicesByUserIdMultiple() throws DeviceMgtException { + + String userId = "eve@example.com"; + String id1 = UUID.randomUUID().toString(); + String id2 = UUID.randomUUID().toString(); + deviceManagementDAO.registerDevice(buildDeviceForUser(id1, "Eve Phone 1", userId, "ACTIVE"), TENANT_ID); + deviceManagementDAO.registerDevice(buildDeviceForUser(id2, "Eve Phone 2", userId, "ACTIVE"), TENANT_ID); + + List devices = deviceManagementDAO.getDevicesByUserId(userId, TENANT_ID); + + Assert.assertEquals(devices.size(), 2); + } + + /** + * Tests that getDevicesByUserId returns an empty list for an unknown user. + * + * @throws DeviceMgtException If the DAO operation fails. + */ + @Test(priority = 11) + public void testGetDevicesByUserIdEmpty() throws DeviceMgtException { + + List devices = deviceManagementDAO.getDevicesByUserId("unknown@example.com", TENANT_ID); + + Assert.assertNotNull(devices); + Assert.assertTrue(devices.isEmpty()); + } + + /** + * Tests that updateDeviceName with a wrong tenant id does not affect the row. + * + * @throws DeviceMgtException If the DAO operation fails. + */ + @Test(priority = 12) + public void testUpdateDeviceNameWrongTenantNoOp() throws DeviceMgtException { + + String id = UUID.randomUUID().toString(); + deviceManagementDAO.registerDevice(buildDevice(id, "Frank Phone", "ACTIVE"), TENANT_ID); + + deviceManagementDAO.updateDeviceName(id, "Frank New Name", OTHER_TENANT_ID); + + Device result = deviceManagementDAO.getDeviceById(id, TENANT_ID); + Assert.assertNotNull(result); + Assert.assertEquals(result.getDeviceName(), "Frank Phone"); + } + + /** + * Tests that deleteDevice with a wrong tenant id does not remove the row. + * + * @throws DeviceMgtException If the DAO operation fails. + */ + @Test(priority = 13) + public void testDeleteDeviceWrongTenantNoOp() throws DeviceMgtException { + + String id = UUID.randomUUID().toString(); + deviceManagementDAO.registerDevice(buildDevice(id, "Grace Phone", "ACTIVE"), TENANT_ID); + + deviceManagementDAO.deleteDevice(id, OTHER_TENANT_ID); + + Device result = deviceManagementDAO.getDeviceById(id, TENANT_ID); + Assert.assertNotNull(result); + Assert.assertEquals(result.getDeviceName(), "Grace Phone"); + } + + private Device buildDevice(String id, String deviceName, String status) { + + return new Device.Builder() + .id(id) + .userId(TEST_USER_ID) + .deviceName(deviceName) + .deviceModel("iPhone 15 Pro") + .publicKey("base64-public-key-" + id) + .status(status) + .registeredAt(Timestamp.from(Instant.now())) + .metadata("{\"label\":\"primary\"}") + .build(); + } + + private Device buildDeviceForUser(String id, String deviceName, String userId, String status) { + + return new Device.Builder() + .id(id) + .userId(userId) + .deviceName(deviceName) + .deviceModel("Pixel 8") + .publicKey("pk-" + id) + .status(status) + .registeredAt(Timestamp.from(Instant.now())) + .metadata(null) + .build(); + } +} diff --git a/components/device-mgt/org.wso2.carbon.identity.device.mgt/src/test/java/org/wso2/carbon/identity/device/mgt/service/DeviceManagementServiceImplTest.java b/components/device-mgt/org.wso2.carbon.identity.device.mgt/src/test/java/org/wso2/carbon/identity/device/mgt/service/DeviceManagementServiceImplTest.java new file mode 100644 index 000000000000..b16802eab4d5 --- /dev/null +++ b/components/device-mgt/org.wso2.carbon.identity.device.mgt/src/test/java/org/wso2/carbon/identity/device/mgt/service/DeviceManagementServiceImplTest.java @@ -0,0 +1,372 @@ +/* +* Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com) All Rights Reserved. +* +* WSO2 LLC. licenses this file to you 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. +*/ + +package org.wso2.carbon.identity.device.mgt.service; + +import org.mockito.MockedStatic; +import org.testng.Assert; +import org.testng.annotations.AfterClass; +import org.testng.annotations.BeforeClass; +import org.testng.annotations.BeforeMethod; +import org.testng.annotations.Test; +import org.wso2.carbon.identity.common.testng.WithCarbonHome; +import org.wso2.carbon.identity.core.util.IdentityTenantUtil; +import org.wso2.carbon.identity.device.mgt.api.constant.ErrorMessage; +import org.wso2.carbon.identity.device.mgt.api.exception.DeviceMgtException; +import org.wso2.carbon.identity.device.mgt.api.model.Device; +import org.wso2.carbon.identity.device.mgt.api.model.DeviceRegistrationInitiation; +import org.wso2.carbon.identity.device.mgt.internal.dao.DeviceManagementDAO; +import org.wso2.carbon.identity.device.mgt.internal.service.impl.DeviceManagementServiceImpl; + +import java.lang.reflect.Field; +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.security.Signature; +import java.util.Base64; +import java.util.UUID; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * Unit tests for {@link DeviceManagementServiceImpl}. + */ +@WithCarbonHome +public class DeviceManagementServiceImplTest { + + private static final String TEST_USERNAME = "alice"; + private static final String TENANT_DOMAIN = "test.com"; + private static final int TENANT_ID = 1; + + private DeviceManagementServiceImpl service; + private DeviceManagementDAO dao; + private MockedStatic identityTenantUtilMocked; + + @BeforeClass + public void setUpClass() { + + service = DeviceManagementServiceImpl.getInstance(); + identityTenantUtilMocked = mockStatic(IdentityTenantUtil.class); + when(IdentityTenantUtil.getTenantId(TENANT_DOMAIN)).thenReturn(TENANT_ID); + } + + @AfterClass + public void tearDownClass() { + + identityTenantUtilMocked.close(); + } + + @BeforeMethod + public void setUp() throws Exception { + + dao = mock(DeviceManagementDAO.class); + Field f = DeviceManagementServiceImpl.class.getDeclaredField("deviceManagementDAO"); + f.setAccessible(true); + f.set(service, dao); + } + + @Test + public void testInitiateReturnsRegistrationIdAndChallenge() throws DeviceMgtException { + + DeviceRegistrationInitiation result = service.initiateDeviceRegistration(TEST_USERNAME, TENANT_DOMAIN); + + Assert.assertNotNull(result); + Assert.assertNotNull(result.getRegistrationId()); + Assert.assertFalse(result.getRegistrationId().isBlank()); + Assert.assertNotNull(result.getChallenge()); + Assert.assertFalse(result.getChallenge().isBlank()); + } + + @Test + public void testInitiateGeneratesUniqueChallenges() throws DeviceMgtException { + + DeviceRegistrationInitiation r1 = service.initiateDeviceRegistration(TEST_USERNAME, TENANT_DOMAIN); + DeviceRegistrationInitiation r2 = service.initiateDeviceRegistration(TEST_USERNAME, TENANT_DOMAIN); + + Assert.assertNotEquals(r1.getChallenge(), r2.getChallenge()); + } + + @Test + public void testInitiateWithBlankUsernameThrows() { + + try { + service.initiateDeviceRegistration(" ", TENANT_DOMAIN); + Assert.fail("Expected DeviceMgtClientException"); + } catch (DeviceMgtException ex) { + Assert.assertEquals(ex.getErrorCode(), ErrorMessage.ERROR_INVALID_DEVICE_FIELD.getCode()); + } + } + + @Test + public void testInitiateWithBlankTenantDomainThrows() { + + try { + service.initiateDeviceRegistration(TEST_USERNAME, ""); + Assert.fail("Expected DeviceMgtClientException"); + } catch (DeviceMgtException ex) { + Assert.assertEquals(ex.getErrorCode(), ErrorMessage.ERROR_INVALID_DEVICE_FIELD.getCode()); + } + } + + @Test + public void testVerifyWithValidSignatureSucceeds() throws Exception { + + KeyPair kp = generateEcKeyPair(); + DeviceRegistrationInitiation initiation = service.initiateDeviceRegistration(TEST_USERNAME, TENANT_DOMAIN); + String sig = signChallengeB64(kp, initiation.getChallenge()); + + Device result = service.verifyDeviceRegistration( + initiation.getRegistrationId(), publicKeyB64(kp), sig, + "Alice's iPhone", null, null, TENANT_DOMAIN); + + Assert.assertNotNull(result); + Assert.assertEquals(result.getPublicKey(), publicKeyB64(kp)); + Assert.assertEquals(result.getUserId(), TEST_USERNAME); + } + + @Test + public void testVerifyWithInvalidSignatureThrows() throws Exception { + + KeyPair kp1 = generateEcKeyPair(); + KeyPair kp2 = generateEcKeyPair(); + DeviceRegistrationInitiation initiation = service.initiateDeviceRegistration(TEST_USERNAME, TENANT_DOMAIN); + String sigFromWrongKey = signChallengeB64(kp2, initiation.getChallenge()); + + try { + service.verifyDeviceRegistration( + initiation.getRegistrationId(), publicKeyB64(kp1), sigFromWrongKey, + "Alice's iPhone", null, null, TENANT_DOMAIN); + Assert.fail("Expected DeviceMgtClientException"); + } catch (DeviceMgtException ex) { + Assert.assertEquals(ex.getErrorCode(), ErrorMessage.ERROR_INVALID_DEVICE_SIGNATURE.getCode()); + } + } + + @Test + public void testVerifyWithMissingRegistrationContextThrows() throws Exception { + + KeyPair kp = generateEcKeyPair(); + + try { + service.verifyDeviceRegistration( + UUID.randomUUID().toString(), publicKeyB64(kp), "fakeSig", + "Device", null, null, TENANT_DOMAIN); + Assert.fail("Expected DeviceMgtClientException"); + } catch (DeviceMgtException ex) { + Assert.assertEquals(ex.getErrorCode(), ErrorMessage.ERROR_REGISTRATION_CONTEXT_NOT_FOUND.getCode()); + } + } + + @Test + public void testVerifyWithMalformedPublicKeyThrows() throws Exception { + + DeviceRegistrationInitiation initiation = service.initiateDeviceRegistration(TEST_USERNAME, TENANT_DOMAIN); + String badKey = Base64.getEncoder().encodeToString(new byte[]{1, 2, 3, 4, 5}); + String fakeSig = Base64.getEncoder().encodeToString(new byte[]{0, 0, 0, 0}); + + try { + service.verifyDeviceRegistration( + initiation.getRegistrationId(), badKey, fakeSig, + "Device", null, null, TENANT_DOMAIN); + Assert.fail("Expected DeviceMgtServerException"); + } catch (DeviceMgtException ex) { + Assert.assertEquals(ex.getErrorCode(), ErrorMessage.ERROR_WHILE_VERIFYING_SIGNATURE.getCode()); + } + } + + @Test + public void testVerifyWithBlankRequiredFieldThrows() { + + try { + service.verifyDeviceRegistration( + UUID.randomUUID().toString(), "", "sig", + "Device", null, null, TENANT_DOMAIN); + Assert.fail("Expected DeviceMgtClientException for blank publicKey"); + } catch (DeviceMgtException ex) { + Assert.assertEquals(ex.getErrorCode(), ErrorMessage.ERROR_INVALID_DEVICE_FIELD.getCode()); + } + + try { + service.verifyDeviceRegistration( + UUID.randomUUID().toString(), "pk", " ", + "Device", null, null, TENANT_DOMAIN); + Assert.fail("Expected DeviceMgtClientException for blank signature"); + } catch (DeviceMgtException ex) { + Assert.assertEquals(ex.getErrorCode(), ErrorMessage.ERROR_INVALID_DEVICE_FIELD.getCode()); + } + + try { + service.verifyDeviceRegistration( + UUID.randomUUID().toString(), "pk", "sig", + "", null, null, TENANT_DOMAIN); + Assert.fail("Expected DeviceMgtClientException for blank deviceName"); + } catch (DeviceMgtException ex) { + Assert.assertEquals(ex.getErrorCode(), ErrorMessage.ERROR_INVALID_DEVICE_FIELD.getCode()); + } + } + + @Test + public void testVerifyConsumesContext() throws Exception { + + KeyPair kp = generateEcKeyPair(); + DeviceRegistrationInitiation initiation = service.initiateDeviceRegistration(TEST_USERNAME, TENANT_DOMAIN); + String sig = signChallengeB64(kp, initiation.getChallenge()); + + service.verifyDeviceRegistration( + initiation.getRegistrationId(), publicKeyB64(kp), sig, + "Device", null, null, TENANT_DOMAIN); + + try { + service.verifyDeviceRegistration( + initiation.getRegistrationId(), publicKeyB64(kp), sig, + "Device", null, null, TENANT_DOMAIN); + Assert.fail("Expected context-not-found after context was consumed"); + } catch (DeviceMgtException ex) { + Assert.assertEquals(ex.getErrorCode(), ErrorMessage.ERROR_REGISTRATION_CONTEXT_NOT_FOUND.getCode()); + } + } + + @Test + public void testCompleteVerifiesAndPersists() throws Exception { + + KeyPair kp = generateEcKeyPair(); + DeviceRegistrationInitiation initiation = service.initiateDeviceRegistration(TEST_USERNAME, TENANT_DOMAIN); + String sig = signChallengeB64(kp, initiation.getChallenge()); + + Device verified = service.verifyDeviceRegistration( + initiation.getRegistrationId(), publicKeyB64(kp), sig, + "Alice's iPhone", "iPhone 15", null, TENANT_DOMAIN); + + when(dao.registerDevice(any(), eq(TENANT_ID))).thenReturn(verified); + service.persistDevice(verified, TENANT_DOMAIN); + + verify(dao).registerDevice(any(), eq(TENANT_ID)); + } + + @Test + public void testGetDeviceByIdDelegatesToDao() throws Exception { + + Device device = mock(Device.class); + when(dao.getDeviceById("d1", TENANT_ID)).thenReturn(device); + + Device result = service.getDeviceById("d1", TENANT_DOMAIN); + + Assert.assertEquals(result, device); + } + + @Test + public void testGetDeviceByIdWithBlankIdThrows() { + + try { + service.getDeviceById("", TENANT_DOMAIN); + Assert.fail("Expected DeviceMgtClientException"); + } catch (DeviceMgtException ex) { + Assert.assertEquals(ex.getErrorCode(), ErrorMessage.ERROR_INVALID_DEVICE_FIELD.getCode()); + } + } + + @Test + public void testUpdateDeviceNameWhenDeviceMissingThrows() throws Exception { + + when(dao.getDeviceById("d1", TENANT_ID)).thenReturn(null); + + try { + service.updateDeviceName("d1", "New Name", TENANT_DOMAIN); + Assert.fail("Expected DeviceMgtClientException"); + } catch (DeviceMgtException ex) { + Assert.assertEquals(ex.getErrorCode(), ErrorMessage.ERROR_DEVICE_NOT_FOUND.getCode()); + } + } + + @Test + public void testUpdateDeviceNameWithBlankArgsThrows() { + + try { + service.updateDeviceName("", "New Name", TENANT_DOMAIN); + Assert.fail("Expected DeviceMgtClientException for blank deviceId"); + } catch (DeviceMgtException ex) { + Assert.assertEquals(ex.getErrorCode(), ErrorMessage.ERROR_INVALID_DEVICE_FIELD.getCode()); + } + + try { + service.updateDeviceName("d1", " ", TENANT_DOMAIN); + Assert.fail("Expected DeviceMgtClientException for blank deviceName"); + } catch (DeviceMgtException ex) { + Assert.assertEquals(ex.getErrorCode(), ErrorMessage.ERROR_INVALID_DEVICE_FIELD.getCode()); + } + } + + @Test + public void testDeleteDeviceWhenDeviceMissingThrows() throws Exception { + + when(dao.getDeviceById("d1", TENANT_ID)).thenReturn(null); + + try { + service.deleteDevice("d1", TENANT_DOMAIN); + Assert.fail("Expected DeviceMgtClientException"); + } catch (DeviceMgtException ex) { + Assert.assertEquals(ex.getErrorCode(), ErrorMessage.ERROR_DEVICE_NOT_FOUND.getCode()); + } + } + + @Test + public void testDeleteDeviceDelegatesToDao() throws Exception { + + Device device = mock(Device.class); + when(dao.getDeviceById("d1", TENANT_ID)).thenReturn(device); + + service.deleteDevice("d1", TENANT_DOMAIN); + + verify(dao).deleteDevice("d1", TENANT_ID); + } + + @Test + public void testGetDevicesByUserIdWithBlankUserThrows() { + + try { + service.getDevicesByUserId("", TENANT_DOMAIN); + Assert.fail("Expected DeviceMgtClientException"); + } catch (DeviceMgtException ex) { + Assert.assertEquals(ex.getErrorCode(), ErrorMessage.ERROR_INVALID_DEVICE_FIELD.getCode()); + } + } + + private static KeyPair generateEcKeyPair() throws Exception { + + KeyPairGenerator kpg = KeyPairGenerator.getInstance("EC"); + kpg.initialize(256); + return kpg.generateKeyPair(); + } + + private static String publicKeyB64(KeyPair kp) { + + return Base64.getEncoder().encodeToString(kp.getPublic().getEncoded()); + } + + private static String signChallengeB64(KeyPair kp, String challengeB64Url) throws Exception { + + Signature sig = Signature.getInstance("SHA256withECDSA"); + sig.initSign(kp.getPrivate()); + sig.update(Base64.getUrlDecoder().decode(challengeB64Url)); + return Base64.getEncoder().encodeToString(sig.sign()); + } +} diff --git a/components/device-mgt/org.wso2.carbon.identity.device.mgt/src/test/java/org/wso2/carbon/identity/device/mgt/util/DeviceManagementExceptionHandlerTest.java b/components/device-mgt/org.wso2.carbon.identity.device.mgt/src/test/java/org/wso2/carbon/identity/device/mgt/util/DeviceManagementExceptionHandlerTest.java new file mode 100644 index 000000000000..2cccc05a8a6a --- /dev/null +++ b/components/device-mgt/org.wso2.carbon.identity.device.mgt/src/test/java/org/wso2/carbon/identity/device/mgt/util/DeviceManagementExceptionHandlerTest.java @@ -0,0 +1,99 @@ +/* +* Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com) All Rights Reserved. +* +* WSO2 LLC. licenses this file to you 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. +*/ + +package org.wso2.carbon.identity.device.mgt.util; + +import org.testng.Assert; +import org.testng.annotations.Test; +import org.wso2.carbon.identity.device.mgt.api.constant.ErrorMessage; +import org.wso2.carbon.identity.device.mgt.api.exception.DeviceMgtClientException; +import org.wso2.carbon.identity.device.mgt.api.exception.DeviceMgtServerException; +import org.wso2.carbon.identity.device.mgt.internal.util.DeviceManagementExceptionHandler; + +/** + * Unit tests for {@link DeviceManagementExceptionHandler}. + */ +public class DeviceManagementExceptionHandlerTest { + + @Test + public void testHandleClientException() { + + ErrorMessage error = ErrorMessage.ERROR_DEVICE_NOT_FOUND; + DeviceMgtClientException ex = DeviceManagementExceptionHandler.handleClientException(error, "device123"); + + Assert.assertEquals(ex.getErrorCode(), error.getCode()); + Assert.assertTrue(ex.getDescription().contains("device123")); + Assert.assertNull(ex.getCause()); + } + + @Test + public void testHandleClientExceptionWithCause() { + + ErrorMessage error = ErrorMessage.ERROR_INVALID_DEVICE_SIGNATURE; + Throwable cause = new RuntimeException("sig error"); + DeviceMgtClientException ex = DeviceManagementExceptionHandler.handleClientException(error, cause, "reg123"); + + Assert.assertEquals(ex.getErrorCode(), error.getCode()); + Assert.assertTrue(ex.getDescription().contains("reg123")); + Assert.assertEquals(ex.getCause(), cause); + } + + @Test + public void testHandleServerExceptionWithCause() { + + ErrorMessage error = ErrorMessage.ERROR_WHILE_VERIFYING_SIGNATURE; + Throwable cause = new RuntimeException("verify error"); + DeviceMgtServerException ex = DeviceManagementExceptionHandler.handleServerException(error, cause, "reg456"); + + Assert.assertEquals(ex.getErrorCode(), error.getCode()); + Assert.assertTrue(ex.getDescription().contains("reg456")); + Assert.assertEquals(ex.getCause(), cause); + } + + @Test + public void testHandleServerExceptionNoCause() { + + ErrorMessage error = ErrorMessage.ERROR_WHILE_REGISTERING_DEVICE; + DeviceMgtServerException ex = DeviceManagementExceptionHandler.handleServerException(error); + + Assert.assertEquals(ex.getErrorCode(), error.getCode()); + Assert.assertNotNull(ex.getDescription()); + Assert.assertNull(ex.getCause()); + } + + @Test + public void testDescriptionSubstitutionFormatting() { + + ErrorMessage error = ErrorMessage.ERROR_REGISTRATION_CONTEXT_NOT_FOUND; + String regId = "test-reg-id-999"; + DeviceMgtClientException ex = DeviceManagementExceptionHandler.handleClientException(error, regId); + + Assert.assertEquals(ex.getErrorCode(), error.getCode()); + Assert.assertTrue(ex.getDescription().contains(regId), + "Description should contain substituted value: " + regId); + } + + @Test + public void testMessageIsPreserved() { + + ErrorMessage error = ErrorMessage.ERROR_DEVICE_NOT_FOUND; + DeviceMgtClientException ex = DeviceManagementExceptionHandler.handleClientException(error, "d1"); + + Assert.assertEquals(ex.getMessage(), error.getMessage()); + } +} diff --git a/components/device-mgt/org.wso2.carbon.identity.device.mgt/src/test/resources/dbscripts/h2.sql b/components/device-mgt/org.wso2.carbon.identity.device.mgt/src/test/resources/dbscripts/h2.sql new file mode 100644 index 000000000000..251910c7574c --- /dev/null +++ b/components/device-mgt/org.wso2.carbon.identity.device.mgt/src/test/resources/dbscripts/h2.sql @@ -0,0 +1,22 @@ +CREATE TABLE IF NOT EXISTS IDN_DEVICE ( + ID CHAR(36) NOT NULL, + DEVICE_NAME VARCHAR(255) NOT NULL, + DEVICE_MODEL VARCHAR(255), + PUBLIC_KEY VARCHAR(4096) NOT NULL, + STATUS VARCHAR(20) NOT NULL DEFAULT 'ACTIVE', + REGISTERED_AT TIMESTAMP NOT NULL, + METADATA VARCHAR(4096) NULL, + TENANT_ID INTEGER NOT NULL, + PRIMARY KEY (ID) +); + +CREATE TABLE IF NOT EXISTS IDN_USER_DEVICE ( + DEVICE_ID CHAR(36) NOT NULL, + USER_ID VARCHAR(255) NOT NULL, + TENANT_ID INTEGER NOT NULL, + PRIMARY KEY (DEVICE_ID), + FOREIGN KEY (DEVICE_ID) REFERENCES IDN_DEVICE (ID) ON DELETE CASCADE +); + +CREATE INDEX IF NOT EXISTS IDX_IUD_USER_TENANT ON IDN_USER_DEVICE (USER_ID, TENANT_ID); +CREATE INDEX IF NOT EXISTS IDX_IDV_STATUS ON IDN_DEVICE (STATUS, TENANT_ID); diff --git a/components/device-mgt/org.wso2.carbon.identity.device.mgt/src/test/resources/repository/conf/carbon.xml b/components/device-mgt/org.wso2.carbon.identity.device.mgt/src/test/resources/repository/conf/carbon.xml new file mode 100644 index 000000000000..a5a1a6470cbc --- /dev/null +++ b/components/device-mgt/org.wso2.carbon.identity.device.mgt/src/test/resources/repository/conf/carbon.xml @@ -0,0 +1,686 @@ + + + + + + + + WSO2 Identity Server + + + IS + + + 5.3.0 + + + localhost + + + localhost + + + local:/${carbon.context}/services/ + + + + + + + IdentityServer + + + + + + + org.wso2.carbon + + + / + + + + + + + + + 15 + + + + + + + + + 0 + + + + + 9999 + + 11111 + + + + + + 10389 + + 8000 + + + + + + 10500 + + + + + + + + + org.wso2.carbon.tomcat.jndi.CarbonJavaURLContextFactory + + + + + + + + + java + + + + + + + + + + false + + + false + + + 600 + + + + false + + + + + + + + 30 + + + + + + + + + 15 + + + + + + ${carbon.home}/repository/deployment/server/ + + + 15 + + + ${carbon.home}/repository/conf/axis2/axis2.xml + + + 30000 + + + ${carbon.home}/repository/deployment/client/ + + ${carbon.home}/repository/conf/axis2/axis2_client.xml + + true + + + + + + + + + + admin + Default Administrator Role + + + user + Default User Role + + + + + + + + + + + + ${carbon.home}/repository/resources/security/wso2carbon.jks + + JKS + + wso2carbon + + wso2carbon + + wso2carbon + + + + + + ${carbon.home}/repository/resources/security/client-truststore.jks + + JKS + + wso2carbon + + + + + + + + + + + + + + + + + + + UserManager + + + false + + org.wso2.carbon.identity.provider.AttributeCallbackHandler + + + org.wso2.carbon.identity.sts.store.DBTokenStore + + + true + allow + + + + + + + claim_mgt_menu + identity_mgt_emailtemplate_menu + identity_security_questions_menu + + + + ${carbon.home}/tmp/work + + + + + + true + + + 10 + + + 30 + + + + + + 100 + + + + keystore + certificate + * + + org.wso2.carbon.ui.transports.fileupload.AnyFileUploadExecutor + + + + + jarZip + + org.wso2.carbon.ui.transports.fileupload.JarZipUploadExecutor + + + + dbs + + org.wso2.carbon.ui.transports.fileupload.DBSFileUploadExecutor + + + + tools + + org.wso2.carbon.ui.transports.fileupload.ToolsFileUploadExecutor + + + + toolsAny + + org.wso2.carbon.ui.transports.fileupload.ToolsAnyFileUploadExecutor + + + + + + + + + + info + org.wso2.carbon.core.transports.util.InfoProcessor + + + wsdl + org.wso2.carbon.core.transports.util.Wsdl11Processor + + + wsdl2 + org.wso2.carbon.core.transports.util.Wsdl20Processor + + + xsd + org.wso2.carbon.core.transports.util.XsdProcessor + + + + + + false + false + true + svn + http://svnrepo.example.com/repos/ + username + password + true + + + + + + + + + + + + + + + ${require.carbon.servlet} + + + + + true + + + + + + + default repository + http://product-dist.wso2.com/p2/carbon/releases/wilkes/ + + + + + + + + true + + + + + + true + + diff --git a/components/device-mgt/org.wso2.carbon.identity.device.mgt/src/test/resources/repository/conf/identity/identity.xml b/components/device-mgt/org.wso2.carbon.identity.device.mgt/src/test/resources/repository/conf/identity/identity.xml new file mode 100644 index 000000000000..07de6831dbf4 --- /dev/null +++ b/components/device-mgt/org.wso2.carbon.identity.device.mgt/src/test/resources/repository/conf/identity/identity.xml @@ -0,0 +1,743 @@ + + + + + + + + + jdbc/WSO2IdentityDB + + + + + true + true + 0 + + true + 20160 + 1140 + + + true + 720 + + + + + + + 15 + 20160 + + + + + + ${carbon.home}/conf/keystores + SunX509 + SunX509 + + + + SelfAndManaged + CertValidate + + + + + + + + + + + ${carbon.protocol}://${carbon.host}:${carbon.management.port}/openidserver + ${carbon.protocol}://${carbon.host}:${carbon.management.port}/openid + ${carbon.protocol}://${carbon.host}:${carbon.management.port}/authenticationendpoint/openid_login.do + + + false + + 7200 + + false + + + + + + + + + + + + + + + + + + + + + + -1 + -1 + -1 + -1 + + + ${carbon.protocol}://${carbon.host}:${carbon.management.port}/oauth/request-token + ${carbon.protocol}://${carbon.host}:${carbon.management.port}/oauth/authorize-url + ${carbon.protocol}://${carbon.host}:${carbon.management.port}/oauth/access-token + ${carbon.protocol}://${carbon.host}:${carbon.management.port}/oauth2/authorize + ${carbon.protocol}://${carbon.host}:${carbon.management.port}/oauth2/token + ${carbon.protocol}://${carbon.host}:${carbon.management.port}/oauth2/revoke + ${carbon.protocol}://${carbon.host}:${carbon.management.port}/oauth2/introspect + ${carbon.protocol}://${carbon.host}:${carbon.management.port}/oauth2/userinfo + ${carbon.protocol}://${carbon.host}:${carbon.management.port}/oidc/checksession + ${carbon.protocol}://${carbon.host}:${carbon.management.port}/oidc/logout + ${carbon.protocol}://${carbon.host}:${carbon.management.port}/authenticationendpoint/oauth2_authz.do + ${carbon.protocol}://${carbon.host}:${carbon.management.port}/authenticationendpoint/oauth2_error.do + ${carbon.protocol}://${carbon.host}:${carbon.management.port}/authenticationendpoint/oauth2_consent.do + ${carbon.protocol}://${carbon.host}:${carbon.management.port}/authenticationendpoint/oauth2_logout_consent.do + ${carbon.protocol}://${carbon.host}:${carbon.management.port}/authenticationendpoint/oauth2_logout.do + + ${carbon.protocol}://${carbon.host}:${carbon.management.port}/.well-known/webfinger + + + ${carbon.protocol}://${carbon.host}:${carbon.management.port}/identity/connect/register + ${carbon.protocol}://${carbon.host}:${carbon.management.port}/oauth2/jwks + ${carbon.protocol}://${carbon.host}:${carbon.management.port}/oauth2/oidcdiscovery + + + 300 + + 3600 + + 3600 + + 84600 + + 300 + + false + + true + + org.wso2.carbon.identity.oauth.tokenprocessor.PlainTextPersistenceProcessor + + + + false + + + + + + token + org.wso2.carbon.identity.oauth2.authz.handlers.AccessTokenResponseTypeHandler + + + code + org.wso2.carbon.identity.oauth2.authz.handlers.CodeResponseTypeHandler + + + id_token + org.wso2.carbon.identity.oauth2.authz.handlers.IDTokenResponseTypeHandler + + + id_token token + org.wso2.carbon.identity.oauth2.authz.handlers.IDTokenTokenResponseTypeHandler + + + + + + authorization_code + org.wso2.carbon.identity.oauth2.token.handlers.grant.AuthorizationCodeGrantHandler + + + password + org.wso2.carbon.identity.oauth2.token.handlers.grant.PasswordGrantHandler + + + refresh_token + org.wso2.carbon.identity.oauth2.token.handlers.grant.RefreshGrantHandler + + + client_credentials + org.wso2.carbon.identity.oauth2.token.handlers.grant.ClientCredentialsGrantHandler + + + urn:ietf:params:oauth:grant-type:saml2-bearer + org.wso2.carbon.identity.oauth2.token.handlers.grant.saml.SAML2BearerGrantHandler + + + iwa:ntlm + org.wso2.carbon.identity.oauth2.token.handlers.grant.iwa.ntlm.NTLMAuthenticationGrantHandler + + + idTokenNotAllowedGrantType + org.wso2.carbon.identity.oauth2.token.handlers.grant.idTokenNotAllowedGrantHandler + false + + + + + + + + + false + + + + false + + + + false + org.wso2.carbon.identity.oauth2.authcontext.JWTTokenGenerator + org.wso2.carbon.identity.oauth2.authcontext.DefaultClaimsRetriever + http://wso2.org/claims + SHA256withRSA + 10 + + + + + + org.wso2.carbon.identity.openidconnect.DefaultIDTokenBuilder + SHA256withRSA + + + + + + ${carbon.protocol}://${carbon.host}:${carbon.management.port}/oauth2/token + org.wso2.carbon.identity.openidconnect.DefaultOIDCClaimsCallbackHandler + 3600 + org.wso2.carbon.identity.oauth.endpoint.user.impl.UserInfoUserStoreClaimRetriever + org.wso2.carbon.identity.oauth.endpoint.user.impl.UserInforRequestDefaultValidator + org.wso2.carbon.identity.oauth.endpoint.user.impl.UserInfoISAccessTokenValidator + org.wso2.carbon.identity.oauth.endpoint.user.impl.UserInfoJSONResponseBuilder + false + + + + + + + + gtalk + talk.google.com + 5222 + gmail.com + multifactor1@gmail.com + wso2carbon + + + + + + 157680000 + 157680000 + ${carbon.host} + + ${carbon.protocol}://${carbon.host}:${carbon.management.port}/samlsso + ${carbon.protocol}://${carbon.host}:${carbon.management.port}/authenticationendpoint/samlsso_logout.do + ${carbon.protocol}://${carbon.host}:${carbon.management.port}/authenticationendpoint/samlsso_notification.do + 5 + 60000 + + false + http://wso2.org/claims + org.wso2.carbon.identity.sso.saml.builders.assertion.ExtendedDefaultAssertionBuilder + + org.wso2.carbon.identity.sso.saml.builders.encryption.DefaultSSOEncrypter + org.wso2.carbon.identity.sso.saml.builders.signature.DefaultSSOSigner + org.wso2.carbon.identity.sso.saml.validators.SAML2HTTPRedirectDeflateSignatureValidator + + + + 5 + false + http://www.w3.org/2000/09/xmldsig#rsa-sha1 + http://www.w3.org/2000/09/xmldsig#sha1 + true + + + + + ${carbon.protocol}://${carbon.host}:${carbon.management.port}/services/wso2carbon-sts + + + + + ${carbon.protocol}://${carbon.host}:${carbon.management.port}/passivests + ${carbon.protocol}://${carbon.host}:${carbon.management.port}/authenticationendpoint/retry.do + org.wso2.carbon.identity.sts.passive.utils.NoPersistenceTokenStore + true + + + + + false + ${Ports.ThriftEntitlementReceivePort} + 10000 + + ${carbon.home}/repository/resources/security/wso2carbon.jks + wso2carbon + + + ${carbon.host} + + + + + + ${carbon.protocol}://${carbon.host}:${carbon.management.port}/wso2/scim/Users + ${carbon.protocol}://${carbon.host}:${carbon.management.port}/wso2/scim/Groups + + + 5 + + + 10 + local://services + + + + + + + + + + + + + org.wso2.carbon.identity.governance.store.JDBCIdentityDataStore + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + /permission/admin/manage/identity/identitymgt + + + + + + /permission/admin/manage/identity/usermgt/view + + + /permission/admin/manage/identity/usermgt/view + + + + /permission/admin/manage/identity/configmgt/list + + + + /permission/admin/manage/identity/configmgt/add + + + /permission/admin/manage/identity/configmgt/update + + + + /permission/admin/manage/identity/configmgt/delete + + + + /permission/admin/manage/identity/configmgt/add + + + /permission/admin/manage/identity/configmgt/update + + + + /permission/admin/manage/identity/configmgt/delete + + + + /permission/admin/manage/identity/configmgt/add + + + /permission/admin/manage/identity/configmgt/update + + + + /permission/admin/manage/identity/configmgt/delete + + + + + + + /permission/admin/manage/identity/consentmgt/add + + + + /permission/admin/manage/identity/consentmgt/delete + + + + /permission/admin/manage/identity/consentmgt/add + + + + /permission/admin/manage/identity/consentmgt/delete + + + + /permission/admin/manage/identity/consentmgt/add + + + + /permission/admin/manage/identity/consentmgt/delete + + + + /permission/admin/manage/identity/identitymgt + + + + /permission/admin/manage/identity/applicationmgt/create + + + /permission/admin/manage/identity/applicationmgt/delete + + + /permission/admin/manage/identity/applicationmgt/update + + + /permission/admin/manage/identity/applicationmgt/view + + + /permission/admin/manage/identity/applicationmgt/delete + + + /permission/admin/manage/identity/applicationmgt/create + + + /permission/admin/manage/identity/applicationmgt/view + + + /permission/admin/manage/identity/pep + + + /permission/admin/manage/identity/usermgt/create + + + /permission/admin/manage/identity/usermgt/list + + + /permission/admin/manage/identity/rolemgt/create + + + /permission/admin/manage/identity/rolemgt/view + + + /permission/admin/manage/identity/usermgt/view + + + /permission/admin/manage/identity/usermgt/update + + + /permission/admin/manage/identity/usermgt/update + + + /permission/admin/manage/identity/usermgt/delete + + + /permission/admin/manage/identity/rolemgt/view + + + /permission/admin/manage/identity/rolemgt/update + + + /permission/admin/manage/identity/rolemgt/update + + + /permission/admin/manage/identity/rolemgt/delete + + + /permission/admin/login + + + /permission/admin/manage/identity/usermgt/delete + + + /permission/admin/login + + + /permission/admin/login + + + /permission/admin/manage/identity/usermgt/create + + + + + + + + + /permission/admin/manage/identity/usermgt + + + /permission/admin/manage/identity/applicationmgt + + + + + + + /permission/admin/manage/identity/usermgt/update + + + + + + /permission/admin/manage/humantask/viewtasks + + + /permission/admin/login + + + /permission/admin/manage/identity/usermgt + + + /permission/admin/manage/identity/ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + /api/identity/user/v0.9 + /api/identity/recovery/v0.9 + /oauth2 + /api/identity/entitlement + + + /identity/(.*) + + + + + + applications,connections + + + + 300 + diff --git a/components/device-mgt/org.wso2.carbon.identity.device.mgt/src/test/resources/testng.xml b/components/device-mgt/org.wso2.carbon.identity.device.mgt/src/test/resources/testng.xml new file mode 100644 index 000000000000..ffeec1c566c5 --- /dev/null +++ b/components/device-mgt/org.wso2.carbon.identity.device.mgt/src/test/resources/testng.xml @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + diff --git a/components/device-mgt/pom.xml b/components/device-mgt/pom.xml new file mode 100644 index 000000000000..98d6e0a24a36 --- /dev/null +++ b/components/device-mgt/pom.xml @@ -0,0 +1,56 @@ + + + + + + + org.wso2.carbon.identity.framework + identity-framework + 7.11.153-SNAPSHOT + ../../pom.xml + + + 4.0.0 + device-mgt + pom + WSO2 Carbon - Device Management Aggregator Module + + Aggregates device management modules for generic device registration. + + http://wso2.org + + + org.wso2.carbon.identity.device.mgt + + + + + + org.wso2.carbon.identity.framework + org.wso2.carbon.identity.device.mgt + ${project.version} + + + + + + diff --git a/features/identity-core/org.wso2.carbon.identity.core.server.feature/resources/dbscripts/db2.sql b/features/identity-core/org.wso2.carbon.identity.core.server.feature/resources/dbscripts/db2.sql index e97fdf3c105e..731e00c53de1 100644 --- a/features/identity-core/org.wso2.carbon.identity.core.server.feature/resources/dbscripts/db2.sql +++ b/features/identity-core/org.wso2.carbon.identity.core.server.feature/resources/dbscripts/db2.sql @@ -3183,3 +3183,29 @@ CREATE INDEX IDX_CONSUMER_KEY_STATE ON IDN_OAUTH2_REFRESH_TOKEN (CONSUMER_KEY_ID / CREATE INDEX IDX_CONSUMER_USER_SCOPE_IDP ON IDN_OAUTH2_REFRESH_TOKEN (CONSUMER_KEY_ID, AUTHZ_USER, TENANT_ID, USER_DOMAIN, TOKEN_SCOPE_HASH, TOKEN_STATE, IDP_ID) / + +-- IDN_DEVICE -- +CREATE TABLE IDN_DEVICE ( + ID CHAR(36) NOT NULL, + DEVICE_NAME VARCHAR(255) NOT NULL, + DEVICE_MODEL VARCHAR(255), + PUBLIC_KEY VARCHAR(4096) NOT NULL, + STATUS VARCHAR(20) NOT NULL DEFAULT 'ACTIVE', + REGISTERED_AT TIMESTAMP NOT NULL, + METADATA VARCHAR(4096), + TENANT_ID INTEGER NOT NULL, + PRIMARY KEY (ID) +) +/ +CREATE TABLE IDN_USER_DEVICE ( + DEVICE_ID CHAR(36) NOT NULL, + USER_ID VARCHAR(255) NOT NULL, + TENANT_ID INTEGER NOT NULL, + PRIMARY KEY (DEVICE_ID), + FOREIGN KEY (DEVICE_ID) REFERENCES IDN_DEVICE (ID) ON DELETE CASCADE +) +/ +CREATE INDEX IDX_IDV_STATUS ON IDN_DEVICE (STATUS, TENANT_ID) +/ +CREATE INDEX IDX_IUD_USER_TENANT ON IDN_USER_DEVICE (USER_ID, TENANT_ID) +/ diff --git a/features/identity-core/org.wso2.carbon.identity.core.server.feature/resources/dbscripts/h2.sql b/features/identity-core/org.wso2.carbon.identity.core.server.feature/resources/dbscripts/h2.sql index e4424c873059..9f24f427fdf9 100644 --- a/features/identity-core/org.wso2.carbon.identity.core.server.feature/resources/dbscripts/h2.sql +++ b/features/identity-core/org.wso2.carbon.identity.core.server.feature/resources/dbscripts/h2.sql @@ -2110,3 +2110,27 @@ CREATE INDEX IDX_CONSUMER_USER_SCOPE_IDP ON IDN_OAUTH2_REFRESH_TOKEN (CONSUMER_K -- WORKFLOWS -- CREATE INDEX IDX_WF_APPROVAL_STATE ON WF_WORKFLOW_APPROVAL_STATE(EVENT_ID,WORKFLOW_ID); CREATE INDEX IDX_WF_APPROVAL_RELATION ON WF_WORKFLOW_APPROVAL_RELATION(TASK_ID, APPROVER_TYPE, APPROVER_NAME); + +-- IDN_DEVICE -- +CREATE TABLE IF NOT EXISTS IDN_DEVICE ( + ID CHAR(36) NOT NULL, + DEVICE_NAME VARCHAR(255) NOT NULL, + DEVICE_MODEL VARCHAR(255), + PUBLIC_KEY VARCHAR(4096) NOT NULL, + STATUS VARCHAR(20) NOT NULL DEFAULT 'ACTIVE', + REGISTERED_AT TIMESTAMP NOT NULL, + METADATA VARCHAR(4096), + TENANT_ID INTEGER NOT NULL, + PRIMARY KEY (ID) +); + +CREATE TABLE IF NOT EXISTS IDN_USER_DEVICE ( + DEVICE_ID CHAR(36) NOT NULL, + USER_ID VARCHAR(255) NOT NULL, + TENANT_ID INTEGER NOT NULL, + PRIMARY KEY (DEVICE_ID), + FOREIGN KEY (DEVICE_ID) REFERENCES IDN_DEVICE (ID) ON DELETE CASCADE +); + +CREATE INDEX IDX_IDV_STATUS ON IDN_DEVICE (STATUS, TENANT_ID); +CREATE INDEX IDX_IUD_USER_TENANT ON IDN_USER_DEVICE (USER_ID, TENANT_ID); diff --git a/features/identity-core/org.wso2.carbon.identity.core.server.feature/resources/dbscripts/mssql.sql b/features/identity-core/org.wso2.carbon.identity.core.server.feature/resources/dbscripts/mssql.sql index dfd5fe9720a9..a791fc58639b 100644 --- a/features/identity-core/org.wso2.carbon.identity.core.server.feature/resources/dbscripts/mssql.sql +++ b/features/identity-core/org.wso2.carbon.identity.core.server.feature/resources/dbscripts/mssql.sql @@ -2318,3 +2318,27 @@ GO CREATE TRIGGER API_RESOURCE_DELETE_TRIGGER ON API_RESOURCE INSTEAD OF DELETE AS BEGIN DELETE FROM AUTHORIZED_API WHERE API_ID IN (SELECT ID FROM DELETED) DELETE FROM API_RESOURCE WHERE ID IN (SELECT ID FROM deleted) END; GO + +-- IDN_DEVICE -- +CREATE TABLE IDN_DEVICE ( + ID CHAR(36) NOT NULL, + DEVICE_NAME VARCHAR(255) NOT NULL, + DEVICE_MODEL VARCHAR(255), + PUBLIC_KEY VARCHAR(4096) NOT NULL, + STATUS VARCHAR(20) NOT NULL DEFAULT 'ACTIVE', + REGISTERED_AT DATETIME NOT NULL, + METADATA VARCHAR(4096), + TENANT_ID INTEGER NOT NULL, + PRIMARY KEY (ID) +); + +CREATE TABLE IDN_USER_DEVICE ( + DEVICE_ID CHAR(36) NOT NULL, + USER_ID VARCHAR(255) NOT NULL, + TENANT_ID INTEGER NOT NULL, + PRIMARY KEY (DEVICE_ID), + FOREIGN KEY (DEVICE_ID) REFERENCES IDN_DEVICE (ID) ON DELETE CASCADE +); + +CREATE INDEX IDX_IDV_STATUS ON IDN_DEVICE (STATUS, TENANT_ID); +CREATE INDEX IDX_IUD_USER_TENANT ON IDN_USER_DEVICE (USER_ID, TENANT_ID); diff --git a/features/identity-core/org.wso2.carbon.identity.core.server.feature/resources/dbscripts/mysql-cluster.sql b/features/identity-core/org.wso2.carbon.identity.core.server.feature/resources/dbscripts/mysql-cluster.sql index 8a4db2322261..69eb5fa3ebc5 100644 --- a/features/identity-core/org.wso2.carbon.identity.core.server.feature/resources/dbscripts/mysql-cluster.sql +++ b/features/identity-core/org.wso2.carbon.identity.core.server.feature/resources/dbscripts/mysql-cluster.sql @@ -2325,3 +2325,27 @@ CREATE INDEX IDX_REFRESH_TOKEN_HASH ON IDN_OAUTH2_REFRESH_TOKEN (REFRESH_TOKEN_H CREATE INDEX IDX_AUTHZ_USER_TENANT_DOMAIN_STATE ON IDN_OAUTH2_REFRESH_TOKEN (AUTHZ_USER, TENANT_ID, USER_DOMAIN, TOKEN_STATE); CREATE INDEX IDX_CONSUMER_KEY_STATE ON IDN_OAUTH2_REFRESH_TOKEN (CONSUMER_KEY_ID, TOKEN_STATE); CREATE INDEX IDX_CONSUMER_USER_SCOPE_IDP ON IDN_OAUTH2_REFRESH_TOKEN (CONSUMER_KEY_ID, AUTHZ_USER, TENANT_ID, USER_DOMAIN, TOKEN_SCOPE_HASH, TOKEN_STATE, IDP_ID); + +-- IDN_DEVICE -- +CREATE TABLE IF NOT EXISTS IDN_DEVICE ( + ID CHAR(36) NOT NULL, + DEVICE_NAME VARCHAR(255) NOT NULL, + DEVICE_MODEL VARCHAR(255), + PUBLIC_KEY VARCHAR(4096) NOT NULL, + STATUS VARCHAR(20) NOT NULL DEFAULT 'ACTIVE', + REGISTERED_AT TIMESTAMP NOT NULL, + METADATA VARCHAR(4096), + TENANT_ID INTEGER NOT NULL, + PRIMARY KEY (ID) +)ENGINE NDB; + +CREATE TABLE IF NOT EXISTS IDN_USER_DEVICE ( + DEVICE_ID CHAR(36) NOT NULL, + USER_ID VARCHAR(255) NOT NULL, + TENANT_ID INTEGER NOT NULL, + PRIMARY KEY (DEVICE_ID), + FOREIGN KEY (DEVICE_ID) REFERENCES IDN_DEVICE (ID) ON DELETE CASCADE +)ENGINE NDB; + +CREATE INDEX IDX_IDV_STATUS ON IDN_DEVICE (STATUS, TENANT_ID); +CREATE INDEX IDX_IUD_USER_TENANT ON IDN_USER_DEVICE (USER_ID, TENANT_ID); diff --git a/features/identity-core/org.wso2.carbon.identity.core.server.feature/resources/dbscripts/mysql.sql b/features/identity-core/org.wso2.carbon.identity.core.server.feature/resources/dbscripts/mysql.sql index a769a5544062..e04ca5fee466 100644 --- a/features/identity-core/org.wso2.carbon.identity.core.server.feature/resources/dbscripts/mysql.sql +++ b/features/identity-core/org.wso2.carbon.identity.core.server.feature/resources/dbscripts/mysql.sql @@ -2137,3 +2137,27 @@ CREATE INDEX IDX_REFRESH_TOKEN_HASH ON IDN_OAUTH2_REFRESH_TOKEN (REFRESH_TOKEN_H CREATE INDEX IDX_AUTHZ_USER_TENANT_DOMAIN_STATE ON IDN_OAUTH2_REFRESH_TOKEN (AUTHZ_USER, TENANT_ID, USER_DOMAIN, TOKEN_STATE); CREATE INDEX IDX_CONSUMER_KEY_STATE ON IDN_OAUTH2_REFRESH_TOKEN (CONSUMER_KEY_ID, TOKEN_STATE); CREATE INDEX IDX_CONSUMER_USER_SCOPE_IDP ON IDN_OAUTH2_REFRESH_TOKEN (CONSUMER_KEY_ID, AUTHZ_USER, TENANT_ID, USER_DOMAIN, TOKEN_SCOPE_HASH, TOKEN_STATE, IDP_ID); + +-- IDN_DEVICE -- +CREATE TABLE IF NOT EXISTS IDN_DEVICE ( + ID CHAR(36) NOT NULL, + DEVICE_NAME VARCHAR(255) NOT NULL, + DEVICE_MODEL VARCHAR(255), + PUBLIC_KEY VARCHAR(4096) NOT NULL, + STATUS VARCHAR(20) NOT NULL DEFAULT 'ACTIVE', + REGISTERED_AT TIMESTAMP NOT NULL, + METADATA VARCHAR(4096), + TENANT_ID INTEGER NOT NULL, + PRIMARY KEY (ID) +)DEFAULT CHARACTER SET latin1 ENGINE INNODB; + +CREATE TABLE IF NOT EXISTS IDN_USER_DEVICE ( + DEVICE_ID CHAR(36) NOT NULL, + USER_ID VARCHAR(255) NOT NULL, + TENANT_ID INTEGER NOT NULL, + PRIMARY KEY (DEVICE_ID), + FOREIGN KEY (DEVICE_ID) REFERENCES IDN_DEVICE (ID) ON DELETE CASCADE +)DEFAULT CHARACTER SET latin1 ENGINE INNODB; + +CREATE INDEX IDX_IDV_STATUS ON IDN_DEVICE (STATUS, TENANT_ID); +CREATE INDEX IDX_IUD_USER_TENANT ON IDN_USER_DEVICE (USER_ID, TENANT_ID); diff --git a/features/identity-core/org.wso2.carbon.identity.core.server.feature/resources/dbscripts/oracle.sql b/features/identity-core/org.wso2.carbon.identity.core.server.feature/resources/dbscripts/oracle.sql index f6f1d0e64c5d..1bfa51cbe1df 100644 --- a/features/identity-core/org.wso2.carbon.identity.core.server.feature/resources/dbscripts/oracle.sql +++ b/features/identity-core/org.wso2.carbon.identity.core.server.feature/resources/dbscripts/oracle.sql @@ -3336,3 +3336,29 @@ CREATE INDEX IDX_CONSUMER_KEY_STATE ON IDN_OAUTH2_REFRESH_TOKEN (CONSUMER_KEY_ID / CREATE INDEX IDX_CONSUMER_USER_SCOPE_IDP ON IDN_OAUTH2_REFRESH_TOKEN (CONSUMER_KEY_ID, AUTHZ_USER, TENANT_ID, USER_DOMAIN, TOKEN_SCOPE_HASH, TOKEN_STATE, IDP_ID) / + +-- IDN_DEVICE -- +CREATE TABLE IDN_DEVICE ( + ID CHAR(36) NOT NULL, + DEVICE_NAME VARCHAR2(255) NOT NULL, + DEVICE_MODEL VARCHAR2(255), + PUBLIC_KEY VARCHAR2(4000) NOT NULL, + STATUS VARCHAR2(20) DEFAULT 'ACTIVE' NOT NULL, + REGISTERED_AT TIMESTAMP NOT NULL, + METADATA VARCHAR2(4000), + TENANT_ID INTEGER NOT NULL, + PRIMARY KEY (ID) +) +/ +CREATE TABLE IDN_USER_DEVICE ( + DEVICE_ID CHAR(36) NOT NULL, + USER_ID VARCHAR2(255) NOT NULL, + TENANT_ID INTEGER NOT NULL, + PRIMARY KEY (DEVICE_ID), + FOREIGN KEY (DEVICE_ID) REFERENCES IDN_DEVICE (ID) ON DELETE CASCADE +) +/ +CREATE INDEX IDX_IDV_STATUS ON IDN_DEVICE (STATUS, TENANT_ID) +/ +CREATE INDEX IDX_IUD_USER_TENANT ON IDN_USER_DEVICE (USER_ID, TENANT_ID) +/ diff --git a/features/identity-core/org.wso2.carbon.identity.core.server.feature/resources/dbscripts/oracle_rac.sql b/features/identity-core/org.wso2.carbon.identity.core.server.feature/resources/dbscripts/oracle_rac.sql index 97dec658d71e..06c639cdbc73 100644 --- a/features/identity-core/org.wso2.carbon.identity.core.server.feature/resources/dbscripts/oracle_rac.sql +++ b/features/identity-core/org.wso2.carbon.identity.core.server.feature/resources/dbscripts/oracle_rac.sql @@ -3236,3 +3236,29 @@ CREATE INDEX IDX_CONSUMER_KEY_STATE ON IDN_OAUTH2_REFRESH_TOKEN (CONSUMER_KEY_ID / CREATE INDEX IDX_CONSUMER_USER_SCOPE_IDP ON IDN_OAUTH2_REFRESH_TOKEN (CONSUMER_KEY_ID, AUTHZ_USER, TENANT_ID, USER_DOMAIN, TOKEN_SCOPE_HASH, TOKEN_STATE, IDP_ID) / + +-- IDN_DEVICE -- +CREATE TABLE IDN_DEVICE ( + ID CHAR(36) NOT NULL, + DEVICE_NAME VARCHAR2(255) NOT NULL, + DEVICE_MODEL VARCHAR2(255), + PUBLIC_KEY VARCHAR2(4000) NOT NULL, + STATUS VARCHAR2(20) DEFAULT 'ACTIVE' NOT NULL, + REGISTERED_AT TIMESTAMP NOT NULL, + METADATA VARCHAR2(4000), + TENANT_ID INTEGER NOT NULL, + PRIMARY KEY (ID) +) +/ +CREATE TABLE IDN_USER_DEVICE ( + DEVICE_ID CHAR(36) NOT NULL, + USER_ID VARCHAR2(255) NOT NULL, + TENANT_ID INTEGER NOT NULL, + PRIMARY KEY (DEVICE_ID), + FOREIGN KEY (DEVICE_ID) REFERENCES IDN_DEVICE (ID) ON DELETE CASCADE +) +/ +CREATE INDEX IDX_IDV_STATUS ON IDN_DEVICE (STATUS, TENANT_ID) +/ +CREATE INDEX IDX_IUD_USER_TENANT ON IDN_USER_DEVICE (USER_ID, TENANT_ID) +/ diff --git a/features/identity-core/org.wso2.carbon.identity.core.server.feature/resources/dbscripts/postgresql.sql b/features/identity-core/org.wso2.carbon.identity.core.server.feature/resources/dbscripts/postgresql.sql index 2ef282070a35..582bd624cfad 100644 --- a/features/identity-core/org.wso2.carbon.identity.core.server.feature/resources/dbscripts/postgresql.sql +++ b/features/identity-core/org.wso2.carbon.identity.core.server.feature/resources/dbscripts/postgresql.sql @@ -2229,3 +2229,27 @@ CREATE INDEX IDX_REFRESH_TOKEN_HASH ON IDN_OAUTH2_REFRESH_TOKEN (REFRESH_TOKEN_H CREATE INDEX IDX_AUTHZ_USER_TENANT_DOMAIN_STATE ON IDN_OAUTH2_REFRESH_TOKEN (AUTHZ_USER, TENANT_ID, USER_DOMAIN, TOKEN_STATE); CREATE INDEX IDX_CONSUMER_KEY_STATE ON IDN_OAUTH2_REFRESH_TOKEN (CONSUMER_KEY_ID, TOKEN_STATE); CREATE INDEX IDX_CONSUMER_USER_SCOPE_IDP ON IDN_OAUTH2_REFRESH_TOKEN (CONSUMER_KEY_ID, AUTHZ_USER, TENANT_ID, USER_DOMAIN, TOKEN_SCOPE_HASH, TOKEN_STATE, IDP_ID); + +-- IDN_DEVICE -- +CREATE TABLE IF NOT EXISTS IDN_DEVICE ( + ID CHAR(36) NOT NULL, + DEVICE_NAME VARCHAR(255) NOT NULL, + DEVICE_MODEL VARCHAR(255), + PUBLIC_KEY VARCHAR(4096) NOT NULL, + STATUS VARCHAR(20) NOT NULL DEFAULT 'ACTIVE', + REGISTERED_AT TIMESTAMP NOT NULL, + METADATA VARCHAR(4096), + TENANT_ID INTEGER NOT NULL, + PRIMARY KEY (ID) +); + +CREATE TABLE IF NOT EXISTS IDN_USER_DEVICE ( + DEVICE_ID CHAR(36) NOT NULL, + USER_ID VARCHAR(255) NOT NULL, + TENANT_ID INTEGER NOT NULL, + PRIMARY KEY (DEVICE_ID), + FOREIGN KEY (DEVICE_ID) REFERENCES IDN_DEVICE (ID) ON DELETE CASCADE +); + +CREATE INDEX IDX_IDV_STATUS ON IDN_DEVICE (STATUS, TENANT_ID); +CREATE INDEX IDX_IUD_USER_TENANT ON IDN_USER_DEVICE (USER_ID, TENANT_ID); diff --git a/pom.xml b/pom.xml index 63f704c39dab..96cdb361d4e8 100644 --- a/pom.xml +++ b/pom.xml @@ -72,6 +72,7 @@ components/client-attestation-mgt components/trusted-app-mgt components/rule-mgt + components/device-mgt components/webhook-mgt components/action-mgt components/ai-services-mgt From c94a00853397a85a7ab70e144c3cf04f95d7f7bd Mon Sep 17 00:00:00 2001 From: kaviska Date: Sun, 12 Jul 2026 10:12:14 +0530 Subject: [PATCH 2/8] Refine device management component - Remove the device registration protocol (initiate/verify, challenge cache and signature verification) from this component. It now lives with its only consumer in the device registration component. - Introduce Device.Status (ACTIVE, INACTIVE) with activateDevice() and deactivateDevice(), replacing the hardcoded status string. Device listing by user now returns only active devices, while admin listings return all. - Remove the unbounded getAllDevices(); the paginated getDevices() and getDeviceCount() pair covers the use case. - Guard against a null device after the post-update re-fetch, so a device removed mid-operation surfaces as DEVICE_NOT_FOUND instead of an NPE. - Add a cache layer (DeviceCache + CacheBackedDeviceManagementDAO) for device lookups by ID, with invalidation on update, status change and delete. --- .../device/mgt/api/constant/ErrorMessage.java | 18 -- .../identity/device/mgt/api/model/Device.java | 16 +- .../model/DeviceRegistrationInitiation.java | 60 ---- .../api/service/DeviceManagementService.java | 104 +++---- .../mgt/internal/cache/DeviceCache.java | 47 +++ .../mgt/internal/cache/DeviceCacheEntry.java | 63 ++++ .../mgt/internal/cache/DeviceCacheKey.java | 67 ++++ .../cache/DeviceRegistrationCache.java | 46 --- .../cache/DeviceRegistrationCacheEntry.java | 52 ---- .../cache/DeviceRegistrationCacheKey.java | 72 ----- .../cache/DeviceRegistrationContext.java | 77 ----- .../constant/DeviceMgtSQLConstants.java | 9 +- .../mgt/internal/dao/DeviceManagementDAO.java | 22 +- .../impl/CacheBackedDeviceManagementDAO.java | 209 +++++++++++++ .../dao/impl/DeviceManagementDAOImpl.java | 70 +++-- .../impl/DeviceManagementServiceImpl.java | 180 +++-------- .../util/DeviceManagementAuditLogger.java | 13 +- .../CacheBackedDeviceManagementDAOTest.java | 229 ++++++++++++++ .../mgt/dao/DeviceManagementDAOImplTest.java | 103 ++++-- .../DeviceManagementServiceImplTest.java | 292 +++++++----------- .../DeviceManagementExceptionHandlerTest.java | 27 +- .../src/test/resources/testng.xml | 1 + 22 files changed, 1007 insertions(+), 770 deletions(-) delete mode 100644 components/device-mgt/org.wso2.carbon.identity.device.mgt/src/main/java/org/wso2/carbon/identity/device/mgt/api/model/DeviceRegistrationInitiation.java create mode 100644 components/device-mgt/org.wso2.carbon.identity.device.mgt/src/main/java/org/wso2/carbon/identity/device/mgt/internal/cache/DeviceCache.java create mode 100644 components/device-mgt/org.wso2.carbon.identity.device.mgt/src/main/java/org/wso2/carbon/identity/device/mgt/internal/cache/DeviceCacheEntry.java create mode 100644 components/device-mgt/org.wso2.carbon.identity.device.mgt/src/main/java/org/wso2/carbon/identity/device/mgt/internal/cache/DeviceCacheKey.java delete mode 100644 components/device-mgt/org.wso2.carbon.identity.device.mgt/src/main/java/org/wso2/carbon/identity/device/mgt/internal/cache/DeviceRegistrationCache.java delete mode 100644 components/device-mgt/org.wso2.carbon.identity.device.mgt/src/main/java/org/wso2/carbon/identity/device/mgt/internal/cache/DeviceRegistrationCacheEntry.java delete mode 100644 components/device-mgt/org.wso2.carbon.identity.device.mgt/src/main/java/org/wso2/carbon/identity/device/mgt/internal/cache/DeviceRegistrationCacheKey.java delete mode 100644 components/device-mgt/org.wso2.carbon.identity.device.mgt/src/main/java/org/wso2/carbon/identity/device/mgt/internal/cache/DeviceRegistrationContext.java create mode 100644 components/device-mgt/org.wso2.carbon.identity.device.mgt/src/main/java/org/wso2/carbon/identity/device/mgt/internal/dao/impl/CacheBackedDeviceManagementDAO.java create mode 100644 components/device-mgt/org.wso2.carbon.identity.device.mgt/src/test/java/org/wso2/carbon/identity/device/mgt/dao/CacheBackedDeviceManagementDAOTest.java diff --git a/components/device-mgt/org.wso2.carbon.identity.device.mgt/src/main/java/org/wso2/carbon/identity/device/mgt/api/constant/ErrorMessage.java b/components/device-mgt/org.wso2.carbon.identity.device.mgt/src/main/java/org/wso2/carbon/identity/device/mgt/api/constant/ErrorMessage.java index 635d925ebc26..039820b5c98d 100644 --- a/components/device-mgt/org.wso2.carbon.identity.device.mgt/src/main/java/org/wso2/carbon/identity/device/mgt/api/constant/ErrorMessage.java +++ b/components/device-mgt/org.wso2.carbon.identity.device.mgt/src/main/java/org/wso2/carbon/identity/device/mgt/api/constant/ErrorMessage.java @@ -27,20 +27,6 @@ public enum ErrorMessage { "No registered device found for the given device id: %s."), ERROR_INVALID_DEVICE_FIELD("DM-60002", "Invalid request.", "%s is empty or invalid."), - ERROR_DEVICE_ALREADY_REGISTERED("DM-60003", "Device already registered.", - "A device with the same public key is already registered for user: %s."), - ERROR_REGISTRATION_CONTEXT_NOT_FOUND("DM-60004", "Registration context not found.", - "No pending registration found for registration ID: %s. The context may have expired."), - ERROR_INVALID_DEVICE_SIGNATURE("DM-60005", "Invalid device signature.", - "The device signature verification failed for registration ID: %s."), - - ERROR_USER_NOT_IDENTIFIED("DM-60006", "User not identified.", - "Cannot initiate device registration: no authenticated user found in the flow context."), - ERROR_DEVICE_POLICY_NOT_COMPLIANT("DM-60007", "Device not compliant.", - "Device does not comply with policy '%s'. Failed fields: %s."), - ERROR_DEVICE_DATA_REQUIRED("DM-60008", "Device data required.", - "A compliance policy is configured for this executor but no device data was submitted. " + - "Send device attributes as a JSON object under the 'deviceData' key."), ERROR_WHILE_REGISTERING_DEVICE("DM-65001", "Error while registering device.", "Error while persisting device registration in the system."), @@ -50,10 +36,6 @@ public enum ErrorMessage { "Error while updating device in the system."), ERROR_WHILE_DELETING_DEVICE("DM-65004", "Error while deleting device.", "Error while deleting device from the system."), - ERROR_WHILE_VERIFYING_SIGNATURE("DM-65005", "Error while verifying device signature.", - "An unexpected error occurred during signature verification for registration ID: %s."), - ERROR_WHILE_EVALUATING_POLICY("DM-65006", "Error while evaluating device policy.", - "An error occurred while evaluating policy '%s'."), ERROR_USER_ID_REQUIRED("DM-65007", "User identifier required.", "Cannot persist device: a valid user identifier (userId) was not set before persistence."); diff --git a/components/device-mgt/org.wso2.carbon.identity.device.mgt/src/main/java/org/wso2/carbon/identity/device/mgt/api/model/Device.java b/components/device-mgt/org.wso2.carbon.identity.device.mgt/src/main/java/org/wso2/carbon/identity/device/mgt/api/model/Device.java index 460c7c2a2a36..35dc9d5b7a3c 100644 --- a/components/device-mgt/org.wso2.carbon.identity.device.mgt/src/main/java/org/wso2/carbon/identity/device/mgt/api/model/Device.java +++ b/components/device-mgt/org.wso2.carbon.identity.device.mgt/src/main/java/org/wso2/carbon/identity/device/mgt/api/model/Device.java @@ -25,12 +25,20 @@ */ public class Device { + /** + * Device Status Enum. + */ + public enum Status { + ACTIVE, + INACTIVE + } + private final String id; private final String userId; private final String deviceName; private final String deviceModel; private final String publicKey; - private final String status; + private final Status status; private final Timestamp registeredAt; private final String metadata; @@ -101,7 +109,7 @@ public String getPublicKey() { * * @return Device status. */ - public String getStatus() { + public Status getStatus() { return status; } @@ -136,7 +144,7 @@ public static class Builder { private String deviceName; private String deviceModel; private String publicKey; - private String status = "ACTIVE"; + private Status status = Status.ACTIVE; private Timestamp registeredAt; private String metadata; @@ -229,7 +237,7 @@ public Builder publicKey(String publicKey) { * @param status Device status. * @return Builder instance. */ - public Builder status(String status) { + public Builder status(Status status) { this.status = status; return this; diff --git a/components/device-mgt/org.wso2.carbon.identity.device.mgt/src/main/java/org/wso2/carbon/identity/device/mgt/api/model/DeviceRegistrationInitiation.java b/components/device-mgt/org.wso2.carbon.identity.device.mgt/src/main/java/org/wso2/carbon/identity/device/mgt/api/model/DeviceRegistrationInitiation.java deleted file mode 100644 index 9226f94c4307..000000000000 --- a/components/device-mgt/org.wso2.carbon.identity.device.mgt/src/main/java/org/wso2/carbon/identity/device/mgt/api/model/DeviceRegistrationInitiation.java +++ /dev/null @@ -1,60 +0,0 @@ -/* - * Copyright (c) 2026, WSO2 LLC. (http://www.wso2.com). - * - * WSO2 LLC. licenses this file to you 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. - */ - -package org.wso2.carbon.identity.device.mgt.api.model; - -/** - * Response model returned when a device registration is initiated. - */ -public class DeviceRegistrationInitiation { - - private final String registrationId; - private final String challenge; - - /** - * Creates an initiation response. - * - * @param registrationId Registration identifier. - * @param challenge Base64url encoded challenge. - */ - public DeviceRegistrationInitiation(String registrationId, String challenge) { - - this.registrationId = registrationId; - this.challenge = challenge; - } - - /** - * Returns the registration identifier. - * - * @return Registration identifier. - */ - public String getRegistrationId() { - - return registrationId; - } - - /** - * Returns the challenge. - * - * @return Challenge. - */ - public String getChallenge() { - - return challenge; - } -} diff --git a/components/device-mgt/org.wso2.carbon.identity.device.mgt/src/main/java/org/wso2/carbon/identity/device/mgt/api/service/DeviceManagementService.java b/components/device-mgt/org.wso2.carbon.identity.device.mgt/src/main/java/org/wso2/carbon/identity/device/mgt/api/service/DeviceManagementService.java index e7232c1b96c0..bec06a0feb53 100644 --- a/components/device-mgt/org.wso2.carbon.identity.device.mgt/src/main/java/org/wso2/carbon/identity/device/mgt/api/service/DeviceManagementService.java +++ b/components/device-mgt/org.wso2.carbon.identity.device.mgt/src/main/java/org/wso2/carbon/identity/device/mgt/api/service/DeviceManagementService.java @@ -1,8 +1,25 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (http://www.wso2.com). + * + * WSO2 LLC. licenses this file to you 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. + */ + package org.wso2.carbon.identity.device.mgt.api.service; import org.wso2.carbon.identity.device.mgt.api.exception.DeviceMgtException; import org.wso2.carbon.identity.device.mgt.api.model.Device; -import org.wso2.carbon.identity.device.mgt.api.model.DeviceRegistrationInitiation; import java.util.List; @@ -11,48 +28,8 @@ */ public interface DeviceManagementService { - /** - * Phase 1 of the two-phase registration protocol. - * Generates a cryptographically random challenge and stores it in the distributed cache - * keyed by the returned registrationId. The client SDK must sign the challenge with its - * private key and return the signature in Phase 2. - * - * @param username Username of the registering user. - * @param tenantDomain Tenant domain. - * @return DeviceRegistrationInitiation containing registrationId and challenge (base64url). - */ - DeviceRegistrationInitiation initiateDeviceRegistration(String username, String tenantDomain) - throws DeviceMgtException; - - /** - * Verifies the device registration challenge-response without persisting to the database. - * Used during registration flows where the user does not yet have a provisioned userId — - * the caller stores the returned object in the flow context and defers the DB write to - * {@link #persistDevice(Device, String)} once UserProvisioningExecutor has run. - * - * @param registrationId Opaque token returned by initiateDeviceRegistration. - * @param publicKey Base64-encoded EC public key (X.509/SubjectPublicKeyInfo DER). - * @param signature Base64-encoded ECDSA signature over the challenge bytes. - * @param deviceName Human-readable name for the device. - * @param deviceModel Hardware model string (nullable). - * @param metadata Optional JSON string for extensible attributes (nullable). - * @param tenantDomain Tenant domain. - * @return A Device whose userId is unset — caller must set the real userId before - * calling {@link #persistDevice(Device, String)}. - */ - Device verifyDeviceRegistration( - String registrationId, - String publicKey, - String signature, - String deviceName, - String deviceModel, - String metadata, - String tenantDomain) throws DeviceMgtException; - /** * Persists a pre-verified {@link Device} to the database. - * Counterpart to {@link #verifyDeviceRegistration}: call this after replacing the placeholder - * userId with the real provisioned userId. * * @param device The verified device to persist. * @param tenantDomain Tenant domain. @@ -60,7 +37,8 @@ Device verifyDeviceRegistration( void persistDevice(Device device, String tenantDomain) throws DeviceMgtException; /** - * Retrieves a device by its UUID. + * Retrieves a device by its UUID. Returns the device regardless of its status (ACTIVE or + * INACTIVE) — this is an admin/tenant-scoped lookup, not a filtered "my devices" view. * * @param deviceId UUID of the device (IDN_DEVICE.ID). * @param tenantDomain Tenant domain. @@ -70,7 +48,8 @@ Device getDeviceById(String deviceId, String tenantDomain) throws DeviceMgtException; /** - * Retrieves all ACTIVE devices registered by a user. + * Retrieves all ACTIVE devices registered by a user. This is the user-facing "my devices" + * list: devices deactivated via {@link #deactivateDevice(String, String)} are excluded. * * @param userId WSO2 user identifier. * @param tenantDomain Tenant domain. @@ -80,16 +59,9 @@ List getDevicesByUserId(String userId, String tenantDomain) throws DeviceMgtException; /** - * Retrieves all devices registered in the tenant. - * - * @param tenantDomain Tenant domain. - * @return List of all Device objects. Empty list if none found. - */ - List getAllDevices(String tenantDomain) - throws DeviceMgtException; - - /** - * Retrieves a page of devices registered in the tenant, ordered by registration time (newest first). + * Retrieves a page of devices registered in the tenant, ordered by registration time (newest + * first). Returns devices of any status (ACTIVE or INACTIVE) — this is an admin/tenant-wide + * view, not filtered like {@link #getDevicesByUserId}. * * @param tenantDomain Tenant domain. * @param offset Number of records to skip. @@ -100,7 +72,8 @@ List getDevices(String tenantDomain, int offset, int limit) throws DeviceMgtException; /** - * Counts all devices registered in the tenant. + * Counts all devices registered in the tenant, regardless of status (ACTIVE or INACTIVE) — + * this is an admin/tenant-wide count, not filtered like {@link #getDevicesByUserId}. * * @param tenantDomain Tenant domain. * @return Total number of devices in the tenant. @@ -119,6 +92,29 @@ int getDeviceCount(String tenantDomain) Device updateDeviceName(String deviceId, String deviceName, String tenantDomain) throws DeviceMgtException; + /** + * Activates a device, setting its status to {@link Device.Status#ACTIVE}. An activated device + * reappears in {@link #getDevicesByUserId(String, String)} results. + * + * @param deviceId UUID of the device. + * @param tenantDomain Tenant domain. + * @return The updated Device. + * @throws DeviceMgtException If the device does not exist or the update fails. + */ + Device activateDevice(String deviceId, String tenantDomain) throws DeviceMgtException; + + /** + * Deactivates a device, setting its status to {@link Device.Status#INACTIVE}. A deactivated + * device is excluded from {@link #getDevicesByUserId(String, String)} results, but remains + * visible via {@link #getDeviceById} and {@link #getDevices}. + * + * @param deviceId UUID of the device. + * @param tenantDomain Tenant domain. + * @return The updated Device. + * @throws DeviceMgtException If the device does not exist or the update fails. + */ + Device deactivateDevice(String deviceId, String tenantDomain) throws DeviceMgtException; + /** * Deletes (hard delete) a device registration record. * diff --git a/components/device-mgt/org.wso2.carbon.identity.device.mgt/src/main/java/org/wso2/carbon/identity/device/mgt/internal/cache/DeviceCache.java b/components/device-mgt/org.wso2.carbon.identity.device.mgt/src/main/java/org/wso2/carbon/identity/device/mgt/internal/cache/DeviceCache.java new file mode 100644 index 000000000000..78fb39bffd2d --- /dev/null +++ b/components/device-mgt/org.wso2.carbon.identity.device.mgt/src/main/java/org/wso2/carbon/identity/device/mgt/internal/cache/DeviceCache.java @@ -0,0 +1,47 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (http://www.wso2.com). + * + * WSO2 LLC. licenses this file to you 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. + */ + +package org.wso2.carbon.identity.device.mgt.internal.cache; + +import org.wso2.carbon.identity.core.cache.BaseCache; +import org.wso2.carbon.utils.CarbonUtils; + +/** + * Cache for Device Management. + */ +public class DeviceCache extends BaseCache { + + private static final String CACHE_NAME = "DeviceCache"; + private static final DeviceCache INSTANCE = new DeviceCache(); + + private DeviceCache() { + + super(CACHE_NAME); + } + + /** + * Returns the cache singleton instance. + * + * @return Cache singleton. + */ + public static DeviceCache getInstance() { + + CarbonUtils.checkSecurity(); + return INSTANCE; + } +} diff --git a/components/device-mgt/org.wso2.carbon.identity.device.mgt/src/main/java/org/wso2/carbon/identity/device/mgt/internal/cache/DeviceCacheEntry.java b/components/device-mgt/org.wso2.carbon.identity.device.mgt/src/main/java/org/wso2/carbon/identity/device/mgt/internal/cache/DeviceCacheEntry.java new file mode 100644 index 000000000000..8ea29f8d711f --- /dev/null +++ b/components/device-mgt/org.wso2.carbon.identity.device.mgt/src/main/java/org/wso2/carbon/identity/device/mgt/internal/cache/DeviceCacheEntry.java @@ -0,0 +1,63 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (http://www.wso2.com). + * + * WSO2 LLC. licenses this file to you 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. + */ + +package org.wso2.carbon.identity.device.mgt.internal.cache; + +import org.wso2.carbon.identity.core.cache.CacheEntry; +import org.wso2.carbon.identity.device.mgt.api.model.Device; + +/** + * Cache entry for Device Management. + * This class is used to store a Device object in cache. + */ +public class DeviceCacheEntry extends CacheEntry { + + private static final long serialVersionUID = 1L; + + private Device device; + + /** + * Creates a cache entry wrapping the given device. + * + * @param device Device to be cached. + */ + public DeviceCacheEntry(Device device) { + + this.device = device; + } + + /** + * Returns the cached device. + * + * @return Cached device. + */ + public Device getDevice() { + + return device; + } + + /** + * Sets the cached device. + * + * @param device Device to be cached. + */ + public void setDevice(Device device) { + + this.device = device; + } +} diff --git a/components/device-mgt/org.wso2.carbon.identity.device.mgt/src/main/java/org/wso2/carbon/identity/device/mgt/internal/cache/DeviceCacheKey.java b/components/device-mgt/org.wso2.carbon.identity.device.mgt/src/main/java/org/wso2/carbon/identity/device/mgt/internal/cache/DeviceCacheKey.java new file mode 100644 index 000000000000..64f92193d698 --- /dev/null +++ b/components/device-mgt/org.wso2.carbon.identity.device.mgt/src/main/java/org/wso2/carbon/identity/device/mgt/internal/cache/DeviceCacheKey.java @@ -0,0 +1,67 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (http://www.wso2.com). + * + * WSO2 LLC. licenses this file to you 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. + */ + +package org.wso2.carbon.identity.device.mgt.internal.cache; + +import org.wso2.carbon.identity.core.cache.CacheKey; + +/** + * Cache key for Device Management. + * This class is used to store Device ID in cache. + */ +public class DeviceCacheKey extends CacheKey { + + private static final long serialVersionUID = 1L; + + private final String deviceId; + + /** + * Creates a cache key for the given device identifier. + * + * @param deviceId Device identifier. + */ + public DeviceCacheKey(String deviceId) { + + this.deviceId = deviceId; + } + + /** + * Returns the device identifier. + * + * @return Device identifier. + */ + public String getDeviceId() { + + return deviceId; + } + + @Override + public boolean equals(Object o) { + + if (!(o instanceof DeviceCacheKey)) { + return false; + } + return deviceId.equals(((DeviceCacheKey) o).getDeviceId()); + } + + @Override + public int hashCode() { + + return deviceId.hashCode(); + } +} diff --git a/components/device-mgt/org.wso2.carbon.identity.device.mgt/src/main/java/org/wso2/carbon/identity/device/mgt/internal/cache/DeviceRegistrationCache.java b/components/device-mgt/org.wso2.carbon.identity.device.mgt/src/main/java/org/wso2/carbon/identity/device/mgt/internal/cache/DeviceRegistrationCache.java deleted file mode 100644 index 8818335c9dba..000000000000 --- a/components/device-mgt/org.wso2.carbon.identity.device.mgt/src/main/java/org/wso2/carbon/identity/device/mgt/internal/cache/DeviceRegistrationCache.java +++ /dev/null @@ -1,46 +0,0 @@ -/* -* Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com) All Rights Reserved. -* -* WSO2 LLC. licenses this file to you 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. -*/ - -package org.wso2.carbon.identity.device.mgt.internal.cache; - -import org.wso2.carbon.identity.core.cache.BaseCache; - -/** - * Distributed cache for temporary registration contexts. - */ -public class DeviceRegistrationCache - extends BaseCache { - - private static final String CACHE_NAME = "DeviceRegistrationCache"; - private static final DeviceRegistrationCache INSTANCE = new DeviceRegistrationCache(); - - private DeviceRegistrationCache() { - super(CACHE_NAME); - } - - /** - * Returns the cache singleton instance. - * - * @return Cache singleton. - */ - public static DeviceRegistrationCache getInstance() { - - return INSTANCE; - } -} - diff --git a/components/device-mgt/org.wso2.carbon.identity.device.mgt/src/main/java/org/wso2/carbon/identity/device/mgt/internal/cache/DeviceRegistrationCacheEntry.java b/components/device-mgt/org.wso2.carbon.identity.device.mgt/src/main/java/org/wso2/carbon/identity/device/mgt/internal/cache/DeviceRegistrationCacheEntry.java deleted file mode 100644 index 8c5807d1c457..000000000000 --- a/components/device-mgt/org.wso2.carbon.identity.device.mgt/src/main/java/org/wso2/carbon/identity/device/mgt/internal/cache/DeviceRegistrationCacheEntry.java +++ /dev/null @@ -1,52 +0,0 @@ -/* -* Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com) All Rights Reserved. -* -* WSO2 LLC. licenses this file to you 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. -*/ - -package org.wso2.carbon.identity.device.mgt.internal.cache; - -import org.wso2.carbon.identity.core.cache.CacheEntry; - -/** - * Cache entry that wraps a registration context. - */ -public class DeviceRegistrationCacheEntry extends CacheEntry { - - private static final long serialVersionUID = 1L; - - private final DeviceRegistrationContext context; - - /** - * Creates a cache entry. - * - * @param context Registration context. - */ - public DeviceRegistrationCacheEntry(DeviceRegistrationContext context) { - - this.context = context; - } - - /** - * Returns the cached context. - * - * @return Registration context. - */ - public DeviceRegistrationContext getContext() { - - return context; - } -} - diff --git a/components/device-mgt/org.wso2.carbon.identity.device.mgt/src/main/java/org/wso2/carbon/identity/device/mgt/internal/cache/DeviceRegistrationCacheKey.java b/components/device-mgt/org.wso2.carbon.identity.device.mgt/src/main/java/org/wso2/carbon/identity/device/mgt/internal/cache/DeviceRegistrationCacheKey.java deleted file mode 100644 index 31b940ba984f..000000000000 --- a/components/device-mgt/org.wso2.carbon.identity.device.mgt/src/main/java/org/wso2/carbon/identity/device/mgt/internal/cache/DeviceRegistrationCacheKey.java +++ /dev/null @@ -1,72 +0,0 @@ -/* -* Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com) All Rights Reserved. -* -* WSO2 LLC. licenses this file to you 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. -*/ - -package org.wso2.carbon.identity.device.mgt.internal.cache; - -import org.wso2.carbon.identity.core.cache.CacheKey; - -/** - * Cache key wrapper for a registration identifier. - */ -public class DeviceRegistrationCacheKey extends CacheKey { - - private static final long serialVersionUID = 1L; - - private final String registrationId; - - /** - * Creates a cache key with the registration identifier. - * - * @param registrationId Registration identifier. - */ - public DeviceRegistrationCacheKey(String registrationId) { - - this.registrationId = registrationId; - } - - /** - * Returns the registration identifier. - * - * @return Registration identifier. - */ - public String getRegistrationId() { - - return registrationId; - } - - @Override - public boolean equals(Object obj) { - - if (this == obj) { - return true; - } - if (!(obj instanceof DeviceRegistrationCacheKey)) { - return false; - } - DeviceRegistrationCacheKey other = (DeviceRegistrationCacheKey) obj; - return registrationId != null && registrationId.equals(other.registrationId); - } - - @Override - public int hashCode() { - - return registrationId != null ? registrationId.hashCode() : 0; - } -} - - diff --git a/components/device-mgt/org.wso2.carbon.identity.device.mgt/src/main/java/org/wso2/carbon/identity/device/mgt/internal/cache/DeviceRegistrationContext.java b/components/device-mgt/org.wso2.carbon.identity.device.mgt/src/main/java/org/wso2/carbon/identity/device/mgt/internal/cache/DeviceRegistrationContext.java deleted file mode 100644 index fa560f400e6d..000000000000 --- a/components/device-mgt/org.wso2.carbon.identity.device.mgt/src/main/java/org/wso2/carbon/identity/device/mgt/internal/cache/DeviceRegistrationContext.java +++ /dev/null @@ -1,77 +0,0 @@ -/* -* Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com) All Rights Reserved. -* -* WSO2 LLC. licenses this file to you 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. -*/ - -package org.wso2.carbon.identity.device.mgt.internal.cache; - -import java.io.Serializable; - -/** - * Context stored between registration initiation and completion. - */ -public class DeviceRegistrationContext implements Serializable { - - private static final long serialVersionUID = 1L; - - private final String username; - private final String challenge; - private final String tenantDomain; - - /** - * Creates a registration context. - * - * @param username Username of the registering user. - * @param challenge Registration challenge. - * @param tenantDomain Tenant domain. - */ - public DeviceRegistrationContext(String username, String challenge, String tenantDomain) { - this.username = username; - this.challenge = challenge; - this.tenantDomain = tenantDomain; - } - - /** - * Returns the username. - * - * @return Username. - */ - public String getUsername() { - - return username; - } - - /** - * Returns the challenge. - * - * @return Challenge. - */ - public String getChallenge() { - - return challenge; - } - - /** - * Returns the tenant domain. - * - * @return Tenant domain. - */ - public String getTenantDomain() { - - return tenantDomain; - } -} - diff --git a/components/device-mgt/org.wso2.carbon.identity.device.mgt/src/main/java/org/wso2/carbon/identity/device/mgt/internal/constant/DeviceMgtSQLConstants.java b/components/device-mgt/org.wso2.carbon.identity.device.mgt/src/main/java/org/wso2/carbon/identity/device/mgt/internal/constant/DeviceMgtSQLConstants.java index 32162faf11aa..18ec920e5bd2 100644 --- a/components/device-mgt/org.wso2.carbon.identity.device.mgt/src/main/java/org/wso2/carbon/identity/device/mgt/internal/constant/DeviceMgtSQLConstants.java +++ b/components/device-mgt/org.wso2.carbon.identity.device.mgt/src/main/java/org/wso2/carbon/identity/device/mgt/internal/constant/DeviceMgtSQLConstants.java @@ -87,12 +87,9 @@ public static final class Query { "UPDATE IDN_DEVICE SET DEVICE_NAME = :DEVICE_NAME; " + "WHERE ID = :ID; AND TENANT_ID = :TENANT_ID;"; - public static final String GET_ALL_DEVICES = - "SELECT D.ID, UD.USER_ID, D.DEVICE_NAME, D.DEVICE_MODEL, D.PUBLIC_KEY, D.STATUS, " + - "D.REGISTERED_AT, D.METADATA, D.TENANT_ID " + - "FROM IDN_DEVICE D " + - "INNER JOIN IDN_USER_DEVICE UD ON D.ID = UD.DEVICE_ID AND D.TENANT_ID = UD.TENANT_ID " + - "WHERE D.TENANT_ID = :TENANT_ID; ORDER BY D.REGISTERED_AT DESC"; + public static final String CHANGE_DEVICE_STATUS = + "UPDATE IDN_DEVICE SET STATUS = :STATUS; " + + "WHERE ID = :ID; AND TENANT_ID = :TENANT_ID;"; // Paginated tenant-wide device listing. Pagination syntax differs per database, so a variant // is selected at runtime based on the detected database type. diff --git a/components/device-mgt/org.wso2.carbon.identity.device.mgt/src/main/java/org/wso2/carbon/identity/device/mgt/internal/dao/DeviceManagementDAO.java b/components/device-mgt/org.wso2.carbon.identity.device.mgt/src/main/java/org/wso2/carbon/identity/device/mgt/internal/dao/DeviceManagementDAO.java index 484b18d3c632..cf0de24869b4 100644 --- a/components/device-mgt/org.wso2.carbon.identity.device.mgt/src/main/java/org/wso2/carbon/identity/device/mgt/internal/dao/DeviceManagementDAO.java +++ b/components/device-mgt/org.wso2.carbon.identity.device.mgt/src/main/java/org/wso2/carbon/identity/device/mgt/internal/dao/DeviceManagementDAO.java @@ -61,16 +61,6 @@ Device getDeviceById(String deviceId, int tenantId) List getDevicesByUserId(String userId, int tenantId) throws DeviceMgtException; - /** - * Finds all devices registered in the tenant. - * - * @param tenantId Tenant identifier. - * @return All devices in the tenant. - * @throws DeviceMgtException If retrieval fails. - */ - List getAllDevices(int tenantId) - throws DeviceMgtException; - /** * Finds a page of devices registered in the tenant, ordered by registration time (newest first). * @@ -105,6 +95,18 @@ int getDeviceCount(int tenantId) Device updateDeviceName(String deviceId, String deviceName, int tenantId) throws DeviceMgtException; + /** + * Updates the status of a device and returns the updated record. + * + * @param deviceId Device identifier. + * @param status New status for the device. + * @param tenantId Tenant identifier. + * @return Updated device. + * @throws DeviceMgtException If the status update fails. + */ + Device changeDeviceStatus(String deviceId, Device.Status status, int tenantId) + throws DeviceMgtException; + /** * Deletes a device. * diff --git a/components/device-mgt/org.wso2.carbon.identity.device.mgt/src/main/java/org/wso2/carbon/identity/device/mgt/internal/dao/impl/CacheBackedDeviceManagementDAO.java b/components/device-mgt/org.wso2.carbon.identity.device.mgt/src/main/java/org/wso2/carbon/identity/device/mgt/internal/dao/impl/CacheBackedDeviceManagementDAO.java new file mode 100644 index 000000000000..735fb6b4c0e7 --- /dev/null +++ b/components/device-mgt/org.wso2.carbon.identity.device.mgt/src/main/java/org/wso2/carbon/identity/device/mgt/internal/dao/impl/CacheBackedDeviceManagementDAO.java @@ -0,0 +1,209 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (http://www.wso2.com). + * + * WSO2 LLC. licenses this file to you 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. + */ + +package org.wso2.carbon.identity.device.mgt.internal.dao.impl; + +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; +import org.wso2.carbon.identity.device.mgt.api.exception.DeviceMgtException; +import org.wso2.carbon.identity.device.mgt.api.model.Device; +import org.wso2.carbon.identity.device.mgt.internal.cache.DeviceCache; +import org.wso2.carbon.identity.device.mgt.internal.cache.DeviceCacheEntry; +import org.wso2.carbon.identity.device.mgt.internal.cache.DeviceCacheKey; +import org.wso2.carbon.identity.device.mgt.internal.dao.DeviceManagementDAO; + +import java.util.List; + +/** + * Cache backed Device Management DAO. + * This class implements the caching on top of the data layer operations. + * This caches the Device object, keyed by device ID and tenant. List/aggregate reads + * (getDevicesByUserId, getDevices, getDeviceCount) are intentionally not cached since + * invalidating a list on every write is a different, harder problem. + */ +public class CacheBackedDeviceManagementDAO implements DeviceManagementDAO { + + private static final Log LOG = LogFactory.getLog(CacheBackedDeviceManagementDAO.class); + + private final DeviceManagementDAO deviceManagementDAO; + private final DeviceCache deviceCache; + + /** + * Wraps the given DAO with a read-through, write-invalidate cache. + * + * @param deviceManagementDAO The underlying DAO to delegate to. + */ + public CacheBackedDeviceManagementDAO(DeviceManagementDAO deviceManagementDAO) { + + this.deviceManagementDAO = deviceManagementDAO; + deviceCache = DeviceCache.getInstance(); + } + + /** + * Persists a new device. + * This method directly invokes the data layer operation — a brand-new device has nothing + * to invalidate in the cache. + * + * @param device Device to persist. + * @param tenantId Tenant identifier. + * @return Persisted device. + * @throws DeviceMgtException If persistence fails. + */ + @Override + public Device registerDevice(Device device, int tenantId) throws DeviceMgtException { + + return deviceManagementDAO.registerDevice(device, tenantId); + } + + /** + * Finds a device by id. + * This method first checks the cache for the Device object. If the Device object is not + * found in the cache, it invokes the data layer operation to get the Device and, if found, + * adds it to the cache. + * + * @param deviceId Device identifier. + * @param tenantId Tenant identifier. + * @return Device or {@code null}. + * @throws DeviceMgtException If retrieval fails. + */ + @Override + public Device getDeviceById(String deviceId, int tenantId) throws DeviceMgtException { + + DeviceCacheKey cacheKey = new DeviceCacheKey(deviceId); + DeviceCacheEntry cacheEntry = deviceCache.getValueFromCache(cacheKey, tenantId); + if (cacheEntry != null && cacheEntry.getDevice() != null) { + if (LOG.isDebugEnabled()) { + LOG.debug("Device cache hit for device id: " + deviceId + ". Returning from cache."); + } + return cacheEntry.getDevice(); + } + + Device device = deviceManagementDAO.getDeviceById(deviceId, tenantId); + if (device != null) { + if (LOG.isDebugEnabled()) { + LOG.debug("Device cache miss for device id: " + deviceId + ". Adding to cache."); + } + deviceCache.addToCacheOnRead(cacheKey, new DeviceCacheEntry(device), tenantId); + } + return device; + } + + /** + * Finds all active devices by user id. + * This method is not cached; list results are not invalidated entry-by-entry. + * + * @param userId User identifier. + * @param tenantId Tenant identifier. + * @return Active devices. + * @throws DeviceMgtException If retrieval fails. + */ + @Override + public List getDevicesByUserId(String userId, int tenantId) throws DeviceMgtException { + + return deviceManagementDAO.getDevicesByUserId(userId, tenantId); + } + + /** + * Finds a page of devices registered in the tenant, ordered by registration time (newest first). + * This method is not cached; list results are not invalidated entry-by-entry. + * + * @param tenantId Tenant identifier. + * @param offset Number of records to skip. + * @param limit Maximum number of records to return. + * @return Page of devices in the tenant. + * @throws DeviceMgtException If retrieval fails. + */ + @Override + public List getDevices(int tenantId, int offset, int limit) throws DeviceMgtException { + + return deviceManagementDAO.getDevices(tenantId, offset, limit); + } + + /** + * Counts all devices registered in the tenant. + * This method is not cached. + * + * @param tenantId Tenant identifier. + * @return Total number of devices in the tenant. + * @throws DeviceMgtException If the count fails. + */ + @Override + public int getDeviceCount(int tenantId) throws DeviceMgtException { + + return deviceManagementDAO.getDeviceCount(tenantId); + } + + /** + * Updates the name of a device. + * This method clears the cache entry upon device name update. + * + * @param deviceId Device identifier. + * @param deviceName Device name. + * @param tenantId Tenant identifier. + * @return Updated device. + * @throws DeviceMgtException If update fails. + */ + @Override + public Device updateDeviceName(String deviceId, String deviceName, int tenantId) throws DeviceMgtException { + + deviceCache.clearCacheEntry(new DeviceCacheKey(deviceId), tenantId); + if (LOG.isDebugEnabled()) { + LOG.debug("Device cache entry is cleared for device id: " + deviceId + " for device name update."); + } + return deviceManagementDAO.updateDeviceName(deviceId, deviceName, tenantId); + } + + /** + * Updates the status of a device and returns the updated record. + * This method clears the cache entry upon status change. + * + * @param deviceId Device identifier. + * @param status New status for the device. + * @param tenantId Tenant identifier. + * @return Updated device. + * @throws DeviceMgtException If the status update fails. + */ + @Override + public Device changeDeviceStatus(String deviceId, Device.Status status, int tenantId) + throws DeviceMgtException { + + deviceCache.clearCacheEntry(new DeviceCacheKey(deviceId), tenantId); + if (LOG.isDebugEnabled()) { + LOG.debug("Device cache entry is cleared for device id: " + deviceId + " for status change."); + } + return deviceManagementDAO.changeDeviceStatus(deviceId, status, tenantId); + } + + /** + * Deletes a device. + * This method clears the cache entry upon device deletion. + * + * @param deviceId Device identifier. + * @param tenantId Tenant identifier. + * @throws DeviceMgtException If deletion fails. + */ + @Override + public void deleteDevice(String deviceId, int tenantId) throws DeviceMgtException { + + deviceCache.clearCacheEntry(new DeviceCacheKey(deviceId), tenantId); + if (LOG.isDebugEnabled()) { + LOG.debug("Device cache entry is cleared for device id: " + deviceId + " for device deletion."); + } + deviceManagementDAO.deleteDevice(deviceId, tenantId); + } +} diff --git a/components/device-mgt/org.wso2.carbon.identity.device.mgt/src/main/java/org/wso2/carbon/identity/device/mgt/internal/dao/impl/DeviceManagementDAOImpl.java b/components/device-mgt/org.wso2.carbon.identity.device.mgt/src/main/java/org/wso2/carbon/identity/device/mgt/internal/dao/impl/DeviceManagementDAOImpl.java index 9516fad0df86..2471b294f605 100644 --- a/components/device-mgt/org.wso2.carbon.identity.device.mgt/src/main/java/org/wso2/carbon/identity/device/mgt/internal/dao/impl/DeviceManagementDAOImpl.java +++ b/components/device-mgt/org.wso2.carbon.identity.device.mgt/src/main/java/org/wso2/carbon/identity/device/mgt/internal/dao/impl/DeviceManagementDAOImpl.java @@ -61,7 +61,8 @@ public Device registerDevice(Device device, int tenantId) preparedStatement.setString( DeviceMgtSQLConstants.Column.DEVICE_MODEL, device.getDeviceModel()); preparedStatement.setString(DeviceMgtSQLConstants.Column.PUBLIC_KEY, device.getPublicKey()); - preparedStatement.setString(DeviceMgtSQLConstants.Column.STATUS, device.getStatus()); + preparedStatement.setString( + DeviceMgtSQLConstants.Column.STATUS, device.getStatus().name()); preparedStatement.setObject( DeviceMgtSQLConstants.Column.REGISTERED_AT, device.getRegisteredAt()); preparedStatement.setInt(DeviceMgtSQLConstants.Column.TENANT_ID, tenantId); @@ -108,7 +109,8 @@ public Device getDeviceById(String deviceId, int tenantId) .deviceName(resultSet.getString(DeviceMgtSQLConstants.Column.DEVICE_NAME)) .deviceModel(resultSet.getString(DeviceMgtSQLConstants.Column.DEVICE_MODEL)) .publicKey(resultSet.getString(DeviceMgtSQLConstants.Column.PUBLIC_KEY)) - .status(resultSet.getString(DeviceMgtSQLConstants.Column.STATUS)) + .status(Device.Status.valueOf( + resultSet.getString(DeviceMgtSQLConstants.Column.STATUS))) .registeredAt(resultSet.getTimestamp(DeviceMgtSQLConstants.Column.REGISTERED_AT)) .metadata(resultSet.getString(DeviceMgtSQLConstants.Column.METADATA)) .build(), @@ -139,7 +141,8 @@ public List getDevicesByUserId(String userId, int tenantId) .deviceName(resultSet.getString(DeviceMgtSQLConstants.Column.DEVICE_NAME)) .deviceModel(resultSet.getString(DeviceMgtSQLConstants.Column.DEVICE_MODEL)) .publicKey(resultSet.getString(DeviceMgtSQLConstants.Column.PUBLIC_KEY)) - .status(resultSet.getString(DeviceMgtSQLConstants.Column.STATUS)) + .status(Device.Status.valueOf( + resultSet.getString(DeviceMgtSQLConstants.Column.STATUS))) .registeredAt(resultSet.getTimestamp(DeviceMgtSQLConstants.Column.REGISTERED_AT)) .metadata(resultSet.getString(DeviceMgtSQLConstants.Column.METADATA)) .build(), @@ -154,34 +157,6 @@ public List getDevicesByUserId(String userId, int tenantId) } } - @Override - public List getAllDevices(int tenantId) throws DeviceMgtException { - - NamedJdbcTemplate jdbcTemplate = new NamedJdbcTemplate(IdentityDatabaseUtil.getDataSource()); - - try { - return jdbcTemplate., RuntimeException>withTransaction( - template -> template.executeQuery( - DeviceMgtSQLConstants.Query.GET_ALL_DEVICES, - (resultSet, rowNumber) -> new Device.Builder() - .id(resultSet.getString(DeviceMgtSQLConstants.Column.ID)) - .userId(resultSet.getString(DeviceMgtSQLConstants.Column.USER_ID)) - .deviceName(resultSet.getString(DeviceMgtSQLConstants.Column.DEVICE_NAME)) - .deviceModel(resultSet.getString(DeviceMgtSQLConstants.Column.DEVICE_MODEL)) - .publicKey(resultSet.getString(DeviceMgtSQLConstants.Column.PUBLIC_KEY)) - .status(resultSet.getString(DeviceMgtSQLConstants.Column.STATUS)) - .registeredAt(resultSet.getTimestamp(DeviceMgtSQLConstants.Column.REGISTERED_AT)) - .metadata(resultSet.getString(DeviceMgtSQLConstants.Column.METADATA)) - .build(), - preparedStatement -> preparedStatement.setInt( - DeviceMgtSQLConstants.Column.TENANT_ID, tenantId))); - - } catch (TransactionException e) { - throw DeviceManagementExceptionHandler.handleServerException( - ErrorMessage.ERROR_WHILE_RETRIEVING_DEVICE, e); - } - } - @Override public List getDevices(int tenantId, int offset, int limit) throws DeviceMgtException { @@ -204,7 +179,8 @@ public List getDevices(int tenantId, int offset, int limit) throws Devic .deviceName(resultSet.getString(DeviceMgtSQLConstants.Column.DEVICE_NAME)) .deviceModel(resultSet.getString(DeviceMgtSQLConstants.Column.DEVICE_MODEL)) .publicKey(resultSet.getString(DeviceMgtSQLConstants.Column.PUBLIC_KEY)) - .status(resultSet.getString(DeviceMgtSQLConstants.Column.STATUS)) + .status(Device.Status.valueOf( + resultSet.getString(DeviceMgtSQLConstants.Column.STATUS))) .registeredAt(resultSet.getTimestamp(DeviceMgtSQLConstants.Column.REGISTERED_AT)) .metadata(resultSet.getString(DeviceMgtSQLConstants.Column.METADATA)) .build(), @@ -325,6 +301,36 @@ public Device updateDeviceName(String deviceId, String deviceName, int tenantId) return getDeviceById(deviceId, tenantId); } + @Override + public Device changeDeviceStatus(String deviceId, Device.Status status, int tenantId) + throws DeviceMgtException { + + NamedJdbcTemplate jdbcTemplate = new NamedJdbcTemplate(IdentityDatabaseUtil.getDataSource()); + + try { + jdbcTemplate.withTransaction(template -> { + template.executeUpdate( + DeviceMgtSQLConstants.Query.CHANGE_DEVICE_STATUS, + preparedStatement -> { + preparedStatement.setString(DeviceMgtSQLConstants.Column.STATUS, status.name()); + preparedStatement.setString(DeviceMgtSQLConstants.Column.ID, deviceId); + preparedStatement.setInt(DeviceMgtSQLConstants.Column.TENANT_ID, tenantId); + }); + return null; + }); + + } catch (TransactionException e) { + throw DeviceManagementExceptionHandler.handleServerException( + ErrorMessage.ERROR_WHILE_UPDATING_DEVICE, e); + } + + if (LOG.isDebugEnabled()) { + LOG.debug("Device status changed to " + status.name() + " for device ID: " + deviceId); + } + + return getDeviceById(deviceId, tenantId); + } + @Override public void deleteDevice(String deviceId, int tenantId) throws DeviceMgtException { diff --git a/components/device-mgt/org.wso2.carbon.identity.device.mgt/src/main/java/org/wso2/carbon/identity/device/mgt/internal/service/impl/DeviceManagementServiceImpl.java b/components/device-mgt/org.wso2.carbon.identity.device.mgt/src/main/java/org/wso2/carbon/identity/device/mgt/internal/service/impl/DeviceManagementServiceImpl.java index b15a930bb13b..0d6d9c68251c 100644 --- a/components/device-mgt/org.wso2.carbon.identity.device.mgt/src/main/java/org/wso2/carbon/identity/device/mgt/internal/service/impl/DeviceManagementServiceImpl.java +++ b/components/device-mgt/org.wso2.carbon.identity.device.mgt/src/main/java/org/wso2/carbon/identity/device/mgt/internal/service/impl/DeviceManagementServiceImpl.java @@ -25,31 +25,14 @@ import org.wso2.carbon.identity.device.mgt.api.exception.DeviceMgtClientException; import org.wso2.carbon.identity.device.mgt.api.exception.DeviceMgtException; import org.wso2.carbon.identity.device.mgt.api.model.Device; -import org.wso2.carbon.identity.device.mgt.api.model.DeviceRegistrationInitiation; import org.wso2.carbon.identity.device.mgt.api.service.DeviceManagementService; -import org.wso2.carbon.identity.device.mgt.internal.cache.DeviceRegistrationCache; -import org.wso2.carbon.identity.device.mgt.internal.cache.DeviceRegistrationCacheEntry; -import org.wso2.carbon.identity.device.mgt.internal.cache.DeviceRegistrationCacheKey; -import org.wso2.carbon.identity.device.mgt.internal.cache.DeviceRegistrationContext; import org.wso2.carbon.identity.device.mgt.internal.dao.DeviceManagementDAO; +import org.wso2.carbon.identity.device.mgt.internal.dao.impl.CacheBackedDeviceManagementDAO; import org.wso2.carbon.identity.device.mgt.internal.dao.impl.DeviceManagementDAOImpl; import org.wso2.carbon.identity.device.mgt.internal.util.DeviceManagementAuditLogger; import org.wso2.carbon.identity.device.mgt.internal.util.DeviceManagementExceptionHandler; -import java.security.InvalidKeyException; -import java.security.KeyFactory; -import java.security.NoSuchAlgorithmException; -import java.security.PublicKey; -import java.security.SecureRandom; -import java.security.Signature; -import java.security.SignatureException; -import java.security.spec.InvalidKeySpecException; -import java.security.spec.X509EncodedKeySpec; -import java.sql.Timestamp; -import java.time.Instant; -import java.util.Base64; import java.util.List; -import java.util.UUID; /** * Default implementation of {@link DeviceManagementService}. @@ -58,12 +41,11 @@ public class DeviceManagementServiceImpl implements DeviceManagementService { private static final Log LOG = LogFactory.getLog(DeviceManagementServiceImpl.class); private static final DeviceManagementServiceImpl INSTANCE = new DeviceManagementServiceImpl(); - private static final SecureRandom SECURE_RANDOM = new SecureRandom(); private static final DeviceManagementAuditLogger AUDIT_LOGGER = new DeviceManagementAuditLogger(); private final DeviceManagementDAO deviceManagementDAO; private DeviceManagementServiceImpl() { - deviceManagementDAO = new DeviceManagementDAOImpl(); + deviceManagementDAO = new CacheBackedDeviceManagementDAO(new DeviceManagementDAOImpl()); } /** @@ -75,77 +57,6 @@ public static DeviceManagementServiceImpl getInstance() { return INSTANCE; } - @Override - public DeviceRegistrationInitiation initiateDeviceRegistration(String username, String tenantDomain) - throws DeviceMgtException { - - validateRequiredField(username, "username"); - validateRequiredField(tenantDomain, "tenantDomain"); - - // Generate 32 random bytes as the challenge, encoded as base64url without padding. - byte[] challengeBytes = new byte[32]; - SECURE_RANDOM.nextBytes(challengeBytes); - String challenge = Base64.getUrlEncoder().withoutPadding().encodeToString(challengeBytes); - - String registrationId = UUID.randomUUID().toString(); - DeviceRegistrationContext context = new DeviceRegistrationContext(username, challenge, tenantDomain); - DeviceRegistrationCacheKey cacheKey = new DeviceRegistrationCacheKey(registrationId); - DeviceRegistrationCacheEntry cacheEntry = new DeviceRegistrationCacheEntry(context); - - DeviceRegistrationCache.getInstance().addToCache(cacheKey, cacheEntry, tenantDomain); - - if (LOG.isDebugEnabled()) { - LOG.debug("Device registration initiated for user: " + username + - " in tenant: " + tenantDomain + - " with registrationId: " + registrationId); - } - return new DeviceRegistrationInitiation(registrationId, challenge); - } - - @Override - public Device verifyDeviceRegistration( - String registrationId, - String publicKey, - String signature, - String deviceName, - String deviceModel, - String metadata, - String tenantDomain) throws DeviceMgtException { - - validateRequiredField(registrationId, "registrationId"); - validateRequiredField(publicKey, "publicKey"); - validateRequiredField(signature, "signature"); - validateRequiredField(deviceName, "deviceName"); - - DeviceRegistrationCacheKey cacheKey = new DeviceRegistrationCacheKey(registrationId); - DeviceRegistrationCacheEntry cacheEntry = - DeviceRegistrationCache.getInstance().getValueFromCache(cacheKey, tenantDomain); - - if (cacheEntry == null) { - throw DeviceManagementExceptionHandler.handleClientException( - ErrorMessage.ERROR_REGISTRATION_CONTEXT_NOT_FOUND, registrationId); - } - - DeviceRegistrationContext context = cacheEntry.getContext(); - verifySignature(registrationId, context.getChallenge(), publicKey, signature); - - DeviceRegistrationCache.getInstance().clearCacheEntry(cacheKey, tenantDomain); - - if (LOG.isDebugEnabled()) { - LOG.debug("Device registration verified (not yet persisted) for user: " + context.getUsername() + - " in tenant: " + tenantDomain); - } - return new Device.Builder() - .id(registrationId) - .deviceName(deviceName) - .deviceModel(deviceModel) - .publicKey(publicKey) - .status("ACTIVE") - .registeredAt(Timestamp.from(Instant.now())) - .metadata(metadata) - .build(); - } - @Override public void persistDevice(Device device, String tenantDomain) throws DeviceMgtException { @@ -181,12 +92,6 @@ public List getDevicesByUserId(String userId, String tenantDomain) userId, IdentityTenantUtil.getTenantId(tenantDomain)); } - @Override - public List getAllDevices(String tenantDomain) throws DeviceMgtException { - - return deviceManagementDAO.getAllDevices(IdentityTenantUtil.getTenantId(tenantDomain)); - } - @Override public List getDevices(String tenantDomain, int offset, int limit) throws DeviceMgtException { @@ -210,10 +115,59 @@ public Device updateDeviceName(String deviceId, String deviceName, String tenant Device updated = deviceManagementDAO.updateDeviceName( deviceId, deviceName, IdentityTenantUtil.getTenantId(tenantDomain)); + if (updated == null) { + throw DeviceManagementExceptionHandler.handleClientException( + ErrorMessage.ERROR_DEVICE_NOT_FOUND, deviceId); + } + AUDIT_LOGGER.printAuditLog(DeviceManagementAuditLogger.Operation.UPDATE, updated); return updated; } + @Override + public Device activateDevice(String deviceId, String tenantDomain) throws DeviceMgtException { + + validateRequiredField(deviceId, "deviceId"); + validateDeviceExists(deviceId, tenantDomain); + + Device updated = deviceManagementDAO.changeDeviceStatus( + deviceId, Device.Status.ACTIVE, IdentityTenantUtil.getTenantId(tenantDomain)); + + if (updated == null) { + throw DeviceManagementExceptionHandler.handleClientException( + ErrorMessage.ERROR_DEVICE_NOT_FOUND, deviceId); + } + + AUDIT_LOGGER.printAuditLog(DeviceManagementAuditLogger.Operation.ACTIVATE, updated); + + if (LOG.isDebugEnabled()) { + LOG.debug("Device activated with ID: " + deviceId + " in tenant: " + tenantDomain); + } + return updated; + } + + @Override + public Device deactivateDevice(String deviceId, String tenantDomain) throws DeviceMgtException { + + validateRequiredField(deviceId, "deviceId"); + validateDeviceExists(deviceId, tenantDomain); + + Device updated = deviceManagementDAO.changeDeviceStatus( + deviceId, Device.Status.INACTIVE, IdentityTenantUtil.getTenantId(tenantDomain)); + + if (updated == null) { + throw DeviceManagementExceptionHandler.handleClientException( + ErrorMessage.ERROR_DEVICE_NOT_FOUND, deviceId); + } + + AUDIT_LOGGER.printAuditLog(DeviceManagementAuditLogger.Operation.DEACTIVATE, updated); + + if (LOG.isDebugEnabled()) { + LOG.debug("Device deactivated with ID: " + deviceId + " in tenant: " + tenantDomain); + } + return updated; + } + @Override public void deleteDevice(String deviceId, String tenantDomain) throws DeviceMgtException { @@ -231,38 +185,6 @@ public void deleteDevice(String deviceId, String tenantDomain) } } - private void verifySignature(String registrationId, String challenge, - String publicKeyBase64, String signatureBase64) throws DeviceMgtException { - - try { - byte[] publicKeyBytes = Base64.getDecoder().decode(publicKeyBase64); - KeyFactory keyFactory = KeyFactory.getInstance("EC"); - PublicKey publicKey = keyFactory.generatePublic(new X509EncodedKeySpec(publicKeyBytes)); - - byte[] challengeBytes = Base64.getUrlDecoder().decode(challenge); - byte[] signatureBytes = Base64.getDecoder().decode(signatureBase64); - - Signature sig = Signature.getInstance("SHA256withECDSA"); - sig.initVerify(publicKey); - sig.update(challengeBytes); - boolean valid = sig.verify(signatureBytes); - - if (!valid) { - throw DeviceManagementExceptionHandler.handleClientException( - ErrorMessage.ERROR_INVALID_DEVICE_SIGNATURE, registrationId); - } - } catch (NoSuchAlgorithmException | InvalidKeySpecException | - InvalidKeyException | SignatureException e) { - if (e instanceof SignatureException && e.getMessage() != null - && e.getMessage().contains("verification failed")) { - throw DeviceManagementExceptionHandler.handleClientException( - ErrorMessage.ERROR_INVALID_DEVICE_SIGNATURE, e, registrationId); - } - throw DeviceManagementExceptionHandler.handleServerException( - ErrorMessage.ERROR_WHILE_VERIFYING_SIGNATURE, e, registrationId); - } - } - private void validateDeviceExists(String deviceId, String tenantDomain) throws DeviceMgtException { diff --git a/components/device-mgt/org.wso2.carbon.identity.device.mgt/src/main/java/org/wso2/carbon/identity/device/mgt/internal/util/DeviceManagementAuditLogger.java b/components/device-mgt/org.wso2.carbon.identity.device.mgt/src/main/java/org/wso2/carbon/identity/device/mgt/internal/util/DeviceManagementAuditLogger.java index 7e520d49ad6f..50325d7c6889 100644 --- a/components/device-mgt/org.wso2.carbon.identity.device.mgt/src/main/java/org/wso2/carbon/identity/device/mgt/internal/util/DeviceManagementAuditLogger.java +++ b/components/device-mgt/org.wso2.carbon.identity.device.mgt/src/main/java/org/wso2/carbon/identity/device/mgt/internal/util/DeviceManagementAuditLogger.java @@ -47,10 +47,14 @@ public class DeviceManagementAuditLogger { * Print an audit log for an operation that carries the full device state. * * @param operation Operation associated with the state change. - * @param device Device to be logged. + * @param device Device to be logged, or {@code null} if unavailable. */ public void printAuditLog(Operation operation, Device device) { + if (device == null) { + printAuditLog(operation, (String) null); + return; + } JSONObject data = createAuditLogEntry(device); buildAuditLog(device.getId(), operation, data); } @@ -104,7 +108,8 @@ private JSONObject createAuditLogEntry(Device device) { device.getDeviceName() != null ? device.getDeviceName() : JSONObject.NULL); data.put(LogConstants.DEVICE_MODEL_FIELD, device.getDeviceModel() != null ? device.getDeviceModel() : JSONObject.NULL); - data.put(LogConstants.STATUS_FIELD, device.getStatus() != null ? device.getStatus() : JSONObject.NULL); + data.put(LogConstants.STATUS_FIELD, + device.getStatus() != null ? device.getStatus().name() : JSONObject.NULL); data.put(LogConstants.REGISTERED_AT_FIELD, device.getRegisteredAt() != null ? String.valueOf(device.getRegisteredAt()) : JSONObject.NULL); data.put(LogConstants.USER_ID_FIELD, device.getUserId() != null @@ -160,7 +165,9 @@ private String fingerprint(String publicKey) { public enum Operation { REGISTER("register-device"), UPDATE("update-device"), - DELETE("delete-device"); + DELETE("delete-device"), + ACTIVATE("activate-device"), + DEACTIVATE("deactivate-device"); private final String logAction; diff --git a/components/device-mgt/org.wso2.carbon.identity.device.mgt/src/test/java/org/wso2/carbon/identity/device/mgt/dao/CacheBackedDeviceManagementDAOTest.java b/components/device-mgt/org.wso2.carbon.identity.device.mgt/src/test/java/org/wso2/carbon/identity/device/mgt/dao/CacheBackedDeviceManagementDAOTest.java new file mode 100644 index 000000000000..6c74e3da7a3c --- /dev/null +++ b/components/device-mgt/org.wso2.carbon.identity.device.mgt/src/test/java/org/wso2/carbon/identity/device/mgt/dao/CacheBackedDeviceManagementDAOTest.java @@ -0,0 +1,229 @@ +/* +* Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com) All Rights Reserved. +* +* WSO2 LLC. licenses this file to you 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. +*/ + +package org.wso2.carbon.identity.device.mgt.dao; + +import org.mockito.MockedStatic; +import org.testng.annotations.AfterClass; +import org.testng.annotations.BeforeClass; +import org.testng.annotations.BeforeMethod; +import org.testng.annotations.Test; +import org.wso2.carbon.identity.common.testng.WithCarbonHome; +import org.wso2.carbon.identity.common.testng.WithRealmService; +import org.wso2.carbon.identity.core.internal.component.IdentityCoreServiceDataHolder; +import org.wso2.carbon.identity.core.util.IdentityTenantUtil; +import org.wso2.carbon.identity.device.mgt.api.exception.DeviceMgtException; +import org.wso2.carbon.identity.device.mgt.api.model.Device; +import org.wso2.carbon.identity.device.mgt.internal.cache.DeviceCache; +import org.wso2.carbon.identity.device.mgt.internal.cache.DeviceCacheEntry; +import org.wso2.carbon.identity.device.mgt.internal.cache.DeviceCacheKey; +import org.wso2.carbon.identity.device.mgt.internal.dao.DeviceManagementDAO; +import org.wso2.carbon.identity.device.mgt.internal.dao.impl.CacheBackedDeviceManagementDAO; + +import static org.mockito.Mockito.CALLS_REAL_METHODS; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.testng.Assert.assertEquals; +import static org.testng.Assert.assertNull; + +/** + * Unit tests for {@link CacheBackedDeviceManagementDAO}. + */ +@WithCarbonHome +@WithRealmService(injectToSingletons = {IdentityCoreServiceDataHolder.class}) +public class CacheBackedDeviceManagementDAOTest { + + private static final String DEVICE_ID = "deviceId"; + private static final int TENANT_ID = 1; + private static final int OTHER_TENANT_ID = 2; + private static final String TENANT_DOMAIN = "tenant1.example.com"; + private static final String OTHER_TENANT_DOMAIN = "tenant2.example.com"; + + private DeviceManagementDAO deviceManagementDAO; + private CacheBackedDeviceManagementDAO cacheBackedDeviceManagementDAO; + private DeviceCache deviceCache; + private MockedStatic identityTenantUtilMocked; + + @BeforeClass + public void setUpClass() { + + // BaseCache resolves a tenant domain per tenantId internally (for its own OSGi cache + // ownership checks); stub distinct domains so the two tenants used below are genuinely + // isolated instead of both falling back to the same default domain. + identityTenantUtilMocked = mockStatic(IdentityTenantUtil.class, CALLS_REAL_METHODS); + when(IdentityTenantUtil.getTenantDomain(TENANT_ID)).thenReturn(TENANT_DOMAIN); + when(IdentityTenantUtil.getTenantDomain(OTHER_TENANT_ID)).thenReturn(OTHER_TENANT_DOMAIN); + + deviceCache = DeviceCache.getInstance(); + } + + @AfterClass + public void tearDownClass() { + + identityTenantUtilMocked.close(); + } + + @BeforeMethod + public void setUp() { + + deviceManagementDAO = mock(DeviceManagementDAO.class); + cacheBackedDeviceManagementDAO = new CacheBackedDeviceManagementDAO(deviceManagementDAO); + + // Reset cache state so tests don't leak into each other. + deviceCache.clear(TENANT_ID); + deviceCache.clear(OTHER_TENANT_ID); + } + + @Test + public void testRegisterDeviceDoesNotTouchCache() throws DeviceMgtException { + + Device device = mock(Device.class); + when(deviceManagementDAO.registerDevice(device, TENANT_ID)).thenReturn(device); + + cacheBackedDeviceManagementDAO.registerDevice(device, TENANT_ID); + + verify(deviceManagementDAO).registerDevice(device, TENANT_ID); + assertNull(deviceCache.getValueFromCache(new DeviceCacheKey(DEVICE_ID), TENANT_ID)); + } + + @Test + public void testGetDeviceByIdCacheMissThenCacheHit() throws DeviceMgtException { + + Device device = mock(Device.class); + when(deviceManagementDAO.getDeviceById(DEVICE_ID, TENANT_ID)).thenReturn(device); + + Device firstResult = cacheBackedDeviceManagementDAO.getDeviceById(DEVICE_ID, TENANT_ID); + + assertEquals(firstResult, device); + verify(deviceManagementDAO, times(1)).getDeviceById(DEVICE_ID, TENANT_ID); + assertEquals(deviceCache.getValueFromCache(new DeviceCacheKey(DEVICE_ID), TENANT_ID).getDevice(), device); + + Device secondResult = cacheBackedDeviceManagementDAO.getDeviceById(DEVICE_ID, TENANT_ID); + + assertEquals(secondResult, device); + // The inner DAO must not be invoked again — the second read is served from the cache. + verify(deviceManagementDAO, times(1)).getDeviceById(DEVICE_ID, TENANT_ID); + } + + @Test + public void testGetDeviceByIdCacheHitWhenPrePopulated() throws DeviceMgtException { + + Device device = mock(Device.class); + deviceCache.addToCache(new DeviceCacheKey(DEVICE_ID), new DeviceCacheEntry(device), TENANT_ID); + + Device result = cacheBackedDeviceManagementDAO.getDeviceById(DEVICE_ID, TENANT_ID); + + assertEquals(result, device); + verify(deviceManagementDAO, never()).getDeviceById(DEVICE_ID, TENANT_ID); + } + + @Test + public void testGetDeviceByIdNullResultIsNotCached() throws DeviceMgtException { + + when(deviceManagementDAO.getDeviceById(DEVICE_ID, TENANT_ID)).thenReturn(null); + + Device result = cacheBackedDeviceManagementDAO.getDeviceById(DEVICE_ID, TENANT_ID); + + assertNull(result); + assertNull(deviceCache.getValueFromCache(new DeviceCacheKey(DEVICE_ID), TENANT_ID)); + } + + @Test + public void testUpdateDeviceNameInvalidatesCache() throws DeviceMgtException { + + Device cached = mock(Device.class); + deviceCache.addToCache(new DeviceCacheKey(DEVICE_ID), new DeviceCacheEntry(cached), TENANT_ID); + + cacheBackedDeviceManagementDAO.updateDeviceName(DEVICE_ID, "New Name", TENANT_ID); + + verify(deviceManagementDAO).updateDeviceName(DEVICE_ID, "New Name", TENANT_ID); + assertNull(deviceCache.getValueFromCache(new DeviceCacheKey(DEVICE_ID), TENANT_ID)); + + // The next read must miss the cache and hit the inner DAO again. + Device fresh = mock(Device.class); + when(deviceManagementDAO.getDeviceById(DEVICE_ID, TENANT_ID)).thenReturn(fresh); + Device result = cacheBackedDeviceManagementDAO.getDeviceById(DEVICE_ID, TENANT_ID); + + assertEquals(result, fresh); + verify(deviceManagementDAO).getDeviceById(DEVICE_ID, TENANT_ID); + } + + @Test + public void testChangeDeviceStatusInvalidatesCache() throws DeviceMgtException { + + Device cached = mock(Device.class); + deviceCache.addToCache(new DeviceCacheKey(DEVICE_ID), new DeviceCacheEntry(cached), TENANT_ID); + + cacheBackedDeviceManagementDAO.changeDeviceStatus(DEVICE_ID, Device.Status.INACTIVE, TENANT_ID); + + verify(deviceManagementDAO).changeDeviceStatus(DEVICE_ID, Device.Status.INACTIVE, TENANT_ID); + assertNull(deviceCache.getValueFromCache(new DeviceCacheKey(DEVICE_ID), TENANT_ID)); + + Device fresh = mock(Device.class); + when(deviceManagementDAO.getDeviceById(DEVICE_ID, TENANT_ID)).thenReturn(fresh); + Device result = cacheBackedDeviceManagementDAO.getDeviceById(DEVICE_ID, TENANT_ID); + + assertEquals(result, fresh); + verify(deviceManagementDAO).getDeviceById(DEVICE_ID, TENANT_ID); + } + + @Test + public void testDeleteDeviceInvalidatesCache() throws DeviceMgtException { + + Device cached = mock(Device.class); + deviceCache.addToCache(new DeviceCacheKey(DEVICE_ID), new DeviceCacheEntry(cached), TENANT_ID); + + cacheBackedDeviceManagementDAO.deleteDevice(DEVICE_ID, TENANT_ID); + + verify(deviceManagementDAO).deleteDevice(DEVICE_ID, TENANT_ID); + assertNull(deviceCache.getValueFromCache(new DeviceCacheKey(DEVICE_ID), TENANT_ID)); + + Device fresh = mock(Device.class); + when(deviceManagementDAO.getDeviceById(DEVICE_ID, TENANT_ID)).thenReturn(fresh); + Device result = cacheBackedDeviceManagementDAO.getDeviceById(DEVICE_ID, TENANT_ID); + + assertEquals(result, fresh); + verify(deviceManagementDAO).getDeviceById(DEVICE_ID, TENANT_ID); + } + + @Test + public void testTenantIsolation() throws DeviceMgtException { + + Device tenantDevice = mock(Device.class); + Device otherTenantDevice = mock(Device.class); + when(deviceManagementDAO.getDeviceById(DEVICE_ID, TENANT_ID)).thenReturn(tenantDevice); + when(deviceManagementDAO.getDeviceById(DEVICE_ID, OTHER_TENANT_ID)).thenReturn(otherTenantDevice); + + Device resultTenant = cacheBackedDeviceManagementDAO.getDeviceById(DEVICE_ID, TENANT_ID); + Device resultOtherTenant = cacheBackedDeviceManagementDAO.getDeviceById(DEVICE_ID, OTHER_TENANT_ID); + + assertEquals(resultTenant, tenantDevice); + assertEquals(resultOtherTenant, otherTenantDevice); + + // Each tenant's cache entry is independent — re-reading each still hits the cache, not the DAO. + cacheBackedDeviceManagementDAO.getDeviceById(DEVICE_ID, TENANT_ID); + cacheBackedDeviceManagementDAO.getDeviceById(DEVICE_ID, OTHER_TENANT_ID); + + verify(deviceManagementDAO, times(1)).getDeviceById(DEVICE_ID, TENANT_ID); + verify(deviceManagementDAO, times(1)).getDeviceById(DEVICE_ID, OTHER_TENANT_ID); + } +} diff --git a/components/device-mgt/org.wso2.carbon.identity.device.mgt/src/test/java/org/wso2/carbon/identity/device/mgt/dao/DeviceManagementDAOImplTest.java b/components/device-mgt/org.wso2.carbon.identity.device.mgt/src/test/java/org/wso2/carbon/identity/device/mgt/dao/DeviceManagementDAOImplTest.java index bf130777fdf6..ae33a5289bac 100644 --- a/components/device-mgt/org.wso2.carbon.identity.device.mgt/src/test/java/org/wso2/carbon/identity/device/mgt/dao/DeviceManagementDAOImplTest.java +++ b/components/device-mgt/org.wso2.carbon.identity.device.mgt/src/test/java/org/wso2/carbon/identity/device/mgt/dao/DeviceManagementDAOImplTest.java @@ -54,13 +54,13 @@ public class DeviceManagementDAOImplTest { @Test(priority = 1) public void testRegisterDevice() throws DeviceMgtException { - Device device = buildDevice(UUID.randomUUID().toString(), "Alice Phone", "ACTIVE"); + Device device = buildDevice(UUID.randomUUID().toString(), "Alice Phone", Device.Status.ACTIVE); Device result = deviceManagementDAO.registerDevice(device, TENANT_ID); Assert.assertNotNull(result); Assert.assertEquals(result.getId(), device.getId()); Assert.assertEquals(result.getDeviceName(), "Alice Phone"); - Assert.assertEquals(result.getStatus(), "ACTIVE"); + Assert.assertEquals(result.getStatus(), Device.Status.ACTIVE); createdDeviceId = result.getId(); } @@ -100,13 +100,13 @@ public void testGetDeviceByIdNotFound() throws DeviceMgtException { @Test(priority = 4, dependsOnMethods = {"testRegisterDevice"}) public void testGetDevicesByUserIdOnlyActive() throws DeviceMgtException { - Device revokedDevice = buildDevice(UUID.randomUUID().toString(), "Old Device", "REVOKED"); - deviceManagementDAO.registerDevice(revokedDevice, TENANT_ID); + Device inactiveDevice = buildDevice(UUID.randomUUID().toString(), "Old Device", Device.Status.INACTIVE); + deviceManagementDAO.registerDevice(inactiveDevice, TENANT_ID); List devices = deviceManagementDAO.getDevicesByUserId(TEST_USER_ID, TENANT_ID); Assert.assertEquals(devices.size(), 1); - Assert.assertEquals(devices.get(0).getStatus(), "ACTIVE"); + Assert.assertEquals(devices.get(0).getStatus(), Device.Status.ACTIVE); Assert.assertEquals(devices.get(0).getId(), createdDeviceId); } @@ -153,7 +153,7 @@ public void testRegisterDeviceFullFieldRoundTrip() throws DeviceMgtException { .deviceName("Carol Phone") .deviceModel("Pixel 8 Pro") .publicKey("pk-full-" + id) - .status("ACTIVE") + .status(Device.Status.ACTIVE) .registeredAt(Timestamp.from(Instant.now())) .metadata("{\"env\":\"test\"}") .build(); @@ -167,7 +167,7 @@ public void testRegisterDeviceFullFieldRoundTrip() throws DeviceMgtException { Assert.assertEquals(result.getDeviceName(), "Carol Phone"); Assert.assertEquals(result.getDeviceModel(), "Pixel 8 Pro"); Assert.assertEquals(result.getPublicKey(), "pk-full-" + id); - Assert.assertEquals(result.getStatus(), "ACTIVE"); + Assert.assertEquals(result.getStatus(), Device.Status.ACTIVE); Assert.assertNotNull(result.getRegisteredAt()); Assert.assertEquals(result.getMetadata(), "{\"env\":\"test\"}"); } @@ -181,31 +181,33 @@ public void testRegisterDeviceFullFieldRoundTrip() throws DeviceMgtException { public void testTenantIsolation() throws DeviceMgtException { String id = UUID.randomUUID().toString(); - deviceManagementDAO.registerDevice(buildDevice(id, "Tenant Device", "ACTIVE"), TENANT_ID); + deviceManagementDAO.registerDevice(buildDevice(id, "Tenant Device", Device.Status.ACTIVE), TENANT_ID); Device fromOtherTenant = deviceManagementDAO.getDeviceById(id, OTHER_TENANT_ID); Assert.assertNull(fromOtherTenant); - List allOtherTenant = deviceManagementDAO.getAllDevices(OTHER_TENANT_ID); + List allOtherTenant = deviceManagementDAO.getDevices(OTHER_TENANT_ID, 0, 100); long found = allOtherTenant.stream().filter(d -> d.getId().equals(id)).count(); Assert.assertEquals(found, 0); } /** - * Tests that getAllDevices returns every device registered under the tenant. + * Tests that getDevices returns every device registered under the tenant. * * @throws DeviceMgtException If the DAO operation fails. */ @Test(priority = 9) - public void testGetAllDevices() throws DeviceMgtException { + public void testGetDevicesReturnsAllForTenant() throws DeviceMgtException { String userId = "dave@example.com"; String id1 = UUID.randomUUID().toString(); String id2 = UUID.randomUUID().toString(); - deviceManagementDAO.registerDevice(buildDeviceForUser(id1, "Dave Phone 1", userId, "ACTIVE"), OTHER_TENANT_ID); - deviceManagementDAO.registerDevice(buildDeviceForUser(id2, "Dave Phone 2", userId, "ACTIVE"), OTHER_TENANT_ID); + deviceManagementDAO.registerDevice( + buildDeviceForUser(id1, "Dave Phone 1", userId, Device.Status.ACTIVE), OTHER_TENANT_ID); + deviceManagementDAO.registerDevice( + buildDeviceForUser(id2, "Dave Phone 2", userId, Device.Status.ACTIVE), OTHER_TENANT_ID); - List all = deviceManagementDAO.getAllDevices(OTHER_TENANT_ID); + List all = deviceManagementDAO.getDevices(OTHER_TENANT_ID, 0, 100); Assert.assertEquals(all.size(), 2); long count = all.stream().filter(d -> d.getUserId().equals(userId)).count(); @@ -223,8 +225,10 @@ public void testGetDevicesByUserIdMultiple() throws DeviceMgtException { String userId = "eve@example.com"; String id1 = UUID.randomUUID().toString(); String id2 = UUID.randomUUID().toString(); - deviceManagementDAO.registerDevice(buildDeviceForUser(id1, "Eve Phone 1", userId, "ACTIVE"), TENANT_ID); - deviceManagementDAO.registerDevice(buildDeviceForUser(id2, "Eve Phone 2", userId, "ACTIVE"), TENANT_ID); + deviceManagementDAO.registerDevice( + buildDeviceForUser(id1, "Eve Phone 1", userId, Device.Status.ACTIVE), TENANT_ID); + deviceManagementDAO.registerDevice( + buildDeviceForUser(id2, "Eve Phone 2", userId, Device.Status.ACTIVE), TENANT_ID); List devices = deviceManagementDAO.getDevicesByUserId(userId, TENANT_ID); @@ -254,7 +258,7 @@ public void testGetDevicesByUserIdEmpty() throws DeviceMgtException { public void testUpdateDeviceNameWrongTenantNoOp() throws DeviceMgtException { String id = UUID.randomUUID().toString(); - deviceManagementDAO.registerDevice(buildDevice(id, "Frank Phone", "ACTIVE"), TENANT_ID); + deviceManagementDAO.registerDevice(buildDevice(id, "Frank Phone", Device.Status.ACTIVE), TENANT_ID); deviceManagementDAO.updateDeviceName(id, "Frank New Name", OTHER_TENANT_ID); @@ -272,7 +276,7 @@ public void testUpdateDeviceNameWrongTenantNoOp() throws DeviceMgtException { public void testDeleteDeviceWrongTenantNoOp() throws DeviceMgtException { String id = UUID.randomUUID().toString(); - deviceManagementDAO.registerDevice(buildDevice(id, "Grace Phone", "ACTIVE"), TENANT_ID); + deviceManagementDAO.registerDevice(buildDevice(id, "Grace Phone", Device.Status.ACTIVE), TENANT_ID); deviceManagementDAO.deleteDevice(id, OTHER_TENANT_ID); @@ -281,7 +285,66 @@ public void testDeleteDeviceWrongTenantNoOp() throws DeviceMgtException { Assert.assertEquals(result.getDeviceName(), "Grace Phone"); } - private Device buildDevice(String id, String deviceName, String status) { + /** + * Tests that changeDeviceStatus(INACTIVE) removes the device from getDevicesByUserId results + * while it remains visible via getDeviceById and getDevices. + * + * @throws DeviceMgtException If the DAO operation fails. + */ + @Test(priority = 14) + public void testChangeDeviceStatusDeactivateExcludesFromUserDeviceList() throws DeviceMgtException { + + String userId = "heidi@example.com"; + String id = UUID.randomUUID().toString(); + deviceManagementDAO.registerDevice( + buildDeviceForUser(id, "Heidi Phone", userId, Device.Status.ACTIVE), TENANT_ID); + + Device updated = deviceManagementDAO.changeDeviceStatus(id, Device.Status.INACTIVE, TENANT_ID); + + Assert.assertNotNull(updated); + Assert.assertEquals(updated.getStatus(), Device.Status.INACTIVE); + + List userDevices = deviceManagementDAO.getDevicesByUserId(userId, TENANT_ID); + Assert.assertTrue(userDevices.isEmpty()); + + Device byId = deviceManagementDAO.getDeviceById(id, TENANT_ID); + Assert.assertNotNull(byId); + Assert.assertEquals(byId.getStatus(), Device.Status.INACTIVE); + + List pagedDevices = deviceManagementDAO.getDevices(TENANT_ID, 0, 100); + long foundInPage = pagedDevices.stream().filter(d -> d.getId().equals(id)).count(); + Assert.assertEquals(foundInPage, 1); + } + + /** + * Tests that changeDeviceStatus(ACTIVE) reinstates a device into getDevicesByUserId results. + * + * @throws DeviceMgtException If the DAO operation fails. + */ + @Test(priority = 15, dependsOnMethods = {"testChangeDeviceStatusDeactivateExcludesFromUserDeviceList"}) + public void testChangeDeviceStatusActivateReincludesInUserDeviceList() throws DeviceMgtException { + + String userId = "heidi@example.com"; + List beforeActivation = deviceManagementDAO.getDevicesByUserId(userId, TENANT_ID); + Assert.assertTrue(beforeActivation.isEmpty()); + + List allDevices = deviceManagementDAO.getDevices(TENANT_ID, 0, 100); + String id = allDevices.stream() + .filter(d -> d.getUserId().equals(userId)) + .findFirst() + .orElseThrow(() -> new AssertionError("Expected Heidi's device to still exist")) + .getId(); + + Device updated = deviceManagementDAO.changeDeviceStatus(id, Device.Status.ACTIVE, TENANT_ID); + + Assert.assertEquals(updated.getStatus(), Device.Status.ACTIVE); + + List userDevices = deviceManagementDAO.getDevicesByUserId(userId, TENANT_ID); + Assert.assertEquals(userDevices.size(), 1); + Assert.assertEquals(userDevices.get(0).getId(), id); + } + + private Device buildDevice(String id, String deviceName, Device.Status status) { return new Device.Builder() .id(id) @@ -295,7 +358,7 @@ private Device buildDevice(String id, String deviceName, String status) { .build(); } - private Device buildDeviceForUser(String id, String deviceName, String userId, String status) { + private Device buildDeviceForUser(String id, String deviceName, String userId, Device.Status status) { return new Device.Builder() .id(id) diff --git a/components/device-mgt/org.wso2.carbon.identity.device.mgt/src/test/java/org/wso2/carbon/identity/device/mgt/service/DeviceManagementServiceImplTest.java b/components/device-mgt/org.wso2.carbon.identity.device.mgt/src/test/java/org/wso2/carbon/identity/device/mgt/service/DeviceManagementServiceImplTest.java index b16802eab4d5..dcd63d83bd88 100644 --- a/components/device-mgt/org.wso2.carbon.identity.device.mgt/src/test/java/org/wso2/carbon/identity/device/mgt/service/DeviceManagementServiceImplTest.java +++ b/components/device-mgt/org.wso2.carbon.identity.device.mgt/src/test/java/org/wso2/carbon/identity/device/mgt/service/DeviceManagementServiceImplTest.java @@ -29,16 +29,12 @@ import org.wso2.carbon.identity.device.mgt.api.constant.ErrorMessage; import org.wso2.carbon.identity.device.mgt.api.exception.DeviceMgtException; import org.wso2.carbon.identity.device.mgt.api.model.Device; -import org.wso2.carbon.identity.device.mgt.api.model.DeviceRegistrationInitiation; import org.wso2.carbon.identity.device.mgt.internal.dao.DeviceManagementDAO; import org.wso2.carbon.identity.device.mgt.internal.service.impl.DeviceManagementServiceImpl; import java.lang.reflect.Field; -import java.security.KeyPair; -import java.security.KeyPairGenerator; -import java.security.Signature; -import java.util.Base64; -import java.util.UUID; +import java.sql.Timestamp; +import java.time.Instant; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; @@ -60,18 +56,25 @@ public class DeviceManagementServiceImplTest { private DeviceManagementServiceImpl service; private DeviceManagementDAO dao; private MockedStatic identityTenantUtilMocked; + private Field daoField; + private DeviceManagementDAO originalDao; @BeforeClass - public void setUpClass() { + public void setUpClass() throws Exception { service = DeviceManagementServiceImpl.getInstance(); identityTenantUtilMocked = mockStatic(IdentityTenantUtil.class); when(IdentityTenantUtil.getTenantId(TENANT_DOMAIN)).thenReturn(TENANT_ID); + + daoField = DeviceManagementServiceImpl.class.getDeclaredField("deviceManagementDAO"); + daoField.setAccessible(true); + originalDao = (DeviceManagementDAO) daoField.get(service); } @AfterClass - public void tearDownClass() { + public void tearDownClass() throws Exception { + daoField.set(service, originalDao); identityTenantUtilMocked.close(); } @@ -79,145 +82,80 @@ public void tearDownClass() { public void setUp() throws Exception { dao = mock(DeviceManagementDAO.class); - Field f = DeviceManagementServiceImpl.class.getDeclaredField("deviceManagementDAO"); - f.setAccessible(true); - f.set(service, dao); + daoField.set(service, dao); } @Test - public void testInitiateReturnsRegistrationIdAndChallenge() throws DeviceMgtException { - - DeviceRegistrationInitiation result = service.initiateDeviceRegistration(TEST_USERNAME, TENANT_DOMAIN); - - Assert.assertNotNull(result); - Assert.assertNotNull(result.getRegistrationId()); - Assert.assertFalse(result.getRegistrationId().isBlank()); - Assert.assertNotNull(result.getChallenge()); - Assert.assertFalse(result.getChallenge().isBlank()); - } + public void testPersistDeviceDelegatesToDao() throws Exception { - @Test - public void testInitiateGeneratesUniqueChallenges() throws DeviceMgtException { + Device device = buildDevice("d1", "alice@example.com"); + when(dao.registerDevice(any(), eq(TENANT_ID))).thenReturn(device); - DeviceRegistrationInitiation r1 = service.initiateDeviceRegistration(TEST_USERNAME, TENANT_DOMAIN); - DeviceRegistrationInitiation r2 = service.initiateDeviceRegistration(TEST_USERNAME, TENANT_DOMAIN); + service.persistDevice(device, TENANT_DOMAIN); - Assert.assertNotEquals(r1.getChallenge(), r2.getChallenge()); + verify(dao).registerDevice(any(), eq(TENANT_ID)); } @Test - public void testInitiateWithBlankUsernameThrows() { - - try { - service.initiateDeviceRegistration(" ", TENANT_DOMAIN); - Assert.fail("Expected DeviceMgtClientException"); - } catch (DeviceMgtException ex) { - Assert.assertEquals(ex.getErrorCode(), ErrorMessage.ERROR_INVALID_DEVICE_FIELD.getCode()); - } - } + public void testPersistDeviceWithoutUserIdThrows() { - @Test - public void testInitiateWithBlankTenantDomainThrows() { + Device device = buildDevice("d1", null); try { - service.initiateDeviceRegistration(TEST_USERNAME, ""); - Assert.fail("Expected DeviceMgtClientException"); + service.persistDevice(device, TENANT_DOMAIN); + Assert.fail("Expected DeviceMgtServerException"); } catch (DeviceMgtException ex) { - Assert.assertEquals(ex.getErrorCode(), ErrorMessage.ERROR_INVALID_DEVICE_FIELD.getCode()); + Assert.assertEquals(ex.getErrorCode(), ErrorMessage.ERROR_USER_ID_REQUIRED.getCode()); } } @Test - public void testVerifyWithValidSignatureSucceeds() throws Exception { + public void testGetDeviceByIdDelegatesToDao() throws Exception { - KeyPair kp = generateEcKeyPair(); - DeviceRegistrationInitiation initiation = service.initiateDeviceRegistration(TEST_USERNAME, TENANT_DOMAIN); - String sig = signChallengeB64(kp, initiation.getChallenge()); + Device device = mock(Device.class); + when(dao.getDeviceById("d1", TENANT_ID)).thenReturn(device); - Device result = service.verifyDeviceRegistration( - initiation.getRegistrationId(), publicKeyB64(kp), sig, - "Alice's iPhone", null, null, TENANT_DOMAIN); + Device result = service.getDeviceById("d1", TENANT_DOMAIN); - Assert.assertNotNull(result); - Assert.assertEquals(result.getPublicKey(), publicKeyB64(kp)); - Assert.assertEquals(result.getUserId(), TEST_USERNAME); + Assert.assertEquals(result, device); } @Test - public void testVerifyWithInvalidSignatureThrows() throws Exception { - - KeyPair kp1 = generateEcKeyPair(); - KeyPair kp2 = generateEcKeyPair(); - DeviceRegistrationInitiation initiation = service.initiateDeviceRegistration(TEST_USERNAME, TENANT_DOMAIN); - String sigFromWrongKey = signChallengeB64(kp2, initiation.getChallenge()); + public void testGetDeviceByIdWithBlankIdThrows() { try { - service.verifyDeviceRegistration( - initiation.getRegistrationId(), publicKeyB64(kp1), sigFromWrongKey, - "Alice's iPhone", null, null, TENANT_DOMAIN); + service.getDeviceById("", TENANT_DOMAIN); Assert.fail("Expected DeviceMgtClientException"); } catch (DeviceMgtException ex) { - Assert.assertEquals(ex.getErrorCode(), ErrorMessage.ERROR_INVALID_DEVICE_SIGNATURE.getCode()); + Assert.assertEquals(ex.getErrorCode(), ErrorMessage.ERROR_INVALID_DEVICE_FIELD.getCode()); } } @Test - public void testVerifyWithMissingRegistrationContextThrows() throws Exception { + public void testUpdateDeviceNameWhenDeviceMissingThrows() throws Exception { - KeyPair kp = generateEcKeyPair(); + when(dao.getDeviceById("d1", TENANT_ID)).thenReturn(null); try { - service.verifyDeviceRegistration( - UUID.randomUUID().toString(), publicKeyB64(kp), "fakeSig", - "Device", null, null, TENANT_DOMAIN); + service.updateDeviceName("d1", "New Name", TENANT_DOMAIN); Assert.fail("Expected DeviceMgtClientException"); } catch (DeviceMgtException ex) { - Assert.assertEquals(ex.getErrorCode(), ErrorMessage.ERROR_REGISTRATION_CONTEXT_NOT_FOUND.getCode()); - } - } - - @Test - public void testVerifyWithMalformedPublicKeyThrows() throws Exception { - - DeviceRegistrationInitiation initiation = service.initiateDeviceRegistration(TEST_USERNAME, TENANT_DOMAIN); - String badKey = Base64.getEncoder().encodeToString(new byte[]{1, 2, 3, 4, 5}); - String fakeSig = Base64.getEncoder().encodeToString(new byte[]{0, 0, 0, 0}); - - try { - service.verifyDeviceRegistration( - initiation.getRegistrationId(), badKey, fakeSig, - "Device", null, null, TENANT_DOMAIN); - Assert.fail("Expected DeviceMgtServerException"); - } catch (DeviceMgtException ex) { - Assert.assertEquals(ex.getErrorCode(), ErrorMessage.ERROR_WHILE_VERIFYING_SIGNATURE.getCode()); + Assert.assertEquals(ex.getErrorCode(), ErrorMessage.ERROR_DEVICE_NOT_FOUND.getCode()); } } @Test - public void testVerifyWithBlankRequiredFieldThrows() { - - try { - service.verifyDeviceRegistration( - UUID.randomUUID().toString(), "", "sig", - "Device", null, null, TENANT_DOMAIN); - Assert.fail("Expected DeviceMgtClientException for blank publicKey"); - } catch (DeviceMgtException ex) { - Assert.assertEquals(ex.getErrorCode(), ErrorMessage.ERROR_INVALID_DEVICE_FIELD.getCode()); - } + public void testUpdateDeviceNameWithBlankArgsThrows() { try { - service.verifyDeviceRegistration( - UUID.randomUUID().toString(), "pk", " ", - "Device", null, null, TENANT_DOMAIN); - Assert.fail("Expected DeviceMgtClientException for blank signature"); + service.updateDeviceName("", "New Name", TENANT_DOMAIN); + Assert.fail("Expected DeviceMgtClientException for blank deviceId"); } catch (DeviceMgtException ex) { Assert.assertEquals(ex.getErrorCode(), ErrorMessage.ERROR_INVALID_DEVICE_FIELD.getCode()); } try { - service.verifyDeviceRegistration( - UUID.randomUUID().toString(), "pk", "sig", - "", null, null, TENANT_DOMAIN); + service.updateDeviceName("d1", " ", TENANT_DOMAIN); Assert.fail("Expected DeviceMgtClientException for blank deviceName"); } catch (DeviceMgtException ex) { Assert.assertEquals(ex.getErrorCode(), ErrorMessage.ERROR_INVALID_DEVICE_FIELD.getCode()); @@ -225,59 +163,49 @@ public void testVerifyWithBlankRequiredFieldThrows() { } @Test - public void testVerifyConsumesContext() throws Exception { + public void testUpdateDeviceNameWhenDaoReturnsNullAfterRefetchThrowsNotNPE() throws Exception { - KeyPair kp = generateEcKeyPair(); - DeviceRegistrationInitiation initiation = service.initiateDeviceRegistration(TEST_USERNAME, TENANT_DOMAIN); - String sig = signChallengeB64(kp, initiation.getChallenge()); - - service.verifyDeviceRegistration( - initiation.getRegistrationId(), publicKeyB64(kp), sig, - "Device", null, null, TENANT_DOMAIN); + Device existing = mock(Device.class); + when(dao.getDeviceById("d1", TENANT_ID)).thenReturn(existing); + when(dao.updateDeviceName("d1", "New Name", TENANT_ID)).thenReturn(null); try { - service.verifyDeviceRegistration( - initiation.getRegistrationId(), publicKeyB64(kp), sig, - "Device", null, null, TENANT_DOMAIN); - Assert.fail("Expected context-not-found after context was consumed"); + service.updateDeviceName("d1", "New Name", TENANT_DOMAIN); + Assert.fail("Expected DeviceMgtClientException"); } catch (DeviceMgtException ex) { - Assert.assertEquals(ex.getErrorCode(), ErrorMessage.ERROR_REGISTRATION_CONTEXT_NOT_FOUND.getCode()); + Assert.assertEquals(ex.getErrorCode(), ErrorMessage.ERROR_DEVICE_NOT_FOUND.getCode()); } } @Test - public void testCompleteVerifiesAndPersists() throws Exception { - - KeyPair kp = generateEcKeyPair(); - DeviceRegistrationInitiation initiation = service.initiateDeviceRegistration(TEST_USERNAME, TENANT_DOMAIN); - String sig = signChallengeB64(kp, initiation.getChallenge()); - - Device verified = service.verifyDeviceRegistration( - initiation.getRegistrationId(), publicKeyB64(kp), sig, - "Alice's iPhone", "iPhone 15", null, TENANT_DOMAIN); + public void testDeleteDeviceWhenDeviceMissingThrows() throws Exception { - when(dao.registerDevice(any(), eq(TENANT_ID))).thenReturn(verified); - service.persistDevice(verified, TENANT_DOMAIN); + when(dao.getDeviceById("d1", TENANT_ID)).thenReturn(null); - verify(dao).registerDevice(any(), eq(TENANT_ID)); + try { + service.deleteDevice("d1", TENANT_DOMAIN); + Assert.fail("Expected DeviceMgtClientException"); + } catch (DeviceMgtException ex) { + Assert.assertEquals(ex.getErrorCode(), ErrorMessage.ERROR_DEVICE_NOT_FOUND.getCode()); + } } @Test - public void testGetDeviceByIdDelegatesToDao() throws Exception { + public void testDeleteDeviceDelegatesToDao() throws Exception { Device device = mock(Device.class); when(dao.getDeviceById("d1", TENANT_ID)).thenReturn(device); - Device result = service.getDeviceById("d1", TENANT_DOMAIN); + service.deleteDevice("d1", TENANT_DOMAIN); - Assert.assertEquals(result, device); + verify(dao).deleteDevice("d1", TENANT_ID); } @Test - public void testGetDeviceByIdWithBlankIdThrows() { + public void testGetDevicesByUserIdWithBlankUserThrows() { try { - service.getDeviceById("", TENANT_DOMAIN); + service.getDevicesByUserId("", TENANT_DOMAIN); Assert.fail("Expected DeviceMgtClientException"); } catch (DeviceMgtException ex) { Assert.assertEquals(ex.getErrorCode(), ErrorMessage.ERROR_INVALID_DEVICE_FIELD.getCode()); @@ -285,12 +213,40 @@ public void testGetDeviceByIdWithBlankIdThrows() { } @Test - public void testUpdateDeviceNameWhenDeviceMissingThrows() throws Exception { + public void testDeactivateDeviceDelegatesToDao() throws Exception { - when(dao.getDeviceById("d1", TENANT_ID)).thenReturn(null); + Device existing = mock(Device.class); + Device deactivated = buildDevice("d1", "alice@example.com", Device.Status.INACTIVE); + when(dao.getDeviceById("d1", TENANT_ID)).thenReturn(existing); + when(dao.changeDeviceStatus("d1", Device.Status.INACTIVE, TENANT_ID)).thenReturn(deactivated); + + Device result = service.deactivateDevice("d1", TENANT_DOMAIN); + + Assert.assertEquals(result.getStatus(), Device.Status.INACTIVE); + verify(dao).changeDeviceStatus("d1", Device.Status.INACTIVE, TENANT_ID); + } + + @Test + public void testActivateDeviceDelegatesToDao() throws Exception { + + Device existing = mock(Device.class); + Device activated = buildDevice("d1", "alice@example.com", Device.Status.ACTIVE); + when(dao.getDeviceById("d1", TENANT_ID)).thenReturn(existing); + when(dao.changeDeviceStatus("d1", Device.Status.ACTIVE, TENANT_ID)).thenReturn(activated); + + Device result = service.activateDevice("d1", TENANT_DOMAIN); + + Assert.assertEquals(result.getStatus(), Device.Status.ACTIVE); + verify(dao).changeDeviceStatus("d1", Device.Status.ACTIVE, TENANT_ID); + } + + @Test + public void testDeactivateDeviceWhenDeviceMissingThrows() throws Exception { + + when(dao.getDeviceById("unknown", TENANT_ID)).thenReturn(null); try { - service.updateDeviceName("d1", "New Name", TENANT_DOMAIN); + service.deactivateDevice("unknown", TENANT_DOMAIN); Assert.fail("Expected DeviceMgtClientException"); } catch (DeviceMgtException ex) { Assert.assertEquals(ex.getErrorCode(), ErrorMessage.ERROR_DEVICE_NOT_FOUND.getCode()); @@ -298,30 +254,27 @@ public void testUpdateDeviceNameWhenDeviceMissingThrows() throws Exception { } @Test - public void testUpdateDeviceNameWithBlankArgsThrows() { + public void testActivateDeviceWhenDeviceMissingThrows() throws Exception { - try { - service.updateDeviceName("", "New Name", TENANT_DOMAIN); - Assert.fail("Expected DeviceMgtClientException for blank deviceId"); - } catch (DeviceMgtException ex) { - Assert.assertEquals(ex.getErrorCode(), ErrorMessage.ERROR_INVALID_DEVICE_FIELD.getCode()); - } + when(dao.getDeviceById("unknown", TENANT_ID)).thenReturn(null); try { - service.updateDeviceName("d1", " ", TENANT_DOMAIN); - Assert.fail("Expected DeviceMgtClientException for blank deviceName"); + service.activateDevice("unknown", TENANT_DOMAIN); + Assert.fail("Expected DeviceMgtClientException"); } catch (DeviceMgtException ex) { - Assert.assertEquals(ex.getErrorCode(), ErrorMessage.ERROR_INVALID_DEVICE_FIELD.getCode()); + Assert.assertEquals(ex.getErrorCode(), ErrorMessage.ERROR_DEVICE_NOT_FOUND.getCode()); } } @Test - public void testDeleteDeviceWhenDeviceMissingThrows() throws Exception { + public void testDeactivateDeviceWhenDaoReturnsNullAfterRefetchThrowsNotNPE() throws Exception { - when(dao.getDeviceById("d1", TENANT_ID)).thenReturn(null); + Device existing = mock(Device.class); + when(dao.getDeviceById("d1", TENANT_ID)).thenReturn(existing); + when(dao.changeDeviceStatus("d1", Device.Status.INACTIVE, TENANT_ID)).thenReturn(null); try { - service.deleteDevice("d1", TENANT_DOMAIN); + service.deactivateDevice("d1", TENANT_DOMAIN); Assert.fail("Expected DeviceMgtClientException"); } catch (DeviceMgtException ex) { Assert.assertEquals(ex.getErrorCode(), ErrorMessage.ERROR_DEVICE_NOT_FOUND.getCode()); @@ -329,44 +282,35 @@ public void testDeleteDeviceWhenDeviceMissingThrows() throws Exception { } @Test - public void testDeleteDeviceDelegatesToDao() throws Exception { - - Device device = mock(Device.class); - when(dao.getDeviceById("d1", TENANT_ID)).thenReturn(device); - - service.deleteDevice("d1", TENANT_DOMAIN); - - verify(dao).deleteDevice("d1", TENANT_ID); - } + public void testActivateDeviceWhenDaoReturnsNullAfterRefetchThrowsNotNPE() throws Exception { - @Test - public void testGetDevicesByUserIdWithBlankUserThrows() { + Device existing = mock(Device.class); + when(dao.getDeviceById("d1", TENANT_ID)).thenReturn(existing); + when(dao.changeDeviceStatus("d1", Device.Status.ACTIVE, TENANT_ID)).thenReturn(null); try { - service.getDevicesByUserId("", TENANT_DOMAIN); + service.activateDevice("d1", TENANT_DOMAIN); Assert.fail("Expected DeviceMgtClientException"); } catch (DeviceMgtException ex) { - Assert.assertEquals(ex.getErrorCode(), ErrorMessage.ERROR_INVALID_DEVICE_FIELD.getCode()); + Assert.assertEquals(ex.getErrorCode(), ErrorMessage.ERROR_DEVICE_NOT_FOUND.getCode()); } } - private static KeyPair generateEcKeyPair() throws Exception { + private static Device buildDevice(String id, String userId) { - KeyPairGenerator kpg = KeyPairGenerator.getInstance("EC"); - kpg.initialize(256); - return kpg.generateKeyPair(); + return buildDevice(id, userId, Device.Status.ACTIVE); } - private static String publicKeyB64(KeyPair kp) { - - return Base64.getEncoder().encodeToString(kp.getPublic().getEncoded()); - } - - private static String signChallengeB64(KeyPair kp, String challengeB64Url) throws Exception { - - Signature sig = Signature.getInstance("SHA256withECDSA"); - sig.initSign(kp.getPrivate()); - sig.update(Base64.getUrlDecoder().decode(challengeB64Url)); - return Base64.getEncoder().encodeToString(sig.sign()); + private static Device buildDevice(String id, String userId, Device.Status status) { + + return new Device.Builder() + .id(id) + .userId(userId) + .deviceName("Alice's iPhone") + .deviceModel("iPhone 15") + .publicKey("dummy-public-key") + .status(status) + .registeredAt(Timestamp.from(Instant.now())) + .build(); } } diff --git a/components/device-mgt/org.wso2.carbon.identity.device.mgt/src/test/java/org/wso2/carbon/identity/device/mgt/util/DeviceManagementExceptionHandlerTest.java b/components/device-mgt/org.wso2.carbon.identity.device.mgt/src/test/java/org/wso2/carbon/identity/device/mgt/util/DeviceManagementExceptionHandlerTest.java index 2cccc05a8a6a..42c7acc3256e 100644 --- a/components/device-mgt/org.wso2.carbon.identity.device.mgt/src/test/java/org/wso2/carbon/identity/device/mgt/util/DeviceManagementExceptionHandlerTest.java +++ b/components/device-mgt/org.wso2.carbon.identity.device.mgt/src/test/java/org/wso2/carbon/identity/device/mgt/util/DeviceManagementExceptionHandlerTest.java @@ -44,24 +44,25 @@ public void testHandleClientException() { @Test public void testHandleClientExceptionWithCause() { - ErrorMessage error = ErrorMessage.ERROR_INVALID_DEVICE_SIGNATURE; - Throwable cause = new RuntimeException("sig error"); - DeviceMgtClientException ex = DeviceManagementExceptionHandler.handleClientException(error, cause, "reg123"); + ErrorMessage error = ErrorMessage.ERROR_INVALID_DEVICE_FIELD; + Throwable cause = new RuntimeException("field error"); + DeviceMgtClientException ex = + DeviceManagementExceptionHandler.handleClientException(error, cause, "deviceId"); Assert.assertEquals(ex.getErrorCode(), error.getCode()); - Assert.assertTrue(ex.getDescription().contains("reg123")); + Assert.assertTrue(ex.getDescription().contains("deviceId")); Assert.assertEquals(ex.getCause(), cause); } @Test public void testHandleServerExceptionWithCause() { - ErrorMessage error = ErrorMessage.ERROR_WHILE_VERIFYING_SIGNATURE; - Throwable cause = new RuntimeException("verify error"); - DeviceMgtServerException ex = DeviceManagementExceptionHandler.handleServerException(error, cause, "reg456"); + ErrorMessage error = ErrorMessage.ERROR_WHILE_UPDATING_DEVICE; + Throwable cause = new RuntimeException("update error"); + DeviceMgtServerException ex = DeviceManagementExceptionHandler.handleServerException(error, cause); Assert.assertEquals(ex.getErrorCode(), error.getCode()); - Assert.assertTrue(ex.getDescription().contains("reg456")); + Assert.assertNotNull(ex.getDescription()); Assert.assertEquals(ex.getCause(), cause); } @@ -79,13 +80,13 @@ public void testHandleServerExceptionNoCause() { @Test public void testDescriptionSubstitutionFormatting() { - ErrorMessage error = ErrorMessage.ERROR_REGISTRATION_CONTEXT_NOT_FOUND; - String regId = "test-reg-id-999"; - DeviceMgtClientException ex = DeviceManagementExceptionHandler.handleClientException(error, regId); + ErrorMessage error = ErrorMessage.ERROR_DEVICE_NOT_FOUND; + String deviceId = "test-device-id-999"; + DeviceMgtClientException ex = DeviceManagementExceptionHandler.handleClientException(error, deviceId); Assert.assertEquals(ex.getErrorCode(), error.getCode()); - Assert.assertTrue(ex.getDescription().contains(regId), - "Description should contain substituted value: " + regId); + Assert.assertTrue(ex.getDescription().contains(deviceId), + "Description should contain substituted value: " + deviceId); } @Test diff --git a/components/device-mgt/org.wso2.carbon.identity.device.mgt/src/test/resources/testng.xml b/components/device-mgt/org.wso2.carbon.identity.device.mgt/src/test/resources/testng.xml index ffeec1c566c5..4fbb4be5fce6 100644 --- a/components/device-mgt/org.wso2.carbon.identity.device.mgt/src/test/resources/testng.xml +++ b/components/device-mgt/org.wso2.carbon.identity.device.mgt/src/test/resources/testng.xml @@ -4,6 +4,7 @@ + From edd6c65f1a839aa993b852a4ead1e3159bcae25d Mon Sep 17 00:00:00 2001 From: kaviska Date: Sun, 12 Jul 2026 18:04:25 +0530 Subject: [PATCH 3/8] Cap the requested page size in getDevices The caller supplied page size was passed to the database unchanged, so a large limit could load an unbounded number of devices into memory - the same problem the removal of getAllDevices was meant to avoid. Validate the limit at the service boundary against IdentityUtil.getMaximumItemPerPage(), capping an oversized page size to the configured maximum, and rejecting a negative page size as a client error instead of silently returning an empty list. --- .../impl/DeviceManagementServiceImpl.java | 31 ++++++++++++++++++- .../DeviceManagementServiceImplTest.java | 31 +++++++++++++++++++ 2 files changed, 61 insertions(+), 1 deletion(-) diff --git a/components/device-mgt/org.wso2.carbon.identity.device.mgt/src/main/java/org/wso2/carbon/identity/device/mgt/internal/service/impl/DeviceManagementServiceImpl.java b/components/device-mgt/org.wso2.carbon.identity.device.mgt/src/main/java/org/wso2/carbon/identity/device/mgt/internal/service/impl/DeviceManagementServiceImpl.java index 0d6d9c68251c..fd3274c208f5 100644 --- a/components/device-mgt/org.wso2.carbon.identity.device.mgt/src/main/java/org/wso2/carbon/identity/device/mgt/internal/service/impl/DeviceManagementServiceImpl.java +++ b/components/device-mgt/org.wso2.carbon.identity.device.mgt/src/main/java/org/wso2/carbon/identity/device/mgt/internal/service/impl/DeviceManagementServiceImpl.java @@ -21,6 +21,7 @@ import org.apache.commons.logging.Log; import org.apache.commons.logging.LogFactory; import org.wso2.carbon.identity.core.util.IdentityTenantUtil; +import org.wso2.carbon.identity.core.util.IdentityUtil; import org.wso2.carbon.identity.device.mgt.api.constant.ErrorMessage; import org.wso2.carbon.identity.device.mgt.api.exception.DeviceMgtClientException; import org.wso2.carbon.identity.device.mgt.api.exception.DeviceMgtException; @@ -95,7 +96,8 @@ public List getDevicesByUserId(String userId, String tenantDomain) @Override public List getDevices(String tenantDomain, int offset, int limit) throws DeviceMgtException { - return deviceManagementDAO.getDevices(IdentityTenantUtil.getTenantId(tenantDomain), offset, limit); + return deviceManagementDAO.getDevices( + IdentityTenantUtil.getTenantId(tenantDomain), offset, validateLimit(limit)); } @Override @@ -204,5 +206,32 @@ private void validateRequiredField(String value, String fieldName) ErrorMessage.ERROR_INVALID_DEVICE_FIELD, fieldName); } } + + /** + * Validates the requested page size against the maximum items per page configured for the server. + * A page size larger than the configured maximum is capped, so that a caller cannot load an + * unbounded number of devices into memory. + * + * @param limit Requested page size. + * @return The page size to use, capped at the configured maximum. + * @throws DeviceMgtClientException If the requested page size is negative. + */ + private int validateLimit(int limit) throws DeviceMgtClientException { + + if (limit < 0) { + throw DeviceManagementExceptionHandler.handleClientException( + ErrorMessage.ERROR_INVALID_DEVICE_FIELD, "limit"); + } + + int maximumItemsPerPage = IdentityUtil.getMaximumItemPerPage(); + if (limit > maximumItemsPerPage) { + if (LOG.isDebugEnabled()) { + LOG.debug("Given limit: " + limit + " exceeds the maximum items per page. Using the maximum: " + + maximumItemsPerPage); + } + return maximumItemsPerPage; + } + return limit; + } } diff --git a/components/device-mgt/org.wso2.carbon.identity.device.mgt/src/test/java/org/wso2/carbon/identity/device/mgt/service/DeviceManagementServiceImplTest.java b/components/device-mgt/org.wso2.carbon.identity.device.mgt/src/test/java/org/wso2/carbon/identity/device/mgt/service/DeviceManagementServiceImplTest.java index dcd63d83bd88..9c069eff4d42 100644 --- a/components/device-mgt/org.wso2.carbon.identity.device.mgt/src/test/java/org/wso2/carbon/identity/device/mgt/service/DeviceManagementServiceImplTest.java +++ b/components/device-mgt/org.wso2.carbon.identity.device.mgt/src/test/java/org/wso2/carbon/identity/device/mgt/service/DeviceManagementServiceImplTest.java @@ -26,6 +26,7 @@ import org.testng.annotations.Test; import org.wso2.carbon.identity.common.testng.WithCarbonHome; import org.wso2.carbon.identity.core.util.IdentityTenantUtil; +import org.wso2.carbon.identity.core.util.IdentityUtil; import org.wso2.carbon.identity.device.mgt.api.constant.ErrorMessage; import org.wso2.carbon.identity.device.mgt.api.exception.DeviceMgtException; import org.wso2.carbon.identity.device.mgt.api.model.Device; @@ -212,6 +213,36 @@ public void testGetDevicesByUserIdWithBlankUserThrows() { } } + @Test + public void testGetDevicesPassesValidLimitThrough() throws Exception { + + service.getDevices(TENANT_DOMAIN, 5, 20); + + verify(dao).getDevices(TENANT_ID, 5, 20); + } + + @Test + public void testGetDevicesCapsLimitAtMaximumItemsPerPage() throws Exception { + + int maximumItemsPerPage = IdentityUtil.getMaximumItemPerPage(); + + service.getDevices(TENANT_DOMAIN, 0, maximumItemsPerPage + 5000); + + // The oversized page size must be capped, so the DAO never sees the caller's value. + verify(dao).getDevices(TENANT_ID, 0, maximumItemsPerPage); + } + + @Test + public void testGetDevicesWithNegativeLimitThrows() { + + try { + service.getDevices(TENANT_DOMAIN, 0, -1); + Assert.fail("Expected DeviceMgtClientException"); + } catch (DeviceMgtException ex) { + Assert.assertEquals(ex.getErrorCode(), ErrorMessage.ERROR_INVALID_DEVICE_FIELD.getCode()); + } + } + @Test public void testDeactivateDeviceDelegatesToDao() throws Exception { From c002fcec7b5ff10c69d0cdae0d7869b1456f1712 Mon Sep 17 00:00:00 2001 From: kaviska Date: Sun, 12 Jul 2026 18:42:17 +0530 Subject: [PATCH 4/8] Update device management validations and database schema --- .../device/mgt/api/constant/ErrorMessage.java | 4 +- .../impl/DeviceManagementServiceImpl.java | 56 +++++++++++++++-- .../DeviceManagementServiceImplTest.java | 60 +++++++++++++++++++ .../src/test/resources/dbscripts/h2.sql | 6 +- .../resources/dbscripts/db2.sql | 6 +- .../resources/dbscripts/h2.sql | 6 +- .../resources/dbscripts/mssql.sql | 6 +- .../resources/dbscripts/mysql-cluster.sql | 6 +- .../resources/dbscripts/mysql.sql | 6 +- .../resources/dbscripts/oracle.sql | 6 +- .../resources/dbscripts/oracle_rac.sql | 6 +- .../resources/dbscripts/postgresql.sql | 6 +- 12 files changed, 142 insertions(+), 32 deletions(-) diff --git a/components/device-mgt/org.wso2.carbon.identity.device.mgt/src/main/java/org/wso2/carbon/identity/device/mgt/api/constant/ErrorMessage.java b/components/device-mgt/org.wso2.carbon.identity.device.mgt/src/main/java/org/wso2/carbon/identity/device/mgt/api/constant/ErrorMessage.java index 039820b5c98d..35c06f13fedf 100644 --- a/components/device-mgt/org.wso2.carbon.identity.device.mgt/src/main/java/org/wso2/carbon/identity/device/mgt/api/constant/ErrorMessage.java +++ b/components/device-mgt/org.wso2.carbon.identity.device.mgt/src/main/java/org/wso2/carbon/identity/device/mgt/api/constant/ErrorMessage.java @@ -37,7 +37,9 @@ public enum ErrorMessage { ERROR_WHILE_DELETING_DEVICE("DM-65004", "Error while deleting device.", "Error while deleting device from the system."), ERROR_USER_ID_REQUIRED("DM-65007", "User identifier required.", - "Cannot persist device: a valid user identifier (userId) was not set before persistence."); + "Cannot persist device: a valid user identifier (userId) was not set before persistence."), + ERROR_DEVICE_FIELD_REQUIRED("DM-65008", "Required device field missing.", + "Cannot persist device: the required field '%s' was not set before persistence."); private final String code; private final String message; diff --git a/components/device-mgt/org.wso2.carbon.identity.device.mgt/src/main/java/org/wso2/carbon/identity/device/mgt/internal/service/impl/DeviceManagementServiceImpl.java b/components/device-mgt/org.wso2.carbon.identity.device.mgt/src/main/java/org/wso2/carbon/identity/device/mgt/internal/service/impl/DeviceManagementServiceImpl.java index fd3274c208f5..bdd85a643e38 100644 --- a/components/device-mgt/org.wso2.carbon.identity.device.mgt/src/main/java/org/wso2/carbon/identity/device/mgt/internal/service/impl/DeviceManagementServiceImpl.java +++ b/components/device-mgt/org.wso2.carbon.identity.device.mgt/src/main/java/org/wso2/carbon/identity/device/mgt/internal/service/impl/DeviceManagementServiceImpl.java @@ -61,10 +61,7 @@ public static DeviceManagementServiceImpl getInstance() { @Override public void persistDevice(Device device, String tenantDomain) throws DeviceMgtException { - if (device.getUserId() == null || device.getUserId().trim().isEmpty()) { - throw DeviceManagementExceptionHandler.handleServerException( - ErrorMessage.ERROR_USER_ID_REQUIRED); - } + validateDeviceForPersistence(device); deviceManagementDAO.registerDevice(device, IdentityTenantUtil.getTenantId(tenantDomain)); AUDIT_LOGGER.printAuditLog(DeviceManagementAuditLogger.Operation.REGISTER, device); @@ -207,6 +204,57 @@ private void validateRequiredField(String value, String fieldName) } } + /** + * Validates that the device carries every field the persistence layer requires. + * The device is constructed internally during the registration flow, so a missing field indicates + * that the device was not fully built before persistence rather than invalid user input. Validating + * here keeps such a failure a clear, coded error instead of a constraint violation or a + * NullPointerException raised inside the data layer. + * The device model and the metadata are optional and are therefore not validated. + * + * @param device Device to be persisted. + * @throws DeviceMgtException If the device or any of its required fields is not set. + */ + private void validateDeviceForPersistence(Device device) throws DeviceMgtException { + + if (device == null) { + throw DeviceManagementExceptionHandler.handleServerException( + ErrorMessage.ERROR_DEVICE_FIELD_REQUIRED, "device"); + } + if (device.getUserId() == null || device.getUserId().trim().isEmpty()) { + throw DeviceManagementExceptionHandler.handleServerException( + ErrorMessage.ERROR_USER_ID_REQUIRED); + } + + validateRequiredDeviceField(device.getId(), "id"); + validateRequiredDeviceField(device.getDeviceName(), "deviceName"); + validateRequiredDeviceField(device.getPublicKey(), "publicKey"); + + if (device.getStatus() == null) { + throw DeviceManagementExceptionHandler.handleServerException( + ErrorMessage.ERROR_DEVICE_FIELD_REQUIRED, "status"); + } + if (device.getRegisteredAt() == null) { + throw DeviceManagementExceptionHandler.handleServerException( + ErrorMessage.ERROR_DEVICE_FIELD_REQUIRED, "registeredAt"); + } + } + + /** + * Validates a required device field that is expected to be set before persistence. + * + * @param value Value of the field. + * @param fieldName Name of the field. + * @throws DeviceMgtException If the field is not set. + */ + private void validateRequiredDeviceField(String value, String fieldName) throws DeviceMgtException { + + if (value == null || value.trim().isEmpty()) { + throw DeviceManagementExceptionHandler.handleServerException( + ErrorMessage.ERROR_DEVICE_FIELD_REQUIRED, fieldName); + } + } + /** * Validates the requested page size against the maximum items per page configured for the server. * A page size larger than the configured maximum is capped, so that a caller cannot load an diff --git a/components/device-mgt/org.wso2.carbon.identity.device.mgt/src/test/java/org/wso2/carbon/identity/device/mgt/service/DeviceManagementServiceImplTest.java b/components/device-mgt/org.wso2.carbon.identity.device.mgt/src/test/java/org/wso2/carbon/identity/device/mgt/service/DeviceManagementServiceImplTest.java index 9c069eff4d42..dc23f77cbec1 100644 --- a/components/device-mgt/org.wso2.carbon.identity.device.mgt/src/test/java/org/wso2/carbon/identity/device/mgt/service/DeviceManagementServiceImplTest.java +++ b/components/device-mgt/org.wso2.carbon.identity.device.mgt/src/test/java/org/wso2/carbon/identity/device/mgt/service/DeviceManagementServiceImplTest.java @@ -23,6 +23,7 @@ import org.testng.annotations.AfterClass; import org.testng.annotations.BeforeClass; import org.testng.annotations.BeforeMethod; +import org.testng.annotations.DataProvider; import org.testng.annotations.Test; import org.wso2.carbon.identity.common.testng.WithCarbonHome; import org.wso2.carbon.identity.core.util.IdentityTenantUtil; @@ -110,6 +111,65 @@ public void testPersistDeviceWithoutUserIdThrows() { } } + @Test + public void testPersistNullDeviceThrows() { + + assertPersistFailsWithFieldRequired(null); + } + + @DataProvider(name = "incompleteDevices") + public Object[][] incompleteDevices() { + + return new Object[][]{ + {"id", completeDeviceBuilder().id(null).build()}, + {"deviceName", completeDeviceBuilder().deviceName(null).build()}, + {"publicKey", completeDeviceBuilder().publicKey(null).build()}, + {"status", completeDeviceBuilder().status(null).build()}, + {"registeredAt", completeDeviceBuilder().registeredAt(null).build()}, + }; + } + + @Test(dataProvider = "incompleteDevices") + public void testPersistDeviceWithMissingRequiredFieldThrows(String fieldName, Device device) { + + // A required field left unset must surface as a coded error, not as a DB constraint + // violation or an NPE raised inside the DAO. + assertPersistFailsWithFieldRequired(device); + } + + @Test + public void testPersistDeviceWithoutOptionalFieldsSucceeds() throws Exception { + + // Device model and metadata are nullable in the schema, so they must not be validated. + Device device = completeDeviceBuilder().deviceModel(null).metadata(null).build(); + + service.persistDevice(device, TENANT_DOMAIN); + + verify(dao).registerDevice(any(), eq(TENANT_ID)); + } + + private void assertPersistFailsWithFieldRequired(Device device) { + + try { + service.persistDevice(device, TENANT_DOMAIN); + Assert.fail("Expected DeviceMgtServerException"); + } catch (DeviceMgtException ex) { + Assert.assertEquals(ex.getErrorCode(), ErrorMessage.ERROR_DEVICE_FIELD_REQUIRED.getCode()); + } + } + + private static Device.Builder completeDeviceBuilder() { + + return new Device.Builder() + .id("d1") + .userId("alice@example.com") + .deviceName("Alice's iPhone") + .deviceModel("iPhone 15") + .publicKey("dummy-public-key") + .status(Device.Status.ACTIVE) + .registeredAt(Timestamp.from(Instant.now())); + } + @Test public void testGetDeviceByIdDelegatesToDao() throws Exception { diff --git a/components/device-mgt/org.wso2.carbon.identity.device.mgt/src/test/resources/dbscripts/h2.sql b/components/device-mgt/org.wso2.carbon.identity.device.mgt/src/test/resources/dbscripts/h2.sql index 251910c7574c..6269afa183c6 100644 --- a/components/device-mgt/org.wso2.carbon.identity.device.mgt/src/test/resources/dbscripts/h2.sql +++ b/components/device-mgt/org.wso2.carbon.identity.device.mgt/src/test/resources/dbscripts/h2.sql @@ -2,10 +2,10 @@ CREATE TABLE IF NOT EXISTS IDN_DEVICE ( ID CHAR(36) NOT NULL, DEVICE_NAME VARCHAR(255) NOT NULL, DEVICE_MODEL VARCHAR(255), - PUBLIC_KEY VARCHAR(4096) NOT NULL, + PUBLIC_KEY VARCHAR(2048) NOT NULL, STATUS VARCHAR(20) NOT NULL DEFAULT 'ACTIVE', REGISTERED_AT TIMESTAMP NOT NULL, - METADATA VARCHAR(4096) NULL, + METADATA VARCHAR(2048) NULL, TENANT_ID INTEGER NOT NULL, PRIMARY KEY (ID) ); @@ -19,4 +19,4 @@ CREATE TABLE IF NOT EXISTS IDN_USER_DEVICE ( ); CREATE INDEX IF NOT EXISTS IDX_IUD_USER_TENANT ON IDN_USER_DEVICE (USER_ID, TENANT_ID); -CREATE INDEX IF NOT EXISTS IDX_IDV_STATUS ON IDN_DEVICE (STATUS, TENANT_ID); +CREATE INDEX IF NOT EXISTS IDX_IDV_TENANT_REGISTERED ON IDN_DEVICE (TENANT_ID, REGISTERED_AT); diff --git a/features/identity-core/org.wso2.carbon.identity.core.server.feature/resources/dbscripts/db2.sql b/features/identity-core/org.wso2.carbon.identity.core.server.feature/resources/dbscripts/db2.sql index 731e00c53de1..dfafef18d88f 100644 --- a/features/identity-core/org.wso2.carbon.identity.core.server.feature/resources/dbscripts/db2.sql +++ b/features/identity-core/org.wso2.carbon.identity.core.server.feature/resources/dbscripts/db2.sql @@ -3189,10 +3189,10 @@ CREATE TABLE IDN_DEVICE ( ID CHAR(36) NOT NULL, DEVICE_NAME VARCHAR(255) NOT NULL, DEVICE_MODEL VARCHAR(255), - PUBLIC_KEY VARCHAR(4096) NOT NULL, + PUBLIC_KEY VARCHAR(2048) NOT NULL, STATUS VARCHAR(20) NOT NULL DEFAULT 'ACTIVE', REGISTERED_AT TIMESTAMP NOT NULL, - METADATA VARCHAR(4096), + METADATA VARCHAR(2048), TENANT_ID INTEGER NOT NULL, PRIMARY KEY (ID) ) @@ -3205,7 +3205,7 @@ CREATE TABLE IDN_USER_DEVICE ( FOREIGN KEY (DEVICE_ID) REFERENCES IDN_DEVICE (ID) ON DELETE CASCADE ) / -CREATE INDEX IDX_IDV_STATUS ON IDN_DEVICE (STATUS, TENANT_ID) +CREATE INDEX IDX_IDV_TENANT_REGISTERED ON IDN_DEVICE (TENANT_ID, REGISTERED_AT) / CREATE INDEX IDX_IUD_USER_TENANT ON IDN_USER_DEVICE (USER_ID, TENANT_ID) / diff --git a/features/identity-core/org.wso2.carbon.identity.core.server.feature/resources/dbscripts/h2.sql b/features/identity-core/org.wso2.carbon.identity.core.server.feature/resources/dbscripts/h2.sql index 9f24f427fdf9..17cf4ba9256f 100644 --- a/features/identity-core/org.wso2.carbon.identity.core.server.feature/resources/dbscripts/h2.sql +++ b/features/identity-core/org.wso2.carbon.identity.core.server.feature/resources/dbscripts/h2.sql @@ -2116,10 +2116,10 @@ CREATE TABLE IF NOT EXISTS IDN_DEVICE ( ID CHAR(36) NOT NULL, DEVICE_NAME VARCHAR(255) NOT NULL, DEVICE_MODEL VARCHAR(255), - PUBLIC_KEY VARCHAR(4096) NOT NULL, + PUBLIC_KEY VARCHAR(2048) NOT NULL, STATUS VARCHAR(20) NOT NULL DEFAULT 'ACTIVE', REGISTERED_AT TIMESTAMP NOT NULL, - METADATA VARCHAR(4096), + METADATA VARCHAR(2048), TENANT_ID INTEGER NOT NULL, PRIMARY KEY (ID) ); @@ -2132,5 +2132,5 @@ CREATE TABLE IF NOT EXISTS IDN_USER_DEVICE ( FOREIGN KEY (DEVICE_ID) REFERENCES IDN_DEVICE (ID) ON DELETE CASCADE ); -CREATE INDEX IDX_IDV_STATUS ON IDN_DEVICE (STATUS, TENANT_ID); +CREATE INDEX IDX_IDV_TENANT_REGISTERED ON IDN_DEVICE (TENANT_ID, REGISTERED_AT); CREATE INDEX IDX_IUD_USER_TENANT ON IDN_USER_DEVICE (USER_ID, TENANT_ID); diff --git a/features/identity-core/org.wso2.carbon.identity.core.server.feature/resources/dbscripts/mssql.sql b/features/identity-core/org.wso2.carbon.identity.core.server.feature/resources/dbscripts/mssql.sql index a791fc58639b..5f57f2b9cf0d 100644 --- a/features/identity-core/org.wso2.carbon.identity.core.server.feature/resources/dbscripts/mssql.sql +++ b/features/identity-core/org.wso2.carbon.identity.core.server.feature/resources/dbscripts/mssql.sql @@ -2324,10 +2324,10 @@ CREATE TABLE IDN_DEVICE ( ID CHAR(36) NOT NULL, DEVICE_NAME VARCHAR(255) NOT NULL, DEVICE_MODEL VARCHAR(255), - PUBLIC_KEY VARCHAR(4096) NOT NULL, + PUBLIC_KEY VARCHAR(2048) NOT NULL, STATUS VARCHAR(20) NOT NULL DEFAULT 'ACTIVE', REGISTERED_AT DATETIME NOT NULL, - METADATA VARCHAR(4096), + METADATA VARCHAR(2048), TENANT_ID INTEGER NOT NULL, PRIMARY KEY (ID) ); @@ -2340,5 +2340,5 @@ CREATE TABLE IDN_USER_DEVICE ( FOREIGN KEY (DEVICE_ID) REFERENCES IDN_DEVICE (ID) ON DELETE CASCADE ); -CREATE INDEX IDX_IDV_STATUS ON IDN_DEVICE (STATUS, TENANT_ID); +CREATE INDEX IDX_IDV_TENANT_REGISTERED ON IDN_DEVICE (TENANT_ID, REGISTERED_AT); CREATE INDEX IDX_IUD_USER_TENANT ON IDN_USER_DEVICE (USER_ID, TENANT_ID); diff --git a/features/identity-core/org.wso2.carbon.identity.core.server.feature/resources/dbscripts/mysql-cluster.sql b/features/identity-core/org.wso2.carbon.identity.core.server.feature/resources/dbscripts/mysql-cluster.sql index 69eb5fa3ebc5..bdf4914534e7 100644 --- a/features/identity-core/org.wso2.carbon.identity.core.server.feature/resources/dbscripts/mysql-cluster.sql +++ b/features/identity-core/org.wso2.carbon.identity.core.server.feature/resources/dbscripts/mysql-cluster.sql @@ -2331,10 +2331,10 @@ CREATE TABLE IF NOT EXISTS IDN_DEVICE ( ID CHAR(36) NOT NULL, DEVICE_NAME VARCHAR(255) NOT NULL, DEVICE_MODEL VARCHAR(255), - PUBLIC_KEY VARCHAR(4096) NOT NULL, + PUBLIC_KEY VARCHAR(2048) NOT NULL, STATUS VARCHAR(20) NOT NULL DEFAULT 'ACTIVE', REGISTERED_AT TIMESTAMP NOT NULL, - METADATA VARCHAR(4096), + METADATA VARCHAR(2048), TENANT_ID INTEGER NOT NULL, PRIMARY KEY (ID) )ENGINE NDB; @@ -2347,5 +2347,5 @@ CREATE TABLE IF NOT EXISTS IDN_USER_DEVICE ( FOREIGN KEY (DEVICE_ID) REFERENCES IDN_DEVICE (ID) ON DELETE CASCADE )ENGINE NDB; -CREATE INDEX IDX_IDV_STATUS ON IDN_DEVICE (STATUS, TENANT_ID); +CREATE INDEX IDX_IDV_TENANT_REGISTERED ON IDN_DEVICE (TENANT_ID, REGISTERED_AT); CREATE INDEX IDX_IUD_USER_TENANT ON IDN_USER_DEVICE (USER_ID, TENANT_ID); diff --git a/features/identity-core/org.wso2.carbon.identity.core.server.feature/resources/dbscripts/mysql.sql b/features/identity-core/org.wso2.carbon.identity.core.server.feature/resources/dbscripts/mysql.sql index e04ca5fee466..f4ff8dbe2e0f 100644 --- a/features/identity-core/org.wso2.carbon.identity.core.server.feature/resources/dbscripts/mysql.sql +++ b/features/identity-core/org.wso2.carbon.identity.core.server.feature/resources/dbscripts/mysql.sql @@ -2143,10 +2143,10 @@ CREATE TABLE IF NOT EXISTS IDN_DEVICE ( ID CHAR(36) NOT NULL, DEVICE_NAME VARCHAR(255) NOT NULL, DEVICE_MODEL VARCHAR(255), - PUBLIC_KEY VARCHAR(4096) NOT NULL, + PUBLIC_KEY VARCHAR(2048) NOT NULL, STATUS VARCHAR(20) NOT NULL DEFAULT 'ACTIVE', REGISTERED_AT TIMESTAMP NOT NULL, - METADATA VARCHAR(4096), + METADATA VARCHAR(2048), TENANT_ID INTEGER NOT NULL, PRIMARY KEY (ID) )DEFAULT CHARACTER SET latin1 ENGINE INNODB; @@ -2159,5 +2159,5 @@ CREATE TABLE IF NOT EXISTS IDN_USER_DEVICE ( FOREIGN KEY (DEVICE_ID) REFERENCES IDN_DEVICE (ID) ON DELETE CASCADE )DEFAULT CHARACTER SET latin1 ENGINE INNODB; -CREATE INDEX IDX_IDV_STATUS ON IDN_DEVICE (STATUS, TENANT_ID); +CREATE INDEX IDX_IDV_TENANT_REGISTERED ON IDN_DEVICE (TENANT_ID, REGISTERED_AT); CREATE INDEX IDX_IUD_USER_TENANT ON IDN_USER_DEVICE (USER_ID, TENANT_ID); diff --git a/features/identity-core/org.wso2.carbon.identity.core.server.feature/resources/dbscripts/oracle.sql b/features/identity-core/org.wso2.carbon.identity.core.server.feature/resources/dbscripts/oracle.sql index 1bfa51cbe1df..c80494dd28a9 100644 --- a/features/identity-core/org.wso2.carbon.identity.core.server.feature/resources/dbscripts/oracle.sql +++ b/features/identity-core/org.wso2.carbon.identity.core.server.feature/resources/dbscripts/oracle.sql @@ -3342,10 +3342,10 @@ CREATE TABLE IDN_DEVICE ( ID CHAR(36) NOT NULL, DEVICE_NAME VARCHAR2(255) NOT NULL, DEVICE_MODEL VARCHAR2(255), - PUBLIC_KEY VARCHAR2(4000) NOT NULL, + PUBLIC_KEY VARCHAR2(2048) NOT NULL, STATUS VARCHAR2(20) DEFAULT 'ACTIVE' NOT NULL, REGISTERED_AT TIMESTAMP NOT NULL, - METADATA VARCHAR2(4000), + METADATA VARCHAR2(2048), TENANT_ID INTEGER NOT NULL, PRIMARY KEY (ID) ) @@ -3358,7 +3358,7 @@ CREATE TABLE IDN_USER_DEVICE ( FOREIGN KEY (DEVICE_ID) REFERENCES IDN_DEVICE (ID) ON DELETE CASCADE ) / -CREATE INDEX IDX_IDV_STATUS ON IDN_DEVICE (STATUS, TENANT_ID) +CREATE INDEX IDX_IDV_TENANT_REGISTERED ON IDN_DEVICE (TENANT_ID, REGISTERED_AT) / CREATE INDEX IDX_IUD_USER_TENANT ON IDN_USER_DEVICE (USER_ID, TENANT_ID) / diff --git a/features/identity-core/org.wso2.carbon.identity.core.server.feature/resources/dbscripts/oracle_rac.sql b/features/identity-core/org.wso2.carbon.identity.core.server.feature/resources/dbscripts/oracle_rac.sql index 06c639cdbc73..fc31a47e225a 100644 --- a/features/identity-core/org.wso2.carbon.identity.core.server.feature/resources/dbscripts/oracle_rac.sql +++ b/features/identity-core/org.wso2.carbon.identity.core.server.feature/resources/dbscripts/oracle_rac.sql @@ -3242,10 +3242,10 @@ CREATE TABLE IDN_DEVICE ( ID CHAR(36) NOT NULL, DEVICE_NAME VARCHAR2(255) NOT NULL, DEVICE_MODEL VARCHAR2(255), - PUBLIC_KEY VARCHAR2(4000) NOT NULL, + PUBLIC_KEY VARCHAR2(2048) NOT NULL, STATUS VARCHAR2(20) DEFAULT 'ACTIVE' NOT NULL, REGISTERED_AT TIMESTAMP NOT NULL, - METADATA VARCHAR2(4000), + METADATA VARCHAR2(2048), TENANT_ID INTEGER NOT NULL, PRIMARY KEY (ID) ) @@ -3258,7 +3258,7 @@ CREATE TABLE IDN_USER_DEVICE ( FOREIGN KEY (DEVICE_ID) REFERENCES IDN_DEVICE (ID) ON DELETE CASCADE ) / -CREATE INDEX IDX_IDV_STATUS ON IDN_DEVICE (STATUS, TENANT_ID) +CREATE INDEX IDX_IDV_TENANT_REGISTERED ON IDN_DEVICE (TENANT_ID, REGISTERED_AT) / CREATE INDEX IDX_IUD_USER_TENANT ON IDN_USER_DEVICE (USER_ID, TENANT_ID) / diff --git a/features/identity-core/org.wso2.carbon.identity.core.server.feature/resources/dbscripts/postgresql.sql b/features/identity-core/org.wso2.carbon.identity.core.server.feature/resources/dbscripts/postgresql.sql index 582bd624cfad..7b6211af18ee 100644 --- a/features/identity-core/org.wso2.carbon.identity.core.server.feature/resources/dbscripts/postgresql.sql +++ b/features/identity-core/org.wso2.carbon.identity.core.server.feature/resources/dbscripts/postgresql.sql @@ -2235,10 +2235,10 @@ CREATE TABLE IF NOT EXISTS IDN_DEVICE ( ID CHAR(36) NOT NULL, DEVICE_NAME VARCHAR(255) NOT NULL, DEVICE_MODEL VARCHAR(255), - PUBLIC_KEY VARCHAR(4096) NOT NULL, + PUBLIC_KEY VARCHAR(2048) NOT NULL, STATUS VARCHAR(20) NOT NULL DEFAULT 'ACTIVE', REGISTERED_AT TIMESTAMP NOT NULL, - METADATA VARCHAR(4096), + METADATA VARCHAR(2048), TENANT_ID INTEGER NOT NULL, PRIMARY KEY (ID) ); @@ -2251,5 +2251,5 @@ CREATE TABLE IF NOT EXISTS IDN_USER_DEVICE ( FOREIGN KEY (DEVICE_ID) REFERENCES IDN_DEVICE (ID) ON DELETE CASCADE ); -CREATE INDEX IDX_IDV_STATUS ON IDN_DEVICE (STATUS, TENANT_ID); +CREATE INDEX IDX_IDV_TENANT_REGISTERED ON IDN_DEVICE (TENANT_ID, REGISTERED_AT); CREATE INDEX IDX_IUD_USER_TENANT ON IDN_USER_DEVICE (USER_ID, TENANT_ID); From 8c574ea5ca1b136f49dc13e5bc1e70e0c508609e Mon Sep 17 00:00:00 2001 From: kaviska Date: Sun, 12 Jul 2026 21:17:13 +0530 Subject: [PATCH 5/8] Add getActiveDeviceById to the device management service --- .../api/service/DeviceManagementService.java | 18 ++++++++ .../impl/DeviceManagementServiceImpl.java | 10 +++++ .../DeviceManagementServiceImplTest.java | 45 +++++++++++++++++++ 3 files changed, 73 insertions(+) diff --git a/components/device-mgt/org.wso2.carbon.identity.device.mgt/src/main/java/org/wso2/carbon/identity/device/mgt/api/service/DeviceManagementService.java b/components/device-mgt/org.wso2.carbon.identity.device.mgt/src/main/java/org/wso2/carbon/identity/device/mgt/api/service/DeviceManagementService.java index bec06a0feb53..a18d14a81930 100644 --- a/components/device-mgt/org.wso2.carbon.identity.device.mgt/src/main/java/org/wso2/carbon/identity/device/mgt/api/service/DeviceManagementService.java +++ b/components/device-mgt/org.wso2.carbon.identity.device.mgt/src/main/java/org/wso2/carbon/identity/device/mgt/api/service/DeviceManagementService.java @@ -39,6 +39,8 @@ public interface DeviceManagementService { /** * Retrieves a device by its UUID. Returns the device regardless of its status (ACTIVE or * INACTIVE) — this is an admin/tenant-scoped lookup, not a filtered "my devices" view. + * Do not use this to decide whether a device should be trusted for authentication — a + * deactivated (revoked) device is still returned. Use {@link #getActiveDeviceById} for that. * * @param deviceId UUID of the device (IDN_DEVICE.ID). * @param tenantDomain Tenant domain. @@ -47,6 +49,22 @@ public interface DeviceManagementService { Device getDeviceById(String deviceId, String tenantDomain) throws DeviceMgtException; + /** + * Retrieves a device by its UUID, but only if its status is {@link Device.Status#ACTIVE}. + * Returns {@code null} both when the device does not exist and when it exists but has been + * deactivated (revoked) — callers that use the result to decide whether to trust a device + * (e.g. token/signature validation) must not be able to distinguish the two cases from the + * return value alone, since doing so would leak whether a given device id was ever registered. + * This is the method authentication/authorization paths must use; {@link #getDeviceById} is + * for management/admin views where an inactive device should still be visible. + * + * @param deviceId UUID of the device (IDN_DEVICE.ID). + * @param tenantDomain Tenant domain. + * @return The Device if it exists and is ACTIVE; {@code null} otherwise. + */ + Device getActiveDeviceById(String deviceId, String tenantDomain) + throws DeviceMgtException; + /** * Retrieves all ACTIVE devices registered by a user. This is the user-facing "my devices" * list: devices deactivated via {@link #deactivateDevice(String, String)} are excluded. diff --git a/components/device-mgt/org.wso2.carbon.identity.device.mgt/src/main/java/org/wso2/carbon/identity/device/mgt/internal/service/impl/DeviceManagementServiceImpl.java b/components/device-mgt/org.wso2.carbon.identity.device.mgt/src/main/java/org/wso2/carbon/identity/device/mgt/internal/service/impl/DeviceManagementServiceImpl.java index bdd85a643e38..6f8738228731 100644 --- a/components/device-mgt/org.wso2.carbon.identity.device.mgt/src/main/java/org/wso2/carbon/identity/device/mgt/internal/service/impl/DeviceManagementServiceImpl.java +++ b/components/device-mgt/org.wso2.carbon.identity.device.mgt/src/main/java/org/wso2/carbon/identity/device/mgt/internal/service/impl/DeviceManagementServiceImpl.java @@ -81,6 +81,16 @@ public Device getDeviceById(String deviceId, String tenantDomain) deviceId, IdentityTenantUtil.getTenantId(tenantDomain)); } + @Override + public Device getActiveDeviceById(String deviceId, String tenantDomain) throws DeviceMgtException { + + Device device = getDeviceById(deviceId, tenantDomain); + if (device == null || device.getStatus() != Device.Status.ACTIVE) { + return null; + } + return device; + } + @Override public List getDevicesByUserId(String userId, String tenantDomain) throws DeviceMgtException { diff --git a/components/device-mgt/org.wso2.carbon.identity.device.mgt/src/test/java/org/wso2/carbon/identity/device/mgt/service/DeviceManagementServiceImplTest.java b/components/device-mgt/org.wso2.carbon.identity.device.mgt/src/test/java/org/wso2/carbon/identity/device/mgt/service/DeviceManagementServiceImplTest.java index dc23f77cbec1..c549752eabaf 100644 --- a/components/device-mgt/org.wso2.carbon.identity.device.mgt/src/test/java/org/wso2/carbon/identity/device/mgt/service/DeviceManagementServiceImplTest.java +++ b/components/device-mgt/org.wso2.carbon.identity.device.mgt/src/test/java/org/wso2/carbon/identity/device/mgt/service/DeviceManagementServiceImplTest.java @@ -192,6 +192,51 @@ public void testGetDeviceByIdWithBlankIdThrows() { } } + @Test + public void testGetActiveDeviceByIdReturnsDeviceWhenActive() throws Exception { + + Device active = buildDevice("d1", "alice@example.com", Device.Status.ACTIVE); + when(dao.getDeviceById("d1", TENANT_ID)).thenReturn(active); + + Device result = service.getActiveDeviceById("d1", TENANT_DOMAIN); + + Assert.assertEquals(result, active); + } + + @Test + public void testGetActiveDeviceByIdReturnsNullWhenInactive() throws Exception { + + // The security-critical case: a deactivated (revoked) device must not be returned, + // even though the record still exists and getDeviceById would return it. + Device inactive = buildDevice("d1", "alice@example.com", Device.Status.INACTIVE); + when(dao.getDeviceById("d1", TENANT_ID)).thenReturn(inactive); + + Device result = service.getActiveDeviceById("d1", TENANT_DOMAIN); + + Assert.assertNull(result); + } + + @Test + public void testGetActiveDeviceByIdReturnsNullWhenDeviceDoesNotExist() throws Exception { + + when(dao.getDeviceById("unknown", TENANT_ID)).thenReturn(null); + + Device result = service.getActiveDeviceById("unknown", TENANT_DOMAIN); + + Assert.assertNull(result); + } + + @Test + public void testGetActiveDeviceByIdWithBlankIdThrows() { + + try { + service.getActiveDeviceById("", TENANT_DOMAIN); + Assert.fail("Expected DeviceMgtClientException"); + } catch (DeviceMgtException ex) { + Assert.assertEquals(ex.getErrorCode(), ErrorMessage.ERROR_INVALID_DEVICE_FIELD.getCode()); + } + } + @Test public void testUpdateDeviceNameWhenDeviceMissingThrows() throws Exception { From 05a71ea24fd92c2432eb2a61159ad5915e884221 Mon Sep 17 00:00:00 2001 From: kaviska Date: Fri, 17 Jul 2026 09:12:55 +0530 Subject: [PATCH 6/8] Add user-scoped device listing and count to the device management service --- .../api/service/DeviceManagementService.java | 29 ++++++++++ .../constant/DeviceMgtSQLConstants.java | 47 +++++++++++++++ .../mgt/internal/dao/DeviceManagementDAO.java | 27 +++++++++ .../impl/CacheBackedDeviceManagementDAO.java | 32 ++++++++++ .../dao/impl/DeviceManagementDAOImpl.java | 54 +++++++++++++++-- .../impl/DeviceManagementServiceImpl.java | 14 +++++ .../util/DeviceManagementAuditLogger.java | 6 +- .../CacheBackedDeviceManagementDAOTest.java | 27 +++++++++ .../mgt/dao/DeviceManagementDAOImplTest.java | 58 +++++++++++++++++++ .../DeviceManagementServiceImplTest.java | 45 ++++++++++++++ 10 files changed, 330 insertions(+), 9 deletions(-) diff --git a/components/device-mgt/org.wso2.carbon.identity.device.mgt/src/main/java/org/wso2/carbon/identity/device/mgt/api/service/DeviceManagementService.java b/components/device-mgt/org.wso2.carbon.identity.device.mgt/src/main/java/org/wso2/carbon/identity/device/mgt/api/service/DeviceManagementService.java index a18d14a81930..7cab767954fc 100644 --- a/components/device-mgt/org.wso2.carbon.identity.device.mgt/src/main/java/org/wso2/carbon/identity/device/mgt/api/service/DeviceManagementService.java +++ b/components/device-mgt/org.wso2.carbon.identity.device.mgt/src/main/java/org/wso2/carbon/identity/device/mgt/api/service/DeviceManagementService.java @@ -89,6 +89,23 @@ List getDevicesByUserId(String userId, String tenantDomain) List getDevices(String tenantDomain, int offset, int limit) throws DeviceMgtException; + /** + * Retrieves a page of devices registered in the tenant, ordered by registration time (newest + * first), optionally filtered to a single user's devices. This is an admin view: it returns + * devices of any status (ACTIVE or INACTIVE), unlike {@link #getDevicesByUserId}, which is the + * user-facing "my devices" list restricted to ACTIVE devices. Do not use + * {@link #getDevicesByUserId} in place of this method for admin views — it silently drops + * INACTIVE devices. + * + * @param tenantDomain Tenant domain. + * @param offset Number of records to skip. + * @param limit Maximum number of records to return. + * @param userId WSO2 user identifier to filter by, or {@code null}/blank for no filtering. + * @return Page of Device objects. Empty list if none found. + */ + List getDevices(String tenantDomain, int offset, int limit, String userId) + throws DeviceMgtException; + /** * Counts all devices registered in the tenant, regardless of status (ACTIVE or INACTIVE) — * this is an admin/tenant-wide count, not filtered like {@link #getDevicesByUserId}. @@ -99,6 +116,18 @@ List getDevices(String tenantDomain, int offset, int limit) int getDeviceCount(String tenantDomain) throws DeviceMgtException; + /** + * Counts devices registered in the tenant, optionally filtered to a single user, regardless of + * status (ACTIVE or INACTIVE). This is an admin/tenant-wide count, not filtered like + * {@link #getDevicesByUserId}, which only counts ACTIVE devices for the user-facing view. + * + * @param tenantDomain Tenant domain. + * @param userId WSO2 user identifier to filter by, or {@code null}/blank for no filtering. + * @return Total number of matching devices in the tenant. + */ + int getDeviceCount(String tenantDomain, String userId) + throws DeviceMgtException; + /** * Updates the display name of a device. * diff --git a/components/device-mgt/org.wso2.carbon.identity.device.mgt/src/main/java/org/wso2/carbon/identity/device/mgt/internal/constant/DeviceMgtSQLConstants.java b/components/device-mgt/org.wso2.carbon.identity.device.mgt/src/main/java/org/wso2/carbon/identity/device/mgt/internal/constant/DeviceMgtSQLConstants.java index 18ec920e5bd2..d1ad70f58e9b 100644 --- a/components/device-mgt/org.wso2.carbon.identity.device.mgt/src/main/java/org/wso2/carbon/identity/device/mgt/internal/constant/DeviceMgtSQLConstants.java +++ b/components/device-mgt/org.wso2.carbon.identity.device.mgt/src/main/java/org/wso2/carbon/identity/device/mgt/internal/constant/DeviceMgtSQLConstants.java @@ -137,6 +137,53 @@ public static final class Query { "INNER JOIN IDN_USER_DEVICE UD ON D.ID = UD.DEVICE_ID AND D.TENANT_ID = UD.TENANT_ID " + "WHERE D.TENANT_ID = :TENANT_ID;"; + // Paginated tenant-wide device listing filtered to a single user. Same DB-specific pagination + // variants as GET_ALL_DEVICES_PAGINATED, with an added user id condition. + + // Default: H2, MySQL, MariaDB, PostgreSQL. + public static final String GET_ALL_DEVICES_PAGINATED_BY_USER = + "SELECT D.ID, UD.USER_ID, D.DEVICE_NAME, D.DEVICE_MODEL, D.PUBLIC_KEY, D.STATUS, " + + "D.REGISTERED_AT, D.METADATA, D.TENANT_ID " + + "FROM IDN_DEVICE D " + + "INNER JOIN IDN_USER_DEVICE UD ON D.ID = UD.DEVICE_ID AND D.TENANT_ID = UD.TENANT_ID " + + "WHERE D.TENANT_ID = :TENANT_ID; AND UD.USER_ID = :USER_ID; " + + "ORDER BY D.REGISTERED_AT DESC LIMIT :LIMIT; OFFSET :OFFSET;"; + + // MS SQL Server. + public static final String GET_ALL_DEVICES_PAGINATED_BY_USER_MSSQL = + "SELECT D.ID, UD.USER_ID, D.DEVICE_NAME, D.DEVICE_MODEL, D.PUBLIC_KEY, D.STATUS, " + + "D.REGISTERED_AT, D.METADATA, D.TENANT_ID " + + "FROM IDN_DEVICE D " + + "INNER JOIN IDN_USER_DEVICE UD ON D.ID = UD.DEVICE_ID AND D.TENANT_ID = UD.TENANT_ID " + + "WHERE D.TENANT_ID = :TENANT_ID; AND UD.USER_ID = :USER_ID; " + + "ORDER BY D.REGISTERED_AT DESC OFFSET :OFFSET; ROWS FETCH NEXT :LIMIT; ROWS ONLY"; + + // Oracle. + public static final String GET_ALL_DEVICES_PAGINATED_BY_USER_ORACLE = + "SELECT ID, USER_ID, DEVICE_NAME, DEVICE_MODEL, PUBLIC_KEY, STATUS, REGISTERED_AT, " + + "METADATA, TENANT_ID FROM (SELECT ID, USER_ID, DEVICE_NAME, DEVICE_MODEL, PUBLIC_KEY, " + + "STATUS, REGISTERED_AT, METADATA, TENANT_ID, rownum AS rnum FROM " + + "(SELECT D.ID, UD.USER_ID, D.DEVICE_NAME, D.DEVICE_MODEL, D.PUBLIC_KEY, D.STATUS, " + + "D.REGISTERED_AT, D.METADATA, D.TENANT_ID FROM IDN_DEVICE D " + + "INNER JOIN IDN_USER_DEVICE UD ON D.ID = UD.DEVICE_ID AND D.TENANT_ID = UD.TENANT_ID " + + "WHERE D.TENANT_ID = :TENANT_ID; AND UD.USER_ID = :USER_ID; ORDER BY D.REGISTERED_AT DESC) " + + "WHERE rownum <= :UPPER_BOUND;) WHERE rnum > :OFFSET;"; + + // DB2. + public static final String GET_ALL_DEVICES_PAGINATED_BY_USER_DB2 = + "SELECT ID, USER_ID, DEVICE_NAME, DEVICE_MODEL, PUBLIC_KEY, STATUS, REGISTERED_AT, " + + "METADATA, TENANT_ID FROM (SELECT ROW_NUMBER() OVER(ORDER BY D.REGISTERED_AT DESC) AS rn, " + + "D.ID, UD.USER_ID, D.DEVICE_NAME, D.DEVICE_MODEL, D.PUBLIC_KEY, D.STATUS, " + + "D.REGISTERED_AT, D.METADATA, D.TENANT_ID FROM IDN_DEVICE D " + + "INNER JOIN IDN_USER_DEVICE UD ON D.ID = UD.DEVICE_ID AND D.TENANT_ID = UD.TENANT_ID " + + "WHERE D.TENANT_ID = :TENANT_ID; AND UD.USER_ID = :USER_ID;) " + + "WHERE rn BETWEEN :LOWER_BOUND; AND :UPPER_BOUND;"; + + public static final String GET_DEVICES_COUNT_BY_USER = + "SELECT COUNT(*) FROM IDN_DEVICE D " + + "INNER JOIN IDN_USER_DEVICE UD ON D.ID = UD.DEVICE_ID AND D.TENANT_ID = UD.TENANT_ID " + + "WHERE D.TENANT_ID = :TENANT_ID; AND UD.USER_ID = :USER_ID;"; + public static final String DELETE_USER_DEVICE = "DELETE FROM IDN_USER_DEVICE " + "WHERE DEVICE_ID = :DEVICE_ID; AND TENANT_ID = :TENANT_ID;"; diff --git a/components/device-mgt/org.wso2.carbon.identity.device.mgt/src/main/java/org/wso2/carbon/identity/device/mgt/internal/dao/DeviceManagementDAO.java b/components/device-mgt/org.wso2.carbon.identity.device.mgt/src/main/java/org/wso2/carbon/identity/device/mgt/internal/dao/DeviceManagementDAO.java index cf0de24869b4..c471e41e5c8c 100644 --- a/components/device-mgt/org.wso2.carbon.identity.device.mgt/src/main/java/org/wso2/carbon/identity/device/mgt/internal/dao/DeviceManagementDAO.java +++ b/components/device-mgt/org.wso2.carbon.identity.device.mgt/src/main/java/org/wso2/carbon/identity/device/mgt/internal/dao/DeviceManagementDAO.java @@ -73,6 +73,21 @@ List getDevicesByUserId(String userId, int tenantId) List getDevices(int tenantId, int offset, int limit) throws DeviceMgtException; + /** + * Finds a page of devices registered in the tenant, ordered by registration time (newest + * first), optionally filtered to a single user. This is an admin/tenant-scoped listing that + * returns devices of any status (ACTIVE or INACTIVE). + * + * @param tenantId Tenant identifier. + * @param offset Number of records to skip. + * @param limit Maximum number of records to return. + * @param userId User identifier to filter by, or {@code null}/blank for no filtering. + * @return Page of devices in the tenant. + * @throws DeviceMgtException If retrieval fails. + */ + List getDevices(int tenantId, int offset, int limit, String userId) + throws DeviceMgtException; + /** * Counts all devices registered in the tenant. * @@ -83,6 +98,18 @@ List getDevices(int tenantId, int offset, int limit) int getDeviceCount(int tenantId) throws DeviceMgtException; + /** + * Counts devices registered in the tenant, optionally filtered to a single user. This is an + * admin/tenant-scoped count that includes devices of any status (ACTIVE or INACTIVE). + * + * @param tenantId Tenant identifier. + * @param userId User identifier to filter by, or {@code null}/blank for no filtering. + * @return Total number of matching devices in the tenant. + * @throws DeviceMgtException If the count fails. + */ + int getDeviceCount(int tenantId, String userId) + throws DeviceMgtException; + /** * Updates the name of a device. * diff --git a/components/device-mgt/org.wso2.carbon.identity.device.mgt/src/main/java/org/wso2/carbon/identity/device/mgt/internal/dao/impl/CacheBackedDeviceManagementDAO.java b/components/device-mgt/org.wso2.carbon.identity.device.mgt/src/main/java/org/wso2/carbon/identity/device/mgt/internal/dao/impl/CacheBackedDeviceManagementDAO.java index 735fb6b4c0e7..409e19699d0f 100644 --- a/components/device-mgt/org.wso2.carbon.identity.device.mgt/src/main/java/org/wso2/carbon/identity/device/mgt/internal/dao/impl/CacheBackedDeviceManagementDAO.java +++ b/components/device-mgt/org.wso2.carbon.identity.device.mgt/src/main/java/org/wso2/carbon/identity/device/mgt/internal/dao/impl/CacheBackedDeviceManagementDAO.java @@ -134,6 +134,23 @@ public List getDevices(int tenantId, int offset, int limit) throws Devic return deviceManagementDAO.getDevices(tenantId, offset, limit); } + /** + * Finds a page of devices registered in the tenant, optionally filtered to a single user. + * This method is not cached; list results are not invalidated entry-by-entry. + * + * @param tenantId Tenant identifier. + * @param offset Number of records to skip. + * @param limit Maximum number of records to return. + * @param userId User identifier to filter by, or {@code null}/blank for no filtering. + * @return Page of devices in the tenant. + * @throws DeviceMgtException If retrieval fails. + */ + @Override + public List getDevices(int tenantId, int offset, int limit, String userId) throws DeviceMgtException { + + return deviceManagementDAO.getDevices(tenantId, offset, limit, userId); + } + /** * Counts all devices registered in the tenant. * This method is not cached. @@ -148,6 +165,21 @@ public int getDeviceCount(int tenantId) throws DeviceMgtException { return deviceManagementDAO.getDeviceCount(tenantId); } + /** + * Counts devices registered in the tenant, optionally filtered to a single user. + * This method is not cached. + * + * @param tenantId Tenant identifier. + * @param userId User identifier to filter by, or {@code null}/blank for no filtering. + * @return Total number of matching devices in the tenant. + * @throws DeviceMgtException If the count fails. + */ + @Override + public int getDeviceCount(int tenantId, String userId) throws DeviceMgtException { + + return deviceManagementDAO.getDeviceCount(tenantId, userId); + } + /** * Updates the name of a device. * This method clears the cache entry upon device name update. diff --git a/components/device-mgt/org.wso2.carbon.identity.device.mgt/src/main/java/org/wso2/carbon/identity/device/mgt/internal/dao/impl/DeviceManagementDAOImpl.java b/components/device-mgt/org.wso2.carbon.identity.device.mgt/src/main/java/org/wso2/carbon/identity/device/mgt/internal/dao/impl/DeviceManagementDAOImpl.java index 2471b294f605..cc8d8ac04b29 100644 --- a/components/device-mgt/org.wso2.carbon.identity.device.mgt/src/main/java/org/wso2/carbon/identity/device/mgt/internal/dao/impl/DeviceManagementDAOImpl.java +++ b/components/device-mgt/org.wso2.carbon.identity.device.mgt/src/main/java/org/wso2/carbon/identity/device/mgt/internal/dao/impl/DeviceManagementDAOImpl.java @@ -160,16 +160,23 @@ public List getDevicesByUserId(String userId, int tenantId) @Override public List getDevices(int tenantId, int offset, int limit) throws DeviceMgtException { + return getDevices(tenantId, offset, limit, null); + } + + @Override + public List getDevices(int tenantId, int offset, int limit, String userId) throws DeviceMgtException { + // FETCH NEXT 0 ROWS (MS SQL) is invalid and an empty page is meaningless, so short-circuit. if (limit <= 0) { return Collections.emptyList(); } int safeOffset = Math.max(offset, 0); + boolean filterByUser = isNotBlank(userId); NamedJdbcTemplate jdbcTemplate = new NamedJdbcTemplate(IdentityDatabaseUtil.getDataSource()); try { PaginationStyle style = resolvePaginationStyle(); - String query = resolvePaginatedQuery(style); + String query = resolvePaginatedQuery(style, filterByUser); List devices = jdbcTemplate., RuntimeException>withTransaction( template -> template.executeQuery( query, @@ -186,6 +193,9 @@ public List getDevices(int tenantId, int offset, int limit) throws Devic .build(), preparedStatement -> { preparedStatement.setInt(DeviceMgtSQLConstants.Column.TENANT_ID, tenantId); + if (filterByUser) { + preparedStatement.setString(DeviceMgtSQLConstants.Column.USER_ID, userId); + } bindPaginationParams(preparedStatement, style, safeOffset, limit); })); return devices != null ? devices : Collections.emptyList(); @@ -198,14 +208,29 @@ public List getDevices(int tenantId, int offset, int limit) throws Devic @Override public int getDeviceCount(int tenantId) throws DeviceMgtException { + return getDeviceCount(tenantId, null); + } + + @Override + public int getDeviceCount(int tenantId, String userId) throws DeviceMgtException { + + boolean filterByUser = isNotBlank(userId); + String query = filterByUser + ? DeviceMgtSQLConstants.Query.GET_DEVICES_COUNT_BY_USER + : DeviceMgtSQLConstants.Query.GET_DEVICES_COUNT; + NamedJdbcTemplate jdbcTemplate = new NamedJdbcTemplate(IdentityDatabaseUtil.getDataSource()); try { Integer count = jdbcTemplate.withTransaction( template -> template.fetchSingleRecord( - DeviceMgtSQLConstants.Query.GET_DEVICES_COUNT, + query, (resultSet, rowNumber) -> resultSet.getInt(1), - preparedStatement -> preparedStatement.setInt( - DeviceMgtSQLConstants.Column.TENANT_ID, tenantId))); + preparedStatement -> { + preparedStatement.setInt(DeviceMgtSQLConstants.Column.TENANT_ID, tenantId); + if (filterByUser) { + preparedStatement.setString(DeviceMgtSQLConstants.Column.USER_ID, userId); + } + })); return count != null ? count : 0; } catch (TransactionException e) { throw DeviceManagementExceptionHandler.handleServerException( @@ -213,6 +238,11 @@ public int getDeviceCount(int tenantId) throws DeviceMgtException { } } + private static boolean isNotBlank(String value) { + + return value != null && !value.trim().isEmpty(); + } + /** * Supported database-specific pagination dialects. */ @@ -235,8 +265,20 @@ private PaginationStyle resolvePaginationStyle() throws DataAccessException { return PaginationStyle.DEFAULT; } - private String resolvePaginatedQuery(PaginationStyle style) { - + private String resolvePaginatedQuery(PaginationStyle style, boolean filterByUser) { + + if (filterByUser) { + switch (style) { + case ORACLE: + return DeviceMgtSQLConstants.Query.GET_ALL_DEVICES_PAGINATED_BY_USER_ORACLE; + case DB2: + return DeviceMgtSQLConstants.Query.GET_ALL_DEVICES_PAGINATED_BY_USER_DB2; + case MSSQL: + return DeviceMgtSQLConstants.Query.GET_ALL_DEVICES_PAGINATED_BY_USER_MSSQL; + default: + return DeviceMgtSQLConstants.Query.GET_ALL_DEVICES_PAGINATED_BY_USER; + } + } switch (style) { case ORACLE: return DeviceMgtSQLConstants.Query.GET_ALL_DEVICES_PAGINATED_ORACLE; diff --git a/components/device-mgt/org.wso2.carbon.identity.device.mgt/src/main/java/org/wso2/carbon/identity/device/mgt/internal/service/impl/DeviceManagementServiceImpl.java b/components/device-mgt/org.wso2.carbon.identity.device.mgt/src/main/java/org/wso2/carbon/identity/device/mgt/internal/service/impl/DeviceManagementServiceImpl.java index 6f8738228731..b132e47a67db 100644 --- a/components/device-mgt/org.wso2.carbon.identity.device.mgt/src/main/java/org/wso2/carbon/identity/device/mgt/internal/service/impl/DeviceManagementServiceImpl.java +++ b/components/device-mgt/org.wso2.carbon.identity.device.mgt/src/main/java/org/wso2/carbon/identity/device/mgt/internal/service/impl/DeviceManagementServiceImpl.java @@ -107,12 +107,26 @@ public List getDevices(String tenantDomain, int offset, int limit) throw IdentityTenantUtil.getTenantId(tenantDomain), offset, validateLimit(limit)); } + @Override + public List getDevices(String tenantDomain, int offset, int limit, String userId) + throws DeviceMgtException { + + return deviceManagementDAO.getDevices( + IdentityTenantUtil.getTenantId(tenantDomain), offset, validateLimit(limit), userId); + } + @Override public int getDeviceCount(String tenantDomain) throws DeviceMgtException { return deviceManagementDAO.getDeviceCount(IdentityTenantUtil.getTenantId(tenantDomain)); } + @Override + public int getDeviceCount(String tenantDomain, String userId) throws DeviceMgtException { + + return deviceManagementDAO.getDeviceCount(IdentityTenantUtil.getTenantId(tenantDomain), userId); + } + @Override public Device updateDeviceName(String deviceId, String deviceName, String tenantDomain) throws DeviceMgtException { diff --git a/components/device-mgt/org.wso2.carbon.identity.device.mgt/src/main/java/org/wso2/carbon/identity/device/mgt/internal/util/DeviceManagementAuditLogger.java b/components/device-mgt/org.wso2.carbon.identity.device.mgt/src/main/java/org/wso2/carbon/identity/device/mgt/internal/util/DeviceManagementAuditLogger.java index 50325d7c6889..d94ee6c6d264 100644 --- a/components/device-mgt/org.wso2.carbon.identity.device.mgt/src/main/java/org/wso2/carbon/identity/device/mgt/internal/util/DeviceManagementAuditLogger.java +++ b/components/device-mgt/org.wso2.carbon.identity.device.mgt/src/main/java/org/wso2/carbon/identity/device/mgt/internal/util/DeviceManagementAuditLogger.java @@ -94,8 +94,8 @@ private void buildAuditLog(String targetId, Operation operation, JSONObject data /** * Create audit log data for the given device. - * The device owner is masked and the public key is reduced to a fingerprint; the raw public key - * and free-form metadata are never logged. + * The device owner is recorded as the opaque user ID (a UUID, not PII, so not masked) and the + * public key is reduced to a fingerprint; the raw public key and free-form metadata are never logged. * * @param device Device to be logged. * @return Audit log data as a JSONObject. @@ -113,7 +113,7 @@ private JSONObject createAuditLogEntry(Device device) { data.put(LogConstants.REGISTERED_AT_FIELD, device.getRegisteredAt() != null ? String.valueOf(device.getRegisteredAt()) : JSONObject.NULL); data.put(LogConstants.USER_ID_FIELD, device.getUserId() != null - ? LoggerUtils.getMaskedContent(device.getUserId()) : JSONObject.NULL); + ? device.getUserId() : JSONObject.NULL); data.put(LogConstants.PUBLIC_KEY_FINGERPRINT_FIELD, device.getPublicKey() != null ? fingerprint(device.getPublicKey()) : JSONObject.NULL); return data; diff --git a/components/device-mgt/org.wso2.carbon.identity.device.mgt/src/test/java/org/wso2/carbon/identity/device/mgt/dao/CacheBackedDeviceManagementDAOTest.java b/components/device-mgt/org.wso2.carbon.identity.device.mgt/src/test/java/org/wso2/carbon/identity/device/mgt/dao/CacheBackedDeviceManagementDAOTest.java index 6c74e3da7a3c..11895d888376 100644 --- a/components/device-mgt/org.wso2.carbon.identity.device.mgt/src/test/java/org/wso2/carbon/identity/device/mgt/dao/CacheBackedDeviceManagementDAOTest.java +++ b/components/device-mgt/org.wso2.carbon.identity.device.mgt/src/test/java/org/wso2/carbon/identity/device/mgt/dao/CacheBackedDeviceManagementDAOTest.java @@ -35,6 +35,9 @@ import org.wso2.carbon.identity.device.mgt.internal.dao.DeviceManagementDAO; import org.wso2.carbon.identity.device.mgt.internal.dao.impl.CacheBackedDeviceManagementDAO; +import java.util.Collections; +import java.util.List; + import static org.mockito.Mockito.CALLS_REAL_METHODS; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.mockStatic; @@ -205,6 +208,30 @@ public void testDeleteDeviceInvalidatesCache() throws DeviceMgtException { verify(deviceManagementDAO).getDeviceById(DEVICE_ID, TENANT_ID); } + @Test + public void testGetDevicesFilteredByUserDelegatesToDao() throws DeviceMgtException { + + Device device = mock(Device.class); + when(deviceManagementDAO.getDevices(TENANT_ID, 0, 100, "alice@example.com")) + .thenReturn(Collections.singletonList(device)); + + List result = cacheBackedDeviceManagementDAO.getDevices(TENANT_ID, 0, 100, "alice@example.com"); + + assertEquals(result, Collections.singletonList(device)); + verify(deviceManagementDAO).getDevices(TENANT_ID, 0, 100, "alice@example.com"); + } + + @Test + public void testGetDeviceCountFilteredByUserDelegatesToDao() throws DeviceMgtException { + + when(deviceManagementDAO.getDeviceCount(TENANT_ID, "alice@example.com")).thenReturn(2); + + int result = cacheBackedDeviceManagementDAO.getDeviceCount(TENANT_ID, "alice@example.com"); + + assertEquals(result, 2); + verify(deviceManagementDAO).getDeviceCount(TENANT_ID, "alice@example.com"); + } + @Test public void testTenantIsolation() throws DeviceMgtException { diff --git a/components/device-mgt/org.wso2.carbon.identity.device.mgt/src/test/java/org/wso2/carbon/identity/device/mgt/dao/DeviceManagementDAOImplTest.java b/components/device-mgt/org.wso2.carbon.identity.device.mgt/src/test/java/org/wso2/carbon/identity/device/mgt/dao/DeviceManagementDAOImplTest.java index ae33a5289bac..6faf38031a66 100644 --- a/components/device-mgt/org.wso2.carbon.identity.device.mgt/src/test/java/org/wso2/carbon/identity/device/mgt/dao/DeviceManagementDAOImplTest.java +++ b/components/device-mgt/org.wso2.carbon.identity.device.mgt/src/test/java/org/wso2/carbon/identity/device/mgt/dao/DeviceManagementDAOImplTest.java @@ -344,6 +344,64 @@ public void testChangeDeviceStatusActivateReincludesInUserDeviceList() throws De Assert.assertEquals(userDevices.get(0).getId(), id); } + /** + * Tests that getDevices(tenantId, offset, limit, userId) returns only the given user's + * devices, including one that is INACTIVE. + * + * @throws DeviceMgtException If the DAO operation fails. + */ + @Test(priority = 16) + public void testGetDevicesFilteredByUserIncludesInactive() throws DeviceMgtException { + + String userId = "ivan@example.com"; + String activeId = UUID.randomUUID().toString(); + String inactiveId = UUID.randomUUID().toString(); + deviceManagementDAO.registerDevice( + buildDeviceForUser(activeId, "Ivan Phone", userId, Device.Status.ACTIVE), TENANT_ID); + deviceManagementDAO.registerDevice( + buildDeviceForUser(inactiveId, "Ivan Tablet", userId, Device.Status.INACTIVE), TENANT_ID); + deviceManagementDAO.registerDevice( + buildDevice(UUID.randomUUID().toString(), "Someone Else Phone", Device.Status.ACTIVE), TENANT_ID); + + List devices = deviceManagementDAO.getDevices(TENANT_ID, 0, 100, userId); + + Assert.assertEquals(devices.size(), 2); + Assert.assertTrue(devices.stream().allMatch(d -> d.getUserId().equals(userId))); + Assert.assertTrue(devices.stream().anyMatch( + d -> d.getId().equals(inactiveId) && d.getStatus() == Device.Status.INACTIVE)); + } + + /** + * Tests that getDeviceCount(tenantId, userId) matches the number of devices returned by the + * filtered getDevices overload. + * + * @throws DeviceMgtException If the DAO operation fails. + */ + @Test(priority = 17, dependsOnMethods = {"testGetDevicesFilteredByUserIncludesInactive"}) + public void testGetDeviceCountFilteredByUser() throws DeviceMgtException { + + int count = deviceManagementDAO.getDeviceCount(TENANT_ID, "ivan@example.com"); + + Assert.assertEquals(count, 2); + } + + /** + * Tests that a null userId behaves exactly like the unfiltered getDevices/getDeviceCount. + * + * @throws DeviceMgtException If the DAO operation fails. + */ + @Test(priority = 18) + public void testGetDevicesAndCountWithNullUserIdMatchesUnfiltered() throws DeviceMgtException { + + List unfiltered = deviceManagementDAO.getDevices(TENANT_ID, 0, 100); + List withNullUser = deviceManagementDAO.getDevices(TENANT_ID, 0, 100, null); + int unfilteredCount = deviceManagementDAO.getDeviceCount(TENANT_ID); + int countWithNullUser = deviceManagementDAO.getDeviceCount(TENANT_ID, null); + + Assert.assertEquals(withNullUser.size(), unfiltered.size()); + Assert.assertEquals(countWithNullUser, unfilteredCount); + } + private Device buildDevice(String id, String deviceName, Device.Status status) { return new Device.Builder() diff --git a/components/device-mgt/org.wso2.carbon.identity.device.mgt/src/test/java/org/wso2/carbon/identity/device/mgt/service/DeviceManagementServiceImplTest.java b/components/device-mgt/org.wso2.carbon.identity.device.mgt/src/test/java/org/wso2/carbon/identity/device/mgt/service/DeviceManagementServiceImplTest.java index c549752eabaf..eb9c59453e53 100644 --- a/components/device-mgt/org.wso2.carbon.identity.device.mgt/src/test/java/org/wso2/carbon/identity/device/mgt/service/DeviceManagementServiceImplTest.java +++ b/components/device-mgt/org.wso2.carbon.identity.device.mgt/src/test/java/org/wso2/carbon/identity/device/mgt/service/DeviceManagementServiceImplTest.java @@ -348,6 +348,51 @@ public void testGetDevicesWithNegativeLimitThrows() { } } + @Test + public void testGetDevicesFilteredByUserPassesUserIdThrough() throws Exception { + + service.getDevices(TENANT_DOMAIN, 5, 20, "alice@example.com"); + + verify(dao).getDevices(TENANT_ID, 5, 20, "alice@example.com"); + } + + @Test + public void testGetDevicesFilteredByUserCapsLimitAtMaximumItemsPerPage() throws Exception { + + int maximumItemsPerPage = IdentityUtil.getMaximumItemPerPage(); + + service.getDevices(TENANT_DOMAIN, 0, maximumItemsPerPage + 5000, "alice@example.com"); + + verify(dao).getDevices(TENANT_ID, 0, maximumItemsPerPage, "alice@example.com"); + } + + @Test + public void testGetDevicesFilteredByUserWithNullUserIdDelegatesWithNull() throws Exception { + + service.getDevices(TENANT_DOMAIN, 0, 20, null); + + verify(dao).getDevices(TENANT_ID, 0, 20, null); + } + + @Test + public void testGetDeviceCountFilteredByUserPassesUserIdThrough() throws Exception { + + when(dao.getDeviceCount(TENANT_ID, "alice@example.com")).thenReturn(3); + + int result = service.getDeviceCount(TENANT_DOMAIN, "alice@example.com"); + + Assert.assertEquals(result, 3); + verify(dao).getDeviceCount(TENANT_ID, "alice@example.com"); + } + + @Test + public void testGetDeviceCountFilteredByUserWithNullUserIdDelegatesWithNull() throws Exception { + + service.getDeviceCount(TENANT_DOMAIN, null); + + verify(dao).getDeviceCount(TENANT_ID, null); + } + @Test public void testDeactivateDeviceDelegatesToDao() throws Exception { From 2776de03c497ccf6a6a115a46509e10c41992bea Mon Sep 17 00:00:00 2001 From: kaviska Date: Tue, 21 Jul 2026 07:03:39 +0530 Subject: [PATCH 7/8] Make device listing pagination deterministic and log retrieval counts Add a D.ID tiebreaker to every paginated listing query and an outer ordering for the Oracle and DB2 variants, so pages do not duplicate or drop rows when registration timestamps tie. Add debug logs for the device listing and count retrievals. --- .../constant/DeviceMgtSQLConstants.java | 28 +++++++++++-------- .../dao/impl/DeviceManagementDAOImpl.java | 12 ++++++-- 2 files changed, 26 insertions(+), 14 deletions(-) diff --git a/components/device-mgt/org.wso2.carbon.identity.device.mgt/src/main/java/org/wso2/carbon/identity/device/mgt/internal/constant/DeviceMgtSQLConstants.java b/components/device-mgt/org.wso2.carbon.identity.device.mgt/src/main/java/org/wso2/carbon/identity/device/mgt/internal/constant/DeviceMgtSQLConstants.java index d1ad70f58e9b..dac7fcb916b9 100644 --- a/components/device-mgt/org.wso2.carbon.identity.device.mgt/src/main/java/org/wso2/carbon/identity/device/mgt/internal/constant/DeviceMgtSQLConstants.java +++ b/components/device-mgt/org.wso2.carbon.identity.device.mgt/src/main/java/org/wso2/carbon/identity/device/mgt/internal/constant/DeviceMgtSQLConstants.java @@ -100,7 +100,7 @@ public static final class Query { "D.REGISTERED_AT, D.METADATA, D.TENANT_ID " + "FROM IDN_DEVICE D " + "INNER JOIN IDN_USER_DEVICE UD ON D.ID = UD.DEVICE_ID AND D.TENANT_ID = UD.TENANT_ID " + - "WHERE D.TENANT_ID = :TENANT_ID; ORDER BY D.REGISTERED_AT DESC " + + "WHERE D.TENANT_ID = :TENANT_ID; ORDER BY D.REGISTERED_AT DESC, D.ID DESC " + "LIMIT :LIMIT; OFFSET :OFFSET;"; // MS SQL Server. @@ -109,7 +109,7 @@ public static final class Query { "D.REGISTERED_AT, D.METADATA, D.TENANT_ID " + "FROM IDN_DEVICE D " + "INNER JOIN IDN_USER_DEVICE UD ON D.ID = UD.DEVICE_ID AND D.TENANT_ID = UD.TENANT_ID " + - "WHERE D.TENANT_ID = :TENANT_ID; ORDER BY D.REGISTERED_AT DESC " + + "WHERE D.TENANT_ID = :TENANT_ID; ORDER BY D.REGISTERED_AT DESC, D.ID DESC " + "OFFSET :OFFSET; ROWS FETCH NEXT :LIMIT; ROWS ONLY"; // Oracle. @@ -120,17 +120,19 @@ public static final class Query { "(SELECT D.ID, UD.USER_ID, D.DEVICE_NAME, D.DEVICE_MODEL, D.PUBLIC_KEY, D.STATUS, " + "D.REGISTERED_AT, D.METADATA, D.TENANT_ID FROM IDN_DEVICE D " + "INNER JOIN IDN_USER_DEVICE UD ON D.ID = UD.DEVICE_ID AND D.TENANT_ID = UD.TENANT_ID " + - "WHERE D.TENANT_ID = :TENANT_ID; ORDER BY D.REGISTERED_AT DESC) " + - "WHERE rownum <= :UPPER_BOUND;) WHERE rnum > :OFFSET;"; + "WHERE D.TENANT_ID = :TENANT_ID; ORDER BY D.REGISTERED_AT DESC, D.ID DESC) " + + "WHERE rownum <= :UPPER_BOUND;) WHERE rnum > :OFFSET; ORDER BY rnum"; // DB2. public static final String GET_ALL_DEVICES_PAGINATED_DB2 = "SELECT ID, USER_ID, DEVICE_NAME, DEVICE_MODEL, PUBLIC_KEY, STATUS, REGISTERED_AT, " + - "METADATA, TENANT_ID FROM (SELECT ROW_NUMBER() OVER(ORDER BY D.REGISTERED_AT DESC) AS rn, " + + "METADATA, TENANT_ID FROM (SELECT " + + "ROW_NUMBER() OVER(ORDER BY D.REGISTERED_AT DESC, D.ID DESC) AS rn, " + "D.ID, UD.USER_ID, D.DEVICE_NAME, D.DEVICE_MODEL, D.PUBLIC_KEY, D.STATUS, " + "D.REGISTERED_AT, D.METADATA, D.TENANT_ID FROM IDN_DEVICE D " + "INNER JOIN IDN_USER_DEVICE UD ON D.ID = UD.DEVICE_ID AND D.TENANT_ID = UD.TENANT_ID " + - "WHERE D.TENANT_ID = :TENANT_ID;) WHERE rn BETWEEN :LOWER_BOUND; AND :UPPER_BOUND;"; + "WHERE D.TENANT_ID = :TENANT_ID;) WHERE rn BETWEEN :LOWER_BOUND; AND :UPPER_BOUND; " + + "ORDER BY rn"; public static final String GET_DEVICES_COUNT = "SELECT COUNT(*) FROM IDN_DEVICE D " + @@ -147,7 +149,7 @@ public static final class Query { "FROM IDN_DEVICE D " + "INNER JOIN IDN_USER_DEVICE UD ON D.ID = UD.DEVICE_ID AND D.TENANT_ID = UD.TENANT_ID " + "WHERE D.TENANT_ID = :TENANT_ID; AND UD.USER_ID = :USER_ID; " + - "ORDER BY D.REGISTERED_AT DESC LIMIT :LIMIT; OFFSET :OFFSET;"; + "ORDER BY D.REGISTERED_AT DESC, D.ID DESC LIMIT :LIMIT; OFFSET :OFFSET;"; // MS SQL Server. public static final String GET_ALL_DEVICES_PAGINATED_BY_USER_MSSQL = @@ -156,7 +158,7 @@ public static final class Query { "FROM IDN_DEVICE D " + "INNER JOIN IDN_USER_DEVICE UD ON D.ID = UD.DEVICE_ID AND D.TENANT_ID = UD.TENANT_ID " + "WHERE D.TENANT_ID = :TENANT_ID; AND UD.USER_ID = :USER_ID; " + - "ORDER BY D.REGISTERED_AT DESC OFFSET :OFFSET; ROWS FETCH NEXT :LIMIT; ROWS ONLY"; + "ORDER BY D.REGISTERED_AT DESC, D.ID DESC OFFSET :OFFSET; ROWS FETCH NEXT :LIMIT; ROWS ONLY"; // Oracle. public static final String GET_ALL_DEVICES_PAGINATED_BY_USER_ORACLE = @@ -166,18 +168,20 @@ public static final class Query { "(SELECT D.ID, UD.USER_ID, D.DEVICE_NAME, D.DEVICE_MODEL, D.PUBLIC_KEY, D.STATUS, " + "D.REGISTERED_AT, D.METADATA, D.TENANT_ID FROM IDN_DEVICE D " + "INNER JOIN IDN_USER_DEVICE UD ON D.ID = UD.DEVICE_ID AND D.TENANT_ID = UD.TENANT_ID " + - "WHERE D.TENANT_ID = :TENANT_ID; AND UD.USER_ID = :USER_ID; ORDER BY D.REGISTERED_AT DESC) " + - "WHERE rownum <= :UPPER_BOUND;) WHERE rnum > :OFFSET;"; + "WHERE D.TENANT_ID = :TENANT_ID; AND UD.USER_ID = :USER_ID; " + + "ORDER BY D.REGISTERED_AT DESC, D.ID DESC) " + + "WHERE rownum <= :UPPER_BOUND;) WHERE rnum > :OFFSET; ORDER BY rnum"; // DB2. public static final String GET_ALL_DEVICES_PAGINATED_BY_USER_DB2 = "SELECT ID, USER_ID, DEVICE_NAME, DEVICE_MODEL, PUBLIC_KEY, STATUS, REGISTERED_AT, " + - "METADATA, TENANT_ID FROM (SELECT ROW_NUMBER() OVER(ORDER BY D.REGISTERED_AT DESC) AS rn, " + + "METADATA, TENANT_ID FROM (SELECT " + + "ROW_NUMBER() OVER(ORDER BY D.REGISTERED_AT DESC, D.ID DESC) AS rn, " + "D.ID, UD.USER_ID, D.DEVICE_NAME, D.DEVICE_MODEL, D.PUBLIC_KEY, D.STATUS, " + "D.REGISTERED_AT, D.METADATA, D.TENANT_ID FROM IDN_DEVICE D " + "INNER JOIN IDN_USER_DEVICE UD ON D.ID = UD.DEVICE_ID AND D.TENANT_ID = UD.TENANT_ID " + "WHERE D.TENANT_ID = :TENANT_ID; AND UD.USER_ID = :USER_ID;) " + - "WHERE rn BETWEEN :LOWER_BOUND; AND :UPPER_BOUND;"; + "WHERE rn BETWEEN :LOWER_BOUND; AND :UPPER_BOUND; ORDER BY rn"; public static final String GET_DEVICES_COUNT_BY_USER = "SELECT COUNT(*) FROM IDN_DEVICE D " + diff --git a/components/device-mgt/org.wso2.carbon.identity.device.mgt/src/main/java/org/wso2/carbon/identity/device/mgt/internal/dao/impl/DeviceManagementDAOImpl.java b/components/device-mgt/org.wso2.carbon.identity.device.mgt/src/main/java/org/wso2/carbon/identity/device/mgt/internal/dao/impl/DeviceManagementDAOImpl.java index cc8d8ac04b29..8cea22c89ed9 100644 --- a/components/device-mgt/org.wso2.carbon.identity.device.mgt/src/main/java/org/wso2/carbon/identity/device/mgt/internal/dao/impl/DeviceManagementDAOImpl.java +++ b/components/device-mgt/org.wso2.carbon.identity.device.mgt/src/main/java/org/wso2/carbon/identity/device/mgt/internal/dao/impl/DeviceManagementDAOImpl.java @@ -198,7 +198,11 @@ public List getDevices(int tenantId, int offset, int limit, String userI } bindPaginationParams(preparedStatement, style, safeOffset, limit); })); - return devices != null ? devices : Collections.emptyList(); + List result = devices != null ? devices : Collections.emptyList(); + if (LOG.isDebugEnabled()) { + LOG.debug("Retrieved " + result.size() + " devices for tenant ID: " + tenantId); + } + return result; } catch (TransactionException | DataAccessException e) { throw DeviceManagementExceptionHandler.handleServerException( ErrorMessage.ERROR_WHILE_RETRIEVING_DEVICE, e); @@ -231,7 +235,11 @@ public int getDeviceCount(int tenantId, String userId) throws DeviceMgtException preparedStatement.setString(DeviceMgtSQLConstants.Column.USER_ID, userId); } })); - return count != null ? count : 0; + int result = count != null ? count : 0; + if (LOG.isDebugEnabled()) { + LOG.debug("Retrieved device count: " + result + " for tenant ID: " + tenantId); + } + return result; } catch (TransactionException e) { throw DeviceManagementExceptionHandler.handleServerException( ErrorMessage.ERROR_WHILE_RETRIEVING_DEVICE, e); From 560502ab1a8fbfed298354def71f5f08cff7a0eb Mon Sep 17 00:00:00 2001 From: kaviska Date: Tue, 21 Jul 2026 21:10:39 +0530 Subject: [PATCH 8/8] Extract device validations into DeviceValidator and rename persistDevice to registerDevice Move the validation logic out of the service into a DeviceValidator, use StringUtils for blank checks, drop the redundant existence pre-check before updates, and move the Status enum to the end of Device. --- .../pom.xml | 7 + .../device/mgt/api/constant/ErrorMessage.java | 6 +- .../identity/device/mgt/api/model/Device.java | 16 +- .../api/service/DeviceManagementService.java | 6 +- .../mgt/internal/dao/DeviceManagementDAO.java | 10 +- .../impl/CacheBackedDeviceManagementDAO.java | 8 +- .../dao/impl/DeviceManagementDAOImpl.java | 12 +- .../impl/DeviceManagementServiceImpl.java | 152 +++---------- .../util/DeviceManagementAuditLogger.java | 8 +- .../mgt/internal/util/DeviceValidator.java | 145 +++++++++++++ .../DeviceManagementServiceImplTest.java | 24 +-- .../device/mgt/util/DeviceValidatorTest.java | 201 ++++++++++++++++++ .../src/test/resources/testng.xml | 1 + 13 files changed, 422 insertions(+), 174 deletions(-) create mode 100644 components/device-mgt/org.wso2.carbon.identity.device.mgt/src/main/java/org/wso2/carbon/identity/device/mgt/internal/util/DeviceValidator.java create mode 100644 components/device-mgt/org.wso2.carbon.identity.device.mgt/src/test/java/org/wso2/carbon/identity/device/mgt/util/DeviceValidatorTest.java diff --git a/components/device-mgt/org.wso2.carbon.identity.device.mgt/pom.xml b/components/device-mgt/org.wso2.carbon.identity.device.mgt/pom.xml index 3840940472c3..cd6fc66c1821 100644 --- a/components/device-mgt/org.wso2.carbon.identity.device.mgt/pom.xml +++ b/components/device-mgt/org.wso2.carbon.identity.device.mgt/pom.xml @@ -72,6 +72,11 @@ commons-logging + + commons-lang.wso2 + commons-lang + + org.testng testng @@ -119,6 +124,8 @@ version="${carbon.identity.package.export.version}" + org.apache.commons.lang; + version="${commons-lang.wso2.osgi.version.range}", org.apache.commons.logging; version="${import.package.version.commons.logging}", org.osgi.framework; diff --git a/components/device-mgt/org.wso2.carbon.identity.device.mgt/src/main/java/org/wso2/carbon/identity/device/mgt/api/constant/ErrorMessage.java b/components/device-mgt/org.wso2.carbon.identity.device.mgt/src/main/java/org/wso2/carbon/identity/device/mgt/api/constant/ErrorMessage.java index 35c06f13fedf..d74d81b8f366 100644 --- a/components/device-mgt/org.wso2.carbon.identity.device.mgt/src/main/java/org/wso2/carbon/identity/device/mgt/api/constant/ErrorMessage.java +++ b/components/device-mgt/org.wso2.carbon.identity.device.mgt/src/main/java/org/wso2/carbon/identity/device/mgt/api/constant/ErrorMessage.java @@ -29,7 +29,7 @@ public enum ErrorMessage { "%s is empty or invalid."), ERROR_WHILE_REGISTERING_DEVICE("DM-65001", "Error while registering device.", - "Error while persisting device registration in the system."), + "Error while registering device in the system."), ERROR_WHILE_RETRIEVING_DEVICE("DM-65002", "Error while retrieving device.", "Error while retrieving device from the system."), ERROR_WHILE_UPDATING_DEVICE("DM-65003", "Error while updating device.", @@ -37,9 +37,9 @@ public enum ErrorMessage { ERROR_WHILE_DELETING_DEVICE("DM-65004", "Error while deleting device.", "Error while deleting device from the system."), ERROR_USER_ID_REQUIRED("DM-65007", "User identifier required.", - "Cannot persist device: a valid user identifier (userId) was not set before persistence."), + "Cannot register device: a valid user identifier (userId) was not set before registration."), ERROR_DEVICE_FIELD_REQUIRED("DM-65008", "Required device field missing.", - "Cannot persist device: the required field '%s' was not set before persistence."); + "Cannot register device: the required field '%s' was not set before registration."); private final String code; private final String message; diff --git a/components/device-mgt/org.wso2.carbon.identity.device.mgt/src/main/java/org/wso2/carbon/identity/device/mgt/api/model/Device.java b/components/device-mgt/org.wso2.carbon.identity.device.mgt/src/main/java/org/wso2/carbon/identity/device/mgt/api/model/Device.java index 35dc9d5b7a3c..354447c59469 100644 --- a/components/device-mgt/org.wso2.carbon.identity.device.mgt/src/main/java/org/wso2/carbon/identity/device/mgt/api/model/Device.java +++ b/components/device-mgt/org.wso2.carbon.identity.device.mgt/src/main/java/org/wso2/carbon/identity/device/mgt/api/model/Device.java @@ -25,14 +25,6 @@ */ public class Device { - /** - * Device Status Enum. - */ - public enum Status { - ACTIVE, - INACTIVE - } - private final String id; private final String userId; private final String deviceName; @@ -277,4 +269,12 @@ public Device build() { return new Device(this); } } + + /** + * Device Status Enum. + */ + public enum Status { + ACTIVE, + INACTIVE + } } diff --git a/components/device-mgt/org.wso2.carbon.identity.device.mgt/src/main/java/org/wso2/carbon/identity/device/mgt/api/service/DeviceManagementService.java b/components/device-mgt/org.wso2.carbon.identity.device.mgt/src/main/java/org/wso2/carbon/identity/device/mgt/api/service/DeviceManagementService.java index 7cab767954fc..61cd3b4656e0 100644 --- a/components/device-mgt/org.wso2.carbon.identity.device.mgt/src/main/java/org/wso2/carbon/identity/device/mgt/api/service/DeviceManagementService.java +++ b/components/device-mgt/org.wso2.carbon.identity.device.mgt/src/main/java/org/wso2/carbon/identity/device/mgt/api/service/DeviceManagementService.java @@ -29,12 +29,12 @@ public interface DeviceManagementService { /** - * Persists a pre-verified {@link Device} to the database. + * Registers a pre-verified {@link Device} in the database. * - * @param device The verified device to persist. + * @param device The verified device to register. * @param tenantDomain Tenant domain. */ - void persistDevice(Device device, String tenantDomain) throws DeviceMgtException; + void registerDevice(Device device, String tenantDomain) throws DeviceMgtException; /** * Retrieves a device by its UUID. Returns the device regardless of its status (ACTIVE or diff --git a/components/device-mgt/org.wso2.carbon.identity.device.mgt/src/main/java/org/wso2/carbon/identity/device/mgt/internal/dao/DeviceManagementDAO.java b/components/device-mgt/org.wso2.carbon.identity.device.mgt/src/main/java/org/wso2/carbon/identity/device/mgt/internal/dao/DeviceManagementDAO.java index c471e41e5c8c..e98c3d4d0c4d 100644 --- a/components/device-mgt/org.wso2.carbon.identity.device.mgt/src/main/java/org/wso2/carbon/identity/device/mgt/internal/dao/DeviceManagementDAO.java +++ b/components/device-mgt/org.wso2.carbon.identity.device.mgt/src/main/java/org/wso2/carbon/identity/device/mgt/internal/dao/DeviceManagementDAO.java @@ -24,17 +24,17 @@ import java.util.List; /** - * DAO contract for registered device persistence. + * DAO contract for device registration. */ public interface DeviceManagementDAO { /** - * Persists a new device. + * Registers a new device. * - * @param device Device to persist. + * @param device Device to register. * @param tenantId Tenant identifier. - * @return Persisted device. - * @throws DeviceMgtException If persistence fails. + * @return Registered device. + * @throws DeviceMgtException If registration fails. */ Device registerDevice(Device device, int tenantId) throws DeviceMgtException; diff --git a/components/device-mgt/org.wso2.carbon.identity.device.mgt/src/main/java/org/wso2/carbon/identity/device/mgt/internal/dao/impl/CacheBackedDeviceManagementDAO.java b/components/device-mgt/org.wso2.carbon.identity.device.mgt/src/main/java/org/wso2/carbon/identity/device/mgt/internal/dao/impl/CacheBackedDeviceManagementDAO.java index 409e19699d0f..418a5d0eb7f5 100644 --- a/components/device-mgt/org.wso2.carbon.identity.device.mgt/src/main/java/org/wso2/carbon/identity/device/mgt/internal/dao/impl/CacheBackedDeviceManagementDAO.java +++ b/components/device-mgt/org.wso2.carbon.identity.device.mgt/src/main/java/org/wso2/carbon/identity/device/mgt/internal/dao/impl/CacheBackedDeviceManagementDAO.java @@ -55,14 +55,14 @@ public CacheBackedDeviceManagementDAO(DeviceManagementDAO deviceManagementDAO) { } /** - * Persists a new device. + * Registers a new device. * This method directly invokes the data layer operation — a brand-new device has nothing * to invalidate in the cache. * - * @param device Device to persist. + * @param device Device to register. * @param tenantId Tenant identifier. - * @return Persisted device. - * @throws DeviceMgtException If persistence fails. + * @return Registered device. + * @throws DeviceMgtException If registration fails. */ @Override public Device registerDevice(Device device, int tenantId) throws DeviceMgtException { diff --git a/components/device-mgt/org.wso2.carbon.identity.device.mgt/src/main/java/org/wso2/carbon/identity/device/mgt/internal/dao/impl/DeviceManagementDAOImpl.java b/components/device-mgt/org.wso2.carbon.identity.device.mgt/src/main/java/org/wso2/carbon/identity/device/mgt/internal/dao/impl/DeviceManagementDAOImpl.java index 8cea22c89ed9..14cb2c5962af 100644 --- a/components/device-mgt/org.wso2.carbon.identity.device.mgt/src/main/java/org/wso2/carbon/identity/device/mgt/internal/dao/impl/DeviceManagementDAOImpl.java +++ b/components/device-mgt/org.wso2.carbon.identity.device.mgt/src/main/java/org/wso2/carbon/identity/device/mgt/internal/dao/impl/DeviceManagementDAOImpl.java @@ -18,6 +18,7 @@ package org.wso2.carbon.identity.device.mgt.internal.dao.impl; +import org.apache.commons.lang.StringUtils; import org.apache.commons.logging.Log; import org.apache.commons.logging.LogFactory; import org.wso2.carbon.database.utils.jdbc.NamedJdbcTemplate; @@ -38,7 +39,7 @@ import java.util.List; /** - * JDBC implementation for registered device persistence. + * JDBC implementation for device registration. */ public class DeviceManagementDAOImpl implements DeviceManagementDAO { @@ -171,7 +172,7 @@ public List getDevices(int tenantId, int offset, int limit, String userI return Collections.emptyList(); } int safeOffset = Math.max(offset, 0); - boolean filterByUser = isNotBlank(userId); + boolean filterByUser = StringUtils.isNotBlank(userId); NamedJdbcTemplate jdbcTemplate = new NamedJdbcTemplate(IdentityDatabaseUtil.getDataSource()); try { @@ -218,7 +219,7 @@ public int getDeviceCount(int tenantId) throws DeviceMgtException { @Override public int getDeviceCount(int tenantId, String userId) throws DeviceMgtException { - boolean filterByUser = isNotBlank(userId); + boolean filterByUser = StringUtils.isNotBlank(userId); String query = filterByUser ? DeviceMgtSQLConstants.Query.GET_DEVICES_COUNT_BY_USER : DeviceMgtSQLConstants.Query.GET_DEVICES_COUNT; @@ -246,11 +247,6 @@ public int getDeviceCount(int tenantId, String userId) throws DeviceMgtException } } - private static boolean isNotBlank(String value) { - - return value != null && !value.trim().isEmpty(); - } - /** * Supported database-specific pagination dialects. */ diff --git a/components/device-mgt/org.wso2.carbon.identity.device.mgt/src/main/java/org/wso2/carbon/identity/device/mgt/internal/service/impl/DeviceManagementServiceImpl.java b/components/device-mgt/org.wso2.carbon.identity.device.mgt/src/main/java/org/wso2/carbon/identity/device/mgt/internal/service/impl/DeviceManagementServiceImpl.java index b132e47a67db..38da3a2d0b09 100644 --- a/components/device-mgt/org.wso2.carbon.identity.device.mgt/src/main/java/org/wso2/carbon/identity/device/mgt/internal/service/impl/DeviceManagementServiceImpl.java +++ b/components/device-mgt/org.wso2.carbon.identity.device.mgt/src/main/java/org/wso2/carbon/identity/device/mgt/internal/service/impl/DeviceManagementServiceImpl.java @@ -21,9 +21,7 @@ import org.apache.commons.logging.Log; import org.apache.commons.logging.LogFactory; import org.wso2.carbon.identity.core.util.IdentityTenantUtil; -import org.wso2.carbon.identity.core.util.IdentityUtil; import org.wso2.carbon.identity.device.mgt.api.constant.ErrorMessage; -import org.wso2.carbon.identity.device.mgt.api.exception.DeviceMgtClientException; import org.wso2.carbon.identity.device.mgt.api.exception.DeviceMgtException; import org.wso2.carbon.identity.device.mgt.api.model.Device; import org.wso2.carbon.identity.device.mgt.api.service.DeviceManagementService; @@ -32,6 +30,7 @@ import org.wso2.carbon.identity.device.mgt.internal.dao.impl.DeviceManagementDAOImpl; import org.wso2.carbon.identity.device.mgt.internal.util.DeviceManagementAuditLogger; import org.wso2.carbon.identity.device.mgt.internal.util.DeviceManagementExceptionHandler; +import org.wso2.carbon.identity.device.mgt.internal.util.DeviceValidator; import java.util.List; @@ -43,6 +42,7 @@ public class DeviceManagementServiceImpl implements DeviceManagementService { private static final Log LOG = LogFactory.getLog(DeviceManagementServiceImpl.class); private static final DeviceManagementServiceImpl INSTANCE = new DeviceManagementServiceImpl(); private static final DeviceManagementAuditLogger AUDIT_LOGGER = new DeviceManagementAuditLogger(); + private static final DeviceValidator DEVICE_VALIDATOR = new DeviceValidator(); private final DeviceManagementDAO deviceManagementDAO; private DeviceManagementServiceImpl() { @@ -59,15 +59,15 @@ public static DeviceManagementServiceImpl getInstance() { } @Override - public void persistDevice(Device device, String tenantDomain) throws DeviceMgtException { + public void registerDevice(Device device, String tenantDomain) throws DeviceMgtException { - validateDeviceForPersistence(device); + DEVICE_VALIDATOR.validateDeviceForRegistration(device); deviceManagementDAO.registerDevice(device, IdentityTenantUtil.getTenantId(tenantDomain)); AUDIT_LOGGER.printAuditLog(DeviceManagementAuditLogger.Operation.REGISTER, device); if (LOG.isDebugEnabled()) { - LOG.debug("Device persisted for user: " + device.getUserId() + + LOG.debug("Device registered for user: " + device.getUserId() + " in tenant: " + tenantDomain + " with device ID: " + device.getId()); } } @@ -76,7 +76,7 @@ public void persistDevice(Device device, String tenantDomain) throws DeviceMgtEx public Device getDeviceById(String deviceId, String tenantDomain) throws DeviceMgtException { - validateRequiredField(deviceId, "deviceId"); + DEVICE_VALIDATOR.validateRequiredField(deviceId, "deviceId"); return deviceManagementDAO.getDeviceById( deviceId, IdentityTenantUtil.getTenantId(tenantDomain)); } @@ -95,7 +95,7 @@ public Device getActiveDeviceById(String deviceId, String tenantDomain) throws D public List getDevicesByUserId(String userId, String tenantDomain) throws DeviceMgtException { - validateRequiredField(userId, "userId"); + DEVICE_VALIDATOR.validateRequiredField(userId, "userId"); return deviceManagementDAO.getDevicesByUserId( userId, IdentityTenantUtil.getTenantId(tenantDomain)); } @@ -104,7 +104,7 @@ public List getDevicesByUserId(String userId, String tenantDomain) public List getDevices(String tenantDomain, int offset, int limit) throws DeviceMgtException { return deviceManagementDAO.getDevices( - IdentityTenantUtil.getTenantId(tenantDomain), offset, validateLimit(limit)); + IdentityTenantUtil.getTenantId(tenantDomain), offset, DEVICE_VALIDATOR.validateLimit(limit)); } @Override @@ -112,7 +112,7 @@ public List getDevices(String tenantDomain, int offset, int limit, Strin throws DeviceMgtException { return deviceManagementDAO.getDevices( - IdentityTenantUtil.getTenantId(tenantDomain), offset, validateLimit(limit), userId); + IdentityTenantUtil.getTenantId(tenantDomain), offset, DEVICE_VALIDATOR.validateLimit(limit), userId); } @Override @@ -131,34 +131,30 @@ public int getDeviceCount(String tenantDomain, String userId) throws DeviceMgtEx public Device updateDeviceName(String deviceId, String deviceName, String tenantDomain) throws DeviceMgtException { - validateRequiredField(deviceId, "deviceId"); - validateRequiredField(deviceName, "deviceName"); - validateDeviceExists(deviceId, tenantDomain); + DEVICE_VALIDATOR.validateRequiredField(deviceId, "deviceId"); + DEVICE_VALIDATOR.validateRequiredField(deviceName, "deviceName"); - Device updated = deviceManagementDAO.updateDeviceName( + Device updatedDevice = deviceManagementDAO.updateDeviceName( deviceId, deviceName, IdentityTenantUtil.getTenantId(tenantDomain)); - if (updated == null) { - throw DeviceManagementExceptionHandler.handleClientException( - ErrorMessage.ERROR_DEVICE_NOT_FOUND, deviceId); + if (updatedDevice == null) { + throw DeviceManagementExceptionHandler.handleClientException(ErrorMessage.ERROR_DEVICE_NOT_FOUND, deviceId); } - AUDIT_LOGGER.printAuditLog(DeviceManagementAuditLogger.Operation.UPDATE, updated); - return updated; + AUDIT_LOGGER.printAuditLog(DeviceManagementAuditLogger.Operation.UPDATE, updatedDevice); + return updatedDevice; } @Override public Device activateDevice(String deviceId, String tenantDomain) throws DeviceMgtException { - validateRequiredField(deviceId, "deviceId"); - validateDeviceExists(deviceId, tenantDomain); + DEVICE_VALIDATOR.validateRequiredField(deviceId, "deviceId"); Device updated = deviceManagementDAO.changeDeviceStatus( deviceId, Device.Status.ACTIVE, IdentityTenantUtil.getTenantId(tenantDomain)); if (updated == null) { - throw DeviceManagementExceptionHandler.handleClientException( - ErrorMessage.ERROR_DEVICE_NOT_FOUND, deviceId); + throw DeviceManagementExceptionHandler.handleClientException(ErrorMessage.ERROR_DEVICE_NOT_FOUND, deviceId); } AUDIT_LOGGER.printAuditLog(DeviceManagementAuditLogger.Operation.ACTIVATE, updated); @@ -172,15 +168,13 @@ public Device activateDevice(String deviceId, String tenantDomain) throws Device @Override public Device deactivateDevice(String deviceId, String tenantDomain) throws DeviceMgtException { - validateRequiredField(deviceId, "deviceId"); - validateDeviceExists(deviceId, tenantDomain); + DEVICE_VALIDATOR.validateRequiredField(deviceId, "deviceId"); Device updated = deviceManagementDAO.changeDeviceStatus( deviceId, Device.Status.INACTIVE, IdentityTenantUtil.getTenantId(tenantDomain)); if (updated == null) { - throw DeviceManagementExceptionHandler.handleClientException( - ErrorMessage.ERROR_DEVICE_NOT_FOUND, deviceId); + throw DeviceManagementExceptionHandler.handleClientException(ErrorMessage.ERROR_DEVICE_NOT_FOUND, deviceId); } AUDIT_LOGGER.printAuditLog(DeviceManagementAuditLogger.Operation.DEACTIVATE, updated); @@ -195,8 +189,11 @@ public Device deactivateDevice(String deviceId, String tenantDomain) throws Devi public void deleteDevice(String deviceId, String tenantDomain) throws DeviceMgtException { - validateRequiredField(deviceId, "deviceId"); - validateDeviceExists(deviceId, tenantDomain); + DEVICE_VALIDATOR.validateRequiredField(deviceId, "deviceId"); + + Device existing = deviceManagementDAO.getDeviceById( + deviceId, IdentityTenantUtil.getTenantId(tenantDomain)); + DEVICE_VALIDATOR.validateDeviceExists(existing, deviceId); deviceManagementDAO.deleteDevice( deviceId, IdentityTenantUtil.getTenantId(tenantDomain)); @@ -207,103 +204,4 @@ public void deleteDevice(String deviceId, String tenantDomain) LOG.debug("Device deleted with ID: " + deviceId + " in tenant: " + tenantDomain); } } - - private void validateDeviceExists(String deviceId, String tenantDomain) - throws DeviceMgtException { - - Device existing = deviceManagementDAO.getDeviceById( - deviceId, IdentityTenantUtil.getTenantId(tenantDomain)); - if (existing == null) { - throw DeviceManagementExceptionHandler.handleClientException( - ErrorMessage.ERROR_DEVICE_NOT_FOUND, deviceId); - } - } - - private void validateRequiredField(String value, String fieldName) - throws DeviceMgtClientException { - - if (value == null || value.trim().isEmpty()) { - throw DeviceManagementExceptionHandler.handleClientException( - ErrorMessage.ERROR_INVALID_DEVICE_FIELD, fieldName); - } - } - - /** - * Validates that the device carries every field the persistence layer requires. - * The device is constructed internally during the registration flow, so a missing field indicates - * that the device was not fully built before persistence rather than invalid user input. Validating - * here keeps such a failure a clear, coded error instead of a constraint violation or a - * NullPointerException raised inside the data layer. - * The device model and the metadata are optional and are therefore not validated. - * - * @param device Device to be persisted. - * @throws DeviceMgtException If the device or any of its required fields is not set. - */ - private void validateDeviceForPersistence(Device device) throws DeviceMgtException { - - if (device == null) { - throw DeviceManagementExceptionHandler.handleServerException( - ErrorMessage.ERROR_DEVICE_FIELD_REQUIRED, "device"); - } - if (device.getUserId() == null || device.getUserId().trim().isEmpty()) { - throw DeviceManagementExceptionHandler.handleServerException( - ErrorMessage.ERROR_USER_ID_REQUIRED); - } - - validateRequiredDeviceField(device.getId(), "id"); - validateRequiredDeviceField(device.getDeviceName(), "deviceName"); - validateRequiredDeviceField(device.getPublicKey(), "publicKey"); - - if (device.getStatus() == null) { - throw DeviceManagementExceptionHandler.handleServerException( - ErrorMessage.ERROR_DEVICE_FIELD_REQUIRED, "status"); - } - if (device.getRegisteredAt() == null) { - throw DeviceManagementExceptionHandler.handleServerException( - ErrorMessage.ERROR_DEVICE_FIELD_REQUIRED, "registeredAt"); - } - } - - /** - * Validates a required device field that is expected to be set before persistence. - * - * @param value Value of the field. - * @param fieldName Name of the field. - * @throws DeviceMgtException If the field is not set. - */ - private void validateRequiredDeviceField(String value, String fieldName) throws DeviceMgtException { - - if (value == null || value.trim().isEmpty()) { - throw DeviceManagementExceptionHandler.handleServerException( - ErrorMessage.ERROR_DEVICE_FIELD_REQUIRED, fieldName); - } - } - - /** - * Validates the requested page size against the maximum items per page configured for the server. - * A page size larger than the configured maximum is capped, so that a caller cannot load an - * unbounded number of devices into memory. - * - * @param limit Requested page size. - * @return The page size to use, capped at the configured maximum. - * @throws DeviceMgtClientException If the requested page size is negative. - */ - private int validateLimit(int limit) throws DeviceMgtClientException { - - if (limit < 0) { - throw DeviceManagementExceptionHandler.handleClientException( - ErrorMessage.ERROR_INVALID_DEVICE_FIELD, "limit"); - } - - int maximumItemsPerPage = IdentityUtil.getMaximumItemPerPage(); - if (limit > maximumItemsPerPage) { - if (LOG.isDebugEnabled()) { - LOG.debug("Given limit: " + limit + " exceeds the maximum items per page. Using the maximum: " - + maximumItemsPerPage); - } - return maximumItemsPerPage; - } - return limit; - } } - diff --git a/components/device-mgt/org.wso2.carbon.identity.device.mgt/src/main/java/org/wso2/carbon/identity/device/mgt/internal/util/DeviceManagementAuditLogger.java b/components/device-mgt/org.wso2.carbon.identity.device.mgt/src/main/java/org/wso2/carbon/identity/device/mgt/internal/util/DeviceManagementAuditLogger.java index d94ee6c6d264..dcf92bfe023d 100644 --- a/components/device-mgt/org.wso2.carbon.identity.device.mgt/src/main/java/org/wso2/carbon/identity/device/mgt/internal/util/DeviceManagementAuditLogger.java +++ b/components/device-mgt/org.wso2.carbon.identity.device.mgt/src/main/java/org/wso2/carbon/identity/device/mgt/internal/util/DeviceManagementAuditLogger.java @@ -18,6 +18,7 @@ package org.wso2.carbon.identity.device.mgt.internal.util; +import org.apache.commons.lang.StringUtils; import org.json.JSONObject; import org.wso2.carbon.context.CarbonContext; import org.wso2.carbon.identity.central.log.mgt.utils.LoggerUtils; @@ -130,15 +131,14 @@ private String getInitiatorId() { String username = carbonContext.getUsername(); String tenantDomain = carbonContext.getTenantDomain(); - if (username == null || username.trim().isEmpty()) { + if (StringUtils.isBlank(username)) { return LoggerUtils.Initiator.System.name(); } String initiator = null; - if (tenantDomain != null && !tenantDomain.trim().isEmpty()) { + if (StringUtils.isNotBlank(tenantDomain)) { initiator = IdentityUtil.getInitiatorId(username, tenantDomain); } - return initiator != null && !initiator.trim().isEmpty() - ? initiator : LoggerUtils.getMaskedContent(username); + return StringUtils.isNotBlank(initiator) ? initiator : LoggerUtils.getMaskedContent(username); } /** diff --git a/components/device-mgt/org.wso2.carbon.identity.device.mgt/src/main/java/org/wso2/carbon/identity/device/mgt/internal/util/DeviceValidator.java b/components/device-mgt/org.wso2.carbon.identity.device.mgt/src/main/java/org/wso2/carbon/identity/device/mgt/internal/util/DeviceValidator.java new file mode 100644 index 000000000000..b9092f9bff66 --- /dev/null +++ b/components/device-mgt/org.wso2.carbon.identity.device.mgt/src/main/java/org/wso2/carbon/identity/device/mgt/internal/util/DeviceValidator.java @@ -0,0 +1,145 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (http://www.wso2.com). + * + * WSO2 LLC. licenses this file to you 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. + */ + +package org.wso2.carbon.identity.device.mgt.internal.util; + +import org.apache.commons.lang.StringUtils; +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; +import org.wso2.carbon.identity.core.util.IdentityUtil; +import org.wso2.carbon.identity.device.mgt.api.constant.ErrorMessage; +import org.wso2.carbon.identity.device.mgt.api.exception.DeviceMgtClientException; +import org.wso2.carbon.identity.device.mgt.api.exception.DeviceMgtException; +import org.wso2.carbon.identity.device.mgt.api.model.Device; + +/** + * Device validator class. + */ +public class DeviceValidator { + + private static final Log LOG = LogFactory.getLog(DeviceValidator.class); + + /** + * Validates that a required field is not blank. + * + * @param value Value of the field. + * @param fieldName Name of the field. + * @throws DeviceMgtClientException If the field is not set. + */ + public void validateRequiredField(String value, String fieldName) + throws DeviceMgtClientException { + + if (StringUtils.isBlank(value)) { + throw DeviceManagementExceptionHandler.handleClientException( + ErrorMessage.ERROR_INVALID_DEVICE_FIELD, fieldName); + } + } + + /** + * Validates that the device carries every field the registration layer requires. + * The device is constructed internally during the registration flow, so a missing field indicates + * that the device was not fully built before registration rather than invalid user input. Validating + * here keeps such a failure a clear, coded error instead of a constraint violation or a + * NullPointerException raised inside the data layer. + * The device model and the metadata are optional and are therefore not validated. + * + * @param device Device to be registered. + * @throws DeviceMgtException If the device or any of its required fields is not set. + */ + public void validateDeviceForRegistration(Device device) throws DeviceMgtException { + + if (device == null) { + throw DeviceManagementExceptionHandler.handleServerException( + ErrorMessage.ERROR_DEVICE_FIELD_REQUIRED, "device"); + } + if (StringUtils.isBlank(device.getUserId())) { + throw DeviceManagementExceptionHandler.handleServerException( + ErrorMessage.ERROR_USER_ID_REQUIRED); + } + + validateRequiredDeviceField(device.getId(), "id"); + validateRequiredDeviceField(device.getDeviceName(), "deviceName"); + validateRequiredDeviceField(device.getPublicKey(), "publicKey"); + + if (device.getStatus() == null) { + throw DeviceManagementExceptionHandler.handleServerException( + ErrorMessage.ERROR_DEVICE_FIELD_REQUIRED, "status"); + } + if (device.getRegisteredAt() == null) { + throw DeviceManagementExceptionHandler.handleServerException( + ErrorMessage.ERROR_DEVICE_FIELD_REQUIRED, "registeredAt"); + } + } + + /** + * Validates a required device field that is expected to be set before registration. + * + * @param value Value of the field. + * @param fieldName Name of the field. + * @throws DeviceMgtException If the field is not set. + */ + public void validateRequiredDeviceField(String value, String fieldName) throws DeviceMgtException { + + if (StringUtils.isBlank(value)) { + throw DeviceManagementExceptionHandler.handleServerException( + ErrorMessage.ERROR_DEVICE_FIELD_REQUIRED, fieldName); + } + } + + /** + * Validates the requested page size against the maximum items per page configured for the server. + * A page size larger than the configured maximum is capped, so that a caller cannot load an + * unbounded number of devices into memory. + * + * @param limit Requested page size. + * @return The page size to use, capped at the configured maximum. + * @throws DeviceMgtClientException If the requested page size is negative. + */ + public int validateLimit(int limit) throws DeviceMgtClientException { + + if (limit < 0) { + throw DeviceManagementExceptionHandler.handleClientException( + ErrorMessage.ERROR_INVALID_DEVICE_FIELD, "limit"); + } + + int maximumItemsPerPage = IdentityUtil.getMaximumItemPerPage(); + if (limit > maximumItemsPerPage) { + if (LOG.isDebugEnabled()) { + LOG.debug("Given limit: " + limit + " exceeds the maximum items per page. Using the maximum: " + + maximumItemsPerPage); + } + return maximumItemsPerPage; + } + return limit; + } + + /** + * Validates that a device looked up by its identifier exists. + * + * @param device Device previously looked up by {@code deviceId}, or {@code null} if not found. + * @param deviceId UUID of the device, used only for the error message. + * @throws DeviceMgtException If the device does not exist. + */ + public void validateDeviceExists(Device device, String deviceId) throws DeviceMgtException { + + if (device == null) { + throw DeviceManagementExceptionHandler.handleClientException( + ErrorMessage.ERROR_DEVICE_NOT_FOUND, deviceId); + } + } +} diff --git a/components/device-mgt/org.wso2.carbon.identity.device.mgt/src/test/java/org/wso2/carbon/identity/device/mgt/service/DeviceManagementServiceImplTest.java b/components/device-mgt/org.wso2.carbon.identity.device.mgt/src/test/java/org/wso2/carbon/identity/device/mgt/service/DeviceManagementServiceImplTest.java index eb9c59453e53..97a4851c4ce9 100644 --- a/components/device-mgt/org.wso2.carbon.identity.device.mgt/src/test/java/org/wso2/carbon/identity/device/mgt/service/DeviceManagementServiceImplTest.java +++ b/components/device-mgt/org.wso2.carbon.identity.device.mgt/src/test/java/org/wso2/carbon/identity/device/mgt/service/DeviceManagementServiceImplTest.java @@ -88,23 +88,23 @@ public void setUp() throws Exception { } @Test - public void testPersistDeviceDelegatesToDao() throws Exception { + public void testRegisterDeviceDelegatesToDao() throws Exception { Device device = buildDevice("d1", "alice@example.com"); when(dao.registerDevice(any(), eq(TENANT_ID))).thenReturn(device); - service.persistDevice(device, TENANT_DOMAIN); + service.registerDevice(device, TENANT_DOMAIN); verify(dao).registerDevice(any(), eq(TENANT_ID)); } @Test - public void testPersistDeviceWithoutUserIdThrows() { + public void testRegisterDeviceWithoutUserIdThrows() { Device device = buildDevice("d1", null); try { - service.persistDevice(device, TENANT_DOMAIN); + service.registerDevice(device, TENANT_DOMAIN); Assert.fail("Expected DeviceMgtServerException"); } catch (DeviceMgtException ex) { Assert.assertEquals(ex.getErrorCode(), ErrorMessage.ERROR_USER_ID_REQUIRED.getCode()); @@ -112,9 +112,9 @@ public void testPersistDeviceWithoutUserIdThrows() { } @Test - public void testPersistNullDeviceThrows() { + public void testRegisterNullDeviceThrows() { - assertPersistFailsWithFieldRequired(null); + assertRegisterFailsWithFieldRequired(null); } @DataProvider(name = "incompleteDevices") @@ -130,28 +130,28 @@ public Object[][] incompleteDevices() { } @Test(dataProvider = "incompleteDevices") - public void testPersistDeviceWithMissingRequiredFieldThrows(String fieldName, Device device) { + public void testRegisterDeviceWithMissingRequiredFieldThrows(String fieldName, Device device) { // A required field left unset must surface as a coded error, not as a DB constraint // violation or an NPE raised inside the DAO. - assertPersistFailsWithFieldRequired(device); + assertRegisterFailsWithFieldRequired(device); } @Test - public void testPersistDeviceWithoutOptionalFieldsSucceeds() throws Exception { + public void testRegisterDeviceWithoutOptionalFieldsSucceeds() throws Exception { // Device model and metadata are nullable in the schema, so they must not be validated. Device device = completeDeviceBuilder().deviceModel(null).metadata(null).build(); - service.persistDevice(device, TENANT_DOMAIN); + service.registerDevice(device, TENANT_DOMAIN); verify(dao).registerDevice(any(), eq(TENANT_ID)); } - private void assertPersistFailsWithFieldRequired(Device device) { + private void assertRegisterFailsWithFieldRequired(Device device) { try { - service.persistDevice(device, TENANT_DOMAIN); + service.registerDevice(device, TENANT_DOMAIN); Assert.fail("Expected DeviceMgtServerException"); } catch (DeviceMgtException ex) { Assert.assertEquals(ex.getErrorCode(), ErrorMessage.ERROR_DEVICE_FIELD_REQUIRED.getCode()); diff --git a/components/device-mgt/org.wso2.carbon.identity.device.mgt/src/test/java/org/wso2/carbon/identity/device/mgt/util/DeviceValidatorTest.java b/components/device-mgt/org.wso2.carbon.identity.device.mgt/src/test/java/org/wso2/carbon/identity/device/mgt/util/DeviceValidatorTest.java new file mode 100644 index 000000000000..54f0cd6685b3 --- /dev/null +++ b/components/device-mgt/org.wso2.carbon.identity.device.mgt/src/test/java/org/wso2/carbon/identity/device/mgt/util/DeviceValidatorTest.java @@ -0,0 +1,201 @@ +/* +* Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com) All Rights Reserved. +* +* WSO2 LLC. licenses this file to you 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. +*/ + +package org.wso2.carbon.identity.device.mgt.util; + +import org.testng.Assert; +import org.testng.annotations.DataProvider; +import org.testng.annotations.Test; +import org.wso2.carbon.identity.common.testng.WithCarbonHome; +import org.wso2.carbon.identity.core.util.IdentityUtil; +import org.wso2.carbon.identity.device.mgt.api.constant.ErrorMessage; +import org.wso2.carbon.identity.device.mgt.api.exception.DeviceMgtException; +import org.wso2.carbon.identity.device.mgt.api.model.Device; +import org.wso2.carbon.identity.device.mgt.internal.util.DeviceValidator; + +import java.sql.Timestamp; +import java.time.Instant; + +/** + * Unit tests for {@link DeviceValidator}. + */ +@WithCarbonHome +public class DeviceValidatorTest { + + private final DeviceValidator deviceValidator = new DeviceValidator(); + + @Test + public void testValidateRequiredFieldWithBlankValueThrows() { + + try { + deviceValidator.validateRequiredField(" ", "deviceId"); + Assert.fail("Expected DeviceMgtClientException"); + } catch (DeviceMgtException ex) { + Assert.assertEquals(ex.getErrorCode(), ErrorMessage.ERROR_INVALID_DEVICE_FIELD.getCode()); + } + } + + @Test + public void testValidateRequiredFieldWithNullValueThrows() { + + try { + deviceValidator.validateRequiredField(null, "deviceId"); + Assert.fail("Expected DeviceMgtClientException"); + } catch (DeviceMgtException ex) { + Assert.assertEquals(ex.getErrorCode(), ErrorMessage.ERROR_INVALID_DEVICE_FIELD.getCode()); + } + } + + @Test + public void testValidateRequiredFieldWithValidValueSucceeds() throws Exception { + + deviceValidator.validateRequiredField("d1", "deviceId"); + } + + @Test + public void testValidateDeviceForRegistrationWithNullDeviceThrows() { + + try { + deviceValidator.validateDeviceForRegistration(null); + Assert.fail("Expected DeviceMgtServerException"); + } catch (DeviceMgtException ex) { + Assert.assertEquals(ex.getErrorCode(), ErrorMessage.ERROR_DEVICE_FIELD_REQUIRED.getCode()); + } + } + + @Test + public void testValidateDeviceForRegistrationWithoutUserIdThrows() { + + Device device = completeDeviceBuilder().userId(null).build(); + + try { + deviceValidator.validateDeviceForRegistration(device); + Assert.fail("Expected DeviceMgtServerException"); + } catch (DeviceMgtException ex) { + Assert.assertEquals(ex.getErrorCode(), ErrorMessage.ERROR_USER_ID_REQUIRED.getCode()); + } + } + + @DataProvider(name = "incompleteDevices") + public Object[][] incompleteDevices() { + + return new Object[][]{ + {completeDeviceBuilder().id(null).build()}, + {completeDeviceBuilder().deviceName(null).build()}, + {completeDeviceBuilder().publicKey(null).build()}, + {completeDeviceBuilder().status(null).build()}, + {completeDeviceBuilder().registeredAt(null).build()}, + }; + } + + @Test(dataProvider = "incompleteDevices") + public void testValidateDeviceForRegistrationWithMissingRequiredFieldThrows(Device device) { + + try { + deviceValidator.validateDeviceForRegistration(device); + Assert.fail("Expected DeviceMgtServerException"); + } catch (DeviceMgtException ex) { + Assert.assertEquals(ex.getErrorCode(), ErrorMessage.ERROR_DEVICE_FIELD_REQUIRED.getCode()); + } + } + + @Test + public void testValidateDeviceForRegistrationWithoutOptionalFieldsSucceeds() throws Exception { + + Device device = completeDeviceBuilder().deviceModel(null).metadata(null).build(); + + deviceValidator.validateDeviceForRegistration(device); + } + + @Test + public void testValidateRequiredDeviceFieldWithBlankValueThrows() { + + try { + deviceValidator.validateRequiredDeviceField("", "id"); + Assert.fail("Expected DeviceMgtException"); + } catch (DeviceMgtException ex) { + Assert.assertEquals(ex.getErrorCode(), ErrorMessage.ERROR_DEVICE_FIELD_REQUIRED.getCode()); + } + } + + @Test + public void testValidateRequiredDeviceFieldWithValidValueSucceeds() throws Exception { + + deviceValidator.validateRequiredDeviceField("d1", "id"); + } + + @Test + public void testValidateLimitWithNegativeValueThrows() { + + try { + deviceValidator.validateLimit(-1); + Assert.fail("Expected DeviceMgtClientException"); + } catch (DeviceMgtException ex) { + Assert.assertEquals(ex.getErrorCode(), ErrorMessage.ERROR_INVALID_DEVICE_FIELD.getCode()); + } + } + + @Test + public void testValidateLimitWithinBoundsIsUnchanged() throws Exception { + + int result = deviceValidator.validateLimit(20); + + Assert.assertEquals(result, 20); + } + + @Test + public void testValidateLimitOverMaximumIsCapped() throws Exception { + + int maximumItemsPerPage = IdentityUtil.getMaximumItemPerPage(); + + int result = deviceValidator.validateLimit(maximumItemsPerPage + 5000); + + Assert.assertEquals(result, maximumItemsPerPage); + } + + @Test + public void testValidateDeviceExistsWithNullDeviceThrows() { + + try { + deviceValidator.validateDeviceExists(null, "d1"); + Assert.fail("Expected DeviceMgtClientException"); + } catch (DeviceMgtException ex) { + Assert.assertEquals(ex.getErrorCode(), ErrorMessage.ERROR_DEVICE_NOT_FOUND.getCode()); + } + } + + @Test + public void testValidateDeviceExistsWithExistingDeviceSucceeds() throws Exception { + + Device device = completeDeviceBuilder().build(); + + deviceValidator.validateDeviceExists(device, "d1"); + } + + private static Device.Builder completeDeviceBuilder() { + + return new Device.Builder() + .id("d1") + .userId("alice@example.com") + .deviceName("Alice's iPhone") + .deviceModel("iPhone 15") + .publicKey("dummy-public-key") + .status(Device.Status.ACTIVE) + .registeredAt(Timestamp.from(Instant.now())); + } +} diff --git a/components/device-mgt/org.wso2.carbon.identity.device.mgt/src/test/resources/testng.xml b/components/device-mgt/org.wso2.carbon.identity.device.mgt/src/test/resources/testng.xml index 4fbb4be5fce6..1a080bd451e8 100644 --- a/components/device-mgt/org.wso2.carbon.identity.device.mgt/src/test/resources/testng.xml +++ b/components/device-mgt/org.wso2.carbon.identity.device.mgt/src/test/resources/testng.xml @@ -15,6 +15,7 @@ +