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 90c43acca3 [#11832] feat(core): Add user external ID, enable/disable, 
and delete-by-external-id APIs (#11848)
90c43acca3 is described below

commit 90c43acca36f0dc1d6cde214622515facfde2bb3
Author: MaSai <[email protected]>
AuthorDate: Tue Jul 7 10:38:09 2026 +0800

    [#11832] feat(core): Add user external ID, enable/disable, and 
delete-by-external-id APIs (#11848)
    
    ### What changes were proposed in this pull request?
    
    User-scoped external ID correlation, enable/disable, and
    delete-by-external-id. Group external-id work is split to #11834;
    pagination remains #11833.
    
    **Persistence**
    - Persist `external_id` / `enabled` in user PO, entity, SQL, and
    converters
    
    **New dispatcher APIs**
    - `getUserByExternalId`
    - `addUser(metalake, user, externalId, enabled)`
    - `enableUser` / `disableUser` (by `externalId`; updates
    `user_meta.enabled` only, keeps `user_role_rel`)
    - `removeUserByExternalId`
    
    **Store path**
    - External-id lookup/update/delete via `EntityStore` →
    `RelationalBackend` → `UserMetaService`
    - `SupportsExternalIdOperations` with `getByExternalId`,
    `updateByExternalId`, and `deleteByExternalId` (USER)
    - `RelationalEntityStore` invalidates cache after enable/disable/delete
    
    **Locking**
    - Get/enable/disable/delete-by-externalId use
    `AuthorizationUtils.ofUserExternalId` for per-externalId TreeLock paths
    
    **Validation & errors**
    - `checkExternalId` in `MetaService` and lock-path helpers; duplicate
    externalId rejected by DB unique constraint
    - Dedicated error messages for missing user by external id
    
    **Wiring**
    - `AccessControlManager`, `UserGroupManager`, hook/event dispatchers,
    and `UserDTO`
    
    Fix: #11832
    
    ### Why are the changes needed?
    
    Downstream integrations need stable external ID lookup on users, the
    ability to disable a user without removing role bindings, and
    delete-by-external-id for identity synchronization workflows.
    
    ### Does this PR introduce _any_ user-facing change?
    
    1. New `User.externalId()` and `User.enabled()` API fields
    2. New user dispatcher methods listed above (no new 8090 REST endpoints)
    
    ### How was this patch tested?
    
    **UT** (`./gradlew :core:test -PskipITs`):
    - `TestAccessControlManager` — user external ID CRUD, enable/disable,
    duplicate/missing externalId, cache invalidation
    - `TestUserMetaService` — external ID lookup, enable/disable,
    delete-by-externalId, duplicate externalId
    - `TestAuthorizationUtils` — `ofUserExternalId` lock paths
    - `TestMemoryEntityStore`, `TestOwnerEvent`
    
    **IT** (`./gradlew :core:test -PskipTests -PskipDockerTests=false`):
    - `TestUserMetaService` (H2 + MySQL + PostgreSQL via Testcontainers)
    
    ---------
    
    Co-authored-by: Cursor <[email protected]>
---
 .../org/apache/gravitino/authorization/User.java   |  25 ++++
 .../apache/gravitino/cli/commands/ListUsers.java   |  10 ++
 .../gravitino/dto/authorization/UserDTO.java       |  55 ++++++-
 .../apache/gravitino/dto/util/DTOConverters.java   |   2 +
 .../src/main/java/org/apache/gravitino/Entity.java |  11 ++
 .../java/org/apache/gravitino/EntityStore.java     |  10 ++
 .../gravitino/SupportsExternalIdOperations.java    |  71 +++++++++
 .../authorization/AccessControlDispatcher.java     |  70 +++++++++
 .../authorization/AccessControlManager.java        |  48 +++++++
 .../authorization/AuthorizationUtils.java          |  40 ++++++
 .../gravitino/authorization/PermissionManager.java |   4 +
 .../authorization/UserGroupExternalManager.java    | 160 +++++++++++++++++++++
 .../gravitino/authorization/UserGroupManager.java  |   6 +-
 .../hook/AccessControlHookDispatcher.java          |  30 ++++
 .../listener/AccessControlEventDispatcher.java     |  35 +++++
 .../java/org/apache/gravitino/meta/UserEntity.java |  46 +++++-
 .../gravitino/storage/relational/JDBCBackend.java  |  26 ++++
 .../storage/relational/RelationalBackend.java      |  29 ++++
 .../storage/relational/RelationalEntityStore.java  |  46 +++++-
 .../storage/relational/mapper/UserMetaMapper.java  |  10 ++
 .../mapper/UserMetaSQLProviderFactory.java         |  10 ++
 .../provider/base/OwnerMetaBaseSQLProvider.java    |   4 +
 .../provider/base/UserMetaBaseSQLProvider.java     |  64 +++++++--
 .../mapper/provider/h2/UserMetaH2Provider.java     |   1 +
 .../postgresql/UserMetaPostgreSQLProvider.java     |   9 +-
 .../gravitino/storage/relational/po/UserPO.java    |  27 ++++
 .../relational/service/UserMetaService.java        |  67 +++++++++
 .../storage/relational/utils/POConverters.java     |   8 ++
 .../authorization/TestAccessControlManager.java    | 111 ++++++++++++++
 .../authorization/TestAuthorizationUtils.java      |  28 ++++
 .../listener/api/event/TestOwnerEvent.java         |   3 +-
 .../storage/memory/TestMemoryEntityStore.java      |  65 ++++++++-
 .../mapper/provider/base/TestAuthMappers.java      |   1 +
 .../provider/base/TestUserRoleRelMapper.java       |   1 +
 .../relational/service/TestUserMetaService.java    | 147 +++++++++++++++++++
 35 files changed, 1259 insertions(+), 21 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 ee1dbfa1db..a5ac53e51e 100644
--- a/api/src/main/java/org/apache/gravitino/authorization/User.java
+++ b/api/src/main/java/org/apache/gravitino/authorization/User.java
@@ -19,6 +19,7 @@
 package org.apache.gravitino.authorization;
 
 import java.util.List;
+import javax.annotation.Nullable;
 import org.apache.gravitino.Auditable;
 import org.apache.gravitino.annotation.Evolving;
 
@@ -34,6 +35,30 @@ public interface User extends Auditable {
    */
   String name();
 
+  /**
+   * 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.
+   *
+   * @return The upstream external identifier, or null if not set.
+   */
+  @Nullable
+  default String externalId() {
+    return null;
+  }
+
+  /**
+   * Whether the user is enabled.
+   *
+   * @return True if the user is enabled, false otherwise.
+   */
+  default boolean enabled() {
+    return true;
+  }
+
   /**
    * The roles of the user. A user can have multiple roles. Every role binds 
several privileges.
    *
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 39c24d500f..19cf9718be 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
@@ -73,6 +73,16 @@ public class ListUsers extends Command {
         return user;
       }
 
+      @Override
+      public String externalId() {
+        return null;
+      }
+
+      @Override
+      public boolean enabled() {
+        return true;
+      }
+
       @Override
       public List<String> roles() {
         return null;
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 35530291bc..68297c9e84 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
@@ -22,6 +22,7 @@ import com.fasterxml.jackson.annotation.JsonProperty;
 import com.google.common.base.Preconditions;
 import java.util.Collections;
 import java.util.List;
+import javax.annotation.Nullable;
 import org.apache.commons.lang3.StringUtils;
 import org.apache.gravitino.Audit;
 import org.apache.gravitino.authorization.User;
@@ -33,6 +34,13 @@ public class UserDTO implements User {
   @JsonProperty("name")
   private String name;
 
+  @Nullable
+  @JsonProperty("externalId")
+  private String externalId;
+
+  @JsonProperty("enabled")
+  private boolean enabled = true;
+
   @JsonProperty("audit")
   private AuditDTO audit;
 
@@ -46,11 +54,16 @@ public class UserDTO implements User {
    * Creates a new instance of UserDTO.
    *
    * @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.
    * @param audit The audit information of the User DTO.
+   * @param enabled Whether the User DTO is enabled.
    */
-  protected UserDTO(String name, List<String> roles, AuditDTO audit) {
+  protected UserDTO(
+      String name, String externalId, List<String> roles, AuditDTO audit, 
boolean enabled) {
     this.name = name;
+    this.externalId = externalId;
+    this.enabled = enabled;
     this.audit = audit;
     this.roles = roles;
   }
@@ -63,6 +76,16 @@ public class UserDTO implements User {
     return name;
   }
 
+  @Override
+  public String externalId() {
+    return externalId;
+  }
+
+  @Override
+  public boolean enabled() {
+    return enabled;
+  }
+
   /**
    * The roles of the user. A user can have multiple roles. Every role binds 
several privileges.
    *
@@ -100,6 +123,12 @@ public class UserDTO implements User {
     /** The name of the user. */
     protected String name;
 
+    /** The external id of the user. */
+    protected String externalId;
+
+    /** Whether the user is enabled. */
+    protected boolean enabled = true;
+
     /** The roles of the user. */
     protected List<String> roles = Collections.emptyList();
 
@@ -117,6 +146,28 @@ public class UserDTO implements User {
       return (S) this;
     }
 
+    /**
+     * Sets the external id of the user.
+     *
+     * @param externalId The external id of the user.
+     * @return The builder instance.
+     */
+    public S withExternalId(String externalId) {
+      this.externalId = externalId;
+      return (S) this;
+    }
+
+    /**
+     * Sets whether the user is enabled.
+     *
+     * @param enabled Whether the user is enabled.
+     * @return The builder instance.
+     */
+    public S withEnabled(boolean enabled) {
+      this.enabled = enabled;
+      return (S) this;
+    }
+
     /**
      * Sets the roles of the user.
      *
@@ -151,7 +202,7 @@ public class UserDTO implements User {
     public UserDTO build() {
       Preconditions.checkArgument(StringUtils.isNotBlank(name), "name cannot 
be null or empty");
       Preconditions.checkArgument(audit != null, "audit cannot be null");
-      return new UserDTO(name, roles, audit);
+      return new UserDTO(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 e9ee2226dd..e5843aa689 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
@@ -494,6 +494,8 @@ public class DTOConverters {
 
     return UserDTO.builder()
         .withName(user.name())
+        .withExternalId(user.externalId())
+        .withEnabled(user.enabled())
         .withRoles(user.roles())
         .withAudit(toDTO(user.auditInfo()))
         .build();
diff --git a/core/src/main/java/org/apache/gravitino/Entity.java 
b/core/src/main/java/org/apache/gravitino/Entity.java
index 3447b225b3..555bd2b3a3 100644
--- a/core/src/main/java/org/apache/gravitino/Entity.java
+++ b/core/src/main/java/org/apache/gravitino/Entity.java
@@ -41,6 +41,17 @@ public interface Entity extends Serializable {
   /** The user schema name in the system catalog. */
   String USER_SCHEMA_NAME = "user";
 
+  /**
+   * 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).
+   *
+   * <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,
+   * <externalId>}} so that concurrent operations on the same external id are 
serialized without
+   * requiring the Gravitino user name.
+   */
+  String USER_EXTERNAL_ID_SCHEMA_NAME = "user-external-id";
+
   /** The group schema name in the system catalog. */
   String GROUP_SCHEMA_NAME = "group";
 
diff --git a/core/src/main/java/org/apache/gravitino/EntityStore.java 
b/core/src/main/java/org/apache/gravitino/EntityStore.java
index d2c19c65c6..cadf3b2615 100644
--- a/core/src/main/java/org/apache/gravitino/EntityStore.java
+++ b/core/src/main/java/org/apache/gravitino/EntityStore.java
@@ -268,4 +268,14 @@ public interface EntityStore extends Closeable {
   default SupportsRelationOperations relationOperations() {
     throw new UnsupportedOperationException("relation operations are not 
supported");
   }
+
+  /**
+   * Get the extra external id operations that are supported by the entity 
store.
+   *
+   * @return the external id operations that are supported by the entity store
+   * @throws UnsupportedOperationException if the extra operations are not 
supported
+   */
+  default SupportsExternalIdOperations externalIdOperations() {
+    throw new UnsupportedOperationException("external id operations are not 
supported");
+  }
 }
diff --git 
a/core/src/main/java/org/apache/gravitino/SupportsExternalIdOperations.java 
b/core/src/main/java/org/apache/gravitino/SupportsExternalIdOperations.java
new file mode 100644
index 0000000000..520da24143
--- /dev/null
+++ b/core/src/main/java/org/apache/gravitino/SupportsExternalIdOperations.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 
external id within a
+ * namespace.
+ */
+public interface SupportsExternalIdOperations {
+
+  /**
+   * Get the entity from the underlying storage by external id.
+   *
+   * @param ident the external 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 getByExternalId(
+      NameIdentifier ident, Entity.EntityType entityType, Class<E> type)
+      throws NoSuchEntityException, IOException;
+
+  /**
+   * Update an entity by external id.
+   *
+   * @param ident the external 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 updateByExternalId(
+      NameIdentifier ident, Entity.EntityType entityType, Class<E> type, 
Function<E, E> updater)
+      throws NoSuchEntityException, IOException;
+
+  /**
+   * Delete an entity by external id.
+   *
+   * @param ident the external 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 deleteByExternalId(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 49771a1f13..258f20a6af 100644
--- 
a/core/src/main/java/org/apache/gravitino/authorization/AccessControlDispatcher.java
+++ 
b/core/src/main/java/org/apache/gravitino/authorization/AccessControlDispatcher.java
@@ -51,6 +51,21 @@ public interface AccessControlDispatcher {
   User addUser(String metalake, String user)
       throws UserAlreadyExistsException, NoSuchMetalakeException;
 
+  /**
+   * Adds a new User.
+   *
+   * @param metalake The Metalake of the User.
+   * @param user The name of the User.
+   * @param externalId The external identifier, or null if unset.
+   * @param enabled Whether the User is enabled.
+   * @return The added User instance.
+   * @throws UserAlreadyExistsException If a User with the same name or 
external id already exists.
+   * @throws NoSuchMetalakeException If the Metalake with the given name does 
not exist.
+   * @throws RuntimeException If adding the User encounters storage issues.
+   */
+  User addUser(String metalake, String user, String externalId, boolean 
enabled)
+      throws UserAlreadyExistsException, NoSuchMetalakeException;
+
   /**
    * Removes a User.
    *
@@ -63,6 +78,19 @@ public interface AccessControlDispatcher {
    */
   boolean removeUser(String metalake, String user) throws 
NoSuchMetalakeException;
 
+  /**
+   * Removes a User by external identifier.
+   *
+   * @param metalake The Metalake of the User.
+   * @param externalId The external identifier 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 IllegalArgumentException If externalId is null or blank.
+   * @throws NoSuchMetalakeException If the Metalake with the given name does 
not exist.
+   * @throws RuntimeException If removing the User encounters storage issues.
+   */
+  boolean removeUserByExternalId(String metalake, String externalId) throws 
NoSuchMetalakeException;
+
   /**
    * Gets a User.
    *
@@ -75,6 +103,48 @@ public interface AccessControlDispatcher {
    */
   User getUser(String metalake, String user) throws NoSuchUserException, 
NoSuchMetalakeException;
 
+  /**
+   * Gets a User by external identifier.
+   *
+   * @param metalake The Metalake of the User.
+   * @param externalId The external identifier of the User.
+   * @return The getting User instance.
+   * @throws IllegalArgumentException If externalId is null or blank.
+   * @throws NoSuchUserException If the User with the given external id does 
not exist.
+   * @throws NoSuchMetalakeException If the Metalake with the given name does 
not exist.
+   * @throws RuntimeException If getting the User encounters storage issues.
+   */
+  User getUserByExternalId(String metalake, String externalId)
+      throws NoSuchUserException, NoSuchMetalakeException;
+
+  /**
+   * Enables a User without removing role bindings.
+   *
+   * @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.
+   * @throws NoSuchMetalakeException If the Metalake with the given name does 
not exist.
+   * @throws RuntimeException If updating the User encounters storage issues.
+   */
+  User enableUser(String metalake, String externalId)
+      throws NoSuchUserException, NoSuchMetalakeException;
+
+  /**
+   * Disables a User without removing role bindings.
+   *
+   * @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.
+   * @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)
+      throws NoSuchUserException, NoSuchMetalakeException;
+
   /**
    * Lists the users.
    *
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 762044178a..25e40e18f9 100644
--- 
a/core/src/main/java/org/apache/gravitino/authorization/AccessControlManager.java
+++ 
b/core/src/main/java/org/apache/gravitino/authorization/AccessControlManager.java
@@ -47,6 +47,7 @@ import org.apache.gravitino.utils.MetadataObjectUtil;
 public class AccessControlManager implements AccessControlDispatcher {
 
   private final UserGroupManager userGroupManager;
+  private final UserGroupExternalManager userGroupExternalManager;
   private final RoleManager roleManager;
   private final PermissionManager permissionManager;
   private final List<String> serviceAdmins;
@@ -54,6 +55,7 @@ public class AccessControlManager implements 
AccessControlDispatcher {
   public AccessControlManager(EntityStore store, IdGenerator idGenerator, 
Config config) {
     this.roleManager = new RoleManager(store, idGenerator);
     this.userGroupManager = new UserGroupManager(store, idGenerator);
+    this.userGroupExternalManager = new UserGroupExternalManager(store, 
idGenerator);
     this.permissionManager = new PermissionManager(store, roleManager);
     this.serviceAdmins = config.get(Configs.SERVICE_ADMINS);
   }
@@ -67,6 +69,15 @@ public class AccessControlManager implements 
AccessControlDispatcher {
         () -> userGroupManager.addUser(metalake, user));
   }
 
+  @Override
+  public User addUser(String metalake, String user, String externalId, boolean 
enabled)
+      throws UserAlreadyExistsException, NoSuchMetalakeException {
+    return TreeLockUtils.doWithTreeLock(
+        
NameIdentifier.of(AuthorizationUtils.ofUserNamespace(metalake).levels()),
+        LockType.WRITE,
+        () -> userGroupExternalManager.addUser(metalake, user, externalId, 
enabled));
+  }
+
   @Override
   public boolean removeUser(String metalake, String user) throws 
NoSuchMetalakeException {
     return TreeLockUtils.doWithTreeLock(
@@ -75,6 +86,15 @@ public class AccessControlManager implements 
AccessControlDispatcher {
         () -> userGroupManager.removeUser(metalake, user));
   }
 
+  @Override
+  public boolean removeUserByExternalId(String metalake, String externalId)
+      throws NoSuchMetalakeException {
+    return TreeLockUtils.doWithTreeLock(
+        AuthorizationUtils.ofUserExternalId(metalake, externalId),
+        LockType.WRITE,
+        () -> userGroupExternalManager.removeUserByExternalId(metalake, 
externalId));
+  }
+
   @Override
   public User getUser(String metalake, String user)
       throws NoSuchUserException, NoSuchMetalakeException {
@@ -84,6 +104,33 @@ public class AccessControlManager implements 
AccessControlDispatcher {
         () -> userGroupManager.getUser(metalake, user));
   }
 
+  @Override
+  public User getUserByExternalId(String metalake, String externalId)
+      throws NoSuchUserException, NoSuchMetalakeException {
+    return TreeLockUtils.doWithTreeLock(
+        AuthorizationUtils.ofUserExternalId(metalake, externalId),
+        LockType.READ,
+        () -> userGroupExternalManager.getUserByExternalId(metalake, 
externalId));
+  }
+
+  @Override
+  public User enableUser(String metalake, String externalId)
+      throws NoSuchUserException, NoSuchMetalakeException {
+    return TreeLockUtils.doWithTreeLock(
+        AuthorizationUtils.ofUserExternalId(metalake, externalId),
+        LockType.WRITE,
+        () -> userGroupExternalManager.enableUser(metalake, externalId));
+  }
+
+  @Override
+  public User disableUser(String metalake, String externalId)
+      throws NoSuchUserException, NoSuchMetalakeException {
+    return TreeLockUtils.doWithTreeLock(
+        AuthorizationUtils.ofUserExternalId(metalake, externalId),
+        LockType.WRITE,
+        () -> userGroupExternalManager.disableUser(metalake, externalId));
+  }
+
   @Override
   public String[] listUserNames(String metalake) throws 
NoSuchMetalakeException {
     return TreeLockUtils.doWithTreeLock(
@@ -100,6 +147,7 @@ public class AccessControlManager implements 
AccessControlDispatcher {
         () -> userGroupManager.listUsers(metalake));
   }
 
+  @Override
   public Group addGroup(String metalake, String group)
       throws GroupAlreadyExistsException, NoSuchMetalakeException {
     return TreeLockUtils.doWithTreeLock(
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 7ac6008c1d..d768df1743 100644
--- 
a/core/src/main/java/org/apache/gravitino/authorization/AuthorizationUtils.java
+++ 
b/core/src/main/java/org/apache/gravitino/authorization/AuthorizationUtils.java
@@ -64,6 +64,8 @@ public class AuthorizationUtils {
   private static final String FILESET_SCHEMA_LOCATION = "location";
   private static final String HIVE_LOCATION = "location";
   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 GROUP_DOES_NOT_EXIST_MSG = "Group %s does not exist in 
the metalake %s";
   static final String ROLE_DOES_NOT_EXIST_MSG = "Role %s does not exist in the 
metalake %s";
 
@@ -145,6 +147,22 @@ public class AuthorizationUtils {
         metalake, Entity.SYSTEM_CATALOG_RESERVED_NAME, 
Entity.USER_SCHEMA_NAME, user);
   }
 
+  /**
+   * Creates a synthetic {@link NameIdentifier} used only as a {@link
+   * org.apache.gravitino.lock.TreeLockUtils} lock path for user operations 
keyed by external id.
+   *
+   * @param metalake the metalake name
+   * @param externalId the external id of the user
+   * @return a synthetic name identifier for tree locking only
+   */
+  public static NameIdentifier ofUserExternalId(String metalake, String 
externalId) {
+    return NameIdentifier.of(
+        metalake,
+        Entity.SYSTEM_CATALOG_RESERVED_NAME,
+        Entity.USER_EXTERNAL_ID_SCHEMA_NAME,
+        externalId);
+  }
+
   public static Namespace ofRoleNamespace(String metalake) {
     return Namespace.of(metalake, Entity.SYSTEM_CATALOG_RESERVED_NAME, 
Entity.ROLE_SCHEMA_NAME);
   }
@@ -157,11 +175,26 @@ public class AuthorizationUtils {
     return Namespace.of(metalake, Entity.SYSTEM_CATALOG_RESERVED_NAME, 
Entity.USER_SCHEMA_NAME);
   }
 
+  public static Namespace ofUserExternalIdNamespace(String metalake) {
+    return Namespace.of(
+        metalake, Entity.SYSTEM_CATALOG_RESERVED_NAME, 
Entity.USER_EXTERNAL_ID_SCHEMA_NAME);
+  }
+
   public static void checkUser(NameIdentifier ident) {
     NameIdentifier.check(ident != null, "User identifier must not be null");
     checkUserNamespace(ident.namespace());
   }
 
+  /**
+   * Validates that the name identifier refers to a user external id in a 
metalake.
+   *
+   * @param ident the external id name identifier to validate
+   */
+  public static void checkUserExternalId(NameIdentifier ident) {
+    NameIdentifier.check(ident != null, "External id identifier must not be 
null");
+    checkUserExternalIdNamespace(ident.namespace());
+  }
+
   public static void checkGroup(NameIdentifier ident) {
     NameIdentifier.check(ident != null, "Group identifier must not be null");
     checkGroupNamespace(ident.namespace());
@@ -179,6 +212,13 @@ public class AuthorizationUtils {
         namespace);
   }
 
+  public static void checkUserExternalIdNamespace(Namespace namespace) {
+    Namespace.check(
+        namespace != null && namespace.length() == 3,
+        "User external id namespace must have 3 levels, the input namespace is 
%s",
+        namespace);
+  }
+
   public static void checkGroupNamespace(Namespace namespace) {
     Namespace.check(
         namespace != null && namespace.length() == 3,
diff --git 
a/core/src/main/java/org/apache/gravitino/authorization/PermissionManager.java 
b/core/src/main/java/org/apache/gravitino/authorization/PermissionManager.java
index fadb3463bc..c864d69051 100644
--- 
a/core/src/main/java/org/apache/gravitino/authorization/PermissionManager.java
+++ 
b/core/src/main/java/org/apache/gravitino/authorization/PermissionManager.java
@@ -118,6 +118,8 @@ class PermissionManager {
                     .withNamespace(userEntity.namespace())
                     .withId(userEntity.id())
                     .withName(userEntity.name())
+                    .withExternalId(userEntity.externalId())
+                    .withEnabled(userEntity.enabled())
                     .withRoleNames(roleNames)
                     .withRoleIds(roleIds)
                     .withAuditInfo(auditInfo)
@@ -384,6 +386,8 @@ class PermissionManager {
                     .withId(userEntity.id())
                     .withNamespace(userEntity.namespace())
                     .withName(userEntity.name())
+                    .withExternalId(userEntity.externalId())
+                    .withEnabled(userEntity.enabled())
                     .withRoleNames(roleNames)
                     .withRoleIds(roleIds)
                     .withAuditInfo(auditInfo)
diff --git 
a/core/src/main/java/org/apache/gravitino/authorization/UserGroupExternalManager.java
 
b/core/src/main/java/org/apache/gravitino/authorization/UserGroupExternalManager.java
new file mode 100644
index 0000000000..fe850a66f8
--- /dev/null
+++ 
b/core/src/main/java/org/apache/gravitino/authorization/UserGroupExternalManager.java
@@ -0,0 +1,160 @@
+/*
+ * 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.collect.Lists;
+import java.io.IOException;
+import java.time.Instant;
+import org.apache.gravitino.Entity;
+import org.apache.gravitino.EntityAlreadyExistsException;
+import org.apache.gravitino.EntityStore;
+import org.apache.gravitino.exceptions.NoSuchEntityException;
+import org.apache.gravitino.exceptions.NoSuchUserException;
+import org.apache.gravitino.exceptions.UserAlreadyExistsException;
+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 operations keyed by external id within a metalake. */
+class UserGroupExternalManager extends UserGroupManager {
+
+  private static final Logger LOG = 
LoggerFactory.getLogger(UserGroupExternalManager.class);
+
+  /**
+   * Creates a {@link UserGroupExternalManager} instance.
+   *
+   * @param store the entity store
+   * @param idGenerator the id generator
+   */
+  UserGroupExternalManager(EntityStore store, IdGenerator idGenerator) {
+    super(store, idGenerator);
+  }
+
+  User addUser(String metalake, String name, String externalId, boolean 
enabled)
+      throws UserAlreadyExistsException {
+    try {
+      UserEntity userEntity =
+          UserEntity.builder()
+              .withId(idGenerator.nextId())
+              .withName(name)
+              .withNamespace(AuthorizationUtils.ofUserNamespace(metalake))
+              .withRoleNames(Lists.newArrayList())
+              .withEnabled(enabled)
+              .withExternalId(externalId)
+              .withAuditInfo(
+                  AuditInfo.builder()
+                      
.withCreator(PrincipalUtils.getCurrentPrincipal().getName())
+                      .withCreateTime(Instant.now())
+                      .build())
+              .build();
+      store.put(userEntity, false /* overwritten */);
+      return userEntity;
+    } catch (EntityAlreadyExistsException e) {
+      LOG.warn("User {} in the metalake {} already exists", name, metalake, e);
+      throw new UserAlreadyExistsException(
+          "User %s in the metalake %s already exists", name, metalake);
+    } catch (IOException ioe) {
+      LOG.error(
+          "Adding user {} failed in the metalake {} due to storage issues", 
name, metalake, ioe);
+      throw new RuntimeException(ioe);
+    }
+  }
+
+  boolean removeUserByExternalId(String metalake, String externalId) {
+    try {
+      return store
+          .externalIdOperations()
+          .deleteByExternalId(
+              AuthorizationUtils.ofUserExternalId(metalake, externalId), 
Entity.EntityType.USER);
+    } catch (IOException ioe) {
+      LOG.error(
+          "Removing user with external id {} in the metalake {} failed due to 
storage issues",
+          externalId,
+          metalake,
+          ioe);
+      throw new RuntimeException(ioe);
+    }
+  }
+
+  User getUserByExternalId(String metalake, String externalId) throws 
NoSuchUserException {
+    try {
+      return store
+          .externalIdOperations()
+          .getByExternalId(
+              AuthorizationUtils.ofUserExternalId(metalake, externalId),
+              Entity.EntityType.USER,
+              UserEntity.class);
+    } 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("Getting user with external id {} failed due to storage 
issues", externalId, ioe);
+      throw new RuntimeException(ioe);
+    }
+  }
+
+  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);
+    }
+  }
+}
diff --git 
a/core/src/main/java/org/apache/gravitino/authorization/UserGroupManager.java 
b/core/src/main/java/org/apache/gravitino/authorization/UserGroupManager.java
index 9a244212e1..d6dc2b0636 100644
--- 
a/core/src/main/java/org/apache/gravitino/authorization/UserGroupManager.java
+++ 
b/core/src/main/java/org/apache/gravitino/authorization/UserGroupManager.java
@@ -52,10 +52,10 @@ class UserGroupManager {
   private static final Logger LOG = 
LoggerFactory.getLogger(UserGroupManager.class);
   private static final String METALAKE_DOES_NOT_EXIST_MSG = "Metalake %s does 
not exist";
 
-  private final EntityStore store;
-  private final IdGenerator idGenerator;
+  protected final EntityStore store;
+  protected final IdGenerator idGenerator;
 
-  UserGroupManager(EntityStore store, IdGenerator idGenerator) {
+  protected UserGroupManager(EntityStore store, IdGenerator idGenerator) {
     this.store = store;
     this.idGenerator = idGenerator;
   }
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 a4b2e28007..9c7bb5aba1 100644
--- 
a/core/src/main/java/org/apache/gravitino/hook/AccessControlHookDispatcher.java
+++ 
b/core/src/main/java/org/apache/gravitino/hook/AccessControlHookDispatcher.java
@@ -69,17 +69,47 @@ public class AccessControlHookDispatcher implements 
AccessControlDispatcher {
     return dispatcher.addUser(metalake, user);
   }
 
+  @Override
+  public User addUser(String metalake, String user, String externalId, boolean 
enabled)
+      throws UserAlreadyExistsException, NoSuchMetalakeException {
+    return dispatcher.addUser(metalake, user, externalId, enabled);
+  }
+
   @Override
   public boolean removeUser(String metalake, String user) throws 
NoSuchMetalakeException {
     return dispatcher.removeUser(metalake, user);
   }
 
+  @Override
+  public boolean removeUserByExternalId(String metalake, String externalId)
+      throws NoSuchMetalakeException {
+    return dispatcher.removeUserByExternalId(metalake, externalId);
+  }
+
   @Override
   public User getUser(String metalake, String user)
       throws NoSuchUserException, NoSuchMetalakeException {
     return dispatcher.getUser(metalake, user);
   }
 
+  @Override
+  public User getUserByExternalId(String metalake, String externalId)
+      throws NoSuchUserException, NoSuchMetalakeException {
+    return dispatcher.getUserByExternalId(metalake, externalId);
+  }
+
+  @Override
+  public User enableUser(String metalake, String externalId)
+      throws NoSuchUserException, NoSuchMetalakeException {
+    return dispatcher.enableUser(metalake, externalId);
+  }
+
+  @Override
+  public User disableUser(String metalake, String externalId)
+      throws NoSuchUserException, NoSuchMetalakeException {
+    return dispatcher.disableUser(metalake, externalId);
+  }
+
   @Override
   public User[] listUsers(String metalake) throws NoSuchMetalakeException {
     return dispatcher.listUsers(metalake);
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 343c7128bb..db12c51d8b 100644
--- 
a/core/src/main/java/org/apache/gravitino/listener/AccessControlEventDispatcher.java
+++ 
b/core/src/main/java/org/apache/gravitino/listener/AccessControlEventDispatcher.java
@@ -145,6 +145,13 @@ public class AccessControlEventDispatcher implements 
AccessControlDispatcher {
     }
   }
 
+  /** {@inheritDoc} */
+  @Override
+  public User addUser(String metalake, String user, String externalId, boolean 
enabled)
+      throws UserAlreadyExistsException, NoSuchMetalakeException {
+    return dispatcher.addUser(metalake, user, externalId, enabled);
+  }
+
   /** {@inheritDoc} */
   @Override
   public boolean removeUser(String metalake, String user) throws 
NoSuchMetalakeException {
@@ -162,6 +169,13 @@ public class AccessControlEventDispatcher implements 
AccessControlDispatcher {
     }
   }
 
+  /** {@inheritDoc} */
+  @Override
+  public boolean removeUserByExternalId(String metalake, String externalId)
+      throws NoSuchMetalakeException {
+    return dispatcher.removeUserByExternalId(metalake, externalId);
+  }
+
   /** {@inheritDoc} */
   @Override
   public User getUser(String metalake, String user)
@@ -180,6 +194,27 @@ public class AccessControlEventDispatcher implements 
AccessControlDispatcher {
     }
   }
 
+  /** {@inheritDoc} */
+  @Override
+  public User getUserByExternalId(String metalake, String externalId)
+      throws NoSuchUserException, NoSuchMetalakeException {
+    return dispatcher.getUserByExternalId(metalake, externalId);
+  }
+
+  /** {@inheritDoc} */
+  @Override
+  public User enableUser(String metalake, String externalId)
+      throws NoSuchUserException, NoSuchMetalakeException {
+    return dispatcher.enableUser(metalake, externalId);
+  }
+
+  /** {@inheritDoc} */
+  @Override
+  public User disableUser(String metalake, String externalId)
+      throws NoSuchUserException, NoSuchMetalakeException {
+    return dispatcher.disableUser(metalake, externalId);
+  }
+
   /** {@inheritDoc} */
   @Override
   public User[] listUsers(String metalake) throws NoSuchMetalakeException {
diff --git a/core/src/main/java/org/apache/gravitino/meta/UserEntity.java 
b/core/src/main/java/org/apache/gravitino/meta/UserEntity.java
index c9d7089f85..579cbd2ee7 100644
--- a/core/src/main/java/org/apache/gravitino/meta/UserEntity.java
+++ b/core/src/main/java/org/apache/gravitino/meta/UserEntity.java
@@ -42,6 +42,12 @@ public class UserEntity implements User, Entity, Auditable, 
HasIdentifier {
   public static final Field NAME =
       Field.required("name", String.class, "The name of the user entity.");
 
+  public static final Field EXTERNAL_ID =
+      Field.optional("external_id", String.class, "The external id of the user 
entity.");
+
+  public static final Field ENABLED =
+      Field.required("enabled", Boolean.class, "Whether the user entity is 
enabled.");
+
   public static final Field AUDIT_INFO =
       Field.required("audit_info", AuditInfo.class, "The audit details of the 
user entity.");
 
@@ -53,6 +59,8 @@ public class UserEntity implements User, Entity, Auditable, 
HasIdentifier {
 
   private Long id;
   private String name;
+  private String externalId;
+  private boolean enabled = true;
   private AuditInfo auditInfo;
   private List<String> roleNames;
   private List<Long> roleIds;
@@ -70,6 +78,8 @@ public class UserEntity implements User, Entity, Auditable, 
HasIdentifier {
     Map<Field, Object> fields = Maps.newHashMap();
     fields.put(ID, id);
     fields.put(NAME, name);
+    fields.put(EXTERNAL_ID, externalId);
+    fields.put(ENABLED, enabled);
     fields.put(AUDIT_INFO, auditInfo);
     fields.put(ROLE_NAMES, roleNames);
     fields.put(ROLE_IDS, roleIds);
@@ -87,6 +97,16 @@ public class UserEntity implements User, Entity, Auditable, 
HasIdentifier {
     return name;
   }
 
+  @Override
+  public String externalId() {
+    return externalId;
+  }
+
+  @Override
+  public boolean enabled() {
+    return enabled;
+  }
+
   /**
    * Returns the unique id of the user.
    *
@@ -165,13 +185,15 @@ public class UserEntity implements User, Entity, 
Auditable, HasIdentifier {
         && Objects.equals(name, that.name)
         && Objects.equals(namespace, that.namespace)
         && Objects.equals(auditInfo, that.auditInfo)
+        && Objects.equals(externalId, that.externalId)
+        && enabled == that.enabled
         && CollectionUtils.isEqualCollection(roleNames, that.roleNames)
         && CollectionUtils.isEqualCollection(roleIds, that.roleIds);
   }
 
   @Override
   public int hashCode() {
-    return Objects.hash(id, name, auditInfo, roleNames, roleIds);
+    return Objects.hash(id, name, auditInfo, externalId, enabled, roleNames, 
roleIds);
   }
 
   public static Builder builder() {
@@ -207,6 +229,28 @@ public class UserEntity implements User, Entity, 
Auditable, HasIdentifier {
       return this;
     }
 
+    /**
+     * Sets the external id of the user entity.
+     *
+     * @param externalId The external id of the user entity.
+     * @return The builder instance.
+     */
+    public Builder withExternalId(String externalId) {
+      userEntity.externalId = externalId;
+      return this;
+    }
+
+    /**
+     * Sets whether the user entity is enabled.
+     *
+     * @param enabled Whether the user entity is enabled.
+     * @return The builder instance.
+     */
+    public Builder withEnabled(boolean enabled) {
+      userEntity.enabled = enabled;
+      return this;
+    }
+
     /**
      * Sets the audit details of the user entity.
      *
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 f401cd50be..e564146ecd 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
@@ -312,6 +312,32 @@ public class JDBCBackend implements RelationalBackend {
     }
   }
 
+  @Override
+  public <E extends Entity & HasIdentifier> E getByExternalId(
+      NameIdentifier ident, Entity.EntityType entityType)
+      throws NoSuchEntityException, IOException {
+    switch (entityType) {
+      case USER:
+        return (E) UserMetaService.getInstance().getUserByExternalId(ident);
+      default:
+        throw new UnsupportedEntityTypeException(
+            "Unsupported entity type: %s for get by external id operation", 
entityType);
+    }
+  }
+
+  @Override
+  public <E extends Entity & HasIdentifier> E updateByExternalId(
+      NameIdentifier ident, Entity.EntityType entityType, Function<E, E> 
updater)
+      throws NoSuchEntityException, IOException {
+    switch (entityType) {
+      case USER:
+        return (E) UserMetaService.getInstance().updateUserByExternalId(ident, 
updater);
+      default:
+        throw new UnsupportedEntityTypeException(
+            "Unsupported entity type: %s for update by external 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 61eb9c435b..cffdd5e278 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
@@ -113,6 +113,35 @@ public interface RelationalBackend extends Closeable, 
SupportsRelationOperations
   <E extends Entity & HasIdentifier> E get(NameIdentifier ident, 
Entity.EntityType entityType)
       throws IOException;
 
+  /**
+   * Retrieves the entity associated with the external id name identifier.
+   *
+   * @param <E> The type of the entity returned.
+   * @param ident The external id name identifier.
+   * @param entityType The type of the entity.
+   * @return The entity associated with the external 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 getByExternalId(
+      NameIdentifier ident, Entity.EntityType entityType) throws 
NoSuchEntityException, IOException;
+
+  /**
+   * Updates an entity by external id.
+   *
+   * @param <E> the type of the entity returned
+   * @param ident the external 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 updateByExternalId(
+      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 6f21491173..a45d4b7d96 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
@@ -40,6 +40,7 @@ import org.apache.gravitino.HasIdentifier;
 import org.apache.gravitino.NameIdentifier;
 import org.apache.gravitino.Namespace;
 import org.apache.gravitino.RelationalEntity;
+import org.apache.gravitino.SupportsExternalIdOperations;
 import org.apache.gravitino.SupportsRelationOperations;
 import org.apache.gravitino.authorization.SecurableObject;
 import org.apache.gravitino.cache.CacheFactory;
@@ -65,7 +66,10 @@ import org.slf4j.LoggerFactory;
  * RelationalBackend} interface. The default JDBC backend is {@link 
JDBCBackend}.
  */
 public class RelationalEntityStore
-    implements EntityStore, SupportsRelationOperations, 
SupportsEntityChangeLog {
+    implements EntityStore,
+        SupportsRelationOperations,
+        SupportsExternalIdOperations,
+        SupportsEntityChangeLog {
   private static final Logger LOGGER = 
LoggerFactory.getLogger(RelationalEntityStore.class);
   public static final ImmutableMap<String, String> RELATIONAL_BACKENDS =
       ImmutableMap.of(
@@ -181,6 +185,46 @@ public class RelationalEntityStore
         });
   }
 
+  @Override
+  public SupportsExternalIdOperations externalIdOperations() {
+    return this;
+  }
+
+  @Override
+  public <E extends Entity & HasIdentifier> E getByExternalId(
+      NameIdentifier ident, Entity.EntityType entityType, Class<E> type)
+      throws NoSuchEntityException, IOException {
+    return backend.getByExternalId(ident, entityType);
+  }
+
+  @Override
+  public <E extends Entity & HasIdentifier> E updateByExternalId(
+      NameIdentifier ident, Entity.EntityType entityType, Class<E> type, 
Function<E, E> updater)
+      throws NoSuchEntityException, IOException {
+    E updatedEntity = backend.updateByExternalId(ident, entityType, updater);
+    cache.invalidate(updatedEntity.nameIdentifier(), entityType);
+    return updatedEntity;
+  }
+
+  @Override
+  public boolean deleteByExternalId(NameIdentifier ident, Entity.EntityType 
entityType)
+      throws IOException {
+    NameIdentifier nameIdent = null;
+    try {
+      HasIdentifier entity = backend.getByExternalId(ident, entityType);
+      nameIdent = entity.nameIdentifier();
+      return backend.delete(nameIdent, entityType, false);
+    } catch (NoSuchEntityException e) {
+      LOGGER.warn(
+          "The entity to be deleted by external 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 4f16086b84..87f8a26b17 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
@@ -98,6 +98,16 @@ public interface UserMetaMapper {
   UserUpdatedAt getUserUpdatedAt(
       @Param("metalakeName") String metalakeName, @Param("userName") String 
userName);
 
+  @SelectProvider(
+      type = UserMetaSQLProviderFactory.class,
+      method = "selectUserMetaByMetalakeNameAndExternalId")
+  UserPO selectUserMetaByMetalakeNameAndExternalId(
+      @Param("metalakeName") String metalakeName, @Param("externalId") String 
externalId);
+
+  @UpdateProvider(type = UserMetaSQLProviderFactory.class, method = 
"updateUserMetaByExternalId")
+  Integer updateUserMetaByExternalId(
+      @Param("newUserMeta") UserPO newUserPO, @Param("oldUserMeta") UserPO 
oldUserPO);
+
   /**
    * Single-round-trip auth prefetch for the JCasbin authorize hot path. 
Returns every version
    * sentinel the request needs:
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 27953c35de..cc7de6fb28 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
@@ -114,4 +114,14 @@ public class UserMetaSQLProviderFactory {
       @Param("groupNames") List<String> groupNames) {
     return getProvider().batchGetAuthSubjectsForUser(metalakeName, userName, 
groupNames);
   }
+
+  public static String selectUserMetaByMetalakeNameAndExternalId(
+      @Param("metalakeName") String metalakeName, @Param("externalId") String 
externalId) {
+    return 
getProvider().selectUserMetaByMetalakeNameAndExternalId(metalakeName, 
externalId);
+  }
+
+  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/OwnerMetaBaseSQLProvider.java
 
b/core/src/main/java/org/apache/gravitino/storage/relational/mapper/provider/base/OwnerMetaBaseSQLProvider.java
index a89c3a1cc3..bee7d6014f 100644
--- 
a/core/src/main/java/org/apache/gravitino/storage/relational/mapper/provider/base/OwnerMetaBaseSQLProvider.java
+++ 
b/core/src/main/java/org/apache/gravitino/storage/relational/mapper/provider/base/OwnerMetaBaseSQLProvider.java
@@ -47,6 +47,8 @@ public class OwnerMetaBaseSQLProvider {
     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,"
@@ -70,6 +72,8 @@ public class OwnerMetaBaseSQLProvider {
         + "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, "
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 4bb92e103c..6c0dc22334 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
@@ -44,25 +44,63 @@ public class UserMetaBaseSQLProvider {
       @Param("metalakeId") Long metalakeId, @Param("userName") String name) {
     return "SELECT user_id as userId, user_name as userName,"
         + " metalake_id as metalakeId,"
-        + " audit_info as auditInfo,"
-        + " current_version as currentVersion, last_version as lastVersion,"
-        + " deleted_at as deletedAt"
+        + " external_id as externalId, enabled as enabled,"
+        + " audit_info as auditInfo, current_version as currentVersion,"
+        + " last_version as lastVersion, deleted_at as deletedAt"
         + " FROM "
         + USER_TABLE_NAME
         + " WHERE metalake_id = #{metalakeId} AND user_name = #{userName}"
         + " AND deleted_at = 0";
   }
 
+  public String selectUserMetaByMetalakeNameAndExternalId(
+      @Param("metalakeName") String metalakeName, @Param("externalId") String 
externalId) {
+    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.external_id = #{externalId}"
+        + " AND ut.deleted_at = 0 AND mt.deleted_at = 0";
+  }
+
+  public String updateUserMetaByExternalId(
+      @Param("newUserMeta") UserPO newUserPO, @Param("oldUserMeta") UserPO 
oldUserPO) {
+    return "UPDATE "
+        + USER_TABLE_NAME
+        + " SET user_name = #{newUserMeta.userName},"
+        + " metalake_id = #{newUserMeta.metalakeId},"
+        + " audit_info = #{newUserMeta.auditInfo},"
+        + " external_id = #{newUserMeta.externalId},"
+        + " enabled = #{newUserMeta.enabled},"
+        + " current_version = #{newUserMeta.currentVersion},"
+        + " last_version = #{newUserMeta.lastVersion},"
+        + " deleted_at = #{newUserMeta.deletedAt}"
+        + " WHERE external_id = #{oldUserMeta.externalId}"
+        + " AND metalake_id = #{oldUserMeta.metalakeId}"
+        + " AND audit_info = #{oldUserMeta.auditInfo}"
+        + " AND current_version = #{oldUserMeta.currentVersion}"
+        + " AND last_version = #{oldUserMeta.lastVersion}"
+        + " AND deleted_at = 0";
+  }
+
   public String insertUserMeta(@Param("userMeta") UserPO userPO) {
     return "INSERT INTO "
         + USER_TABLE_NAME
-        + " (user_id, user_name,"
-        + " metalake_id, audit_info,"
-        + " current_version, last_version, deleted_at)"
+        + " (user_id, user_name, metalake_id, external_id, enabled,"
+        + " audit_info, current_version, last_version, deleted_at)"
         + " VALUES ("
         + " #{userMeta.userId},"
         + " #{userMeta.userName},"
         + " #{userMeta.metalakeId},"
+        + " #{userMeta.externalId},"
+        + " #{userMeta.enabled},"
         + " #{userMeta.auditInfo},"
         + " #{userMeta.currentVersion},"
         + " #{userMeta.lastVersion},"
@@ -73,13 +111,14 @@ public class UserMetaBaseSQLProvider {
   public String insertUserMetaOnDuplicateKeyUpdate(@Param("userMeta") UserPO 
userPO) {
     return "INSERT INTO "
         + USER_TABLE_NAME
-        + " (user_id, user_name,"
-        + " metalake_id, audit_info,"
-        + " current_version, last_version, deleted_at)"
+        + " (user_id, user_name, metalake_id, external_id, enabled,"
+        + " audit_info, current_version, last_version, deleted_at)"
         + " VALUES ("
         + " #{userMeta.userId},"
         + " #{userMeta.userName},"
         + " #{userMeta.metalakeId},"
+        + " #{userMeta.externalId},"
+        + " #{userMeta.enabled},"
         + " #{userMeta.auditInfo},"
         + " #{userMeta.currentVersion},"
         + " #{userMeta.lastVersion},"
@@ -89,6 +128,8 @@ public class UserMetaBaseSQLProvider {
         + " user_name = #{userMeta.userName},"
         + " metalake_id = #{userMeta.metalakeId},"
         + " audit_info = #{userMeta.auditInfo},"
+        + " external_id = #{userMeta.externalId},"
+        + " enabled = #{userMeta.enabled},"
         + " current_version = #{userMeta.currentVersion},"
         + " last_version = #{userMeta.lastVersion},"
         + " deleted_at = #{userMeta.deletedAt}";
@@ -117,6 +158,8 @@ public class UserMetaBaseSQLProvider {
         + " SET user_name = #{newUserMeta.userName},"
         + " metalake_id = #{newUserMeta.metalakeId},"
         + " audit_info = #{newUserMeta.auditInfo},"
+        + " external_id = #{newUserMeta.externalId},"
+        + " enabled = #{newUserMeta.enabled},"
         + " current_version = #{newUserMeta.currentVersion},"
         + " last_version = #{newUserMeta.lastVersion},"
         + " deleted_at = #{newUserMeta.deletedAt}"
@@ -132,6 +175,7 @@ public class UserMetaBaseSQLProvider {
   public String listUsersByRoleId(@Param("roleId") Long roleId) {
     return "SELECT us.user_id as userId, us.user_name as userName,"
         + " us.metalake_id as metalakeId,"
+        + " us.external_id as externalId, us.enabled as enabled,"
         + " us.audit_info as auditInfo, us.current_version as currentVersion,"
         + " us.last_version as lastVersion, us.deleted_at as deletedAt"
         + " FROM "
@@ -146,6 +190,7 @@ public class UserMetaBaseSQLProvider {
   public String listUserPOsByMetalake(@Param("metalakeName") String 
metalakeName) {
     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"
@@ -161,6 +206,7 @@ public class UserMetaBaseSQLProvider {
   public String listExtendedUserPOsByMetalakeId(@Param("metalakeId") Long 
metalakeId) {
     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,"
diff --git 
a/core/src/main/java/org/apache/gravitino/storage/relational/mapper/provider/h2/UserMetaH2Provider.java
 
b/core/src/main/java/org/apache/gravitino/storage/relational/mapper/provider/h2/UserMetaH2Provider.java
index fb2a3bd827..894babf2db 100644
--- 
a/core/src/main/java/org/apache/gravitino/storage/relational/mapper/provider/h2/UserMetaH2Provider.java
+++ 
b/core/src/main/java/org/apache/gravitino/storage/relational/mapper/provider/h2/UserMetaH2Provider.java
@@ -30,6 +30,7 @@ public class UserMetaH2Provider extends 
UserMetaBaseSQLProvider {
   public String listExtendedUserPOsByMetalakeId(@Param("metalakeId") Long 
metalakeId) {
     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,"
diff --git 
a/core/src/main/java/org/apache/gravitino/storage/relational/mapper/provider/postgresql/UserMetaPostgreSQLProvider.java
 
b/core/src/main/java/org/apache/gravitino/storage/relational/mapper/provider/postgresql/UserMetaPostgreSQLProvider.java
index a85a1610ab..2305535b1d 100644
--- 
a/core/src/main/java/org/apache/gravitino/storage/relational/mapper/provider/postgresql/UserMetaPostgreSQLProvider.java
+++ 
b/core/src/main/java/org/apache/gravitino/storage/relational/mapper/provider/postgresql/UserMetaPostgreSQLProvider.java
@@ -47,13 +47,15 @@ public class UserMetaPostgreSQLProvider extends 
UserMetaBaseSQLProvider {
   public String insertUserMetaOnDuplicateKeyUpdate(UserPO userPO) {
     return "INSERT INTO "
         + USER_TABLE_NAME
-        + " (user_id, user_name,"
-        + " metalake_id, audit_info,"
+        + " (user_id, user_name, metalake_id, external_id, enabled,"
+        + " audit_info,"
         + " current_version, last_version, deleted_at)"
         + " VALUES ("
         + " #{userMeta.userId},"
         + " #{userMeta.userName},"
         + " #{userMeta.metalakeId},"
+        + " #{userMeta.externalId},"
+        + " #{userMeta.enabled},"
         + " #{userMeta.auditInfo},"
         + " #{userMeta.currentVersion},"
         + " #{userMeta.lastVersion},"
@@ -62,6 +64,8 @@ public class UserMetaPostgreSQLProvider extends 
UserMetaBaseSQLProvider {
         + " ON CONFLICT(user_id) DO UPDATE SET"
         + " user_name = #{userMeta.userName},"
         + " metalake_id = #{userMeta.metalakeId},"
+        + " external_id = #{userMeta.externalId},"
+        + " enabled = #{userMeta.enabled},"
         + " audit_info = #{userMeta.auditInfo},"
         + " current_version = #{userMeta.currentVersion},"
         + " last_version = #{userMeta.lastVersion},"
@@ -72,6 +76,7 @@ public class UserMetaPostgreSQLProvider extends 
UserMetaBaseSQLProvider {
   public String listExtendedUserPOsByMetalakeId(Long metalakeId) {
     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,"
diff --git 
a/core/src/main/java/org/apache/gravitino/storage/relational/po/UserPO.java 
b/core/src/main/java/org/apache/gravitino/storage/relational/po/UserPO.java
index 2a9079bc53..0bf3cd9dfe 100644
--- a/core/src/main/java/org/apache/gravitino/storage/relational/po/UserPO.java
+++ b/core/src/main/java/org/apache/gravitino/storage/relational/po/UserPO.java
@@ -25,6 +25,8 @@ public class UserPO {
   private Long userId;
   private String userName;
   private Long metalakeId;
+  private String externalId;
+  private Boolean enabled;
   private String auditInfo;
   private Long currentVersion;
   private Long lastVersion;
@@ -42,6 +44,14 @@ public class UserPO {
     return metalakeId;
   }
 
+  public String getExternalId() {
+    return externalId;
+  }
+
+  public Boolean getEnabled() {
+    return enabled;
+  }
+
   public String getAuditInfo() {
     return auditInfo;
   }
@@ -70,6 +80,8 @@ public class UserPO {
     return Objects.equal(getUserId(), tablePO.getUserId())
         && Objects.equal(getUserName(), tablePO.getUserName())
         && Objects.equal(getMetalakeId(), tablePO.getMetalakeId())
+        && Objects.equal(getExternalId(), tablePO.getExternalId())
+        && Objects.equal(getEnabled(), tablePO.getEnabled())
         && Objects.equal(getAuditInfo(), tablePO.getAuditInfo())
         && Objects.equal(getCurrentVersion(), tablePO.getCurrentVersion())
         && Objects.equal(getLastVersion(), tablePO.getLastVersion())
@@ -82,6 +94,8 @@ public class UserPO {
         getUserId(),
         getUserName(),
         getMetalakeId(),
+        getExternalId(),
+        getEnabled(),
         getAuditInfo(),
         getCurrentVersion(),
         getLastVersion(),
@@ -110,6 +124,16 @@ public class UserPO {
       return this;
     }
 
+    public Builder withExternalId(String externalId) {
+      userPO.externalId = externalId;
+      return this;
+    }
+
+    public Builder withEnabled(Boolean enabled) {
+      userPO.enabled = enabled;
+      return this;
+    }
+
     public Builder withAuditInfo(String auditInfo) {
       userPO.auditInfo = auditInfo;
       return this;
@@ -141,6 +165,9 @@ public class UserPO {
     }
 
     public UserPO build() {
+      if (userPO.enabled == null) {
+        userPO.enabled = true;
+      }
       validate();
       return userPO;
     }
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 bb810b1811..ba8cf5271a 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
@@ -320,4 +320,71 @@ public class UserMetaService {
 
     return userDeletedCount[0] + userRoleRelDeletedCount[0];
   }
+
+  private UserPO getUserPOByMetalakeNameAndExternalId(String metalakeName, 
String externalId) {
+    UserPO userPO =
+        SessionUtils.getWithoutCommit(
+            UserMetaMapper.class,
+            mapper -> 
mapper.selectUserMetaByMetalakeNameAndExternalId(metalakeName, externalId));
+
+    if (userPO == null) {
+      throw new NoSuchEntityException(
+          NoSuchEntityException.NO_SUCH_ENTITY_MESSAGE,
+          Entity.EntityType.USER.name().toLowerCase(),
+          externalId);
+    }
+    return userPO;
+  }
+
+  @Monitored(
+      metricsSource = GRAVITINO_RELATIONAL_STORE_METRIC_NAME,
+      baseMetricName = "getUserByExternalId")
+  public UserEntity getUserByExternalId(NameIdentifier ident) {
+    AuthorizationUtils.checkUserExternalId(ident);
+    String metalake = ident.namespace().level(0);
+    String externalId = ident.name();
+    Namespace userNamespace = AuthorizationUtils.ofUserNamespace(metalake);
+    UserPO userPO = getUserPOByMetalakeNameAndExternalId(metalake, externalId);
+    List<RolePO> rolePOs = 
RoleMetaService.getInstance().listRolesByUserId(userPO.getUserId());
+    return POConverters.fromUserPO(userPO, rolePOs, userNamespace);
+  }
+
+  @Monitored(
+      metricsSource = GRAVITINO_RELATIONAL_STORE_METRIC_NAME,
+      baseMetricName = "updateUserByExternalId")
+  public <E extends Entity & HasIdentifier> UserEntity updateUserByExternalId(
+      NameIdentifier ident, Function<E, E> updater) throws IOException {
+    AuthorizationUtils.checkUserExternalId(ident);
+    String metalake = ident.namespace().level(0);
+    String externalId = ident.name();
+    Namespace userNamespace = AuthorizationUtils.ofUserNamespace(metalake);
+    UserPO oldUserPO = getUserPOByMetalakeNameAndExternalId(metalake, 
externalId);
+    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.updateUserMetaByExternalId(
+                          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;
+  }
 }
diff --git 
a/core/src/main/java/org/apache/gravitino/storage/relational/utils/POConverters.java
 
b/core/src/main/java/org/apache/gravitino/storage/relational/utils/POConverters.java
index ae69ce2797..48eb78dfc1 100644
--- 
a/core/src/main/java/org/apache/gravitino/storage/relational/utils/POConverters.java
+++ 
b/core/src/main/java/org/apache/gravitino/storage/relational/utils/POConverters.java
@@ -956,6 +956,8 @@ public class POConverters {
       return builder
           .withUserId(userEntity.id())
           .withUserName(userEntity.name())
+          .withExternalId(userEntity.externalId())
+          .withEnabled(userEntity.enabled())
           
.withAuditInfo(JsonUtils.anyFieldMapper().writeValueAsString(userEntity.auditInfo()))
           .withCurrentVersion(INIT_VERSION)
           .withLastVersion(INIT_VERSION)
@@ -983,6 +985,8 @@ public class POConverters {
           .withUserId(oldUserPO.getUserId())
           .withUserName(newUser.name())
           .withMetalakeId(oldUserPO.getMetalakeId())
+          .withExternalId(newUser.externalId())
+          .withEnabled(newUser.enabled())
           
.withAuditInfo(JsonUtils.anyFieldMapper().writeValueAsString(newUser.auditInfo()))
           .withCurrentVersion(nextVersion)
           .withLastVersion(nextVersion)
@@ -1012,6 +1016,8 @@ public class POConverters {
               .withId(userPO.getUserId())
               .withName(userPO.getUserName())
               .withNamespace(namespace)
+              .withExternalId(userPO.getExternalId())
+              .withEnabled(userPO.getEnabled())
               .withAuditInfo(
                   JsonUtils.anyFieldMapper().readValue(userPO.getAuditInfo(), 
AuditInfo.class));
       if (!roleNames.isEmpty()) {
@@ -1040,6 +1046,8 @@ public class POConverters {
               .withId(userPO.getUserId())
               .withName(userPO.getUserName())
               .withNamespace(namespace)
+              .withExternalId(userPO.getExternalId())
+              .withEnabled(userPO.getEnabled())
               .withAuditInfo(
                   JsonUtils.anyFieldMapper().readValue(userPO.getAuditInfo(), 
AuditInfo.class));
       if (StringUtils.isNotBlank(userPO.getRoleNames())) {
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 e3523d3bd1..1763c2b8c6 100644
--- 
a/core/src/test/java/org/apache/gravitino/authorization/TestAccessControlManager.java
+++ 
b/core/src/test/java/org/apache/gravitino/authorization/TestAccessControlManager.java
@@ -49,7 +49,9 @@ import java.io.File;
 import java.io.IOException;
 import java.time.Instant;
 import java.util.Arrays;
+import java.util.Collections;
 import java.util.Comparator;
+import java.util.List;
 import java.util.Map;
 import java.util.UUID;
 import org.apache.commons.lang3.reflect.FieldUtils;
@@ -80,6 +82,7 @@ import org.junit.jupiter.api.AfterAll;
 import org.junit.jupiter.api.Assertions;
 import org.junit.jupiter.api.BeforeAll;
 import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.function.Executable;
 import org.mockito.Mockito;
 
 public class TestAccessControlManager {
@@ -454,6 +457,114 @@ public class TestAccessControlManager {
     accessControlManager.deleteRole("metalake_list", "testList2");
   }
 
+  @Test
+  public void testUserExtId() {
+    accessControlManager.addUser(METALAKE, "disabled_user", 
"ext-disabled-user", false);
+    accessControlManager.removeUser(METALAKE, "disabled_user");
+
+    String user = "ext_user";
+    String extId = "ext-user-1";
+    User added = accessControlManager.addUser(METALAKE, user, extId, true);
+    Assertions.assertEquals(extId, added.externalId());
+    Assertions.assertTrue(added.enabled());
+
+    createCatalogRole("ext_role");
+    accessControlManager.grantRolesToUser(METALAKE, 
Lists.newArrayList("ext_role"), user);
+    User disabled = accessControlManager.disableUser(METALAKE, extId);
+    Assertions.assertFalse(disabled.enabled());
+    assertSortedRoles(disabled, "ext_role");
+
+    createCatalogRole("ext_role2");
+    accessControlManager.grantRolesToUser(METALAKE, 
Lists.newArrayList("ext_role2"), user);
+    assertSortedRoles(accessControlManager.getUser(METALAKE, user), 
"ext_role", "ext_role2");
+    Assertions.assertFalse(accessControlManager.getUserByExternalId(METALAKE, 
extId).enabled());
+
+    User enabled = accessControlManager.enableUser(METALAKE, extId);
+    Assertions.assertTrue(enabled.enabled());
+    assertSortedRoles(enabled, "ext_role", "ext_role2");
+
+    accessControlManager.revokeRolesFromUser(
+        METALAKE, Lists.newArrayList("ext_role", "ext_role2"), user);
+    accessControlManager.deleteRole(METALAKE, "ext_role2");
+    accessControlManager.deleteRole(METALAKE, "ext_role");
+    accessControlManager.removeUser(METALAKE, user);
+  }
+
+  @Test
+  public void testMissingExt() {
+    assertInvalidExt(() -> accessControlManager.getUserByExternalId(METALAKE, 
null));
+    assertInvalidExt(() -> accessControlManager.getUserByExternalId(METALAKE, 
""));
+    assertMissingExt(
+        NoSuchUserException.class,
+        () -> accessControlManager.getUserByExternalId(METALAKE, 
"missing-ext-id"));
+    assertMissingExt(
+        NoSuchUserException.class,
+        () -> accessControlManager.disableUser(METALAKE, "missing-ext-id"));
+    assertMissingExt(
+        NoSuchUserException.class,
+        () -> accessControlManager.enableUser(METALAKE, "missing-ext-id"));
+  }
+
+  @Test
+  public void testExtDup() {
+    accessControlManager.addUser(METALAKE, "u1", "dup-ext", true);
+    assertThrowsExt(
+        UserAlreadyExistsException.class,
+        () -> accessControlManager.addUser(METALAKE, "u2", "dup-ext", true));
+    accessControlManager.removeUser(METALAKE, "u1");
+  }
+
+  @Test
+  public void testUserExtDel() {
+    String extId = "ext-remove-user";
+    accessControlManager.addUser(METALAKE, "remove_user", extId, true);
+    
Assertions.assertTrue(accessControlManager.removeUserByExternalId(METALAKE, 
extId));
+    assertMissingExt(
+        NoSuchUserException.class, () -> 
accessControlManager.getUserByExternalId(METALAKE, extId));
+    Assertions.assertThrows(
+        NoSuchUserException.class, () -> 
accessControlManager.getUser(METALAKE, "remove_user"));
+    
Assertions.assertFalse(accessControlManager.removeUserByExternalId(METALAKE, 
"missing-ext-id"));
+  }
+
+  @Test
+  public void testExtCache() {
+    String extId = "ext-cache-user";
+    accessControlManager.addUser(METALAKE, "cache_user", extId, true);
+    accessControlManager.getUser(METALAKE, "cache_user");
+    accessControlManager.disableUser(METALAKE, extId);
+    Assertions.assertFalse(accessControlManager.getUser(METALAKE, 
"cache_user").enabled());
+    accessControlManager.removeUser(METALAKE, "cache_user");
+  }
+
+  private void createCatalogRole(String role) {
+    accessControlManager.createRole(
+        METALAKE,
+        role,
+        ImmutableMap.of("k1", "v1"),
+        Lists.newArrayList(
+            SecurableObjects.ofCatalog(
+                "catalog", 
Lists.newArrayList(Privileges.UseCatalog.allow()))));
+  }
+
+  private void assertSortedRoles(User user, String... expectedRoles) {
+    List<String> roles = Lists.newArrayList(user.roles());
+    Collections.sort(roles);
+    Assertions.assertEquals(Lists.newArrayList(expectedRoles), roles);
+  }
+
+  private void assertThrowsExt(Class<? extends Exception> type, Executable 
executable) {
+    Assertions.assertThrows(type, executable);
+  }
+
+  private void assertInvalidExt(Executable executable) {
+    Assertions.assertThrows(IllegalArgumentException.class, executable);
+  }
+
+  private void assertMissingExt(Class<? extends Exception> type, Executable 
executable) {
+    Exception ex = Assertions.assertThrows(type, executable);
+    Assertions.assertTrue(ex.getMessage().contains("external id"));
+  }
+
   private void testProperties(Map<String, String> expectedProps, Map<String, 
String> testProps) {
     expectedProps.forEach(
         (k, v) -> {
diff --git 
a/core/src/test/java/org/apache/gravitino/authorization/TestAuthorizationUtils.java
 
b/core/src/test/java/org/apache/gravitino/authorization/TestAuthorizationUtils.java
index c0ae444eb0..5017f4211c 100644
--- 
a/core/src/test/java/org/apache/gravitino/authorization/TestAuthorizationUtils.java
+++ 
b/core/src/test/java/org/apache/gravitino/authorization/TestAuthorizationUtils.java
@@ -54,6 +54,7 @@ class TestAuthorizationUtils {
     NameIdentifier user = AuthorizationUtils.ofUser(metalake, "user");
     NameIdentifier group = AuthorizationUtils.ofGroup(metalake, "group");
     NameIdentifier role = AuthorizationUtils.ofRole(metalake, "role");
+    NameIdentifier userExt = AuthorizationUtils.ofUserExternalId(metalake, 
"ext-1");
 
     Assertions.assertEquals(AuthorizationUtils.ofUserNamespace(metalake), 
user.namespace());
     Assertions.assertEquals("user", user.name());
@@ -61,6 +62,17 @@ class TestAuthorizationUtils {
     Assertions.assertEquals("group", group.name());
     Assertions.assertEquals(AuthorizationUtils.ofRoleNamespace(metalake), 
role.namespace());
     Assertions.assertEquals("role", role.name());
+    Assertions.assertEquals(
+        AuthorizationUtils.ofUserExternalIdNamespace(metalake), 
userExt.namespace());
+    Assertions.assertEquals("ext-1", userExt.name());
+    Assertions.assertNotEquals(user, userExt);
+    Assertions.assertNotEquals(AuthorizationUtils.ofUser(metalake, "ext-1"), 
userExt);
+    assertInvalidExternalId(() -> 
AuthorizationUtils.ofUserExternalId(metalake, null));
+    assertInvalidExternalId(() -> 
AuthorizationUtils.ofUserExternalId(metalake, ""));
+  }
+
+  private void 
assertInvalidExternalId(org.junit.jupiter.api.function.Executable executable) {
+    Assertions.assertThrows(IllegalNameIdentifierException.class, executable);
   }
 
   @Test
@@ -119,15 +131,19 @@ class TestAuthorizationUtils {
   @Test
   void testCheckNameIdentifier() {
     NameIdentifier user = AuthorizationUtils.ofUser(metalake, "user");
+    NameIdentifier userExternalId = 
AuthorizationUtils.ofUserExternalId(metalake, "ext-1");
     NameIdentifier group = AuthorizationUtils.ofGroup(metalake, "group");
     NameIdentifier role = AuthorizationUtils.ofRole(metalake, "role");
 
     Assertions.assertDoesNotThrow(() -> AuthorizationUtils.checkUser(user));
+    Assertions.assertDoesNotThrow(() -> 
AuthorizationUtils.checkUserExternalId(userExternalId));
     Assertions.assertDoesNotThrow(() -> AuthorizationUtils.checkGroup(group));
     Assertions.assertDoesNotThrow(() -> AuthorizationUtils.checkRole(role));
 
     Assertions.assertThrows(
         IllegalNameIdentifierException.class, () -> 
AuthorizationUtils.checkUser(null));
+    Assertions.assertThrows(
+        IllegalNameIdentifierException.class, () -> 
AuthorizationUtils.checkUserExternalId(null));
     Assertions.assertThrows(
         IllegalNameIdentifierException.class, () -> 
AuthorizationUtils.checkGroup(null));
     Assertions.assertThrows(
@@ -135,6 +151,9 @@ class TestAuthorizationUtils {
     Assertions.assertThrows(
         IllegalNameIdentifierException.class,
         () -> AuthorizationUtils.checkUser(NameIdentifier.of("")));
+    Assertions.assertThrows(
+        IllegalNameIdentifierException.class,
+        () -> AuthorizationUtils.checkUserExternalId(NameIdentifier.of("")));
     Assertions.assertThrows(
         IllegalNameIdentifierException.class,
         () -> AuthorizationUtils.checkGroup(NameIdentifier.of("")));
@@ -146,15 +165,21 @@ class TestAuthorizationUtils {
   @Test
   void testCheckNamespace() {
     Namespace userNamespace = AuthorizationUtils.ofUserNamespace(metalake);
+    Namespace userExternalIdNamespace = 
AuthorizationUtils.ofUserExternalIdNamespace(metalake);
     Namespace groupNamespace = AuthorizationUtils.ofGroupNamespace(metalake);
     Namespace roleNamespace = AuthorizationUtils.ofRoleNamespace(metalake);
 
     Assertions.assertDoesNotThrow(() -> 
AuthorizationUtils.checkUserNamespace(userNamespace));
+    Assertions.assertDoesNotThrow(
+        () -> 
AuthorizationUtils.checkUserExternalIdNamespace(userExternalIdNamespace));
     Assertions.assertDoesNotThrow(() -> 
AuthorizationUtils.checkGroupNamespace(groupNamespace));
     Assertions.assertDoesNotThrow(() -> 
AuthorizationUtils.checkRoleNamespace(roleNamespace));
 
     Assertions.assertThrows(
         IllegalNamespaceException.class, () -> 
AuthorizationUtils.checkUserNamespace(null));
+    Assertions.assertThrows(
+        IllegalNamespaceException.class,
+        () -> AuthorizationUtils.checkUserExternalIdNamespace(null));
     Assertions.assertThrows(
         IllegalNamespaceException.class, () -> 
AuthorizationUtils.checkGroupNamespace(null));
     Assertions.assertThrows(
@@ -162,6 +187,9 @@ class TestAuthorizationUtils {
     Assertions.assertThrows(
         IllegalNamespaceException.class,
         () -> AuthorizationUtils.checkUserNamespace(Namespace.of("a", "b")));
+    Assertions.assertThrows(
+        IllegalNamespaceException.class,
+        () -> 
AuthorizationUtils.checkUserExternalIdNamespace(Namespace.of("a", "b")));
     Assertions.assertThrows(
         IllegalNamespaceException.class,
         () -> AuthorizationUtils.checkGroupNamespace(Namespace.of("a")));
diff --git 
a/core/src/test/java/org/apache/gravitino/listener/api/event/TestOwnerEvent.java
 
b/core/src/test/java/org/apache/gravitino/listener/api/event/TestOwnerEvent.java
index 4f2c597b38..49f177c479 100644
--- 
a/core/src/test/java/org/apache/gravitino/listener/api/event/TestOwnerEvent.java
+++ 
b/core/src/test/java/org/apache/gravitino/listener/api/event/TestOwnerEvent.java
@@ -37,7 +37,6 @@ import org.apache.gravitino.authorization.OwnerManager;
 import org.apache.gravitino.listener.DummyEventListener;
 import org.apache.gravitino.listener.EventBus;
 import org.apache.gravitino.listener.api.info.OwnerInfo;
-import org.apache.gravitino.storage.relational.RelationalBackend;
 import org.junit.jupiter.api.Assertions;
 import org.junit.jupiter.api.BeforeAll;
 import org.junit.jupiter.api.Test;
@@ -51,7 +50,7 @@ public class TestOwnerEvent {
   private OwnerManager innerOwnerManager;
   private DummyEventListener dummyEventListener;
 
-  interface TestEntityStore extends EntityStore, RelationalBackend, 
SupportsRelationOperations {
+  interface TestEntityStore extends EntityStore, SupportsRelationOperations {
     // This interface is used to mock the EntityStore for testing purposes.
   }
 
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 ead13a39f1..8d337c32ce 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
@@ -41,6 +41,7 @@ import org.apache.gravitino.HasIdentifier;
 import org.apache.gravitino.Metalake;
 import org.apache.gravitino.NameIdentifier;
 import org.apache.gravitino.Namespace;
+import org.apache.gravitino.SupportsExternalIdOperations;
 import org.apache.gravitino.TestCatalog;
 import org.apache.gravitino.authorization.AuthorizationUtils;
 import org.apache.gravitino.authorization.Privileges;
@@ -65,7 +66,7 @@ import org.mockito.Mockito;
 
 public class TestMemoryEntityStore {
 
-  public static class InMemoryEntityStore implements EntityStore {
+  public static class InMemoryEntityStore implements EntityStore, 
SupportsExternalIdOperations {
     private final Map<NameIdentifier, Entity> entityMap;
     private final Lock lock;
 
@@ -147,6 +148,68 @@ public class TestMemoryEntityStore {
       return e;
     }
 
+    @Override
+    public SupportsExternalIdOperations externalIdOperations() {
+      return this;
+    }
+
+    @Override
+    public <E extends Entity & HasIdentifier> E getByExternalId(
+        NameIdentifier ident, EntityType entityType, Class<E> type)
+        throws NoSuchEntityException, IOException {
+      if (entityType != EntityType.USER) {
+        throw new UnsupportedOperationException(
+            "Get by external id is not supported for entity type: " + 
entityType);
+      }
+
+      AuthorizationUtils.checkUserExternalId(ident);
+      String externalId = ident.name();
+      Namespace userNamespace = 
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(userNamespace)) {
+          continue;
+        }
+
+        String entityExternalId = null;
+        if (entity instanceof UserEntity userEntity) {
+          entityExternalId = userEntity.externalId();
+        }
+
+        if (externalId.equals(entityExternalId)) {
+          return (E) entity;
+        }
+      }
+
+      throw new NoSuchEntityException(
+          NoSuchEntityException.NO_SUCH_ENTITY_MESSAGE,
+          entityType.name().toLowerCase(),
+          externalId);
+    }
+
+    @Override
+    @SuppressWarnings("unchecked")
+    public <E extends Entity & HasIdentifier> E updateByExternalId(
+        NameIdentifier ident, EntityType entityType, Class<E> type, 
Function<E, E> updater)
+        throws NoSuchEntityException, IOException {
+      E user = getByExternalId(ident, entityType, type);
+      E updated = updater.apply(user);
+      return update(user.nameIdentifier(), type, entityType, entity -> 
updated);
+    }
+
+    @Override
+    public boolean deleteByExternalId(NameIdentifier ident, EntityType 
entityType)
+        throws IOException {
+      try {
+        UserEntity user = getByExternalId(ident, entityType, UserEntity.class);
+        return delete(user.nameIdentifier(), entityType);
+      } catch (NoSuchEntityException e) {
+        return false;
+      }
+    }
+
     @Override
     public <E extends Entity & HasIdentifier> List<E> batchGet(
         List<NameIdentifier> idents, EntityType entityType, Class<E> e) {
diff --git 
a/core/src/test/java/org/apache/gravitino/storage/relational/mapper/provider/base/TestAuthMappers.java
 
b/core/src/test/java/org/apache/gravitino/storage/relational/mapper/provider/base/TestAuthMappers.java
index 2175a0a6b5..e26613c790 100644
--- 
a/core/src/test/java/org/apache/gravitino/storage/relational/mapper/provider/base/TestAuthMappers.java
+++ 
b/core/src/test/java/org/apache/gravitino/storage/relational/mapper/provider/base/TestAuthMappers.java
@@ -414,6 +414,7 @@ public class TestAuthMappers {
             .withUserId(userId)
             .withUserName(userName)
             .withMetalakeId(metalakeId)
+            .withEnabled(true)
             .withAuditInfo(auditInfo.toString())
             .withCurrentVersion(1L)
             .withLastVersion(0L)
diff --git 
a/core/src/test/java/org/apache/gravitino/storage/relational/mapper/provider/base/TestUserRoleRelMapper.java
 
b/core/src/test/java/org/apache/gravitino/storage/relational/mapper/provider/base/TestUserRoleRelMapper.java
index 38e9b044cc..1c99ea8e89 100644
--- 
a/core/src/test/java/org/apache/gravitino/storage/relational/mapper/provider/base/TestUserRoleRelMapper.java
+++ 
b/core/src/test/java/org/apache/gravitino/storage/relational/mapper/provider/base/TestUserRoleRelMapper.java
@@ -166,6 +166,7 @@ public class TestUserRoleRelMapper {
             .withUserId(1L)
             .withUserName("user1")
             .withMetalakeId(1L)
+            .withEnabled(true)
             .withAuditInfo(auditInfo.toString())
             .withCurrentVersion(1L)
             .withLastVersion(0L)
diff --git 
a/core/src/test/java/org/apache/gravitino/storage/relational/service/TestUserMetaService.java
 
b/core/src/test/java/org/apache/gravitino/storage/relational/service/TestUserMetaService.java
index 73345e1340..f6370317a9 100644
--- 
a/core/src/test/java/org/apache/gravitino/storage/relational/service/TestUserMetaService.java
+++ 
b/core/src/test/java/org/apache/gravitino/storage/relational/service/TestUserMetaService.java
@@ -40,6 +40,7 @@ import java.util.function.Function;
 import java.util.stream.Collectors;
 import org.apache.gravitino.Entity;
 import org.apache.gravitino.EntityAlreadyExistsException;
+import org.apache.gravitino.NameIdentifier;
 import org.apache.gravitino.Namespace;
 import org.apache.gravitino.authorization.AuthorizationUtils;
 import org.apache.gravitino.exceptions.NoSuchEntityException;
@@ -65,6 +66,7 @@ import org.apache.gravitino.utils.NamespaceUtil;
 import org.apache.ibatis.session.SqlSession;
 import org.junit.jupiter.api.Assertions;
 import org.junit.jupiter.api.TestTemplate;
+import org.junit.jupiter.api.function.Executable;
 
 class TestUserMetaService extends TestJDBCBackend {
 
@@ -1260,6 +1262,151 @@ class TestUserMetaService extends TestJDBCBackend {
     assertTrue(none.isEmpty());
   }
 
+  @TestTemplate
+  void testUserExtId() throws IOException {
+    UserMetaService svc = userMetaService();
+    svc.insertUser(userWithExtId("u1", "ext-1"), false);
+    UserEntity found = svc.getUserByExternalId(userExtIdent("ext-1"));
+    Assertions.assertEquals("u1", found.name());
+    Assertions.assertEquals("ext-1", found.externalId());
+    assertThrowsExt(
+        NoSuchEntityException.class, () -> 
svc.getUserByExternalId(userExtIdent("missing-ext-id")));
+    assertThrowsExt(
+        IllegalArgumentException.class, () -> 
svc.getUserByExternalId(userExtIdent("")));
+    assertThrowsExt(
+        NoSuchEntityException.class,
+        () -> svc.updateUserByExternalId(userExtIdent("missing-ext-id"), 
enabledUpdater(false)));
+  }
+
+  @TestTemplate
+  void testExtEnable() throws IOException {
+    UserMetaService svc = userMetaService();
+    svc.insertUser(userWithExtId("u1", "ext-1"), false);
+    Assertions.assertFalse(
+        svc.updateUserByExternalId(userExtIdent("ext-1"), 
enabledUpdater(false)).enabled());
+    Assertions.assertTrue(
+        svc.updateUserByExternalId(userExtIdent("ext-1"), 
enabledUpdater(true)).enabled());
+
+    svc.insertUser(userWithExtId("u2", "ext-db"), false);
+    long updatedAtBefore = queryUpdatedAtByExtId("ext-db");
+    svc.updateUserByExternalId(userExtIdent("ext-db"), enabledUpdater(false));
+    Assertions.assertFalse(queryEnabledByExtId("ext-db"));
+    long updatedAtAfterDisable = queryUpdatedAtByExtId("ext-db");
+    Assertions.assertTrue(updatedAtAfterDisable >= updatedAtBefore);
+
+    svc.updateUserByExternalId(userExtIdent("ext-db"), enabledUpdater(true));
+    Assertions.assertTrue(queryEnabledByExtId("ext-db"));
+    Assertions.assertTrue(queryUpdatedAtByExtId("ext-db") >= 
updatedAtAfterDisable);
+  }
+
+  @TestTemplate
+  void testExtEnableDel() throws IOException {
+    UserMetaService svc = userMetaService();
+    UserEntity user = userWithExtId("u1", "ext-del");
+    svc.insertUser(user, false);
+    Assertions.assertTrue(svc.deleteUser(user.nameIdentifier()));
+    assertThrowsExt(
+        NoSuchEntityException.class,
+        () -> svc.updateUserByExternalId(userExtIdent("ext-del"), 
enabledUpdater(false)));
+  }
+
+  @TestTemplate
+  void testUserExtDel() throws IOException {
+    UserMetaService svc = userMetaService();
+    svc.insertUser(userWithExtId("u1", "ext-del-by"), false);
+    UserEntity user = svc.getUserByExternalId(userExtIdent("ext-del-by"));
+    Assertions.assertEquals("u1", user.name());
+    Assertions.assertTrue(svc.deleteUser(user.nameIdentifier()));
+    assertThrowsExt(
+        NoSuchEntityException.class, () -> 
svc.getUserByExternalId(userExtIdent("ext-del-by")));
+    assertThrowsExt(NoSuchEntityException.class, () -> 
svc.deleteUser(user.nameIdentifier()));
+  }
+
+  @TestTemplate
+  void testExtDup() throws IOException {
+    UserMetaService svc = userMetaService();
+    svc.insertUser(userWithExtId("u1", "ext-1"), false);
+    assertThrowsExt(
+        EntityAlreadyExistsException.class,
+        () -> svc.insertUser(userWithExtId("u2", "ext-1"), false));
+  }
+
+  private UserMetaService userMetaService() throws IOException {
+    createAndInsertMakeLake(metalakeName);
+    return UserMetaService.getInstance();
+  }
+
+  private void assertThrowsExt(Class<? extends Exception> type, Executable 
executable) {
+    Assertions.assertThrows(type, executable);
+  }
+
+  private UserEntity userWithExtId(String name, String externalId) {
+    return UserEntity.builder()
+        .withId(RandomIdGenerator.INSTANCE.nextId())
+        .withName(name)
+        .withNamespace(AuthorizationUtils.ofUserNamespace(metalakeName))
+        .withExternalId(externalId)
+        .withEnabled(true)
+        .withAuditInfo(AUDIT_INFO)
+        .build();
+  }
+
+  private Namespace userNamespace() {
+    return AuthorizationUtils.ofUserNamespace(metalakeName);
+  }
+
+  private NameIdentifier userExtIdent(String externalId) {
+    return AuthorizationUtils.ofUserExternalId(metalakeName, externalId);
+  }
+
+  private Function<UserEntity, UserEntity> enabledUpdater(boolean enabled) {
+    return 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();
+  }
+
+  private boolean queryEnabledByExtId(String externalId) {
+    try (SqlSession sqlSession =
+            
SqlSessionFactoryHelper.getInstance().getSqlSessionFactory().openSession(true);
+        Connection connection = sqlSession.getConnection();
+        Statement statement = connection.createStatement();
+        ResultSet rs =
+            statement.executeQuery(
+                String.format(
+                    "SELECT enabled FROM user_meta WHERE external_id = '%s' 
AND deleted_at = 0",
+                    externalId))) {
+      Assertions.assertTrue(rs.next());
+      return rs.getBoolean(1);
+    } catch (SQLException e) {
+      throw new RuntimeException("Query user enabled failed", e);
+    }
+  }
+
+  private long queryUpdatedAtByExtId(String externalId) {
+    try (SqlSession sqlSession =
+            
SqlSessionFactoryHelper.getInstance().getSqlSessionFactory().openSession(true);
+        Connection connection = sqlSession.getConnection();
+        Statement statement = connection.createStatement();
+        ResultSet rs =
+            statement.executeQuery(
+                String.format(
+                    "SELECT updated_at FROM user_meta WHERE external_id = '%s' 
AND deleted_at = 0",
+                    externalId))) {
+      Assertions.assertTrue(rs.next());
+      return rs.getLong(1);
+    } catch (SQLException e) {
+      throw new RuntimeException("Query user updated_at failed", e);
+    }
+  }
+
   private Integer countUsers(Long metalakeId) {
     int count = 0;
     try (SqlSession sqlSession =

Reply via email to