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 5f418566ca [#12330] feat(core): Add user by-id APIs and alterUserById 
via UserChange (#12332)
5f418566ca is described below

commit 5f418566ca02fb9cef831a56b8299580b2d1950b
Author: MaSai <[email protected]>
AuthorDate: Tue Aug 4 09:43:09 2026 +0800

    [#12330] feat(core): Add user by-id APIs and alterUserById via UserChange 
(#12332)
    
    ### What changes were proposed in this pull request?
    
    Add AccessControlDispatcher APIs that locate users by Gravitino-assigned
    id, and a single alter path for mutable attributes.
    
    **API / model**
    - Expose `User.id()` and align `UserDTO` / `UserInfo` (required non-null
    id)
    - `getUserById` / `removeUserById`
    - `UserChange` + `alterUserById` (update `enabled` and/or `externalId`
    in one call)
    - Remove dedicated `enableUser` / `disableUser` (by externalId and by
    id) and `updateUserExternalIdById`
    
    **Persistence / store**
    - Relational lookup/update/delete by metalake + user id
    (`UserMetaService`, mapper/SQL)
    - `SupportsIdOperations` wired through JDBC and in-memory entity stores
    for `USER`
    
    **Wiring**
    - `UserGroupIdManager`, `AccessControlManager`, hook dispatcher
    - TreeLock paths via `AuthorizationUtils.ofUserId` /
    `USER_ID_SCHEMA_NAME`
    
    Existing by-name get/remove and by-externalId get/remove are unchanged.
    
    Fix: #12330
    
    ### Why are the changes needed?
    
    SCIM push (Azure Entra / Okta) stores the SCIM `id` returned on create
    and uses it for subsequent `GET` / `PATCH` / `DELETE`. Core previously
    only keyed users by name or `externalId`. A single `alterUserById`
    matches SCIM PATCH that may update `active` and `externalId` together.
    
    ### Does this PR introduce _any_ user-facing change?
    
    - Yes: new `User.id()`, by-id get/remove, `UserChange` / `alterUserById`
    - Breaking: removes `enableUser` / `disableUser` and related by-id
    update helpers in favor of `alterUserById`
    
    ### How was this patch tested?
    
    ```bash
    ./gradlew :api:test \
      --tests org.apache.gravitino.authorization.TestUserChange \
      :core:test \
      --tests org.apache.gravitino.authorization.TestAccessControlManager \
      --tests org.apache.gravitino.listener.api.event.TestUserEvent \
      -PskipITs
    ```
    
    ---------
    
    Co-authored-by: Cursor <[email protected]>
---
 .../org/apache/gravitino/authorization/User.java   |  15 ++-
 .../apache/gravitino/authorization/UserChange.java | 132 ++++++++++++++++++++
 .../gravitino/authorization/TestUserChange.java    |  43 +++++++
 .../apache/gravitino/cli/commands/ListUsers.java   |   5 +
 .../apache/gravitino/client/TestPermission.java    |   2 +
 .../org/apache/gravitino/client/TestUserGroup.java |   2 +
 .../gravitino/dto/authorization/UserDTO.java       |  37 +++++-
 .../apache/gravitino/dto/util/DTOConverters.java   |   1 +
 .../gravitino/dto/responses/TestResponses.java     |   2 +-
 .../src/main/java/org/apache/gravitino/Entity.java |  12 +-
 .../java/org/apache/gravitino/EntityStore.java     |  10 ++
 .../org/apache/gravitino/SupportsIdOperations.java |  71 +++++++++++
 .../authorization/AccessControlDispatcher.java     |  40 ++++--
 .../authorization/AccessControlManager.java        |  22 +++-
 .../authorization/AuthorizationUtils.java          |  39 ++++++
 .../authorization/UserGroupExternalManager.java    |  44 -------
 .../authorization/UserGroupIdManager.java          | 138 +++++++++++++++++++++
 .../hook/AccessControlHookDispatcher.java          |  14 ++-
 .../listener/AccessControlEventDispatcher.java     |  41 ++----
 .../listener/api/event/DisableUserEvent.java       |  65 ----------
 .../api/event/DisableUserFailureEvent.java         |  57 ---------
 .../listener/api/event/DisableUserPreEvent.java    |  55 --------
 .../listener/api/event/EnableUserEvent.java        |  65 ----------
 .../listener/api/event/EnableUserFailureEvent.java |  57 ---------
 .../listener/api/event/EnableUserPreEvent.java     |  55 --------
 .../gravitino/listener/api/info/UserInfo.java      |  12 ++
 .../gravitino/storage/relational/JDBCBackend.java  |  33 +++++
 .../storage/relational/RelationalBackend.java      |  29 +++++
 .../storage/relational/RelationalEntityStore.java  |  40 ++++++
 .../storage/relational/mapper/UserMetaMapper.java  |   6 +
 .../mapper/UserMetaSQLProviderFactory.java         |   5 +
 .../provider/base/UserMetaBaseSQLProvider.java     |  17 +++
 .../relational/service/UserMetaService.java        |  85 +++++++++++++
 .../authorization/TestAccessControlManager.java    |  64 ++++++++--
 .../listener/api/event/TestUserEvent.java          |  18 +--
 .../storage/memory/TestMemoryEntityStore.java      |  67 +++++++++-
 36 files changed, 919 insertions(+), 481 deletions(-)

diff --git a/api/src/main/java/org/apache/gravitino/authorization/User.java 
b/api/src/main/java/org/apache/gravitino/authorization/User.java
index a5ac53e51e..3e58c7d3a3 100644
--- a/api/src/main/java/org/apache/gravitino/authorization/User.java
+++ b/api/src/main/java/org/apache/gravitino/authorization/User.java
@@ -35,13 +35,24 @@ public interface User extends Auditable {
    */
   String name();
 
+  /**
+   * The unique id assigned by Gravitino.
+   *
+   * <p>This id is server-assigned and immutable. Upstream systems may also 
supply an optional
+   * {@link #externalId()}.
+   *
+   * @return The unique id of the user.
+   */
+  Long id();
+
   /**
    * The stable identifier assigned by an upstream identity system (for 
example, SCIM, LDAP, or
    * IAM), or null if not set.
    *
    * <p>Gravitino {@link User#name() user names} may differ from upstream ids 
or be unknown at sync
-   * time. External id lets integrators look up, enable/disable, and delete 
users without relying on
-   * the Gravitino user name.
+   * time. External id lets integrators look up and correlate users without 
relying on the Gravitino
+   * user name. Mutable attributes such as {@link #enabled()} and {@code 
externalId} are updated via
+   * {@code alterUserById}.
    *
    * @return The upstream external identifier, or null if not set.
    */
diff --git 
a/api/src/main/java/org/apache/gravitino/authorization/UserChange.java 
b/api/src/main/java/org/apache/gravitino/authorization/UserChange.java
new file mode 100644
index 0000000000..f147784370
--- /dev/null
+++ b/api/src/main/java/org/apache/gravitino/authorization/UserChange.java
@@ -0,0 +1,132 @@
+/*
+ * 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.authorization;
+
+import java.util.Objects;
+import javax.annotation.Nullable;
+import org.apache.gravitino.annotation.Evolving;
+
+/**
+ * A user change is a change to a user. It can be used to update the enabled 
flag or the optional
+ * external id. Multiple changes may be applied in one {@code alterUserById} 
call.
+ */
+@Evolving
+public interface UserChange {
+
+  /**
+   * Creates a user change to update whether the user is enabled.
+   *
+   * @param enabled Whether the user should be enabled.
+   * @return The user change.
+   */
+  static UserChange updateEnabled(boolean enabled) {
+    return new UpdateEnabled(enabled);
+  }
+
+  /**
+   * Creates a user change to update the external identifier.
+   *
+   * @param newExternalId The new external identifier, or null to clear it.
+   * @return The user change.
+   */
+  static UserChange updateExternalId(@Nullable String newExternalId) {
+    return new UpdateExternalId(newExternalId);
+  }
+
+  /** A user change to update the enabled flag. */
+  final class UpdateEnabled implements UserChange {
+    private final boolean enabled;
+
+    private UpdateEnabled(boolean enabled) {
+      this.enabled = enabled;
+    }
+
+    /**
+     * Returns whether the user should be enabled.
+     *
+     * @return Whether the user should be enabled.
+     */
+    public boolean enabled() {
+      return enabled;
+    }
+
+    @Override
+    public boolean equals(Object o) {
+      if (this == o) {
+        return true;
+      }
+      if (!(o instanceof UpdateEnabled)) {
+        return false;
+      }
+      UpdateEnabled that = (UpdateEnabled) o;
+      return enabled == that.enabled;
+    }
+
+    @Override
+    public int hashCode() {
+      return Objects.hash(enabled);
+    }
+
+    @Override
+    public String toString() {
+      return "UpdateEnabled " + enabled;
+    }
+  }
+
+  /** A user change to update the external identifier. */
+  final class UpdateExternalId implements UserChange {
+    @Nullable private final String newExternalId;
+
+    private UpdateExternalId(@Nullable String newExternalId) {
+      this.newExternalId = newExternalId;
+    }
+
+    /**
+     * Returns the new external identifier, or null to clear it.
+     *
+     * @return The new external identifier, or null.
+     */
+    @Nullable
+    public String getNewExternalId() {
+      return newExternalId;
+    }
+
+    @Override
+    public boolean equals(Object o) {
+      if (this == o) {
+        return true;
+      }
+      if (!(o instanceof UpdateExternalId)) {
+        return false;
+      }
+      UpdateExternalId that = (UpdateExternalId) o;
+      return Objects.equals(newExternalId, that.newExternalId);
+    }
+
+    @Override
+    public int hashCode() {
+      return Objects.hash(newExternalId);
+    }
+
+    @Override
+    public String toString() {
+      return "UpdateExternalId " + newExternalId;
+    }
+  }
+}
diff --git 
a/api/src/test/java/org/apache/gravitino/authorization/TestUserChange.java 
b/api/src/test/java/org/apache/gravitino/authorization/TestUserChange.java
new file mode 100644
index 0000000000..cfb051b5f4
--- /dev/null
+++ b/api/src/test/java/org/apache/gravitino/authorization/TestUserChange.java
@@ -0,0 +1,43 @@
+/*
+ * 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.authorization;
+
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+
+public class TestUserChange {
+
+  @Test
+  void testUpdateEnabled() {
+    UserChange.UpdateEnabled change = (UserChange.UpdateEnabled) 
UserChange.updateEnabled(false);
+    Assertions.assertFalse(change.enabled());
+    Assertions.assertEquals(UserChange.updateEnabled(false), change);
+    Assertions.assertNotEquals(UserChange.updateEnabled(true), change);
+  }
+
+  @Test
+  void testUpdateExternalId() {
+    UserChange.UpdateExternalId change =
+        (UserChange.UpdateExternalId) UserChange.updateExternalId("ext-1");
+    Assertions.assertEquals("ext-1", change.getNewExternalId());
+    Assertions.assertEquals(UserChange.updateExternalId("ext-1"), change);
+    Assertions.assertEquals(UserChange.updateExternalId(null), 
UserChange.updateExternalId(null));
+    Assertions.assertNotEquals(UserChange.updateExternalId("ext-2"), change);
+  }
+}
diff --git 
a/clients/cli/src/main/java/org/apache/gravitino/cli/commands/ListUsers.java 
b/clients/cli/src/main/java/org/apache/gravitino/cli/commands/ListUsers.java
index 19cf9718be..19382a0e93 100644
--- a/clients/cli/src/main/java/org/apache/gravitino/cli/commands/ListUsers.java
+++ b/clients/cli/src/main/java/org/apache/gravitino/cli/commands/ListUsers.java
@@ -68,6 +68,11 @@ public class ListUsers extends Command {
 
   private User getUser(String user) {
     return new User() {
+      @Override
+      public Long id() {
+        return null;
+      }
+
       @Override
       public String name() {
         return user;
diff --git 
a/clients/client-java/src/test/java/org/apache/gravitino/client/TestPermission.java
 
b/clients/client-java/src/test/java/org/apache/gravitino/client/TestPermission.java
index d1731607a8..4704886190 100644
--- 
a/clients/client-java/src/test/java/org/apache/gravitino/client/TestPermission.java
+++ 
b/clients/client-java/src/test/java/org/apache/gravitino/client/TestPermission.java
@@ -92,6 +92,7 @@ public class TestPermission extends TestBase {
     RoleGrantRequest request = new RoleGrantRequest(roles);
     UserDTO userDTO =
         UserDTO.builder()
+            .withId(1L)
             .withName("user")
             .withRoles(Lists.newArrayList("roles"))
             
.withAudit(AuditDTO.builder().withCreator("test").withCreateTime(Instant.now()).build())
@@ -118,6 +119,7 @@ public class TestPermission extends TestBase {
         String.format(API_PERMISSION_PATH, metalakeName, 
String.format("users/%s/revoke", user));
     UserDTO userDTO =
         UserDTO.builder()
+            .withId(1L)
             .withName("user")
             .withRoles(Lists.newArrayList())
             
.withAudit(AuditDTO.builder().withCreator("test").withCreateTime(Instant.now()).build())
diff --git 
a/clients/client-java/src/test/java/org/apache/gravitino/client/TestUserGroup.java
 
b/clients/client-java/src/test/java/org/apache/gravitino/client/TestUserGroup.java
index 924acb9cf0..9c0a12d133 100644
--- 
a/clients/client-java/src/test/java/org/apache/gravitino/client/TestUserGroup.java
+++ 
b/clients/client-java/src/test/java/org/apache/gravitino/client/TestUserGroup.java
@@ -134,6 +134,7 @@ public class TestUserGroup extends TestBase {
 
     UserDTO mockUser =
         UserDTO.builder()
+            .withId(1L)
             .withName(username)
             .withExternalId(externalId)
             .withEnabled(false)
@@ -427,6 +428,7 @@ public class TestUserGroup extends TestBase {
 
   private UserDTO mockUserDTO(String name) {
     return UserDTO.builder()
+        .withId(1L)
         .withName(name)
         
.withAudit(AuditDTO.builder().withCreator("creator").withCreateTime(Instant.now()).build())
         .build();
diff --git 
a/common/src/main/java/org/apache/gravitino/dto/authorization/UserDTO.java 
b/common/src/main/java/org/apache/gravitino/dto/authorization/UserDTO.java
index 68297c9e84..2e741a8e01 100644
--- a/common/src/main/java/org/apache/gravitino/dto/authorization/UserDTO.java
+++ b/common/src/main/java/org/apache/gravitino/dto/authorization/UserDTO.java
@@ -31,6 +31,9 @@ import org.apache.gravitino.dto.AuditDTO;
 /** Represents a User Data Transfer Object (DTO). */
 public class UserDTO implements User {
 
+  @JsonProperty("id")
+  private Long id;
+
   @JsonProperty("name")
   private String name;
 
@@ -53,6 +56,7 @@ public class UserDTO implements User {
   /**
    * Creates a new instance of UserDTO.
    *
+   * @param id The id of the User DTO.
    * @param name The name of the User DTO.
    * @param externalId The external id of the User DTO.
    * @param roles The roles of the User DTO.
@@ -60,7 +64,13 @@ public class UserDTO implements User {
    * @param enabled Whether the User DTO is enabled.
    */
   protected UserDTO(
-      String name, String externalId, List<String> roles, AuditDTO audit, 
boolean enabled) {
+      Long id,
+      String name,
+      String externalId,
+      List<String> roles,
+      AuditDTO audit,
+      boolean enabled) {
+    this.id = id;
     this.name = name;
     this.externalId = externalId;
     this.enabled = enabled;
@@ -68,6 +78,14 @@ public class UserDTO implements User {
     this.roles = roles;
   }
 
+  /**
+   * @return The id of the User DTO.
+   */
+  @Override
+  public Long id() {
+    return id;
+  }
+
   /**
    * @return The name of the User DTO.
    */
@@ -120,6 +138,9 @@ public class UserDTO implements User {
    */
   public static class Builder<S extends Builder> {
 
+    /** The id of the user. */
+    protected Long id;
+
     /** The name of the user. */
     protected String name;
 
@@ -135,6 +156,17 @@ public class UserDTO implements User {
     /** The audit information of the user. */
     protected AuditDTO audit;
 
+    /**
+     * Sets the id of the user.
+     *
+     * @param id The id of the user.
+     * @return The builder instance.
+     */
+    public S withId(Long id) {
+      this.id = id;
+      return (S) this;
+    }
+
     /**
      * Sets the name of the user.
      *
@@ -200,9 +232,10 @@ public class UserDTO implements User {
      * @throws IllegalArgumentException If the name or audit are not set.
      */
     public UserDTO build() {
+      Preconditions.checkArgument(id != null, "id cannot be null");
       Preconditions.checkArgument(StringUtils.isNotBlank(name), "name cannot 
be null or empty");
       Preconditions.checkArgument(audit != null, "audit cannot be null");
-      return new UserDTO(name, externalId, roles, audit, enabled);
+      return new UserDTO(id, name, externalId, roles, audit, enabled);
     }
   }
 }
diff --git 
a/common/src/main/java/org/apache/gravitino/dto/util/DTOConverters.java 
b/common/src/main/java/org/apache/gravitino/dto/util/DTOConverters.java
index 2279048a91..75394cc69f 100644
--- a/common/src/main/java/org/apache/gravitino/dto/util/DTOConverters.java
+++ b/common/src/main/java/org/apache/gravitino/dto/util/DTOConverters.java
@@ -493,6 +493,7 @@ public class DTOConverters {
     }
 
     return UserDTO.builder()
+        .withId(user.id())
         .withName(user.name())
         .withExternalId(user.externalId())
         .withEnabled(user.enabled())
diff --git 
a/common/src/test/java/org/apache/gravitino/dto/responses/TestResponses.java 
b/common/src/test/java/org/apache/gravitino/dto/responses/TestResponses.java
index f1c5977eda..89269ab966 100644
--- a/common/src/test/java/org/apache/gravitino/dto/responses/TestResponses.java
+++ b/common/src/test/java/org/apache/gravitino/dto/responses/TestResponses.java
@@ -285,7 +285,7 @@ public class TestResponses {
   void testUserResponse() throws IllegalArgumentException {
     AuditDTO audit =
         
AuditDTO.builder().withCreator("creator").withCreateTime(Instant.now()).build();
-    UserDTO user = 
UserDTO.builder().withName("user1").withAudit(audit).build();
+    UserDTO user = 
UserDTO.builder().withId(1L).withName("user1").withAudit(audit).build();
     UserResponse response = new UserResponse(user);
     response.validate(); // No exception thrown
   }
diff --git a/core/src/main/java/org/apache/gravitino/Entity.java 
b/core/src/main/java/org/apache/gravitino/Entity.java
index 29bae55637..934f119eb0 100644
--- a/core/src/main/java/org/apache/gravitino/Entity.java
+++ b/core/src/main/java/org/apache/gravitino/Entity.java
@@ -43,7 +43,17 @@ public interface Entity extends Serializable {
 
   /**
    * A virtual schema name used only for {@link 
org.apache.gravitino.lock.TreeLockUtils} lock paths
-   * when operating on users by external id (for example, 
get/enable/disable/delete-by-external-id).
+   * when operating on users by Gravitino-assigned id (for example, 
get/alter/delete-by-id).
+   *
+   * <p>This is not a real metadata schema and does not store entities. It 
forms part of a synthetic
+   * {@link org.apache.gravitino.NameIdentifier} such as {@code {metalake, 
system, user-id, <id>}}
+   * so that concurrent operations on the same user id are serialized.
+   */
+  String USER_ID_SCHEMA_NAME = "user-id";
+
+  /**
+   * A virtual schema name used only for {@link 
org.apache.gravitino.lock.TreeLockUtils} lock paths
+   * when operating on users by external id (for example, 
get/delete-by-external-id).
    *
    * <p>This is not a real metadata schema and does not store entities. It 
forms part of a synthetic
    * {@link org.apache.gravitino.NameIdentifier} such as {@code {metalake, 
system, user-external-id,
diff --git a/core/src/main/java/org/apache/gravitino/EntityStore.java 
b/core/src/main/java/org/apache/gravitino/EntityStore.java
index cadf3b2615..1f844a2e27 100644
--- a/core/src/main/java/org/apache/gravitino/EntityStore.java
+++ b/core/src/main/java/org/apache/gravitino/EntityStore.java
@@ -278,4 +278,14 @@ public interface EntityStore extends Closeable {
   default SupportsExternalIdOperations externalIdOperations() {
     throw new UnsupportedOperationException("external id operations are not 
supported");
   }
+
+  /**
+   * Get the extra id operations that are supported by the entity store.
+   *
+   * @return the id operations that are supported by the entity store
+   * @throws UnsupportedOperationException if the extra operations are not 
supported
+   */
+  default SupportsIdOperations idOperations() {
+    throw new UnsupportedOperationException("id operations are not supported");
+  }
 }
diff --git a/core/src/main/java/org/apache/gravitino/SupportsIdOperations.java 
b/core/src/main/java/org/apache/gravitino/SupportsIdOperations.java
new file mode 100644
index 0000000000..36c4634955
--- /dev/null
+++ b/core/src/main/java/org/apache/gravitino/SupportsIdOperations.java
@@ -0,0 +1,71 @@
+/*
+ * 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;
+
+import java.io.IOException;
+import java.util.function.Function;
+import org.apache.gravitino.exceptions.NoSuchEntityException;
+
+/**
+ * Optional extension for entity stores that support lookup and mutation by 
Gravitino-assigned id
+ * within a metalake.
+ */
+public interface SupportsIdOperations {
+
+  /**
+   * Get the entity from the underlying storage by Gravitino-assigned id.
+   *
+   * @param ident the id name identifier
+   * @param entityType the general type of the entity
+   * @param type the detailed type of the entity
+   * @param <E> the class of entity
+   * @return the entity retrieved from the underlying storage
+   * @throws NoSuchEntityException if the entity does not exist
+   * @throws IOException if the retrieve operation fails
+   */
+  <E extends Entity & HasIdentifier> E getById(
+      NameIdentifier ident, Entity.EntityType entityType, Class<E> type)
+      throws NoSuchEntityException, IOException;
+
+  /**
+   * Update an entity by Gravitino-assigned id.
+   *
+   * @param ident the id name identifier
+   * @param entityType the general type of the entity
+   * @param type the detailed type of the entity
+   * @param updater the updater function to update the entity
+   * @param <E> the class of entity
+   * @return the updated entity
+   * @throws NoSuchEntityException if the entity does not exist
+   * @throws IOException if the update operation fails
+   */
+  <E extends Entity & HasIdentifier> E updateById(
+      NameIdentifier ident, Entity.EntityType entityType, Class<E> type, 
Function<E, E> updater)
+      throws NoSuchEntityException, IOException;
+
+  /**
+   * Delete an entity by Gravitino-assigned id.
+   *
+   * @param ident the id name identifier
+   * @param entityType the general type of the entity
+   * @return true if the entity exists and is deleted successfully, false 
otherwise
+   * @throws IOException if the delete operation fails
+   */
+  boolean deleteById(NameIdentifier ident, Entity.EntityType entityType) 
throws IOException;
+}
diff --git 
a/core/src/main/java/org/apache/gravitino/authorization/AccessControlDispatcher.java
 
b/core/src/main/java/org/apache/gravitino/authorization/AccessControlDispatcher.java
index b657700bc9..20c3c0ba0d 100644
--- 
a/core/src/main/java/org/apache/gravitino/authorization/AccessControlDispatcher.java
+++ 
b/core/src/main/java/org/apache/gravitino/authorization/AccessControlDispatcher.java
@@ -118,31 +118,47 @@ public interface AccessControlDispatcher {
       throws NoSuchUserException, NoSuchMetalakeException;
 
   /**
-   * Enables a User without removing role bindings.
+   * Gets a User by Gravitino-assigned id.
    *
    * @param metalake The Metalake of the User.
-   * @param externalId The external identifier of the User.
-   * @return The updated User instance.
-   * @throws IllegalArgumentException If externalId is null or blank.
-   * @throws NoSuchUserException If the User with the given external id does 
not exist.
+   * @param userId The Gravitino-assigned id of the User.
+   * @return The getting User instance.
+   * @throws NoSuchUserException If the User with the given id does not exist.
    * @throws NoSuchMetalakeException If the Metalake with the given name does 
not exist.
-   * @throws RuntimeException If updating the User encounters storage issues.
+   * @throws RuntimeException If getting the User encounters storage issues.
    */
-  User enableUser(String metalake, String externalId)
+  User getUserById(String metalake, long userId)
       throws NoSuchUserException, NoSuchMetalakeException;
 
   /**
-   * Disables a User without removing role bindings.
+   * Removes a User by Gravitino-assigned id.
    *
    * @param metalake The Metalake of the User.
-   * @param externalId The external identifier of the User.
+   * @param userId The Gravitino-assigned id of the User.
+   * @return True if the User was successfully removed, false only when 
there's no such user,
+   *     otherwise it will throw an exception.
+   * @throws NoSuchMetalakeException If the Metalake with the given name does 
not exist.
+   * @throws RuntimeException If removing the User encounters storage issues.
+   */
+  boolean removeUserById(String metalake, long userId) throws 
NoSuchMetalakeException;
+
+  /**
+   * Alters a User by Gravitino-assigned id.
+   *
+   * <p>Supports updating {@code enabled} and/or {@code externalId} in one 
call via {@link
+   * UserChange}. Role bindings are preserved.
+   *
+   * @param metalake The Metalake of the User.
+   * @param userId The Gravitino-assigned id of the User.
+   * @param changes The changes to apply. Must not be empty.
    * @return The updated User instance.
-   * @throws IllegalArgumentException If externalId is null or blank.
-   * @throws NoSuchUserException If the User with the given external id does 
not exist.
+   * @throws IllegalArgumentException If changes is null or empty, or contains 
an unsupported
+   *     change.
+   * @throws NoSuchUserException If the User with the given id does not exist.
    * @throws NoSuchMetalakeException If the Metalake with the given name does 
not exist.
    * @throws RuntimeException If updating the User encounters storage issues.
    */
-  User disableUser(String metalake, String externalId)
+  User alterUserById(String metalake, long userId, UserChange... changes)
       throws NoSuchUserException, NoSuchMetalakeException;
 
   /**
diff --git 
a/core/src/main/java/org/apache/gravitino/authorization/AccessControlManager.java
 
b/core/src/main/java/org/apache/gravitino/authorization/AccessControlManager.java
index 2d21c4eb8a..386e7a3936 100644
--- 
a/core/src/main/java/org/apache/gravitino/authorization/AccessControlManager.java
+++ 
b/core/src/main/java/org/apache/gravitino/authorization/AccessControlManager.java
@@ -48,6 +48,7 @@ public class AccessControlManager implements 
AccessControlDispatcher {
 
   private final UserGroupManager userGroupManager;
   private final UserGroupExternalManager userGroupExternalManager;
+  private final UserGroupIdManager userGroupIdManager;
   private final RoleManager roleManager;
   private final PermissionManager permissionManager;
   private final List<String> serviceAdmins;
@@ -56,6 +57,7 @@ public class AccessControlManager implements 
AccessControlDispatcher {
     this.roleManager = new RoleManager(store, idGenerator);
     this.userGroupManager = new UserGroupManager(store, idGenerator);
     this.userGroupExternalManager = new UserGroupExternalManager(store, 
idGenerator);
+    this.userGroupIdManager = new UserGroupIdManager(store, idGenerator);
     this.permissionManager = new PermissionManager(store, roleManager);
     this.serviceAdmins = config.get(Configs.SERVICE_ADMINS);
   }
@@ -114,21 +116,29 @@ public class AccessControlManager implements 
AccessControlDispatcher {
   }
 
   @Override
-  public User enableUser(String metalake, String externalId)
+  public User getUserById(String metalake, long userId)
       throws NoSuchUserException, NoSuchMetalakeException {
     return TreeLockUtils.doWithTreeLock(
-        AuthorizationUtils.ofUserExternalId(metalake, externalId),
+        AuthorizationUtils.ofUserId(metalake, userId),
+        LockType.READ,
+        () -> userGroupIdManager.getUserById(metalake, userId));
+  }
+
+  @Override
+  public boolean removeUserById(String metalake, long userId) throws 
NoSuchMetalakeException {
+    return TreeLockUtils.doWithTreeLock(
+        AuthorizationUtils.ofUserId(metalake, userId),
         LockType.WRITE,
-        () -> userGroupExternalManager.enableUser(metalake, externalId));
+        () -> userGroupIdManager.removeUserById(metalake, userId));
   }
 
   @Override
-  public User disableUser(String metalake, String externalId)
+  public User alterUserById(String metalake, long userId, UserChange... 
changes)
       throws NoSuchUserException, NoSuchMetalakeException {
     return TreeLockUtils.doWithTreeLock(
-        AuthorizationUtils.ofUserExternalId(metalake, externalId),
+        AuthorizationUtils.ofUserId(metalake, userId),
         LockType.WRITE,
-        () -> userGroupExternalManager.disableUser(metalake, externalId));
+        () -> userGroupIdManager.alterUserById(metalake, userId, changes));
   }
 
   @Override
diff --git 
a/core/src/main/java/org/apache/gravitino/authorization/AuthorizationUtils.java 
b/core/src/main/java/org/apache/gravitino/authorization/AuthorizationUtils.java
index c16d6a3beb..8c555cf29c 100644
--- 
a/core/src/main/java/org/apache/gravitino/authorization/AuthorizationUtils.java
+++ 
b/core/src/main/java/org/apache/gravitino/authorization/AuthorizationUtils.java
@@ -66,6 +66,8 @@ public class AuthorizationUtils {
   static final String USER_DOES_NOT_EXIST_MSG = "User %s does not exist in the 
metalake %s";
   static final String USER_WITH_EXTERNAL_ID_DOES_NOT_EXIST_MSG =
       "User with external id %s does not exist in the metalake %s";
+  static final String USER_WITH_ID_DOES_NOT_EXIST_MSG =
+      "User with id %s does not exist in the metalake %s";
   static final String GROUP_DOES_NOT_EXIST_MSG = "Group %s does not exist in 
the metalake %s";
   static final String GROUP_WITH_EXTERNAL_ID_DOES_NOT_EXIST_MSG =
       "Group with external id %s does not exist in the metalake %s";
@@ -165,6 +167,22 @@ public class AuthorizationUtils {
         externalId);
   }
 
+  /**
+   * Creates a synthetic {@link NameIdentifier} used only as a tree-lock path 
for user operations
+   * keyed by Gravitino-assigned id.
+   *
+   * @param metalake the metalake name
+   * @param userId the Gravitino-assigned user id
+   * @return a synthetic name identifier for tree locking only
+   */
+  public static NameIdentifier ofUserId(String metalake, long userId) {
+    return NameIdentifier.of(
+        metalake,
+        Entity.SYSTEM_CATALOG_RESERVED_NAME,
+        Entity.USER_ID_SCHEMA_NAME,
+        String.valueOf(userId));
+  }
+
   /**
    * Creates a synthetic {@link NameIdentifier} used only as a {@link
    * org.apache.gravitino.lock.TreeLockUtils} lock path for group operations 
keyed by external id.
@@ -202,6 +220,10 @@ public class AuthorizationUtils {
         metalake, Entity.SYSTEM_CATALOG_RESERVED_NAME, 
Entity.USER_EXTERNAL_ID_SCHEMA_NAME);
   }
 
+  public static Namespace ofUserIdNamespace(String metalake) {
+    return Namespace.of(metalake, Entity.SYSTEM_CATALOG_RESERVED_NAME, 
Entity.USER_ID_SCHEMA_NAME);
+  }
+
   public static Namespace ofGroupExternalIdNamespace(String metalake) {
     return Namespace.of(
         metalake, Entity.SYSTEM_CATALOG_RESERVED_NAME, 
Entity.GROUP_EXTERNAL_ID_SCHEMA_NAME);
@@ -222,6 +244,16 @@ public class AuthorizationUtils {
     checkUserExternalIdNamespace(ident.namespace());
   }
 
+  /**
+   * Validates that the name identifier refers to a user id in a metalake.
+   *
+   * @param ident the user id name identifier to validate
+   */
+  public static void checkUserId(NameIdentifier ident) {
+    NameIdentifier.check(ident != null, "User id identifier must not be null");
+    checkUserIdNamespace(ident.namespace());
+  }
+
   public static void checkGroup(NameIdentifier ident) {
     NameIdentifier.check(ident != null, "Group identifier must not be null");
     checkGroupNamespace(ident.namespace());
@@ -256,6 +288,13 @@ public class AuthorizationUtils {
         namespace);
   }
 
+  public static void checkUserIdNamespace(Namespace namespace) {
+    Namespace.check(
+        namespace != null && namespace.length() == 3,
+        "User id namespace must have 3 levels, the input namespace is %s",
+        namespace);
+  }
+
   public static void checkGroupExternalIdNamespace(Namespace namespace) {
     Namespace.check(
         namespace != null && namespace.length() == 3,
diff --git 
a/core/src/main/java/org/apache/gravitino/authorization/UserGroupExternalManager.java
 
b/core/src/main/java/org/apache/gravitino/authorization/UserGroupExternalManager.java
index 32475f6a64..7fb7bc62dd 100644
--- 
a/core/src/main/java/org/apache/gravitino/authorization/UserGroupExternalManager.java
+++ 
b/core/src/main/java/org/apache/gravitino/authorization/UserGroupExternalManager.java
@@ -117,50 +117,6 @@ class UserGroupExternalManager extends UserGroupManager {
     }
   }
 
-  User enableUser(String metalake, String externalId) throws 
NoSuchUserException {
-    return updateEnabledByExternalId(metalake, externalId, true);
-  }
-
-  User disableUser(String metalake, String externalId) throws 
NoSuchUserException {
-    return updateEnabledByExternalId(metalake, externalId, false);
-  }
-
-  private User updateEnabledByExternalId(String metalake, String externalId, 
boolean enabled)
-      throws NoSuchUserException {
-    try {
-      return store
-          .externalIdOperations()
-          .updateByExternalId(
-              AuthorizationUtils.ofUserExternalId(metalake, externalId),
-              Entity.EntityType.USER,
-              UserEntity.class,
-              user ->
-                  UserEntity.builder()
-                      .withId(user.id())
-                      .withName(user.name())
-                      .withNamespace(user.namespace())
-                      .withExternalId(user.externalId())
-                      .withEnabled(enabled)
-                      .withRoleNames(user.roleNames())
-                      .withRoleIds(user.roleIds())
-                      .withAuditInfo(user.auditInfo())
-                      .build());
-    } catch (NoSuchEntityException e) {
-      LOG.warn(
-          "User with external id {} does not exist in the metalake {}", 
externalId, metalake, e);
-      throw new NoSuchUserException(
-          AuthorizationUtils.USER_WITH_EXTERNAL_ID_DOES_NOT_EXIST_MSG, 
externalId, metalake);
-    } catch (IOException ioe) {
-      LOG.error(
-          "Updating enabled state for user with external id {} in the metalake 
{} failed due to"
-              + " storage issues",
-          externalId,
-          metalake,
-          ioe);
-      throw new RuntimeException(ioe);
-    }
-  }
-
   Group addGroup(String metalake, String group, String externalId)
       throws GroupAlreadyExistsException {
     try {
diff --git 
a/core/src/main/java/org/apache/gravitino/authorization/UserGroupIdManager.java 
b/core/src/main/java/org/apache/gravitino/authorization/UserGroupIdManager.java
new file mode 100644
index 0000000000..22001934b7
--- /dev/null
+++ 
b/core/src/main/java/org/apache/gravitino/authorization/UserGroupIdManager.java
@@ -0,0 +1,138 @@
+/*
+ * 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.authorization;
+
+import com.google.common.base.Preconditions;
+import java.io.IOException;
+import java.time.Instant;
+import org.apache.gravitino.Entity;
+import org.apache.gravitino.EntityStore;
+import org.apache.gravitino.exceptions.NoSuchEntityException;
+import org.apache.gravitino.exceptions.NoSuchUserException;
+import org.apache.gravitino.meta.AuditInfo;
+import org.apache.gravitino.meta.UserEntity;
+import org.apache.gravitino.storage.IdGenerator;
+import org.apache.gravitino.utils.PrincipalUtils;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/** Manages user and group operations keyed by Gravitino-assigned id within a 
metalake. */
+class UserGroupIdManager extends UserGroupManager {
+
+  private static final Logger LOG = 
LoggerFactory.getLogger(UserGroupIdManager.class);
+
+  /**
+   * Creates a {@link UserGroupIdManager} instance.
+   *
+   * @param store the entity store
+   * @param idGenerator the id generator
+   */
+  UserGroupIdManager(EntityStore store, IdGenerator idGenerator) {
+    super(store, idGenerator);
+  }
+
+  boolean removeUserById(String metalake, long userId) {
+    try {
+      return store
+          .idOperations()
+          .deleteById(AuthorizationUtils.ofUserId(metalake, userId), 
Entity.EntityType.USER);
+    } catch (IOException ioe) {
+      LOG.error(
+          "Removing user with id {} in the metalake {} failed due to storage 
issues",
+          userId,
+          metalake,
+          ioe);
+      throw new RuntimeException(ioe);
+    }
+  }
+
+  User getUserById(String metalake, long userId) throws NoSuchUserException {
+    try {
+      return store
+          .idOperations()
+          .getById(
+              AuthorizationUtils.ofUserId(metalake, userId),
+              Entity.EntityType.USER,
+              UserEntity.class);
+    } catch (NoSuchEntityException e) {
+      LOG.warn("User with id {} does not exist in the metalake {}", userId, 
metalake, e);
+      throw new NoSuchUserException(
+          AuthorizationUtils.USER_WITH_ID_DOES_NOT_EXIST_MSG, userId, 
metalake);
+    } catch (IOException ioe) {
+      LOG.error("Getting user with id {} failed due to storage issues", 
userId, ioe);
+      throw new RuntimeException(ioe);
+    }
+  }
+
+  User alterUserById(String metalake, long userId, UserChange... changes)
+      throws NoSuchUserException {
+    Preconditions.checkArgument(
+        changes != null && changes.length > 0, "User changes cannot be empty");
+    try {
+      return store
+          .idOperations()
+          .updateById(
+              AuthorizationUtils.ofUserId(metalake, userId),
+              Entity.EntityType.USER,
+              UserEntity.class,
+              user -> applyChanges(user, changes));
+    } catch (NoSuchEntityException e) {
+      LOG.warn("User with id {} does not exist in the metalake {}", userId, 
metalake, e);
+      throw new NoSuchUserException(
+          AuthorizationUtils.USER_WITH_ID_DOES_NOT_EXIST_MSG, userId, 
metalake);
+    } catch (IOException ioe) {
+      LOG.error(
+          "Altering user with id {} in the metalake {} failed due to storage 
issues",
+          userId,
+          metalake,
+          ioe);
+      throw new RuntimeException(ioe);
+    }
+  }
+
+  private static UserEntity applyChanges(UserEntity user, UserChange... 
changes) {
+    String externalId = user.externalId();
+    boolean enabled = user.enabled();
+    for (UserChange change : changes) {
+      if (change instanceof UserChange.UpdateEnabled) {
+        enabled = ((UserChange.UpdateEnabled) change).enabled();
+      } else if (change instanceof UserChange.UpdateExternalId) {
+        externalId = ((UserChange.UpdateExternalId) change).getNewExternalId();
+      } else {
+        throw new IllegalArgumentException("Unsupported user change: " + 
change);
+      }
+    }
+    return UserEntity.builder()
+        .withId(user.id())
+        .withName(user.name())
+        .withNamespace(user.namespace())
+        .withExternalId(externalId)
+        .withEnabled(enabled)
+        .withRoleNames(user.roleNames())
+        .withRoleIds(user.roleIds())
+        .withAuditInfo(
+            AuditInfo.builder()
+                .withCreator(user.auditInfo().creator())
+                .withCreateTime(user.auditInfo().createTime())
+                
.withLastModifier(PrincipalUtils.getCurrentPrincipal().getName())
+                .withLastModifiedTime(Instant.now())
+                .build())
+        .build();
+  }
+}
diff --git 
a/core/src/main/java/org/apache/gravitino/hook/AccessControlHookDispatcher.java 
b/core/src/main/java/org/apache/gravitino/hook/AccessControlHookDispatcher.java
index 3c1d8d7949..f8028b8c3a 100644
--- 
a/core/src/main/java/org/apache/gravitino/hook/AccessControlHookDispatcher.java
+++ 
b/core/src/main/java/org/apache/gravitino/hook/AccessControlHookDispatcher.java
@@ -34,6 +34,7 @@ import org.apache.gravitino.authorization.Privilege;
 import org.apache.gravitino.authorization.Role;
 import org.apache.gravitino.authorization.SecurableObject;
 import org.apache.gravitino.authorization.User;
+import org.apache.gravitino.authorization.UserChange;
 import org.apache.gravitino.exceptions.GroupAlreadyExistsException;
 import org.apache.gravitino.exceptions.IllegalRoleException;
 import org.apache.gravitino.exceptions.NoSuchGroupException;
@@ -99,15 +100,20 @@ public class AccessControlHookDispatcher implements 
AccessControlDispatcher {
   }
 
   @Override
-  public User enableUser(String metalake, String externalId)
+  public User getUserById(String metalake, long userId)
       throws NoSuchUserException, NoSuchMetalakeException {
-    return dispatcher.enableUser(metalake, externalId);
+    return dispatcher.getUserById(metalake, userId);
   }
 
   @Override
-  public User disableUser(String metalake, String externalId)
+  public boolean removeUserById(String metalake, long userId) throws 
NoSuchMetalakeException {
+    return dispatcher.removeUserById(metalake, userId);
+  }
+
+  @Override
+  public User alterUserById(String metalake, long userId, UserChange... 
changes)
       throws NoSuchUserException, NoSuchMetalakeException {
-    return dispatcher.disableUser(metalake, externalId);
+    return dispatcher.alterUserById(metalake, userId, changes);
   }
 
   @Override
diff --git 
a/core/src/main/java/org/apache/gravitino/listener/AccessControlEventDispatcher.java
 
b/core/src/main/java/org/apache/gravitino/listener/AccessControlEventDispatcher.java
index 9128e5f594..dd3f734402 100644
--- 
a/core/src/main/java/org/apache/gravitino/listener/AccessControlEventDispatcher.java
+++ 
b/core/src/main/java/org/apache/gravitino/listener/AccessControlEventDispatcher.java
@@ -29,6 +29,7 @@ import org.apache.gravitino.authorization.Privilege;
 import org.apache.gravitino.authorization.Role;
 import org.apache.gravitino.authorization.SecurableObject;
 import org.apache.gravitino.authorization.User;
+import org.apache.gravitino.authorization.UserChange;
 import org.apache.gravitino.exceptions.GroupAlreadyExistsException;
 import org.apache.gravitino.exceptions.IllegalRoleException;
 import org.apache.gravitino.exceptions.NoSuchGroupException;
@@ -50,12 +51,6 @@ import 
org.apache.gravitino.listener.api.event.CreateRolePreEvent;
 import org.apache.gravitino.listener.api.event.DeleteRoleEvent;
 import org.apache.gravitino.listener.api.event.DeleteRoleFailureEvent;
 import org.apache.gravitino.listener.api.event.DeleteRolePreEvent;
-import org.apache.gravitino.listener.api.event.DisableUserEvent;
-import org.apache.gravitino.listener.api.event.DisableUserFailureEvent;
-import org.apache.gravitino.listener.api.event.DisableUserPreEvent;
-import org.apache.gravitino.listener.api.event.EnableUserEvent;
-import org.apache.gravitino.listener.api.event.EnableUserFailureEvent;
-import org.apache.gravitino.listener.api.event.EnableUserPreEvent;
 import org.apache.gravitino.listener.api.event.GetGroupByExternalIdEvent;
 import 
org.apache.gravitino.listener.api.event.GetGroupByExternalIdFailureEvent;
 import org.apache.gravitino.listener.api.event.GetGroupByExternalIdPreEvent;
@@ -258,38 +253,22 @@ public class AccessControlEventDispatcher implements 
AccessControlDispatcher {
 
   /** {@inheritDoc} */
   @Override
-  public User enableUser(String metalake, String externalId)
+  public User getUserById(String metalake, long userId)
       throws NoSuchUserException, NoSuchMetalakeException {
-    String initiator = PrincipalUtils.getCurrentUserName();
-
-    eventBus.dispatchEvent(new EnableUserPreEvent(initiator, metalake, 
externalId));
-    try {
-      User userObject = dispatcher.enableUser(metalake, externalId);
-      eventBus.dispatchEvent(new EnableUserEvent(initiator, metalake, new 
UserInfo(userObject)));
+    return dispatcher.getUserById(metalake, userId);
+  }
 
-      return userObject;
-    } catch (Exception e) {
-      eventBus.dispatchEvent(new EnableUserFailureEvent(initiator, metalake, 
e, externalId));
-      throw e;
-    }
+  /** {@inheritDoc} */
+  @Override
+  public boolean removeUserById(String metalake, long userId) throws 
NoSuchMetalakeException {
+    return dispatcher.removeUserById(metalake, userId);
   }
 
   /** {@inheritDoc} */
   @Override
-  public User disableUser(String metalake, String externalId)
+  public User alterUserById(String metalake, long userId, UserChange... 
changes)
       throws NoSuchUserException, NoSuchMetalakeException {
-    String initiator = PrincipalUtils.getCurrentUserName();
-
-    eventBus.dispatchEvent(new DisableUserPreEvent(initiator, metalake, 
externalId));
-    try {
-      User userObject = dispatcher.disableUser(metalake, externalId);
-      eventBus.dispatchEvent(new DisableUserEvent(initiator, metalake, new 
UserInfo(userObject)));
-
-      return userObject;
-    } catch (Exception e) {
-      eventBus.dispatchEvent(new DisableUserFailureEvent(initiator, metalake, 
e, externalId));
-      throw e;
-    }
+    return dispatcher.alterUserById(metalake, userId, changes);
   }
 
   /** {@inheritDoc} */
diff --git 
a/core/src/main/java/org/apache/gravitino/listener/api/event/DisableUserEvent.java
 
b/core/src/main/java/org/apache/gravitino/listener/api/event/DisableUserEvent.java
deleted file mode 100644
index e52e9420b7..0000000000
--- 
a/core/src/main/java/org/apache/gravitino/listener/api/event/DisableUserEvent.java
+++ /dev/null
@@ -1,65 +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.listener.api.event;
-
-import org.apache.gravitino.annotation.DeveloperApi;
-import org.apache.gravitino.authorization.AuthorizationUtils;
-import org.apache.gravitino.listener.api.info.UserInfo;
-
-/** Represents an event triggered after successfully disabling a user by 
external id. */
-@DeveloperApi
-public class DisableUserEvent extends UserEvent {
-  private final UserInfo updatedUserInfo;
-
-  /**
-   * Creates a new {DisableUserEvent}.
-   *
-   * @param initiator The user who initiated the request.
-   * @param metalake The metalake name.
-   * @param updatedUserInfo The updated user information.
-   */
-  public DisableUserEvent(String initiator, String metalake, UserInfo 
updatedUserInfo) {
-    super(
-        initiator,
-        AuthorizationUtils.ofUserExternalId(
-            metalake,
-            updatedUserInfo
-                .externalId()
-                .orElseThrow(
-                    () ->
-                        new IllegalStateException(
-                            "User external id is required for 
DisableUserEvent"))));
-    this.updatedUserInfo = updatedUserInfo;
-  }
-
-  /**
-   * Returns the updated user information.
-   *
-   * @return The user information.
-   */
-  public UserInfo updatedUserInfo() {
-    return updatedUserInfo;
-  }
-
-  @Override
-  public OperationType operationType() {
-    return OperationType.DISABLE_USER;
-  }
-}
diff --git 
a/core/src/main/java/org/apache/gravitino/listener/api/event/DisableUserFailureEvent.java
 
b/core/src/main/java/org/apache/gravitino/listener/api/event/DisableUserFailureEvent.java
deleted file mode 100644
index ae6361ccf0..0000000000
--- 
a/core/src/main/java/org/apache/gravitino/listener/api/event/DisableUserFailureEvent.java
+++ /dev/null
@@ -1,57 +0,0 @@
-/*
- * Licensed to the Apache Software Foundation (ASF) under one
- * or more contributor license agreements.  See the NOTICE file
- * distributed with this work for additional information
- * regarding copyright ownership.  The ASF licenses this file
- * to you under the Apache License, Version 2.0 (the
- * "License"); you may not use this file except in compliance
- * with the License.  You may obtain a copy of the License at
- *
- *  http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing,
- * software distributed under the License is distributed on an
- * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
- * KIND, either express or implied.  See the License for the
- * specific language governing permissions and limitations
- * under the License.
- */
-
-package org.apache.gravitino.listener.api.event;
-
-import org.apache.gravitino.annotation.DeveloperApi;
-import org.apache.gravitino.authorization.AuthorizationUtils;
-
-/** Represents an event triggered when disabling a user by external id fails. 
*/
-@DeveloperApi
-public class DisableUserFailureEvent extends UserFailureEvent {
-  private final String externalId;
-
-  /**
-   * Creates a new {DisableUserFailureEvent}.
-   *
-   * @param initiator The user who initiated the request.
-   * @param metalake The metalake name.
-   * @param exception The exception that caused the failure.
-   * @param externalId The external identifier of the user.
-   */
-  public DisableUserFailureEvent(
-      String initiator, String metalake, Exception exception, String 
externalId) {
-    super(initiator, AuthorizationUtils.ofUserExternalId(metalake, 
externalId), exception);
-    this.externalId = externalId;
-  }
-
-  /**
-   * Returns the external identifier of the user.
-   *
-   * @return The external identifier.
-   */
-  public String externalId() {
-    return externalId;
-  }
-
-  @Override
-  public OperationType operationType() {
-    return OperationType.DISABLE_USER;
-  }
-}
diff --git 
a/core/src/main/java/org/apache/gravitino/listener/api/event/DisableUserPreEvent.java
 
b/core/src/main/java/org/apache/gravitino/listener/api/event/DisableUserPreEvent.java
deleted file mode 100644
index 91a4458c4d..0000000000
--- 
a/core/src/main/java/org/apache/gravitino/listener/api/event/DisableUserPreEvent.java
+++ /dev/null
@@ -1,55 +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.listener.api.event;
-
-import org.apache.gravitino.annotation.DeveloperApi;
-import org.apache.gravitino.authorization.AuthorizationUtils;
-
-/** Represents an event triggered before disabling a user by external id. */
-@DeveloperApi
-public class DisableUserPreEvent extends UserPreEvent {
-  private final String externalId;
-
-  /**
-   * Creates a new {DisableUserPreEvent}.
-   *
-   * @param initiator The user who initiated the request.
-   * @param metalake The metalake name.
-   * @param externalId The external identifier of the user.
-   */
-  public DisableUserPreEvent(String initiator, String metalake, String 
externalId) {
-    super(initiator, AuthorizationUtils.ofUserExternalId(metalake, 
externalId));
-    this.externalId = externalId;
-  }
-
-  /**
-   * Returns the external identifier of the user.
-   *
-   * @return The external identifier.
-   */
-  public String externalId() {
-    return externalId;
-  }
-
-  @Override
-  public OperationType operationType() {
-    return OperationType.DISABLE_USER;
-  }
-}
diff --git 
a/core/src/main/java/org/apache/gravitino/listener/api/event/EnableUserEvent.java
 
b/core/src/main/java/org/apache/gravitino/listener/api/event/EnableUserEvent.java
deleted file mode 100644
index 4f08221a23..0000000000
--- 
a/core/src/main/java/org/apache/gravitino/listener/api/event/EnableUserEvent.java
+++ /dev/null
@@ -1,65 +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.listener.api.event;
-
-import org.apache.gravitino.annotation.DeveloperApi;
-import org.apache.gravitino.authorization.AuthorizationUtils;
-import org.apache.gravitino.listener.api.info.UserInfo;
-
-/** Represents an event triggered after successfully enabling a user by 
external id. */
-@DeveloperApi
-public class EnableUserEvent extends UserEvent {
-  private final UserInfo updatedUserInfo;
-
-  /**
-   * Creates a new {EnableUserEvent}.
-   *
-   * @param initiator The user who initiated the request.
-   * @param metalake The metalake name.
-   * @param updatedUserInfo The updated user information.
-   */
-  public EnableUserEvent(String initiator, String metalake, UserInfo 
updatedUserInfo) {
-    super(
-        initiator,
-        AuthorizationUtils.ofUserExternalId(
-            metalake,
-            updatedUserInfo
-                .externalId()
-                .orElseThrow(
-                    () ->
-                        new IllegalStateException(
-                            "User external id is required for 
EnableUserEvent"))));
-    this.updatedUserInfo = updatedUserInfo;
-  }
-
-  /**
-   * Returns the updated user information.
-   *
-   * @return The user information.
-   */
-  public UserInfo updatedUserInfo() {
-    return updatedUserInfo;
-  }
-
-  @Override
-  public OperationType operationType() {
-    return OperationType.ENABLE_USER;
-  }
-}
diff --git 
a/core/src/main/java/org/apache/gravitino/listener/api/event/EnableUserFailureEvent.java
 
b/core/src/main/java/org/apache/gravitino/listener/api/event/EnableUserFailureEvent.java
deleted file mode 100644
index 08a2cab0a7..0000000000
--- 
a/core/src/main/java/org/apache/gravitino/listener/api/event/EnableUserFailureEvent.java
+++ /dev/null
@@ -1,57 +0,0 @@
-/*
- * Licensed to the Apache Software Foundation (ASF) under one
- * or more contributor license agreements.  See the NOTICE file
- * distributed with this work for additional information
- * regarding copyright ownership.  The ASF licenses this file
- * to you under the Apache License, Version 2.0 (the
- * "License"); you may not use this file except in compliance
- * with the License.  You may obtain a copy of the License at
- *
- *  http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing,
- * software distributed under the License is distributed on an
- * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
- * KIND, either express or implied.  See the License for the
- * specific language governing permissions and limitations
- * under the License.
- */
-
-package org.apache.gravitino.listener.api.event;
-
-import org.apache.gravitino.annotation.DeveloperApi;
-import org.apache.gravitino.authorization.AuthorizationUtils;
-
-/** Represents an event triggered when enabling a user by external id fails. */
-@DeveloperApi
-public class EnableUserFailureEvent extends UserFailureEvent {
-  private final String externalId;
-
-  /**
-   * Creates a new {EnableUserFailureEvent}.
-   *
-   * @param initiator The user who initiated the request.
-   * @param metalake The metalake name.
-   * @param exception The exception that caused the failure.
-   * @param externalId The external identifier of the user.
-   */
-  public EnableUserFailureEvent(
-      String initiator, String metalake, Exception exception, String 
externalId) {
-    super(initiator, AuthorizationUtils.ofUserExternalId(metalake, 
externalId), exception);
-    this.externalId = externalId;
-  }
-
-  /**
-   * Returns the external identifier of the user.
-   *
-   * @return The external identifier.
-   */
-  public String externalId() {
-    return externalId;
-  }
-
-  @Override
-  public OperationType operationType() {
-    return OperationType.ENABLE_USER;
-  }
-}
diff --git 
a/core/src/main/java/org/apache/gravitino/listener/api/event/EnableUserPreEvent.java
 
b/core/src/main/java/org/apache/gravitino/listener/api/event/EnableUserPreEvent.java
deleted file mode 100644
index e89a76ee04..0000000000
--- 
a/core/src/main/java/org/apache/gravitino/listener/api/event/EnableUserPreEvent.java
+++ /dev/null
@@ -1,55 +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.listener.api.event;
-
-import org.apache.gravitino.annotation.DeveloperApi;
-import org.apache.gravitino.authorization.AuthorizationUtils;
-
-/** Represents an event triggered before enabling a user by external id. */
-@DeveloperApi
-public class EnableUserPreEvent extends UserPreEvent {
-  private final String externalId;
-
-  /**
-   * Creates a new {@link EnableUserPreEvent}.
-   *
-   * @param initiator The user who initiated the request.
-   * @param metalake The metalake name.
-   * @param externalId The external identifier of the user.
-   */
-  public EnableUserPreEvent(String initiator, String metalake, String 
externalId) {
-    super(initiator, AuthorizationUtils.ofUserExternalId(metalake, 
externalId));
-    this.externalId = externalId;
-  }
-
-  /**
-   * Returns the external identifier of the user.
-   *
-   * @return The external identifier.
-   */
-  public String externalId() {
-    return externalId;
-  }
-
-  @Override
-  public OperationType operationType() {
-    return OperationType.ENABLE_USER;
-  }
-}
diff --git 
a/core/src/main/java/org/apache/gravitino/listener/api/info/UserInfo.java 
b/core/src/main/java/org/apache/gravitino/listener/api/info/UserInfo.java
index 295342dcfc..c2a0614ad2 100644
--- a/core/src/main/java/org/apache/gravitino/listener/api/info/UserInfo.java
+++ b/core/src/main/java/org/apache/gravitino/listener/api/info/UserInfo.java
@@ -19,6 +19,7 @@
 
 package org.apache.gravitino.listener.api.info;
 
+import com.google.common.base.Preconditions;
 import java.util.List;
 import java.util.Optional;
 import org.apache.gravitino.annotation.DeveloperApi;
@@ -27,6 +28,7 @@ import org.apache.gravitino.authorization.User;
 /** Provides read-only access to user information for event listeners. */
 @DeveloperApi
 public class UserInfo {
+  private final Long id;
   private final String name;
   private final Optional<String> externalId;
   private final boolean enabled;
@@ -38,12 +40,22 @@ public class UserInfo {
    * @param user the {@link User} instance.
    */
   public UserInfo(User user) {
+    this.id = Preconditions.checkNotNull(user.id(), "user id");
     this.name = user.name();
     this.externalId = Optional.ofNullable(user.externalId());
     this.enabled = user.enabled();
     this.roles = user.roles();
   }
 
+  /**
+   * Returns the Gravitino-assigned id of the user.
+   *
+   * @return the user id
+   */
+  public Long id() {
+    return id;
+  }
+
   /**
    * Returns the name of the user.
    *
diff --git 
a/core/src/main/java/org/apache/gravitino/storage/relational/JDBCBackend.java 
b/core/src/main/java/org/apache/gravitino/storage/relational/JDBCBackend.java
index daef6d052e..d7e7a0e1a5 100644
--- 
a/core/src/main/java/org/apache/gravitino/storage/relational/JDBCBackend.java
+++ 
b/core/src/main/java/org/apache/gravitino/storage/relational/JDBCBackend.java
@@ -42,6 +42,7 @@ import org.apache.gravitino.Namespace;
 import org.apache.gravitino.RelationalEntity;
 import org.apache.gravitino.SupportsRelationOperations;
 import org.apache.gravitino.UnsupportedEntityTypeException;
+import org.apache.gravitino.authorization.AuthorizationUtils;
 import org.apache.gravitino.exceptions.NoSuchEntityException;
 import org.apache.gravitino.meta.BaseMetalake;
 import org.apache.gravitino.meta.CatalogEntity;
@@ -342,6 +343,38 @@ public class JDBCBackend implements RelationalBackend, 
SupportsOrphanedRelationC
     }
   }
 
+  @Override
+  public <E extends Entity & HasIdentifier> E getById(
+      NameIdentifier ident, Entity.EntityType entityType)
+      throws NoSuchEntityException, IOException {
+    switch (entityType) {
+      case USER:
+        AuthorizationUtils.checkUserId(ident);
+        return (E)
+            UserMetaService.getInstance()
+                .getUserById(ident.namespace().level(0), 
Long.parseLong(ident.name()));
+      default:
+        throw new UnsupportedEntityTypeException(
+            "Unsupported entity type: %s for get by id operation", entityType);
+    }
+  }
+
+  @Override
+  public <E extends Entity & HasIdentifier> E updateById(
+      NameIdentifier ident, Entity.EntityType entityType, Function<E, E> 
updater)
+      throws NoSuchEntityException, IOException {
+    switch (entityType) {
+      case USER:
+        AuthorizationUtils.checkUserId(ident);
+        return (E)
+            UserMetaService.getInstance()
+                .updateUserById(ident.namespace().level(0), 
Long.parseLong(ident.name()), updater);
+      default:
+        throw new UnsupportedEntityTypeException(
+            "Unsupported entity type: %s for update by id operation", 
entityType);
+    }
+  }
+
   @Override
   public <E extends Entity & HasIdentifier> List<E> batchGet(
       List<NameIdentifier> identifiers, Entity.EntityType entityType) {
diff --git 
a/core/src/main/java/org/apache/gravitino/storage/relational/RelationalBackend.java
 
b/core/src/main/java/org/apache/gravitino/storage/relational/RelationalBackend.java
index cffdd5e278..50e48af1c2 100644
--- 
a/core/src/main/java/org/apache/gravitino/storage/relational/RelationalBackend.java
+++ 
b/core/src/main/java/org/apache/gravitino/storage/relational/RelationalBackend.java
@@ -142,6 +142,35 @@ public interface RelationalBackend extends Closeable, 
SupportsRelationOperations
       NameIdentifier ident, Entity.EntityType entityType, Function<E, E> 
updater)
       throws NoSuchEntityException, IOException;
 
+  /**
+   * Retrieves an entity by Gravitino-assigned id.
+   *
+   * @param <E> The type of the entity returned.
+   * @param ident The id name identifier.
+   * @param entityType The type of the entity.
+   * @return The entity associated with the id name identifier.
+   * @throws NoSuchEntityException If the entity does not exist.
+   * @throws IOException If an I/O exception occurs during retrieval.
+   */
+  <E extends Entity & HasIdentifier> E getById(NameIdentifier ident, 
Entity.EntityType entityType)
+      throws NoSuchEntityException, IOException;
+
+  /**
+   * Updates an entity by Gravitino-assigned id.
+   *
+   * @param <E> the type of the entity returned
+   * @param ident the id name identifier
+   * @param entityType the type of the entity
+   * @param updater a {@link Function} that takes the current entity instance 
and returns the
+   *     updated instance
+   * @return the updated entity
+   * @throws NoSuchEntityException if the entity does not exist
+   * @throws IOException if the update operation fails
+   */
+  <E extends Entity & HasIdentifier> E updateById(
+      NameIdentifier ident, Entity.EntityType entityType, Function<E, E> 
updater)
+      throws NoSuchEntityException, IOException;
+
   /**
    * Batch retrieves the entities associated with the identifiers and the 
entity type.
    *
diff --git 
a/core/src/main/java/org/apache/gravitino/storage/relational/RelationalEntityStore.java
 
b/core/src/main/java/org/apache/gravitino/storage/relational/RelationalEntityStore.java
index cd303eb0a9..b762320169 100644
--- 
a/core/src/main/java/org/apache/gravitino/storage/relational/RelationalEntityStore.java
+++ 
b/core/src/main/java/org/apache/gravitino/storage/relational/RelationalEntityStore.java
@@ -48,6 +48,7 @@ import org.apache.gravitino.RelationQuery;
 import org.apache.gravitino.RelationUpdate;
 import org.apache.gravitino.RelationalEntity;
 import org.apache.gravitino.SupportsExternalIdOperations;
+import org.apache.gravitino.SupportsIdOperations;
 import org.apache.gravitino.SupportsRelationOperations;
 import org.apache.gravitino.authorization.SecurableObject;
 import org.apache.gravitino.cache.CacheFactory;
@@ -76,6 +77,7 @@ public class RelationalEntityStore
     implements EntityStore,
         SupportsRelationOperations,
         SupportsExternalIdOperations,
+        SupportsIdOperations,
         SupportsEntityChangeLog {
   private static final Logger LOGGER = 
LoggerFactory.getLogger(RelationalEntityStore.class);
   public static final ImmutableMap<String, String> RELATIONAL_BACKENDS =
@@ -205,6 +207,11 @@ public class RelationalEntityStore
     return this;
   }
 
+  @Override
+  public SupportsIdOperations idOperations() {
+    return this;
+  }
+
   @Override
   public <E extends Entity & HasIdentifier> E getByExternalId(
       NameIdentifier ident, Entity.EntityType entityType, Class<E> type)
@@ -240,6 +247,39 @@ public class RelationalEntityStore
     }
   }
 
+  @Override
+  public <E extends Entity & HasIdentifier> E getById(
+      NameIdentifier ident, Entity.EntityType entityType, Class<E> type)
+      throws NoSuchEntityException, IOException {
+    return backend.getById(ident, entityType);
+  }
+
+  @Override
+  public <E extends Entity & HasIdentifier> E updateById(
+      NameIdentifier ident, Entity.EntityType entityType, Class<E> type, 
Function<E, E> updater)
+      throws NoSuchEntityException, IOException {
+    E updatedEntity = backend.updateById(ident, entityType, updater);
+    cache.invalidate(updatedEntity.nameIdentifier(), entityType);
+    return updatedEntity;
+  }
+
+  @Override
+  public boolean deleteById(NameIdentifier ident, Entity.EntityType 
entityType) throws IOException {
+    NameIdentifier nameIdent = null;
+    try {
+      HasIdentifier entity = backend.getById(ident, entityType);
+      nameIdent = entity.nameIdentifier();
+      return backend.delete(nameIdent, entityType, false);
+    } catch (NoSuchEntityException e) {
+      LOGGER.warn("The entity to be deleted by id does not exist in the store: 
{}", ident, e);
+      return false;
+    } finally {
+      if (nameIdent != null) {
+        cache.invalidate(nameIdent, entityType);
+      }
+    }
+  }
+
   @Override
   public <E extends Entity & HasIdentifier> List<E> batchGet(
       List<NameIdentifier> idents, Entity.EntityType entityType, Class<E> 
clazz) {
diff --git 
a/core/src/main/java/org/apache/gravitino/storage/relational/mapper/UserMetaMapper.java
 
b/core/src/main/java/org/apache/gravitino/storage/relational/mapper/UserMetaMapper.java
index 87f8a26b17..45bcb63c5d 100644
--- 
a/core/src/main/java/org/apache/gravitino/storage/relational/mapper/UserMetaMapper.java
+++ 
b/core/src/main/java/org/apache/gravitino/storage/relational/mapper/UserMetaMapper.java
@@ -104,6 +104,12 @@ public interface UserMetaMapper {
   UserPO selectUserMetaByMetalakeNameAndExternalId(
       @Param("metalakeName") String metalakeName, @Param("externalId") String 
externalId);
 
+  @SelectProvider(
+      type = UserMetaSQLProviderFactory.class,
+      method = "selectUserMetaByMetalakeNameAndId")
+  UserPO selectUserMetaByMetalakeNameAndId(
+      @Param("metalakeName") String metalakeName, @Param("userId") Long 
userId);
+
   @UpdateProvider(type = UserMetaSQLProviderFactory.class, method = 
"updateUserMetaByExternalId")
   Integer updateUserMetaByExternalId(
       @Param("newUserMeta") UserPO newUserPO, @Param("oldUserMeta") UserPO 
oldUserPO);
diff --git 
a/core/src/main/java/org/apache/gravitino/storage/relational/mapper/UserMetaSQLProviderFactory.java
 
b/core/src/main/java/org/apache/gravitino/storage/relational/mapper/UserMetaSQLProviderFactory.java
index cc7de6fb28..9d668dd2f3 100644
--- 
a/core/src/main/java/org/apache/gravitino/storage/relational/mapper/UserMetaSQLProviderFactory.java
+++ 
b/core/src/main/java/org/apache/gravitino/storage/relational/mapper/UserMetaSQLProviderFactory.java
@@ -120,6 +120,11 @@ public class UserMetaSQLProviderFactory {
     return 
getProvider().selectUserMetaByMetalakeNameAndExternalId(metalakeName, 
externalId);
   }
 
+  public static String selectUserMetaByMetalakeNameAndId(
+      @Param("metalakeName") String metalakeName, @Param("userId") Long 
userId) {
+    return getProvider().selectUserMetaByMetalakeNameAndId(metalakeName, 
userId);
+  }
+
   public static String updateUserMetaByExternalId(
       @Param("newUserMeta") UserPO newUserPO, @Param("oldUserMeta") UserPO 
oldUserPO) {
     return getProvider().updateUserMetaByExternalId(newUserPO, oldUserPO);
diff --git 
a/core/src/main/java/org/apache/gravitino/storage/relational/mapper/provider/base/UserMetaBaseSQLProvider.java
 
b/core/src/main/java/org/apache/gravitino/storage/relational/mapper/provider/base/UserMetaBaseSQLProvider.java
index 6c0dc22334..c0647d3855 100644
--- 
a/core/src/main/java/org/apache/gravitino/storage/relational/mapper/provider/base/UserMetaBaseSQLProvider.java
+++ 
b/core/src/main/java/org/apache/gravitino/storage/relational/mapper/provider/base/UserMetaBaseSQLProvider.java
@@ -70,6 +70,23 @@ public class UserMetaBaseSQLProvider {
         + " AND ut.deleted_at = 0 AND mt.deleted_at = 0";
   }
 
+  public String selectUserMetaByMetalakeNameAndId(
+      @Param("metalakeName") String metalakeName, @Param("userId") Long 
userId) {
+    return "SELECT ut.user_id as userId, ut.user_name as userName,"
+        + " ut.metalake_id as metalakeId,"
+        + " ut.external_id as externalId, ut.enabled as enabled,"
+        + " ut.audit_info as auditInfo, ut.current_version as currentVersion,"
+        + " ut.last_version as lastVersion, ut.deleted_at as deletedAt"
+        + " FROM "
+        + USER_TABLE_NAME
+        + " ut JOIN "
+        + MetalakeMetaMapper.TABLE_NAME
+        + " mt ON ut.metalake_id = mt.metalake_id"
+        + " WHERE mt.metalake_name = #{metalakeName}"
+        + " AND ut.user_id = #{userId}"
+        + " AND ut.deleted_at = 0 AND mt.deleted_at = 0";
+  }
+
   public String updateUserMetaByExternalId(
       @Param("newUserMeta") UserPO newUserPO, @Param("oldUserMeta") UserPO 
oldUserPO) {
     return "UPDATE "
diff --git 
a/core/src/main/java/org/apache/gravitino/storage/relational/service/UserMetaService.java
 
b/core/src/main/java/org/apache/gravitino/storage/relational/service/UserMetaService.java
index ba8cf5271a..0fbdab8ca2 100644
--- 
a/core/src/main/java/org/apache/gravitino/storage/relational/service/UserMetaService.java
+++ 
b/core/src/main/java/org/apache/gravitino/storage/relational/service/UserMetaService.java
@@ -387,4 +387,89 @@ public class UserMetaService {
     }
     return newEntity;
   }
+
+  private UserPO getUserPOByMetalakeNameAndId(String metalakeName, Long 
userId) {
+    UserPO userPO =
+        SessionUtils.getWithoutCommit(
+            UserMetaMapper.class,
+            mapper -> mapper.selectUserMetaByMetalakeNameAndId(metalakeName, 
userId));
+
+    if (userPO == null) {
+      throw new NoSuchEntityException(
+          NoSuchEntityException.NO_SUCH_ENTITY_MESSAGE,
+          Entity.EntityType.USER.name().toLowerCase(),
+          String.valueOf(userId));
+    }
+    return userPO;
+  }
+
+  @Monitored(metricsSource = GRAVITINO_RELATIONAL_STORE_METRIC_NAME, 
baseMetricName = "getUserById")
+  public UserEntity getUserById(String metalake, long userId) {
+    Namespace userNamespace = AuthorizationUtils.ofUserNamespace(metalake);
+    UserPO userPO = getUserPOByMetalakeNameAndId(metalake, userId);
+    List<RolePO> rolePOs = 
RoleMetaService.getInstance().listRolesByUserId(userPO.getUserId());
+    return POConverters.fromUserPO(userPO, rolePOs, userNamespace);
+  }
+
+  @Monitored(
+      metricsSource = GRAVITINO_RELATIONAL_STORE_METRIC_NAME,
+      baseMetricName = "updateUserById")
+  public <E extends Entity & HasIdentifier> UserEntity updateUserById(
+      String metalake, long userId, Function<E, E> updater) throws IOException 
{
+    Namespace userNamespace = AuthorizationUtils.ofUserNamespace(metalake);
+    UserPO oldUserPO = getUserPOByMetalakeNameAndId(metalake, userId);
+    List<RolePO> rolePOs = 
RoleMetaService.getInstance().listRolesByUserId(oldUserPO.getUserId());
+    UserEntity oldEntity = POConverters.fromUserPO(oldUserPO, rolePOs, 
userNamespace);
+    UserEntity newEntity = (UserEntity) updater.apply((E) oldEntity);
+    Preconditions.checkArgument(
+        Objects.equals(oldEntity.id(), newEntity.id()),
+        "The updated user entity id: %s should be same with the user entity id 
before: %s",
+        newEntity.id(),
+        oldEntity.id());
+
+    try {
+      SessionUtils.doMultipleWithCommit(
+          () ->
+              SessionUtils.doWithoutCommit(
+                  UserMetaMapper.class,
+                  mapper ->
+                      mapper.updateUserMeta(
+                          POConverters.updateUserPOWithVersion(oldUserPO, 
newEntity), oldUserPO)),
+          () ->
+              SessionUtils.doWithoutCommit(
+                  UserMetaMapper.class,
+                  mapper -> mapper.touchUserUpdatedAt(oldUserPO.getUserId())));
+    } catch (RuntimeException re) {
+      ExceptionUtils.checkSQLException(
+          re, Entity.EntityType.USER, newEntity.nameIdentifier().toString());
+      throw re;
+    }
+    return newEntity;
+  }
+
+  @Monitored(
+      metricsSource = GRAVITINO_RELATIONAL_STORE_METRIC_NAME,
+      baseMetricName = "deleteUserById")
+  public boolean deleteUserById(String metalake, long userId) {
+    try {
+      getUserPOByMetalakeNameAndId(metalake, userId);
+    } catch (NoSuchEntityException e) {
+      return false;
+    }
+
+    SessionUtils.doMultipleWithCommit(
+        () ->
+            SessionUtils.doWithoutCommit(
+                UserMetaMapper.class, mapper -> 
mapper.softDeleteUserMetaByUserId(userId)),
+        () ->
+            SessionUtils.doWithoutCommit(
+                UserRoleRelMapper.class, mapper -> 
mapper.softDeleteUserRoleRelByUserId(userId)),
+        () ->
+            SessionUtils.doWithoutCommit(
+                OwnerMetaMapper.class,
+                mapper ->
+                    mapper.softDeleteOwnerRelByOwnerIdAndType(
+                        userId, Entity.EntityType.USER.name())));
+    return true;
+  }
 }
diff --git 
a/core/src/test/java/org/apache/gravitino/authorization/TestAccessControlManager.java
 
b/core/src/test/java/org/apache/gravitino/authorization/TestAccessControlManager.java
index 8cc94e01fb..c80b4abadc 100644
--- 
a/core/src/test/java/org/apache/gravitino/authorization/TestAccessControlManager.java
+++ 
b/core/src/test/java/org/apache/gravitino/authorization/TestAccessControlManager.java
@@ -471,10 +471,12 @@ public class TestAccessControlManager {
     User added = accessControlManager.addUser(METALAKE, user, extId, true);
     Assertions.assertEquals(extId, added.externalId());
     Assertions.assertTrue(added.enabled());
+    long userId = added.id();
 
     createCatalogRole("ext_role");
     accessControlManager.grantRolesToUser(METALAKE, 
Lists.newArrayList("ext_role"), user);
-    User disabled = accessControlManager.disableUser(METALAKE, extId);
+    User disabled =
+        accessControlManager.alterUserById(METALAKE, userId, 
UserChange.updateEnabled(false));
     Assertions.assertFalse(disabled.enabled());
     assertSortedRoles(disabled, "ext_role");
 
@@ -483,7 +485,8 @@ public class TestAccessControlManager {
     assertSortedRoles(accessControlManager.getUser(METALAKE, user), 
"ext_role", "ext_role2");
     Assertions.assertFalse(accessControlManager.getUserByExternalId(METALAKE, 
extId).enabled());
 
-    User enabled = accessControlManager.enableUser(METALAKE, extId);
+    User enabled =
+        accessControlManager.alterUserById(METALAKE, userId, 
UserChange.updateEnabled(true));
     Assertions.assertTrue(enabled.enabled());
     assertSortedRoles(enabled, "ext_role", "ext_role2");
 
@@ -506,12 +509,9 @@ public class TestAccessControlManager {
     assertMissingExt(
         NoSuchGroupException.class,
         () -> accessControlManager.getGroupByExternalId(METALAKE, 
"missing-ext-id"));
-    assertMissingExt(
-        NoSuchUserException.class,
-        () -> accessControlManager.disableUser(METALAKE, "missing-ext-id"));
-    assertMissingExt(
+    Assertions.assertThrows(
         NoSuchUserException.class,
-        () -> accessControlManager.enableUser(METALAKE, "missing-ext-id"));
+        () -> accessControlManager.alterUserById(METALAKE, -1L, 
UserChange.updateEnabled(false)));
   }
 
   @Test
@@ -555,12 +555,58 @@ public class TestAccessControlManager {
         accessControlManager.removeGroupByExternalId(METALAKE, 
"missing-ext-id"));
   }
 
+  @Test
+  public void testUserById() {
+    User added = accessControlManager.addUser(METALAKE, "id_user", 
"ext-id-user", true);
+    long userId = added.id();
+    Assertions.assertNotNull(userId);
+
+    User loaded = accessControlManager.getUserById(METALAKE, userId);
+    Assertions.assertEquals(userId, loaded.id());
+    Assertions.assertEquals("id_user", loaded.name());
+    Assertions.assertEquals("ext-id-user", loaded.externalId());
+    Assertions.assertTrue(loaded.enabled());
+
+    User disabled =
+        accessControlManager.alterUserById(METALAKE, userId, 
UserChange.updateEnabled(false));
+    Assertions.assertFalse(disabled.enabled());
+    Assertions.assertEquals(userId, disabled.id());
+    Assertions.assertEquals("ext-id-user", disabled.externalId());
+    Assertions.assertEquals(added.auditInfo().creator(), 
disabled.auditInfo().creator());
+    Assertions.assertEquals(added.auditInfo().createTime(), 
disabled.auditInfo().createTime());
+    Assertions.assertNotNull(disabled.auditInfo().lastModifier());
+    Assertions.assertNotNull(disabled.auditInfo().lastModifiedTime());
+    Assertions.assertTrue(
+        
!disabled.auditInfo().lastModifiedTime().isBefore(added.auditInfo().createTime()));
+
+    User enabledAndExt =
+        accessControlManager.alterUserById(
+            METALAKE,
+            userId,
+            UserChange.updateEnabled(true),
+            UserChange.updateExternalId("ext-id-user-2"));
+    Assertions.assertTrue(enabledAndExt.enabled());
+    Assertions.assertEquals(userId, enabledAndExt.id());
+    Assertions.assertEquals("ext-id-user-2", enabledAndExt.externalId());
+    User byNewExt = accessControlManager.getUserByExternalId(METALAKE, 
"ext-id-user-2");
+    Assertions.assertEquals("id_user", byNewExt.name());
+    Assertions.assertEquals(userId, byNewExt.id());
+
+    Assertions.assertTrue(accessControlManager.removeUserById(METALAKE, 
userId));
+    Assertions.assertThrows(
+        NoSuchUserException.class, () -> 
accessControlManager.getUserById(METALAKE, userId));
+    Assertions.assertFalse(accessControlManager.removeUserById(METALAKE, 
userId));
+    Assertions.assertThrows(
+        NoSuchUserException.class,
+        () -> accessControlManager.alterUserById(METALAKE, userId, 
UserChange.updateEnabled(true)));
+  }
+
   @Test
   public void testExtCache() {
     String extId = "ext-cache-user";
-    accessControlManager.addUser(METALAKE, "cache_user", extId, true);
+    User added = accessControlManager.addUser(METALAKE, "cache_user", extId, 
true);
     accessControlManager.getUser(METALAKE, "cache_user");
-    accessControlManager.disableUser(METALAKE, extId);
+    accessControlManager.alterUserById(METALAKE, added.id(), 
UserChange.updateEnabled(false));
     Assertions.assertFalse(accessControlManager.getUser(METALAKE, 
"cache_user").enabled());
     accessControlManager.removeUser(METALAKE, "cache_user");
   }
diff --git 
a/core/src/test/java/org/apache/gravitino/listener/api/event/TestUserEvent.java 
b/core/src/test/java/org/apache/gravitino/listener/api/event/TestUserEvent.java
index e9ea0639dd..90c9161582 100644
--- 
a/core/src/test/java/org/apache/gravitino/listener/api/event/TestUserEvent.java
+++ 
b/core/src/test/java/org/apache/gravitino/listener/api/event/TestUserEvent.java
@@ -90,6 +90,7 @@ public class TestUserEvent {
     User mockUser = getMockUser("mock_user", ImmutableList.of("admin"));
     UserInfo info = new UserInfo(mockUser);
 
+    Assertions.assertEquals(1L, info.id());
     Assertions.assertEquals("mock_user", info.name());
     Assertions.assertEquals(Optional.empty(), info.externalId());
     Assertions.assertEquals(ImmutableList.of("admin"), info.roles());
@@ -505,19 +506,6 @@ public class TestUserEvent {
     Assertions.assertEquals(OperationType.GET_USER_BY_EXTERNAL_ID, 
event.operationType());
   }
 
-  @Test
-  void testEnableUserEvent() {
-    dispatcher.enableUser(METALAKE, USER_EXT_ID);
-
-    PreEvent preEvent = dummyEventListener.popPreEvent();
-    Assertions.assertEquals(EnableUserPreEvent.class, preEvent.getClass());
-    Assertions.assertEquals(OperationType.ENABLE_USER, 
preEvent.operationType());
-
-    Event event = dummyEventListener.popPostEvent();
-    Assertions.assertEquals(EnableUserEvent.class, event.getClass());
-    Assertions.assertEquals(OperationType.ENABLE_USER, event.operationType());
-  }
-
   @Test
   void testRemoveUserByExternalIdEvent() {
     dispatcher.removeUserByExternalId(METALAKE, USER_EXT_ID);
@@ -558,7 +546,6 @@ public class TestUserEvent {
 
     when(dispatcher.getUser(METALAKE, userName)).thenReturn(user);
     when(dispatcher.getUserByExternalId(METALAKE, 
USER_EXT_ID)).thenReturn(externalIdUser);
-    when(dispatcher.enableUser(METALAKE, 
USER_EXT_ID)).thenReturn(externalIdUser);
     when(dispatcher.getUser(METALAKE, inExistUserName))
         .thenThrow(new NoSuchUserException("user not found"));
     when(dispatcher.getUser(INEXIST_METALAKE, userName))
@@ -580,6 +567,7 @@ public class TestUserEvent {
 
   private User getMockUser(String name, List<String> roles) {
     User user = mock(User.class);
+    when(user.id()).thenReturn(1L);
     when(user.name()).thenReturn(name);
     when(user.roles()).thenReturn(roles);
 
@@ -589,6 +577,7 @@ public class TestUserEvent {
   private User getMockUserWithExtId(
       String name, String externalId, boolean enabled, List<String> roles) {
     User user = mock(User.class);
+    when(user.id()).thenReturn(1L);
     when(user.name()).thenReturn(name);
     when(user.externalId()).thenReturn(externalId);
     when(user.enabled()).thenReturn(enabled);
@@ -598,6 +587,7 @@ public class TestUserEvent {
   }
 
   private void validateUserInfo(UserInfo userInfo, User expectedUser) {
+    Assertions.assertEquals(expectedUser.id(), userInfo.id());
     Assertions.assertEquals(userInfo.name(), expectedUser.name());
     Assertions.assertEquals(Optional.ofNullable(expectedUser.externalId()), 
userInfo.externalId());
     Assertions.assertEquals(userInfo.roles(), expectedUser.roles());
diff --git 
a/core/src/test/java/org/apache/gravitino/storage/memory/TestMemoryEntityStore.java
 
b/core/src/test/java/org/apache/gravitino/storage/memory/TestMemoryEntityStore.java
index a3d679acae..ad7acc8959 100644
--- 
a/core/src/test/java/org/apache/gravitino/storage/memory/TestMemoryEntityStore.java
+++ 
b/core/src/test/java/org/apache/gravitino/storage/memory/TestMemoryEntityStore.java
@@ -42,6 +42,7 @@ import org.apache.gravitino.Metalake;
 import org.apache.gravitino.NameIdentifier;
 import org.apache.gravitino.Namespace;
 import org.apache.gravitino.SupportsExternalIdOperations;
+import org.apache.gravitino.SupportsIdOperations;
 import org.apache.gravitino.TestCatalog;
 import org.apache.gravitino.authorization.AuthorizationUtils;
 import org.apache.gravitino.authorization.Privileges;
@@ -66,7 +67,9 @@ import org.mockito.Mockito;
 
 public class TestMemoryEntityStore {
 
-  public static class InMemoryEntityStore implements EntityStore, 
SupportsExternalIdOperations {
+  public static class InMemoryEntityStore
+      implements EntityStore, SupportsExternalIdOperations, 
SupportsIdOperations {
+
     private final Map<NameIdentifier, Entity> entityMap;
     private final Lock lock;
 
@@ -153,6 +156,11 @@ public class TestMemoryEntityStore {
       return this;
     }
 
+    @Override
+    public SupportsIdOperations idOperations() {
+      return this;
+    }
+
     @Override
     public <E extends Entity & HasIdentifier> E getByExternalId(
         NameIdentifier ident, EntityType entityType, Class<E> type)
@@ -227,6 +235,63 @@ public class TestMemoryEntityStore {
       }
     }
 
+    @Override
+    @SuppressWarnings("unchecked")
+    public <E extends Entity & HasIdentifier> E getById(
+        NameIdentifier ident, EntityType entityType, Class<E> type)
+        throws NoSuchEntityException, IOException {
+      if (entityType != EntityType.USER) {
+        throw new UnsupportedOperationException(
+            "Get by id is not supported for entity type: " + entityType);
+      }
+
+      AuthorizationUtils.checkUserId(ident);
+      long entityId = Long.parseLong(ident.name());
+      Namespace entityNamespace = 
AuthorizationUtils.ofUserNamespace(ident.namespace().level(0));
+
+      for (Map.Entry<NameIdentifier, Entity> entry : entityMap.entrySet()) {
+        Entity entity = entry.getValue();
+        if (!entity.type().equals(entityType)
+            || !entry.getKey().namespace().equals(entityNamespace)) {
+          continue;
+        }
+
+        if (entity instanceof HasIdentifier hasIdentifier
+            && java.util.Objects.equals(hasIdentifier.id(), entityId)) {
+          return (E) entity;
+        }
+      }
+
+      throw new NoSuchEntityException(
+          NoSuchEntityException.NO_SUCH_ENTITY_MESSAGE,
+          entityType.name().toLowerCase(),
+          String.valueOf(entityId));
+    }
+
+    @Override
+    @SuppressWarnings("unchecked")
+    public <E extends Entity & HasIdentifier> E updateById(
+        NameIdentifier ident, EntityType entityType, Class<E> type, 
Function<E, E> updater)
+        throws NoSuchEntityException, IOException {
+      E entity = getById(ident, entityType, type);
+      E updated = updater.apply(entity);
+      return update(entity.nameIdentifier(), type, entityType, e -> updated);
+    }
+
+    @Override
+    public boolean deleteById(NameIdentifier ident, EntityType entityType) 
throws IOException {
+      try {
+        if (entityType == EntityType.USER) {
+          UserEntity user = getById(ident, entityType, UserEntity.class);
+          return delete(user.nameIdentifier(), entityType);
+        }
+        throw new UnsupportedOperationException(
+            "Delete by id is not supported for entity type: " + entityType);
+      } catch (NoSuchEntityException e) {
+        return false;
+      }
+    }
+
     @Override
     public <E extends Entity & HasIdentifier> List<E> batchGet(
         List<NameIdentifier> idents, EntityType entityType, Class<E> e) {

Reply via email to