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

roryqi 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 08472d438c [#11069] feat(authn): Add built-in IdP user group relation 
storage model (#11140)
08472d438c is described below

commit 08472d438cc1f417876c524d7709d6e4ce8a1340
Author: MaSai <[email protected]>
AuthorDate: Wed May 20 15:38:55 2026 +0800

    [#11069] feat(authn): Add built-in IdP user group relation storage model 
(#11140)
    
    ### What changes were proposed in this pull request?
    
    Add built-in IdP group-user relation storage for the `idp-basic` plugin
    on top of the merged user/group meta mappers:
    
    - `IdpGroupUserRelPO`, `IdpGroupUserRelMapper`, and SQL provider factory
    with H2/PostgreSQL/MySQL backends
    - Register `IdpGroupUserRelMapper` in `IdpBasicMapperPackageProvider`
    - Unit tests for PO, SQL providers, and JDBC-backed storage
    (`TestIdpGroupUserRelStorage`)
    
    Code follows the `org.apache.gravitino.idp.storage` package layout
    introduced for user/group meta on `main`.
    
    ### Why are the changes needed?
    
    Built-in IdP needs persistent group-user membership (join table) to
    support authorization and user management flows.
    
    Fix: #11069
    
    ### Does this PR introduce _any_ user-facing change?
    
    No user-facing API changes. This adds internal relational storage/mapper
    support only.
    
    ### How was this patch tested?
    
    - `./gradlew :plugins:idp-basic:spotlessApply`
    - `./gradlew :plugins:idp-basic:test -PskipITs`
---
 design-docs/gravitino-local-authentication.md      |  34 +-
 .../idp/storage/mapper/IdpUserGroupRelMapper.java  |  77 ++++
 .../mapper/IdpUserGroupRelSQLProviderFactory.java  |  88 +++++
 .../provider/IdpBasicMapperPackageProvider.java    |   4 +-
 .../base/IdpUserGroupRelBaseSQLProvider.java       | 130 +++++++
 .../provider/h2/IdpUserGroupRelH2Provider.java     |  87 +++++
 .../IdpUserGroupRelPostgreSQLProvider.java         |  98 +++++
 .../idp/storage/po/IdpUserGroupRelPO.java}         |  35 +-
 .../storage/mapper/AbstractIdpMetaStorageTest.java |  11 +-
 .../mapper/TestIdpBasicMapperPackageProvider.java  |   6 +-
 .../storage/mapper/TestIdpGroupMetaStorage.java    |  42 ---
 .../storage/mapper/TestIdpUserGroupRelStorage.java | 401 +++++++++++++++++++++
 .../idp/storage/mapper/TestIdpUserMetaStorage.java |  59 ---
 .../base/TestIdpGroupMetaBaseSQLProvider.java      | 163 ---------
 .../base/TestIdpUserMetaBaseSQLProvider.java       | 169 ---------
 .../provider/h2/TestIdpUserMetaH2Provider.java     |  35 --
 .../TestIdpGroupMetaPostgreSQLProvider.java        |  45 ---
 .../TestIdpUserMetaPostgreSQLProvider.java         |  45 ---
 .../idp/storage/po/TestIdpUserGroupRelPO.java      |  89 +++++
 scripts/h2/schema-1.3.0-h2.sql                     |  10 +-
 scripts/h2/upgrade-1.2.0-to-1.3.0-h2.sql           |  10 +-
 scripts/mysql/schema-1.3.0-mysql.sql               |  12 +-
 scripts/mysql/upgrade-1.2.0-to-1.3.0-mysql.sql     |  12 +-
 scripts/postgresql/schema-1.3.0-postgresql.sql     |  26 +-
 .../upgrade-1.2.0-to-1.3.0-postgresql.sql          |  24 +-
 25 files changed, 1062 insertions(+), 650 deletions(-)

diff --git a/design-docs/gravitino-local-authentication.md 
b/design-docs/gravitino-local-authentication.md
index fe80d10e2f..498ff153ba 100644
--- a/design-docs/gravitino-local-authentication.md
+++ b/design-docs/gravitino-local-authentication.md
@@ -168,7 +168,7 @@ Local authentication requires three new tables:
 
 1. `idp_user_meta` — IdP user records
 2. `idp_group_meta` — IdP group records
-3. `idp_group_user_rel` — user/group membership mapping
+3. `idp_user_group_rel` — user/group membership mapping
 
 These tables follow Gravitino's existing metadata table conventions:
 
@@ -179,7 +179,7 @@ These tables follow Gravitino's existing metadata table 
conventions:
 Soft-deleted rows in `idp_user_meta` and `idp_group_meta` should be cleaned 
asynchronously by
 Gravitino's GC thread, following the same lifecycle management pattern used by 
other metadata
 tables. When a local user or local group is physically removed by the GC 
thread, the implementation
-should also clean the corresponding soft-deleted rows in `idp_group_user_rel` 
to avoid leaving
+should also clean the corresponding soft-deleted rows in `idp_user_group_rel` 
to avoid leaving
 orphaned membership records.
 
 Unlike Gravitino's existing `user_meta` and `group_meta` tables, 
`idp_user_meta` and
@@ -195,7 +195,6 @@ CREATE TABLE IF NOT EXISTS `idp_user_meta` (
     `user_id` BIGINT(20) UNSIGNED NOT NULL COMMENT 'user id',
     `user_name` VARCHAR(128) NOT NULL COMMENT 'username',
     `password_hash` VARCHAR(1024) NOT NULL COMMENT 'hashed password',
-    `audit_info` MEDIUMTEXT NOT NULL COMMENT 'user audit info',
     `current_version` INT UNSIGNED NOT NULL DEFAULT 1 COMMENT 'user current 
version',
     `last_version` INT UNSIGNED NOT NULL DEFAULT 1 COMMENT 'user last version',
     `deleted_at` BIGINT(20) UNSIGNED NOT NULL DEFAULT 0 COMMENT 'user deleted 
at',
@@ -210,7 +209,6 @@ CREATE TABLE IF NOT EXISTS `idp_user_meta` (
 CREATE TABLE IF NOT EXISTS `idp_group_meta` (
     `group_id` BIGINT(20) UNSIGNED NOT NULL COMMENT 'group id',
     `group_name` VARCHAR(128) NOT NULL COMMENT 'group name',
-    `audit_info` MEDIUMTEXT NOT NULL COMMENT 'group audit info',
     `current_version` INT UNSIGNED NOT NULL DEFAULT 1 COMMENT 'group current 
version',
     `last_version` INT UNSIGNED NOT NULL DEFAULT 1 COMMENT 'group last 
version',
     `deleted_at` BIGINT(20) UNSIGNED NOT NULL DEFAULT 0 COMMENT 'group deleted 
at',
@@ -219,21 +217,21 @@ CREATE TABLE IF NOT EXISTS `idp_group_meta` (
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin COMMENT 'IdP group 
metadata';
 ```
 
-### 5.3 `idp_group_user_rel`
+### 5.3 `idp_user_group_rel`
 
 ```sql
-CREATE TABLE IF NOT EXISTS `idp_group_user_rel` (
+CREATE TABLE IF NOT EXISTS `idp_user_group_rel` (
     `id` BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT COMMENT 'auto increment 
id',
-    `group_id` BIGINT(20) UNSIGNED NOT NULL COMMENT 'IdP group id',
     `user_id` BIGINT(20) UNSIGNED NOT NULL COMMENT 'IdP user id',
-    `audit_info` MEDIUMTEXT NOT NULL COMMENT 'relation audit info',
+    `group_id` BIGINT(20) UNSIGNED NOT NULL COMMENT 'IdP group id',
     `current_version` INT UNSIGNED NOT NULL DEFAULT 1 COMMENT 'relation 
current version',
     `last_version` INT UNSIGNED NOT NULL DEFAULT 1 COMMENT 'relation last 
version',
     `deleted_at` BIGINT(20) UNSIGNED NOT NULL DEFAULT 0 COMMENT 'relation 
deleted at',
     PRIMARY KEY (`id`),
-    UNIQUE KEY `uk_gi_ui_del` (`group_id`, `user_id`, `deleted_at`),
-    KEY `idx_uid` (`user_id`)
-) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin COMMENT 'IdP group 
user relation';
+    UNIQUE KEY `uk_iuig_del` (`user_id`, `group_id`, `deleted_at`),
+    KEY `idx_iuig_uid` (`user_id`),
+    KEY `idx_iuig_gid` (`group_id`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin COMMENT 'IdP user 
group relation';
 ```
 
 ### 5.4 Relationship Model
@@ -242,7 +240,7 @@ The logical entity relationship is straightforward:
 
 ```text
 idp_user_meta
-    └──< idp_group_user_rel >── idp_group_meta
+    └──< idp_user_group_rel >── idp_group_meta
 ```
 
 For integration with Gravitino's existing access control model, the local 
authentication tables are also
@@ -269,7 +267,7 @@ idp_user_meta --(user_name, logical mapping)--> user_meta[*]
 idp_group_meta --(group_name, logical mapping)--> group_meta[*]
 
 idp_user_meta
-    └──< idp_group_user_rel >── idp_group_meta
+    └──< idp_user_group_rel >── idp_group_meta
 ```
 
 This supports direct username lookup for authentication, group resolution for 
authorization, and a
@@ -380,7 +378,7 @@ The password verification flow is:
 The local user's groups are resolved by:
 
 1. Query `idp_user_meta` by username and `deleted_at = 0` to get `user_id`.
-2. Query `idp_group_user_rel` by `user_id` to get all active `group_id` values.
+2. Query `idp_user_group_rel` by `user_id` to get all active `group_id` values.
 3. Query `idp_group_meta` by those `group_id` values to load the full group 
set.
 
 This keeps the model aligned with Gravitino's existing authorization 
architecture, where user-group
@@ -479,7 +477,7 @@ At a high level:
 5. **Get group**: read the local group information and its current user 
memberships.
 6. **Add group**: create a new local group.
 7. **Remove group**: soft-delete the group record.
-8. **Add user to group**: create a row in `idp_group_user_rel`.
+8. **Add user to group**: create a row in `idp_user_group_rel`.
 9. **Remove user from group**: soft-delete the corresponding relation row.
 
 ### 9.1 HTTP Interface Design
@@ -743,10 +741,10 @@ curl -X PUT -H "Accept: 
application/vnd.gravitino.v1+json" \
 |---|---|---|---|
 | 1 | Authenticator module wiring | `settings.gradle.kts`, 
`server/build.gradle.kts`, `plugins:idp-basic` | Add the new module and make 
the server load it when `gravitino.authenticators=basic`. |
 | 2 | Password hashing support | `PasswordHasher`, `Argon2idPasswordHasher`, 
related tests | Use Argon2id as the only supported password hashing algorithm 
and store PHC-style hash strings. |
-| 3 | IdP metadata schema | JDBC schema files, mapper definitions, store layer 
| Create `idp_user_meta`, `idp_group_meta`, and `idp_group_user_rel` with 
soft-delete support. |
+| 3 | IdP metadata schema | JDBC schema files, mapper definitions, store layer 
| Create `idp_user_meta`, `idp_group_meta`, and `idp_user_group_rel` with 
soft-delete support. |
 | 4 | Service admin initialization | startup initialization logic, validation 
logic | Validate `GRAVITINO_INITIAL_ADMIN_PASSWORD`, initialize missing 
configured service admins during startup, and fail startup when required 
credentials are absent. |
 | 5 | Basic authentication flow | `BasicAuthenticator`, auth manager, filter 
integration | Verify Basic credentials against `idp_user_meta` and resolve the 
authenticated principal. |
-| 6 | Group resolution | store layer, auth manager | Load the user's active 
groups from `idp_group_user_rel` and `idp_group_meta` for later authorization. |
+| 6 | Group resolution | store layer, auth manager | Load the user's active 
groups from `idp_user_group_rel` and `idp_group_meta` for later authorization. |
 | 7 | Local IdP management APIs | REST resources, DTOs, request/response 
classes | Implement user CRUD, group CRUD, and group membership management 
under `/api/idp`. |
 | 8 | Tests and documentation | unit tests, integration tests, design and user 
docs | Cover initialization flow, authentication, metadata persistence, REST 
APIs, and doc/config alignment. |
 
@@ -756,7 +754,7 @@ curl -X PUT -H "Accept: application/vnd.gravitino.v1+json" \
 |---|---|
 | Module wiring | The design, module name, and server wiring all consistently 
use `plugins:idp-basic`, while the authenticator mode remains `basic`. |
 | Configuration | All examples use `gravitino.authenticators=basic`, and no 
obsolete configuration keys remain in the document. |
-| Schema design | The document consistently uses `idp_user_meta`, 
`idp_group_meta`, and `idp_group_user_rel`, and the soft-delete lifecycle is 
clearly described. |
+| Schema design | The document consistently uses `idp_user_meta`, 
`idp_group_meta`, and `idp_user_group_rel`, and the soft-delete lifecycle is 
clearly described. |
 | Security constraints | The document states that passwords are never stored 
in plaintext, Basic authentication should be used only over HTTPS, and 
initialization must enforce password policy. |
 | Initialization flow | The document explains how configured service admins 
are initialized during startup, when `GRAVITINO_INITIAL_ADMIN_PASSWORD` is 
required, and that only hashed passwords are written. |
 | Authentication flow | The Basic authentication flow is described end-to-end, 
including username/password parsing, user lookup, hash verification, and group 
resolution. |
diff --git 
a/plugins/idp-basic/src/main/java/org/apache/gravitino/idp/storage/mapper/IdpUserGroupRelMapper.java
 
b/plugins/idp-basic/src/main/java/org/apache/gravitino/idp/storage/mapper/IdpUserGroupRelMapper.java
new file mode 100644
index 0000000000..d610d76ed0
--- /dev/null
+++ 
b/plugins/idp-basic/src/main/java/org/apache/gravitino/idp/storage/mapper/IdpUserGroupRelMapper.java
@@ -0,0 +1,77 @@
+/*
+ * 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.mapper;
+
+import java.util.List;
+import org.apache.gravitino.idp.storage.po.IdpUserGroupRelPO;
+import org.apache.ibatis.annotations.DeleteProvider;
+import org.apache.ibatis.annotations.InsertProvider;
+import org.apache.ibatis.annotations.Param;
+import org.apache.ibatis.annotations.SelectProvider;
+import org.apache.ibatis.annotations.UpdateProvider;
+
+/**
+ * A MyBatis mapper for built-in IdP user-group relation operations.
+ *
+ * <p>This interface defines the SQL statements MyBatis executes for the 
built-in IdP user-group
+ * relation store. The SQLs are provided through {@code *Provider} annotations 
on this mapper
+ * interface. See the <a 
href="https://mybatis.org/mybatis-3/getting-started.html";>MyBatis getting
+ * started guide</a>.
+ */
+public interface IdpUserGroupRelMapper {
+  String IDP_USER_GROUP_REL_TABLE_NAME = "idp_user_group_rel";
+
+  @SelectProvider(
+      type = IdpUserGroupRelSQLProviderFactory.class,
+      method = "selectGroupNamesByUsername")
+  List<String> selectGroupNamesByUsername(@Param("username") String username);
+
+  @SelectProvider(
+      type = IdpUserGroupRelSQLProviderFactory.class,
+      method = "selectUsernamesByGroupName")
+  List<String> selectUsernamesByGroupName(@Param("groupName") String 
groupName);
+
+  @InsertProvider(type = IdpUserGroupRelSQLProviderFactory.class, method = 
"batchInsertRelations")
+  void batchInsertRelations(@Param("relations") List<IdpUserGroupRelPO> 
relations);
+
+  /**
+   * Soft-deletes user-group relations in a group by user name. An empty list 
soft-deletes all
+   * active relations in the group; pass null for an explicit error.
+   */
+  @UpdateProvider(type = IdpUserGroupRelSQLProviderFactory.class, method = 
"softDeleteRelations")
+  Integer softDeleteRelations(
+      @Param("groupName") String groupName, @Param("usernames") List<String> 
usernames);
+
+  @UpdateProvider(
+      type = IdpUserGroupRelSQLProviderFactory.class,
+      method = "softDeleteRelationsByUsername")
+  Integer softDeleteRelationsByUsername(@Param("username") String username);
+
+  @UpdateProvider(
+      type = IdpUserGroupRelSQLProviderFactory.class,
+      method = "softDeleteRelationsByGroupName")
+  Integer softDeleteRelationsByGroupName(@Param("groupName") String groupName);
+
+  @DeleteProvider(
+      type = IdpUserGroupRelSQLProviderFactory.class,
+      method = "deleteIdpUserGroupRelMetasByLegacyTimeline")
+  Integer deleteIdpUserGroupRelMetasByLegacyTimeline(
+      @Param("legacyTimeline") Long legacyTimeline, @Param("limit") int limit);
+}
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
new file mode 100644
index 0000000000..b5f1fab08c
--- /dev/null
+++ 
b/plugins/idp-basic/src/main/java/org/apache/gravitino/idp/storage/mapper/IdpUserGroupRelSQLProviderFactory.java
@@ -0,0 +1,88 @@
+/*
+ * 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.mapper;
+
+import com.google.common.collect.ImmutableMap;
+import java.util.List;
+import java.util.Map;
+import 
org.apache.gravitino.idp.storage.mapper.provider.base.IdpUserGroupRelBaseSQLProvider;
+import 
org.apache.gravitino.idp.storage.mapper.provider.h2.IdpUserGroupRelH2Provider;
+import 
org.apache.gravitino.idp.storage.mapper.provider.postgresql.IdpUserGroupRelPostgreSQLProvider;
+import org.apache.gravitino.idp.storage.po.IdpUserGroupRelPO;
+import org.apache.gravitino.storage.relational.JDBCBackend.JDBCBackendType;
+import org.apache.ibatis.annotations.Param;
+
+public class IdpUserGroupRelSQLProviderFactory {
+  private static final IdpUserGroupRelBaseSQLProvider MYSQL_PROVIDER =
+      new IdpUserGroupRelBaseSQLProvider();
+  private static final IdpUserGroupRelBaseSQLProvider H2_PROVIDER = new 
IdpUserGroupRelH2Provider();
+  private static final IdpUserGroupRelBaseSQLProvider POSTGRESQL_PROVIDER =
+      new IdpUserGroupRelPostgreSQLProvider();
+  private static final Map<JDBCBackendType, IdpUserGroupRelBaseSQLProvider> 
PROVIDER_MAP =
+      ImmutableMap.of(
+          JDBCBackendType.MYSQL,
+          MYSQL_PROVIDER,
+          JDBCBackendType.H2,
+          H2_PROVIDER,
+          JDBCBackendType.POSTGRESQL,
+          POSTGRESQL_PROVIDER);
+
+  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);
+  }
+
+  public static String selectUsernamesByGroupName(@Param("groupName") String 
groupName) {
+    return currentProvider().selectUsernamesByGroupName(groupName);
+  }
+
+  public static String batchInsertRelations(@Param("relations") 
List<IdpUserGroupRelPO> relations) {
+    return currentProvider().batchInsertRelations(relations);
+  }
+
+  public static String softDeleteRelations(
+      @Param("groupName") String groupName, @Param("usernames") List<String> 
usernames) {
+    return currentProvider().softDeleteRelations(groupName, usernames);
+  }
+
+  public static String softDeleteRelationsByUsername(@Param("username") String 
username) {
+    return currentProvider().softDeleteRelationsByUsername(username);
+  }
+
+  public static String softDeleteRelationsByGroupName(@Param("groupName") 
String groupName) {
+    return currentProvider().softDeleteRelationsByGroupName(groupName);
+  }
+
+  public static String deleteIdpUserGroupRelMetasByLegacyTimeline(
+      @Param("legacyTimeline") Long legacyTimeline, @Param("limit") int limit) 
{
+    return 
currentProvider().deleteIdpUserGroupRelMetasByLegacyTimeline(legacyTimeline, 
limit);
+  }
+}
diff --git 
a/plugins/idp-basic/src/main/java/org/apache/gravitino/idp/storage/mapper/provider/IdpBasicMapperPackageProvider.java
 
b/plugins/idp-basic/src/main/java/org/apache/gravitino/idp/storage/mapper/provider/IdpBasicMapperPackageProvider.java
index 075c994067..c4b5aef35f 100644
--- 
a/plugins/idp-basic/src/main/java/org/apache/gravitino/idp/storage/mapper/provider/IdpBasicMapperPackageProvider.java
+++ 
b/plugins/idp-basic/src/main/java/org/apache/gravitino/idp/storage/mapper/provider/IdpBasicMapperPackageProvider.java
@@ -21,6 +21,7 @@ package org.apache.gravitino.idp.storage.mapper.provider;
 import com.google.common.collect.ImmutableList;
 import java.util.List;
 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.storage.relational.mapper.provider.MapperPackageProvider;
 
@@ -29,6 +30,7 @@ public class IdpBasicMapperPackageProvider implements 
MapperPackageProvider {
 
   @Override
   public List<Class<?>> getMapperClasses() {
-    return ImmutableList.of(IdpUserMetaMapper.class, IdpGroupMetaMapper.class);
+    return ImmutableList.of(
+        IdpUserMetaMapper.class, IdpGroupMetaMapper.class, 
IdpUserGroupRelMapper.class);
   }
 }
diff --git 
a/plugins/idp-basic/src/main/java/org/apache/gravitino/idp/storage/mapper/provider/base/IdpUserGroupRelBaseSQLProvider.java
 
b/plugins/idp-basic/src/main/java/org/apache/gravitino/idp/storage/mapper/provider/base/IdpUserGroupRelBaseSQLProvider.java
new file mode 100644
index 0000000000..f7608bc1d7
--- /dev/null
+++ 
b/plugins/idp-basic/src/main/java/org/apache/gravitino/idp/storage/mapper/provider/base/IdpUserGroupRelBaseSQLProvider.java
@@ -0,0 +1,130 @@
+/*
+ * 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.mapper.provider.base;
+
+import java.util.List;
+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.IdpUserGroupRelPO;
+import org.apache.ibatis.annotations.Param;
+
+public class IdpUserGroupRelBaseSQLProvider {
+
+  public String selectGroupNamesByUsername(@Param("username") String username) 
{
+    return "SELECT g.group_name"
+        + " FROM "
+        + IdpUserMetaMapper.IDP_USER_TABLE_NAME
+        + " u JOIN "
+        + IdpUserGroupRelMapper.IDP_USER_GROUP_REL_TABLE_NAME
+        + " r ON r.user_id = u.user_id AND r.deleted_at = 0"
+        + " JOIN "
+        + IdpGroupMetaMapper.IDP_GROUP_TABLE_NAME
+        + " g ON g.group_id = r.group_id AND g.deleted_at = 0"
+        + " WHERE u.user_name = #{username}"
+        + " AND u.deleted_at = 0"
+        + " ORDER BY g.group_name";
+  }
+
+  public String selectUsernamesByGroupName(@Param("groupName") String 
groupName) {
+    return "SELECT u.user_name"
+        + " FROM "
+        + IdpGroupMetaMapper.IDP_GROUP_TABLE_NAME
+        + " g JOIN "
+        + IdpUserGroupRelMapper.IDP_USER_GROUP_REL_TABLE_NAME
+        + " r ON r.group_id = g.group_id AND r.deleted_at = 0"
+        + " JOIN "
+        + IdpUserMetaMapper.IDP_USER_TABLE_NAME
+        + " u ON u.user_id = r.user_id AND u.deleted_at = 0"
+        + " WHERE g.group_name = #{groupName}"
+        + " AND g.deleted_at = 0"
+        + " ORDER BY u.user_name";
+  }
+
+  public String batchInsertRelations(@Param("relations") 
List<IdpUserGroupRelPO> relations) {
+    return "<script>"
+        + "INSERT INTO "
+        + IdpUserGroupRelMapper.IDP_USER_GROUP_REL_TABLE_NAME
+        + " (id, user_id, group_id, current_version, last_version, deleted_at)"
+        + " VALUES "
+        + "<foreach item='item' collection='relations' separator=','>"
+        + "(#{item.id}, #{item.userId}, #{item.groupId}, 
#{item.currentVersion},"
+        + " #{item.lastVersion}, #{item.deletedAt})"
+        + "</foreach>"
+        + "</script>";
+  }
+
+  public String softDeleteRelations(
+      @Param("groupName") String groupName, @Param("usernames") List<String> 
usernames) {
+    return "<script>"
+        + "UPDATE "
+        + IdpUserGroupRelMapper.IDP_USER_GROUP_REL_TABLE_NAME
+        + " r INNER JOIN "
+        + IdpGroupMetaMapper.IDP_GROUP_TABLE_NAME
+        + " g ON g.group_id = r.group_id AND g.deleted_at = 0"
+        + " INNER JOIN "
+        + IdpUserMetaMapper.IDP_USER_TABLE_NAME
+        + " u ON u.user_id = r.user_id AND u.deleted_at = 0"
+        + " SET r.deleted_at = "
+        + currentTimeMillisExpression()
+        + " WHERE g.group_name = #{groupName}"
+        + " AND r.deleted_at = 0"
+        + "<foreach collection='usernames' item='username'"
+        + " open=' AND u.user_name IN (' separator=',' close=')'>"
+        + "#{username}"
+        + "</foreach>"
+        + "</script>";
+  }
+
+  public String softDeleteRelationsByUsername(@Param("username") String 
username) {
+    return "UPDATE "
+        + IdpUserGroupRelMapper.IDP_USER_GROUP_REL_TABLE_NAME
+        + " r INNER JOIN "
+        + IdpUserMetaMapper.IDP_USER_TABLE_NAME
+        + " u ON u.user_id = r.user_id AND u.deleted_at = 0"
+        + " SET r.deleted_at = "
+        + currentTimeMillisExpression()
+        + " WHERE u.user_name = #{username}"
+        + " AND r.deleted_at = 0";
+  }
+
+  public String softDeleteRelationsByGroupName(@Param("groupName") String 
groupName) {
+    return "UPDATE "
+        + IdpUserGroupRelMapper.IDP_USER_GROUP_REL_TABLE_NAME
+        + " r INNER JOIN "
+        + IdpGroupMetaMapper.IDP_GROUP_TABLE_NAME
+        + " g ON g.group_id = r.group_id AND g.deleted_at = 0"
+        + " SET r.deleted_at = "
+        + currentTimeMillisExpression()
+        + " WHERE g.group_name = #{groupName}"
+        + " AND r.deleted_at = 0";
+  }
+
+  public String deleteIdpUserGroupRelMetasByLegacyTimeline(
+      @Param("legacyTimeline") Long legacyTimeline, @Param("limit") int limit) 
{
+    return "DELETE FROM "
+        + IdpUserGroupRelMapper.IDP_USER_GROUP_REL_TABLE_NAME
+        + " WHERE deleted_at > 0 AND deleted_at < #{legacyTimeline} LIMIT 
#{limit}";
+  }
+
+  protected String currentTimeMillisExpression() {
+    return "(UNIX_TIMESTAMP() * 1000.0)";
+  }
+}
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
new file mode 100644
index 0000000000..1f9d1dc7aa
--- /dev/null
+++ 
b/plugins/idp-basic/src/main/java/org/apache/gravitino/idp/storage/mapper/provider/h2/IdpUserGroupRelH2Provider.java
@@ -0,0 +1,87 @@
+/*
+ * 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.mapper.provider.h2;
+
+import java.util.List;
+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.mapper.provider.base.IdpUserGroupRelBaseSQLProvider;
+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) {
+    return "<script>"
+        + "UPDATE "
+        + IdpUserGroupRelMapper.IDP_USER_GROUP_REL_TABLE_NAME
+        + " r SET deleted_at = "
+        + currentTimeMillisExpression()
+        + " WHERE r.deleted_at = 0"
+        + " AND r.group_id IN (SELECT g.group_id FROM "
+        + IdpGroupMetaMapper.IDP_GROUP_TABLE_NAME
+        + " g WHERE g.group_name = #{groupName} AND g.deleted_at = 0)"
+        + " AND r.user_id IN (SELECT u.user_id FROM "
+        + IdpUserMetaMapper.IDP_USER_TABLE_NAME
+        + " u WHERE u.deleted_at = 0"
+        + "<foreach collection='usernames' item='username'"
+        + " open=' AND u.user_name IN (' separator=',' close=')'>"
+        + "#{username}"
+        + "</foreach>"
+        + ")"
+        + "</script>";
+  }
+
+  @Override
+  public String softDeleteRelationsByUsername(@Param("username") String 
username) {
+    return "MERGE INTO "
+        + IdpUserGroupRelMapper.IDP_USER_GROUP_REL_TABLE_NAME
+        + " r USING "
+        + IdpUserMetaMapper.IDP_USER_TABLE_NAME
+        + " u ON r.user_id = u.user_id"
+        + " AND u.user_name = #{username}"
+        + " AND u.deleted_at = 0"
+        + " AND r.deleted_at = 0"
+        + " WHEN MATCHED THEN UPDATE SET r.deleted_at = "
+        + currentTimeMillisExpression();
+  }
+
+  @Override
+  public String softDeleteRelationsByGroupName(@Param("groupName") String 
groupName) {
+    return "MERGE INTO "
+        + IdpUserGroupRelMapper.IDP_USER_GROUP_REL_TABLE_NAME
+        + " r USING "
+        + IdpGroupMetaMapper.IDP_GROUP_TABLE_NAME
+        + " g ON r.group_id = g.group_id"
+        + " AND g.group_name = #{groupName}"
+        + " AND g.deleted_at = 0"
+        + " AND r.deleted_at = 0"
+        + " WHEN MATCHED THEN UPDATE SET r.deleted_at = "
+        + currentTimeMillisExpression();
+  }
+}
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
new file mode 100644
index 0000000000..03cbeb6635
--- /dev/null
+++ 
b/plugins/idp-basic/src/main/java/org/apache/gravitino/idp/storage/mapper/provider/postgresql/IdpUserGroupRelPostgreSQLProvider.java
@@ -0,0 +1,98 @@
+/*
+ * 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.mapper.provider.postgresql;
+
+import java.util.List;
+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.mapper.provider.base.IdpUserGroupRelBaseSQLProvider;
+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) {
+    return "<script>"
+        + "UPDATE "
+        + IdpUserGroupRelMapper.IDP_USER_GROUP_REL_TABLE_NAME
+        + " r SET deleted_at = "
+        + currentTimeMillisExpression()
+        + " FROM "
+        + IdpGroupMetaMapper.IDP_GROUP_TABLE_NAME
+        + " g, "
+        + IdpUserMetaMapper.IDP_USER_TABLE_NAME
+        + " u WHERE r.group_id = g.group_id"
+        + " AND r.user_id = u.user_id"
+        + " AND g.group_name = #{groupName}"
+        + " AND g.deleted_at = 0"
+        + " AND u.deleted_at = 0"
+        + " AND r.deleted_at = 0"
+        + "<foreach collection='usernames' item='username'"
+        + " open=' AND u.user_name IN (' separator=',' close=')'>"
+        + "#{username}"
+        + "</foreach>"
+        + "</script>";
+  }
+
+  @Override
+  public String softDeleteRelationsByUsername(@Param("username") String 
username) {
+    return "UPDATE "
+        + IdpUserGroupRelMapper.IDP_USER_GROUP_REL_TABLE_NAME
+        + " r SET deleted_at = "
+        + currentTimeMillisExpression()
+        + " FROM "
+        + IdpUserMetaMapper.IDP_USER_TABLE_NAME
+        + " u WHERE r.user_id = u.user_id"
+        + " AND u.user_name = #{username}"
+        + " AND u.deleted_at = 0"
+        + " AND r.deleted_at = 0";
+  }
+
+  @Override
+  public String softDeleteRelationsByGroupName(@Param("groupName") String 
groupName) {
+    return "UPDATE "
+        + IdpUserGroupRelMapper.IDP_USER_GROUP_REL_TABLE_NAME
+        + " r SET deleted_at = "
+        + currentTimeMillisExpression()
+        + " FROM "
+        + IdpGroupMetaMapper.IDP_GROUP_TABLE_NAME
+        + " g WHERE r.group_id = g.group_id"
+        + " AND g.group_name = #{groupName}"
+        + " AND g.deleted_at = 0"
+        + " AND r.deleted_at = 0";
+  }
+
+  @Override
+  public String deleteIdpUserGroupRelMetasByLegacyTimeline(
+      @Param("legacyTimeline") Long legacyTimeline, @Param("limit") int limit) 
{
+    return "DELETE FROM "
+        + IdpUserGroupRelMapper.IDP_USER_GROUP_REL_TABLE_NAME
+        + " WHERE id IN (SELECT id FROM "
+        + IdpUserGroupRelMapper.IDP_USER_GROUP_REL_TABLE_NAME
+        + " WHERE deleted_at > 0 AND deleted_at < #{legacyTimeline} LIMIT 
#{limit})";
+  }
+}
diff --git 
a/plugins/idp-basic/src/test/java/org/apache/gravitino/idp/storage/mapper/provider/h2/TestIdpGroupMetaH2Provider.java
 
b/plugins/idp-basic/src/main/java/org/apache/gravitino/idp/storage/po/IdpUserGroupRelPO.java
similarity index 57%
rename from 
plugins/idp-basic/src/test/java/org/apache/gravitino/idp/storage/mapper/provider/h2/TestIdpGroupMetaH2Provider.java
rename to 
plugins/idp-basic/src/main/java/org/apache/gravitino/idp/storage/po/IdpUserGroupRelPO.java
index fb6584795a..ecf168f9f7 100644
--- 
a/plugins/idp-basic/src/test/java/org/apache/gravitino/idp/storage/mapper/provider/h2/TestIdpGroupMetaH2Provider.java
+++ 
b/plugins/idp-basic/src/main/java/org/apache/gravitino/idp/storage/po/IdpUserGroupRelPO.java
@@ -16,20 +16,27 @@
  * specific language governing permissions and limitations
  * under the License.
  */
+package org.apache.gravitino.idp.storage.po;
 
-package org.apache.gravitino.idp.storage.mapper.provider.h2;
+import lombok.AccessLevel;
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.EqualsAndHashCode;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+import lombok.ToString;
 
-import org.junit.jupiter.api.Assertions;
-import org.junit.jupiter.api.Test;
-
-class TestIdpGroupMetaH2Provider {
-
-  @Test
-  void testCurrentTimeMillisExpression() {
-    IdpGroupMetaH2Provider provider = new IdpGroupMetaH2Provider();
-
-    Assertions.assertEquals(
-        "DATEDIFF('MILLISECOND', TIMESTAMP '1970-01-01 00:00:00', 
CURRENT_TIMESTAMP())",
-        provider.currentTimeMillisExpression());
-  }
+@Getter
+@EqualsAndHashCode
+@ToString
+@NoArgsConstructor(access = AccessLevel.PRIVATE)
+@AllArgsConstructor(access = AccessLevel.PRIVATE)
+@Builder(setterPrefix = "with")
+public class IdpUserGroupRelPO {
+  private Long id;
+  private Long userId;
+  private Long groupId;
+  private Long currentVersion;
+  private Long lastVersion;
+  private Long deletedAt;
 }
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 f41f323835..047af1bb08 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
@@ -85,15 +85,6 @@ abstract class AbstractIdpMetaStorageTest {
     initializeMappers();
   }
 
-  protected void restartBackend() throws IOException {
-    closeSession();
-    backend.close();
-    backend = new JDBCBackend();
-    backend.initialize(config);
-    sharedSession = 
SqlSessionFactoryHelper.getInstance().getSqlSessionFactory().openSession(true);
-    initializeMappers();
-  }
-
   protected void closeSession() {
     if (sharedSession != null) {
       sharedSession.close();
@@ -101,7 +92,7 @@ abstract class AbstractIdpMetaStorageTest {
     }
   }
 
-  protected abstract void initializeMappers();
+  protected void initializeMappers() {}
 
   private Config createBackendConfig(String type) throws IOException {
     Config backendConfig = new Config(false) {};
diff --git 
a/plugins/idp-basic/src/test/java/org/apache/gravitino/idp/storage/mapper/TestIdpBasicMapperPackageProvider.java
 
b/plugins/idp-basic/src/test/java/org/apache/gravitino/idp/storage/mapper/TestIdpBasicMapperPackageProvider.java
index 3fcf1b6a7b..d522d36100 100644
--- 
a/plugins/idp-basic/src/test/java/org/apache/gravitino/idp/storage/mapper/TestIdpBasicMapperPackageProvider.java
+++ 
b/plugins/idp-basic/src/test/java/org/apache/gravitino/idp/storage/mapper/TestIdpBasicMapperPackageProvider.java
@@ -35,9 +35,11 @@ public class TestIdpBasicMapperPackageProvider {
     MapperPackageProvider provider = new IdpBasicMapperPackageProvider();
     List<Class<?>> mapperClasses = provider.getMapperClasses();
 
-    assertEquals(2, mapperClasses.size());
+    assertEquals(3, mapperClasses.size());
     assertTrue(
-        mapperClasses.containsAll(List.of(IdpUserMetaMapper.class, 
IdpGroupMetaMapper.class)));
+        mapperClasses.containsAll(
+            List.of(
+                IdpUserMetaMapper.class, IdpGroupMetaMapper.class, 
IdpUserGroupRelMapper.class)));
   }
 
   @Test
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 48ad87d48a..8d34c986ec 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
@@ -23,13 +23,11 @@ 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.Comparator;
 import java.util.List;
 import org.apache.gravitino.idp.storage.po.IdpGroupPO;
-import org.apache.gravitino.storage.relational.session.SqlSessionFactoryHelper;
 import org.apache.ibatis.exceptions.PersistenceException;
 import org.junit.jupiter.api.Tag;
 import org.junit.jupiter.params.ParameterizedTest;
@@ -177,44 +175,4 @@ class TestIdpGroupMetaStorage extends 
AbstractIdpMetaStorageTest {
     assertNull(idpGroupMetaMapper.selectIdpGroup("legacy-group"));
     assertNull(idpGroupMetaMapper.selectIdpGroup("new-group"));
   }
-
-  @ParameterizedTest
-  @MethodSource("storageProvider")
-  void testRestart(String type) throws IOException {
-    init(type);
-    IdpGroupPO expectedActiveGroup =
-        IdpGroupPO.builder()
-            .withGroupId(1L)
-            .withGroupName("dev")
-            .withCurrentVersion(3L)
-            .withLastVersion(2L)
-            .withDeletedAt(0L)
-            .build();
-    idpGroupMetaMapper.insertIdpGroup(expectedActiveGroup);
-    idpGroupMetaMapper.insertIdpGroup(
-        IdpGroupPO.builder()
-            .withGroupId(2L)
-            .withGroupName("ops")
-            .withCurrentVersion(1L)
-            .withLastVersion(0L)
-            .withDeletedAt(10L)
-            .build());
-
-    assertPersistedGroups(expectedActiveGroup);
-
-    restartBackend();
-
-    assertPersistedGroups(expectedActiveGroup);
-  }
-
-  private void assertPersistedGroups(IdpGroupPO expectedActiveGroup) {
-    assertTrue(
-        SqlSessionFactoryHelper.getInstance()
-            .getSqlSessionFactory()
-            .getConfiguration()
-            .hasMapper(IdpGroupMetaMapper.class));
-
-    assertEquals(expectedActiveGroup, 
idpGroupMetaMapper.selectIdpGroup("dev"));
-    assertNull(idpGroupMetaMapper.selectIdpGroup("ops"));
-  }
 }
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
new file mode 100644
index 0000000000..3572eb8f41
--- /dev/null
+++ 
b/plugins/idp-basic/src/test/java/org/apache/gravitino/idp/storage/mapper/TestIdpUserGroupRelStorage.java
@@ -0,0 +1,401 @@
+/*
+ * 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.mapper;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertIterableEquals;
+
+import java.io.IOException;
+import java.util.List;
+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.junit.jupiter.api.Tag;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.MethodSource;
+
+@Tag("gravitino-docker-test")
+class TestIdpUserGroupRelStorage 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 testBatchInsertIdpUserGroupsAndSelectGroupNamesByUsername(String type) 
throws IOException {
+    init(type);
+    idpGroupMetaMapper.insertIdpGroup(
+        IdpGroupPO.builder()
+            .withGroupId(1L)
+            .withGroupName("dev")
+            .withCurrentVersion(1L)
+            .withLastVersion(0L)
+            .withDeletedAt(0L)
+            .build());
+    idpGroupMetaMapper.insertIdpGroup(
+        IdpGroupPO.builder()
+            .withGroupId(2L)
+            .withGroupName("ops")
+            .withCurrentVersion(1L)
+            .withLastVersion(0L)
+            .withDeletedAt(0L)
+            .build());
+    idpUserMetaMapper.insertIdpUser(
+        IdpUserPO.builder()
+            .withUserId(1L)
+            .withUserName("alice")
+            .withPasswordHash("hash-a")
+            .withCurrentVersion(1L)
+            .withLastVersion(0L)
+            .withDeletedAt(0L)
+            .build());
+    idpUserGroupRelMapper.batchInsertRelations(
+        List.of(
+            IdpUserGroupRelPO.builder()
+                .withId(100L)
+                .withUserId(1L)
+                .withGroupId(1L)
+                .withCurrentVersion(1L)
+                .withLastVersion(0L)
+                .withDeletedAt(0L)
+                .build(),
+            IdpUserGroupRelPO.builder()
+                .withId(101L)
+                .withUserId(1L)
+                .withGroupId(2L)
+                .withCurrentVersion(1L)
+                .withLastVersion(0L)
+                .withDeletedAt(0L)
+                .build()));
+
+    assertIterableEquals(
+        List.of("dev", "ops"), 
idpUserGroupRelMapper.selectGroupNamesByUsername("alice"));
+    assertIterableEquals(
+        List.of(), 
idpUserGroupRelMapper.selectGroupNamesByUsername("missing-user"));
+  }
+
+  @ParameterizedTest
+  @MethodSource("storageProvider")
+  void testSelectUsernamesByGroupName(String type) throws IOException {
+    init(type);
+    idpGroupMetaMapper.insertIdpGroup(
+        IdpGroupPO.builder()
+            .withGroupId(1L)
+            .withGroupName("dev")
+            .withCurrentVersion(1L)
+            .withLastVersion(0L)
+            .withDeletedAt(0L)
+            .build());
+    idpUserMetaMapper.insertIdpUser(
+        IdpUserPO.builder()
+            .withUserId(1L)
+            .withUserName("alice")
+            .withPasswordHash("hash-a")
+            .withCurrentVersion(1L)
+            .withLastVersion(0L)
+            .withDeletedAt(0L)
+            .build());
+    idpUserMetaMapper.insertIdpUser(
+        IdpUserPO.builder()
+            .withUserId(2L)
+            .withUserName("bob")
+            .withPasswordHash("hash-b")
+            .withCurrentVersion(1L)
+            .withLastVersion(0L)
+            .withDeletedAt(0L)
+            .build());
+    idpUserGroupRelMapper.batchInsertRelations(
+        List.of(
+            IdpUserGroupRelPO.builder()
+                .withId(100L)
+                .withUserId(1L)
+                .withGroupId(1L)
+                .withCurrentVersion(1L)
+                .withLastVersion(0L)
+                .withDeletedAt(0L)
+                .build(),
+            IdpUserGroupRelPO.builder()
+                .withId(101L)
+                .withUserId(2L)
+                .withGroupId(1L)
+                .withCurrentVersion(1L)
+                .withLastVersion(0L)
+                .withDeletedAt(0L)
+                .build()));
+
+    assertIterableEquals(
+        List.of("alice", "bob"), 
idpUserGroupRelMapper.selectUsernamesByGroupName("dev"));
+    assertIterableEquals(
+        List.of(), 
idpUserGroupRelMapper.selectUsernamesByGroupName("missing-group"));
+  }
+
+  @ParameterizedTest
+  @MethodSource("storageProvider")
+  void testSoftDeleteIdpUserGroups(String type) throws IOException {
+    init(type);
+    idpGroupMetaMapper.insertIdpGroup(
+        IdpGroupPO.builder()
+            .withGroupId(1L)
+            .withGroupName("dev")
+            .withCurrentVersion(1L)
+            .withLastVersion(0L)
+            .withDeletedAt(0L)
+            .build());
+    idpUserMetaMapper.insertIdpUser(
+        IdpUserPO.builder()
+            .withUserId(1L)
+            .withUserName("alice")
+            .withPasswordHash("hash-a")
+            .withCurrentVersion(1L)
+            .withLastVersion(0L)
+            .withDeletedAt(0L)
+            .build());
+    idpUserMetaMapper.insertIdpUser(
+        IdpUserPO.builder()
+            .withUserId(2L)
+            .withUserName("bob")
+            .withPasswordHash("hash-b")
+            .withCurrentVersion(1L)
+            .withLastVersion(0L)
+            .withDeletedAt(0L)
+            .build());
+    idpUserGroupRelMapper.batchInsertRelations(
+        List.of(
+            IdpUserGroupRelPO.builder()
+                .withId(100L)
+                .withUserId(1L)
+                .withGroupId(1L)
+                .withCurrentVersion(1L)
+                .withLastVersion(0L)
+                .withDeletedAt(0L)
+                .build(),
+            IdpUserGroupRelPO.builder()
+                .withId(101L)
+                .withUserId(2L)
+                .withGroupId(1L)
+                .withCurrentVersion(1L)
+                .withLastVersion(0L)
+                .withDeletedAt(0L)
+                .build()));
+
+    assertEquals(1, idpUserGroupRelMapper.softDeleteRelations("dev", 
List.of("alice")));
+    assertIterableEquals(List.of("bob"), 
idpUserGroupRelMapper.selectUsernamesByGroupName("dev"));
+    assertEquals(0, idpUserGroupRelMapper.softDeleteRelations("dev", 
List.of("alice")));
+    assertEquals(1, idpUserGroupRelMapper.softDeleteRelations("dev", 
List.of()));
+    assertIterableEquals(List.of(), 
idpUserGroupRelMapper.selectUsernamesByGroupName("dev"));
+    assertEquals(
+        2, 
idpUserGroupRelMapper.deleteIdpUserGroupRelMetasByLegacyTimeline(Long.MAX_VALUE,
 10));
+  }
+
+  @ParameterizedTest
+  @MethodSource("storageProvider")
+  void testSoftDeleteRelationsByUsername(String type) throws IOException {
+    init(type);
+    idpGroupMetaMapper.insertIdpGroup(
+        IdpGroupPO.builder()
+            .withGroupId(1L)
+            .withGroupName("dev")
+            .withCurrentVersion(1L)
+            .withLastVersion(0L)
+            .withDeletedAt(0L)
+            .build());
+    idpUserMetaMapper.insertIdpUser(
+        IdpUserPO.builder()
+            .withUserId(1L)
+            .withUserName("alice")
+            .withPasswordHash("hash-a")
+            .withCurrentVersion(1L)
+            .withLastVersion(0L)
+            .withDeletedAt(0L)
+            .build());
+    idpUserMetaMapper.insertIdpUser(
+        IdpUserPO.builder()
+            .withUserId(2L)
+            .withUserName("bob")
+            .withPasswordHash("hash-b")
+            .withCurrentVersion(1L)
+            .withLastVersion(0L)
+            .withDeletedAt(0L)
+            .build());
+    idpUserGroupRelMapper.batchInsertRelations(
+        List.of(
+            IdpUserGroupRelPO.builder()
+                .withId(100L)
+                .withUserId(1L)
+                .withGroupId(1L)
+                .withCurrentVersion(1L)
+                .withLastVersion(0L)
+                .withDeletedAt(0L)
+                .build(),
+            IdpUserGroupRelPO.builder()
+                .withId(101L)
+                .withUserId(2L)
+                .withGroupId(1L)
+                .withCurrentVersion(1L)
+                .withLastVersion(0L)
+                .withDeletedAt(0L)
+                .build()));
+
+    assertEquals(1, 
idpUserGroupRelMapper.softDeleteRelationsByUsername("bob"));
+    assertIterableEquals(List.of("alice"), 
idpUserGroupRelMapper.selectUsernamesByGroupName("dev"));
+    assertEquals(0, 
idpUserGroupRelMapper.softDeleteRelationsByUsername("bob"));
+  }
+
+  @ParameterizedTest
+  @MethodSource("storageProvider")
+  void testSoftDeleteRelationsByGroupName(String type) throws IOException {
+    init(type);
+    idpGroupMetaMapper.insertIdpGroup(
+        IdpGroupPO.builder()
+            .withGroupId(2L)
+            .withGroupName("ops")
+            .withCurrentVersion(1L)
+            .withLastVersion(0L)
+            .withDeletedAt(0L)
+            .build());
+    idpUserMetaMapper.insertIdpUser(
+        IdpUserPO.builder()
+            .withUserId(3L)
+            .withUserName("carol")
+            .withPasswordHash("hash-c")
+            .withCurrentVersion(1L)
+            .withLastVersion(0L)
+            .withDeletedAt(0L)
+            .build());
+    idpUserGroupRelMapper.batchInsertRelations(
+        List.of(
+            IdpUserGroupRelPO.builder()
+                .withId(102L)
+                .withUserId(3L)
+                .withGroupId(2L)
+                .withCurrentVersion(1L)
+                .withLastVersion(0L)
+                .withDeletedAt(0L)
+                .build()));
+
+    assertEquals(1, 
idpUserGroupRelMapper.softDeleteRelationsByGroupName("ops"));
+    assertIterableEquals(List.of(), 
idpUserGroupRelMapper.selectUsernamesByGroupName("ops"));
+    assertEquals(0, 
idpUserGroupRelMapper.softDeleteRelationsByGroupName("ops"));
+  }
+
+  @ParameterizedTest
+  @MethodSource("storageProvider")
+  void testDeleteIdpUserGroupRelMetasByLegacyTimeline(String type) throws 
IOException {
+    init(type);
+    idpGroupMetaMapper.insertIdpGroup(
+        IdpGroupPO.builder()
+            .withGroupId(1L)
+            .withGroupName("dev")
+            .withCurrentVersion(1L)
+            .withLastVersion(0L)
+            .withDeletedAt(0L)
+            .build());
+    idpGroupMetaMapper.insertIdpGroup(
+        IdpGroupPO.builder()
+            .withGroupId(2L)
+            .withGroupName("ops")
+            .withCurrentVersion(1L)
+            .withLastVersion(0L)
+            .withDeletedAt(0L)
+            .build());
+    idpGroupMetaMapper.insertIdpGroup(
+        IdpGroupPO.builder()
+            .withGroupId(3L)
+            .withGroupName("qa")
+            .withCurrentVersion(1L)
+            .withLastVersion(0L)
+            .withDeletedAt(0L)
+            .build());
+    idpUserMetaMapper.insertIdpUser(
+        IdpUserPO.builder()
+            .withUserId(1L)
+            .withUserName("legacy-user")
+            .withPasswordHash("hash")
+            .withCurrentVersion(1L)
+            .withLastVersion(0L)
+            .withDeletedAt(0L)
+            .build());
+    idpUserMetaMapper.insertIdpUser(
+        IdpUserPO.builder()
+            .withUserId(2L)
+            .withUserName("new-user")
+            .withPasswordHash("hash")
+            .withCurrentVersion(1L)
+            .withLastVersion(0L)
+            .withDeletedAt(0L)
+            .build());
+    idpUserMetaMapper.insertIdpUser(
+        IdpUserPO.builder()
+            .withUserId(3L)
+            .withUserName("active-user")
+            .withPasswordHash("hash")
+            .withCurrentVersion(1L)
+            .withLastVersion(0L)
+            .withDeletedAt(0L)
+            .build());
+    idpUserGroupRelMapper.batchInsertRelations(
+        List.of(
+            IdpUserGroupRelPO.builder()
+                .withId(100L)
+                .withUserId(1L)
+                .withGroupId(1L)
+                .withCurrentVersion(1L)
+                .withLastVersion(0L)
+                .withDeletedAt(10L)
+                .build()));
+    idpUserGroupRelMapper.batchInsertRelations(
+        List.of(
+            IdpUserGroupRelPO.builder()
+                .withId(101L)
+                .withUserId(2L)
+                .withGroupId(2L)
+                .withCurrentVersion(1L)
+                .withLastVersion(0L)
+                .withDeletedAt(30L)
+                .build()));
+    idpUserGroupRelMapper.batchInsertRelations(
+        List.of(
+            IdpUserGroupRelPO.builder()
+                .withId(102L)
+                .withUserId(3L)
+                .withGroupId(3L)
+                .withCurrentVersion(1L)
+                .withLastVersion(0L)
+                .withDeletedAt(0L)
+                .build()));
+
+    assertEquals(1, 
idpUserGroupRelMapper.deleteIdpUserGroupRelMetasByLegacyTimeline(20L, 10));
+    assertEquals(0, 
idpUserGroupRelMapper.deleteIdpUserGroupRelMetasByLegacyTimeline(20L, 10));
+    assertEquals(1, 
idpUserGroupRelMapper.deleteIdpUserGroupRelMetasByLegacyTimeline(40L, 10));
+    assertEquals(
+        0, 
idpUserGroupRelMapper.deleteIdpUserGroupRelMetasByLegacyTimeline(Long.MAX_VALUE,
 10));
+    assertIterableEquals(
+        List.of("active-user"), 
idpUserGroupRelMapper.selectUsernamesByGroupName("qa"));
+    assertIterableEquals(List.of(), 
idpUserGroupRelMapper.selectUsernamesByGroupName("dev"));
+    assertIterableEquals(List.of(), 
idpUserGroupRelMapper.selectUsernamesByGroupName("ops"));
+  }
+}
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 f2d87f4341..d7901ad747 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
@@ -23,13 +23,11 @@ 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.Comparator;
 import java.util.List;
 import org.apache.gravitino.idp.storage.po.IdpUserPO;
-import org.apache.gravitino.storage.relational.session.SqlSessionFactoryHelper;
 import org.apache.ibatis.exceptions.PersistenceException;
 import org.junit.jupiter.api.Tag;
 import org.junit.jupiter.params.ParameterizedTest;
@@ -229,61 +227,4 @@ class TestIdpUserMetaStorage extends 
AbstractIdpMetaStorageTest {
     assertNull(idpUserMetaMapper.selectIdpUser("legacy-user"));
     assertNull(idpUserMetaMapper.selectIdpUser("new-user"));
   }
-
-  @ParameterizedTest
-  @MethodSource("storageProvider")
-  void testRestart(String type) throws IOException {
-    init(type);
-    IdpUserPO expectedActiveUser =
-        IdpUserPO.builder()
-            .withUserId(1L)
-            .withUserName("alice")
-            .withPasswordHash("hash-a")
-            .withCurrentVersion(3L)
-            .withLastVersion(2L)
-            .withDeletedAt(0L)
-            .build();
-    idpUserMetaMapper.insertIdpUser(expectedActiveUser);
-    idpUserMetaMapper.insertIdpUser(
-        IdpUserPO.builder()
-            .withUserId(2L)
-            .withUserName("bob")
-            .withPasswordHash("hash-b")
-            .withCurrentVersion(1L)
-            .withLastVersion(0L)
-            .withDeletedAt(10L)
-            .build());
-    assertEquals(1, idpUserMetaMapper.updateIdpUserPassword(1L, "hash-a-2"));
-
-    expectedActiveUser =
-        IdpUserPO.builder()
-            .withUserId(expectedActiveUser.getUserId())
-            .withUserName(expectedActiveUser.getUserName())
-            .withPasswordHash("hash-a-2")
-            .withCurrentVersion(expectedActiveUser.getCurrentVersion())
-            .withLastVersion(expectedActiveUser.getLastVersion())
-            .withDeletedAt(expectedActiveUser.getDeletedAt())
-            .build();
-
-    assertPersistedUsers(expectedActiveUser);
-
-    restartBackend();
-
-    assertPersistedUsers(expectedActiveUser);
-  }
-
-  private void assertPersistedUsers(IdpUserPO expectedActiveUser) {
-    assertTrue(
-        SqlSessionFactoryHelper.getInstance()
-            .getSqlSessionFactory()
-            .getConfiguration()
-            .hasMapper(IdpUserMetaMapper.class));
-
-    assertEquals(expectedActiveUser, idpUserMetaMapper.selectIdpUser("alice"));
-    assertNull(idpUserMetaMapper.selectIdpUser("bob"));
-
-    List<IdpUserPO> users = idpUserMetaMapper.selectIdpUsers(List.of("bob", 
"alice"));
-    users.sort(Comparator.comparing(IdpUserPO::getUserId));
-    assertIterableEquals(List.of(expectedActiveUser), users);
-  }
 }
diff --git 
a/plugins/idp-basic/src/test/java/org/apache/gravitino/idp/storage/mapper/provider/base/TestIdpGroupMetaBaseSQLProvider.java
 
b/plugins/idp-basic/src/test/java/org/apache/gravitino/idp/storage/mapper/provider/base/TestIdpGroupMetaBaseSQLProvider.java
deleted file mode 100644
index 2b98776e18..0000000000
--- 
a/plugins/idp-basic/src/test/java/org/apache/gravitino/idp/storage/mapper/provider/base/TestIdpGroupMetaBaseSQLProvider.java
+++ /dev/null
@@ -1,163 +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.mapper.provider.base;
-
-import java.util.Arrays;
-import java.util.Collections;
-import java.util.HashMap;
-import java.util.Map;
-import org.apache.gravitino.idp.storage.po.IdpGroupPO;
-import org.apache.ibatis.builder.BuilderException;
-import org.apache.ibatis.mapping.BoundSql;
-import org.apache.ibatis.mapping.SqlSource;
-import org.apache.ibatis.scripting.xmltags.XMLLanguageDriver;
-import org.apache.ibatis.session.Configuration;
-import org.junit.jupiter.api.Assertions;
-import org.junit.jupiter.api.Test;
-
-public class TestIdpGroupMetaBaseSQLProvider {
-
-  protected IdpGroupMetaBaseSQLProvider createProvider() {
-    return new IdpGroupMetaBaseSQLProvider() {
-      @Override
-      protected String currentTimeMillisExpression() {
-        return "CURRENT_TIME_MILLIS()";
-      }
-    };
-  }
-
-  protected String expectedDeleteAtClause() {
-    return "deleted_at = CURRENT_TIME_MILLIS()";
-  }
-
-  protected String expectedDeleteIdpGroupMetasByLegacyTimelineSql() {
-    return "DELETE FROM idp_group_meta WHERE deleted_at > 0 AND deleted_at < 
#{legacyTimeline}"
-        + " LIMIT #{limit}";
-  }
-
-  @Test
-  void testSelectIdpGroup() {
-    String normalizedSql = 
createProvider().selectIdpGroup("group").replaceAll("\\s+", " ").trim();
-
-    Assertions.assertTrue(normalizedSql.contains("SELECT group_id as 
groupId"));
-    Assertions.assertTrue(normalizedSql.contains("FROM idp_group_meta"));
-    Assertions.assertTrue(
-        normalizedSql.contains("WHERE group_name = #{groupName} AND deleted_at 
= 0"));
-  }
-
-  @Test
-  void testSelectIdpGroups() {
-    String script = createProvider().selectIdpGroups(Arrays.asList("dev", 
"ops"));
-    Map<String, Object> params = new HashMap<>();
-    params.put("groupNames", Arrays.asList("dev", "ops"));
-
-    String normalizedSql = renderScript(script, params);
-
-    Assertions.assertTrue(normalizedSql.contains("SELECT group_id as 
groupId"));
-    Assertions.assertTrue(normalizedSql.contains("FROM idp_group_meta"));
-    Assertions.assertTrue(
-        normalizedSql.matches(".*group_name 
IN\\s*\\(\\s*\\?\\s*,\\s*\\?\\s*\\).*"));
-    Assertions.assertFalse(normalizedSql.matches(".*\\b1\\s*=\\s*0\\b.*"));
-  }
-
-  @Test
-  void testSelectIdpGroupsWithEmptyGroupNames() {
-    String script = createProvider().selectIdpGroups(Collections.emptyList());
-    Map<String, Object> params = new HashMap<>();
-    params.put("groupNames", Collections.emptyList());
-
-    String normalizedSql = renderScript(script, params);
-
-    Assertions.assertFalse(
-        normalizedSql.matches(".*\\bIN\\s*\\(\\s*\\).*"),
-        "Empty groupNames should not generate invalid SQL IN (...) with no 
values");
-    Assertions.assertFalse(normalizedSql.matches(".*\\b1\\s*=\\s*0\\b.*"));
-    Assertions.assertEquals(
-        "SELECT group_id as groupId, group_name as groupName, current_version 
as"
-            + " currentVersion, last_version as lastVersion, deleted_at as 
deletedAt FROM"
-            + " idp_group_meta WHERE deleted_at = 0",
-        normalizedSql);
-  }
-
-  @Test
-  void testSelectIdpGroupsWithNullGroupNames() {
-    String script = createProvider().selectIdpGroups(null);
-    Map<String, Object> params = new HashMap<>();
-    params.put("groupNames", null);
-
-    Assertions.assertThrows(BuilderException.class, () -> renderScript(script, 
params));
-  }
-
-  @Test
-  void testInsertIdpGroup() {
-    String normalizedSql =
-        createProvider().insertIdpGroup(newGroupPO()).replaceAll("\\s+", " 
").trim();
-
-    Assertions.assertTrue(normalizedSql.contains("INSERT INTO 
idp_group_meta"));
-    Assertions.assertTrue(
-        normalizedSql.contains(
-            "(group_id, group_name, current_version, last_version, 
deleted_at)"));
-    Assertions.assertTrue(
-        normalizedSql.contains(
-            "VALUES ( #{groupMeta.groupId}, #{groupMeta.groupName}, 
#{groupMeta.currentVersion},"
-                + " #{groupMeta.lastVersion}, #{groupMeta.deletedAt} )"));
-  }
-
-  @Test
-  void testSoftDeleteIdpGroup() {
-    String normalizedSql = 
createProvider().softDeleteIdpGroup(1L).replaceAll("\\s+", " ").trim();
-
-    Assertions.assertTrue(normalizedSql.contains("UPDATE idp_group_meta"));
-    Assertions.assertTrue(normalizedSql.contains(expectedDeleteAtClause()));
-    Assertions.assertTrue(normalizedSql.contains("WHERE group_id = #{groupId} 
AND deleted_at = 0"));
-  }
-
-  @Test
-  void testDeleteIdpGroupMetasByLegacyTimeline() {
-    String normalizedSql =
-        createProvider().deleteIdpGroupMetasByLegacyTimeline(1L, 
2).replaceAll("\\s+", " ").trim();
-
-    Assertions.assertEquals(expectedDeleteIdpGroupMetasByLegacyTimelineSql(), 
normalizedSql);
-  }
-
-  @Test
-  void testCurrentTimeMillisExpression() {
-    Assertions.assertEquals(
-        "(UNIX_TIMESTAMP() * 1000.0)",
-        new IdpGroupMetaBaseSQLProvider().currentTimeMillisExpression());
-  }
-
-  private IdpGroupPO newGroupPO() {
-    return IdpGroupPO.builder()
-        .withGroupId(1L)
-        .withGroupName("group")
-        .withCurrentVersion(1L)
-        .withLastVersion(1L)
-        .withDeletedAt(0L)
-        .build();
-  }
-
-  private String renderScript(String script, Map<String, Object> params) {
-    SqlSource sqlSource =
-        new XMLLanguageDriver().createSqlSource(new Configuration(), script, 
Map.class);
-    BoundSql boundSql = sqlSource.getBoundSql(params);
-    return boundSql.getSql().replaceAll("\\s+", " ").trim();
-  }
-}
diff --git 
a/plugins/idp-basic/src/test/java/org/apache/gravitino/idp/storage/mapper/provider/base/TestIdpUserMetaBaseSQLProvider.java
 
b/plugins/idp-basic/src/test/java/org/apache/gravitino/idp/storage/mapper/provider/base/TestIdpUserMetaBaseSQLProvider.java
deleted file mode 100644
index b14e015428..0000000000
--- 
a/plugins/idp-basic/src/test/java/org/apache/gravitino/idp/storage/mapper/provider/base/TestIdpUserMetaBaseSQLProvider.java
+++ /dev/null
@@ -1,169 +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.mapper.provider.base;
-
-import java.util.Arrays;
-import java.util.Collections;
-import java.util.HashMap;
-import java.util.Map;
-import org.apache.gravitino.idp.storage.po.IdpUserPO;
-import org.apache.ibatis.builder.BuilderException;
-import org.apache.ibatis.mapping.BoundSql;
-import org.apache.ibatis.mapping.SqlSource;
-import org.apache.ibatis.scripting.xmltags.XMLLanguageDriver;
-import org.apache.ibatis.session.Configuration;
-import org.junit.jupiter.api.Assertions;
-import org.junit.jupiter.api.Test;
-
-public class TestIdpUserMetaBaseSQLProvider {
-
-  @Test
-  void testSelectIdpUser() {
-    String normalizedSql = 
createProvider().selectIdpUser("tom").replaceAll("\\s+", " ").trim();
-
-    Assertions.assertTrue(normalizedSql.contains("SELECT user_id as userId"));
-    Assertions.assertTrue(normalizedSql.contains("FROM idp_user_meta"));
-    Assertions.assertTrue(
-        normalizedSql.contains("WHERE user_name = #{username} AND deleted_at = 
0"));
-  }
-
-  @Test
-  void testSelectIdpUsers() {
-    String script = createProvider().selectIdpUsers(Arrays.asList("tom", 
"jerry"));
-    Map<String, Object> params = new HashMap<>();
-    params.put("usernames", Arrays.asList("tom", "jerry"));
-
-    String normalizedSql = renderScript(script, params);
-
-    Assertions.assertTrue(normalizedSql.contains("SELECT user_id as userId"));
-    Assertions.assertTrue(normalizedSql.contains("FROM idp_user_meta"));
-    Assertions.assertTrue(normalizedSql.matches(".*user_name IN \\( \\? , \\? 
\\).*"));
-    Assertions.assertFalse(normalizedSql.matches(".*\\b1\\s*=\\s*0\\b.*"));
-  }
-
-  @Test
-  void testSelectIdpUsersWithEmptyUserNames() {
-    String script = createProvider().selectIdpUsers(Collections.emptyList());
-    Map<String, Object> params = new HashMap<>();
-    params.put("usernames", Collections.emptyList());
-
-    String normalizedSql = renderScript(script, params);
-
-    Assertions.assertFalse(
-        normalizedSql.matches(".*\\bIN\\s*\\(\\s*\\).*"),
-        "Empty userNames should not generate invalid SQL IN (...) with no 
values");
-    Assertions.assertFalse(normalizedSql.matches(".*\\b1\\s*=\\s*0\\b.*"));
-    Assertions.assertEquals(
-        "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 idp_user_meta WHERE deleted_at = 
0",
-        normalizedSql);
-  }
-
-  @Test
-  void testSelectIdpUsersWithNullUserNames() {
-    String script = createProvider().selectIdpUsers(null);
-    Map<String, Object> params = new HashMap<>();
-    params.put("usernames", null);
-
-    Assertions.assertThrows(BuilderException.class, () -> renderScript(script, 
params));
-  }
-
-  @Test
-  void testInsertIdpUser() {
-    String normalizedSql =
-        createProvider().insertIdpUser(newUserPO()).replaceAll("\\s+", " 
").trim();
-
-    Assertions.assertTrue(normalizedSql.contains("INSERT INTO idp_user_meta"));
-    Assertions.assertTrue(
-        normalizedSql.contains(
-            "(user_id, user_name, password_hash, current_version, 
last_version, deleted_at)"));
-    Assertions.assertTrue(
-        normalizedSql.contains(
-            "VALUES ( #{userMeta.userId}, #{userMeta.userName}, 
#{userMeta.passwordHash},"
-                + " #{userMeta.currentVersion}, #{userMeta.lastVersion},"
-                + " #{userMeta.deletedAt} )"));
-  }
-
-  @Test
-  void testUpdateIdpUserPassword() {
-    String normalizedSql =
-        createProvider().updateIdpUserPassword(1L, "hash").replaceAll("\\s+", 
" ").trim();
-
-    Assertions.assertTrue(normalizedSql.contains("UPDATE idp_user_meta"));
-    Assertions.assertTrue(normalizedSql.contains("SET password_hash = 
#{passwordHash}"));
-    Assertions.assertTrue(normalizedSql.contains("WHERE user_id = #{userId}"));
-    Assertions.assertTrue(normalizedSql.contains("AND deleted_at = 0"));
-  }
-
-  @Test
-  void testSoftDeleteIdpUser() {
-    String normalizedSql = 
createProvider().softDeleteIdpUser(1L).replaceAll("\\s+", " ").trim();
-
-    Assertions.assertTrue(normalizedSql.contains("UPDATE idp_user_meta"));
-    Assertions.assertTrue(normalizedSql.contains("CURRENT_TIME_MILLIS()"));
-    Assertions.assertTrue(normalizedSql.contains("WHERE user_id = #{userId} 
AND deleted_at = 0"));
-  }
-
-  @Test
-  void testDeleteIdpUserMetasByLegacyTimeline() {
-    String normalizedSql =
-        createProvider().deleteIdpUserMetasByLegacyTimeline(1L, 
2).replaceAll("\\s+", " ").trim();
-
-    Assertions.assertEquals(
-        "DELETE FROM idp_user_meta WHERE deleted_at > 0 AND deleted_at < 
#{legacyTimeline}"
-            + " LIMIT #{limit}",
-        normalizedSql);
-  }
-
-  @Test
-  void testCurrentTimeMillisExpression() {
-    Assertions.assertEquals(
-        "(UNIX_TIMESTAMP() * 1000.0)",
-        new IdpUserMetaBaseSQLProvider().currentTimeMillisExpression());
-  }
-
-  private IdpUserMetaBaseSQLProvider createProvider() {
-    return new IdpUserMetaBaseSQLProvider() {
-      @Override
-      protected String currentTimeMillisExpression() {
-        return "CURRENT_TIME_MILLIS()";
-      }
-    };
-  }
-
-  private String renderScript(String script, Map<String, Object> params) {
-    SqlSource sqlSource =
-        new XMLLanguageDriver().createSqlSource(new Configuration(), script, 
Map.class);
-    BoundSql boundSql = sqlSource.getBoundSql(params);
-    return boundSql.getSql().replaceAll("\\s+", " ").trim();
-  }
-
-  private IdpUserPO newUserPO() {
-    return IdpUserPO.builder()
-        .withUserId(1L)
-        .withUserName("tom")
-        .withPasswordHash("hash")
-        .withCurrentVersion(1L)
-        .withLastVersion(1L)
-        .withDeletedAt(0L)
-        .build();
-  }
-}
diff --git 
a/plugins/idp-basic/src/test/java/org/apache/gravitino/idp/storage/mapper/provider/h2/TestIdpUserMetaH2Provider.java
 
b/plugins/idp-basic/src/test/java/org/apache/gravitino/idp/storage/mapper/provider/h2/TestIdpUserMetaH2Provider.java
deleted file mode 100644
index ab2c5f2ea7..0000000000
--- 
a/plugins/idp-basic/src/test/java/org/apache/gravitino/idp/storage/mapper/provider/h2/TestIdpUserMetaH2Provider.java
+++ /dev/null
@@ -1,35 +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.mapper.provider.h2;
-
-import org.junit.jupiter.api.Assertions;
-import org.junit.jupiter.api.Test;
-
-class TestIdpUserMetaH2Provider {
-
-  @Test
-  void testCurrentTimeMillisExpression() {
-    IdpUserMetaH2Provider provider = new IdpUserMetaH2Provider();
-
-    Assertions.assertEquals(
-        "DATEDIFF('MILLISECOND', TIMESTAMP '1970-01-01 00:00:00', 
CURRENT_TIMESTAMP())",
-        provider.currentTimeMillisExpression());
-  }
-}
diff --git 
a/plugins/idp-basic/src/test/java/org/apache/gravitino/idp/storage/mapper/provider/postgresql/TestIdpGroupMetaPostgreSQLProvider.java
 
b/plugins/idp-basic/src/test/java/org/apache/gravitino/idp/storage/mapper/provider/postgresql/TestIdpGroupMetaPostgreSQLProvider.java
deleted file mode 100644
index bdc1b16987..0000000000
--- 
a/plugins/idp-basic/src/test/java/org/apache/gravitino/idp/storage/mapper/provider/postgresql/TestIdpGroupMetaPostgreSQLProvider.java
+++ /dev/null
@@ -1,45 +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.mapper.provider.postgresql;
-
-import org.junit.jupiter.api.Assertions;
-import org.junit.jupiter.api.Test;
-
-public class TestIdpGroupMetaPostgreSQLProvider {
-
-  @Test
-  void testCurrentTimeMillisExpression() {
-    IdpGroupMetaPostgreSQLProvider provider = new 
IdpGroupMetaPostgreSQLProvider();
-
-    Assertions.assertEquals(
-        "CAST(EXTRACT(EPOCH FROM CURRENT_TIMESTAMP) * 1000 AS BIGINT)",
-        provider.currentTimeMillisExpression());
-  }
-
-  @Test
-  void testDeleteIdpGroupMetasByLegacyTimeline() {
-    IdpGroupMetaPostgreSQLProvider provider = new 
IdpGroupMetaPostgreSQLProvider();
-
-    Assertions.assertEquals(
-        "DELETE FROM idp_group_meta WHERE group_id IN (SELECT group_id FROM 
idp_group_meta"
-            + " WHERE deleted_at > 0 AND deleted_at < #{legacyTimeline} LIMIT 
#{limit})",
-        provider.deleteIdpGroupMetasByLegacyTimeline(1L, 2));
-  }
-}
diff --git 
a/plugins/idp-basic/src/test/java/org/apache/gravitino/idp/storage/mapper/provider/postgresql/TestIdpUserMetaPostgreSQLProvider.java
 
b/plugins/idp-basic/src/test/java/org/apache/gravitino/idp/storage/mapper/provider/postgresql/TestIdpUserMetaPostgreSQLProvider.java
deleted file mode 100644
index 21a16b074d..0000000000
--- 
a/plugins/idp-basic/src/test/java/org/apache/gravitino/idp/storage/mapper/provider/postgresql/TestIdpUserMetaPostgreSQLProvider.java
+++ /dev/null
@@ -1,45 +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.mapper.provider.postgresql;
-
-import org.junit.jupiter.api.Assertions;
-import org.junit.jupiter.api.Test;
-
-public class TestIdpUserMetaPostgreSQLProvider {
-
-  @Test
-  void testCurrentTimeMillisExpression() {
-    IdpUserMetaPostgreSQLProvider provider = new 
IdpUserMetaPostgreSQLProvider();
-
-    Assertions.assertEquals(
-        "CAST(EXTRACT(EPOCH FROM CURRENT_TIMESTAMP) * 1000 AS BIGINT)",
-        provider.currentTimeMillisExpression());
-  }
-
-  @Test
-  void testDeleteIdpUserMetasByLegacyTimeline() {
-    IdpUserMetaPostgreSQLProvider provider = new 
IdpUserMetaPostgreSQLProvider();
-
-    Assertions.assertEquals(
-        "DELETE FROM idp_user_meta WHERE user_id IN (SELECT user_id FROM 
idp_user_meta"
-            + " WHERE deleted_at > 0 AND deleted_at < #{legacyTimeline} LIMIT 
#{limit})",
-        provider.deleteIdpUserMetasByLegacyTimeline(1L, 2));
-  }
-}
diff --git 
a/plugins/idp-basic/src/test/java/org/apache/gravitino/idp/storage/po/TestIdpUserGroupRelPO.java
 
b/plugins/idp-basic/src/test/java/org/apache/gravitino/idp/storage/po/TestIdpUserGroupRelPO.java
new file mode 100644
index 0000000000..4a5c34c1a7
--- /dev/null
+++ 
b/plugins/idp-basic/src/test/java/org/apache/gravitino/idp/storage/po/TestIdpUserGroupRelPO.java
@@ -0,0 +1,89 @@
+/*
+ * 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.po;
+
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+
+public class TestIdpUserGroupRelPO {
+
+  @Test
+  public void testIdpUserGroupRelPOBuilder() {
+    IdpUserGroupRelPO relPO =
+        IdpUserGroupRelPO.builder()
+            .withId(1L)
+            .withUserId(20L)
+            .withGroupId(10L)
+            .withCurrentVersion(1L)
+            .withLastVersion(0L)
+            .withDeletedAt(0L)
+            .build();
+
+    Assertions.assertEquals(1L, relPO.getId());
+    Assertions.assertEquals(20L, relPO.getUserId());
+    Assertions.assertEquals(10L, relPO.getGroupId());
+    Assertions.assertEquals(1L, relPO.getCurrentVersion());
+    Assertions.assertEquals(0L, relPO.getLastVersion());
+    Assertions.assertEquals(0L, relPO.getDeletedAt());
+  }
+
+  @Test
+  public void testEqualsAndHashCode() {
+    IdpUserGroupRelPO relPO1 =
+        IdpUserGroupRelPO.builder()
+            .withId(1L)
+            .withUserId(20L)
+            .withGroupId(10L)
+            .withCurrentVersion(1L)
+            .withLastVersion(0L)
+            .withDeletedAt(0L)
+            .build();
+
+    IdpUserGroupRelPO relPO2 =
+        IdpUserGroupRelPO.builder()
+            .withId(1L)
+            .withUserId(20L)
+            .withGroupId(10L)
+            .withCurrentVersion(1L)
+            .withLastVersion(0L)
+            .withDeletedAt(0L)
+            .build();
+
+    Assertions.assertEquals(relPO1, relPO2);
+    Assertions.assertEquals(relPO1.hashCode(), relPO2.hashCode());
+  }
+
+  @Test
+  public void testBuilderReuseDoesNotMutateBuiltObject() {
+    var builder =
+        IdpUserGroupRelPO.builder()
+            .withId(1L)
+            .withUserId(20L)
+            .withGroupId(10L)
+            .withCurrentVersion(1L)
+            .withLastVersion(0L)
+            .withDeletedAt(0L);
+
+    IdpUserGroupRelPO firstRelation = builder.build();
+    IdpUserGroupRelPO secondRelation = builder.withUserId(21L).build();
+
+    Assertions.assertEquals(20L, firstRelation.getUserId());
+    Assertions.assertEquals(21L, secondRelation.getUserId());
+  }
+}
diff --git a/scripts/h2/schema-1.3.0-h2.sql b/scripts/h2/schema-1.3.0-h2.sql
index 5b11e5f882..fc6f026ec3 100644
--- a/scripts/h2/schema-1.3.0-h2.sql
+++ b/scripts/h2/schema-1.3.0-h2.sql
@@ -271,17 +271,17 @@ CREATE TABLE IF NOT EXISTS `idp_group_meta` (
     CONSTRAINT `uk_ign_del` UNIQUE (`group_name`, `deleted_at`)
 ) ENGINE=InnoDB;
 
-CREATE TABLE IF NOT EXISTS `idp_group_user_rel` (
+CREATE TABLE IF NOT EXISTS `idp_user_group_rel` (
     `id` BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT COMMENT 'auto increment 
id',
-    `group_id` BIGINT(20) UNSIGNED NOT NULL COMMENT 'idp group id',
     `user_id` BIGINT(20) UNSIGNED NOT NULL COMMENT 'idp user id',
+    `group_id` BIGINT(20) UNSIGNED NOT NULL COMMENT 'idp group id',
     `current_version` INT UNSIGNED NOT NULL DEFAULT 1 COMMENT 'idp relation 
current version',
     `last_version` INT UNSIGNED NOT NULL DEFAULT 1 COMMENT 'idp relation last 
version',
     `deleted_at` BIGINT(20) UNSIGNED NOT NULL DEFAULT 0 COMMENT 'idp relation 
deleted at',
     PRIMARY KEY (`id`),
-    CONSTRAINT `uk_igiu_del` UNIQUE (`group_id`, `user_id`, `deleted_at`),
-    KEY `idx_iug_gid` (`group_id`),
-    KEY `idx_iug_uid` (`user_id`)
+    CONSTRAINT `uk_iuig_del` UNIQUE (`user_id`, `group_id`, `deleted_at`),
+    KEY `idx_iuig_uid` (`user_id`),
+    KEY `idx_iuig_gid` (`group_id`)
 ) ENGINE=InnoDB;
 
 CREATE TABLE IF NOT EXISTS `tag_meta` (
diff --git a/scripts/h2/upgrade-1.2.0-to-1.3.0-h2.sql 
b/scripts/h2/upgrade-1.2.0-to-1.3.0-h2.sql
index c343bbc447..5366a16436 100644
--- a/scripts/h2/upgrade-1.2.0-to-1.3.0-h2.sql
+++ b/scripts/h2/upgrade-1.2.0-to-1.3.0-h2.sql
@@ -88,15 +88,15 @@ CREATE TABLE IF NOT EXISTS `idp_group_meta` (
     CONSTRAINT `uk_ign_del` UNIQUE (`group_name`, `deleted_at`)
 ) ENGINE=InnoDB;
 
-CREATE TABLE IF NOT EXISTS `idp_group_user_rel` (
+CREATE TABLE IF NOT EXISTS `idp_user_group_rel` (
     `id` BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT COMMENT 'auto increment 
id',
-    `group_id` BIGINT(20) UNSIGNED NOT NULL COMMENT 'idp group id',
     `user_id` BIGINT(20) UNSIGNED NOT NULL COMMENT 'idp user id',
+    `group_id` BIGINT(20) UNSIGNED NOT NULL COMMENT 'idp group id',
     `current_version` INT UNSIGNED NOT NULL DEFAULT 1 COMMENT 'idp relation 
current version',
     `last_version` INT UNSIGNED NOT NULL DEFAULT 1 COMMENT 'idp relation last 
version',
     `deleted_at` BIGINT(20) UNSIGNED NOT NULL DEFAULT 0 COMMENT 'idp relation 
deleted at',
     PRIMARY KEY (`id`),
-    CONSTRAINT `uk_igiu_del` UNIQUE (`group_id`, `user_id`, `deleted_at`),
-    KEY `idx_iug_gid` (`group_id`),
-    KEY `idx_iug_uid` (`user_id`)
+    CONSTRAINT `uk_iuig_del` UNIQUE (`user_id`, `group_id`, `deleted_at`),
+    KEY `idx_iuig_uid` (`user_id`),
+    KEY `idx_iuig_gid` (`group_id`)
 ) ENGINE=InnoDB;
diff --git a/scripts/mysql/schema-1.3.0-mysql.sql 
b/scripts/mysql/schema-1.3.0-mysql.sql
index 0c1cb6b8e0..7ed2d03e17 100644
--- a/scripts/mysql/schema-1.3.0-mysql.sql
+++ b/scripts/mysql/schema-1.3.0-mysql.sql
@@ -262,18 +262,18 @@ CREATE TABLE IF NOT EXISTS `idp_group_meta` (
     UNIQUE KEY `uk_ign_del` (`group_name`, `deleted_at`)
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin COMMENT 'local IdP 
group metadata';
 
-CREATE TABLE IF NOT EXISTS `idp_group_user_rel` (
+CREATE TABLE IF NOT EXISTS `idp_user_group_rel` (
     `id` BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT COMMENT 'auto increment 
id',
-    `group_id` BIGINT(20) UNSIGNED NOT NULL COMMENT 'idp group id',
     `user_id` BIGINT(20) UNSIGNED NOT NULL COMMENT 'idp user id',
+    `group_id` BIGINT(20) UNSIGNED NOT NULL COMMENT 'idp group id',
     `current_version` INT UNSIGNED NOT NULL DEFAULT 1 COMMENT 'idp relation 
current version',
     `last_version` INT UNSIGNED NOT NULL DEFAULT 1 COMMENT 'idp relation last 
version',
     `deleted_at` BIGINT(20) UNSIGNED NOT NULL DEFAULT 0 COMMENT 'idp relation 
deleted at',
     PRIMARY KEY (`id`),
-    UNIQUE KEY `uk_igiu_del` (`group_id`, `user_id`, `deleted_at`),
-    KEY `idx_iug_gid` (`group_id`),
-    KEY `idx_iug_uid` (`user_id`)
-) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin COMMENT 'local IdP 
group user relation';
+    UNIQUE KEY `uk_iuig_del` (`user_id`, `group_id`, `deleted_at`),
+    KEY `idx_iuig_uid` (`user_id`),
+    KEY `idx_iuig_gid` (`group_id`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin COMMENT 'local IdP 
user group relation';
 
 CREATE TABLE IF NOT EXISTS `tag_meta` (
     `tag_id` BIGINT(20) UNSIGNED NOT NULL COMMENT 'tag id',
diff --git a/scripts/mysql/upgrade-1.2.0-to-1.3.0-mysql.sql 
b/scripts/mysql/upgrade-1.2.0-to-1.3.0-mysql.sql
index 623aa238f7..d31b6314b0 100644
--- a/scripts/mysql/upgrade-1.2.0-to-1.3.0-mysql.sql
+++ b/scripts/mysql/upgrade-1.2.0-to-1.3.0-mysql.sql
@@ -106,15 +106,15 @@ CREATE TABLE IF NOT EXISTS `idp_group_meta` (
     UNIQUE KEY `uk_ign_del` (`group_name`, `deleted_at`)
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin COMMENT 'local IdP 
group metadata';
 
-CREATE TABLE IF NOT EXISTS `idp_group_user_rel` (
+CREATE TABLE IF NOT EXISTS `idp_user_group_rel` (
     `id` BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT COMMENT 'auto increment 
id',
-    `group_id` BIGINT(20) UNSIGNED NOT NULL COMMENT 'idp group id',
     `user_id` BIGINT(20) UNSIGNED NOT NULL COMMENT 'idp user id',
+    `group_id` BIGINT(20) UNSIGNED NOT NULL COMMENT 'idp group id',
     `current_version` INT UNSIGNED NOT NULL DEFAULT 1 COMMENT 'idp relation 
current version',
     `last_version` INT UNSIGNED NOT NULL DEFAULT 1 COMMENT 'idp relation last 
version',
     `deleted_at` BIGINT(20) UNSIGNED NOT NULL DEFAULT 0 COMMENT 'idp relation 
deleted at',
     PRIMARY KEY (`id`),
-    UNIQUE KEY `uk_igiu_del` (`group_id`, `user_id`, `deleted_at`),
-    KEY `idx_iug_gid` (`group_id`),
-    KEY `idx_iug_uid` (`user_id`)
-) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin COMMENT 'local IdP 
group user relation';
+    UNIQUE KEY `uk_iuig_del` (`user_id`, `group_id`, `deleted_at`),
+    KEY `idx_iuig_uid` (`user_id`),
+    KEY `idx_iuig_gid` (`group_id`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin COMMENT 'local IdP 
user group relation';
diff --git a/scripts/postgresql/schema-1.3.0-postgresql.sql 
b/scripts/postgresql/schema-1.3.0-postgresql.sql
index 86a23eb5a0..b922b369da 100644
--- a/scripts/postgresql/schema-1.3.0-postgresql.sql
+++ b/scripts/postgresql/schema-1.3.0-postgresql.sql
@@ -465,26 +465,26 @@ COMMENT ON COLUMN idp_group_meta.current_version IS 'idp 
group current version';
 COMMENT ON COLUMN idp_group_meta.last_version IS 'idp group last version';
 COMMENT ON COLUMN idp_group_meta.deleted_at IS 'idp group deleted at';
 
-CREATE TABLE IF NOT EXISTS idp_group_user_rel (
+CREATE TABLE IF NOT EXISTS idp_user_group_rel (
     id BIGINT NOT NULL GENERATED BY DEFAULT AS IDENTITY,
-    group_id BIGINT NOT NULL,
     user_id BIGINT NOT NULL,
+    group_id BIGINT NOT NULL,
     current_version INT NOT NULL DEFAULT 1,
     last_version INT NOT NULL DEFAULT 1,
     deleted_at BIGINT NOT NULL DEFAULT 0,
     PRIMARY KEY (id),
-    UNIQUE (group_id, user_id, deleted_at)
+    CONSTRAINT uk_iuig_del UNIQUE (user_id, group_id, deleted_at)
 );
-CREATE INDEX IF NOT EXISTS idp_group_user_rel_idx_group_id ON 
idp_group_user_rel (group_id);
-CREATE INDEX IF NOT EXISTS idp_group_user_rel_idx_user_id ON 
idp_group_user_rel (user_id);
-COMMENT ON TABLE idp_group_user_rel IS 'local IdP group user relation';
-
-COMMENT ON COLUMN idp_group_user_rel.id IS 'auto increment id';
-COMMENT ON COLUMN idp_group_user_rel.group_id IS 'idp group id';
-COMMENT ON COLUMN idp_group_user_rel.user_id IS 'idp user id';
-COMMENT ON COLUMN idp_group_user_rel.current_version IS 'idp relation current 
version';
-COMMENT ON COLUMN idp_group_user_rel.last_version IS 'idp relation last 
version';
-COMMENT ON COLUMN idp_group_user_rel.deleted_at IS 'idp relation deleted at';
+CREATE INDEX IF NOT EXISTS idp_user_group_rel_idx_iuig_uid ON 
idp_user_group_rel (user_id);
+CREATE INDEX IF NOT EXISTS idp_user_group_rel_idx_iuig_gid ON 
idp_user_group_rel (group_id);
+COMMENT ON TABLE idp_user_group_rel IS 'local IdP user group relation';
+
+COMMENT ON COLUMN idp_user_group_rel.id IS 'auto increment id';
+COMMENT ON COLUMN idp_user_group_rel.user_id IS 'idp user id';
+COMMENT ON COLUMN idp_user_group_rel.group_id IS 'idp group id';
+COMMENT ON COLUMN idp_user_group_rel.current_version IS 'idp relation current 
version';
+COMMENT ON COLUMN idp_user_group_rel.last_version IS 'idp relation last 
version';
+COMMENT ON COLUMN idp_user_group_rel.deleted_at IS 'idp relation deleted at';
 
 CREATE TABLE IF NOT EXISTS tag_meta (
     tag_id BIGINT NOT NULL,
diff --git a/scripts/postgresql/upgrade-1.2.0-to-1.3.0-postgresql.sql 
b/scripts/postgresql/upgrade-1.2.0-to-1.3.0-postgresql.sql
index fdcfefedb7..bf3071567d 100644
--- a/scripts/postgresql/upgrade-1.2.0-to-1.3.0-postgresql.sql
+++ b/scripts/postgresql/upgrade-1.2.0-to-1.3.0-postgresql.sql
@@ -131,23 +131,23 @@ COMMENT ON COLUMN idp_group_meta.current_version IS 'idp 
group current version';
 COMMENT ON COLUMN idp_group_meta.last_version IS 'idp group last version';
 COMMENT ON COLUMN idp_group_meta.deleted_at IS 'idp group deleted at';
 
-CREATE TABLE IF NOT EXISTS idp_group_user_rel (
+CREATE TABLE IF NOT EXISTS idp_user_group_rel (
     id BIGINT NOT NULL GENERATED BY DEFAULT AS IDENTITY,
-    group_id BIGINT NOT NULL,
     user_id BIGINT NOT NULL,
+    group_id BIGINT NOT NULL,
     current_version INT NOT NULL DEFAULT 1,
     last_version INT NOT NULL DEFAULT 1,
     deleted_at BIGINT NOT NULL DEFAULT 0,
     PRIMARY KEY (id),
-    UNIQUE (group_id, user_id, deleted_at)
+    CONSTRAINT uk_iuig_del UNIQUE (user_id, group_id, deleted_at)
 );
-CREATE INDEX IF NOT EXISTS idp_group_user_rel_idx_group_id ON 
idp_group_user_rel (group_id);
-CREATE INDEX IF NOT EXISTS idp_group_user_rel_idx_user_id ON 
idp_group_user_rel (user_id);
-COMMENT ON TABLE idp_group_user_rel IS 'local IdP group user relation';
+CREATE INDEX IF NOT EXISTS idp_user_group_rel_idx_iuig_uid ON 
idp_user_group_rel (user_id);
+CREATE INDEX IF NOT EXISTS idp_user_group_rel_idx_iuig_gid ON 
idp_user_group_rel (group_id);
+COMMENT ON TABLE idp_user_group_rel IS 'local IdP user group relation';
 
-COMMENT ON COLUMN idp_group_user_rel.id IS 'auto increment id';
-COMMENT ON COLUMN idp_group_user_rel.group_id IS 'idp group id';
-COMMENT ON COLUMN idp_group_user_rel.user_id IS 'idp user id';
-COMMENT ON COLUMN idp_group_user_rel.current_version IS 'idp relation current 
version';
-COMMENT ON COLUMN idp_group_user_rel.last_version IS 'idp relation last 
version';
-COMMENT ON COLUMN idp_group_user_rel.deleted_at IS 'idp relation deleted at';
+COMMENT ON COLUMN idp_user_group_rel.id IS 'auto increment id';
+COMMENT ON COLUMN idp_user_group_rel.user_id IS 'idp user id';
+COMMENT ON COLUMN idp_user_group_rel.group_id IS 'idp group id';
+COMMENT ON COLUMN idp_user_group_rel.current_version IS 'idp relation current 
version';
+COMMENT ON COLUMN idp_user_group_rel.last_version IS 'idp relation last 
version';
+COMMENT ON COLUMN idp_user_group_rel.deleted_at IS 'idp relation deleted at';


Reply via email to