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

mchades 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 aff2c9259c [#10966] feat(idp-basic): Add built-in IdP logical layer 
for user group management (#11209)
aff2c9259c is described below

commit aff2c9259c0c386496f4ca22e61b5dd113f4ac73
Author: MaSai <[email protected]>
AuthorDate: Tue May 26 11:34:08 2026 +0800

    [#10966] feat(idp-basic): Add built-in IdP logical layer for user group 
management (#11209)
    
    ### What changes were proposed in this pull request?
    
    This PR adds the built-in IdP logical layer in the `idp-basic` plugin
    for global user and group management:
    
    - Introduce `IdpUser` / `IdpGroup` models and corresponding metadata
    entities.
    - Add `IdpStore` / `IdpEntityStore` / `IdpJDBCBackend` for relational
    persistence.
    - Add `IdpUserGroupManager` as the business layer for user CRUD, group
    CRUD, membership changes, and password updates.
    
    ### Why are the changes needed?
    
    This is the logical-layer subtask for built-in IdP support (#10959). It
    provides the storage and management foundation required before exposing
    REST APIs and DTOs in follow-up work.
    
    Fix: #10966
    
    ### Does this PR introduce _any_ user-facing change?
    
    No. This change is internal to the `idp-basic` plugin and does not
    expose new public APIs yet.
    
    ### How was this patch tested?
    
    - `./gradlew :plugins:idp-basic:test -PskipITs -PskipDockerTests=false`
    
    ---------
    
    Co-authored-by: Cursor <[email protected]>
---
 .../apache/gravitino/idp/IdpUserGroupManager.java  | 191 +++++++++++++++++++
 .../idp/exception/AlreadyExistsException.java      |  49 +++++
 .../org/apache/gravitino/idp/model/IdpGroup.java   |  72 +++++++
 .../org/apache/gravitino/idp/model/IdpUser.java    |  72 +++++++
 .../gravitino/idp/storage/IdpStorageBootstrap.java |  57 ------
 .../idp/storage/gc/IdpLegacyGarbageCollector.java  | 137 --------------
 .../storage/relational/IdpGarbageCollector.java    | 121 ++++++++++++
 .../storage/relational/IdpRelationalStorage.java   |  81 ++++++++
 .../converters/IdpSQLExceptionConverter.java       |  94 ++++++++++
 .../IdpSQLExceptionConverterFactory.java           |  84 +++++++++
 .../relational/utils/IdpExceptionUtils.java        |  44 +++++
 .../idp/storage/service/IdpGroupMetaService.java   | 141 ++++++++++----
 .../idp/storage/service/IdpUserMetaService.java    |  48 +++--
 .../gravitino/idp/TestIdpUserGroupManager.java     | 206 +++++++++++++++++++++
 .../storage/mapper/AbstractIdpMetaStorageTest.java |  25 +++
 .../TestIdpGarbageCollector.java}                  |  23 +--
 .../storage/service/TestIdpGroupMetaService.java   |  28 ++-
 .../storage/service/TestIdpUserMetaService.java    |   6 +-
 18 files changed, 1202 insertions(+), 277 deletions(-)

diff --git 
a/plugins/idp-basic/src/main/java/org/apache/gravitino/idp/IdpUserGroupManager.java
 
b/plugins/idp-basic/src/main/java/org/apache/gravitino/idp/IdpUserGroupManager.java
new file mode 100644
index 0000000000..38065951a4
--- /dev/null
+++ 
b/plugins/idp-basic/src/main/java/org/apache/gravitino/idp/IdpUserGroupManager.java
@@ -0,0 +1,191 @@
+/*
+ * 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;
+
+import com.google.common.base.Preconditions;
+import java.io.Closeable;
+import java.io.IOException;
+import java.util.Collections;
+import java.util.List;
+import javax.annotation.Nullable;
+import org.apache.gravitino.Config;
+import org.apache.gravitino.idp.basic.password.PasswordHasher;
+import org.apache.gravitino.idp.basic.password.PasswordHasherFactory;
+import org.apache.gravitino.idp.model.IdpGroup;
+import org.apache.gravitino.idp.model.IdpUser;
+import org.apache.gravitino.idp.storage.po.IdpGroupPO;
+import org.apache.gravitino.idp.storage.po.IdpUserPO;
+import org.apache.gravitino.idp.storage.relational.IdpGarbageCollector;
+import org.apache.gravitino.idp.storage.relational.IdpRelationalStorage;
+import org.apache.gravitino.idp.storage.service.IdpGroupMetaService;
+import org.apache.gravitino.idp.storage.service.IdpUserMetaService;
+import org.apache.gravitino.storage.IdGenerator;
+import org.apache.gravitino.storage.relational.utils.POConverters;
+
+/**
+ * Manager for built-in IdP users and groups. It mirrors {@link
+ * org.apache.gravitino.authorization.UserGroupManager} but operates on global 
IdP metadata.
+ */
+public class IdpUserGroupManager implements Closeable {
+
+  private static final IdpUserMetaService USER_SERVICE = 
IdpUserMetaService.getInstance();
+  private static final IdpGroupMetaService GROUP_SERVICE = 
IdpGroupMetaService.getInstance();
+
+  private final IdpRelationalStorage relationalStorage;
+  private final IdGenerator idGenerator;
+  private final PasswordHasher passwordHasher;
+  private final IdpGarbageCollector garbageCollector;
+
+  /**
+   * Creates a built-in IdP user and group manager.
+   *
+   * @param config The server configuration.
+   * @param idGenerator The id generator.
+   */
+  public IdpUserGroupManager(Config config, IdGenerator idGenerator) {
+    this.relationalStorage = new IdpRelationalStorage(config);
+    this.idGenerator = idGenerator;
+    this.passwordHasher = PasswordHasherFactory.create();
+    this.garbageCollector = new IdpGarbageCollector(config);
+    garbageCollector.start();
+  }
+
+  /**
+   * Adds a built-in IdP user.
+   *
+   * @param username The username.
+   * @param password The plaintext password.
+   * @return The created built-in IdP user.
+   */
+  public IdpUser addUser(String username, String password) throws IOException {
+    USER_SERVICE.insertIdpUser(newUserPO(username, 
passwordHasher.hash(password)));
+    return new IdpUser(username, Collections.emptyList());
+  }
+
+  /**
+   * Removes a built-in IdP user.
+   *
+   * @param username The username.
+   * @return True if the user was removed, false if it did not exist.
+   */
+  public boolean removeUser(String username) {
+    return USER_SERVICE.deleteIdpUser(username);
+  }
+
+  /**
+   * Gets a built-in IdP user.
+   *
+   * @param username The username.
+   * @return The built-in IdP user.
+   */
+  public IdpUser getUser(String username) {
+    IdpUserPO userPO = USER_SERVICE.getIdpUserByUsername(username);
+    return new IdpUser(userPO.getUsername(), 
USER_SERVICE.listGroupNamesByUsername(username));
+  }
+
+  /**
+   * Changes the password for a built-in IdP user.
+   *
+   * @param username The username.
+   * @param password The new plaintext password.
+   * @return True if the password was updated, false if the user did not exist.
+   */
+  public boolean changePassword(String username, String password) {
+    return USER_SERVICE.updateIdpUserPassword(username, 
passwordHasher.hash(password));
+  }
+
+  /**
+   * Adds a built-in IdP group.
+   *
+   * @param groupName The group name.
+   * @return The created built-in IdP group.
+   */
+  public IdpGroup addGroup(String groupName) throws IOException {
+    GROUP_SERVICE.insertIdpGroup(newGroupPO(groupName));
+    return new IdpGroup(groupName, Collections.emptyList());
+  }
+
+  /**
+   * Removes a built-in IdP group.
+   *
+   * @param groupName The group name.
+   * @param force Whether to force delete a non-empty group.
+   * @return True if the group was removed, false if it did not exist.
+   */
+  public boolean removeGroup(String groupName, boolean force) {
+    return GROUP_SERVICE.deleteIdpGroup(groupName, force);
+  }
+
+  /**
+   * Gets a built-in IdP group.
+   *
+   * @param groupName The group name.
+   * @return The built-in IdP group.
+   */
+  public IdpGroup getGroup(String groupName) {
+    IdpGroupPO groupPO = GROUP_SERVICE.getIdpGroupByName(groupName);
+    return new IdpGroup(groupPO.getGroupName(), 
GROUP_SERVICE.listUsernamesByGroupName(groupName));
+  }
+
+  /**
+   * Changes built-in IdP group membership.
+   *
+   * @param groupName The group name.
+   * @param additions The usernames to add, or null if none.
+   * @param removals The usernames to remove, or null if none.
+   * @return The updated built-in IdP group.
+   */
+  public IdpGroup changeGroupMembership(
+      String groupName, @Nullable List<String> additions, @Nullable 
List<String> removals) {
+    List<String> additionsList = additions == null ? Collections.emptyList() : 
additions;
+    List<String> removalsList = removals == null ? Collections.emptyList() : 
removals;
+    Preconditions.checkArgument(
+        !additionsList.isEmpty() || !removalsList.isEmpty(),
+        "additions and removals cannot both be empty");
+    GROUP_SERVICE.changeGroupMembership(groupName, additionsList, 
removalsList);
+    return getGroup(groupName);
+  }
+
+  @Override
+  public void close() throws IOException {
+    garbageCollector.close();
+    relationalStorage.close();
+  }
+
+  private IdpUserPO newUserPO(String username, String passwordHash) {
+    return IdpUserPO.builder()
+        .withUserId(idGenerator.nextId())
+        .withUsername(username)
+        .withPasswordHash(passwordHash)
+        .withCurrentVersion(POConverters.INIT_VERSION)
+        .withLastVersion(POConverters.INIT_VERSION)
+        .withDeletedAt(POConverters.DEFAULT_DELETED_AT)
+        .build();
+  }
+
+  private IdpGroupPO newGroupPO(String groupName) {
+    return IdpGroupPO.builder()
+        .withGroupId(idGenerator.nextId())
+        .withGroupName(groupName)
+        .withCurrentVersion(POConverters.INIT_VERSION)
+        .withLastVersion(POConverters.INIT_VERSION)
+        .withDeletedAt(POConverters.DEFAULT_DELETED_AT)
+        .build();
+  }
+}
diff --git 
a/plugins/idp-basic/src/main/java/org/apache/gravitino/idp/exception/AlreadyExistsException.java
 
b/plugins/idp-basic/src/main/java/org/apache/gravitino/idp/exception/AlreadyExistsException.java
new file mode 100644
index 0000000000..24f0ae6dde
--- /dev/null
+++ 
b/plugins/idp-basic/src/main/java/org/apache/gravitino/idp/exception/AlreadyExistsException.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;
+
+/** Exception thrown when a built-in IdP entity already exists. */
+public class AlreadyExistsException extends RuntimeException {
+
+  /**
+   * Constructs a new exception with the specified detail message.
+   *
+   * @param message The detail message.
+   * @param args The arguments to the message.
+   */
+  @FormatMethod
+  public AlreadyExistsException(@FormatString String message, Object... args) {
+    super(String.format(message, args));
+  }
+
+  /**
+   * Constructs a new exception with the specified detail message and cause.
+   *
+   * @param cause The cause.
+   * @param message The detail message.
+   * @param args The arguments to the message.
+   */
+  @FormatMethod
+  public AlreadyExistsException(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/model/IdpGroup.java 
b/plugins/idp-basic/src/main/java/org/apache/gravitino/idp/model/IdpGroup.java
new file mode 100644
index 0000000000..50fddf00a1
--- /dev/null
+++ 
b/plugins/idp-basic/src/main/java/org/apache/gravitino/idp/model/IdpGroup.java
@@ -0,0 +1,72 @@
+/*
+ * 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.model;
+
+import java.util.List;
+import java.util.Objects;
+
+/** Built-in IdP group. */
+public class IdpGroup {
+
+  private final String name;
+  private final List<String> usernames;
+
+  /**
+   * Creates a built-in IdP group.
+   *
+   * @param name The group name.
+   * @param usernames The usernames in the group.
+   */
+  public IdpGroup(String name, List<String> usernames) {
+    this.name = name;
+    this.usernames = usernames;
+  }
+
+  /** Returns the group name. */
+  public String name() {
+    return name;
+  }
+
+  /** Returns the usernames in the group. */
+  public List<String> usernames() {
+    return usernames;
+  }
+
+  @Override
+  public boolean equals(Object other) {
+    if (this == other) {
+      return true;
+    }
+    if (!(other instanceof IdpGroup)) {
+      return false;
+    }
+    IdpGroup that = (IdpGroup) other;
+    return Objects.equals(name, that.name) && Objects.equals(usernames, 
that.usernames);
+  }
+
+  @Override
+  public int hashCode() {
+    return Objects.hash(name, usernames);
+  }
+
+  @Override
+  public String toString() {
+    return "IdpGroup{name='" + name + "', usernames=" + usernames + '}';
+  }
+}
diff --git 
a/plugins/idp-basic/src/main/java/org/apache/gravitino/idp/model/IdpUser.java 
b/plugins/idp-basic/src/main/java/org/apache/gravitino/idp/model/IdpUser.java
new file mode 100644
index 0000000000..aae49bd266
--- /dev/null
+++ 
b/plugins/idp-basic/src/main/java/org/apache/gravitino/idp/model/IdpUser.java
@@ -0,0 +1,72 @@
+/*
+ * 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.model;
+
+import java.util.List;
+import java.util.Objects;
+
+/** Built-in IdP user. */
+public class IdpUser {
+
+  private final String name;
+  private final List<String> groupNames;
+
+  /**
+   * Creates a built-in IdP user.
+   *
+   * @param name The username.
+   * @param groupNames The group names the user belongs to.
+   */
+  public IdpUser(String name, List<String> groupNames) {
+    this.name = name;
+    this.groupNames = groupNames;
+  }
+
+  /** Returns the username. */
+  public String name() {
+    return name;
+  }
+
+  /** Returns the group names the user belongs to. */
+  public List<String> groupNames() {
+    return groupNames;
+  }
+
+  @Override
+  public boolean equals(Object other) {
+    if (this == other) {
+      return true;
+    }
+    if (!(other instanceof IdpUser)) {
+      return false;
+    }
+    IdpUser that = (IdpUser) other;
+    return Objects.equals(name, that.name) && Objects.equals(groupNames, 
that.groupNames);
+  }
+
+  @Override
+  public int hashCode() {
+    return Objects.hash(name, groupNames);
+  }
+
+  @Override
+  public String toString() {
+    return "IdpUser{name='" + name + "', groupNames=" + groupNames + '}';
+  }
+}
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
deleted file mode 100644
index 9d2caedd78..0000000000
--- 
a/plugins/idp-basic/src/main/java/org/apache/gravitino/idp/storage/IdpStorageBootstrap.java
+++ /dev/null
@@ -1,57 +0,0 @@
-/*
- * 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
deleted file mode 100644
index 0898fe7b30..0000000000
--- 
a/plugins/idp-basic/src/main/java/org/apache/gravitino/idp/storage/gc/IdpLegacyGarbageCollector.java
+++ /dev/null
@@ -1,137 +0,0 @@
-/*
- * 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/relational/IdpGarbageCollector.java
 
b/plugins/idp-basic/src/main/java/org/apache/gravitino/idp/storage/relational/IdpGarbageCollector.java
new file mode 100644
index 0000000000..861cea6ac0
--- /dev/null
+++ 
b/plugins/idp-basic/src/main/java/org/apache/gravitino/idp/storage/relational/IdpGarbageCollector.java
@@ -0,0 +1,121 @@
+/*
+ * 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.relational;
+
+import static 
org.apache.gravitino.Configs.GARBAGE_COLLECTOR_SINGLE_DELETION_LIMIT;
+import static org.apache.gravitino.Configs.STORE_DELETE_AFTER_TIME;
+
+import java.io.Closeable;
+import java.io.IOException;
+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;
+
+/** Garbage collector for built-in IdP metadata. */
+public final class IdpGarbageCollector implements Closeable {
+
+  private static final Logger LOG = 
LoggerFactory.getLogger(IdpGarbageCollector.class);
+
+  private static final IdpUserMetaService USER_SERVICE = 
IdpUserMetaService.getInstance();
+  private static final IdpGroupMetaService GROUP_SERVICE = 
IdpGroupMetaService.getInstance();
+
+  private final long storeDeleteAfterTimeMillis;
+
+  private final ScheduledExecutorService garbageCollectorPool =
+      new ScheduledThreadPoolExecutor(
+          2,
+          r -> {
+            Thread t = new Thread(r, "Idp-Garbage-Collector");
+            t.setDaemon(true);
+            return t;
+          },
+          new ThreadPoolExecutor.AbortPolicy());
+
+  /**
+   * Creates a garbage collector for built-in IdP metadata.
+   *
+   * @param config The server configuration.
+   */
+  public IdpGarbageCollector(Config config) {
+    storeDeleteAfterTimeMillis = config.get(STORE_DELETE_AFTER_TIME);
+  }
+
+  /** Starts the scheduled garbage collector. */
+  public void start() {
+    long dateTimelineMinute = storeDeleteAfterTimeMillis / 1000 / 60;
+    long frequency = Math.max(dateTimelineMinute / 10, 10);
+    garbageCollectorPool.scheduleAtFixedRate(this::collectAndClean, 5, 
frequency, TimeUnit.MINUTES);
+  }
+
+  void collectAndClean() {
+    long threadId = Thread.currentThread().getId();
+    LOG.debug("Thread {} start to collect built-in IdP garbage...", threadId);
+
+    try {
+      long legacyTimeline = System.currentTimeMillis() - 
storeDeleteAfterTimeMillis;
+      purgeLegacyData(
+          () -> USER_SERVICE.deleteUserMetasByLegacyTimeline(legacyTimeline, 
deletionLimit()));
+      purgeLegacyData(
+          () -> GROUP_SERVICE.deleteGroupMetasByLegacyTimeline(legacyTimeline, 
deletionLimit()));
+    } catch (Exception e) {
+      LOG.error("Thread {} failed to collect and clean built-in IdP garbage.", 
threadId, e);
+    } finally {
+      LOG.debug("Thread {} finish to collect built-in IdP garbage.", threadId);
+    }
+  }
+
+  @Override
+  public void close() throws IOException {
+    garbageCollectorPool.shutdown();
+    try {
+      if (!garbageCollectorPool.awaitTermination(5, TimeUnit.SECONDS)) {
+        garbageCollectorPool.shutdownNow();
+      }
+    } catch (InterruptedException ex) {
+      garbageCollectorPool.shutdownNow();
+      Thread.currentThread().interrupt();
+    }
+  }
+
+  private static int deletionLimit() {
+    return GARBAGE_COLLECTOR_SINGLE_DELETION_LIMIT;
+  }
+
+  private static void purgeLegacyData(LegacyDataDeleter deleter) {
+    long deletedCount = Long.MAX_VALUE;
+    try {
+      while (deletedCount > 0) {
+        deletedCount = deleter.delete();
+      }
+    } catch (RuntimeException e) {
+      LOG.error("Failed to physically delete built-in IdP legacy data", e);
+    }
+  }
+
+  @FunctionalInterface
+  private interface LegacyDataDeleter {
+    int delete();
+  }
+}
diff --git 
a/plugins/idp-basic/src/main/java/org/apache/gravitino/idp/storage/relational/IdpRelationalStorage.java
 
b/plugins/idp-basic/src/main/java/org/apache/gravitino/idp/storage/relational/IdpRelationalStorage.java
new file mode 100644
index 0000000000..57d87a6ec7
--- /dev/null
+++ 
b/plugins/idp-basic/src/main/java/org/apache/gravitino/idp/storage/relational/IdpRelationalStorage.java
@@ -0,0 +1,81 @@
+/*
+ * 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.relational;
+
+import com.google.common.collect.ImmutableMap;
+import java.io.Closeable;
+import java.io.IOException;
+import java.util.Map;
+import org.apache.gravitino.Config;
+import org.apache.gravitino.Configs;
+import 
org.apache.gravitino.idp.storage.relational.converters.IdpSQLExceptionConverterFactory;
+import org.apache.gravitino.storage.relational.JDBCBackend.JDBCBackendType;
+import org.apache.gravitino.storage.relational.JDBCDatabase;
+import org.apache.gravitino.storage.relational.database.H2Database;
+import org.apache.gravitino.storage.relational.session.SqlSessionFactoryHelper;
+
+/** JDBC bootstrap for built-in IdP relational storage. */
+public final class IdpRelationalStorage implements Closeable {
+
+  private static final Map<JDBCBackendType, String> EMBEDDED_JDBC_DATABASE_MAP 
=
+      ImmutableMap.of(JDBCBackendType.H2, H2Database.class.getCanonicalName());
+
+  private JDBCDatabase jdbcDatabase;
+
+  /**
+   * Initializes the JDBC session factory and optional embedded database.
+   *
+   * @param config The server configuration.
+   */
+  public IdpRelationalStorage(Config config) {
+    jdbcDatabase = startEmbeddedDatabaseIfNecessary(config);
+    SqlSessionFactoryHelper.getInstance().init(config);
+    IdpSQLExceptionConverterFactory.initConverter(config);
+  }
+
+  @Override
+  public void close() throws IOException {
+    SqlSessionFactoryHelper.getInstance().close();
+    IdpSQLExceptionConverterFactory.close();
+    if (jdbcDatabase != null) {
+      jdbcDatabase.close();
+      jdbcDatabase = null;
+    }
+  }
+
+  private JDBCDatabase startEmbeddedDatabaseIfNecessary(Config config) {
+    String jdbcUrl = config.get(Configs.ENTITY_RELATIONAL_JDBC_BACKEND_URL);
+    JDBCBackendType jdbcBackendType = JDBCBackendType.fromURI(jdbcUrl);
+    if (jdbcBackendType != JDBCBackendType.H2) {
+      return null;
+    }
+
+    try {
+      JDBCDatabase database =
+          (JDBCDatabase)
+              Class.forName(EMBEDDED_JDBC_DATABASE_MAP.get(jdbcBackendType))
+                  .getDeclaredConstructor()
+                  .newInstance();
+      database.initialize(config);
+      return database;
+    } catch (Exception e) {
+      throw new RuntimeException("Failed to create and initialize IdP JDBC 
backend.", e);
+    }
+  }
+}
diff --git 
a/plugins/idp-basic/src/main/java/org/apache/gravitino/idp/storage/relational/converters/IdpSQLExceptionConverter.java
 
b/plugins/idp-basic/src/main/java/org/apache/gravitino/idp/storage/relational/converters/IdpSQLExceptionConverter.java
new file mode 100644
index 0000000000..89721f0eab
--- /dev/null
+++ 
b/plugins/idp-basic/src/main/java/org/apache/gravitino/idp/storage/relational/converters/IdpSQLExceptionConverter.java
@@ -0,0 +1,94 @@
+/*
+ * 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.relational.converters;
+
+import java.io.IOException;
+import java.sql.SQLException;
+import org.apache.gravitino.idp.exception.AlreadyExistsException;
+
+/** Converts JDBC SQL exceptions to built-in IdP exceptions. */
+public final class IdpSQLExceptionConverter {
+
+  /** MySQL duplicate entry error code. */
+  private static final int MYSQL_DUPLICATE_ENTRY_ERROR_CODE = 1062;
+
+  /** H2 duplicate entry error code. */
+  private static final int H2_DUPLICATE_ENTRY_ERROR_CODE = 23505;
+
+  /** PostgreSQL duplicate entry SQL state. */
+  private static final String POSTGRESQL_DUPLICATE_ENTRY_SQL_STATE = "23505";
+
+  /** Supported JDBC backend types for IdP relational storage. */
+  public enum JdbcType {
+    MYSQL,
+    H2,
+    POSTGRESQL
+  }
+
+  private final JdbcType jdbcType;
+
+  /**
+   * Creates a converter for the given JDBC backend type.
+   *
+   * @param jdbcType The JDBC backend type.
+   */
+  public IdpSQLExceptionConverter(JdbcType jdbcType) {
+    this.jdbcType = jdbcType;
+  }
+
+  /**
+   * Convert JDBC exception to IdP exception.
+   *
+   * @param sqlException The sql exception to map
+   * @param resourceType The resource type, for example {@code user} or {@code 
group}
+   * @param name The name of the resource
+   * @throws IOException if an I/O error occurs during exception conversion
+   */
+  @SuppressWarnings("FormatStringAnnotation")
+  public void toIdpException(SQLException sqlException, String resourceType, 
String name)
+      throws IOException {
+    if (isDuplicateEntry(sqlException)) {
+      throw new AlreadyExistsException(
+          sqlException, "IdP %s %s already exists", resourceType, name);
+    }
+    throw toIOException(sqlException);
+  }
+
+  private boolean isDuplicateEntry(SQLException sqlException) {
+    switch (jdbcType) {
+      case MYSQL:
+        return sqlException.getErrorCode() == MYSQL_DUPLICATE_ENTRY_ERROR_CODE;
+      case H2:
+        // Same as core H2ExceptionConverter: H2 in MySQL mode may report 1062 
or 23505.
+        return sqlException.getErrorCode() == H2_DUPLICATE_ENTRY_ERROR_CODE
+            || sqlException.getErrorCode() == MYSQL_DUPLICATE_ENTRY_ERROR_CODE;
+      case POSTGRESQL:
+        return 
POSTGRESQL_DUPLICATE_ENTRY_SQL_STATE.equals(sqlException.getSQLState());
+      default:
+        throw new IllegalStateException("Unsupported JDBC type: " + jdbcType);
+    }
+  }
+
+  private IOException toIOException(SQLException sqlException) {
+    if (jdbcType == JdbcType.H2) {
+      return new IOException("error code: " + sqlException.getErrorCode(), 
sqlException);
+    }
+    return new IOException(sqlException);
+  }
+}
diff --git 
a/plugins/idp-basic/src/main/java/org/apache/gravitino/idp/storage/relational/converters/IdpSQLExceptionConverterFactory.java
 
b/plugins/idp-basic/src/main/java/org/apache/gravitino/idp/storage/relational/converters/IdpSQLExceptionConverterFactory.java
new file mode 100644
index 0000000000..2f6a6dba42
--- /dev/null
+++ 
b/plugins/idp-basic/src/main/java/org/apache/gravitino/idp/storage/relational/converters/IdpSQLExceptionConverterFactory.java
@@ -0,0 +1,84 @@
+/*
+ * 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.relational.converters;
+
+import com.google.common.base.Preconditions;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+import org.apache.gravitino.Config;
+import org.apache.gravitino.Configs;
+
+/** Factory for built-in IdP JDBC SQL exception converters. */
+public class IdpSQLExceptionConverterFactory {
+  private static final Pattern TYPE_PATTERN = Pattern.compile("jdbc:(\\w+):");
+  private static volatile IdpSQLExceptionConverter converter;
+
+  private IdpSQLExceptionConverterFactory() {}
+
+  /**
+   * Initializes the SQL exception converter from the JDBC backend URL in 
config.
+   *
+   * @param config The server configuration.
+   */
+  public static synchronized void initConverter(Config config) {
+    if (converter == null) {
+      String jdbcUrl = config.get(Configs.ENTITY_RELATIONAL_JDBC_BACKEND_URL);
+      converter = new IdpSQLExceptionConverter(parseJdbcType(jdbcUrl));
+    }
+  }
+
+  /**
+   * Returns the initialized SQL exception converter.
+   *
+   * @return The SQL exception converter.
+   */
+  public static IdpSQLExceptionConverter getConverter() {
+    Preconditions.checkState(converter != null, "Exception converter is not 
initialized.");
+    return converter;
+  }
+
+  /** Closes and resets the SQL exception converter. */
+  public static void close() {
+    if (converter != null) {
+      synchronized (IdpSQLExceptionConverterFactory.class) {
+        if (converter != null) {
+          converter = null;
+        }
+      }
+    }
+  }
+
+  private static IdpSQLExceptionConverter.JdbcType parseJdbcType(String 
jdbcUrl) {
+    Matcher typeMatcher = TYPE_PATTERN.matcher(jdbcUrl);
+    if (!typeMatcher.find()) {
+      throw new IllegalArgumentException(
+          String.format("Cannot find jdbc type in jdbc url: %s", jdbcUrl));
+    }
+
+    String jdbcType = typeMatcher.group(1);
+    if (jdbcType.equalsIgnoreCase("mysql")) {
+      return IdpSQLExceptionConverter.JdbcType.MYSQL;
+    } else if (jdbcType.equalsIgnoreCase("h2")) {
+      return IdpSQLExceptionConverter.JdbcType.H2;
+    } else if (jdbcType.equalsIgnoreCase("postgresql")) {
+      return IdpSQLExceptionConverter.JdbcType.POSTGRESQL;
+    }
+    throw new IllegalArgumentException(String.format("Unsupported jdbc type: 
%s", jdbcType));
+  }
+}
diff --git 
a/plugins/idp-basic/src/main/java/org/apache/gravitino/idp/storage/relational/utils/IdpExceptionUtils.java
 
b/plugins/idp-basic/src/main/java/org/apache/gravitino/idp/storage/relational/utils/IdpExceptionUtils.java
new file mode 100644
index 0000000000..b831786e39
--- /dev/null
+++ 
b/plugins/idp-basic/src/main/java/org/apache/gravitino/idp/storage/relational/utils/IdpExceptionUtils.java
@@ -0,0 +1,44 @@
+/*
+ * 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.relational.utils;
+
+import java.io.IOException;
+import java.sql.SQLException;
+import 
org.apache.gravitino.idp.storage.relational.converters.IdpSQLExceptionConverterFactory;
+
+/** Utilities for translating JDBC exceptions in built-in IdP storage. */
+public class IdpExceptionUtils {
+  private IdpExceptionUtils() {}
+
+  /**
+   * Converts JDBC SQL exceptions into built-in IdP exceptions when possible.
+   *
+   * @param re The runtime exception thrown by JDBC/MyBatis.
+   * @param resourceType The resource type, for example {@code user} or {@code 
group}
+   * @param name The resource name.
+   * @throws IOException if the SQL exception cannot be mapped to an IdP 
exception.
+   */
+  public static void checkSQLException(RuntimeException re, String 
resourceType, String name)
+      throws IOException {
+    if (re.getCause() instanceof SQLException) {
+      IdpSQLExceptionConverterFactory.getConverter()
+          .toIdpException((SQLException) re.getCause(), resourceType, name);
+    }
+  }
+}
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
index ce51423775..09313581f9 100644
--- 
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
@@ -20,14 +20,19 @@ package org.apache.gravitino.idp.storage.service;
 
 import static 
org.apache.gravitino.metrics.source.MetricsSource.GRAVITINO_RELATIONAL_STORE_METRIC_NAME;
 
+import com.google.common.collect.Lists;
+import com.google.common.collect.Sets;
+import java.io.IOException;
 import java.util.ArrayList;
 import java.util.List;
 import java.util.Map;
+import java.util.Set;
 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.idp.storage.relational.utils.IdpExceptionUtils;
 import org.apache.gravitino.metrics.Monitored;
 import org.apache.gravitino.storage.RandomIdGenerator;
 import org.apache.gravitino.storage.relational.utils.SessionUtils;
@@ -49,7 +54,13 @@ public class IdpGroupMetaService {
       metricsSource = GRAVITINO_RELATIONAL_STORE_METRIC_NAME,
       baseMetricName = "getIdpGroupByName")
   public IdpGroupPO getIdpGroupByName(String groupName) {
-    return getIdpGroupPOByName(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;
   }
 
   @Monitored(
@@ -63,47 +74,109 @@ public class IdpGroupMetaService {
   @Monitored(
       metricsSource = GRAVITINO_RELATIONAL_STORE_METRIC_NAME,
       baseMetricName = "insertIdpGroup")
-  public void insertIdpGroup(IdpGroupPO groupPO) {
-    SessionUtils.doWithCommit(IdpGroupMetaMapper.class, mapper -> 
mapper.insertIdpGroup(groupPO));
+  public void insertIdpGroup(IdpGroupPO groupPO) throws IOException {
+    try {
+      SessionUtils.doWithCommit(IdpGroupMetaMapper.class, mapper -> 
mapper.insertIdpGroup(groupPO));
+    } catch (RuntimeException re) {
+      IdpExceptionUtils.checkSQLException(re, "group", groupPO.getGroupName());
+      throw re;
+    }
   }
 
+  /**
+   * Deletes a built-in IdP group.
+   *
+   * @param groupName the group name
+   * @param force when false, rejects deletion if the group still has members; 
when true, removes
+   *     memberships and deletes the group
+   * @return true if the group was deleted
+   */
   @Monitored(
       metricsSource = GRAVITINO_RELATIONAL_STORE_METRIC_NAME,
       baseMetricName = "deleteIdpGroup")
-  public boolean deleteIdpGroup(String groupName) {
+  public boolean deleteIdpGroup(String groupName, boolean force) {
+    if (!force && !listUsernamesByGroupName(groupName).isEmpty()) {
+      throw new IllegalStateException(
+          String.format("IdP group %s is not empty, use force=true to delete 
it", groupName));
+    }
+
+    int[] deletedCount = new int[] {0};
     SessionUtils.doMultipleWithCommit(
-        () ->
+        () -> {
+          if (force) {
             SessionUtils.doWithoutCommit(
                 IdpUserGroupRelMapper.class,
-                mapper -> mapper.softDeleteRelationsByGroupName(groupName)),
-        () ->
-            SessionUtils.doWithoutCommit(
-                IdpGroupMetaMapper.class, mapper -> 
mapper.softDeleteIdpGroup(groupName)));
-    return true;
+                mapper -> mapper.softDeleteRelationsByGroupName(groupName));
+          }
+        },
+        () -> {
+          Integer deleted =
+              SessionUtils.getWithoutCommit(
+                  IdpGroupMetaMapper.class, mapper -> 
mapper.softDeleteIdpGroup(groupName));
+          deletedCount[0] = deleted == null ? 0 : deleted;
+        });
+    return deletedCount[0] > 0;
   }
 
+  /**
+   * Changes built-in IdP group membership in a single transaction.
+   *
+   * @param groupName The group name.
+   * @param additions The usernames to add.
+   * @param removals The usernames to remove.
+   */
   @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));
-  }
+      baseMetricName = "changeGroupMembership")
+  public void changeGroupMembership(
+      String groupName, List<String> additions, List<String> removals) {
+    SessionUtils.doMultipleWithCommit(
+        () -> {
+          IdpGroupPO group =
+              SessionUtils.getWithoutCommit(
+                  IdpGroupMetaMapper.class,
+                  mapper -> {
+                    IdpGroupPO groupPO = mapper.selectIdpGroup(groupName);
+                    if (groupPO == null) {
+                      throw new NotFoundException("IdP group not found: %s", 
groupName);
+                    }
+                    return groupPO;
+                  });
+
+          List<String> currentUsernames =
+              SessionUtils.getWithoutCommit(
+                  IdpUserGroupRelMapper.class,
+                  mapper -> mapper.selectUsernamesByGroupName(groupName));
+          Set<String> oldUsernames = Sets.newHashSet(currentUsernames);
+          Set<String> newUsernames = Sets.newHashSet(oldUsernames);
+          newUsernames.addAll(additions);
+          newUsernames.removeAll(removals);
+
+          Set<String> insertUsernames = Sets.difference(newUsernames, 
oldUsernames);
+          Set<String> deleteUsernames = Sets.difference(oldUsernames, 
newUsernames);
+          if (insertUsernames.isEmpty() && deleteUsernames.isEmpty()) {
+            return;
+          }
+
+          List<String> insertList = Lists.newArrayList(insertUsernames);
+          if (!insertList.isEmpty()) {
+            Map<String, Long> userIds =
+                
IdpUserMetaService.getInstance().resolveUserIdsByUsernames(insertList);
+            List<IdpUserGroupRelPO> relations = new 
ArrayList<>(insertList.size());
+            for (String username : insertList) {
+              relations.add(newUserGroupRelation(group.getGroupId(), 
userIds.get(username)));
+            }
+            SessionUtils.doWithoutCommit(
+                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));
+          List<String> deleteList = Lists.newArrayList(deleteUsernames);
+          if (!deleteList.isEmpty()) {
+            SessionUtils.doWithoutCommit(
+                IdpUserGroupRelMapper.class,
+                mapper -> mapper.softDeleteRelations(groupName, deleteList));
+          }
+        });
   }
 
   @Monitored(
@@ -139,14 +212,4 @@ public class IdpGroupMetaService {
         .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
index b4f62c74e3..70834283ef 100644
--- 
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
@@ -20,6 +20,7 @@ package org.apache.gravitino.idp.storage.service;
 
 import static 
org.apache.gravitino.metrics.source.MetricsSource.GRAVITINO_RELATIONAL_STORE_METRIC_NAME;
 
+import java.io.IOException;
 import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
@@ -27,6 +28,7 @@ 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.idp.storage.relational.utils.IdpExceptionUtils;
 import org.apache.gravitino.metrics.Monitored;
 import org.apache.gravitino.storage.relational.utils.SessionUtils;
 
@@ -47,7 +49,13 @@ public class IdpUserMetaService {
       metricsSource = GRAVITINO_RELATIONAL_STORE_METRIC_NAME,
       baseMetricName = "getIdpUserByUsername")
   public IdpUserPO getIdpUserByUsername(String username) {
-    return getIdpUserPOByUsername(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;
   }
 
   @Monitored(
@@ -61,23 +69,35 @@ public class IdpUserMetaService {
   @Monitored(
       metricsSource = GRAVITINO_RELATIONAL_STORE_METRIC_NAME,
       baseMetricName = "insertIdpUser")
-  public void insertIdpUser(IdpUserPO userPO) {
-    SessionUtils.doWithCommit(IdpUserMetaMapper.class, mapper -> 
mapper.insertIdpUser(userPO));
+  public void insertIdpUser(IdpUserPO userPO) throws IOException {
+    try {
+      SessionUtils.doWithCommit(IdpUserMetaMapper.class, mapper -> 
mapper.insertIdpUser(userPO));
+    } catch (RuntimeException re) {
+      IdpExceptionUtils.checkSQLException(re, "user", userPO.getUsername());
+      throw re;
+    }
   }
 
   @Monitored(
       metricsSource = GRAVITINO_RELATIONAL_STORE_METRIC_NAME,
       baseMetricName = "deleteIdpUser")
   public boolean deleteIdpUser(String username) {
+    int[] deletedCount = new int[] {0};
     SessionUtils.doMultipleWithCommit(
-        () ->
+        () -> {
+          Integer deleted =
+              SessionUtils.getWithoutCommit(
+                  IdpUserMetaMapper.class, mapper -> 
mapper.softDeleteIdpUser(username));
+          deletedCount[0] = deleted == null ? 0 : deleted;
+        },
+        () -> {
+          if (deletedCount[0] > 0) {
             SessionUtils.doWithoutCommit(
                 IdpUserGroupRelMapper.class,
-                mapper -> mapper.softDeleteRelationsByUsername(username)),
-        () ->
-            SessionUtils.doWithoutCommit(
-                IdpUserMetaMapper.class, mapper -> 
mapper.softDeleteIdpUser(username)));
-    return true;
+                mapper -> mapper.softDeleteRelationsByUsername(username));
+          }
+        });
+    return deletedCount[0] > 0;
   }
 
   /**
@@ -144,14 +164,4 @@ public class IdpUserMetaService {
     }
     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/TestIdpUserGroupManager.java
 
b/plugins/idp-basic/src/test/java/org/apache/gravitino/idp/TestIdpUserGroupManager.java
new file mode 100644
index 0000000000..5112e8d555
--- /dev/null
+++ 
b/plugins/idp-basic/src/test/java/org/apache/gravitino/idp/TestIdpUserGroupManager.java
@@ -0,0 +1,206 @@
+/*
+ * 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;
+
+import static org.apache.gravitino.Configs.CACHE_ENABLED;
+import static 
org.apache.gravitino.Configs.ENTITY_RELATIONAL_JDBC_BACKEND_DRIVER;
+import static 
org.apache.gravitino.Configs.ENTITY_RELATIONAL_JDBC_BACKEND_MAX_CONNECTIONS;
+import static org.apache.gravitino.Configs.ENTITY_RELATIONAL_JDBC_BACKEND_URL;
+import static 
org.apache.gravitino.Configs.ENTITY_RELATIONAL_JDBC_BACKEND_WAIT_MILLISECONDS;
+import static org.apache.gravitino.Configs.ENTITY_RELATIONAL_STORE;
+import static org.apache.gravitino.Configs.ENTITY_STORE;
+import static org.apache.gravitino.Configs.RELATIONAL_ENTITY_STORE;
+import static org.apache.gravitino.Configs.STORE_DELETE_AFTER_TIME;
+
+import com.google.common.collect.Lists;
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.Comparator;
+import java.util.stream.Stream;
+import org.apache.gravitino.Config;
+import org.apache.gravitino.idp.exception.AlreadyExistsException;
+import org.apache.gravitino.idp.exception.NotFoundException;
+import org.apache.gravitino.idp.model.IdpGroup;
+import org.apache.gravitino.idp.model.IdpUser;
+import org.apache.gravitino.storage.RandomIdGenerator;
+import org.junit.jupiter.api.AfterAll;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+
+/** Integration tests for {@link IdpUserGroupManager} backed by an embedded H2 
store. */
+public class TestIdpUserGroupManager {
+
+  private static IdpUserGroupManager manager;
+  private static Path h2Path;
+
+  @BeforeAll
+  public static void setUp() throws Exception {
+    h2Path = Files.createTempDirectory("gravitino_idp_manager_h2_");
+
+    Config config = new Config(false) {};
+    config.set(ENTITY_STORE, RELATIONAL_ENTITY_STORE);
+    config.set(ENTITY_RELATIONAL_STORE, "h2");
+    config.set(
+        ENTITY_RELATIONAL_JDBC_BACKEND_URL,
+        String.format("jdbc:h2:file:%s;DB_CLOSE_DELAY=-1;MODE=MYSQL", h2Path));
+    config.set(ENTITY_RELATIONAL_JDBC_BACKEND_DRIVER, "org.h2.Driver");
+    config.set(ENTITY_RELATIONAL_JDBC_BACKEND_MAX_CONNECTIONS, 100);
+    config.set(ENTITY_RELATIONAL_JDBC_BACKEND_WAIT_MILLISECONDS, 1000L);
+    config.set(STORE_DELETE_AFTER_TIME, 20 * 60 * 1000L);
+    config.set(CACHE_ENABLED, false);
+
+    manager = new IdpUserGroupManager(config, RandomIdGenerator.INSTANCE);
+  }
+
+  @AfterAll
+  public static void tearDown() throws IOException {
+    if (manager != null) {
+      manager.close();
+      manager = null;
+    }
+
+    if (h2Path != null && Files.exists(h2Path)) {
+      try (Stream<Path> paths = Files.walk(h2Path)) {
+        
paths.sorted(Comparator.reverseOrder()).forEach(TestIdpUserGroupManager::deletePath);
+      }
+    }
+  }
+
+  @Test
+  public void testAddUser() throws IOException {
+    IdpUser user = manager.addUser("testAdd", "password123");
+    Assertions.assertEquals("testAdd", user.name());
+    Assertions.assertTrue(user.groupNames().isEmpty());
+
+    Assertions.assertThrows(
+        AlreadyExistsException.class, () -> manager.addUser("testAdd", 
"password456"));
+  }
+
+  @Test
+  public void testGetUser() throws IOException {
+    manager.addUser("testGet", "password123");
+
+    IdpUser user = manager.getUser("testGet");
+    Assertions.assertEquals("testGet", user.name());
+
+    Throwable exception =
+        Assertions.assertThrows(NotFoundException.class, () -> 
manager.getUser("not-exist"));
+    Assertions.assertTrue(exception.getMessage().contains("IdP user not found: 
not-exist"));
+  }
+
+  @Test
+  public void testRemoveUser() throws IOException {
+    manager.addUser("testRemove", "password123");
+
+    Assertions.assertTrue(manager.removeUser("testRemove"));
+    Assertions.assertFalse(manager.removeUser("no-exist"));
+  }
+
+  @Test
+  public void testChangePassword() throws IOException {
+    manager.addUser("testChangePassword", "password123");
+
+    Assertions.assertTrue(manager.changePassword("testChangePassword", 
"new-password"));
+    Assertions.assertEquals("testChangePassword", 
manager.getUser("testChangePassword").name());
+
+    Assertions.assertFalse(manager.changePassword("not-exist", "password123"));
+  }
+
+  @Test
+  public void testAddGroup() throws IOException {
+    IdpGroup group = manager.addGroup("testAddGroup");
+    Assertions.assertEquals("testAddGroup", group.name());
+    Assertions.assertTrue(group.usernames().isEmpty());
+
+    Assertions.assertThrows(AlreadyExistsException.class, () -> 
manager.addGroup("testAddGroup"));
+  }
+
+  @Test
+  public void testGetGroup() throws IOException {
+    manager.addGroup("testGetGroup");
+
+    IdpGroup group = manager.getGroup("testGetGroup");
+    Assertions.assertEquals("testGetGroup", group.name());
+
+    Throwable exception =
+        Assertions.assertThrows(NotFoundException.class, () -> 
manager.getGroup("not-exist"));
+    Assertions.assertTrue(exception.getMessage().contains("IdP group not 
found: not-exist"));
+  }
+
+  @Test
+  public void testChangeGroupMembership() throws IOException {
+    manager.addUser("groupUser1", "password123");
+    manager.addUser("groupUser2", "password123");
+    manager.addUser("groupUser3", "password123");
+    manager.addGroup("testMembershipGroup");
+
+    IdpGroup group =
+        manager.changeGroupMembership(
+            "testMembershipGroup", Lists.newArrayList("groupUser1", 
"groupUser2"), null);
+    Assertions.assertTrue(group.usernames().contains("groupUser1"));
+    Assertions.assertTrue(group.usernames().contains("groupUser2"));
+
+    group =
+        manager.changeGroupMembership(
+            "testMembershipGroup",
+            Lists.newArrayList("groupUser3"),
+            Lists.newArrayList("groupUser1"));
+    Assertions.assertFalse(group.usernames().contains("groupUser1"));
+    Assertions.assertTrue(group.usernames().contains("groupUser2"));
+    Assertions.assertTrue(group.usernames().contains("groupUser3"));
+
+    group =
+        manager.changeGroupMembership(
+            "testMembershipGroup", null, Lists.newArrayList("groupUser2", 
"groupUser3"));
+    Assertions.assertTrue(group.usernames().isEmpty());
+
+    Assertions.assertThrows(
+        IllegalArgumentException.class,
+        () -> manager.changeGroupMembership("testMembershipGroup", null, 
null));
+  }
+
+  @Test
+  public void testRemoveGroup() throws IOException {
+    manager.addUser("groupMember", "password123");
+    manager.addGroup("testRemoveGroup");
+    manager.changeGroupMembership("testRemoveGroup", 
Lists.newArrayList("groupMember"), null);
+
+    Assertions.assertThrows(
+        IllegalStateException.class, () -> 
manager.removeGroup("testRemoveGroup", false));
+
+    manager.changeGroupMembership("testRemoveGroup", null, 
Lists.newArrayList("groupMember"));
+    Assertions.assertTrue(manager.removeGroup("testRemoveGroup", false));
+    Assertions.assertFalse(manager.removeGroup("no-exist", false));
+
+    manager.addUser("forceMember", "password123");
+    manager.addGroup("testForceRemoveGroup");
+    manager.changeGroupMembership("testForceRemoveGroup", 
Lists.newArrayList("forceMember"), null);
+    Assertions.assertTrue(manager.removeGroup("testForceRemoveGroup", true));
+  }
+
+  private static void deletePath(Path path) {
+    try {
+      Files.deleteIfExists(path);
+    } catch (IOException e) {
+      throw new RuntimeException("Delete path failed: " + path, e);
+    }
+  }
+}
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 2a2806aaec..d556fc26b5 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
@@ -34,6 +34,7 @@ import org.apache.commons.lang3.StringUtils;
 import org.apache.gravitino.Config;
 import org.apache.gravitino.Configs;
 import org.apache.gravitino.config.ConfigConstants;
+import 
org.apache.gravitino.idp.storage.relational.converters.IdpSQLExceptionConverterFactory;
 import org.apache.gravitino.integration.test.container.ContainerSuite;
 import org.apache.gravitino.integration.test.container.MySQLContainer;
 import org.apache.gravitino.integration.test.container.PostgreSQLContainer;
@@ -53,6 +54,10 @@ public abstract class AbstractIdpMetaStorageTest {
   protected JDBCBackend backend;
   public SqlSession sharedSession;
 
+  protected Config getConfig() {
+    return config;
+  }
+
   private Config config;
   private Path h2Path;
 
@@ -69,6 +74,7 @@ public abstract class AbstractIdpMetaStorageTest {
     }
 
     SqlSessionFactoryHelper.getInstance().close();
+    IdpSQLExceptionConverterFactory.close();
     ContainerSuite.getInstance().close();
 
     if (h2Path != null && Files.exists(h2Path)) {
@@ -81,6 +87,7 @@ public abstract class AbstractIdpMetaStorageTest {
     config = createBackendConfig(type);
     backend = new JDBCBackend();
     backend.initialize(config);
+    IdpSQLExceptionConverterFactory.initConverter(config);
     sharedSession = 
SqlSessionFactoryHelper.getInstance().getSqlSessionFactory().openSession(true);
     initializeMappers();
   }
@@ -92,6 +99,24 @@ public abstract class AbstractIdpMetaStorageTest {
     }
   }
 
+  /**
+   * Re-initializes the JDBC backend after another component closed the shared 
SqlSession factory.
+   */
+  protected void reinitializeBackend() throws IOException {
+    if (backend != null) {
+      backend.close();
+    }
+    backend = new JDBCBackend();
+    backend.initialize(config);
+    IdpSQLExceptionConverterFactory.initConverter(config);
+  }
+
+  protected void reopenSession() {
+    closeSession();
+    sharedSession = 
SqlSessionFactoryHelper.getInstance().getSqlSessionFactory().openSession(true);
+    initializeMappers();
+  }
+
   protected void initializeMappers() {}
 
   private Config createBackendConfig(String type) throws IOException {
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/relational/TestIdpGarbageCollector.java
similarity index 91%
rename from 
plugins/idp-basic/src/test/java/org/apache/gravitino/idp/storage/gc/TestIdpLegacyGarbageCollector.java
rename to 
plugins/idp-basic/src/test/java/org/apache/gravitino/idp/storage/relational/TestIdpGarbageCollector.java
index 599954ed7a..7120c38d53 100644
--- 
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/relational/TestIdpGarbageCollector.java
@@ -16,7 +16,7 @@
  * specific language governing permissions and limitations
  * under the License.
  */
-package org.apache.gravitino.idp.storage.gc;
+package org.apache.gravitino.idp.storage.relational;
 
 import static org.apache.gravitino.Configs.STORE_DELETE_AFTER_TIME;
 import static org.junit.jupiter.api.Assertions.assertEquals;
@@ -27,7 +27,6 @@ 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;
@@ -42,7 +41,7 @@ import org.junit.jupiter.params.ParameterizedTest;
 import org.junit.jupiter.params.provider.MethodSource;
 
 @Tag("gravitino-docker-test")
-class TestIdpLegacyGarbageCollector extends AbstractIdpMetaStorageTest {
+class TestIdpGarbageCollector extends AbstractIdpMetaStorageTest {
   private IdpUserMetaMapper idpUserMetaMapper;
   private IdpGroupMetaMapper idpGroupMetaMapper;
   private IdpUserGroupRelMapper idpUserGroupRelMapper;
@@ -70,12 +69,16 @@ class TestIdpLegacyGarbageCollector extends 
AbstractIdpMetaStorageTest {
     assertEquals(2, countGroups());
     assertEquals(8, countUserGroupRels());
 
-    Config config = new Config(false) {};
-    config.set(STORE_DELETE_AFTER_TIME, 600000L);
+    getConfig().set(STORE_DELETE_AFTER_TIME, 600000L);
 
     closeSession();
-    IdpLegacyGarbageCollector garbageCollector = new 
IdpLegacyGarbageCollector(config);
-    garbageCollector.collectAndClean();
+    IdpGarbageCollector garbageCollector = new 
IdpGarbageCollector(getConfig());
+    try {
+      garbageCollector.collectAndClean();
+    } finally {
+      garbageCollector.close();
+    }
+    reinitializeBackend();
     reopenSession();
 
     assertEquals(0, countUsers());
@@ -83,12 +86,6 @@ class TestIdpLegacyGarbageCollector extends 
AbstractIdpMetaStorageTest {
     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(
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
index 4b7f8e1f5b..42d5f1fc84 100644
--- 
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
@@ -26,6 +26,7 @@ import static org.junit.jupiter.api.Assertions.assertTrue;
 
 import java.io.IOException;
 import java.util.List;
+import org.apache.gravitino.idp.exception.AlreadyExistsException;
 import org.apache.gravitino.idp.exception.NotFoundException;
 import org.apache.gravitino.idp.storage.po.IdpGroupPO;
 import org.junit.jupiter.api.Tag;
@@ -64,7 +65,8 @@ class TestIdpGroupMetaService extends 
AbstractIdpMetaServiceTest {
 
     assertThrows(NotFoundException.class, () -> 
groupMetaService.getIdpGroupByName("group1"));
     runServiceCall(() -> groupMetaService.insertIdpGroup(group1));
-    runServiceCall(() -> groupMetaService.addUsersToGroup("group1", 
List.of("user1")));
+    runServiceCall(
+        () -> groupMetaService.changeGroupMembership("group1", 
List.of("user1"), List.of()));
     assertEquals("group1", 
groupMetaService.getIdpGroupByName("group1").getGroupName());
     assertIterableEquals(List.of("user1"), 
groupMetaService.listUsernamesByGroupName("group1"));
 
@@ -76,19 +78,23 @@ class TestIdpGroupMetaService extends 
AbstractIdpMetaServiceTest {
             .withLastVersion(0L)
             .withDeletedAt(0L)
             .build();
-    assertThrowsRuntimeException(() -> 
groupMetaService.insertIdpGroup(duplicateGroup));
+    assertThrows(
+        AlreadyExistsException.class, () -> 
groupMetaService.insertIdpGroup(duplicateGroup));
   }
 
   @ParameterizedTest
   @MethodSource("storageProvider")
-  void testDeleteIdpGroupCascadesMemberships(String type) throws IOException {
+  void testDeleteIdpGroupForceRemovesMemberships(String type) throws 
IOException {
     init(type);
     insertUsers(4);
     insertGroups(4);
     insertGroupUserGroupRelations();
     IdpGroupMetaService groupMetaService = IdpGroupMetaService.getInstance();
 
-    runServiceCall(() -> 
assertTrue(groupMetaService.deleteIdpGroup("group1")));
+    assertThrows(
+        IllegalStateException.class, () -> 
groupMetaService.deleteIdpGroup("group1", false));
+
+    runServiceCall(() -> assertTrue(groupMetaService.deleteIdpGroup("group1", 
true)));
     assertNull(idpGroupMetaMapper.selectIdpGroup("group1"));
     assertEquals(4, countGroups());
     assertEquals(8, countUserGroupRels());
@@ -114,21 +120,21 @@ class TestIdpGroupMetaService extends 
AbstractIdpMetaServiceTest {
     runServiceCall(() -> groupMetaService.insertIdpGroup(group1));
 
     runServiceCall(
-        () -> groupMetaService.addUsersToGroup("engineering", List.of("user1", 
"user2")));
+        () ->
+            groupMetaService.changeGroupMembership(
+                "engineering", List.of("user1", "user2"), List.of()));
     assertIterableEquals(
         List.of("user1", "user2"), 
groupMetaService.listUsernamesByGroupName("engineering"));
 
     runServiceCall(
-        () ->
-            assertEquals(
-                1, groupMetaService.removeUsersFromGroup("engineering", 
List.of("user1"))));
+        () -> groupMetaService.changeGroupMembership("engineering", List.of(), 
List.of("user1")));
     assertIterableEquals(
         List.of("user2"), 
groupMetaService.listUsernamesByGroupName("engineering"));
   }
 
   @ParameterizedTest
   @MethodSource("storageProvider")
-  void testAddUsersToGroupThrowsWhenUserMissing(String type) throws 
IOException {
+  void testChangeGroupMembershipThrowsWhenUserMissing(String type) throws 
IOException {
     init(type);
     insertUsers(1);
     IdpGroupMetaService groupMetaService = IdpGroupMetaService.getInstance();
@@ -147,7 +153,9 @@ class TestIdpGroupMetaService extends 
AbstractIdpMetaServiceTest {
         NotFoundException.class,
         () ->
             runServiceCall(
-                () -> groupMetaService.addUsersToGroup("engineering", 
List.of("missing-user"))));
+                () ->
+                    groupMetaService.changeGroupMembership(
+                        "engineering", List.of("missing-user"), List.of())));
   }
 
   @ParameterizedTest
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
index dbc71999d5..7af936a0a5 100644
--- 
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
@@ -27,6 +27,7 @@ import static org.junit.jupiter.api.Assertions.assertTrue;
 
 import java.io.IOException;
 import java.util.List;
+import org.apache.gravitino.idp.exception.AlreadyExistsException;
 import org.apache.gravitino.idp.exception.NotFoundException;
 import org.apache.gravitino.idp.storage.po.IdpUserPO;
 import org.junit.jupiter.api.Tag;
@@ -67,7 +68,8 @@ class TestIdpUserMetaService extends 
AbstractIdpMetaServiceTest {
 
     assertThrows(NotFoundException.class, () -> 
userMetaService.getIdpUserByUsername("user1"));
     runServiceCall(() -> userMetaService.insertIdpUser(user1));
-    runServiceCall(() -> groupMetaService.addUsersToGroup("group1", 
List.of("user1")));
+    runServiceCall(
+        () -> groupMetaService.changeGroupMembership("group1", 
List.of("user1"), List.of()));
     assertEquals("user1", 
userMetaService.getIdpUserByUsername("user1").getUsername());
     assertIterableEquals(List.of("group1"), 
userMetaService.listGroupNamesByUsername("user1"));
 
@@ -80,7 +82,7 @@ class TestIdpUserMetaService extends 
AbstractIdpMetaServiceTest {
             .withLastVersion(0L)
             .withDeletedAt(0L)
             .build();
-    assertThrowsRuntimeException(() -> 
userMetaService.insertIdpUser(duplicateUser));
+    assertThrows(AlreadyExistsException.class, () -> 
userMetaService.insertIdpUser(duplicateUser));
   }
 
   @ParameterizedTest

Reply via email to