This is an automated email from the ASF dual-hosted git repository.

yuqi1129 pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/gravitino.git


The following commit(s) were added to refs/heads/main by this push:
     new 74d0e1b091 [#11044] feat(idp-basic): Add built-in IdP storage services 
(#11177)
74d0e1b091 is described below

commit 74d0e1b0914007b09f32be866b3f7253c87a65e1
Author: MaSai <[email protected]>
AuthorDate: Fri May 22 22:51:52 2026 +0800

    [#11044] feat(idp-basic): Add built-in IdP storage services (#11177)
    
    ### What changes were proposed in this pull request?
    
    This PR implements built-in IdP relational storage entirely in the
    `idp-basic` plugin, without changes to the `core` module.
    
    - Add `IdpUserMetaService` and `IdpGroupMetaService` for insert/delete
    and user-group relation management.
    - Add `IdpLegacyGarbageCollector` and `IdpLegacyGarbageCollectorManager`
    for legacy metadata cleanup.
    - Introduce plugin-local `NoSuchEntityException` and rename
    `IdpUserPO.userName` to `username`.
    - Register IdP mappers via `IdpBasicMapperPackageProvider` and package
    the plugin in distribution (`copyLibAndConfigs`).
    - Add mapper and service tests (`AbstractIdpMetaServiceTest`, legacy
    timeline coverage, GC tests).
    
    Fix: #11044
    
    ### Why are the changes needed?
    
    Issue #11044 tracks built-in IdP storage for Gravitino. Keeping the
    implementation in `idp-basic` avoids coupling core to IdP-specific
    tables and lets the plugin own mapper registration, meta services, and
    legacy garbage collection.
    
    ### Does this PR introduce _any_ user-facing change?
    
    No. This is internal storage wiring for built-in IdP metadata. No public
    API or configuration keys are added or changed in this PR.
    
    ### How was this patch tested?
    
    - [x] `./gradlew :plugins:idp-basic:test -PskipITs
    -PskipDockerTests=true` (H2)
    - [x] `./gradlew :plugins:idp-basic:test -PskipITs
    -PskipDockerTests=false` (H2, MySQL, PostgreSQL via Testcontainers)
---
 build.gradle.kts                                   |   4 +-
 plugins/idp-basic/build.gradle.kts                 |  22 +++
 .../gravitino/idp/exception/NotFoundException.java |  49 +++++
 .../gravitino/idp/storage/IdpStorageBootstrap.java |  57 ++++++
 .../idp/storage/gc/IdpLegacyGarbageCollector.java  | 137 ++++++++++++++
 .../idp/storage/mapper/IdpGroupMetaMapper.java     |  10 +-
 .../mapper/IdpGroupMetaSQLProviderFactory.java     |  29 ++-
 .../mapper/IdpUserGroupRelSQLProviderFactory.java  |  20 +-
 .../idp/storage/mapper/IdpUserMetaMapper.java      |  12 +-
 .../mapper/IdpUserMetaSQLProviderFactory.java      |  32 ++--
 .../provider/base/IdpGroupMetaBaseSQLProvider.java |  20 +-
 .../provider/base/IdpUserMetaBaseSQLProvider.java  |  19 +-
 .../provider/h2/IdpUserGroupRelH2Provider.java     |  10 +-
 .../postgresql/IdpGroupMetaPostgreSQLProvider.java |  10 +-
 .../IdpUserGroupRelPostgreSQLProvider.java         |  10 +-
 .../postgresql/IdpUserMetaPostgreSQLProvider.java  |  10 +-
 .../apache/gravitino/idp/storage/po/IdpUserPO.java |   2 +-
 .../idp/storage/service/IdpGroupMetaService.java   | 152 +++++++++++++++
 .../idp/storage/service/IdpUserMetaService.java    | 157 ++++++++++++++++
 .../storage/gc/TestIdpLegacyGarbageCollector.java  | 183 +++++++++++++++++++
 .../storage/mapper/AbstractIdpMetaStorageTest.java |   8 +-
 .../storage/mapper/TestIdpGroupMetaStorage.java    |  44 +----
 .../storage/mapper/TestIdpUserGroupRelStorage.java |  22 +--
 .../idp/storage/mapper/TestIdpUserMetaStorage.java | 100 ++++------
 .../gravitino/idp/storage/po/TestIdpUserPO.java    |  20 +-
 .../service/AbstractIdpMetaServiceTest.java        | 203 +++++++++++++++++++++
 .../storage/service/TestIdpGroupMetaService.java   | 190 +++++++++++++++++++
 .../storage/service/TestIdpUserMetaService.java    | 156 ++++++++++++++++
 28 files changed, 1446 insertions(+), 242 deletions(-)

diff --git a/build.gradle.kts b/build.gradle.kts
index 06113d3940..d630488f12 100644
--- a/build.gradle.kts
+++ b/build.gradle.kts
@@ -789,7 +789,8 @@ tasks {
         ":authorizations:copyLibAndConfig",
         ":iceberg:iceberg-rest-server:copyLibAndConfigs",
         ":lance:lance-rest-server:copyLibAndConfigs",
-        ":maintenance:optimizer:copyLibAndConfigs"
+        ":maintenance:optimizer:copyLibAndConfigs",
+        ":plugins:idp-basic:copyLibAndConfigs"
       )
     if (!skipWeb) {
       dependencies.add(":web:web:build")
@@ -1103,6 +1104,7 @@ tasks {
         it.name != "integration-test" &&
         it.parent?.name != "bundles" &&
         it.parent?.name != "maintenance" &&
+        it.parent?.name != "plugins" &&
         it.name != "mcp-server"
       ) {
         from(it.configurations.runtimeClasspath) {
diff --git a/plugins/idp-basic/build.gradle.kts 
b/plugins/idp-basic/build.gradle.kts
index 3229f452c8..a977c0d581 100644
--- a/plugins/idp-basic/build.gradle.kts
+++ b/plugins/idp-basic/build.gradle.kts
@@ -26,6 +26,7 @@ plugins {
 dependencies {
   annotationProcessor(libs.lombok)
 
+  implementation(project(":common"))
   implementation(project(":core"))
 
   implementation(libs.bcprov.jdk18on)
@@ -34,6 +35,7 @@ dependencies {
   implementation(libs.mybatis)
 
   compileOnly(libs.lombok)
+  compileOnly(libs.slf4j.api)
 
   testImplementation(project(":common"))
   testImplementation(project(":core"))
@@ -53,6 +55,26 @@ dependencies {
 }
 
 tasks {
+  val copyLibs by registering(Copy::class) {
+    dependsOn(jar)
+    from(layout.buildDirectory.dir("libs")) {
+      include("gravitino-idp-basic-*.jar")
+      exclude("*-javadoc.jar", "*-sources.jar")
+    }
+    // Argon2id password hashing; not bundled in the server distribution today.
+    from(configurations.runtimeClasspath) {
+      include("bcprov-jdk18on-*.jar")
+    }
+    into("$rootDir/distribution/package/libs")
+    duplicatesStrategy = DuplicatesStrategy.EXCLUDE
+  }
+
+  register("copyLibAndConfigs", Copy::class) {
+    group = "gravitino distribution"
+    description = "Copy idp-basic plugin jar into distribution package libs"
+    dependsOn(copyLibs)
+  }
+
   test {
     environment("GRAVITINO_HOME", rootDir.path)
     environment("GRAVITINO_TEST", "true")
diff --git 
a/plugins/idp-basic/src/main/java/org/apache/gravitino/idp/exception/NotFoundException.java
 
b/plugins/idp-basic/src/main/java/org/apache/gravitino/idp/exception/NotFoundException.java
new file mode 100644
index 0000000000..33e5852a78
--- /dev/null
+++ 
b/plugins/idp-basic/src/main/java/org/apache/gravitino/idp/exception/NotFoundException.java
@@ -0,0 +1,49 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF 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.apache.gravitino.idp.exception;
+
+import com.google.errorprone.annotations.FormatMethod;
+import com.google.errorprone.annotations.FormatString;
+
+/** This exception is thrown when a built-in IdP resource is not found. */
+public class NotFoundException extends RuntimeException {
+
+  /**
+   * Constructs a new NotFoundException with the given message.
+   *
+   * @param message the detail message
+   * @param args the arguments to the message
+   */
+  @FormatMethod
+  public NotFoundException(@FormatString String message, Object... args) {
+    super(String.format(message, args));
+  }
+
+  /**
+   * Constructs a new NotFoundException with the given message and cause.
+   *
+   * @param cause the cause of the exception
+   * @param message the detail message
+   * @param args the arguments to the message
+   */
+  @FormatMethod
+  public NotFoundException(Throwable cause, @FormatString String message, 
Object... args) {
+    super(String.format(message, args), cause);
+  }
+}
diff --git 
a/plugins/idp-basic/src/main/java/org/apache/gravitino/idp/storage/IdpStorageBootstrap.java
 
b/plugins/idp-basic/src/main/java/org/apache/gravitino/idp/storage/IdpStorageBootstrap.java
new file mode 100644
index 0000000000..9d2caedd78
--- /dev/null
+++ 
b/plugins/idp-basic/src/main/java/org/apache/gravitino/idp/storage/IdpStorageBootstrap.java
@@ -0,0 +1,57 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF 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.apache.gravitino.idp.storage;
+
+import java.util.concurrent.atomic.AtomicBoolean;
+import org.apache.gravitino.GravitinoEnv;
+import org.apache.gravitino.idp.storage.gc.IdpLegacyGarbageCollector;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * One-time initialization for built-in IdP storage components that are not 
wired through core
+ * entity-store lifecycle.
+ *
+ * <p>Not invoked from {@link
+ * 
org.apache.gravitino.idp.storage.mapper.provider.IdpBasicMapperPackageProvider} 
in the current
+ * PR; a future change can wire this through server/plugin lifecycle.
+ */
+public final class IdpStorageBootstrap {
+
+  private static final Logger LOG = 
LoggerFactory.getLogger(IdpStorageBootstrap.class);
+
+  private static final AtomicBoolean INITIALIZED = new AtomicBoolean(false);
+
+  private IdpStorageBootstrap() {}
+
+  /** Initializes IdP storage background tasks once per JVM. */
+  public static void initializeOnce() {
+    if (!INITIALIZED.compareAndSet(false, true)) {
+      return;
+    }
+
+    try {
+      
IdpLegacyGarbageCollector.startScheduledCollector(GravitinoEnv.getInstance().config());
+    } catch (Exception e) {
+      INITIALIZED.set(false);
+      LOG.warn("Failed to initialize built-in IdP storage", e);
+    }
+  }
+}
diff --git 
a/plugins/idp-basic/src/main/java/org/apache/gravitino/idp/storage/gc/IdpLegacyGarbageCollector.java
 
b/plugins/idp-basic/src/main/java/org/apache/gravitino/idp/storage/gc/IdpLegacyGarbageCollector.java
new file mode 100644
index 0000000000..0898fe7b30
--- /dev/null
+++ 
b/plugins/idp-basic/src/main/java/org/apache/gravitino/idp/storage/gc/IdpLegacyGarbageCollector.java
@@ -0,0 +1,137 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF 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.apache.gravitino.idp.storage.gc;
+
+import static 
org.apache.gravitino.Configs.GARBAGE_COLLECTOR_SINGLE_DELETION_LIMIT;
+import static org.apache.gravitino.Configs.STORE_DELETE_AFTER_TIME;
+
+import java.util.concurrent.ScheduledExecutorService;
+import java.util.concurrent.ScheduledThreadPoolExecutor;
+import java.util.concurrent.ThreadPoolExecutor;
+import java.util.concurrent.TimeUnit;
+import org.apache.gravitino.Config;
+import org.apache.gravitino.idp.storage.service.IdpGroupMetaService;
+import org.apache.gravitino.idp.storage.service.IdpUserMetaService;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Periodically purges soft-deleted built-in IdP rows after {@link
+ * org.apache.gravitino.Configs#STORE_DELETE_AFTER_TIME}.
+ *
+ * <p>Unlike core {@link 
org.apache.gravitino.storage.relational.RelationalGarbageCollector}, which
+ * is started from {@code RelationalEntityStore}, this plugin has no 
entity-store lifecycle hook.
+ * {@link org.apache.gravitino.idp.storage.IdpStorageBootstrap} can start it 
once server wiring is
+ * added; mapper registration alone does not start the collector.
+ */
+public final class IdpLegacyGarbageCollector {
+
+  private static final Logger LOG = 
LoggerFactory.getLogger(IdpLegacyGarbageCollector.class);
+
+  private static volatile IdpLegacyGarbageCollector instance;
+
+  private final long storeDeleteAfterTimeMillis;
+
+  private final ScheduledExecutorService garbageCollectorPool =
+      new ScheduledThreadPoolExecutor(
+          2,
+          r -> {
+            Thread t = new Thread(r, "IdpBasic-Legacy-Garbage-Collector");
+            t.setDaemon(true);
+            return t;
+          },
+          new ThreadPoolExecutor.AbortPolicy());
+
+  /**
+   * Starts the scheduled legacy garbage collector. Idempotent; only the first 
call takes effect.
+   *
+   * @param config Gravitino server configuration
+   */
+  public static void startScheduledCollector(Config config) {
+    if (instance != null) {
+      return;
+    }
+    synchronized (IdpLegacyGarbageCollector.class) {
+      if (instance != null) {
+        return;
+      }
+      IdpLegacyGarbageCollector collector = new 
IdpLegacyGarbageCollector(config);
+      collector.start();
+      instance = collector;
+    }
+  }
+
+  public IdpLegacyGarbageCollector(Config config) {
+    storeDeleteAfterTimeMillis = config.get(STORE_DELETE_AFTER_TIME);
+  }
+
+  public void collectAndClean() {
+    long threadId = Thread.currentThread().getId();
+    LOG.debug("Thread {} start to collect garbage...", threadId);
+
+    try {
+      LOG.debug("Start to collect and delete legacy data by thread {}", 
threadId);
+      long legacyTimeline = System.currentTimeMillis() - 
storeDeleteAfterTimeMillis;
+      long deletedCount = Long.MAX_VALUE;
+      LOG.debug(
+          "Try to physically delete {} legacy data that has been marked 
deleted before {}",
+          "idp_user",
+          legacyTimeline);
+      try {
+        while (deletedCount > 0) {
+          deletedCount =
+              IdpUserMetaService.getInstance()
+                  .deleteUserMetasByLegacyTimeline(
+                      legacyTimeline, GARBAGE_COLLECTOR_SINGLE_DELETION_LIMIT);
+        }
+      } catch (Exception e) {
+        LOG.error("Failed to physically delete type of idp_user's legacy data: 
", e);
+      }
+
+      deletedCount = Long.MAX_VALUE;
+      LOG.debug(
+          "Try to physically delete {} legacy data that has been marked 
deleted before {}",
+          "idp_group",
+          legacyTimeline);
+      try {
+        while (deletedCount > 0) {
+          deletedCount =
+              IdpGroupMetaService.getInstance()
+                  .deleteGroupMetasByLegacyTimeline(
+                      legacyTimeline, GARBAGE_COLLECTOR_SINGLE_DELETION_LIMIT);
+        }
+      } catch (Exception e) {
+        LOG.error("Failed to physically delete type of idp_group's legacy 
data: ", e);
+      }
+    } catch (Exception e) {
+      LOG.error("Thread {} failed to collect and clean garbage.", threadId, e);
+    } finally {
+      LOG.debug("Thread {} finish to collect garbage.", threadId);
+    }
+  }
+
+  private void start() {
+    long dateTimelineMinute = storeDeleteAfterTimeMillis / 1000 / 60;
+
+    // We will collect garbage every 10 minutes at least. If the 
dateTimelineMinute is larger than
+    // 100 minutes, we would collect garbage every dateTimelineMinute/10 
minutes.
+    long frequency = Math.max(dateTimelineMinute / 10, 10);
+    garbageCollectorPool.scheduleAtFixedRate(this::collectAndClean, 5, 
frequency, TimeUnit.MINUTES);
+  }
+}
diff --git 
a/plugins/idp-basic/src/main/java/org/apache/gravitino/idp/storage/mapper/IdpGroupMetaMapper.java
 
b/plugins/idp-basic/src/main/java/org/apache/gravitino/idp/storage/mapper/IdpGroupMetaMapper.java
index 88419f8349..cc4a01e9f3 100644
--- 
a/plugins/idp-basic/src/main/java/org/apache/gravitino/idp/storage/mapper/IdpGroupMetaMapper.java
+++ 
b/plugins/idp-basic/src/main/java/org/apache/gravitino/idp/storage/mapper/IdpGroupMetaMapper.java
@@ -19,7 +19,6 @@
 
 package org.apache.gravitino.idp.storage.mapper;
 
-import java.util.List;
 import org.apache.gravitino.idp.storage.po.IdpGroupPO;
 import org.apache.ibatis.annotations.DeleteProvider;
 import org.apache.ibatis.annotations.InsertProvider;
@@ -41,18 +40,11 @@ public interface IdpGroupMetaMapper {
   @SelectProvider(type = IdpGroupMetaSQLProviderFactory.class, method = 
"selectIdpGroup")
   IdpGroupPO selectIdpGroup(@Param("groupName") String groupName);
 
-  /**
-   * Selects active groups by name. An empty list returns all active groups; 
pass null for an
-   * explicit error.
-   */
-  @SelectProvider(type = IdpGroupMetaSQLProviderFactory.class, method = 
"selectIdpGroups")
-  List<IdpGroupPO> selectIdpGroups(@Param("groupNames") List<String> 
groupNames);
-
   @InsertProvider(type = IdpGroupMetaSQLProviderFactory.class, method = 
"insertIdpGroup")
   void insertIdpGroup(@Param("groupMeta") IdpGroupPO groupPO);
 
   @UpdateProvider(type = IdpGroupMetaSQLProviderFactory.class, method = 
"softDeleteIdpGroup")
-  Integer softDeleteIdpGroup(@Param("groupId") Long groupId);
+  Integer softDeleteIdpGroup(@Param("groupName") String groupName);
 
   @DeleteProvider(
       type = IdpGroupMetaSQLProviderFactory.class,
diff --git 
a/plugins/idp-basic/src/main/java/org/apache/gravitino/idp/storage/mapper/IdpGroupMetaSQLProviderFactory.java
 
b/plugins/idp-basic/src/main/java/org/apache/gravitino/idp/storage/mapper/IdpGroupMetaSQLProviderFactory.java
index 4241ffc598..ee52b33c36 100644
--- 
a/plugins/idp-basic/src/main/java/org/apache/gravitino/idp/storage/mapper/IdpGroupMetaSQLProviderFactory.java
+++ 
b/plugins/idp-basic/src/main/java/org/apache/gravitino/idp/storage/mapper/IdpGroupMetaSQLProviderFactory.java
@@ -20,7 +20,6 @@
 package org.apache.gravitino.idp.storage.mapper;
 
 import com.google.common.collect.ImmutableMap;
-import java.util.List;
 import java.util.Map;
 import 
org.apache.gravitino.idp.storage.mapper.provider.base.IdpGroupMetaBaseSQLProvider;
 import 
org.apache.gravitino.idp.storage.mapper.provider.h2.IdpGroupMetaH2Provider;
@@ -46,34 +45,30 @@ public class IdpGroupMetaSQLProviderFactory {
 
   private IdpGroupMetaSQLProviderFactory() {}
 
-  private static IdpGroupMetaBaseSQLProvider currentProvider() {
-    return SQLProviderFactoryHelper.currentProvider(
-        PROVIDER_MAP, IdpGroupMetaSQLProviderFactory.class);
-  }
-
-  static IdpGroupMetaBaseSQLProvider getProvider(String databaseId) {
-    return SQLProviderFactoryHelper.getProvider(
-        databaseId, PROVIDER_MAP, IdpGroupMetaSQLProviderFactory.class);
-  }
-
   public static String selectIdpGroup(@Param("groupName") String groupName) {
     return currentProvider().selectIdpGroup(groupName);
   }
 
-  public static String selectIdpGroups(@Param("groupNames") List<String> 
groupNames) {
-    return currentProvider().selectIdpGroups(groupNames);
-  }
-
   public static String insertIdpGroup(@Param("groupMeta") IdpGroupPO groupPO) {
     return currentProvider().insertIdpGroup(groupPO);
   }
 
-  public static String softDeleteIdpGroup(@Param("groupId") Long groupId) {
-    return currentProvider().softDeleteIdpGroup(groupId);
+  public static String softDeleteIdpGroup(@Param("groupName") String 
groupName) {
+    return currentProvider().softDeleteIdpGroup(groupName);
   }
 
   public static String deleteIdpGroupMetasByLegacyTimeline(
       @Param("legacyTimeline") Long legacyTimeline, @Param("limit") int limit) 
{
     return 
currentProvider().deleteIdpGroupMetasByLegacyTimeline(legacyTimeline, limit);
   }
+
+  static IdpGroupMetaBaseSQLProvider getProvider(String databaseId) {
+    return SQLProviderFactoryHelper.getProvider(
+        databaseId, PROVIDER_MAP, IdpGroupMetaSQLProviderFactory.class);
+  }
+
+  private static IdpGroupMetaBaseSQLProvider currentProvider() {
+    return SQLProviderFactoryHelper.currentProvider(
+        PROVIDER_MAP, IdpGroupMetaSQLProviderFactory.class);
+  }
 }
diff --git 
a/plugins/idp-basic/src/main/java/org/apache/gravitino/idp/storage/mapper/IdpUserGroupRelSQLProviderFactory.java
 
b/plugins/idp-basic/src/main/java/org/apache/gravitino/idp/storage/mapper/IdpUserGroupRelSQLProviderFactory.java
index b5f1fab08c..4343043e2c 100644
--- 
a/plugins/idp-basic/src/main/java/org/apache/gravitino/idp/storage/mapper/IdpUserGroupRelSQLProviderFactory.java
+++ 
b/plugins/idp-basic/src/main/java/org/apache/gravitino/idp/storage/mapper/IdpUserGroupRelSQLProviderFactory.java
@@ -46,16 +46,6 @@ public class IdpUserGroupRelSQLProviderFactory {
 
   private IdpUserGroupRelSQLProviderFactory() {}
 
-  private static IdpUserGroupRelBaseSQLProvider currentProvider() {
-    return SQLProviderFactoryHelper.currentProvider(
-        PROVIDER_MAP, IdpUserGroupRelSQLProviderFactory.class);
-  }
-
-  static IdpUserGroupRelBaseSQLProvider getProvider(String databaseId) {
-    return SQLProviderFactoryHelper.getProvider(
-        databaseId, PROVIDER_MAP, IdpUserGroupRelSQLProviderFactory.class);
-  }
-
   public static String selectGroupNamesByUsername(@Param("username") String 
username) {
     return currentProvider().selectGroupNamesByUsername(username);
   }
@@ -85,4 +75,14 @@ public class IdpUserGroupRelSQLProviderFactory {
       @Param("legacyTimeline") Long legacyTimeline, @Param("limit") int limit) 
{
     return 
currentProvider().deleteIdpUserGroupRelMetasByLegacyTimeline(legacyTimeline, 
limit);
   }
+
+  static IdpUserGroupRelBaseSQLProvider getProvider(String databaseId) {
+    return SQLProviderFactoryHelper.getProvider(
+        databaseId, PROVIDER_MAP, IdpUserGroupRelSQLProviderFactory.class);
+  }
+
+  private static IdpUserGroupRelBaseSQLProvider currentProvider() {
+    return SQLProviderFactoryHelper.currentProvider(
+        PROVIDER_MAP, IdpUserGroupRelSQLProviderFactory.class);
+  }
 }
diff --git 
a/plugins/idp-basic/src/main/java/org/apache/gravitino/idp/storage/mapper/IdpUserMetaMapper.java
 
b/plugins/idp-basic/src/main/java/org/apache/gravitino/idp/storage/mapper/IdpUserMetaMapper.java
index 41c3476030..aa61b70d21 100644
--- 
a/plugins/idp-basic/src/main/java/org/apache/gravitino/idp/storage/mapper/IdpUserMetaMapper.java
+++ 
b/plugins/idp-basic/src/main/java/org/apache/gravitino/idp/storage/mapper/IdpUserMetaMapper.java
@@ -41,22 +41,18 @@ public interface IdpUserMetaMapper {
   @SelectProvider(type = IdpUserMetaSQLProviderFactory.class, method = 
"selectIdpUser")
   IdpUserPO selectIdpUser(@Param("username") String username);
 
-  /**
-   * Selects active users by name. An empty list returns all active users; 
pass null for an explicit
-   * error.
-   */
-  @SelectProvider(type = IdpUserMetaSQLProviderFactory.class, method = 
"selectIdpUsers")
-  List<IdpUserPO> selectIdpUsers(@Param("usernames") List<String> usernames);
+  @SelectProvider(type = IdpUserMetaSQLProviderFactory.class, method = 
"selectIdpUsersByUsernames")
+  List<IdpUserPO> selectIdpUsersByUsernames(@Param("usernames") List<String> 
usernames);
 
   @InsertProvider(type = IdpUserMetaSQLProviderFactory.class, method = 
"insertIdpUser")
   void insertIdpUser(@Param("userMeta") IdpUserPO userPO);
 
   @UpdateProvider(type = IdpUserMetaSQLProviderFactory.class, method = 
"updateIdpUserPassword")
   Integer updateIdpUserPassword(
-      @Param("userId") Long userId, @Param("passwordHash") String 
passwordHash);
+      @Param("username") String username, @Param("passwordHash") String 
passwordHash);
 
   @UpdateProvider(type = IdpUserMetaSQLProviderFactory.class, method = 
"softDeleteIdpUser")
-  Integer softDeleteIdpUser(@Param("userId") Long userId);
+  Integer softDeleteIdpUser(@Param("username") String username);
 
   @DeleteProvider(
       type = IdpUserMetaSQLProviderFactory.class,
diff --git 
a/plugins/idp-basic/src/main/java/org/apache/gravitino/idp/storage/mapper/IdpUserMetaSQLProviderFactory.java
 
b/plugins/idp-basic/src/main/java/org/apache/gravitino/idp/storage/mapper/IdpUserMetaSQLProviderFactory.java
index 46057906d9..99cf0c3b4a 100644
--- 
a/plugins/idp-basic/src/main/java/org/apache/gravitino/idp/storage/mapper/IdpUserMetaSQLProviderFactory.java
+++ 
b/plugins/idp-basic/src/main/java/org/apache/gravitino/idp/storage/mapper/IdpUserMetaSQLProviderFactory.java
@@ -45,22 +45,12 @@ public class IdpUserMetaSQLProviderFactory {
 
   private IdpUserMetaSQLProviderFactory() {}
 
-  private static IdpUserMetaBaseSQLProvider currentProvider() {
-    return SQLProviderFactoryHelper.currentProvider(
-        PROVIDER_MAP, IdpUserMetaSQLProviderFactory.class);
-  }
-
-  static IdpUserMetaBaseSQLProvider getProvider(String databaseId) {
-    return SQLProviderFactoryHelper.getProvider(
-        databaseId, PROVIDER_MAP, IdpUserMetaSQLProviderFactory.class);
-  }
-
   public static String selectIdpUser(@Param("username") String username) {
     return currentProvider().selectIdpUser(username);
   }
 
-  public static String selectIdpUsers(@Param("usernames") List<String> 
usernames) {
-    return currentProvider().selectIdpUsers(usernames);
+  public static String selectIdpUsersByUsernames(@Param("usernames") 
List<String> usernames) {
+    return currentProvider().selectIdpUsersByUsernames(usernames);
   }
 
   public static String insertIdpUser(@Param("userMeta") IdpUserPO userPO) {
@@ -68,16 +58,26 @@ public class IdpUserMetaSQLProviderFactory {
   }
 
   public static String updateIdpUserPassword(
-      @Param("userId") Long userId, @Param("passwordHash") String 
passwordHash) {
-    return currentProvider().updateIdpUserPassword(userId, passwordHash);
+      @Param("username") String username, @Param("passwordHash") String 
passwordHash) {
+    return currentProvider().updateIdpUserPassword(username, passwordHash);
   }
 
-  public static String softDeleteIdpUser(@Param("userId") Long userId) {
-    return currentProvider().softDeleteIdpUser(userId);
+  public static String softDeleteIdpUser(@Param("username") String username) {
+    return currentProvider().softDeleteIdpUser(username);
   }
 
   public static String deleteIdpUserMetasByLegacyTimeline(
       @Param("legacyTimeline") Long legacyTimeline, @Param("limit") int limit) 
{
     return 
currentProvider().deleteIdpUserMetasByLegacyTimeline(legacyTimeline, limit);
   }
+
+  static IdpUserMetaBaseSQLProvider getProvider(String databaseId) {
+    return SQLProviderFactoryHelper.getProvider(
+        databaseId, PROVIDER_MAP, IdpUserMetaSQLProviderFactory.class);
+  }
+
+  private static IdpUserMetaBaseSQLProvider currentProvider() {
+    return SQLProviderFactoryHelper.currentProvider(
+        PROVIDER_MAP, IdpUserMetaSQLProviderFactory.class);
+  }
 }
diff --git 
a/plugins/idp-basic/src/main/java/org/apache/gravitino/idp/storage/mapper/provider/base/IdpGroupMetaBaseSQLProvider.java
 
b/plugins/idp-basic/src/main/java/org/apache/gravitino/idp/storage/mapper/provider/base/IdpGroupMetaBaseSQLProvider.java
index a99edacec2..878aa44f1c 100644
--- 
a/plugins/idp-basic/src/main/java/org/apache/gravitino/idp/storage/mapper/provider/base/IdpGroupMetaBaseSQLProvider.java
+++ 
b/plugins/idp-basic/src/main/java/org/apache/gravitino/idp/storage/mapper/provider/base/IdpGroupMetaBaseSQLProvider.java
@@ -19,7 +19,6 @@
 
 package org.apache.gravitino.idp.storage.mapper.provider.base;
 
-import java.util.List;
 import org.apache.gravitino.idp.storage.mapper.IdpGroupMetaMapper;
 import org.apache.gravitino.idp.storage.po.IdpGroupPO;
 import org.apache.ibatis.annotations.Param;
@@ -35,21 +34,6 @@ public class IdpGroupMetaBaseSQLProvider {
         + " WHERE group_name = #{groupName} AND deleted_at = 0";
   }
 
-  public String selectIdpGroups(@Param("groupNames") List<String> groupNames) {
-    return "<script>"
-        + "SELECT group_id as groupId, group_name as groupName,"
-        + " current_version as currentVersion,"
-        + " last_version as lastVersion, deleted_at as deletedAt"
-        + " FROM "
-        + IdpGroupMetaMapper.IDP_GROUP_TABLE_NAME
-        + " WHERE deleted_at = 0 "
-        + "<foreach collection='groupNames' item='groupName'"
-        + " open='AND group_name IN (' separator=',' close=')'>"
-        + "#{groupName}"
-        + "</foreach>"
-        + "</script>";
-  }
-
   public String insertIdpGroup(@Param("groupMeta") IdpGroupPO groupPO) {
     return "INSERT INTO "
         + IdpGroupMetaMapper.IDP_GROUP_TABLE_NAME
@@ -63,12 +47,12 @@ public class IdpGroupMetaBaseSQLProvider {
         + " )";
   }
 
-  public String softDeleteIdpGroup(@Param("groupId") Long groupId) {
+  public String softDeleteIdpGroup(@Param("groupName") String groupName) {
     return "UPDATE "
         + IdpGroupMetaMapper.IDP_GROUP_TABLE_NAME
         + " SET deleted_at = "
         + currentTimeMillisExpression()
-        + " WHERE group_id = #{groupId} AND deleted_at = 0";
+        + " WHERE group_name = #{groupName} AND deleted_at = 0";
   }
 
   public String deleteIdpGroupMetasByLegacyTimeline(
diff --git 
a/plugins/idp-basic/src/main/java/org/apache/gravitino/idp/storage/mapper/provider/base/IdpUserMetaBaseSQLProvider.java
 
b/plugins/idp-basic/src/main/java/org/apache/gravitino/idp/storage/mapper/provider/base/IdpUserMetaBaseSQLProvider.java
index 5c026d1db5..d89f3f7757 100644
--- 
a/plugins/idp-basic/src/main/java/org/apache/gravitino/idp/storage/mapper/provider/base/IdpUserMetaBaseSQLProvider.java
+++ 
b/plugins/idp-basic/src/main/java/org/apache/gravitino/idp/storage/mapper/provider/base/IdpUserMetaBaseSQLProvider.java
@@ -26,7 +26,7 @@ import org.apache.ibatis.annotations.Param;
 
 public class IdpUserMetaBaseSQLProvider {
   public String selectIdpUser(@Param("username") String username) {
-    return "SELECT user_id as userId, user_name as userName, password_hash as 
passwordHash,"
+    return "SELECT user_id as userId, user_name as username, password_hash as 
passwordHash,"
         + " current_version as currentVersion,"
         + " last_version as lastVersion, deleted_at as deletedAt"
         + " FROM "
@@ -34,9 +34,9 @@ public class IdpUserMetaBaseSQLProvider {
         + " WHERE user_name = #{username} AND deleted_at = 0";
   }
 
-  public String selectIdpUsers(@Param("usernames") List<String> usernames) {
+  public String selectIdpUsersByUsernames(@Param("usernames") List<String> 
usernames) {
     return "<script>"
-        + "SELECT user_id as userId, user_name as userName, password_hash as 
passwordHash,"
+        + "SELECT user_id as userId, user_name as username, password_hash as 
passwordHash,"
         + " current_version as currentVersion,"
         + " last_version as lastVersion, deleted_at as deletedAt"
         + " FROM "
@@ -55,7 +55,7 @@ public class IdpUserMetaBaseSQLProvider {
         + " (user_id, user_name, password_hash, current_version, last_version, 
deleted_at)"
         + " VALUES ("
         + " #{userMeta.userId},"
-        + " #{userMeta.userName},"
+        + " #{userMeta.username},"
         + " #{userMeta.passwordHash},"
         + " #{userMeta.currentVersion},"
         + " #{userMeta.lastVersion},"
@@ -64,20 +64,21 @@ public class IdpUserMetaBaseSQLProvider {
   }
 
   public String updateIdpUserPassword(
-      @Param("userId") Long userId, @Param("passwordHash") String 
passwordHash) {
+      @Param("username") String username, @Param("passwordHash") String 
passwordHash) {
     return "UPDATE "
         + IdpUserMetaMapper.IDP_USER_TABLE_NAME
         + " SET password_hash = #{passwordHash}"
-        + " WHERE user_id = #{userId}"
-        + " AND deleted_at = 0";
+        + " WHERE user_name = #{username}"
+        + " AND deleted_at = 0"
+        + " AND password_hash <> #{passwordHash}";
   }
 
-  public String softDeleteIdpUser(@Param("userId") Long userId) {
+  public String softDeleteIdpUser(@Param("username") String username) {
     return "UPDATE "
         + IdpUserMetaMapper.IDP_USER_TABLE_NAME
         + " SET deleted_at = "
         + currentTimeMillisExpression()
-        + " WHERE user_id = #{userId} AND deleted_at = 0";
+        + " WHERE user_name = #{username} AND deleted_at = 0";
   }
 
   public String deleteIdpUserMetasByLegacyTimeline(
diff --git 
a/plugins/idp-basic/src/main/java/org/apache/gravitino/idp/storage/mapper/provider/h2/IdpUserGroupRelH2Provider.java
 
b/plugins/idp-basic/src/main/java/org/apache/gravitino/idp/storage/mapper/provider/h2/IdpUserGroupRelH2Provider.java
index 1f9d1dc7aa..3803d96d12 100644
--- 
a/plugins/idp-basic/src/main/java/org/apache/gravitino/idp/storage/mapper/provider/h2/IdpUserGroupRelH2Provider.java
+++ 
b/plugins/idp-basic/src/main/java/org/apache/gravitino/idp/storage/mapper/provider/h2/IdpUserGroupRelH2Provider.java
@@ -29,11 +29,6 @@ import org.apache.ibatis.annotations.Param;
 /** SQL provider for IdP user-group relation statements on H2 backends. */
 public class IdpUserGroupRelH2Provider extends IdpUserGroupRelBaseSQLProvider {
 
-  @Override
-  protected String currentTimeMillisExpression() {
-    return "DATEDIFF('MILLISECOND', TIMESTAMP '1970-01-01 00:00:00', 
CURRENT_TIMESTAMP())";
-  }
-
   @Override
   public String softDeleteRelations(
       @Param("groupName") String groupName, @Param("usernames") List<String> 
usernames) {
@@ -84,4 +79,9 @@ public class IdpUserGroupRelH2Provider extends 
IdpUserGroupRelBaseSQLProvider {
         + " WHEN MATCHED THEN UPDATE SET r.deleted_at = "
         + currentTimeMillisExpression();
   }
+
+  @Override
+  protected String currentTimeMillisExpression() {
+    return "DATEDIFF('MILLISECOND', TIMESTAMP '1970-01-01 00:00:00', 
CURRENT_TIMESTAMP())";
+  }
 }
diff --git 
a/plugins/idp-basic/src/main/java/org/apache/gravitino/idp/storage/mapper/provider/postgresql/IdpGroupMetaPostgreSQLProvider.java
 
b/plugins/idp-basic/src/main/java/org/apache/gravitino/idp/storage/mapper/provider/postgresql/IdpGroupMetaPostgreSQLProvider.java
index a808bc2472..3bed7f059d 100644
--- 
a/plugins/idp-basic/src/main/java/org/apache/gravitino/idp/storage/mapper/provider/postgresql/IdpGroupMetaPostgreSQLProvider.java
+++ 
b/plugins/idp-basic/src/main/java/org/apache/gravitino/idp/storage/mapper/provider/postgresql/IdpGroupMetaPostgreSQLProvider.java
@@ -25,11 +25,6 @@ import org.apache.ibatis.annotations.Param;
 
 public class IdpGroupMetaPostgreSQLProvider extends 
IdpGroupMetaBaseSQLProvider {
 
-  @Override
-  protected String currentTimeMillisExpression() {
-    return "CAST(EXTRACT(EPOCH FROM CURRENT_TIMESTAMP) * 1000 AS BIGINT)";
-  }
-
   @Override
   public String deleteIdpGroupMetasByLegacyTimeline(
       @Param("legacyTimeline") Long legacyTimeline, @Param("limit") int limit) 
{
@@ -39,4 +34,9 @@ public class IdpGroupMetaPostgreSQLProvider extends 
IdpGroupMetaBaseSQLProvider
         + IdpGroupMetaMapper.IDP_GROUP_TABLE_NAME
         + " WHERE deleted_at > 0 AND deleted_at < #{legacyTimeline} LIMIT 
#{limit})";
   }
+
+  @Override
+  protected String currentTimeMillisExpression() {
+    return "CAST(EXTRACT(EPOCH FROM CURRENT_TIMESTAMP) * 1000 AS BIGINT)";
+  }
 }
diff --git 
a/plugins/idp-basic/src/main/java/org/apache/gravitino/idp/storage/mapper/provider/postgresql/IdpUserGroupRelPostgreSQLProvider.java
 
b/plugins/idp-basic/src/main/java/org/apache/gravitino/idp/storage/mapper/provider/postgresql/IdpUserGroupRelPostgreSQLProvider.java
index 03cbeb6635..121aa0e6a3 100644
--- 
a/plugins/idp-basic/src/main/java/org/apache/gravitino/idp/storage/mapper/provider/postgresql/IdpUserGroupRelPostgreSQLProvider.java
+++ 
b/plugins/idp-basic/src/main/java/org/apache/gravitino/idp/storage/mapper/provider/postgresql/IdpUserGroupRelPostgreSQLProvider.java
@@ -28,11 +28,6 @@ import org.apache.ibatis.annotations.Param;
 
 public class IdpUserGroupRelPostgreSQLProvider extends 
IdpUserGroupRelBaseSQLProvider {
 
-  @Override
-  protected String currentTimeMillisExpression() {
-    return "CAST(EXTRACT(EPOCH FROM CURRENT_TIMESTAMP) * 1000 AS BIGINT)";
-  }
-
   @Override
   public String softDeleteRelations(
       @Param("groupName") String groupName, @Param("usernames") List<String> 
usernames) {
@@ -95,4 +90,9 @@ public class IdpUserGroupRelPostgreSQLProvider extends 
IdpUserGroupRelBaseSQLPro
         + IdpUserGroupRelMapper.IDP_USER_GROUP_REL_TABLE_NAME
         + " WHERE deleted_at > 0 AND deleted_at < #{legacyTimeline} LIMIT 
#{limit})";
   }
+
+  @Override
+  protected String currentTimeMillisExpression() {
+    return "CAST(EXTRACT(EPOCH FROM CURRENT_TIMESTAMP) * 1000 AS BIGINT)";
+  }
 }
diff --git 
a/plugins/idp-basic/src/main/java/org/apache/gravitino/idp/storage/mapper/provider/postgresql/IdpUserMetaPostgreSQLProvider.java
 
b/plugins/idp-basic/src/main/java/org/apache/gravitino/idp/storage/mapper/provider/postgresql/IdpUserMetaPostgreSQLProvider.java
index c41dd86589..9993b2e041 100644
--- 
a/plugins/idp-basic/src/main/java/org/apache/gravitino/idp/storage/mapper/provider/postgresql/IdpUserMetaPostgreSQLProvider.java
+++ 
b/plugins/idp-basic/src/main/java/org/apache/gravitino/idp/storage/mapper/provider/postgresql/IdpUserMetaPostgreSQLProvider.java
@@ -25,11 +25,6 @@ import org.apache.ibatis.annotations.Param;
 
 public class IdpUserMetaPostgreSQLProvider extends IdpUserMetaBaseSQLProvider {
 
-  @Override
-  protected String currentTimeMillisExpression() {
-    return "CAST(EXTRACT(EPOCH FROM CURRENT_TIMESTAMP) * 1000 AS BIGINT)";
-  }
-
   @Override
   public String deleteIdpUserMetasByLegacyTimeline(
       @Param("legacyTimeline") Long legacyTimeline, @Param("limit") int limit) 
{
@@ -39,4 +34,9 @@ public class IdpUserMetaPostgreSQLProvider extends 
IdpUserMetaBaseSQLProvider {
         + IdpUserMetaMapper.IDP_USER_TABLE_NAME
         + " WHERE deleted_at > 0 AND deleted_at < #{legacyTimeline} LIMIT 
#{limit})";
   }
+
+  @Override
+  protected String currentTimeMillisExpression() {
+    return "CAST(EXTRACT(EPOCH FROM CURRENT_TIMESTAMP) * 1000 AS BIGINT)";
+  }
 }
diff --git 
a/plugins/idp-basic/src/main/java/org/apache/gravitino/idp/storage/po/IdpUserPO.java
 
b/plugins/idp-basic/src/main/java/org/apache/gravitino/idp/storage/po/IdpUserPO.java
index cbe03a610b..2da098a481 100644
--- 
a/plugins/idp-basic/src/main/java/org/apache/gravitino/idp/storage/po/IdpUserPO.java
+++ 
b/plugins/idp-basic/src/main/java/org/apache/gravitino/idp/storage/po/IdpUserPO.java
@@ -34,7 +34,7 @@ import lombok.ToString;
 @Builder(setterPrefix = "with")
 public class IdpUserPO {
   private Long userId;
-  private String userName;
+  private String username;
   private String passwordHash;
   private Long currentVersion;
   private Long lastVersion;
diff --git 
a/plugins/idp-basic/src/main/java/org/apache/gravitino/idp/storage/service/IdpGroupMetaService.java
 
b/plugins/idp-basic/src/main/java/org/apache/gravitino/idp/storage/service/IdpGroupMetaService.java
new file mode 100644
index 0000000000..ce51423775
--- /dev/null
+++ 
b/plugins/idp-basic/src/main/java/org/apache/gravitino/idp/storage/service/IdpGroupMetaService.java
@@ -0,0 +1,152 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF 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.apache.gravitino.idp.storage.service;
+
+import static 
org.apache.gravitino.metrics.source.MetricsSource.GRAVITINO_RELATIONAL_STORE_METRIC_NAME;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+import org.apache.gravitino.idp.exception.NotFoundException;
+import org.apache.gravitino.idp.storage.mapper.IdpGroupMetaMapper;
+import org.apache.gravitino.idp.storage.mapper.IdpUserGroupRelMapper;
+import org.apache.gravitino.idp.storage.po.IdpGroupPO;
+import org.apache.gravitino.idp.storage.po.IdpUserGroupRelPO;
+import org.apache.gravitino.metrics.Monitored;
+import org.apache.gravitino.storage.RandomIdGenerator;
+import org.apache.gravitino.storage.relational.utils.SessionUtils;
+
+/**
+ * The service class for built-in IdP group metadata. It provides the basic 
database operations for
+ * group.
+ */
+public class IdpGroupMetaService {
+  private static final IdpGroupMetaService INSTANCE = new 
IdpGroupMetaService();
+
+  private IdpGroupMetaService() {}
+
+  public static IdpGroupMetaService getInstance() {
+    return INSTANCE;
+  }
+
+  @Monitored(
+      metricsSource = GRAVITINO_RELATIONAL_STORE_METRIC_NAME,
+      baseMetricName = "getIdpGroupByName")
+  public IdpGroupPO getIdpGroupByName(String groupName) {
+    return getIdpGroupPOByName(groupName);
+  }
+
+  @Monitored(
+      metricsSource = GRAVITINO_RELATIONAL_STORE_METRIC_NAME,
+      baseMetricName = "listUsernamesByGroupName")
+  public List<String> listUsernamesByGroupName(String groupName) {
+    return SessionUtils.getWithoutCommit(
+        IdpUserGroupRelMapper.class, mapper -> 
mapper.selectUsernamesByGroupName(groupName));
+  }
+
+  @Monitored(
+      metricsSource = GRAVITINO_RELATIONAL_STORE_METRIC_NAME,
+      baseMetricName = "insertIdpGroup")
+  public void insertIdpGroup(IdpGroupPO groupPO) {
+    SessionUtils.doWithCommit(IdpGroupMetaMapper.class, mapper -> 
mapper.insertIdpGroup(groupPO));
+  }
+
+  @Monitored(
+      metricsSource = GRAVITINO_RELATIONAL_STORE_METRIC_NAME,
+      baseMetricName = "deleteIdpGroup")
+  public boolean deleteIdpGroup(String groupName) {
+    SessionUtils.doMultipleWithCommit(
+        () ->
+            SessionUtils.doWithoutCommit(
+                IdpUserGroupRelMapper.class,
+                mapper -> mapper.softDeleteRelationsByGroupName(groupName)),
+        () ->
+            SessionUtils.doWithoutCommit(
+                IdpGroupMetaMapper.class, mapper -> 
mapper.softDeleteIdpGroup(groupName)));
+    return true;
+  }
+
+  @Monitored(
+      metricsSource = GRAVITINO_RELATIONAL_STORE_METRIC_NAME,
+      baseMetricName = "addUsersToGroup")
+  public void addUsersToGroup(String groupName, List<String> usernames) {
+    IdpGroupPO group = getIdpGroupPOByName(groupName);
+    Map<String, Long> userIds =
+        IdpUserMetaService.getInstance().resolveUserIdsByUsernames(usernames);
+    List<IdpUserGroupRelPO> relations = new ArrayList<>(usernames.size());
+    for (String username : usernames) {
+      relations.add(newUserGroupRelation(group.getGroupId(), 
userIds.get(username)));
+    }
+
+    SessionUtils.doWithCommit(
+        IdpUserGroupRelMapper.class, mapper -> 
mapper.batchInsertRelations(relations));
+  }
+
+  @Monitored(
+      metricsSource = GRAVITINO_RELATIONAL_STORE_METRIC_NAME,
+      baseMetricName = "removeUsersFromGroup")
+  public int removeUsersFromGroup(String groupName, List<String> usernames) {
+    return SessionUtils.doWithCommitAndFetchResult(
+        IdpUserGroupRelMapper.class, mapper -> 
mapper.softDeleteRelations(groupName, usernames));
+  }
+
+  @Monitored(
+      metricsSource = GRAVITINO_RELATIONAL_STORE_METRIC_NAME,
+      baseMetricName = "deleteIdpGroupMetasByLegacyTimeline")
+  public int deleteGroupMetasByLegacyTimeline(long legacyTimeline, int limit) {
+    int[] groupDeletedCount = new int[] {0};
+    int[] relDeletedCount = new int[] {0};
+
+    SessionUtils.doMultipleWithCommit(
+        () ->
+            groupDeletedCount[0] =
+                SessionUtils.getWithoutCommit(
+                    IdpGroupMetaMapper.class,
+                    mapper -> 
mapper.deleteIdpGroupMetasByLegacyTimeline(legacyTimeline, limit)),
+        () ->
+            relDeletedCount[0] =
+                SessionUtils.getWithoutCommit(
+                    IdpUserGroupRelMapper.class,
+                    mapper ->
+                        
mapper.deleteIdpUserGroupRelMetasByLegacyTimeline(legacyTimeline, limit)));
+
+    return groupDeletedCount[0] + relDeletedCount[0];
+  }
+
+  private static IdpUserGroupRelPO newUserGroupRelation(long groupId, long 
userId) {
+    return IdpUserGroupRelPO.builder()
+        .withId(RandomIdGenerator.INSTANCE.nextId())
+        .withUserId(userId)
+        .withGroupId(groupId)
+        .withCurrentVersion(1L)
+        .withLastVersion(0L)
+        .withDeletedAt(0L)
+        .build();
+  }
+
+  private IdpGroupPO getIdpGroupPOByName(String groupName) {
+    IdpGroupPO groupPO =
+        SessionUtils.getWithoutCommit(
+            IdpGroupMetaMapper.class, mapper -> 
mapper.selectIdpGroup(groupName));
+    if (groupPO == null) {
+      throw new NotFoundException("IdP group not found: %s", groupName);
+    }
+    return groupPO;
+  }
+}
diff --git 
a/plugins/idp-basic/src/main/java/org/apache/gravitino/idp/storage/service/IdpUserMetaService.java
 
b/plugins/idp-basic/src/main/java/org/apache/gravitino/idp/storage/service/IdpUserMetaService.java
new file mode 100644
index 0000000000..b4f62c74e3
--- /dev/null
+++ 
b/plugins/idp-basic/src/main/java/org/apache/gravitino/idp/storage/service/IdpUserMetaService.java
@@ -0,0 +1,157 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF 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.apache.gravitino.idp.storage.service;
+
+import static 
org.apache.gravitino.metrics.source.MetricsSource.GRAVITINO_RELATIONAL_STORE_METRIC_NAME;
+
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import org.apache.gravitino.idp.exception.NotFoundException;
+import org.apache.gravitino.idp.storage.mapper.IdpUserGroupRelMapper;
+import org.apache.gravitino.idp.storage.mapper.IdpUserMetaMapper;
+import org.apache.gravitino.idp.storage.po.IdpUserPO;
+import org.apache.gravitino.metrics.Monitored;
+import org.apache.gravitino.storage.relational.utils.SessionUtils;
+
+/**
+ * The service class for built-in IdP user metadata. It provides the basic 
database operations for
+ * user.
+ */
+public class IdpUserMetaService {
+  private static final IdpUserMetaService INSTANCE = new IdpUserMetaService();
+
+  private IdpUserMetaService() {}
+
+  public static IdpUserMetaService getInstance() {
+    return INSTANCE;
+  }
+
+  @Monitored(
+      metricsSource = GRAVITINO_RELATIONAL_STORE_METRIC_NAME,
+      baseMetricName = "getIdpUserByUsername")
+  public IdpUserPO getIdpUserByUsername(String username) {
+    return getIdpUserPOByUsername(username);
+  }
+
+  @Monitored(
+      metricsSource = GRAVITINO_RELATIONAL_STORE_METRIC_NAME,
+      baseMetricName = "listGroupNamesByUsername")
+  public List<String> listGroupNamesByUsername(String username) {
+    return SessionUtils.getWithoutCommit(
+        IdpUserGroupRelMapper.class, mapper -> 
mapper.selectGroupNamesByUsername(username));
+  }
+
+  @Monitored(
+      metricsSource = GRAVITINO_RELATIONAL_STORE_METRIC_NAME,
+      baseMetricName = "insertIdpUser")
+  public void insertIdpUser(IdpUserPO userPO) {
+    SessionUtils.doWithCommit(IdpUserMetaMapper.class, mapper -> 
mapper.insertIdpUser(userPO));
+  }
+
+  @Monitored(
+      metricsSource = GRAVITINO_RELATIONAL_STORE_METRIC_NAME,
+      baseMetricName = "deleteIdpUser")
+  public boolean deleteIdpUser(String username) {
+    SessionUtils.doMultipleWithCommit(
+        () ->
+            SessionUtils.doWithoutCommit(
+                IdpUserGroupRelMapper.class,
+                mapper -> mapper.softDeleteRelationsByUsername(username)),
+        () ->
+            SessionUtils.doWithoutCommit(
+                IdpUserMetaMapper.class, mapper -> 
mapper.softDeleteIdpUser(username)));
+    return true;
+  }
+
+  /**
+   * Updates the password hash for an active user, following the core 
relational meta update flow:
+   * load the current row, commit the update, and use the affected row count 
as the success signal.
+   *
+   * @param username username of the user
+   * @param passwordHash new password hash to store
+   * @return {@code true} if the user exists and the password hash was updated
+   */
+  @Monitored(
+      metricsSource = GRAVITINO_RELATIONAL_STORE_METRIC_NAME,
+      baseMetricName = "updateIdpUserPassword")
+  public boolean updateIdpUserPassword(String username, String passwordHash) {
+    Integer updated =
+        SessionUtils.doWithCommitAndFetchResult(
+            IdpUserMetaMapper.class,
+            mapper -> mapper.updateIdpUserPassword(username, passwordHash));
+    return updated > 0;
+  }
+
+  @Monitored(
+      metricsSource = GRAVITINO_RELATIONAL_STORE_METRIC_NAME,
+      baseMetricName = "deleteIdpUserMetasByLegacyTimeline")
+  public int deleteUserMetasByLegacyTimeline(long legacyTimeline, int limit) {
+    int[] userDeletedCount = new int[] {0};
+    int[] relDeletedCount = new int[] {0};
+
+    SessionUtils.doMultipleWithCommit(
+        () ->
+            userDeletedCount[0] =
+                SessionUtils.getWithoutCommit(
+                    IdpUserMetaMapper.class,
+                    mapper -> 
mapper.deleteIdpUserMetasByLegacyTimeline(legacyTimeline, limit)),
+        () ->
+            relDeletedCount[0] =
+                SessionUtils.getWithoutCommit(
+                    IdpUserGroupRelMapper.class,
+                    mapper ->
+                        
mapper.deleteIdpUserGroupRelMetasByLegacyTimeline(legacyTimeline, limit)));
+
+    return userDeletedCount[0] + relDeletedCount[0];
+  }
+
+  /**
+   * Resolves active user ids for the given usernames in one query. Throws if 
any username is
+   * missing.
+   *
+   * @param usernames usernames to resolve
+   * @return username to user id map
+   */
+  Map<String, Long> resolveUserIdsByUsernames(List<String> usernames) {
+    List<IdpUserPO> users =
+        SessionUtils.getWithoutCommit(
+            IdpUserMetaMapper.class, mapper -> 
mapper.selectIdpUsersByUsernames(usernames));
+    Map<String, Long> userIds = new HashMap<>(users.size());
+    for (IdpUserPO user : users) {
+      userIds.put(user.getUsername(), user.getUserId());
+    }
+    for (String username : usernames) {
+      if (!userIds.containsKey(username)) {
+        throw new NotFoundException("IdP user not found: %s", username);
+      }
+    }
+    return userIds;
+  }
+
+  private IdpUserPO getIdpUserPOByUsername(String username) {
+    IdpUserPO userPO =
+        SessionUtils.getWithoutCommit(
+            IdpUserMetaMapper.class, mapper -> mapper.selectIdpUser(username));
+    if (userPO == null) {
+      throw new NotFoundException("IdP user not found: %s", username);
+    }
+    return userPO;
+  }
+}
diff --git 
a/plugins/idp-basic/src/test/java/org/apache/gravitino/idp/storage/gc/TestIdpLegacyGarbageCollector.java
 
b/plugins/idp-basic/src/test/java/org/apache/gravitino/idp/storage/gc/TestIdpLegacyGarbageCollector.java
new file mode 100644
index 0000000000..599954ed7a
--- /dev/null
+++ 
b/plugins/idp-basic/src/test/java/org/apache/gravitino/idp/storage/gc/TestIdpLegacyGarbageCollector.java
@@ -0,0 +1,183 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF 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.apache.gravitino.idp.storage.gc;
+
+import static org.apache.gravitino.Configs.STORE_DELETE_AFTER_TIME;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNull;
+
+import java.sql.Connection;
+import java.sql.ResultSet;
+import java.sql.SQLException;
+import java.sql.Statement;
+import java.util.List;
+import org.apache.gravitino.Config;
+import org.apache.gravitino.idp.storage.mapper.AbstractIdpMetaStorageTest;
+import org.apache.gravitino.idp.storage.mapper.IdpGroupMetaMapper;
+import org.apache.gravitino.idp.storage.mapper.IdpUserGroupRelMapper;
+import org.apache.gravitino.idp.storage.mapper.IdpUserMetaMapper;
+import org.apache.gravitino.idp.storage.po.IdpGroupPO;
+import org.apache.gravitino.idp.storage.po.IdpUserGroupRelPO;
+import org.apache.gravitino.idp.storage.po.IdpUserPO;
+import org.apache.gravitino.storage.relational.session.SqlSessionFactoryHelper;
+import org.apache.ibatis.session.SqlSession;
+import org.junit.jupiter.api.Tag;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.MethodSource;
+
+@Tag("gravitino-docker-test")
+class TestIdpLegacyGarbageCollector extends AbstractIdpMetaStorageTest {
+  private IdpUserMetaMapper idpUserMetaMapper;
+  private IdpGroupMetaMapper idpGroupMetaMapper;
+  private IdpUserGroupRelMapper idpUserGroupRelMapper;
+
+  @Override
+  protected void initializeMappers() {
+    idpUserMetaMapper = sharedSession.getMapper(IdpUserMetaMapper.class);
+    idpGroupMetaMapper = sharedSession.getMapper(IdpGroupMetaMapper.class);
+    idpUserGroupRelMapper = 
sharedSession.getMapper(IdpUserGroupRelMapper.class);
+  }
+
+  @ParameterizedTest
+  @MethodSource("storageProvider")
+  void testCollectAndClean(String type) throws Exception {
+    init(type);
+    insertGroups();
+    insertUsers();
+    insertUserGroupRelations();
+
+    markAllSoftDeleted();
+    reopenSession();
+    assertNull(idpUserMetaMapper.selectIdpUser("user1"));
+    assertNull(idpGroupMetaMapper.selectIdpGroup("group1"));
+    assertEquals(4, countUsers());
+    assertEquals(2, countGroups());
+    assertEquals(8, countUserGroupRels());
+
+    Config config = new Config(false) {};
+    config.set(STORE_DELETE_AFTER_TIME, 600000L);
+
+    closeSession();
+    IdpLegacyGarbageCollector garbageCollector = new 
IdpLegacyGarbageCollector(config);
+    garbageCollector.collectAndClean();
+    reopenSession();
+
+    assertEquals(0, countUsers());
+    assertEquals(0, countGroups());
+    assertEquals(0, countUserGroupRels());
+  }
+
+  private void reopenSession() {
+    closeSession();
+    sharedSession = 
SqlSessionFactoryHelper.getInstance().getSqlSessionFactory().openSession(true);
+    initializeMappers();
+  }
+
+  private void insertGroups() {
+    for (long index = 1L; index <= 2L; index++) {
+      idpGroupMetaMapper.insertIdpGroup(
+          IdpGroupPO.builder()
+              .withGroupId(index)
+              .withGroupName("group" + index)
+              .withCurrentVersion(1L)
+              .withLastVersion(0L)
+              .withDeletedAt(0L)
+              .build());
+    }
+  }
+
+  private void insertUsers() {
+    for (long index = 1L; index <= 4L; index++) {
+      idpUserMetaMapper.insertIdpUser(
+          IdpUserPO.builder()
+              .withUserId(index)
+              .withUsername("user" + index)
+              .withPasswordHash("hash-" + index)
+              .withCurrentVersion(1L)
+              .withLastVersion(0L)
+              .withDeletedAt(0L)
+              .build());
+    }
+  }
+
+  private void insertUserGroupRelations() {
+    idpUserGroupRelMapper.batchInsertRelations(
+        List.of(
+            userGroupRel(100L, "user1", "group1"),
+            userGroupRel(101L, "user2", "group1"),
+            userGroupRel(102L, "user3", "group1"),
+            userGroupRel(103L, "user4", "group1"),
+            userGroupRel(104L, "user1", "group2"),
+            userGroupRel(105L, "user2", "group2"),
+            userGroupRel(106L, "user3", "group2"),
+            userGroupRel(107L, "user4", "group2")));
+  }
+
+  private IdpUserGroupRelPO userGroupRel(long relationOrdinal, String 
username, String groupName) {
+    IdpUserPO user = idpUserMetaMapper.selectIdpUser(username);
+    IdpGroupPO group = idpGroupMetaMapper.selectIdpGroup(groupName);
+    return IdpUserGroupRelPO.builder()
+        .withId(relationOrdinal)
+        .withUserId(user.getUserId())
+        .withGroupId(group.getGroupId())
+        .withCurrentVersion(1L)
+        .withLastVersion(0L)
+        .withDeletedAt(0L)
+        .build();
+  }
+
+  private void markAllSoftDeleted() throws SQLException {
+    try (SqlSession sqlSession =
+            
SqlSessionFactoryHelper.getInstance().getSqlSessionFactory().openSession(true);
+        Connection connection = sqlSession.getConnection();
+        Statement statement = connection.createStatement()) {
+      statement.execute("UPDATE idp_user_meta SET deleted_at = 1 WHERE 
deleted_at = 0");
+      statement.execute("UPDATE idp_group_meta SET deleted_at = 1 WHERE 
deleted_at = 0");
+      statement.execute("UPDATE idp_user_group_rel SET deleted_at = 1 WHERE 
deleted_at = 0");
+    }
+  }
+
+  private Integer countUsers() {
+    return countRows("idp_user_meta");
+  }
+
+  private Integer countGroups() {
+    return countRows("idp_group_meta");
+  }
+
+  private Integer countUserGroupRels() {
+    return countRows("idp_user_group_rel");
+  }
+
+  private Integer countRows(String tableName) {
+    int count = 0;
+    try (SqlSession sqlSession =
+            
SqlSessionFactoryHelper.getInstance().getSqlSessionFactory().openSession(true);
+        Connection connection = sqlSession.getConnection();
+        Statement statement = connection.createStatement();
+        ResultSet rs = statement.executeQuery("SELECT count(*) FROM " + 
tableName)) {
+      while (rs.next()) {
+        count = rs.getInt(1);
+      }
+    } catch (SQLException e) {
+      throw new RuntimeException("SQL execution failed", e);
+    }
+    return count;
+  }
+}
diff --git 
a/plugins/idp-basic/src/test/java/org/apache/gravitino/idp/storage/mapper/AbstractIdpMetaStorageTest.java
 
b/plugins/idp-basic/src/test/java/org/apache/gravitino/idp/storage/mapper/AbstractIdpMetaStorageTest.java
index 047af1bb08..2a2806aaec 100644
--- 
a/plugins/idp-basic/src/test/java/org/apache/gravitino/idp/storage/mapper/AbstractIdpMetaStorageTest.java
+++ 
b/plugins/idp-basic/src/test/java/org/apache/gravitino/idp/storage/mapper/AbstractIdpMetaStorageTest.java
@@ -43,7 +43,7 @@ import 
org.apache.gravitino.storage.relational.session.SqlSessionFactoryHelper;
 import org.apache.ibatis.session.SqlSession;
 import org.junit.jupiter.api.AfterEach;
 
-abstract class AbstractIdpMetaStorageTest {
+public abstract class AbstractIdpMetaStorageTest {
   private static final String H2_BACKEND = "h2";
   private static final String MYSQL_BACKEND = "mysql";
   private static final String POSTGRESQL_BACKEND = "postgresql";
@@ -51,12 +51,12 @@ abstract class AbstractIdpMetaStorageTest {
   private static final TestDatabaseName POSTGRESQL_TEST_DATABASE = 
TestDatabaseName.PG_JDBC_BACKEND;
 
   protected JDBCBackend backend;
-  protected SqlSession sharedSession;
+  public SqlSession sharedSession;
 
   private Config config;
   private Path h2Path;
 
-  static Stream<String> storageProvider() {
+  public static Stream<String> storageProvider() {
     return Stream.of(H2_BACKEND, MYSQL_BACKEND, POSTGRESQL_BACKEND);
   }
 
@@ -77,7 +77,7 @@ abstract class AbstractIdpMetaStorageTest {
     }
   }
 
-  protected void init(String type) throws IOException {
+  public void init(String type) throws IOException {
     config = createBackendConfig(type);
     backend = new JDBCBackend();
     backend.initialize(config);
diff --git 
a/plugins/idp-basic/src/test/java/org/apache/gravitino/idp/storage/mapper/TestIdpGroupMetaStorage.java
 
b/plugins/idp-basic/src/test/java/org/apache/gravitino/idp/storage/mapper/TestIdpGroupMetaStorage.java
index 8d34c986ec..40433a46f9 100644
--- 
a/plugins/idp-basic/src/test/java/org/apache/gravitino/idp/storage/mapper/TestIdpGroupMetaStorage.java
+++ 
b/plugins/idp-basic/src/test/java/org/apache/gravitino/idp/storage/mapper/TestIdpGroupMetaStorage.java
@@ -20,15 +20,10 @@
 package org.apache.gravitino.idp.storage.mapper;
 
 import static org.junit.jupiter.api.Assertions.assertEquals;
-import static org.junit.jupiter.api.Assertions.assertIterableEquals;
 import static org.junit.jupiter.api.Assertions.assertNull;
-import static org.junit.jupiter.api.Assertions.assertThrows;
 
 import java.io.IOException;
-import java.util.Comparator;
-import java.util.List;
 import org.apache.gravitino.idp.storage.po.IdpGroupPO;
-import org.apache.ibatis.exceptions.PersistenceException;
 import org.junit.jupiter.api.Tag;
 import org.junit.jupiter.params.ParameterizedTest;
 import org.junit.jupiter.params.provider.MethodSource;
@@ -60,38 +55,6 @@ class TestIdpGroupMetaStorage extends 
AbstractIdpMetaStorageTest {
     assertNull(idpGroupMetaMapper.selectIdpGroup("unknown"));
   }
 
-  @ParameterizedTest
-  @MethodSource("storageProvider")
-  void testSelectIdpGroups(String type) throws IOException {
-    init(type);
-    IdpGroupPO firstGroup =
-        IdpGroupPO.builder()
-            .withGroupId(1L)
-            .withGroupName("dev")
-            .withCurrentVersion(1L)
-            .withLastVersion(0L)
-            .withDeletedAt(0L)
-            .build();
-    IdpGroupPO secondGroup =
-        IdpGroupPO.builder()
-            .withGroupId(2L)
-            .withGroupName("ops")
-            .withCurrentVersion(1L)
-            .withLastVersion(0L)
-            .withDeletedAt(0L)
-            .build();
-    idpGroupMetaMapper.insertIdpGroup(firstGroup);
-    idpGroupMetaMapper.insertIdpGroup(secondGroup);
-
-    List<IdpGroupPO> groups = 
idpGroupMetaMapper.selectIdpGroups(List.of("ops", "dev"));
-    groups.sort(Comparator.comparing(IdpGroupPO::getGroupId));
-    assertIterableEquals(List.of(firstGroup, secondGroup), groups);
-    List<IdpGroupPO> groupsWithEmptyFilter = 
idpGroupMetaMapper.selectIdpGroups(List.of());
-    groupsWithEmptyFilter.sort(Comparator.comparing(IdpGroupPO::getGroupId));
-    assertIterableEquals(List.of(firstGroup, secondGroup), 
groupsWithEmptyFilter);
-    assertThrows(PersistenceException.class, () -> 
idpGroupMetaMapper.selectIdpGroups(null));
-  }
-
   @ParameterizedTest
   @MethodSource("storageProvider")
   void testSelectIdpGroupIgnoresDeletedGroups(String type) throws IOException {
@@ -114,8 +77,7 @@ class TestIdpGroupMetaStorage extends 
AbstractIdpMetaStorageTest {
             .withDeletedAt(10L)
             .build());
 
-    assertIterableEquals(
-        List.of(activeGroup), 
idpGroupMetaMapper.selectIdpGroups(List.of("dev", "ops")));
+    assertEquals(activeGroup, idpGroupMetaMapper.selectIdpGroup("dev"));
     assertNull(idpGroupMetaMapper.selectIdpGroup("ops"));
   }
 
@@ -132,9 +94,9 @@ class TestIdpGroupMetaStorage extends 
AbstractIdpMetaStorageTest {
             .withDeletedAt(0L)
             .build());
 
-    assertEquals(1, idpGroupMetaMapper.softDeleteIdpGroup(1L));
+    assertEquals(1, idpGroupMetaMapper.softDeleteIdpGroup("dev"));
     assertNull(idpGroupMetaMapper.selectIdpGroup("dev"));
-    assertEquals(0, idpGroupMetaMapper.softDeleteIdpGroup(1L));
+    assertEquals(0, idpGroupMetaMapper.softDeleteIdpGroup("dev"));
     assertEquals(1, 
idpGroupMetaMapper.deleteIdpGroupMetasByLegacyTimeline(Long.MAX_VALUE, 10));
   }
 
diff --git 
a/plugins/idp-basic/src/test/java/org/apache/gravitino/idp/storage/mapper/TestIdpUserGroupRelStorage.java
 
b/plugins/idp-basic/src/test/java/org/apache/gravitino/idp/storage/mapper/TestIdpUserGroupRelStorage.java
index 3572eb8f41..80d0a47ede 100644
--- 
a/plugins/idp-basic/src/test/java/org/apache/gravitino/idp/storage/mapper/TestIdpUserGroupRelStorage.java
+++ 
b/plugins/idp-basic/src/test/java/org/apache/gravitino/idp/storage/mapper/TestIdpUserGroupRelStorage.java
@@ -67,7 +67,7 @@ class TestIdpUserGroupRelStorage extends 
AbstractIdpMetaStorageTest {
     idpUserMetaMapper.insertIdpUser(
         IdpUserPO.builder()
             .withUserId(1L)
-            .withUserName("alice")
+            .withUsername("alice")
             .withPasswordHash("hash-a")
             .withCurrentVersion(1L)
             .withLastVersion(0L)
@@ -113,7 +113,7 @@ class TestIdpUserGroupRelStorage extends 
AbstractIdpMetaStorageTest {
     idpUserMetaMapper.insertIdpUser(
         IdpUserPO.builder()
             .withUserId(1L)
-            .withUserName("alice")
+            .withUsername("alice")
             .withPasswordHash("hash-a")
             .withCurrentVersion(1L)
             .withLastVersion(0L)
@@ -122,7 +122,7 @@ class TestIdpUserGroupRelStorage extends 
AbstractIdpMetaStorageTest {
     idpUserMetaMapper.insertIdpUser(
         IdpUserPO.builder()
             .withUserId(2L)
-            .withUserName("bob")
+            .withUsername("bob")
             .withPasswordHash("hash-b")
             .withCurrentVersion(1L)
             .withLastVersion(0L)
@@ -168,7 +168,7 @@ class TestIdpUserGroupRelStorage extends 
AbstractIdpMetaStorageTest {
     idpUserMetaMapper.insertIdpUser(
         IdpUserPO.builder()
             .withUserId(1L)
-            .withUserName("alice")
+            .withUsername("alice")
             .withPasswordHash("hash-a")
             .withCurrentVersion(1L)
             .withLastVersion(0L)
@@ -177,7 +177,7 @@ class TestIdpUserGroupRelStorage extends 
AbstractIdpMetaStorageTest {
     idpUserMetaMapper.insertIdpUser(
         IdpUserPO.builder()
             .withUserId(2L)
-            .withUserName("bob")
+            .withUsername("bob")
             .withPasswordHash("hash-b")
             .withCurrentVersion(1L)
             .withLastVersion(0L)
@@ -226,7 +226,7 @@ class TestIdpUserGroupRelStorage extends 
AbstractIdpMetaStorageTest {
     idpUserMetaMapper.insertIdpUser(
         IdpUserPO.builder()
             .withUserId(1L)
-            .withUserName("alice")
+            .withUsername("alice")
             .withPasswordHash("hash-a")
             .withCurrentVersion(1L)
             .withLastVersion(0L)
@@ -235,7 +235,7 @@ class TestIdpUserGroupRelStorage extends 
AbstractIdpMetaStorageTest {
     idpUserMetaMapper.insertIdpUser(
         IdpUserPO.builder()
             .withUserId(2L)
-            .withUserName("bob")
+            .withUsername("bob")
             .withPasswordHash("hash-b")
             .withCurrentVersion(1L)
             .withLastVersion(0L)
@@ -280,7 +280,7 @@ class TestIdpUserGroupRelStorage extends 
AbstractIdpMetaStorageTest {
     idpUserMetaMapper.insertIdpUser(
         IdpUserPO.builder()
             .withUserId(3L)
-            .withUserName("carol")
+            .withUsername("carol")
             .withPasswordHash("hash-c")
             .withCurrentVersion(1L)
             .withLastVersion(0L)
@@ -333,7 +333,7 @@ class TestIdpUserGroupRelStorage extends 
AbstractIdpMetaStorageTest {
     idpUserMetaMapper.insertIdpUser(
         IdpUserPO.builder()
             .withUserId(1L)
-            .withUserName("legacy-user")
+            .withUsername("legacy-user")
             .withPasswordHash("hash")
             .withCurrentVersion(1L)
             .withLastVersion(0L)
@@ -342,7 +342,7 @@ class TestIdpUserGroupRelStorage extends 
AbstractIdpMetaStorageTest {
     idpUserMetaMapper.insertIdpUser(
         IdpUserPO.builder()
             .withUserId(2L)
-            .withUserName("new-user")
+            .withUsername("new-user")
             .withPasswordHash("hash")
             .withCurrentVersion(1L)
             .withLastVersion(0L)
@@ -351,7 +351,7 @@ class TestIdpUserGroupRelStorage extends 
AbstractIdpMetaStorageTest {
     idpUserMetaMapper.insertIdpUser(
         IdpUserPO.builder()
             .withUserId(3L)
-            .withUserName("active-user")
+            .withUsername("active-user")
             .withPasswordHash("hash")
             .withCurrentVersion(1L)
             .withLastVersion(0L)
diff --git 
a/plugins/idp-basic/src/test/java/org/apache/gravitino/idp/storage/mapper/TestIdpUserMetaStorage.java
 
b/plugins/idp-basic/src/test/java/org/apache/gravitino/idp/storage/mapper/TestIdpUserMetaStorage.java
index d7901ad747..189d93aaca 100644
--- 
a/plugins/idp-basic/src/test/java/org/apache/gravitino/idp/storage/mapper/TestIdpUserMetaStorage.java
+++ 
b/plugins/idp-basic/src/test/java/org/apache/gravitino/idp/storage/mapper/TestIdpUserMetaStorage.java
@@ -20,15 +20,11 @@
 package org.apache.gravitino.idp.storage.mapper;
 
 import static org.junit.jupiter.api.Assertions.assertEquals;
-import static org.junit.jupiter.api.Assertions.assertIterableEquals;
 import static org.junit.jupiter.api.Assertions.assertNull;
-import static org.junit.jupiter.api.Assertions.assertThrows;
 
 import java.io.IOException;
-import java.util.Comparator;
 import java.util.List;
 import org.apache.gravitino.idp.storage.po.IdpUserPO;
-import org.apache.ibatis.exceptions.PersistenceException;
 import org.junit.jupiter.api.Tag;
 import org.junit.jupiter.params.ParameterizedTest;
 import org.junit.jupiter.params.provider.MethodSource;
@@ -49,7 +45,7 @@ class TestIdpUserMetaStorage extends 
AbstractIdpMetaStorageTest {
     IdpUserPO firstUser =
         IdpUserPO.builder()
             .withUserId(1L)
-            .withUserName("alice")
+            .withUsername("alice")
             .withPasswordHash("hash-a")
             .withCurrentVersion(1L)
             .withLastVersion(0L)
@@ -63,82 +59,51 @@ class TestIdpUserMetaStorage extends 
AbstractIdpMetaStorageTest {
 
   @ParameterizedTest
   @MethodSource("storageProvider")
-  void testSelectIdpUsers(String type) throws IOException {
+  void testSelectIdpUsersByUsernames(String type) throws IOException {
     init(type);
-    IdpUserPO firstUser =
-        IdpUserPO.builder()
-            .withUserId(1L)
-            .withUserName("alice")
-            .withPasswordHash("hash-a")
-            .withCurrentVersion(1L)
-            .withLastVersion(0L)
-            .withDeletedAt(0L)
-            .build();
-    IdpUserPO secondUser =
-        IdpUserPO.builder()
-            .withUserId(2L)
-            .withUserName("bob")
-            .withPasswordHash("hash-b")
-            .withCurrentVersion(1L)
-            .withLastVersion(0L)
-            .withDeletedAt(0L)
-            .build();
-    idpUserMetaMapper.insertIdpUser(firstUser);
-    idpUserMetaMapper.insertIdpUser(secondUser);
-
-    List<IdpUserPO> users = idpUserMetaMapper.selectIdpUsers(List.of("bob", 
"alice"));
-    users.sort(Comparator.comparing(IdpUserPO::getUserId));
-    assertIterableEquals(List.of(firstUser, secondUser), users);
-    List<IdpUserPO> usersWithEmptyFilter = 
idpUserMetaMapper.selectIdpUsers(List.of());
-    usersWithEmptyFilter.sort(Comparator.comparing(IdpUserPO::getUserId));
-    assertIterableEquals(List.of(firstUser, secondUser), usersWithEmptyFilter);
-    assertThrows(PersistenceException.class, () -> 
idpUserMetaMapper.selectIdpUsers(null));
-  }
-
-  @ParameterizedTest
-  @MethodSource("storageProvider")
-  void testSelectIdpUsersIgnoresDeletedUsers(String type) throws IOException {
-    init(type);
-    IdpUserPO activeUser =
+    idpUserMetaMapper.insertIdpUser(
         IdpUserPO.builder()
             .withUserId(1L)
-            .withUserName("alice")
+            .withUsername("alice")
             .withPasswordHash("hash-a")
             .withCurrentVersion(1L)
             .withLastVersion(0L)
             .withDeletedAt(0L)
-            .build();
-    idpUserMetaMapper.insertIdpUser(activeUser);
+            .build());
     idpUserMetaMapper.insertIdpUser(
         IdpUserPO.builder()
             .withUserId(2L)
-            .withUserName("bob")
+            .withUsername("bob")
             .withPasswordHash("hash-b")
             .withCurrentVersion(1L)
             .withLastVersion(0L)
-            .withDeletedAt(10L)
+            .withDeletedAt(0L)
             .build());
 
-    assertIterableEquals(
-        List.of(activeUser), idpUserMetaMapper.selectIdpUsers(List.of("alice", 
"bob")));
-    assertNull(idpUserMetaMapper.selectIdpUser("bob"));
+    assertEquals(
+        List.of("alice", "bob"),
+        idpUserMetaMapper.selectIdpUsersByUsernames(List.of("alice", "bob", 
"alice")).stream()
+            .map(IdpUserPO::getUsername)
+            .sorted()
+            .toList());
+    assertEquals(List.of(), 
idpUserMetaMapper.selectIdpUsersByUsernames(List.of("unknown")));
   }
 
   @ParameterizedTest
   @MethodSource("storageProvider")
   void testUpdateIdpUserPassword(String type) throws IOException {
     init(type);
-    idpUserMetaMapper.insertIdpUser(
+    IdpUserPO oldUserPO =
         IdpUserPO.builder()
             .withUserId(1L)
-            .withUserName("alice")
+            .withUsername("alice")
             .withPasswordHash("hash-a")
             .withCurrentVersion(1L)
             .withLastVersion(0L)
             .withDeletedAt(0L)
-            .build());
-
-    assertEquals(1, idpUserMetaMapper.updateIdpUserPassword(1L, "hash-a-2"));
+            .build();
+    idpUserMetaMapper.insertIdpUser(oldUserPO);
+    assertEquals(1, idpUserMetaMapper.updateIdpUserPassword("alice", 
"hash-a-2"));
     assertEquals("hash-a-2", 
idpUserMetaMapper.selectIdpUser("alice").getPasswordHash());
     assertEquals(1L, 
idpUserMetaMapper.selectIdpUser("alice").getCurrentVersion());
     assertEquals(0L, 
idpUserMetaMapper.selectIdpUser("alice").getLastVersion());
@@ -151,38 +116,39 @@ class TestIdpUserMetaStorage extends 
AbstractIdpMetaStorageTest {
     idpUserMetaMapper.insertIdpUser(
         IdpUserPO.builder()
             .withUserId(1L)
-            .withUserName("alice")
+            .withUsername("alice")
             .withPasswordHash("hash-a")
             .withCurrentVersion(3L)
             .withLastVersion(2L)
             .withDeletedAt(0L)
             .build());
 
-    assertEquals(1, idpUserMetaMapper.updateIdpUserPassword(1L, "hash-a-2"));
+    assertEquals(1, idpUserMetaMapper.updateIdpUserPassword("alice", 
"hash-a-2"));
     assertEquals("hash-a-2", 
idpUserMetaMapper.selectIdpUser("alice").getPasswordHash());
     assertEquals(3L, 
idpUserMetaMapper.selectIdpUser("alice").getCurrentVersion());
     assertEquals(2L, 
idpUserMetaMapper.selectIdpUser("alice").getLastVersion());
+    assertEquals(0, idpUserMetaMapper.updateIdpUserPassword("alice", 
"hash-a-2"));
   }
 
   @ParameterizedTest
   @MethodSource("storageProvider")
   void testSoftDeleteIdpUser(String type) throws IOException {
     init(type);
-    idpUserMetaMapper.insertIdpUser(
+    IdpUserPO oldUserPO =
         IdpUserPO.builder()
             .withUserId(1L)
-            .withUserName("alice")
+            .withUsername("alice")
             .withPasswordHash("hash-a")
             .withCurrentVersion(1L)
             .withLastVersion(0L)
             .withDeletedAt(0L)
-            .build());
+            .build();
+    idpUserMetaMapper.insertIdpUser(oldUserPO);
 
-    assertEquals(1, idpUserMetaMapper.softDeleteIdpUser(1L));
+    assertEquals(1, idpUserMetaMapper.softDeleteIdpUser("alice"));
     assertNull(idpUserMetaMapper.selectIdpUser("alice"));
-    assertIterableEquals(List.of(), 
idpUserMetaMapper.selectIdpUsers(List.of("alice")));
-    assertEquals(0, idpUserMetaMapper.softDeleteIdpUser(1L));
-    assertEquals(0, idpUserMetaMapper.updateIdpUserPassword(1L, "hash-a-2"));
+    assertEquals(0, idpUserMetaMapper.softDeleteIdpUser("alice"));
+    assertEquals(0, idpUserMetaMapper.updateIdpUserPassword("alice", 
"hash-a-2"));
     assertEquals(1, 
idpUserMetaMapper.deleteIdpUserMetasByLegacyTimeline(Long.MAX_VALUE, 10));
     assertEquals(0, 
idpUserMetaMapper.deleteIdpUserMetasByLegacyTimeline(Long.MAX_VALUE, 10));
   }
@@ -194,7 +160,7 @@ class TestIdpUserMetaStorage extends 
AbstractIdpMetaStorageTest {
     idpUserMetaMapper.insertIdpUser(
         IdpUserPO.builder()
             .withUserId(1L)
-            .withUserName("legacy-user")
+            .withUsername("legacy-user")
             .withPasswordHash("hash")
             .withCurrentVersion(1L)
             .withLastVersion(0L)
@@ -203,7 +169,7 @@ class TestIdpUserMetaStorage extends 
AbstractIdpMetaStorageTest {
     idpUserMetaMapper.insertIdpUser(
         IdpUserPO.builder()
             .withUserId(2L)
-            .withUserName("new-user")
+            .withUsername("new-user")
             .withPasswordHash("hash")
             .withCurrentVersion(1L)
             .withLastVersion(0L)
@@ -212,7 +178,7 @@ class TestIdpUserMetaStorage extends 
AbstractIdpMetaStorageTest {
     idpUserMetaMapper.insertIdpUser(
         IdpUserPO.builder()
             .withUserId(3L)
-            .withUserName("active-user")
+            .withUsername("active-user")
             .withPasswordHash("hash")
             .withCurrentVersion(1L)
             .withLastVersion(0L)
@@ -223,7 +189,7 @@ class TestIdpUserMetaStorage extends 
AbstractIdpMetaStorageTest {
     assertEquals(0, idpUserMetaMapper.deleteIdpUserMetasByLegacyTimeline(20L, 
10));
     assertEquals(1, idpUserMetaMapper.deleteIdpUserMetasByLegacyTimeline(40L, 
10));
     assertEquals(0, 
idpUserMetaMapper.deleteIdpUserMetasByLegacyTimeline(Long.MAX_VALUE, 10));
-    assertEquals("active-user", 
idpUserMetaMapper.selectIdpUser("active-user").getUserName());
+    assertEquals("active-user", 
idpUserMetaMapper.selectIdpUser("active-user").getUsername());
     assertNull(idpUserMetaMapper.selectIdpUser("legacy-user"));
     assertNull(idpUserMetaMapper.selectIdpUser("new-user"));
   }
diff --git 
a/plugins/idp-basic/src/test/java/org/apache/gravitino/idp/storage/po/TestIdpUserPO.java
 
b/plugins/idp-basic/src/test/java/org/apache/gravitino/idp/storage/po/TestIdpUserPO.java
index 8bdfa0f16c..51b62170aa 100644
--- 
a/plugins/idp-basic/src/test/java/org/apache/gravitino/idp/storage/po/TestIdpUserPO.java
+++ 
b/plugins/idp-basic/src/test/java/org/apache/gravitino/idp/storage/po/TestIdpUserPO.java
@@ -28,7 +28,7 @@ public class TestIdpUserPO {
     IdpUserPO userPO =
         IdpUserPO.builder()
             .withUserId(1L)
-            .withUserName("alice")
+            .withUsername("alice")
             .withPasswordHash("hash")
             .withCurrentVersion(1L)
             .withLastVersion(1L)
@@ -36,7 +36,7 @@ public class TestIdpUserPO {
             .build();
 
     Assertions.assertEquals(1L, userPO.getUserId());
-    Assertions.assertEquals("alice", userPO.getUserName());
+    Assertions.assertEquals("alice", userPO.getUsername());
     Assertions.assertEquals("hash", userPO.getPasswordHash());
     Assertions.assertEquals(1L, userPO.getCurrentVersion());
     Assertions.assertEquals(1L, userPO.getLastVersion());
@@ -48,14 +48,14 @@ public class TestIdpUserPO {
     IdpUserPO userPO =
         IdpUserPO.builder()
             .withUserId(1L)
-            .withUserName("alice")
+            .withUsername("alice")
             .withCurrentVersion(1L)
             .withLastVersion(1L)
             .withDeletedAt(0L)
             .build();
 
     Assertions.assertEquals(1L, userPO.getUserId());
-    Assertions.assertEquals("alice", userPO.getUserName());
+    Assertions.assertEquals("alice", userPO.getUsername());
     Assertions.assertNull(userPO.getPasswordHash());
   }
 
@@ -64,7 +64,7 @@ public class TestIdpUserPO {
     IdpUserPO userPO1 =
         IdpUserPO.builder()
             .withUserId(1L)
-            .withUserName("alice")
+            .withUsername("alice")
             .withPasswordHash("hash")
             .withCurrentVersion(1L)
             .withLastVersion(1L)
@@ -74,7 +74,7 @@ public class TestIdpUserPO {
     IdpUserPO userPO2 =
         IdpUserPO.builder()
             .withUserId(1L)
-            .withUserName("alice")
+            .withUsername("alice")
             .withPasswordHash("hash")
             .withCurrentVersion(1L)
             .withLastVersion(1L)
@@ -90,16 +90,16 @@ public class TestIdpUserPO {
     var builder =
         IdpUserPO.builder()
             .withUserId(1L)
-            .withUserName("alice")
+            .withUsername("alice")
             .withPasswordHash("hash")
             .withCurrentVersion(1L)
             .withLastVersion(1L)
             .withDeletedAt(0L);
 
     IdpUserPO firstUser = builder.build();
-    IdpUserPO secondUser = builder.withUserName("bob").build();
+    IdpUserPO secondUser = builder.withUsername("bob").build();
 
-    Assertions.assertEquals("alice", firstUser.getUserName());
-    Assertions.assertEquals("bob", secondUser.getUserName());
+    Assertions.assertEquals("alice", firstUser.getUsername());
+    Assertions.assertEquals("bob", secondUser.getUsername());
   }
 }
diff --git 
a/plugins/idp-basic/src/test/java/org/apache/gravitino/idp/storage/service/AbstractIdpMetaServiceTest.java
 
b/plugins/idp-basic/src/test/java/org/apache/gravitino/idp/storage/service/AbstractIdpMetaServiceTest.java
new file mode 100644
index 0000000000..f640f3ec28
--- /dev/null
+++ 
b/plugins/idp-basic/src/test/java/org/apache/gravitino/idp/storage/service/AbstractIdpMetaServiceTest.java
@@ -0,0 +1,203 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF 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.apache.gravitino.idp.storage.service;
+
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+import java.io.IOException;
+import java.sql.Connection;
+import java.sql.ResultSet;
+import java.sql.SQLException;
+import java.sql.Statement;
+import java.time.Instant;
+import java.util.List;
+import org.apache.gravitino.idp.storage.mapper.AbstractIdpMetaStorageTest;
+import org.apache.gravitino.idp.storage.mapper.IdpGroupMetaMapper;
+import org.apache.gravitino.idp.storage.mapper.IdpUserGroupRelMapper;
+import org.apache.gravitino.idp.storage.mapper.IdpUserMetaMapper;
+import org.apache.gravitino.idp.storage.po.IdpGroupPO;
+import org.apache.gravitino.idp.storage.po.IdpUserGroupRelPO;
+import org.apache.gravitino.idp.storage.po.IdpUserPO;
+import org.apache.gravitino.storage.relational.session.SqlSessionFactoryHelper;
+import org.apache.ibatis.session.SqlSession;
+
+/** Base class for IdP meta service tests. */
+public abstract class AbstractIdpMetaServiceTest extends 
AbstractIdpMetaStorageTest {
+  protected static final long LEGACY_TIMELINE = Instant.now().toEpochMilli() + 
1000;
+
+  protected IdpUserMetaMapper idpUserMetaMapper;
+  protected IdpGroupMetaMapper idpGroupMetaMapper;
+  protected IdpUserGroupRelMapper idpUserGroupRelMapper;
+
+  @Override
+  protected void initializeMappers() {
+    idpUserMetaMapper = sharedSession.getMapper(IdpUserMetaMapper.class);
+    idpGroupMetaMapper = sharedSession.getMapper(IdpGroupMetaMapper.class);
+    idpUserGroupRelMapper = 
sharedSession.getMapper(IdpUserGroupRelMapper.class);
+  }
+
+  /** Reopens the shared session after service-layer commits or direct SQL 
updates. */
+  protected void refreshSession() {
+    closeSession();
+    sharedSession = 
SqlSessionFactoryHelper.getInstance().getSqlSessionFactory().openSession(true);
+    initializeMappers();
+  }
+
+  /**
+   * Runs a service call outside the shared session (services use their own 
sessions), then
+   * refreshes mapper state for assertions.
+   */
+  protected void runServiceCall(ServiceCall serviceCall) throws IOException {
+    closeSession();
+    serviceCall.run();
+    refreshSession();
+  }
+
+  @FunctionalInterface
+  protected interface ServiceCall {
+    void run() throws IOException;
+  }
+
+  /** Asserts the service call fails with a runtime exception (e.g. unique 
constraint violation). */
+  protected void assertThrowsRuntimeException(ServiceCall insertCall) throws 
IOException {
+    closeSession();
+    try {
+      assertThrows(RuntimeException.class, insertCall::run);
+    } finally {
+      refreshSession();
+    }
+  }
+
+  protected void insertGroups(long count) {
+    for (long index = 1L; index <= count; index++) {
+      idpGroupMetaMapper.insertIdpGroup(
+          IdpGroupPO.builder()
+              .withGroupId(index)
+              .withGroupName("group" + index)
+              .withCurrentVersion(1L)
+              .withLastVersion(0L)
+              .withDeletedAt(0L)
+              .build());
+    }
+  }
+
+  protected void insertUsers(long count) {
+    for (long index = 1L; index <= count; index++) {
+      idpUserMetaMapper.insertIdpUser(
+          IdpUserPO.builder()
+              .withUserId(index)
+              .withUsername("user" + index)
+              .withPasswordHash("hash-" + index)
+              .withCurrentVersion(1L)
+              .withLastVersion(0L)
+              .withDeletedAt(0L)
+              .build());
+    }
+  }
+
+  protected void insertDefaultUserGroupRelations() {
+    idpUserGroupRelMapper.batchInsertRelations(
+        List.of(
+            userGroupRel(100L, "user1", "group1"),
+            userGroupRel(101L, "user2", "group1"),
+            userGroupRel(102L, "user3", "group1"),
+            userGroupRel(103L, "user4", "group1"),
+            userGroupRel(104L, "user1", "group2"),
+            userGroupRel(105L, "user2", "group2"),
+            userGroupRel(106L, "user3", "group2"),
+            userGroupRel(107L, "user4", "group2")));
+  }
+
+  protected void insertGroupUserGroupRelations() {
+    idpUserGroupRelMapper.batchInsertRelations(
+        List.of(
+            userGroupRel(100L, "user1", "group1"),
+            userGroupRel(101L, "user2", "group1"),
+            userGroupRel(102L, "user1", "group2"),
+            userGroupRel(103L, "user2", "group2"),
+            userGroupRel(104L, "user3", "group3"),
+            userGroupRel(105L, "user4", "group3"),
+            userGroupRel(106L, "user3", "group4"),
+            userGroupRel(107L, "user4", "group4")));
+  }
+
+  protected IdpUserGroupRelPO userGroupRel(
+      long relationOrdinal, String username, String groupName) {
+    IdpUserPO user = idpUserMetaMapper.selectIdpUser(username);
+    IdpGroupPO group = idpGroupMetaMapper.selectIdpGroup(groupName);
+    return IdpUserGroupRelPO.builder()
+        .withId(relationOrdinal)
+        .withUserId(user.getUserId())
+        .withGroupId(group.getGroupId())
+        .withCurrentVersion(1L)
+        .withLastVersion(0L)
+        .withDeletedAt(0L)
+        .build();
+  }
+
+  protected void softDeleteAllUsersAndRelations() throws SQLException {
+    executeUpdate(
+        "UPDATE idp_user_meta SET deleted_at = 1 WHERE deleted_at = 0",
+        "UPDATE idp_user_group_rel SET deleted_at = 1 WHERE deleted_at = 0");
+  }
+
+  protected void softDeleteAllGroups() throws SQLException {
+    executeUpdate(
+        "UPDATE idp_group_meta SET deleted_at = 1 WHERE deleted_at = 0",
+        "UPDATE idp_user_group_rel SET deleted_at = 1 WHERE deleted_at = 0");
+  }
+
+  protected int countUsers() {
+    return countTableRows("idp_user_meta");
+  }
+
+  protected int countGroups() {
+    return countTableRows("idp_group_meta");
+  }
+
+  protected int countUserGroupRels() {
+    return countTableRows("idp_user_group_rel");
+  }
+
+  private void executeUpdate(String... sqlStatements) throws SQLException {
+    try (SqlSession sqlSession =
+            
SqlSessionFactoryHelper.getInstance().getSqlSessionFactory().openSession(true);
+        Connection connection = sqlSession.getConnection();
+        Statement statement = connection.createStatement()) {
+      for (String sql : sqlStatements) {
+        statement.execute(sql);
+      }
+    }
+  }
+
+  private int countTableRows(String tableName) {
+    try (SqlSession sqlSession =
+            
SqlSessionFactoryHelper.getInstance().getSqlSessionFactory().openSession(true);
+        Connection connection = sqlSession.getConnection();
+        Statement statement = connection.createStatement();
+        ResultSet rs = statement.executeQuery("SELECT count(*) FROM " + 
tableName)) {
+      if (rs.next()) {
+        return rs.getInt(1);
+      }
+      throw new IllegalStateException("Count query returned no rows for table: 
" + tableName);
+    } catch (SQLException e) {
+      throw new RuntimeException("SQL execution failed", e);
+    }
+  }
+}
diff --git 
a/plugins/idp-basic/src/test/java/org/apache/gravitino/idp/storage/service/TestIdpGroupMetaService.java
 
b/plugins/idp-basic/src/test/java/org/apache/gravitino/idp/storage/service/TestIdpGroupMetaService.java
new file mode 100644
index 0000000000..4b7f8e1f5b
--- /dev/null
+++ 
b/plugins/idp-basic/src/test/java/org/apache/gravitino/idp/storage/service/TestIdpGroupMetaService.java
@@ -0,0 +1,190 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF 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.apache.gravitino.idp.storage.service;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertIterableEquals;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.io.IOException;
+import java.util.List;
+import org.apache.gravitino.idp.exception.NotFoundException;
+import org.apache.gravitino.idp.storage.po.IdpGroupPO;
+import org.junit.jupiter.api.Tag;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.MethodSource;
+
+@Tag("gravitino-docker-test")
+class TestIdpGroupMetaService extends AbstractIdpMetaServiceTest {
+
+  @ParameterizedTest
+  @MethodSource("storageProvider")
+  void testGetIdpGroupByName(String type) throws IOException {
+    init(type);
+    insertGroups(1);
+    IdpGroupMetaService groupMetaService = IdpGroupMetaService.getInstance();
+
+    assertThrows(NotFoundException.class, () -> 
groupMetaService.getIdpGroupByName("missing"));
+    assertEquals("group1", 
groupMetaService.getIdpGroupByName("group1").getGroupName());
+  }
+
+  @ParameterizedTest
+  @MethodSource("storageProvider")
+  void testInsertIdpGroup(String type) throws IOException {
+    init(type);
+    insertUsers(4);
+    IdpGroupMetaService groupMetaService = IdpGroupMetaService.getInstance();
+
+    IdpGroupPO group1 =
+        IdpGroupPO.builder()
+            .withGroupId(1L)
+            .withGroupName("group1")
+            .withCurrentVersion(1L)
+            .withLastVersion(0L)
+            .withDeletedAt(0L)
+            .build();
+
+    assertThrows(NotFoundException.class, () -> 
groupMetaService.getIdpGroupByName("group1"));
+    runServiceCall(() -> groupMetaService.insertIdpGroup(group1));
+    runServiceCall(() -> groupMetaService.addUsersToGroup("group1", 
List.of("user1")));
+    assertEquals("group1", 
groupMetaService.getIdpGroupByName("group1").getGroupName());
+    assertIterableEquals(List.of("user1"), 
groupMetaService.listUsernamesByGroupName("group1"));
+
+    IdpGroupPO duplicateGroup =
+        IdpGroupPO.builder()
+            .withGroupId(2L)
+            .withGroupName("group1")
+            .withCurrentVersion(1L)
+            .withLastVersion(0L)
+            .withDeletedAt(0L)
+            .build();
+    assertThrowsRuntimeException(() -> 
groupMetaService.insertIdpGroup(duplicateGroup));
+  }
+
+  @ParameterizedTest
+  @MethodSource("storageProvider")
+  void testDeleteIdpGroupCascadesMemberships(String type) throws IOException {
+    init(type);
+    insertUsers(4);
+    insertGroups(4);
+    insertGroupUserGroupRelations();
+    IdpGroupMetaService groupMetaService = IdpGroupMetaService.getInstance();
+
+    runServiceCall(() -> 
assertTrue(groupMetaService.deleteIdpGroup("group1")));
+    assertNull(idpGroupMetaMapper.selectIdpGroup("group1"));
+    assertEquals(4, countGroups());
+    assertEquals(8, countUserGroupRels());
+    assertIterableEquals(List.of(), 
groupMetaService.listUsernamesByGroupName("group1"));
+    assertEquals("group2", 
idpGroupMetaMapper.selectIdpGroup("group2").getGroupName());
+  }
+
+  @ParameterizedTest
+  @MethodSource("storageProvider")
+  void testAddAndRemoveUsersFromGroup(String type) throws IOException {
+    init(type);
+    insertUsers(4);
+    IdpGroupMetaService groupMetaService = IdpGroupMetaService.getInstance();
+
+    IdpGroupPO group1 =
+        IdpGroupPO.builder()
+            .withGroupId(1L)
+            .withGroupName("engineering")
+            .withCurrentVersion(1L)
+            .withLastVersion(0L)
+            .withDeletedAt(0L)
+            .build();
+    runServiceCall(() -> groupMetaService.insertIdpGroup(group1));
+
+    runServiceCall(
+        () -> groupMetaService.addUsersToGroup("engineering", List.of("user1", 
"user2")));
+    assertIterableEquals(
+        List.of("user1", "user2"), 
groupMetaService.listUsernamesByGroupName("engineering"));
+
+    runServiceCall(
+        () ->
+            assertEquals(
+                1, groupMetaService.removeUsersFromGroup("engineering", 
List.of("user1"))));
+    assertIterableEquals(
+        List.of("user2"), 
groupMetaService.listUsernamesByGroupName("engineering"));
+  }
+
+  @ParameterizedTest
+  @MethodSource("storageProvider")
+  void testAddUsersToGroupThrowsWhenUserMissing(String type) throws 
IOException {
+    init(type);
+    insertUsers(1);
+    IdpGroupMetaService groupMetaService = IdpGroupMetaService.getInstance();
+    runServiceCall(
+        () ->
+            groupMetaService.insertIdpGroup(
+                IdpGroupPO.builder()
+                    .withGroupId(1L)
+                    .withGroupName("engineering")
+                    .withCurrentVersion(1L)
+                    .withLastVersion(0L)
+                    .withDeletedAt(0L)
+                    .build()));
+
+    assertThrows(
+        NotFoundException.class,
+        () ->
+            runServiceCall(
+                () -> groupMetaService.addUsersToGroup("engineering", 
List.of("missing-user"))));
+  }
+
+  @ParameterizedTest
+  @MethodSource("storageProvider")
+  void testDeleteGroupMetasByLegacyTimeline(String type) throws Exception {
+    init(type);
+    insertUsers(4);
+    insertGroups(4);
+    insertGroupUserGroupRelations();
+    IdpGroupMetaService groupMetaService = IdpGroupMetaService.getInstance();
+
+    assertEquals(0, 
groupMetaService.deleteGroupMetasByLegacyTimeline(LEGACY_TIMELINE, 4));
+    assertEquals("group1", 
idpGroupMetaMapper.selectIdpGroup("group1").getGroupName());
+    assertEquals(4, countGroups());
+    assertEquals(8, countUserGroupRels());
+
+    softDeleteAllGroups();
+    refreshSession();
+    assertNull(idpGroupMetaMapper.selectIdpGroup("group1"));
+    assertEquals(4, countGroups());
+    assertEquals(8, countUserGroupRels());
+
+    assertEquals(6, 
groupMetaService.deleteGroupMetasByLegacyTimeline(LEGACY_TIMELINE, 3));
+    refreshSession();
+    assertEquals(1, countGroups());
+    assertEquals(5, countUserGroupRels());
+
+    assertEquals(4, 
groupMetaService.deleteGroupMetasByLegacyTimeline(LEGACY_TIMELINE, 3));
+    refreshSession();
+    assertEquals(0, countGroups());
+    assertEquals(2, countUserGroupRels());
+
+    assertEquals(2, 
groupMetaService.deleteGroupMetasByLegacyTimeline(LEGACY_TIMELINE, 3));
+    refreshSession();
+    assertEquals(0, countGroups());
+    assertEquals(0, countUserGroupRels());
+
+    assertEquals(0, 
groupMetaService.deleteGroupMetasByLegacyTimeline(LEGACY_TIMELINE, 3));
+  }
+}
diff --git 
a/plugins/idp-basic/src/test/java/org/apache/gravitino/idp/storage/service/TestIdpUserMetaService.java
 
b/plugins/idp-basic/src/test/java/org/apache/gravitino/idp/storage/service/TestIdpUserMetaService.java
new file mode 100644
index 0000000000..dbc71999d5
--- /dev/null
+++ 
b/plugins/idp-basic/src/test/java/org/apache/gravitino/idp/storage/service/TestIdpUserMetaService.java
@@ -0,0 +1,156 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF 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.apache.gravitino.idp.storage.service;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertIterableEquals;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.io.IOException;
+import java.util.List;
+import org.apache.gravitino.idp.exception.NotFoundException;
+import org.apache.gravitino.idp.storage.po.IdpUserPO;
+import org.junit.jupiter.api.Tag;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.MethodSource;
+
+@Tag("gravitino-docker-test")
+class TestIdpUserMetaService extends AbstractIdpMetaServiceTest {
+
+  @ParameterizedTest
+  @MethodSource("storageProvider")
+  void testGetIdpUserByUsername(String type) throws IOException {
+    init(type);
+    insertUsers(1);
+    IdpUserMetaService userMetaService = IdpUserMetaService.getInstance();
+
+    assertThrows(NotFoundException.class, () -> 
userMetaService.getIdpUserByUsername("missing"));
+    assertEquals("user1", 
userMetaService.getIdpUserByUsername("user1").getUsername());
+  }
+
+  @ParameterizedTest
+  @MethodSource("storageProvider")
+  void testInsertIdpUser(String type) throws IOException {
+    init(type);
+    insertGroups(2);
+    IdpUserMetaService userMetaService = IdpUserMetaService.getInstance();
+    IdpGroupMetaService groupMetaService = IdpGroupMetaService.getInstance();
+
+    IdpUserPO user1 =
+        IdpUserPO.builder()
+            .withUserId(1L)
+            .withUsername("user1")
+            .withPasswordHash("hash-1")
+            .withCurrentVersion(1L)
+            .withLastVersion(0L)
+            .withDeletedAt(0L)
+            .build();
+
+    assertThrows(NotFoundException.class, () -> 
userMetaService.getIdpUserByUsername("user1"));
+    runServiceCall(() -> userMetaService.insertIdpUser(user1));
+    runServiceCall(() -> groupMetaService.addUsersToGroup("group1", 
List.of("user1")));
+    assertEquals("user1", 
userMetaService.getIdpUserByUsername("user1").getUsername());
+    assertIterableEquals(List.of("group1"), 
userMetaService.listGroupNamesByUsername("user1"));
+
+    IdpUserPO duplicateUser =
+        IdpUserPO.builder()
+            .withUserId(2L)
+            .withUsername("user1")
+            .withPasswordHash("hash-2")
+            .withCurrentVersion(1L)
+            .withLastVersion(0L)
+            .withDeletedAt(0L)
+            .build();
+    assertThrowsRuntimeException(() -> 
userMetaService.insertIdpUser(duplicateUser));
+  }
+
+  @ParameterizedTest
+  @MethodSource("storageProvider")
+  void testUpdateIdpUserPassword(String type) throws IOException {
+    init(type);
+    insertUsers(1);
+    IdpUserMetaService userMetaService = IdpUserMetaService.getInstance();
+
+    runServiceCall(() -> 
assertFalse(userMetaService.updateIdpUserPassword("missing", "hash-2")));
+
+    runServiceCall(() -> 
assertTrue(userMetaService.updateIdpUserPassword("user1", "hash-2")));
+    assertEquals("hash-2", 
userMetaService.getIdpUserByUsername("user1").getPasswordHash());
+    assertEquals(1L, 
userMetaService.getIdpUserByUsername("user1").getCurrentVersion());
+
+    runServiceCall(() -> 
assertFalse(userMetaService.updateIdpUserPassword("user1", "hash-2")));
+  }
+
+  @ParameterizedTest
+  @MethodSource("storageProvider")
+  void testDeleteIdpUser(String type) throws IOException {
+    init(type);
+    insertGroups(2);
+    insertUsers(4);
+    insertDefaultUserGroupRelations();
+    IdpUserMetaService userMetaService = IdpUserMetaService.getInstance();
+
+    runServiceCall(() -> assertTrue(userMetaService.deleteIdpUser("user1")));
+    assertNull(idpUserMetaMapper.selectIdpUser("user1"));
+    assertEquals(4, countUsers());
+    assertEquals(8, countUserGroupRels());
+    assertIterableEquals(List.of(), 
userMetaService.listGroupNamesByUsername("user1"));
+    assertEquals("user2", 
idpUserMetaMapper.selectIdpUser("user2").getUsername());
+  }
+
+  @ParameterizedTest
+  @MethodSource("storageProvider")
+  void testDeleteUserMetasByLegacyTimeline(String type) throws Exception {
+    init(type);
+    insertGroups(2);
+    insertUsers(4);
+    insertDefaultUserGroupRelations();
+    IdpUserMetaService userMetaService = IdpUserMetaService.getInstance();
+
+    assertEquals(0, 
userMetaService.deleteUserMetasByLegacyTimeline(LEGACY_TIMELINE, 4));
+    assertEquals("user1", 
idpUserMetaMapper.selectIdpUser("user1").getUsername());
+    assertEquals(4, countUsers());
+    assertEquals(8, countUserGroupRels());
+
+    softDeleteAllUsersAndRelations();
+    refreshSession();
+    assertNull(idpUserMetaMapper.selectIdpUser("user1"));
+    assertEquals(4, countUsers());
+    assertEquals(8, countUserGroupRels());
+
+    assertEquals(6, 
userMetaService.deleteUserMetasByLegacyTimeline(LEGACY_TIMELINE, 3));
+    refreshSession();
+    assertEquals(1, countUsers());
+    assertEquals(5, countUserGroupRels());
+
+    assertEquals(4, 
userMetaService.deleteUserMetasByLegacyTimeline(LEGACY_TIMELINE, 3));
+    refreshSession();
+    assertEquals(0, countUsers());
+    assertEquals(2, countUserGroupRels());
+
+    assertEquals(2, 
userMetaService.deleteUserMetasByLegacyTimeline(LEGACY_TIMELINE, 3));
+    refreshSession();
+    assertEquals(0, countUsers());
+    assertEquals(0, countUserGroupRels());
+
+    assertEquals(0, 
userMetaService.deleteUserMetasByLegacyTimeline(LEGACY_TIMELINE, 3));
+  }
+}

Reply via email to