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..cd6fc66c1821
--- /dev/null
+++ b/components/device-mgt/org.wso2.carbon.identity.device.mgt/pom.xml
@@ -0,0 +1,180 @@
+
+
+
+
+
+
+ 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
+
+
+
+ commons-lang.wso2
+ commons-lang
+
+
+
+ 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.lang;
+ version="${commons-lang.wso2.osgi.version.range}",
+ 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..d74d81b8f366
--- /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,84 @@
+/*
+ * 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_WHILE_REGISTERING_DEVICE("DM-65001", "Error while registering device.",
+ "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.",
+ "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_USER_ID_REQUIRED("DM-65007", "User identifier required.",
+ "Cannot register device: a valid user identifier (userId) was not set before registration."),
+ ERROR_DEVICE_FIELD_REQUIRED("DM-65008", "Required device field missing.",
+ "Cannot register device: the required field '%s' was not set before registration.");
+
+ 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..354447c59469
--- /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,280 @@
+/*
+ * 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 Status 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 Status 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 Status status = 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(Status 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);
+ }
+ }
+
+ /**
+ * 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
new file mode 100644
index 000000000000..61cd3b4656e0
--- /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,173 @@
+/*
+ * 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 java.util.List;
+
+/**
+ * Service interface for device management operations.
+ */
+public interface DeviceManagementService {
+
+ /**
+ * Registers a pre-verified {@link Device} in the database.
+ *
+ * @param device The verified device to register.
+ * @param tenantDomain Tenant domain.
+ */
+ void registerDevice(Device device, String tenantDomain) throws DeviceMgtException;
+
+ /**
+ * 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.
+ * @return The Device, or null if not found.
+ */
+ 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.
+ *
+ * @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 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.
+ * @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;
+
+ /**
+ * 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}.
+ *
+ * @param tenantDomain Tenant domain.
+ * @return Total number of devices in the tenant.
+ */
+ 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.
+ *
+ * @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;
+
+ /**
+ * 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.
+ *
+ * @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/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/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..dac7fcb916b9
--- /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,202 @@
+/*
+ * 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 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.
+
+ // 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, D.ID 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, D.ID 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, 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, 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; " +
+ "ORDER BY rn";
+
+ 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;";
+
+ // 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, D.ID 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, D.ID 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, 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, 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; ORDER BY rn";
+
+ 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;";
+
+ 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..e98c3d4d0c4d
--- /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,146 @@
+/*
+ * 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 device registration.
+ */
+public interface DeviceManagementDAO {
+
+ /**
+ * Registers a new device.
+ *
+ * @param device Device to register.
+ * @param tenantId Tenant identifier.
+ * @return Registered device.
+ * @throws DeviceMgtException If registration 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 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;
+
+ /**
+ * 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.
+ *
+ * @param tenantId Tenant identifier.
+ * @return Total number of devices in the tenant.
+ * @throws DeviceMgtException If the count fails.
+ */
+ 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.
+ *
+ * @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;
+
+ /**
+ * 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.
+ *
+ * @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/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..418a5d0eb7f5
--- /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,241 @@
+/*
+ * 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();
+ }
+
+ /**
+ * 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 register.
+ * @param tenantId Tenant identifier.
+ * @return Registered device.
+ * @throws DeviceMgtException If registration 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);
+ }
+
+ /**
+ * 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.
+ *
+ * @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);
+ }
+
+ /**
+ * 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.
+ *
+ * @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
new file mode 100644
index 000000000000..14cb2c5962af
--- /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,412 @@
+/*
+ * 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.lang.StringUtils;
+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 device registration.
+ */
+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().name());
+ 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(Device.Status.valueOf(
+ 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(Device.Status.valueOf(
+ 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 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 = StringUtils.isNotBlank(userId);
+
+ NamedJdbcTemplate jdbcTemplate = new NamedJdbcTemplate(IdentityDatabaseUtil.getDataSource());
+ try {
+ PaginationStyle style = resolvePaginationStyle();
+ String query = resolvePaginatedQuery(style, filterByUser);
+ 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(Device.Status.valueOf(
+ 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);
+ if (filterByUser) {
+ preparedStatement.setString(DeviceMgtSQLConstants.Column.USER_ID, userId);
+ }
+ bindPaginationParams(preparedStatement, style, safeOffset, limit);
+ }));
+ 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);
+ }
+ }
+
+ @Override
+ public int getDeviceCount(int tenantId) throws DeviceMgtException {
+
+ return getDeviceCount(tenantId, null);
+ }
+
+ @Override
+ public int getDeviceCount(int tenantId, String userId) throws DeviceMgtException {
+
+ boolean filterByUser = StringUtils.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(
+ query,
+ (resultSet, rowNumber) -> resultSet.getInt(1),
+ preparedStatement -> {
+ preparedStatement.setInt(DeviceMgtSQLConstants.Column.TENANT_ID, tenantId);
+ if (filterByUser) {
+ preparedStatement.setString(DeviceMgtSQLConstants.Column.USER_ID, userId);
+ }
+ }));
+ 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);
+ }
+ }
+
+ /**
+ * 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, 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;
+ 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 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 {
+
+ 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..38da3a2d0b09
--- /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,207 @@
+/*
+* 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.DeviceMgtException;
+import org.wso2.carbon.identity.device.mgt.api.model.Device;
+import org.wso2.carbon.identity.device.mgt.api.service.DeviceManagementService;
+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 org.wso2.carbon.identity.device.mgt.internal.util.DeviceValidator;
+
+import java.util.List;
+
+/**
+ * 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 DeviceManagementAuditLogger AUDIT_LOGGER = new DeviceManagementAuditLogger();
+ private static final DeviceValidator DEVICE_VALIDATOR = new DeviceValidator();
+ private final DeviceManagementDAO deviceManagementDAO;
+
+ private DeviceManagementServiceImpl() {
+ deviceManagementDAO = new CacheBackedDeviceManagementDAO(new DeviceManagementDAOImpl());
+ }
+
+ /**
+ * Returns the service singleton instance.
+ *
+ * @return Service singleton.
+ */
+ public static DeviceManagementServiceImpl getInstance() {
+ return INSTANCE;
+ }
+
+ @Override
+ public void registerDevice(Device device, String tenantDomain) throws DeviceMgtException {
+
+ DEVICE_VALIDATOR.validateDeviceForRegistration(device);
+ deviceManagementDAO.registerDevice(device, IdentityTenantUtil.getTenantId(tenantDomain));
+
+ AUDIT_LOGGER.printAuditLog(DeviceManagementAuditLogger.Operation.REGISTER, device);
+
+ if (LOG.isDebugEnabled()) {
+ LOG.debug("Device registered for user: " + device.getUserId() +
+ " in tenant: " + tenantDomain + " with device ID: " + device.getId());
+ }
+ }
+
+ @Override
+ public Device getDeviceById(String deviceId, String tenantDomain)
+ throws DeviceMgtException {
+
+ DEVICE_VALIDATOR.validateRequiredField(deviceId, "deviceId");
+ return deviceManagementDAO.getDeviceById(
+ 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 {
+
+ DEVICE_VALIDATOR.validateRequiredField(userId, "userId");
+ return deviceManagementDAO.getDevicesByUserId(
+ userId, IdentityTenantUtil.getTenantId(tenantDomain));
+ }
+
+ @Override
+ public List getDevices(String tenantDomain, int offset, int limit) throws DeviceMgtException {
+
+ return deviceManagementDAO.getDevices(
+ IdentityTenantUtil.getTenantId(tenantDomain), offset, DEVICE_VALIDATOR.validateLimit(limit));
+ }
+
+ @Override
+ public List getDevices(String tenantDomain, int offset, int limit, String userId)
+ throws DeviceMgtException {
+
+ return deviceManagementDAO.getDevices(
+ IdentityTenantUtil.getTenantId(tenantDomain), offset, DEVICE_VALIDATOR.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 {
+
+ DEVICE_VALIDATOR.validateRequiredField(deviceId, "deviceId");
+ DEVICE_VALIDATOR.validateRequiredField(deviceName, "deviceName");
+
+ Device updatedDevice = deviceManagementDAO.updateDeviceName(
+ deviceId, deviceName, IdentityTenantUtil.getTenantId(tenantDomain));
+
+ if (updatedDevice == null) {
+ throw DeviceManagementExceptionHandler.handleClientException(ErrorMessage.ERROR_DEVICE_NOT_FOUND, deviceId);
+ }
+
+ AUDIT_LOGGER.printAuditLog(DeviceManagementAuditLogger.Operation.UPDATE, updatedDevice);
+ return updatedDevice;
+ }
+
+ @Override
+ public Device activateDevice(String deviceId, String tenantDomain) throws DeviceMgtException {
+
+ 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);
+ }
+
+ 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 {
+
+ 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);
+ }
+
+ 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 {
+
+ DEVICE_VALIDATOR.validateRequiredField(deviceId, "deviceId");
+
+ Device existing = deviceManagementDAO.getDeviceById(
+ deviceId, IdentityTenantUtil.getTenantId(tenantDomain));
+ DEVICE_VALIDATOR.validateDeviceExists(existing, deviceId);
+
+ 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);
+ }
+ }
+}
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..dcf92bfe023d
--- /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,203 @@
+/*
+ * 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.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, 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);
+ }
+
+ /**
+ * 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 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.
+ */
+ 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().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
+ ? 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 (StringUtils.isBlank(username)) {
+ return LoggerUtils.Initiator.System.name();
+ }
+ String initiator = null;
+ if (StringUtils.isNotBlank(tenantDomain)) {
+ initiator = IdentityUtil.getInitiatorId(username, tenantDomain);
+ }
+ return StringUtils.isNotBlank(initiator) ? 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"),
+ ACTIVATE("activate-device"),
+ DEACTIVATE("deactivate-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/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/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..11895d888376
--- /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,256 @@
+/*
+* 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 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;
+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 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 {
+
+ 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
new file mode 100644
index 000000000000..6faf38031a66
--- /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,432 @@
+/*
+* 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", 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(), Device.Status.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 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(), Device.Status.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(Device.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(), Device.Status.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", Device.Status.ACTIVE), TENANT_ID);
+
+ Device fromOtherTenant = deviceManagementDAO.getDeviceById(id, OTHER_TENANT_ID);
+ Assert.assertNull(fromOtherTenant);
+
+ 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 getDevices returns every device registered under the tenant.
+ *
+ * @throws DeviceMgtException If the DAO operation fails.
+ */
+ @Test(priority = 9)
+ 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, Device.Status.ACTIVE), OTHER_TENANT_ID);
+ deviceManagementDAO.registerDevice(
+ buildDeviceForUser(id2, "Dave Phone 2", userId, Device.Status.ACTIVE), 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();
+ 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, 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);
+
+ 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", Device.Status.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", Device.Status.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");
+ }
+
+ /**
+ * 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);
+ }
+
+ /**
+ * 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()
+ .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, Device.Status 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..97a4851c4ce9
--- /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,497 @@
+/*
+* 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.DataProvider;
+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;
+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.sql.Timestamp;
+import java.time.Instant;
+
+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;
+ private Field daoField;
+ private DeviceManagementDAO originalDao;
+
+ @BeforeClass
+ 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() throws Exception {
+
+ daoField.set(service, originalDao);
+ identityTenantUtilMocked.close();
+ }
+
+ @BeforeMethod
+ public void setUp() throws Exception {
+
+ dao = mock(DeviceManagementDAO.class);
+ daoField.set(service, dao);
+ }
+
+ @Test
+ public void testRegisterDeviceDelegatesToDao() throws Exception {
+
+ Device device = buildDevice("d1", "alice@example.com");
+ when(dao.registerDevice(any(), eq(TENANT_ID))).thenReturn(device);
+
+ service.registerDevice(device, TENANT_DOMAIN);
+
+ verify(dao).registerDevice(any(), eq(TENANT_ID));
+ }
+
+ @Test
+ public void testRegisterDeviceWithoutUserIdThrows() {
+
+ Device device = buildDevice("d1", null);
+
+ try {
+ service.registerDevice(device, TENANT_DOMAIN);
+ Assert.fail("Expected DeviceMgtServerException");
+ } catch (DeviceMgtException ex) {
+ Assert.assertEquals(ex.getErrorCode(), ErrorMessage.ERROR_USER_ID_REQUIRED.getCode());
+ }
+ }
+
+ @Test
+ public void testRegisterNullDeviceThrows() {
+
+ assertRegisterFailsWithFieldRequired(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 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.
+ assertRegisterFailsWithFieldRequired(device);
+ }
+
+ @Test
+ 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.registerDevice(device, TENANT_DOMAIN);
+
+ verify(dao).registerDevice(any(), eq(TENANT_ID));
+ }
+
+ private void assertRegisterFailsWithFieldRequired(Device device) {
+
+ try {
+ service.registerDevice(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 {
+
+ 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 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 {
+
+ 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 testUpdateDeviceNameWhenDaoReturnsNullAfterRefetchThrowsNotNPE() throws Exception {
+
+ 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.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 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());
+ }
+ }
+
+ @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 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 {
+
+ 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.deactivateDevice("unknown", TENANT_DOMAIN);
+ Assert.fail("Expected DeviceMgtClientException");
+ } catch (DeviceMgtException ex) {
+ Assert.assertEquals(ex.getErrorCode(), ErrorMessage.ERROR_DEVICE_NOT_FOUND.getCode());
+ }
+ }
+
+ @Test
+ public void testActivateDeviceWhenDeviceMissingThrows() throws Exception {
+
+ when(dao.getDeviceById("unknown", TENANT_ID)).thenReturn(null);
+
+ try {
+ service.activateDevice("unknown", TENANT_DOMAIN);
+ Assert.fail("Expected DeviceMgtClientException");
+ } catch (DeviceMgtException ex) {
+ Assert.assertEquals(ex.getErrorCode(), ErrorMessage.ERROR_DEVICE_NOT_FOUND.getCode());
+ }
+ }
+
+ @Test
+ public void testDeactivateDeviceWhenDaoReturnsNullAfterRefetchThrowsNotNPE() throws Exception {
+
+ 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.deactivateDevice("d1", TENANT_DOMAIN);
+ Assert.fail("Expected DeviceMgtClientException");
+ } catch (DeviceMgtException ex) {
+ Assert.assertEquals(ex.getErrorCode(), ErrorMessage.ERROR_DEVICE_NOT_FOUND.getCode());
+ }
+ }
+
+ @Test
+ public void testActivateDeviceWhenDaoReturnsNullAfterRefetchThrowsNotNPE() throws Exception {
+
+ 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.activateDevice("d1", TENANT_DOMAIN);
+ Assert.fail("Expected DeviceMgtClientException");
+ } catch (DeviceMgtException ex) {
+ Assert.assertEquals(ex.getErrorCode(), ErrorMessage.ERROR_DEVICE_NOT_FOUND.getCode());
+ }
+ }
+
+ private static Device buildDevice(String id, String userId) {
+
+ return buildDevice(id, userId, Device.Status.ACTIVE);
+ }
+
+ 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
new file mode 100644
index 000000000000..42c7acc3256e
--- /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,100 @@
+/*
+* 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_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("deviceId"));
+ Assert.assertEquals(ex.getCause(), cause);
+ }
+
+ @Test
+ public void testHandleServerExceptionWithCause() {
+
+ 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.assertNotNull(ex.getDescription());
+ 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_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(deviceId),
+ "Description should contain substituted value: " + deviceId);
+ }
+
+ @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/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/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..6269afa183c6
--- /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(2048) NOT NULL,
+ STATUS VARCHAR(20) NOT NULL DEFAULT 'ACTIVE',
+ REGISTERED_AT TIMESTAMP NOT NULL,
+ METADATA VARCHAR(2048) 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_TENANT_REGISTERED ON IDN_DEVICE (TENANT_ID, REGISTERED_AT);
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..1a080bd451e8
--- /dev/null
+++ b/components/device-mgt/org.wso2.carbon.identity.device.mgt/src/test/resources/testng.xml
@@ -0,0 +1,21 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
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..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
@@ -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(2048) NOT NULL,
+ STATUS VARCHAR(20) NOT NULL DEFAULT 'ACTIVE',
+ REGISTERED_AT TIMESTAMP NOT NULL,
+ METADATA VARCHAR(2048),
+ 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_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 e4424c873059..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
@@ -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(2048) NOT NULL,
+ STATUS VARCHAR(20) NOT NULL DEFAULT 'ACTIVE',
+ REGISTERED_AT TIMESTAMP NOT NULL,
+ METADATA VARCHAR(2048),
+ 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_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 dfd5fe9720a9..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
@@ -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(2048) NOT NULL,
+ STATUS VARCHAR(20) NOT NULL DEFAULT 'ACTIVE',
+ REGISTERED_AT DATETIME NOT NULL,
+ METADATA VARCHAR(2048),
+ 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_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 8a4db2322261..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
@@ -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(2048) NOT NULL,
+ STATUS VARCHAR(20) NOT NULL DEFAULT 'ACTIVE',
+ REGISTERED_AT TIMESTAMP NOT NULL,
+ METADATA VARCHAR(2048),
+ 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_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 a769a5544062..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
@@ -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(2048) NOT NULL,
+ STATUS VARCHAR(20) NOT NULL DEFAULT 'ACTIVE',
+ REGISTERED_AT TIMESTAMP NOT NULL,
+ METADATA VARCHAR(2048),
+ 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_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 f6f1d0e64c5d..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
@@ -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(2048) NOT NULL,
+ STATUS VARCHAR2(20) DEFAULT 'ACTIVE' NOT NULL,
+ REGISTERED_AT TIMESTAMP NOT NULL,
+ METADATA VARCHAR2(2048),
+ 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_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 97dec658d71e..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
@@ -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(2048) NOT NULL,
+ STATUS VARCHAR2(20) DEFAULT 'ACTIVE' NOT NULL,
+ REGISTERED_AT TIMESTAMP NOT NULL,
+ METADATA VARCHAR2(2048),
+ 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_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 2ef282070a35..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
@@ -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(2048) NOT NULL,
+ STATUS VARCHAR(20) NOT NULL DEFAULT 'ACTIVE',
+ REGISTERED_AT TIMESTAMP NOT NULL,
+ METADATA VARCHAR(2048),
+ 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_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/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