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 d7e8b22ca5 [#13925] improvement(idp-basic): Require currentPassword 
for self password change (#13926)
d7e8b22ca5 is described below

commit d7e8b22ca56f4dab50c7ad728cf287ca735374b7
Author: MaSai <[email protected]>
AuthorDate: Mon Oct 19 18:57:45 2026 +0800

    [#13925] improvement(idp-basic): Require currentPassword for self password 
change (#13926)
    
    ### What changes were proposed in this pull request?
    
    Require `oldPassword` when a caller changes their own local IdP
    password, and verify it against the stored hash before applying
    `newPassword`. Service admins resetting another user's password continue
    to send only `newPassword` and must omit `oldPassword`.
    
    - Extend `UpdateUserRequest` / OpenAPI with `newPassword` and optional
    `oldPassword` (replacing the previous `password` field on update)
    - Enforce the self vs admin-reset rules in `IdpUserOperations`
    - Map `UnauthorizedException` from a failed old-password check to HTTP
    401 in IdP REST
    - Update docs and unit/IT coverage
    
    ### Why are the changes needed?
    
    Self-service password change previously accepted only the new password.
    A caller with a valid Basic session could change their password without
    proving knowledge of the existing password again. Field names match the
    enterprise API (`oldPassword` / `newPassword`).
    
    Fix: #13925
    
    ### Does this PR introduce _any_ user-facing change?
    
    Yes. `PUT /api/idp/users/{user}` for a self password change now
    requires:
    
    ```json
    {"newPassword":"{new_password}","oldPassword":"{old_password}"}
    ```
    
    A service admin resetting another user uses:
    
    ```json
    {"newPassword":"{new_password}"}
    ```
    
    Sending `oldPassword` on an admin reset of another user is rejected.
    Create-user still uses `password`.
    
    ### How was this patch tested?
    
    - `./gradlew :plugins:idp-basic:test --tests
    org.apache.gravitino.idp.TestIdpUserGroupManager --tests
    org.apache.gravitino.idp.dto.requests.TestUpdateUserRequest --tests
    org.apache.gravitino.idp.web.rest.TestIdpOperations -PskipITs`
    - `./gradlew :docs:build`
    
    ---------
    
    Co-authored-by: Cursor <[email protected]>
---
 design-docs/gravitino-local-authentication.md      |  32 +++--
 docs/open-api/idp/idp.yaml                         | 148 ++++++++++++++++-----
 docs/open-api/idp/openapi.yaml                     |   6 +
 docs/security/local-users-and-groups.md            |  46 +++++--
 .../apache/gravitino/idp/IdpUserGroupManager.java  |  24 +++-
 .../idp/basic/IdpCredentialValidator.java          |  14 +-
 ...UserRequest.java => ChangePasswordRequest.java} |  49 +++----
 ...rRequest.java => UpdateUserEnabledRequest.java} |  44 ++----
 .../org/apache/gravitino/idp/web/IdpRESTUtils.java |  11 ++
 .../idp/web/rest/IdpAuthorizationFilter.java       |  28 ++--
 .../gravitino/idp/web/rest/IdpUserOperations.java  |  91 +++++++++----
 .../gravitino/idp/TestIdpUserGroupManager.java     |  33 ++++-
 .../dto/requests/TestChangePasswordRequest.java    |  79 +++++++++++
 .../dto/requests/TestUpdateUserEnabledRequest.java |  49 +++++++
 .../idp/dto/requests/TestUpdateUserRequest.java    |  83 ------------
 .../idp/integration/test/IdpRESTApiIT.java         |  51 +++++--
 .../idp/web/rest/TestIdpAuthorizationFilter.java   |   6 +
 .../gravitino/idp/web/rest/TestIdpOperations.java  |  53 ++++++--
 18 files changed, 579 insertions(+), 268 deletions(-)

diff --git a/design-docs/gravitino-local-authentication.md 
b/design-docs/gravitino-local-authentication.md
index ba3cce4c65..a862573f83 100644
--- a/design-docs/gravitino-local-authentication.md
+++ b/design-docs/gravitino-local-authentication.md
@@ -460,19 +460,33 @@ http://localhost:8090/api/idp/users/alice
 ```
 
 
-#### 9.1.4 Reset a local user password
+#### 9.1.4 Change a local user password
 
-`PUT /api/idp/users/{user}` — administrator-only reset (`password` only; no 
`oldPassword`). Usernames
-must not contain `:`. Password length is 12–64 characters.
+`PUT /api/idp/users/{user}/password` — service admins may reset any user's 
password with
+`newPassword` only (must omit `oldPassword`). Authenticated non-admin users 
may change only their
+own password and must also send `oldPassword`. Usernames must not contain `:`. 
Password length is
+12–64 characters.
 
 ```shell
 curl -X PUT -H "Accept: application/vnd.gravitino.v1+json" \
 -H "Content-Type: application/json" -d '{
-  "password": "Passw0rd-For-Gravitino-Alice-V2"
-}' http://localhost:8090/api/idp/users/alice
+  "newPassword": "Passw0rd-For-Gravitino-Alice-V2"
+}' http://localhost:8090/api/idp/users/alice/password
 ```
 
-#### 9.1.5 Get a local group
+#### 9.1.5 Update a local user enabled flag
+
+`PUT /api/idp/users/{user}/enabled` — service-admin only. Users listed in
+`gravitino.authorization.serviceAdmins` cannot have their enabled flag changed.
+
+```shell
+curl -X PUT -H "Accept: application/vnd.gravitino.v1+json" \
+-H "Content-Type: application/json" -d '{
+  "enabled": false
+}' http://localhost:8090/api/idp/users/alice/enabled
+```
+
+#### 9.1.6 Get a local group
 
 `GET /api/idp/groups/{group}`
 
@@ -482,7 +496,7 @@ http://localhost:8090/api/idp/groups/engineering
 ```
 
 
-#### 9.1.6 Add a local group
+#### 9.1.7 Add a local group
 
 `POST /api/idp/groups`
 
@@ -494,7 +508,7 @@ curl -X POST -H "Accept: application/vnd.gravitino.v1+json" 
\
 ```
 
 
-#### 9.1.7 Remove a local group
+#### 9.1.8 Remove a local group
 
 `DELETE /api/idp/groups/{group}?force={true|false}` — soft-delete. Use 
`force=true` when the group
 still has members.
@@ -505,7 +519,7 @@ curl -X DELETE -H "Accept: 
application/vnd.gravitino.v1+json" \
 ```
 
 
-#### 9.1.8 Change local group membership
+#### 9.1.9 Change local group membership
 
 `PUT /api/idp/groups/{group}/users` — at least one of `usersToAdd` or 
`usersToRemove` is required.
 
diff --git a/docs/open-api/idp/idp.yaml b/docs/open-api/idp/idp.yaml
index a1b4c0d8fa..a3db6a9514 100644
--- a/docs/open-api/idp/idp.yaml
+++ b/docs/open-api/idp/idp.yaml
@@ -109,31 +109,49 @@ paths:
         "5xx":
           $ref: "../openapi.yaml#/components/responses/ServerErrorResponse"
 
+    delete:
+      tags:
+        - IDP
+      summary: Remove local user store user
+      description: Removes the specified local user store user.
+      operationId: removeIdpUser
+      responses:
+        "200":
+          $ref: "../openapi.yaml#/components/responses/RemoveResponse"
+        "403":
+          $ref: "#/components/responses/IdpForbiddenErrorResponse"
+        "5xx":
+          $ref: "../openapi.yaml#/components/responses/ServerErrorResponse"
+
+  /idp/users/{user}/password:
+    parameters:
+      - $ref: "../openapi.yaml#/components/parameters/user"
+
     put:
       tags:
         - IDP
-      summary: Update local user store user
+      summary: Change local user store user password
       description: >
-        Updates the password and/or enabled flag of the specified local user 
store user and returns
-        the user object. At least one of `password` or `enabled` is required. 
Disabled users cannot
-        authenticate. Users listed in `gravitino.authorization.serviceAdmins` 
cannot be disabled.
-        Service admins may update any user. Authenticated non-admin users may 
update only their own
-        password and cannot change `enabled` or another user's account.
-      operationId: updateIdpUser
+        Changes the password of the specified local user store user and 
returns the user object.
+        Service admins may reset any user's password. Authenticated non-admin 
users may change only
+        their own password. Changing your own password requires `oldPassword` 
and verifies it
+        against the stored hash. A service admin resetting another user's 
password must omit
+        `oldPassword`.
+      operationId: changeIdpUserPassword
       requestBody:
         required: true
         content:
           application/json:
             schema:
-              $ref: "#/components/schemas/UpdateUserRequest"
+              $ref: "#/components/schemas/ChangePasswordRequest"
             examples:
-              UpdateUserPasswordRequest:
-                $ref: "#/components/examples/UpdateUserPasswordRequest"
-              UpdateUserEnabledRequest:
-                $ref: "#/components/examples/UpdateUserEnabledRequest"
+              ChangeUserPasswordRequest:
+                $ref: "#/components/examples/ChangeUserPasswordRequest"
+              ChangeOwnUserPasswordRequest:
+                $ref: "#/components/examples/ChangeOwnUserPasswordRequest"
       responses:
         "200":
-          description: Returns the local user store user after the update
+          description: Returns the local user store user after the password 
change
           content:
             application/vnd.gravitino.v1+json:
               schema:
@@ -149,7 +167,7 @@ paths:
                 $ref: "../openapi.yaml#/components/schemas/ErrorModel"
               examples:
                 IllegalArgumentException:
-                  $ref: 
"#/components/examples/IdpUpdateUserIllegalArgumentException"
+                  $ref: 
"#/components/examples/IdpChangePasswordIllegalArgumentException"
         "403":
           $ref: "#/components/responses/IdpForbiddenErrorResponse"
         "404":
@@ -164,17 +182,58 @@ paths:
         "5xx":
           $ref: "../openapi.yaml#/components/responses/ServerErrorResponse"
 
-    delete:
+  /idp/users/{user}/enabled:
+    parameters:
+      - $ref: "../openapi.yaml#/components/parameters/user"
+
+    put:
       tags:
         - IDP
-      summary: Remove local user store user
-      description: Removes the specified local user store user.
-      operationId: removeIdpUser
+      summary: Update local user store user enabled flag
+      description: >
+        Updates the enabled flag of the specified local user store user and 
returns the user object.
+        Disabled users cannot authenticate. Users listed in 
`gravitino.authorization.serviceAdmins`
+        cannot have their enabled flag changed. Only service admins may call 
this API.
+      operationId: updateIdpUserEnabled
+      requestBody:
+        required: true
+        content:
+          application/json:
+            schema:
+              $ref: "#/components/schemas/UpdateUserEnabledRequest"
+            examples:
+              UpdateUserEnabledRequest:
+                $ref: "#/components/examples/UpdateUserEnabledRequest"
       responses:
         "200":
-          $ref: "../openapi.yaml#/components/responses/RemoveResponse"
+          description: Returns the local user store user after the 
enabled-flag update
+          content:
+            application/vnd.gravitino.v1+json:
+              schema:
+                $ref: "#/components/schemas/IdpUserResponse"
+              examples:
+                IdpUserUpdateResponse:
+                  $ref: "#/components/examples/IdpUserUpdateResponse"
+        "400":
+          description: Bad Request - The request body is invalid
+          content:
+            application/vnd.gravitino.v1+json:
+              schema:
+                $ref: "../openapi.yaml#/components/schemas/ErrorModel"
+              examples:
+                IllegalArgumentException:
+                  $ref: 
"#/components/examples/IdpUpdateEnabledIllegalArgumentException"
         "403":
           $ref: "#/components/responses/IdpForbiddenErrorResponse"
+        "404":
+          description: Not Found - The specified local user store user does 
not exist
+          content:
+            application/vnd.gravitino.v1+json:
+              schema:
+                $ref: "../openapi.yaml#/components/schemas/ErrorModel"
+              examples:
+                NotFoundException:
+                  $ref: "#/components/examples/IdpUserUpdateNotFoundException"
         "5xx":
           $ref: "../openapi.yaml#/components/responses/ServerErrorResponse"
 
@@ -410,21 +469,31 @@ components:
           default: true
           description: Whether the user is enabled. Disabled users cannot 
authenticate.
 
-    UpdateUserRequest:
+    ChangePasswordRequest:
       type: object
-      anyOf:
-        - required:
-            - password
-        - required:
-            - enabled
+      required:
+        - newPassword
       properties:
-        password:
+        newPassword:
           type: string
           format: password
           minLength: 12
           maxLength: 64
           description: The new password of the local user store user.
           writeOnly: true
+        oldPassword:
+          type: string
+          format: password
+          description: >
+            The caller's current password. Required when changing your own 
password; must be
+            omitted when a service admin resets another user's password.
+          writeOnly: true
+
+    UpdateUserEnabledRequest:
+      type: object
+      required:
+        - enabled
+      properties:
         enabled:
           type: boolean
           description: Whether the local user store user should be enabled.
@@ -497,9 +566,9 @@ components:
     IdpForbiddenErrorResponse:
       description:
         Forbidden - Callers listed in `gravitino.authorization.serviceAdmins` 
may manage local
-        user store users and groups. Authenticated non-admin users may only 
`GET` or `PUT`
-        `/idp/users/{user}` for their own username (password-only on update). 
Unauthenticated
-        requests and other non-admin IdP management calls receive this 
response.
+        user store users and groups. Authenticated non-admin users may only 
`GET`
+        `/idp/users/{user}` or `PUT` `/idp/users/{user}/password` for their 
own username.
+        Unauthenticated requests and other non-admin IdP management calls 
receive this response.
       content:
         application/vnd.gravitino.v1+json:
           schema:
@@ -515,9 +584,15 @@ components:
         "password": "Passw0rd-1234"
       }
 
-    UpdateUserPasswordRequest:
+    ChangeUserPasswordRequest:
+      value: {
+        "newPassword": "Passw0rd-5678"
+      }
+
+    ChangeOwnUserPasswordRequest:
       value: {
-        "password": "Passw0rd-5678"
+        "newPassword": "Passw0rd-5678",
+        "oldPassword": "Passw0rd-1234"
       }
 
     UpdateUserEnabledRequest:
@@ -630,11 +705,18 @@ components:
         "message": "Failed to operate local user store user [] operation 
[ADD], reason [\"user\" field is required and cannot be empty]"
       }
 
-    IdpUpdateUserIllegalArgumentException:
+    IdpChangePasswordIllegalArgumentException:
+      value: {
+        "code": 1001,
+        "type": "IllegalArgumentException",
+        "message": "Failed to operate local user store user [alice] operation 
[UPDATE], reason [\"newPassword\" field is required]"
+      }
+
+    IdpUpdateEnabledIllegalArgumentException:
       value: {
         "code": 1001,
         "type": "IllegalArgumentException",
-        "message": "Failed to operate local user store user [alice] operation 
[UPDATE], reason [\"password\" or \"enabled\" field is required]"
+        "message": "Failed to operate local user store user [alice] operation 
[UPDATE], reason [\"enabled\" field is required]"
       }
 
     IdpAddGroupIllegalArgumentException:
diff --git a/docs/open-api/idp/openapi.yaml b/docs/open-api/idp/openapi.yaml
index 263c50c7fa..12874a76d5 100644
--- a/docs/open-api/idp/openapi.yaml
+++ b/docs/open-api/idp/openapi.yaml
@@ -58,6 +58,12 @@ paths:
   /idp/users/{user}:
     $ref: "./idp.yaml#/paths/~1idp~1users~1%7Buser%7D"
 
+  /idp/users/{user}/password:
+    $ref: "./idp.yaml#/paths/~1idp~1users~1%7Buser%7D~1password"
+
+  /idp/users/{user}/enabled:
+    $ref: "./idp.yaml#/paths/~1idp~1users~1%7Buser%7D~1enabled"
+
   /idp/groups:
     $ref: "./idp.yaml#/paths/~1idp~1groups"
 
diff --git a/docs/security/local-users-and-groups.md 
b/docs/security/local-users-and-groups.md
index 70b18c9e23..86048556a7 100644
--- a/docs/security/local-users-and-groups.md
+++ b/docs/security/local-users-and-groups.md
@@ -74,34 +74,54 @@ These rules apply to user creation, password changes, and 
`GRAVITINO_INITIAL_ADM
 | Password length | 12 to 64 characters inclusive          |
 
 Passwords may be reset by a service admin or changed by the user for their own 
account.
-The request carries the new password only and no current password; callers 
authenticate with
-HTTP Basic using their current credentials.
+Callers authenticate with HTTP Basic. A self-service password change must also 
send
+`oldPassword` in the request body so the server can verify it against the 
stored hash.
+A service admin resetting another user's password sends only `newPassword` and 
must not
+send `oldPassword`.
 
 ## Managing Users and Groups
 
 All management endpoints are under `http://{host}:{port}/api/idp` and require 
Basic authentication.
-Most operations require a service admin. Authenticated users may also `GET` or 
`PUT`
-`/api/idp/users/{user}` for their own username (read profile / change password 
only).
+Most operations require a service admin. Authenticated users may also `GET`
+`/api/idp/users/{user}` or `PUT` `/api/idp/users/{user}/password` for their 
own username
+(read profile / change password only).
 Send `Accept: application/vnd.gravitino.v1+json` on every request, and
 `Content-Type: application/json` on requests with a body.
 
 ### User Operations
 
-| Operation     | Method | Path                    | Body                      
                                 |
-|---------------|--------|-------------------------|------------------------------------------------------------|
-| Get a user    | GET    | `/api/idp/users/{user}` | None                      
                                 |
-| Add a user    | POST   | `/api/idp/users`        | 
`{"user":"alice","password":"{password}"}`                 |
-| Update a user | PUT    | `/api/idp/users/{user}` | 
`{"password":"{new_password}"}` and/or `{"enabled":false}` |
-| Remove a user | DELETE | `/api/idp/users/{user}` | None                      
                                 |
+| Operation              | Method | Path                             | Body    
                                                           |
+|------------------------|--------|----------------------------------|--------------------------------------------------------------------|
+| Get a user             | GET    | `/api/idp/users/{user}`          | None    
                                                           |
+| Add a user             | POST   | `/api/idp/users`                 | 
`{"user":"alice","password":"{password}"}`                         |
+| Change a user password | PUT    | `/api/idp/users/{user}/password` | 
`{"newPassword":"{new_password}"}` (plus `oldPassword` for self)   |
+| Update a user enabled  | PUT    | `/api/idp/users/{user}/enabled`  | 
`{"enabled":false}`                                                |
+| Remove a user          | DELETE | `/api/idp/users/{user}`          | None    
                                                           |
 
-The add-user body uses the field name `user` rather than `name`. `enabled` is 
optional on create and defaults to `true`. A disabled user cannot authenticate. 
`PUT /api/idp/users/{user}` accepts `password` and/or `enabled`; at least one 
is required. Users listed in `gravitino.authorization.serviceAdmins` cannot be 
disabled. Non-admin callers may get or update only their own user, and on 
update may change only their password (not `enabled` or another user).
+The add-user body uses the field name `user` rather than `name`. `enabled` is 
optional on create and defaults to `true`. A disabled user cannot authenticate. 
Password changes use `PUT /api/idp/users/{user}/password` with required 
`newPassword`. Enabled-flag updates use `PUT /api/idp/users/{user}/enabled` 
with required `enabled` and are service-admin only; users listed in 
`gravitino.authorization.serviceAdmins` cannot have their enabled flag changed. 
Non-admin callers may get only their o [...]
 
 ```shell
 curl -s -X PUT -H "Accept: application/vnd.gravitino.v1+json" \
   -H "Content-Type: application/json" \
   -H "Authorization: Basic $(echo -n 'alice:{user_password}' | base64)" \
-  -d '{"password":"{new_password}"}' \
-  http://localhost:8090/api/idp/users/alice
+  -d '{"newPassword":"{new_password}","oldPassword":"{user_password}"}' \
+  http://localhost:8090/api/idp/users/alice/password
+```
+
+```shell
+curl -s -X PUT -H "Accept: application/vnd.gravitino.v1+json" \
+  -H "Content-Type: application/json" \
+  -H "Authorization: Basic $(echo -n 'admin:{admin_password}' | base64)" \
+  -d '{"newPassword":"{new_password}"}' \
+  http://localhost:8090/api/idp/users/alice/password
+```
+
+```shell
+curl -s -X PUT -H "Accept: application/vnd.gravitino.v1+json" \
+  -H "Content-Type: application/json" \
+  -H "Authorization: Basic $(echo -n 'admin:{admin_password}' | base64)" \
+  -d '{"enabled":false}' \
+  http://localhost:8090/api/idp/users/alice/enabled
 ```
 
 ```shell
diff --git 
a/plugins/idp-basic/src/main/java/org/apache/gravitino/idp/IdpUserGroupManager.java
 
b/plugins/idp-basic/src/main/java/org/apache/gravitino/idp/IdpUserGroupManager.java
index 63300314fb..0e5fb53fbb 100644
--- 
a/plugins/idp-basic/src/main/java/org/apache/gravitino/idp/IdpUserGroupManager.java
+++ 
b/plugins/idp-basic/src/main/java/org/apache/gravitino/idp/IdpUserGroupManager.java
@@ -196,6 +196,24 @@ public class IdpUserGroupManager implements Closeable {
     return USER_SERVICE.updateIdpUserPassword(username, 
passwordHasher.hash(password));
   }
 
+  /**
+   * Changes the password for a built-in IdP user after verifying the old 
password.
+   *
+   * @param username The username.
+   * @param newPassword The new plaintext password.
+   * @param oldPassword The caller's current plaintext password.
+   * @return {@code true} if the password was updated
+   * @throws NotFoundException if the user does not exist
+   * @throws IllegalArgumentException if {@code oldPassword} does not match 
the stored hash
+   */
+  public boolean changePassword(String username, String newPassword, String 
oldPassword) {
+    IdpUser user = USER_SERVICE.getIdpUser(username);
+    if (user.passwordHash() == null || !passwordHasher.verify(oldPassword, 
user.passwordHash())) {
+      throw new IllegalArgumentException("\"oldPassword\" does not match the 
stored password");
+    }
+    return changePassword(username, newPassword);
+  }
+
   /**
    * Enables or disables a built-in IdP user.
    *
@@ -203,11 +221,11 @@ public class IdpUserGroupManager implements Closeable {
    * @param enabled Whether the user should be enabled.
    * @return {@code true} if the enabled flag was updated
    * @throws NotFoundException if the user does not exist
-   * @throws IllegalArgumentException if {@code enabled} is {@code false} and 
the username is a
-   *     configured service admin
+   * @throws IllegalArgumentException if the username is a configured service 
admin
    */
   public boolean updateEnabled(String username, boolean enabled) {
-    checkCanDisableUser(username, enabled);
+    Preconditions.checkArgument(
+        !isServiceAdmin(username), "Cannot update the enabled flag of service 
admin %s", username);
     return USER_SERVICE.updateIdpUserEnabled(username, enabled);
   }
 
diff --git 
a/plugins/idp-basic/src/main/java/org/apache/gravitino/idp/basic/IdpCredentialValidator.java
 
b/plugins/idp-basic/src/main/java/org/apache/gravitino/idp/basic/IdpCredentialValidator.java
index a565f0ab3b..8528a07b8c 100644
--- 
a/plugins/idp-basic/src/main/java/org/apache/gravitino/idp/basic/IdpCredentialValidator.java
+++ 
b/plugins/idp-basic/src/main/java/org/apache/gravitino/idp/basic/IdpCredentialValidator.java
@@ -67,8 +67,20 @@ public final class IdpCredentialValidator {
   }
 
   public static void validatePassword(String password) {
+    validatePassword(password, "password");
+  }
+
+  /**
+   * Validates a password field.
+   *
+   * @param password the password value
+   * @param fieldName the JSON/request field name used in error messages
+   */
+  public static void validatePassword(String password, String fieldName) {
     Preconditions.checkArgument(
-        StringUtils.isNotBlank(password), "\"password\" field is required and 
cannot be empty");
+        StringUtils.isNotBlank(password),
+        "\"%s\" field is required and cannot be empty",
+        fieldName);
     Preconditions.checkArgument(
         password.length() >= MIN_PASSWORD_LENGTH && password.length() <= 
MAX_PASSWORD_LENGTH,
         "Password must be at least %s characters long and at most %s 
characters long",
diff --git 
a/plugins/idp-basic/src/main/java/org/apache/gravitino/idp/dto/requests/UpdateUserRequest.java
 
b/plugins/idp-basic/src/main/java/org/apache/gravitino/idp/dto/requests/ChangePasswordRequest.java
similarity index 55%
copy from 
plugins/idp-basic/src/main/java/org/apache/gravitino/idp/dto/requests/UpdateUserRequest.java
copy to 
plugins/idp-basic/src/main/java/org/apache/gravitino/idp/dto/requests/ChangePasswordRequest.java
index 6414d0c603..727dc7127d 100644
--- 
a/plugins/idp-basic/src/main/java/org/apache/gravitino/idp/dto/requests/UpdateUserRequest.java
+++ 
b/plugins/idp-basic/src/main/java/org/apache/gravitino/idp/dto/requests/ChangePasswordRequest.java
@@ -21,7 +21,6 @@ package org.apache.gravitino.idp.dto.requests;
 
 import com.fasterxml.jackson.annotation.JsonInclude;
 import com.fasterxml.jackson.annotation.JsonProperty;
-import com.google.common.base.Preconditions;
 import lombok.Builder;
 import lombok.EqualsAndHashCode;
 import lombok.Getter;
@@ -30,61 +29,57 @@ import lombok.extern.jackson.Jacksonized;
 import org.apache.gravitino.idp.basic.IdpCredentialValidator;
 import org.apache.gravitino.rest.RESTRequest;
 
-/** Represents a request to update a built-in IdP user password and/or enabled 
flag. */
+/** Represents a request to change a built-in IdP user password. */
 @Getter
 @EqualsAndHashCode
 @ToString
 @Builder
 @Jacksonized
-public class UpdateUserRequest implements RESTRequest {
+public class ChangePasswordRequest implements RESTRequest {
 
-  @JsonProperty("password")
+  @JsonProperty("newPassword")
   @ToString.Exclude
-  private final String password;
+  private final String newPassword;
 
-  @JsonProperty("enabled")
+  @JsonProperty("oldPassword")
   @JsonInclude(JsonInclude.Include.NON_NULL)
-  private final Boolean enabled;
+  @ToString.Exclude
+  private final String oldPassword;
 
-  /** Default constructor for UpdateUserRequest. (Used for Jackson 
deserialization.) */
-  public UpdateUserRequest() {
+  /** Default constructor for ChangePasswordRequest. (Used for Jackson 
deserialization.) */
+  public ChangePasswordRequest() {
     this(null, null);
   }
 
   /**
-   * Creates a new UpdateUserRequest that updates only the password.
+   * Creates a ChangePasswordRequest for an admin password reset (no old 
password).
    *
-   * @param password The new password of the built-in IdP user.
+   * @param newPassword The new password.
    */
-  public UpdateUserRequest(String password) {
-    this(password, null);
+  public ChangePasswordRequest(String newPassword) {
+    this(newPassword, null);
   }
 
   /**
-   * Creates a new UpdateUserRequest.
+   * Creates a ChangePasswordRequest.
    *
-   * @param password The new password of the built-in IdP user, or {@code 
null} to leave it
-   *     unchanged.
-   * @param enabled Whether the built-in IdP user should be enabled, or {@code 
null} to leave it
-   *     unchanged.
+   * @param newPassword The new password.
+   * @param oldPassword The caller's current password when changing their own 
password, or {@code
+   *     null} when a service admin resets another user's password.
    */
-  public UpdateUserRequest(String password, Boolean enabled) {
+  public ChangePasswordRequest(String newPassword, String oldPassword) {
     super();
-    this.password = password;
-    this.enabled = enabled;
+    this.newPassword = newPassword;
+    this.oldPassword = oldPassword;
   }
 
   /**
-   * Validates the {@link UpdateUserRequest} request.
+   * Validates the {@link ChangePasswordRequest} request.
    *
    * @throws IllegalArgumentException If the request is invalid, this 
exception is thrown.
    */
   @Override
   public void validate() throws IllegalArgumentException {
-    Preconditions.checkArgument(
-        password != null || enabled != null, "\"password\" or \"enabled\" 
field is required");
-    if (password != null) {
-      IdpCredentialValidator.validatePassword(password);
-    }
+    IdpCredentialValidator.validatePassword(newPassword, "newPassword");
   }
 }
diff --git 
a/plugins/idp-basic/src/main/java/org/apache/gravitino/idp/dto/requests/UpdateUserRequest.java
 
b/plugins/idp-basic/src/main/java/org/apache/gravitino/idp/dto/requests/UpdateUserEnabledRequest.java
similarity index 54%
rename from 
plugins/idp-basic/src/main/java/org/apache/gravitino/idp/dto/requests/UpdateUserRequest.java
rename to 
plugins/idp-basic/src/main/java/org/apache/gravitino/idp/dto/requests/UpdateUserEnabledRequest.java
index 6414d0c603..11597c48c7 100644
--- 
a/plugins/idp-basic/src/main/java/org/apache/gravitino/idp/dto/requests/UpdateUserRequest.java
+++ 
b/plugins/idp-basic/src/main/java/org/apache/gravitino/idp/dto/requests/UpdateUserEnabledRequest.java
@@ -19,7 +19,6 @@
 
 package org.apache.gravitino.idp.dto.requests;
 
-import com.fasterxml.jackson.annotation.JsonInclude;
 import com.fasterxml.jackson.annotation.JsonProperty;
 import com.google.common.base.Preconditions;
 import lombok.Builder;
@@ -27,64 +26,41 @@ import lombok.EqualsAndHashCode;
 import lombok.Getter;
 import lombok.ToString;
 import lombok.extern.jackson.Jacksonized;
-import org.apache.gravitino.idp.basic.IdpCredentialValidator;
 import org.apache.gravitino.rest.RESTRequest;
 
-/** Represents a request to update a built-in IdP user password and/or enabled 
flag. */
+/** Represents a request to update a built-in IdP user's enabled flag. */
 @Getter
 @EqualsAndHashCode
 @ToString
 @Builder
 @Jacksonized
-public class UpdateUserRequest implements RESTRequest {
-
-  @JsonProperty("password")
-  @ToString.Exclude
-  private final String password;
+public class UpdateUserEnabledRequest implements RESTRequest {
 
   @JsonProperty("enabled")
-  @JsonInclude(JsonInclude.Include.NON_NULL)
   private final Boolean enabled;
 
-  /** Default constructor for UpdateUserRequest. (Used for Jackson 
deserialization.) */
-  public UpdateUserRequest() {
-    this(null, null);
-  }
-
-  /**
-   * Creates a new UpdateUserRequest that updates only the password.
-   *
-   * @param password The new password of the built-in IdP user.
-   */
-  public UpdateUserRequest(String password) {
-    this(password, null);
+  /** Default constructor for UpdateUserEnabledRequest. (Used for Jackson 
deserialization.) */
+  public UpdateUserEnabledRequest() {
+    this(null);
   }
 
   /**
-   * Creates a new UpdateUserRequest.
+   * Creates an UpdateUserEnabledRequest.
    *
-   * @param password The new password of the built-in IdP user, or {@code 
null} to leave it
-   *     unchanged.
-   * @param enabled Whether the built-in IdP user should be enabled, or {@code 
null} to leave it
-   *     unchanged.
+   * @param enabled Whether the built-in IdP user should be enabled.
    */
-  public UpdateUserRequest(String password, Boolean enabled) {
+  public UpdateUserEnabledRequest(Boolean enabled) {
     super();
-    this.password = password;
     this.enabled = enabled;
   }
 
   /**
-   * Validates the {@link UpdateUserRequest} request.
+   * Validates the {@link UpdateUserEnabledRequest} request.
    *
    * @throws IllegalArgumentException If the request is invalid, this 
exception is thrown.
    */
   @Override
   public void validate() throws IllegalArgumentException {
-    Preconditions.checkArgument(
-        password != null || enabled != null, "\"password\" or \"enabled\" 
field is required");
-    if (password != null) {
-      IdpCredentialValidator.validatePassword(password);
-    }
+    Preconditions.checkArgument(enabled != null, "\"enabled\" field is 
required");
   }
 }
diff --git 
a/plugins/idp-basic/src/main/java/org/apache/gravitino/idp/web/IdpRESTUtils.java
 
b/plugins/idp-basic/src/main/java/org/apache/gravitino/idp/web/IdpRESTUtils.java
index 35a2831b5a..324bbf2f46 100644
--- 
a/plugins/idp-basic/src/main/java/org/apache/gravitino/idp/web/IdpRESTUtils.java
+++ 
b/plugins/idp-basic/src/main/java/org/apache/gravitino/idp/web/IdpRESTUtils.java
@@ -30,6 +30,7 @@ import org.apache.gravitino.exceptions.AlreadyExistsException;
 import org.apache.gravitino.exceptions.ForbiddenException;
 import org.apache.gravitino.exceptions.NonEmptyEntityException;
 import org.apache.gravitino.exceptions.NotFoundException;
+import org.apache.gravitino.exceptions.UnauthorizedException;
 import org.apache.gravitino.server.web.ServerHealth;
 import org.apache.gravitino.utils.PrincipalUtils;
 import org.slf4j.Logger;
@@ -105,6 +106,13 @@ public final class IdpRESTUtils {
     return json(Response.Status.FORBIDDEN, ErrorResponse.forbidden(message, 
throwable));
   }
 
+  public static Response unauthorized(String message, Throwable throwable) {
+    return json(
+        Response.Status.UNAUTHORIZED,
+        ErrorResponse.unauthorized(
+            exceptionType(throwable, "UnauthorizedException"), message, 
throwable));
+  }
+
   public static Response unsupportedOperation(String message, Throwable 
throwable) {
     return json(
         Response.Status.NOT_IMPLEMENTED, 
ErrorResponse.unsupportedOperation(message, throwable));
@@ -123,6 +131,9 @@ public final class IdpRESTUtils {
     if (e instanceof ForbiddenException) {
       return forbidden(errorMsg, e);
     }
+    if (e instanceof UnauthorizedException) {
+      return unauthorized(errorMsg, e);
+    }
     if (e instanceof NotFoundException) {
       return notFound(errorMsg, e);
     }
diff --git 
a/plugins/idp-basic/src/main/java/org/apache/gravitino/idp/web/rest/IdpAuthorizationFilter.java
 
b/plugins/idp-basic/src/main/java/org/apache/gravitino/idp/web/rest/IdpAuthorizationFilter.java
index 24f8ff6f05..f445778881 100644
--- 
a/plugins/idp-basic/src/main/java/org/apache/gravitino/idp/web/rest/IdpAuthorizationFilter.java
+++ 
b/plugins/idp-basic/src/main/java/org/apache/gravitino/idp/web/rest/IdpAuthorizationFilter.java
@@ -34,8 +34,8 @@ import org.apache.gravitino.utils.PrincipalUtils;
  * Enforces built-in IdP management API access rules without server 
interception.
  *
  * <p>Callers listed in {@link Configs#SERVICE_ADMINS} may perform all IdP 
management operations.
- * Authenticated non-admin users may {@code GET} or {@code PUT} {@code 
/idp/users/{user}} only for
- * their own username (read profile / change password). Other IdP management 
APIs remain
+ * Authenticated non-admin users may {@code GET} {@code /idp/users/{user}} or 
{@code PUT} {@code
+ * /idp/users/{user}/password} only for their own username. Other IdP 
management APIs remain
  * service-admin only.
  *
  * <p>This filter runs as a Jersey request filter after the servlet {@code 
AuthenticationFilter} has
@@ -82,22 +82,26 @@ public class IdpAuthorizationFilter implements 
ContainerRequestFilter {
   }
 
   /**
-   * Returns whether the request is a GET or PUT to {@code 
/idp/users/{currentUser}}.
-   *
-   * <p>The resource method still rejects non-admin updates that change {@code 
enabled}.
+   * Returns whether the request is a self-service user access: {@code GET 
/idp/users/{currentUser}}
+   * or {@code PUT /idp/users/{currentUser}/password}.
    */
   static boolean isSelfUserAccess(ContainerRequestContext requestContext, 
String currentUser) {
     if (currentUser == null) {
       return false;
     }
-    String method = requestContext.getMethod();
-    if (!"GET".equalsIgnoreCase(method) && !"PUT".equalsIgnoreCase(method)) {
+    List<PathSegment> segments = requestContext.getUriInfo().getPathSegments();
+    if (segments.size() < 3
+        || !"idp".equals(segments.get(0).getPath())
+        || !"users".equals(segments.get(1).getPath())
+        || !currentUser.equals(segments.get(2).getPath())) {
       return false;
     }
-    List<PathSegment> segments = requestContext.getUriInfo().getPathSegments();
-    return segments.size() == 3
-        && "idp".equals(segments.get(0).getPath())
-        && "users".equals(segments.get(1).getPath())
-        && currentUser.equals(segments.get(2).getPath());
+    String method = requestContext.getMethod();
+    if ("GET".equalsIgnoreCase(method) && segments.size() == 3) {
+      return true;
+    }
+    return "PUT".equalsIgnoreCase(method)
+        && segments.size() == 4
+        && "password".equals(segments.get(3).getPath());
   }
 }
diff --git 
a/plugins/idp-basic/src/main/java/org/apache/gravitino/idp/web/rest/IdpUserOperations.java
 
b/plugins/idp-basic/src/main/java/org/apache/gravitino/idp/web/rest/IdpUserOperations.java
index 5db95b4f20..35d994d896 100644
--- 
a/plugins/idp-basic/src/main/java/org/apache/gravitino/idp/web/rest/IdpUserOperations.java
+++ 
b/plugins/idp-basic/src/main/java/org/apache/gravitino/idp/web/rest/IdpUserOperations.java
@@ -20,6 +20,7 @@ package org.apache.gravitino.idp.web.rest;
 
 import com.codahale.metrics.annotation.ResponseMetered;
 import com.codahale.metrics.annotation.Timed;
+import com.google.common.base.Preconditions;
 import java.util.List;
 import javax.inject.Inject;
 import javax.servlet.http.HttpServletRequest;
@@ -32,13 +33,15 @@ import javax.ws.rs.PathParam;
 import javax.ws.rs.Produces;
 import javax.ws.rs.core.Context;
 import javax.ws.rs.core.Response;
+import org.apache.commons.lang3.StringUtils;
 import org.apache.gravitino.Configs;
 import org.apache.gravitino.GravitinoEnv;
 import org.apache.gravitino.dto.responses.RemoveResponse;
 import org.apache.gravitino.exceptions.ForbiddenException;
 import org.apache.gravitino.idp.IdpUserGroupManager;
 import org.apache.gravitino.idp.dto.requests.AddUserRequest;
-import org.apache.gravitino.idp.dto.requests.UpdateUserRequest;
+import org.apache.gravitino.idp.dto.requests.ChangePasswordRequest;
+import org.apache.gravitino.idp.dto.requests.UpdateUserEnabledRequest;
 import org.apache.gravitino.idp.dto.responses.IdpUserResponse;
 import org.apache.gravitino.idp.web.IdpManagement;
 import org.apache.gravitino.idp.web.IdpOperationType;
@@ -94,22 +97,43 @@ public class IdpUserOperations {
         request.getUser());
   }
 
+  /**
+   * Changes a user's password. Self-service changes require {@code 
oldPassword}. Service admins may
+   * reset another user without {@code oldPassword} and must not send it.
+   *
+   * @param user the username whose password is changed
+   * @param request the password change request
+   * @return the updated user response
+   */
   @PUT
-  @Path("{user}")
+  @Path("{user}/password")
   @Produces("application/vnd.gravitino.v1+json")
-  @Timed(name = "update-idp-user." + MetricNames.HTTP_PROCESS_DURATION, 
absolute = true)
-  @ResponseMetered(name = "update-idp-user", absolute = true)
-  public Response updateUser(@PathParam("user") String user, UpdateUserRequest 
request) {
+  @Timed(name = "change-idp-user-password." + 
MetricNames.HTTP_PROCESS_DURATION, absolute = true)
+  @ResponseMetered(name = "change-idp-user-password", absolute = true)
+  public Response changePassword(@PathParam("user") String user, 
ChangePasswordRequest request) {
     return IdpRESTUtils.doAs(
         httpRequest,
         () -> {
           request.validate();
-          enforceSelfPasswordUpdateRules(user, request);
-          if (request.getPassword() != null) {
-            userGroupManager.changePassword(user, request.getPassword());
+          String currentUser = PrincipalUtils.getCurrentUserName();
+          List<String> serviceAdmins =
+              GravitinoEnv.getInstance().config().get(Configs.SERVICE_ADMINS);
+          boolean isServiceAdmin =
+              IdpAuthorizationFilter.isServiceAdmin(serviceAdmins, 
currentUser);
+          if (!isServiceAdmin && !user.equals(currentUser)) {
+            throw new ForbiddenException("Only service admins can change 
another user's password");
           }
-          if (request.getEnabled() != null) {
-            userGroupManager.updateEnabled(user, request.getEnabled());
+          if (user.equals(currentUser)) {
+            Preconditions.checkArgument(
+                StringUtils.isNotBlank(request.getOldPassword()),
+                "\"oldPassword\" is required when changing your own password");
+            userGroupManager.changePassword(
+                user, request.getNewPassword(), request.getOldPassword());
+          } else {
+            Preconditions.checkArgument(
+                request.getOldPassword() == null,
+                "\"oldPassword\" is only allowed when changing your own 
password");
+            userGroupManager.changePassword(user, request.getNewPassword());
           }
           return IdpRESTUtils.ok(new 
IdpUserResponse(userGroupManager.getUser(user).toDTO()));
         },
@@ -119,24 +143,39 @@ public class IdpUserOperations {
   }
 
   /**
-   * Non-service-admins may only change their own password and cannot update 
{@code enabled}.
+   * Updates a user's enabled flag. Only service admins may call this API, and 
the target must not
+   * be a configured service admin.
    *
-   * @param user the path username being updated
-   * @param request the update request
+   * @param user the username whose enabled flag is updated
+   * @param request the enabled-flag update request
+   * @return the updated user response
    */
-  private static void enforceSelfPasswordUpdateRules(String user, 
UpdateUserRequest request) {
-    String currentUser = PrincipalUtils.getCurrentUserName();
-    List<String> serviceAdmins = 
GravitinoEnv.getInstance().config().get(Configs.SERVICE_ADMINS);
-    if (IdpAuthorizationFilter.isServiceAdmin(serviceAdmins, currentUser)) {
-      return;
-    }
-    if (!user.equals(currentUser)) {
-      throw new ForbiddenException(
-          "Only service admins can update another user's password or enabled 
flag");
-    }
-    if (request.getEnabled() != null) {
-      throw new ForbiddenException("Only service admins can update the enabled 
flag");
-    }
+  @PUT
+  @Path("{user}/enabled")
+  @Produces("application/vnd.gravitino.v1+json")
+  @Timed(name = "update-idp-user-enabled." + 
MetricNames.HTTP_PROCESS_DURATION, absolute = true)
+  @ResponseMetered(name = "update-idp-user-enabled", absolute = true)
+  public Response updateEnabled(@PathParam("user") String user, 
UpdateUserEnabledRequest request) {
+    return IdpRESTUtils.doAs(
+        httpRequest,
+        () -> {
+          request.validate();
+          String currentUser = PrincipalUtils.getCurrentUserName();
+          List<String> serviceAdmins =
+              GravitinoEnv.getInstance().config().get(Configs.SERVICE_ADMINS);
+          if (!IdpAuthorizationFilter.isServiceAdmin(serviceAdmins, 
currentUser)) {
+            throw new ForbiddenException("Only service admins can update the 
enabled flag");
+          }
+          Preconditions.checkArgument(
+              !IdpAuthorizationFilter.isServiceAdmin(serviceAdmins, user),
+              "Cannot update the enabled flag of service admin %s",
+              user);
+          userGroupManager.updateEnabled(user, request.getEnabled());
+          return IdpRESTUtils.ok(new 
IdpUserResponse(userGroupManager.getUser(user).toDTO()));
+        },
+        "user",
+        IdpOperationType.UPDATE,
+        user);
   }
 
   @DELETE
diff --git 
a/plugins/idp-basic/src/test/java/org/apache/gravitino/idp/TestIdpUserGroupManager.java
 
b/plugins/idp-basic/src/test/java/org/apache/gravitino/idp/TestIdpUserGroupManager.java
index d3d017bc96..1afa1b5e14 100644
--- 
a/plugins/idp-basic/src/test/java/org/apache/gravitino/idp/TestIdpUserGroupManager.java
+++ 
b/plugins/idp-basic/src/test/java/org/apache/gravitino/idp/TestIdpUserGroupManager.java
@@ -151,6 +151,22 @@ public class TestIdpUserGroupManager {
         NotFoundException.class, () -> manager.changePassword("not-exist", 
VALID_PASSWORD));
   }
 
+  @Test
+  public void testChangePasswordWithOldPassword() throws IOException {
+    manager.addUser("testChangeWithOld", VALID_PASSWORD);
+
+    Assertions.assertTrue(
+        manager.changePassword("testChangeWithOld", NEW_VALID_PASSWORD, 
VALID_PASSWORD));
+    Assertions.assertEquals(
+        "testChangeWithOld", manager.authenticate("testChangeWithOld", 
NEW_VALID_PASSWORD).name());
+
+    Assertions.assertThrows(
+        IllegalArgumentException.class,
+        () -> manager.changePassword("testChangeWithOld", VALID_PASSWORD, 
"wrong-old-password"));
+    Assertions.assertEquals(
+        "testChangeWithOld", manager.authenticate("testChangeWithOld", 
NEW_VALID_PASSWORD).name());
+  }
+
   @Test
   public void testUpdateEnabled() throws IOException {
     manager.addUser("testEnabled", VALID_PASSWORD);
@@ -176,16 +192,25 @@ public class TestIdpUserGroupManager {
   }
 
   @Test
-  public void testCannotDisableServiceAdmin() throws IOException {
+  public void testCannotUpdateEnabledOfServiceAdmin() throws IOException {
     loadServiceAdminConfig(BASIC_AUTHENTICATOR, "svcAdminDisable");
     manager.addUser("svcAdminDisable", VALID_PASSWORD);
 
-    IllegalArgumentException exception =
+    IllegalArgumentException disableException =
         Assertions.assertThrows(
             IllegalArgumentException.class, () -> 
manager.updateEnabled("svcAdminDisable", false));
-    Assertions.assertEquals("Cannot disable service admin svcAdminDisable", 
exception.getMessage());
+    Assertions.assertEquals(
+        "Cannot update the enabled flag of service admin svcAdminDisable",
+        disableException.getMessage());
+    Assertions.assertTrue(manager.getUser("svcAdminDisable").enabled());
+
+    IllegalArgumentException enableException =
+        Assertions.assertThrows(
+            IllegalArgumentException.class, () -> 
manager.updateEnabled("svcAdminDisable", true));
+    Assertions.assertEquals(
+        "Cannot update the enabled flag of service admin svcAdminDisable",
+        enableException.getMessage());
     Assertions.assertTrue(manager.getUser("svcAdminDisable").enabled());
-    Assertions.assertTrue(manager.updateEnabled("svcAdminDisable", true));
   }
 
   @Test
diff --git 
a/plugins/idp-basic/src/test/java/org/apache/gravitino/idp/dto/requests/TestChangePasswordRequest.java
 
b/plugins/idp-basic/src/test/java/org/apache/gravitino/idp/dto/requests/TestChangePasswordRequest.java
new file mode 100644
index 0000000000..2da1c6a6da
--- /dev/null
+++ 
b/plugins/idp-basic/src/test/java/org/apache/gravitino/idp/dto/requests/TestChangePasswordRequest.java
@@ -0,0 +1,79 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.apache.gravitino.idp.dto.requests;
+
+import com.fasterxml.jackson.core.JsonProcessingException;
+import org.apache.gravitino.json.JsonUtils;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+
+public class TestChangePasswordRequest {
+
+  private static final String VALID_PASSWORD = "new_password12";
+
+  @Test
+  public void testChangePasswordRequestSerDe() throws JsonProcessingException {
+    ChangePasswordRequest request = new ChangePasswordRequest(VALID_PASSWORD);
+
+    String serJson = JsonUtils.objectMapper().writeValueAsString(request);
+    ChangePasswordRequest deserRequest =
+        JsonUtils.objectMapper().readValue(serJson, 
ChangePasswordRequest.class);
+
+    Assertions.assertEquals(request, deserRequest);
+    Assertions.assertEquals(VALID_PASSWORD, deserRequest.getNewPassword());
+    Assertions.assertNull(deserRequest.getOldPassword());
+    Assertions.assertTrue(serJson.contains("\"newPassword\""));
+
+    ChangePasswordRequest withOld = new ChangePasswordRequest(VALID_PASSWORD, 
"old_password12");
+    ChangePasswordRequest deserWithOld =
+        JsonUtils.objectMapper()
+            .readValue(
+                JsonUtils.objectMapper().writeValueAsString(withOld), 
ChangePasswordRequest.class);
+    Assertions.assertEquals(withOld, deserWithOld);
+    Assertions.assertEquals("old_password12", deserWithOld.getOldPassword());
+  }
+
+  @Test
+  public void testChangePasswordRequestValidate() {
+    Assertions.assertDoesNotThrow(() -> new 
ChangePasswordRequest(VALID_PASSWORD).validate());
+    Assertions.assertDoesNotThrow(
+        () -> new ChangePasswordRequest(VALID_PASSWORD, 
"old_password12").validate());
+    IllegalArgumentException missing =
+        Assertions.assertThrows(
+            IllegalArgumentException.class, () -> new 
ChangePasswordRequest().validate());
+    Assertions.assertTrue(missing.getMessage().contains("\"newPassword\""));
+    IllegalArgumentException blank =
+        Assertions.assertThrows(
+            IllegalArgumentException.class, () -> new ChangePasswordRequest(" 
").validate());
+    Assertions.assertTrue(blank.getMessage().contains("\"newPassword\""));
+    Assertions.assertThrows(
+        IllegalArgumentException.class, () -> new 
ChangePasswordRequest("short").validate());
+  }
+
+  @Test
+  public void testChangePasswordRequestToStringDoesNotExposePassword() {
+    String requestString = new ChangePasswordRequest(VALID_PASSWORD, 
"old_password12").toString();
+
+    Assertions.assertFalse(requestString.contains(VALID_PASSWORD));
+    Assertions.assertFalse(requestString.contains("old_password12"));
+    Assertions.assertFalse(requestString.contains("newPassword="));
+    Assertions.assertFalse(requestString.contains("oldPassword="));
+  }
+}
diff --git 
a/plugins/idp-basic/src/test/java/org/apache/gravitino/idp/dto/requests/TestUpdateUserEnabledRequest.java
 
b/plugins/idp-basic/src/test/java/org/apache/gravitino/idp/dto/requests/TestUpdateUserEnabledRequest.java
new file mode 100644
index 0000000000..2bb81d74a9
--- /dev/null
+++ 
b/plugins/idp-basic/src/test/java/org/apache/gravitino/idp/dto/requests/TestUpdateUserEnabledRequest.java
@@ -0,0 +1,49 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.apache.gravitino.idp.dto.requests;
+
+import com.fasterxml.jackson.core.JsonProcessingException;
+import org.apache.gravitino.json.JsonUtils;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+
+public class TestUpdateUserEnabledRequest {
+
+  @Test
+  public void testUpdateUserEnabledRequestSerDe() throws 
JsonProcessingException {
+    UpdateUserEnabledRequest request = new UpdateUserEnabledRequest(false);
+
+    String serJson = JsonUtils.objectMapper().writeValueAsString(request);
+    UpdateUserEnabledRequest deserRequest =
+        JsonUtils.objectMapper().readValue(serJson, 
UpdateUserEnabledRequest.class);
+
+    Assertions.assertEquals(request, deserRequest);
+    Assertions.assertFalse(deserRequest.getEnabled());
+    Assertions.assertTrue(serJson.contains("\"enabled\""));
+  }
+
+  @Test
+  public void testUpdateUserEnabledRequestValidate() {
+    Assertions.assertDoesNotThrow(() -> new 
UpdateUserEnabledRequest(true).validate());
+    Assertions.assertDoesNotThrow(() -> new 
UpdateUserEnabledRequest(false).validate());
+    Assertions.assertThrows(
+        IllegalArgumentException.class, () -> new 
UpdateUserEnabledRequest().validate());
+  }
+}
diff --git 
a/plugins/idp-basic/src/test/java/org/apache/gravitino/idp/dto/requests/TestUpdateUserRequest.java
 
b/plugins/idp-basic/src/test/java/org/apache/gravitino/idp/dto/requests/TestUpdateUserRequest.java
deleted file mode 100644
index 9fa7c88a0c..0000000000
--- 
a/plugins/idp-basic/src/test/java/org/apache/gravitino/idp/dto/requests/TestUpdateUserRequest.java
+++ /dev/null
@@ -1,83 +0,0 @@
-/*
- * Licensed to the Apache Software Foundation (ASF) under one
- * or more contributor license agreements.  See the NOTICE file
- * distributed with this work for additional information
- * regarding copyright ownership.  The ASF licenses this file
- * to you under the Apache License, Version 2.0 (the
- * "License"); you may not use this file except in compliance
- * with the License.  You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing,
- * software distributed under the License is distributed on an
- * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
- * KIND, either express or implied.  See the License for the
- * specific language governing permissions and limitations
- * under the License.
- */
-
-package org.apache.gravitino.idp.dto.requests;
-
-import com.fasterxml.jackson.core.JsonProcessingException;
-import org.apache.gravitino.json.JsonUtils;
-import org.junit.jupiter.api.Assertions;
-import org.junit.jupiter.api.Test;
-
-public class TestUpdateUserRequest {
-
-  private static final String VALID_PASSWORD = "new_password12";
-
-  @Test
-  public void testUpdateUserRequestSerDe() throws JsonProcessingException {
-    UpdateUserRequest request = new UpdateUserRequest(VALID_PASSWORD);
-
-    String serJson = JsonUtils.objectMapper().writeValueAsString(request);
-    UpdateUserRequest deserRequest =
-        JsonUtils.objectMapper().readValue(serJson, UpdateUserRequest.class);
-
-    Assertions.assertEquals(request, deserRequest);
-    Assertions.assertEquals(VALID_PASSWORD, deserRequest.getPassword());
-
-    // Test with null password
-    UpdateUserRequest request1 = new UpdateUserRequest();
-
-    String serJson1 = JsonUtils.objectMapper().writeValueAsString(request1);
-    UpdateUserRequest deserRequest1 =
-        JsonUtils.objectMapper().readValue(serJson1, UpdateUserRequest.class);
-
-    Assertions.assertEquals(request1, deserRequest1);
-    Assertions.assertNull(deserRequest1.getPassword());
-
-    UpdateUserRequest enabledOnly = new UpdateUserRequest(null, false);
-    UpdateUserRequest deserEnabled =
-        JsonUtils.objectMapper()
-            .readValue(
-                JsonUtils.objectMapper().writeValueAsString(enabledOnly), 
UpdateUserRequest.class);
-    Assertions.assertEquals(enabledOnly, deserEnabled);
-    Assertions.assertNull(deserEnabled.getPassword());
-    Assertions.assertFalse(deserEnabled.getEnabled());
-  }
-
-  @Test
-  public void testUpdateUserRequestValidate() {
-    Assertions.assertDoesNotThrow(() -> new 
UpdateUserRequest(VALID_PASSWORD).validate());
-    Assertions.assertDoesNotThrow(() -> new UpdateUserRequest(null, 
false).validate());
-    Assertions.assertDoesNotThrow(() -> new UpdateUserRequest(VALID_PASSWORD, 
true).validate());
-    Assertions.assertThrows(
-        IllegalArgumentException.class, () -> new 
UpdateUserRequest().validate());
-    Assertions.assertThrows(
-        IllegalArgumentException.class, () -> new UpdateUserRequest(" 
").validate());
-    Assertions.assertThrows(
-        IllegalArgumentException.class, () -> new 
UpdateUserRequest("short").validate());
-  }
-
-  @Test
-  public void testUpdateUserRequestToStringDoesNotExposePassword() {
-    String requestString = new UpdateUserRequest(VALID_PASSWORD).toString();
-
-    Assertions.assertFalse(requestString.contains(VALID_PASSWORD));
-    Assertions.assertFalse(requestString.contains("password="));
-    Assertions.assertFalse(requestString.contains("\"password\""));
-  }
-}
diff --git 
a/plugins/idp-basic/src/test/java/org/apache/gravitino/idp/integration/test/IdpRESTApiIT.java
 
b/plugins/idp-basic/src/test/java/org/apache/gravitino/idp/integration/test/IdpRESTApiIT.java
index 528d34da16..463b44282b 100644
--- 
a/plugins/idp-basic/src/test/java/org/apache/gravitino/idp/integration/test/IdpRESTApiIT.java
+++ 
b/plugins/idp-basic/src/test/java/org/apache/gravitino/idp/integration/test/IdpRESTApiIT.java
@@ -42,8 +42,9 @@ import org.apache.gravitino.auth.AuthenticatorType;
 import org.apache.gravitino.dto.responses.ErrorConstants;
 import org.apache.gravitino.idp.dto.requests.AddGroupRequest;
 import org.apache.gravitino.idp.dto.requests.AddUserRequest;
+import org.apache.gravitino.idp.dto.requests.ChangePasswordRequest;
 import org.apache.gravitino.idp.dto.requests.GroupMembershipChangeRequest;
-import org.apache.gravitino.idp.dto.requests.UpdateUserRequest;
+import org.apache.gravitino.idp.dto.requests.UpdateUserEnabledRequest;
 import org.apache.gravitino.idp.dto.responses.IdpGroupResponse;
 import org.apache.gravitino.idp.dto.responses.IdpUserResponse;
 import org.apache.gravitino.idp.web.rest.feature.IdpRESTFeature;
@@ -153,22 +154,47 @@ public class IdpRESTApiIT extends BaseIT {
     self.validate();
     Assertions.assertEquals(USER1, self.getUser().name());
 
+    assertError(
+        400,
+        put(
+            "/idp/users/" + USER1 + "/password",
+            new ChangePasswordRequest(UPDATED_PASSWORD),
+            USER1,
+            USER_PASSWORD),
+        ErrorConstants.ILLEGAL_ARGUMENTS_CODE);
+    assertError(
+        400,
+        put(
+            "/idp/users/" + USER1 + "/password",
+            new ChangePasswordRequest(UPDATED_PASSWORD, "wrong-password-12"),
+            USER1,
+            USER_PASSWORD),
+        ErrorConstants.ILLEGAL_ARGUMENTS_CODE);
+
     HttpResponse<String> selfUpdate =
-        put("/idp/users/" + USER1, new UpdateUserRequest(UPDATED_PASSWORD), 
USER1, USER_PASSWORD);
+        put(
+            "/idp/users/" + USER1 + "/password",
+            new ChangePasswordRequest(UPDATED_PASSWORD, USER_PASSWORD),
+            USER1,
+            USER_PASSWORD);
     Assertions.assertEquals(200, selfUpdate.statusCode(), selfUpdate.body());
     Assertions.assertEquals(200, get("/version", USER1, 
UPDATED_PASSWORD).statusCode());
 
     assertError(
         403,
         put(
-            "/idp/users/" + USER2,
-            new UpdateUserRequest(UPDATED_PASSWORD),
+            "/idp/users/" + USER2 + "/password",
+            new ChangePasswordRequest(UPDATED_PASSWORD),
             USER1,
             UPDATED_PASSWORD),
         ErrorConstants.FORBIDDEN_CODE);
     assertError(
         403,
-        put("/idp/users/" + USER1, new UpdateUserRequest(null, false), USER1, 
UPDATED_PASSWORD),
+        put(
+            "/idp/users/" + USER1 + "/enabled",
+            new UpdateUserEnabledRequest(false),
+            USER1,
+            UPDATED_PASSWORD),
         ErrorConstants.FORBIDDEN_CODE);
 
     Assertions.assertTrue(deleteUser(USER1));
@@ -202,13 +228,13 @@ public class IdpRESTApiIT extends BaseIT {
     changePassword(USER1, UPDATED_PASSWORD);
     Assertions.assertEquals(USER1, getUser(USER1).getUser().name());
 
-    put("/idp/users/" + USER1, new UpdateUserRequest(null, false));
+    put("/idp/users/" + USER1 + "/enabled", new 
UpdateUserEnabledRequest(false));
     Assertions.assertFalse(getUser(USER1).getUser().enabled());
-    put("/idp/users/" + USER1, new UpdateUserRequest(null, true));
+    put("/idp/users/" + USER1 + "/enabled", new 
UpdateUserEnabledRequest(true));
     Assertions.assertTrue(getUser(USER1).getUser().enabled());
     assertError(
         400,
-        put("/idp/users/" + ADMIN, new UpdateUserRequest(null, false)),
+        put("/idp/users/" + ADMIN + "/enabled", new 
UpdateUserEnabledRequest(false)),
         ErrorConstants.ILLEGAL_ARGUMENTS_CODE);
     Assertions.assertTrue(getUser(ADMIN).getUser().enabled());
 
@@ -218,11 +244,13 @@ public class IdpRESTApiIT extends BaseIT {
         ErrorConstants.NOT_FOUND_CODE);
     assertError(
         404,
-        put("/idp/users/" + MISSING_USER, new 
UpdateUserRequest(UPDATED_PASSWORD)),
+        put(
+            "/idp/users/" + MISSING_USER + "/password",
+            new ChangePasswordRequest(UPDATED_PASSWORD)),
         ErrorConstants.NOT_FOUND_CODE);
     assertError(
         400,
-        put("/idp/users/" + USER1, new UpdateUserRequest(" ")),
+        put("/idp/users/" + USER1 + "/password", new ChangePasswordRequest(" 
")),
         ErrorConstants.ILLEGAL_ARGUMENTS_CODE);
 
     Assertions.assertTrue(deleteUser(USER1));
@@ -340,7 +368,8 @@ public class IdpRESTApiIT extends BaseIT {
   }
 
   private static void changePassword(String username, String password) throws 
Exception {
-    HttpResponse<String> response = put("/idp/users/" + username, new 
UpdateUserRequest(password));
+    HttpResponse<String> response =
+        put("/idp/users/" + username + "/password", new 
ChangePasswordRequest(password));
     Assertions.assertEquals(200, response.statusCode(), response.body());
   }
 
diff --git 
a/plugins/idp-basic/src/test/java/org/apache/gravitino/idp/web/rest/TestIdpAuthorizationFilter.java
 
b/plugins/idp-basic/src/test/java/org/apache/gravitino/idp/web/rest/TestIdpAuthorizationFilter.java
index 2c4a017f83..75b656d201 100644
--- 
a/plugins/idp-basic/src/test/java/org/apache/gravitino/idp/web/rest/TestIdpAuthorizationFilter.java
+++ 
b/plugins/idp-basic/src/test/java/org/apache/gravitino/idp/web/rest/TestIdpAuthorizationFilter.java
@@ -53,9 +53,15 @@ class TestIdpAuthorizationFilter {
         IdpAuthorizationFilter.isSelfUserAccess(request("GET", "idp", "users", 
"bob"), "alice"));
     assertFalse(
         IdpAuthorizationFilter.isSelfUserAccess(request("PUT", "idp", 
"groups", "alice"), "alice"));
+    assertFalse(
+        IdpAuthorizationFilter.isSelfUserAccess(
+            request("PUT", "idp", "users", "alice", "enabled"), "alice"));
     assertTrue(
         IdpAuthorizationFilter.isSelfUserAccess(request("GET", "idp", "users", 
"alice"), "alice"));
     assertTrue(
+        IdpAuthorizationFilter.isSelfUserAccess(
+            request("PUT", "idp", "users", "alice", "password"), "alice"));
+    assertFalse(
         IdpAuthorizationFilter.isSelfUserAccess(request("PUT", "idp", "users", 
"alice"), "alice"));
   }
 
diff --git 
a/plugins/idp-basic/src/test/java/org/apache/gravitino/idp/web/rest/TestIdpOperations.java
 
b/plugins/idp-basic/src/test/java/org/apache/gravitino/idp/web/rest/TestIdpOperations.java
index 153d5908ac..68c56b5c47 100644
--- 
a/plugins/idp-basic/src/test/java/org/apache/gravitino/idp/web/rest/TestIdpOperations.java
+++ 
b/plugins/idp-basic/src/test/java/org/apache/gravitino/idp/web/rest/TestIdpOperations.java
@@ -51,8 +51,9 @@ import org.apache.gravitino.idp.IdpUserGroupManager;
 import org.apache.gravitino.idp.dto.IdpGroupDTO;
 import org.apache.gravitino.idp.dto.requests.AddGroupRequest;
 import org.apache.gravitino.idp.dto.requests.AddUserRequest;
+import org.apache.gravitino.idp.dto.requests.ChangePasswordRequest;
 import org.apache.gravitino.idp.dto.requests.GroupMembershipChangeRequest;
-import org.apache.gravitino.idp.dto.requests.UpdateUserRequest;
+import org.apache.gravitino.idp.dto.requests.UpdateUserEnabledRequest;
 import org.apache.gravitino.idp.dto.responses.IdpGroupResponse;
 import org.apache.gravitino.idp.dto.responses.IdpUserResponse;
 import org.apache.gravitino.idp.model.IdpGroup;
@@ -155,36 +156,64 @@ class TestIdpOperations extends JerseyTest {
 
   @Test
   void testChangePasswordAndRemoveUser() {
-    UpdateUserRequest req = new UpdateUserRequest(VALID_PASSWORD);
+    ChangePasswordRequest req = new ChangePasswordRequest(VALID_PASSWORD);
     when(MANAGER.changePassword("user1", VALID_PASSWORD)).thenReturn(true);
     when(MANAGER.getUser("user1")).thenReturn(buildUser("user1"));
     when(MANAGER.removeUser("user1")).thenReturn(true);
 
     Assertions.assertEquals(
-        "user1", put("/idp/users/user1", 
req).readEntity(IdpUserResponse.class).getUser().name());
+        "user1",
+        put("/idp/users/user1/password", 
req).readEntity(IdpUserResponse.class).getUser().name());
     
Assertions.assertTrue(delete("/idp/users/user1").readEntity(RemoveResponse.class).removed());
   }
 
+  @Test
+  void testAdminSelfPasswordChangeRequiresOldPassword() {
+    ChangePasswordRequest missingOld = new 
ChangePasswordRequest(VALID_PASSWORD);
+    assertError(
+        Response.Status.BAD_REQUEST,
+        put("/idp/users/admin/password", missingOld),
+        ErrorConstants.ILLEGAL_ARGUMENTS_CODE);
+
+    ChangePasswordRequest withOld = new ChangePasswordRequest(VALID_PASSWORD, 
"admin-old-pass12");
+    when(MANAGER.changePassword("admin", VALID_PASSWORD, 
"admin-old-pass12")).thenReturn(true);
+    when(MANAGER.getUser("admin")).thenReturn(buildUser("admin"));
+    Assertions.assertEquals(
+        "admin",
+        put("/idp/users/admin/password", withOld)
+            .readEntity(IdpUserResponse.class)
+            .getUser()
+            .name());
+  }
+
+  @Test
+  void testAdminResetRejectsOldPassword() {
+    ChangePasswordRequest req = new ChangePasswordRequest(VALID_PASSWORD, 
"other-old-pass12");
+    assertError(
+        Response.Status.BAD_REQUEST,
+        put("/idp/users/user1/password", req),
+        ErrorConstants.ILLEGAL_ARGUMENTS_CODE);
+  }
+
   @Test
   void testUpdateEnabled() {
-    UpdateUserRequest req = new UpdateUserRequest(null, false);
+    UpdateUserEnabledRequest req = new UpdateUserEnabledRequest(false);
     when(MANAGER.updateEnabled("user1", false)).thenReturn(true);
     when(MANAGER.getUser("user1")).thenReturn(new IdpUser("user1", 
Collections.emptyList(), false));
 
     Assertions.assertFalse(
-        put("/idp/users/user1", 
req).readEntity(IdpUserResponse.class).getUser().enabled());
+        put("/idp/users/user1/enabled", 
req).readEntity(IdpUserResponse.class).getUser().enabled());
   }
 
   @Test
-  void testCannotDisableServiceAdmin() {
-    UpdateUserRequest req = new UpdateUserRequest(null, false);
-    doThrow(new IllegalArgumentException("Cannot disable service admin admin"))
-        .when(MANAGER)
-        .updateEnabled("admin", false);
-
+  void testCannotUpdateEnabledOfServiceAdmin() {
+    assertError(
+        Response.Status.BAD_REQUEST,
+        put("/idp/users/admin/enabled", new UpdateUserEnabledRequest(false)),
+        ErrorConstants.ILLEGAL_ARGUMENTS_CODE);
     assertError(
         Response.Status.BAD_REQUEST,
-        put("/idp/users/admin", req),
+        put("/idp/users/admin/enabled", new UpdateUserEnabledRequest(true)),
         ErrorConstants.ILLEGAL_ARGUMENTS_CODE);
   }
 

Reply via email to