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 34425e6004 [#10964] feat(idp-basic): Add built-in IdP REST APIs 
(#11234)
34425e6004 is described below

commit 34425e60045da43fd15d3c9d640761421f564992
Author: MaSai <[email protected]>
AuthorDate: Wed May 27 13:12:23 2026 +0800

    [#10964] feat(idp-basic): Add built-in IdP REST APIs (#11234)
    
    ### What changes were proposed in this pull request?
    
    Implement built-in IdP management REST APIs in the `idp-basic` plugin:
    
    - **REST resources**: `IdpUserOperations` and `IdpGroupOperations` under
    `/api/idp/users` and `/api/idp/groups`
      - User: GET/POST/PUT (password reset)/DELETE
    - Group: GET/POST/DELETE (`force` query param), membership via `PUT
    .../{group}/add` and `PUT .../{group}/remove`
    - **Web layer**: `IdpRestUtils` (response helpers + exception mapping),
    `IdpDTOConverters`, plugin-local DTOs
    - **Authorization (plugin-only)**: `@IdpManagement` name binding +
    `IdpAuthorizationFilter` — APIs are available only when `basic` is in
    `gravitino.authenticators`, and caller must be service admin
    - **Validation**: `IdpCredentialValidator` in REST DTOs
    (`AddUserRequest`, `ResetPasswordRequest`) — username must not contain
    `:`, password length 12–64
    - **OpenAPI**: `docs/open-api/idp.yaml` documents all IdP endpoints
    
    Enable via server config:
    
    ```properties
    gravitino.server.rest.extensionPackages = org.apache.gravitino.idp.web.rest
    ```
    
    No changes to server/core DTOs or `GravitinoInterceptionService`.
    
    ### Why are the changes needed?
    
    Local authentication (`design-docs/gravitino-local-authentication.md`)
    requires service-admin APIs to manage built-in IdP users and groups.
    These endpoints are global (not metalake-scoped) and must be gated when
    `basic` authenticator is enabled.
    
    Fixes #10964
    
    ### Does this PR introduce _any_ user-facing change?
    
    Yes. New REST APIs under `/api/idp` when the `idp-basic` plugin is on
    the classpath and `gravitino.server.rest.extensionPackages` includes
    `org.apache.gravitino.idp.web.rest`. No default change to
    `gravitino.conf` until operators opt in.
    
    ### How was this patch tested?
    
    - `./gradlew :plugins:idp-basic:test -PskipITs`
    - `./gradlew :server:test --tests
    org.apache.gravitino.server.TestIdpRestExtension -PskipITs`
    - `./gradlew :docs:build`
---
 plugins/idp-basic/build.gradle.kts                 |  14 +-
 .../apache/gravitino/idp/IdpUserGroupManager.java  |   4 +-
 .../idp/basic/IdpCredentialValidator.java          |  49 +++++
 .../gravitino/idp/dto/requests/AddUserRequest.java |   9 +-
 ...wordRequest.java => ChangePasswordRequest.java} |  20 +-
 .../org/apache/gravitino/idp/model/IdpGroup.java   |  10 +
 .../org/apache/gravitino/idp/model/IdpUser.java    |  10 +
 .../provider/base/IdpUserMetaBaseSQLProvider.java  |   3 +-
 .../idp/storage/service/IdpUserMetaService.java    |  22 +-
 .../apache/gravitino/idp/web/IdpManagement.java    |  34 +++
 .../apache/gravitino/idp/web/IdpOperationType.java |  27 +++
 .../org/apache/gravitino/idp/web/IdpRESTUtils.java | 138 +++++++++++++
 .../idp/web/rest/IdpAuthorizationFilter.java       |  75 +++++++
 .../gravitino/idp/web/rest/IdpBasicBinder.java     |  34 +++
 .../gravitino/idp/web/rest/IdpGroupOperations.java | 133 ++++++++++++
 .../gravitino/idp/web/rest/IdpUserOperations.java  | 121 +++++++++++
 .../idp/web/rest/feature/IdpRESTFeature.java       |  70 +++++++
 .../gravitino/idp/TestIdpUserGroupManager.java     |  36 ++--
 .../idp/dto/requests/TestAddUserRequest.java       |  17 +-
 ...Request.java => TestChangePasswordRequest.java} |  36 ++--
 .../idp/storage/mapper/TestIdpUserMetaStorage.java |   2 +-
 .../storage/service/TestIdpUserMetaService.java    |   8 +-
 .../idp/web/rest/TestIdpAuthorizationFilter.java   |  36 ++++
 .../gravitino/idp/web/rest/TestIdpOperations.java  | 229 +++++++++++++++++++++
 .../idp/web/rest/TestIdpRestExtension.java         | 180 ++++++++++++++++
 .../idp/web/rest/feature/TestIdpRESTFeature.java   |  37 ++++
 26 files changed, 1287 insertions(+), 67 deletions(-)

diff --git a/plugins/idp-basic/build.gradle.kts 
b/plugins/idp-basic/build.gradle.kts
index 26182a1608..f7ac06bb91 100644
--- a/plugins/idp-basic/build.gradle.kts
+++ b/plugins/idp-basic/build.gradle.kts
@@ -30,23 +30,33 @@ dependencies {
   implementation(project(":core"))
 
   implementation(libs.bcprov.jdk18on)
+  implementation(libs.bundles.jersey)
   implementation(libs.commons.lang3)
   implementation(libs.guava)
   implementation(libs.jackson.annotations)
   implementation(libs.jackson.databind)
+  implementation(libs.metrics.jersey2)
   implementation(libs.mybatis)
+  implementation(libs.servlet)
 
   compileOnly(libs.lombok)
   compileOnly(libs.slf4j.api)
 
-  testImplementation(project(":common"))
-  testImplementation(project(":core"))
+  testImplementation(project(":server"))
+  testImplementation(project(":server-common"))
   testImplementation(project(":integration-test-common", "testArtifacts"))
 
   testImplementation(libs.awaitility)
   testImplementation(libs.commons.io)
+  testImplementation(libs.jersey.test.framework.core) {
+    exclude(group = "org.junit.jupiter")
+  }
+  testImplementation(libs.jersey.test.framework.provider.jetty) {
+    exclude(group = "org.junit.jupiter")
+  }
   testImplementation(libs.junit.jupiter.api)
   testImplementation(libs.junit.jupiter.params)
+  testImplementation(libs.mockito.inline)
   testImplementation(libs.mysql.driver)
   testImplementation(libs.postgresql.driver)
   testImplementation(libs.testcontainers)
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 bb26bfa4fc..1468fc811b 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
@@ -27,6 +27,7 @@ import javax.annotation.Nullable;
 import org.apache.gravitino.Config;
 import org.apache.gravitino.idp.basic.password.PasswordHasher;
 import org.apache.gravitino.idp.basic.password.PasswordHasherFactory;
+import org.apache.gravitino.idp.exception.NotFoundException;
 import org.apache.gravitino.idp.model.IdpGroup;
 import org.apache.gravitino.idp.model.IdpUser;
 import org.apache.gravitino.idp.storage.po.IdpGroupPO;
@@ -104,7 +105,8 @@ public class IdpUserGroupManager implements Closeable {
    *
    * @param username The username.
    * @param password The new plaintext password.
-   * @return True if the password was updated, false if the user did not exist.
+   * @return {@code true} if the password was updated
+   * @throws NotFoundException if the user does not exist
    */
   public boolean changePassword(String username, String password) {
     return USER_SERVICE.updateIdpUserPassword(username, 
passwordHasher.hash(password));
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
new file mode 100644
index 0000000000..45701a0fc9
--- /dev/null
+++ 
b/plugins/idp-basic/src/main/java/org/apache/gravitino/idp/basic/IdpCredentialValidator.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.basic;
+
+import com.google.common.base.Preconditions;
+import org.apache.commons.lang3.StringUtils;
+
+/** Validates built-in IdP username and password credentials. */
+public final class IdpCredentialValidator {
+
+  private static final int MIN_PASSWORD_LENGTH = 12;
+
+  private static final int MAX_PASSWORD_LENGTH = 64;
+
+  private IdpCredentialValidator() {}
+
+  public static void validateUsername(String username) {
+    Preconditions.checkArgument(
+        StringUtils.isNotBlank(username), "\"user\" field is required and 
cannot be empty");
+    Preconditions.checkArgument(!username.contains(":"), "User name cannot 
contain a colon (:)");
+  }
+
+  public static void validatePassword(String password) {
+    Preconditions.checkArgument(
+        StringUtils.isNotBlank(password), "\"password\" field is required and 
cannot be empty");
+    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",
+        MIN_PASSWORD_LENGTH,
+        MAX_PASSWORD_LENGTH);
+  }
+}
diff --git 
a/plugins/idp-basic/src/main/java/org/apache/gravitino/idp/dto/requests/AddUserRequest.java
 
b/plugins/idp-basic/src/main/java/org/apache/gravitino/idp/dto/requests/AddUserRequest.java
index 8625eff833..6be8ade145 100644
--- 
a/plugins/idp-basic/src/main/java/org/apache/gravitino/idp/dto/requests/AddUserRequest.java
+++ 
b/plugins/idp-basic/src/main/java/org/apache/gravitino/idp/dto/requests/AddUserRequest.java
@@ -20,13 +20,12 @@
 package org.apache.gravitino.idp.dto.requests;
 
 import com.fasterxml.jackson.annotation.JsonProperty;
-import com.google.common.base.Preconditions;
 import lombok.Builder;
 import lombok.EqualsAndHashCode;
 import lombok.Getter;
 import lombok.ToString;
 import lombok.extern.jackson.Jacksonized;
-import org.apache.commons.lang3.StringUtils;
+import org.apache.gravitino.idp.basic.IdpCredentialValidator;
 import org.apache.gravitino.rest.RESTRequest;
 
 /** Represents a request to add a built-in IdP user. */
@@ -68,9 +67,7 @@ public class AddUserRequest implements RESTRequest {
    */
   @Override
   public void validate() throws IllegalArgumentException {
-    Preconditions.checkArgument(
-        StringUtils.isNotBlank(user), "\"user\" field is required and cannot 
be empty");
-    Preconditions.checkArgument(
-        StringUtils.isNotBlank(password), "\"password\" field is required and 
cannot be empty");
+    IdpCredentialValidator.validateUsername(user);
+    IdpCredentialValidator.validatePassword(password);
   }
 }
diff --git 
a/plugins/idp-basic/src/main/java/org/apache/gravitino/idp/dto/requests/ResetPasswordRequest.java
 
b/plugins/idp-basic/src/main/java/org/apache/gravitino/idp/dto/requests/ChangePasswordRequest.java
similarity index 71%
rename from 
plugins/idp-basic/src/main/java/org/apache/gravitino/idp/dto/requests/ResetPasswordRequest.java
rename to 
plugins/idp-basic/src/main/java/org/apache/gravitino/idp/dto/requests/ChangePasswordRequest.java
index a6b1908b33..f923585adf 100644
--- 
a/plugins/idp-basic/src/main/java/org/apache/gravitino/idp/dto/requests/ResetPasswordRequest.java
+++ 
b/plugins/idp-basic/src/main/java/org/apache/gravitino/idp/dto/requests/ChangePasswordRequest.java
@@ -20,50 +20,48 @@
 package org.apache.gravitino.idp.dto.requests;
 
 import com.fasterxml.jackson.annotation.JsonProperty;
-import com.google.common.base.Preconditions;
 import lombok.Builder;
 import lombok.EqualsAndHashCode;
 import lombok.Getter;
 import lombok.ToString;
 import lombok.extern.jackson.Jacksonized;
-import org.apache.commons.lang3.StringUtils;
+import org.apache.gravitino.idp.basic.IdpCredentialValidator;
 import org.apache.gravitino.rest.RESTRequest;
 
-/** Represents a request to reset a built-in IdP user password. */
+/** Represents a request to change a built-in IdP user password. */
 @Getter
 @EqualsAndHashCode
 @ToString
 @Builder
 @Jacksonized
-public class ResetPasswordRequest implements RESTRequest {
+public class ChangePasswordRequest implements RESTRequest {
 
   @JsonProperty("password")
   @ToString.Exclude
   private final String password;
 
-  /** Default constructor for ResetPasswordRequest. (Used for Jackson 
deserialization.) */
-  public ResetPasswordRequest() {
+  /** Default constructor for ChangePasswordRequest. (Used for Jackson 
deserialization.) */
+  public ChangePasswordRequest() {
     this(null);
   }
 
   /**
-   * Creates a new ResetPasswordRequest.
+   * Creates a new ChangePasswordRequest.
    *
    * @param password The new password of the built-in IdP user.
    */
-  public ResetPasswordRequest(String password) {
+  public ChangePasswordRequest(String password) {
     super();
     this.password = password;
   }
 
   /**
-   * Validates the {@link ResetPasswordRequest} 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(
-        StringUtils.isNotBlank(password), "\"password\" field is required and 
cannot be empty");
+    IdpCredentialValidator.validatePassword(password);
   }
 }
diff --git 
a/plugins/idp-basic/src/main/java/org/apache/gravitino/idp/model/IdpGroup.java 
b/plugins/idp-basic/src/main/java/org/apache/gravitino/idp/model/IdpGroup.java
index 50fddf00a1..10f4aee5df 100644
--- 
a/plugins/idp-basic/src/main/java/org/apache/gravitino/idp/model/IdpGroup.java
+++ 
b/plugins/idp-basic/src/main/java/org/apache/gravitino/idp/model/IdpGroup.java
@@ -20,6 +20,7 @@ package org.apache.gravitino.idp.model;
 
 import java.util.List;
 import java.util.Objects;
+import org.apache.gravitino.idp.dto.IdpGroupDTO;
 
 /** Built-in IdP group. */
 public class IdpGroup {
@@ -48,6 +49,15 @@ public class IdpGroup {
     return usernames;
   }
 
+  /**
+   * Converts this group to a REST DTO.
+   *
+   * @return the group DTO
+   */
+  public IdpGroupDTO toDTO() {
+    return IdpGroupDTO.builder().withName(name).withUsers(usernames).build();
+  }
+
   @Override
   public boolean equals(Object other) {
     if (this == other) {
diff --git 
a/plugins/idp-basic/src/main/java/org/apache/gravitino/idp/model/IdpUser.java 
b/plugins/idp-basic/src/main/java/org/apache/gravitino/idp/model/IdpUser.java
index aae49bd266..cf5037c56f 100644
--- 
a/plugins/idp-basic/src/main/java/org/apache/gravitino/idp/model/IdpUser.java
+++ 
b/plugins/idp-basic/src/main/java/org/apache/gravitino/idp/model/IdpUser.java
@@ -20,6 +20,7 @@ package org.apache.gravitino.idp.model;
 
 import java.util.List;
 import java.util.Objects;
+import org.apache.gravitino.idp.dto.IdpUserDTO;
 
 /** Built-in IdP user. */
 public class IdpUser {
@@ -48,6 +49,15 @@ public class IdpUser {
     return groupNames;
   }
 
+  /**
+   * Converts this user to a REST DTO.
+   *
+   * @return the user DTO
+   */
+  public IdpUserDTO toDTO() {
+    return IdpUserDTO.builder().withName(name).withGroups(groupNames).build();
+  }
+
   @Override
   public boolean equals(Object other) {
     if (this == other) {
diff --git 
a/plugins/idp-basic/src/main/java/org/apache/gravitino/idp/storage/mapper/provider/base/IdpUserMetaBaseSQLProvider.java
 
b/plugins/idp-basic/src/main/java/org/apache/gravitino/idp/storage/mapper/provider/base/IdpUserMetaBaseSQLProvider.java
index d89f3f7757..f194d66663 100644
--- 
a/plugins/idp-basic/src/main/java/org/apache/gravitino/idp/storage/mapper/provider/base/IdpUserMetaBaseSQLProvider.java
+++ 
b/plugins/idp-basic/src/main/java/org/apache/gravitino/idp/storage/mapper/provider/base/IdpUserMetaBaseSQLProvider.java
@@ -69,8 +69,7 @@ public class IdpUserMetaBaseSQLProvider {
         + IdpUserMetaMapper.IDP_USER_TABLE_NAME
         + " SET password_hash = #{passwordHash}"
         + " WHERE user_name = #{username}"
-        + " AND deleted_at = 0"
-        + " AND password_hash <> #{passwordHash}";
+        + " AND deleted_at = 0";
   }
 
   public String softDeleteIdpUser(@Param("username") String username) {
diff --git 
a/plugins/idp-basic/src/main/java/org/apache/gravitino/idp/storage/service/IdpUserMetaService.java
 
b/plugins/idp-basic/src/main/java/org/apache/gravitino/idp/storage/service/IdpUserMetaService.java
index 70834283ef..c3d3205bd2 100644
--- 
a/plugins/idp-basic/src/main/java/org/apache/gravitino/idp/storage/service/IdpUserMetaService.java
+++ 
b/plugins/idp-basic/src/main/java/org/apache/gravitino/idp/storage/service/IdpUserMetaService.java
@@ -101,22 +101,28 @@ public class IdpUserMetaService {
   }
 
   /**
-   * Updates the password hash for an active user, following the core 
relational meta update flow:
-   * load the current row, commit the update, and use the affected row count 
as the success signal.
+   * Updates the password hash for an active user. Succeeds when the user 
exists and the update is
+   * committed, even if the stored hash is already equal to {@code 
passwordHash}.
    *
    * @param username username of the user
    * @param passwordHash new password hash to store
-   * @return {@code true} if the user exists and the password hash was updated
+   * @return {@code true} if the password hash was updated
+   * @throws NotFoundException if the user does not exist or is soft-deleted
    */
   @Monitored(
       metricsSource = GRAVITINO_RELATIONAL_STORE_METRIC_NAME,
       baseMetricName = "updateIdpUserPassword")
   public boolean updateIdpUserPassword(String username, String passwordHash) {
-    Integer updated =
-        SessionUtils.doWithCommitAndFetchResult(
-            IdpUserMetaMapper.class,
-            mapper -> mapper.updateIdpUserPassword(username, passwordHash));
-    return updated > 0;
+    return SessionUtils.doWithCommitAndFetchResult(
+        IdpUserMetaMapper.class,
+        mapper -> {
+          IdpUserPO userPO = mapper.selectIdpUser(username);
+          if (userPO == null) {
+            throw new NotFoundException("IdP user not found: %s", username);
+          }
+          mapper.updateIdpUserPassword(username, passwordHash);
+          return true;
+        });
   }
 
   @Monitored(
diff --git 
a/plugins/idp-basic/src/main/java/org/apache/gravitino/idp/web/IdpManagement.java
 
b/plugins/idp-basic/src/main/java/org/apache/gravitino/idp/web/IdpManagement.java
new file mode 100644
index 0000000000..fd3a813d4d
--- /dev/null
+++ 
b/plugins/idp-basic/src/main/java/org/apache/gravitino/idp/web/IdpManagement.java
@@ -0,0 +1,34 @@
+/*
+ * 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.web;
+
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.annotation.Target;
+import javax.ws.rs.NameBinding;
+
+/**
+ * Binds built-in IdP management REST resources to {@link
+ * org.apache.gravitino.idp.web.rest.IdpAuthorizationFilter}.
+ */
+@NameBinding
+@Target({ElementType.TYPE, ElementType.METHOD})
+@Retention(RetentionPolicy.RUNTIME)
+public @interface IdpManagement {}
diff --git 
a/plugins/idp-basic/src/main/java/org/apache/gravitino/idp/web/IdpOperationType.java
 
b/plugins/idp-basic/src/main/java/org/apache/gravitino/idp/web/IdpOperationType.java
new file mode 100644
index 0000000000..7e69768f48
--- /dev/null
+++ 
b/plugins/idp-basic/src/main/java/org/apache/gravitino/idp/web/IdpOperationType.java
@@ -0,0 +1,27 @@
+/*
+ * 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.web;
+
+/** Operation types for built-in IdP REST error handling. */
+public enum IdpOperationType {
+  GET,
+  ADD,
+  UPDATE,
+  REMOVE
+}
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
new file mode 100644
index 0000000000..ea9c44e8f8
--- /dev/null
+++ 
b/plugins/idp-basic/src/main/java/org/apache/gravitino/idp/web/IdpRESTUtils.java
@@ -0,0 +1,138 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *  http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.gravitino.idp.web;
+
+import java.security.PrivilegedExceptionAction;
+import javax.servlet.http.HttpServletRequest;
+import javax.ws.rs.core.MediaType;
+import javax.ws.rs.core.Response;
+import org.apache.commons.lang3.StringUtils;
+import org.apache.gravitino.UserPrincipal;
+import org.apache.gravitino.auth.AuthConstants;
+import org.apache.gravitino.dto.responses.ErrorResponse;
+import org.apache.gravitino.idp.exception.AlreadyExistsException;
+import org.apache.gravitino.idp.exception.NotFoundException;
+import org.apache.gravitino.utils.PrincipalUtils;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/** REST helpers for built-in IdP resources in the {@code idp-basic} plugin. */
+public final class IdpRESTUtils {
+
+  private static final Logger LOG = 
LoggerFactory.getLogger(IdpRESTUtils.class);
+
+  private static final MediaType JSON = MediaType.APPLICATION_JSON_TYPE;
+
+  private IdpRESTUtils() {}
+
+  public static <T> Response ok(T entity) {
+    return json(Response.Status.OK, entity);
+  }
+
+  public static Response doAs(
+      HttpServletRequest httpRequest, PrivilegedExceptionAction<Response> 
action) throws Exception {
+    UserPrincipal principal =
+        (UserPrincipal)
+            
httpRequest.getAttribute(AuthConstants.AUTHENTICATED_PRINCIPAL_ATTRIBUTE_NAME);
+    return PrincipalUtils.doAs(
+        principal == null ? new UserPrincipal(AuthConstants.ANONYMOUS_USER) : 
principal, action);
+  }
+
+  public static Response doAs(
+      HttpServletRequest httpRequest,
+      PrivilegedExceptionAction<Response> action,
+      String resourceType,
+      IdpOperationType op,
+      String resourceName) {
+    try {
+      return doAs(httpRequest, action);
+    } catch (Exception e) {
+      return handleException(resourceType, op, resourceName, e);
+    }
+  }
+
+  public static Response handleException(
+      String resourceType, IdpOperationType op, String name, Exception e) {
+    String formatted = StringUtils.isBlank(name) ? "" : " [" + name + "]";
+    String errorMsg =
+        String.format(
+            "Failed to operate built-in IdP %s %s operation [%s], reason [%s]",
+            resourceType, formatted, op.name(), e.getMessage());
+    LOG.warn(errorMsg, e);
+    return toErrorResponse(errorMsg, e);
+  }
+
+  public static Response illegalArguments(String message, Throwable throwable) 
{
+    return json(
+        Response.Status.BAD_REQUEST,
+        ErrorResponse.illegalArguments(
+            exceptionType(throwable, "IllegalArgumentException"), message, 
throwable));
+  }
+
+  public static Response notFound(String message, Throwable throwable) {
+    return json(
+        Response.Status.NOT_FOUND,
+        ErrorResponse.notFound(exceptionType(throwable, "NotFoundException"), 
message, throwable));
+  }
+
+  public static Response alreadyExists(String message, Throwable throwable) {
+    return json(
+        Response.Status.CONFLICT,
+        ErrorResponse.alreadyExists(
+            exceptionType(throwable, "AlreadyExistsException"), message, 
throwable));
+  }
+
+  public static Response forbidden(String message, Throwable throwable) {
+    return json(Response.Status.FORBIDDEN, ErrorResponse.forbidden(message, 
throwable));
+  }
+
+  public static Response unsupportedOperation(String message, Throwable 
throwable) {
+    return json(
+        Response.Status.METHOD_NOT_ALLOWED, 
ErrorResponse.unsupportedOperation(message, throwable));
+  }
+
+  public static Response internalError(String message, Throwable throwable) {
+    return json(
+        Response.Status.INTERNAL_SERVER_ERROR, 
ErrorResponse.internalError(message, throwable));
+  }
+
+  private static Response toErrorResponse(String errorMsg, Exception e) {
+    if (e instanceof IllegalArgumentException) {
+      return illegalArguments(errorMsg, e);
+    }
+    if (e instanceof NotFoundException) {
+      return notFound(errorMsg, e);
+    }
+    if (e instanceof AlreadyExistsException) {
+      return alreadyExists(errorMsg, e);
+    }
+    if (e instanceof IllegalStateException || e instanceof 
UnsupportedOperationException) {
+      return unsupportedOperation(errorMsg, e);
+    }
+    return internalError(errorMsg, e);
+  }
+
+  private static Response json(Response.Status status, Object entity) {
+    return Response.status(status).entity(entity).type(JSON).build();
+  }
+
+  private static String exceptionType(Throwable throwable, String defaultType) 
{
+    return throwable == null ? defaultType : 
throwable.getClass().getSimpleName();
+  }
+}
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
new file mode 100644
index 0000000000..6bad34ea3f
--- /dev/null
+++ 
b/plugins/idp-basic/src/main/java/org/apache/gravitino/idp/web/rest/IdpAuthorizationFilter.java
@@ -0,0 +1,75 @@
+/*
+ * 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.web.rest;
+
+import java.io.IOException;
+import java.util.List;
+import java.util.function.Supplier;
+import javax.ws.rs.container.ContainerRequestContext;
+import javax.ws.rs.container.ContainerRequestFilter;
+import org.apache.gravitino.Configs;
+import org.apache.gravitino.GravitinoEnv;
+import org.apache.gravitino.idp.web.IdpManagement;
+import org.apache.gravitino.idp.web.IdpRESTUtils;
+import org.apache.gravitino.utils.PrincipalUtils;
+
+/**
+ * Enforces built-in IdP management API access rules without server 
interception.
+ *
+ * <p>Caller must be listed in {@link Configs#SERVICE_ADMINS}. This filter 
runs as a Jersey request
+ * filter after the servlet {@code AuthenticationFilter} has authenticated the 
caller and populated
+ * the current user principal.
+ *
+ * <p>Registered only when {@code basic} is configured in {@link 
Configs#AUTHENTICATORS}. Scoped to
+ * resources annotated with {@link IdpManagement} via Jersey name binding.
+ */
+@IdpManagement
+public class IdpAuthorizationFilter implements ContainerRequestFilter {
+
+  /** Error message when the caller is not a service admin. */
+  public static final String SERVICE_ADMIN_REQUIRED_MESSAGE =
+      "Only service admins can manage built-in IdP users and groups.";
+
+  private final Supplier<List<String>> serviceAdminsSupplier;
+  private final Supplier<String> currentUserSupplier;
+
+  /** Creates a filter backed by the running Gravitino server configuration. */
+  public IdpAuthorizationFilter() {
+    this(
+        () -> GravitinoEnv.getInstance().config().get(Configs.SERVICE_ADMINS),
+        PrincipalUtils::getCurrentUserName);
+  }
+
+  IdpAuthorizationFilter(
+      Supplier<List<String>> serviceAdminsSupplier, Supplier<String> 
currentUserSupplier) {
+    this.serviceAdminsSupplier = serviceAdminsSupplier;
+    this.currentUserSupplier = currentUserSupplier;
+  }
+
+  @Override
+  public void filter(ContainerRequestContext requestContext) throws 
IOException {
+    if (!isServiceAdmin(serviceAdminsSupplier.get(), 
currentUserSupplier.get())) {
+      
requestContext.abortWith(IdpRESTUtils.forbidden(SERVICE_ADMIN_REQUIRED_MESSAGE, 
null));
+    }
+  }
+
+  static boolean isServiceAdmin(List<String> serviceAdmins, String 
currentUser) {
+    return currentUser != null && serviceAdmins != null && 
serviceAdmins.contains(currentUser);
+  }
+}
diff --git 
a/plugins/idp-basic/src/main/java/org/apache/gravitino/idp/web/rest/IdpBasicBinder.java
 
b/plugins/idp-basic/src/main/java/org/apache/gravitino/idp/web/rest/IdpBasicBinder.java
new file mode 100644
index 0000000000..f9722c76f7
--- /dev/null
+++ 
b/plugins/idp-basic/src/main/java/org/apache/gravitino/idp/web/rest/IdpBasicBinder.java
@@ -0,0 +1,34 @@
+/*
+ * 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.web.rest;
+
+import org.apache.gravitino.GravitinoEnv;
+import org.apache.gravitino.idp.IdpUserGroupManager;
+import org.glassfish.hk2.utilities.binding.AbstractBinder;
+
+/** Registers built-in IdP REST dependencies for Jersey/HK2 injection. */
+public class IdpBasicBinder extends AbstractBinder {
+
+  @Override
+  protected void configure() {
+    GravitinoEnv gravitinoEnv = GravitinoEnv.getInstance();
+    bind(new IdpUserGroupManager(gravitinoEnv.config(), 
gravitinoEnv.idGenerator()))
+        .to(IdpUserGroupManager.class);
+  }
+}
diff --git 
a/plugins/idp-basic/src/main/java/org/apache/gravitino/idp/web/rest/IdpGroupOperations.java
 
b/plugins/idp-basic/src/main/java/org/apache/gravitino/idp/web/rest/IdpGroupOperations.java
new file mode 100644
index 0000000000..f2ad5233d4
--- /dev/null
+++ 
b/plugins/idp-basic/src/main/java/org/apache/gravitino/idp/web/rest/IdpGroupOperations.java
@@ -0,0 +1,133 @@
+/*
+ * 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.web.rest;
+
+import com.codahale.metrics.annotation.ResponseMetered;
+import com.codahale.metrics.annotation.Timed;
+import java.util.Arrays;
+import javax.inject.Inject;
+import javax.servlet.http.HttpServletRequest;
+import javax.ws.rs.DELETE;
+import javax.ws.rs.DefaultValue;
+import javax.ws.rs.GET;
+import javax.ws.rs.POST;
+import javax.ws.rs.PUT;
+import javax.ws.rs.Path;
+import javax.ws.rs.PathParam;
+import javax.ws.rs.Produces;
+import javax.ws.rs.QueryParam;
+import javax.ws.rs.core.Context;
+import javax.ws.rs.core.Response;
+import org.apache.gravitino.dto.responses.RemoveResponse;
+import org.apache.gravitino.idp.IdpUserGroupManager;
+import org.apache.gravitino.idp.dto.requests.AddGroupRequest;
+import org.apache.gravitino.idp.dto.requests.GroupMembershipChangeRequest;
+import org.apache.gravitino.idp.dto.responses.IdpGroupResponse;
+import org.apache.gravitino.idp.web.IdpManagement;
+import org.apache.gravitino.idp.web.IdpOperationType;
+import org.apache.gravitino.idp.web.IdpRESTUtils;
+import org.apache.gravitino.metrics.MetricNames;
+
+/** REST resource for built-in IdP group management exposed by the {@code 
idp-basic} plugin. */
+@IdpManagement
+@Path("/idp/groups")
+public class IdpGroupOperations {
+
+  private final IdpUserGroupManager userGroupManager;
+
+  @Context private HttpServletRequest httpRequest;
+
+  @Inject
+  public IdpGroupOperations(IdpUserGroupManager userGroupManager) {
+    this.userGroupManager = userGroupManager;
+  }
+
+  @GET
+  @Path("{group}")
+  @Produces("application/vnd.gravitino.v1+json")
+  @Timed(name = "get-idp-group." + MetricNames.HTTP_PROCESS_DURATION, absolute 
= true)
+  @ResponseMetered(name = "get-idp-group", absolute = true)
+  public Response getGroup(@PathParam("group") String group) {
+    return IdpRESTUtils.doAs(
+        httpRequest,
+        () -> IdpRESTUtils.ok(new 
IdpGroupResponse(userGroupManager.getGroup(group).toDTO())),
+        "group",
+        IdpOperationType.GET,
+        group);
+  }
+
+  @POST
+  @Produces("application/vnd.gravitino.v1+json")
+  @Timed(name = "add-idp-group." + MetricNames.HTTP_PROCESS_DURATION, absolute 
= true)
+  @ResponseMetered(name = "add-idp-group", absolute = true)
+  public Response addGroup(AddGroupRequest request) {
+    return IdpRESTUtils.doAs(
+        httpRequest,
+        () -> {
+          request.validate();
+          return IdpRESTUtils.ok(
+              new 
IdpGroupResponse(userGroupManager.addGroup(request.getGroup()).toDTO()));
+        },
+        "group",
+        IdpOperationType.ADD,
+        request.getGroup());
+  }
+
+  @DELETE
+  @Path("{group}")
+  @Produces("application/vnd.gravitino.v1+json")
+  @Timed(name = "remove-idp-group." + MetricNames.HTTP_PROCESS_DURATION, 
absolute = true)
+  @ResponseMetered(name = "remove-idp-group", absolute = true)
+  public Response removeGroup(
+      @PathParam("group") String group, @DefaultValue("false") 
@QueryParam("force") boolean force) {
+    return IdpRESTUtils.doAs(
+        httpRequest,
+        () -> IdpRESTUtils.ok(new 
RemoveResponse(userGroupManager.removeGroup(group, force))),
+        "group",
+        IdpOperationType.REMOVE,
+        group);
+  }
+
+  @PUT
+  @Path("{group}/users")
+  @Produces("application/vnd.gravitino.v1+json")
+  @Timed(name = "change-idp-group-users." + MetricNames.HTTP_PROCESS_DURATION, 
absolute = true)
+  @ResponseMetered(name = "change-idp-group-users", absolute = true)
+  public Response changeGroupMembership(
+      @PathParam("group") String group, GroupMembershipChangeRequest request) {
+    return IdpRESTUtils.doAs(
+        httpRequest,
+        () -> {
+          request.validate();
+          String[] usersToAdd = request.getUsersToAdd();
+          String[] usersToRemove = request.getUsersToRemove();
+          return IdpRESTUtils.ok(
+              new IdpGroupResponse(
+                  userGroupManager
+                      .changeGroupMembership(
+                          group,
+                          usersToAdd == null ? null : 
Arrays.asList(usersToAdd),
+                          usersToRemove == null ? null : 
Arrays.asList(usersToRemove))
+                      .toDTO()));
+        },
+        "group",
+        IdpOperationType.UPDATE,
+        group);
+  }
+}
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
new file mode 100644
index 0000000000..9d73e30b3c
--- /dev/null
+++ 
b/plugins/idp-basic/src/main/java/org/apache/gravitino/idp/web/rest/IdpUserOperations.java
@@ -0,0 +1,121 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *  http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.gravitino.idp.web.rest;
+
+import com.codahale.metrics.annotation.ResponseMetered;
+import com.codahale.metrics.annotation.Timed;
+import javax.inject.Inject;
+import javax.servlet.http.HttpServletRequest;
+import javax.ws.rs.DELETE;
+import javax.ws.rs.GET;
+import javax.ws.rs.POST;
+import javax.ws.rs.PUT;
+import javax.ws.rs.Path;
+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.gravitino.dto.responses.RemoveResponse;
+import org.apache.gravitino.idp.IdpUserGroupManager;
+import org.apache.gravitino.idp.dto.requests.AddUserRequest;
+import org.apache.gravitino.idp.dto.requests.ChangePasswordRequest;
+import org.apache.gravitino.idp.dto.responses.IdpUserResponse;
+import org.apache.gravitino.idp.web.IdpManagement;
+import org.apache.gravitino.idp.web.IdpOperationType;
+import org.apache.gravitino.idp.web.IdpRESTUtils;
+import org.apache.gravitino.metrics.MetricNames;
+
+/** REST resource for built-in IdP user management exposed by the {@code 
idp-basic} plugin. */
+@IdpManagement
+@Path("/idp/users")
+public class IdpUserOperations {
+
+  private final IdpUserGroupManager userGroupManager;
+
+  @Context private HttpServletRequest httpRequest;
+
+  @Inject
+  public IdpUserOperations(IdpUserGroupManager userGroupManager) {
+    this.userGroupManager = userGroupManager;
+  }
+
+  @GET
+  @Path("{user}")
+  @Produces("application/vnd.gravitino.v1+json")
+  @Timed(name = "get-idp-user." + MetricNames.HTTP_PROCESS_DURATION, absolute 
= true)
+  @ResponseMetered(name = "get-idp-user", absolute = true)
+  public Response getUser(@PathParam("user") String user) {
+    return IdpRESTUtils.doAs(
+        httpRequest,
+        () -> IdpRESTUtils.ok(new 
IdpUserResponse(userGroupManager.getUser(user).toDTO())),
+        "user",
+        IdpOperationType.GET,
+        user);
+  }
+
+  @POST
+  @Produces("application/vnd.gravitino.v1+json")
+  @Timed(name = "add-idp-user." + MetricNames.HTTP_PROCESS_DURATION, absolute 
= true)
+  @ResponseMetered(name = "add-idp-user", absolute = true)
+  public Response addUser(AddUserRequest request) {
+    return IdpRESTUtils.doAs(
+        httpRequest,
+        () -> {
+          request.validate();
+          return IdpRESTUtils.ok(
+              new IdpUserResponse(
+                  userGroupManager.addUser(request.getUser(), 
request.getPassword()).toDTO()));
+        },
+        "user",
+        IdpOperationType.ADD,
+        request.getUser());
+  }
+
+  @PUT
+  @Path("{user}")
+  @Produces("application/vnd.gravitino.v1+json")
+  @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();
+          userGroupManager.changePassword(user, request.getPassword());
+          return IdpRESTUtils.ok(new 
IdpUserResponse(userGroupManager.getUser(user).toDTO()));
+        },
+        "user",
+        IdpOperationType.UPDATE,
+        user);
+  }
+
+  @DELETE
+  @Path("{user}")
+  @Produces("application/vnd.gravitino.v1+json")
+  @Timed(name = "remove-idp-user." + MetricNames.HTTP_PROCESS_DURATION, 
absolute = true)
+  @ResponseMetered(name = "remove-idp-user", absolute = true)
+  public Response removeUser(@PathParam("user") String user) {
+    return IdpRESTUtils.doAs(
+        httpRequest,
+        () -> IdpRESTUtils.ok(new 
RemoveResponse(userGroupManager.removeUser(user))),
+        "user",
+        IdpOperationType.REMOVE,
+        user);
+  }
+}
diff --git 
a/plugins/idp-basic/src/main/java/org/apache/gravitino/idp/web/rest/feature/IdpRESTFeature.java
 
b/plugins/idp-basic/src/main/java/org/apache/gravitino/idp/web/rest/feature/IdpRESTFeature.java
new file mode 100644
index 0000000000..129a771527
--- /dev/null
+++ 
b/plugins/idp-basic/src/main/java/org/apache/gravitino/idp/web/rest/feature/IdpRESTFeature.java
@@ -0,0 +1,70 @@
+/*
+ * 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.web.rest.feature;
+
+import java.util.List;
+import javax.ws.rs.core.Feature;
+import javax.ws.rs.core.FeatureContext;
+import javax.ws.rs.ext.Provider;
+import org.apache.gravitino.Configs;
+import org.apache.gravitino.GravitinoEnv;
+import org.apache.gravitino.idp.web.rest.IdpAuthorizationFilter;
+import org.apache.gravitino.idp.web.rest.IdpBasicBinder;
+import org.apache.gravitino.idp.web.rest.IdpGroupOperations;
+import org.apache.gravitino.idp.web.rest.IdpUserOperations;
+
+/**
+ * Conditionally registers built-in IdP REST resources when {@code basic} is 
configured in {@link
+ * Configs#AUTHENTICATORS}.
+ *
+ * <p>Configure {@link Configs#REST_API_EXTENSION_PACKAGES} to {@code
+ * org.apache.gravitino.idp.web.rest.feature} so Jersey only auto-discovers 
this feature. IdP REST
+ * resource classes remain in {@code org.apache.gravitino.idp.web.rest} and 
are registered here only
+ * when the {@code basic} authenticator is enabled.
+ */
+@Provider
+public class IdpRESTFeature implements Feature {
+
+  /** Authenticator name that enables built-in IdP management APIs. */
+  public static final String BASIC_AUTHENTICATOR = "basic";
+
+  @Override
+  public boolean configure(FeatureContext context) {
+    if (!basicAuthenticatorEnabled(
+        GravitinoEnv.getInstance().config().get(Configs.AUTHENTICATORS))) {
+      return true;
+    }
+
+    context.register(IdpBasicBinder.class);
+    context.register(IdpUserOperations.class);
+    context.register(IdpGroupOperations.class);
+    context.register(IdpAuthorizationFilter.class);
+    return true;
+  }
+
+  /**
+   * Returns whether built-in IdP management REST APIs should be registered.
+   *
+   * @param authenticators configured authenticator names
+   * @return {@code true} when {@code basic} is included in {@code 
authenticators}
+   */
+  public static boolean basicAuthenticatorEnabled(List<String> authenticators) 
{
+    return authenticators != null && 
authenticators.contains(BASIC_AUTHENTICATOR);
+  }
+}
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 5112e8d555..9bc0d26a57 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
@@ -35,6 +35,7 @@ import java.nio.file.Path;
 import java.util.Comparator;
 import java.util.stream.Stream;
 import org.apache.gravitino.Config;
+import org.apache.gravitino.idp.basic.IdpCredentialValidator;
 import org.apache.gravitino.idp.exception.AlreadyExistsException;
 import org.apache.gravitino.idp.exception.NotFoundException;
 import org.apache.gravitino.idp.model.IdpGroup;
@@ -48,6 +49,16 @@ import org.junit.jupiter.api.Test;
 /** Integration tests for {@link IdpUserGroupManager} backed by an embedded H2 
store. */
 public class TestIdpUserGroupManager {
 
+  private static final String VALID_PASSWORD = "Passw0rd-1234";
+  private static final String ANOTHER_VALID_PASSWORD = "AnotherPass1!";
+  private static final String NEW_VALID_PASSWORD = "New-Password1!";
+
+  static {
+    IdpCredentialValidator.validatePassword(VALID_PASSWORD);
+    IdpCredentialValidator.validatePassword(ANOTHER_VALID_PASSWORD);
+    IdpCredentialValidator.validatePassword(NEW_VALID_PASSWORD);
+  }
+
   private static IdpUserGroupManager manager;
   private static Path h2Path;
 
@@ -86,17 +97,17 @@ public class TestIdpUserGroupManager {
 
   @Test
   public void testAddUser() throws IOException {
-    IdpUser user = manager.addUser("testAdd", "password123");
+    IdpUser user = manager.addUser("testAdd", VALID_PASSWORD);
     Assertions.assertEquals("testAdd", user.name());
     Assertions.assertTrue(user.groupNames().isEmpty());
 
     Assertions.assertThrows(
-        AlreadyExistsException.class, () -> manager.addUser("testAdd", 
"password456"));
+        AlreadyExistsException.class, () -> manager.addUser("testAdd", 
ANOTHER_VALID_PASSWORD));
   }
 
   @Test
   public void testGetUser() throws IOException {
-    manager.addUser("testGet", "password123");
+    manager.addUser("testGet", VALID_PASSWORD);
 
     IdpUser user = manager.getUser("testGet");
     Assertions.assertEquals("testGet", user.name());
@@ -108,7 +119,7 @@ public class TestIdpUserGroupManager {
 
   @Test
   public void testRemoveUser() throws IOException {
-    manager.addUser("testRemove", "password123");
+    manager.addUser("testRemove", VALID_PASSWORD);
 
     Assertions.assertTrue(manager.removeUser("testRemove"));
     Assertions.assertFalse(manager.removeUser("no-exist"));
@@ -116,12 +127,13 @@ public class TestIdpUserGroupManager {
 
   @Test
   public void testChangePassword() throws IOException {
-    manager.addUser("testChangePassword", "password123");
+    manager.addUser("testChangePassword", VALID_PASSWORD);
 
-    Assertions.assertTrue(manager.changePassword("testChangePassword", 
"new-password"));
+    Assertions.assertTrue(manager.changePassword("testChangePassword", 
NEW_VALID_PASSWORD));
     Assertions.assertEquals("testChangePassword", 
manager.getUser("testChangePassword").name());
 
-    Assertions.assertFalse(manager.changePassword("not-exist", "password123"));
+    Assertions.assertThrows(
+        NotFoundException.class, () -> manager.changePassword("not-exist", 
VALID_PASSWORD));
   }
 
   @Test
@@ -147,9 +159,9 @@ public class TestIdpUserGroupManager {
 
   @Test
   public void testChangeGroupMembership() throws IOException {
-    manager.addUser("groupUser1", "password123");
-    manager.addUser("groupUser2", "password123");
-    manager.addUser("groupUser3", "password123");
+    manager.addUser("groupUser1", VALID_PASSWORD);
+    manager.addUser("groupUser2", VALID_PASSWORD);
+    manager.addUser("groupUser3", VALID_PASSWORD);
     manager.addGroup("testMembershipGroup");
 
     IdpGroup group =
@@ -179,7 +191,7 @@ public class TestIdpUserGroupManager {
 
   @Test
   public void testRemoveGroup() throws IOException {
-    manager.addUser("groupMember", "password123");
+    manager.addUser("groupMember", VALID_PASSWORD);
     manager.addGroup("testRemoveGroup");
     manager.changeGroupMembership("testRemoveGroup", 
Lists.newArrayList("groupMember"), null);
 
@@ -190,7 +202,7 @@ public class TestIdpUserGroupManager {
     Assertions.assertTrue(manager.removeGroup("testRemoveGroup", false));
     Assertions.assertFalse(manager.removeGroup("no-exist", false));
 
-    manager.addUser("forceMember", "password123");
+    manager.addUser("forceMember", VALID_PASSWORD);
     manager.addGroup("testForceRemoveGroup");
     manager.changeGroupMembership("testForceRemoveGroup", 
Lists.newArrayList("forceMember"), null);
     Assertions.assertTrue(manager.removeGroup("testForceRemoveGroup", true));
diff --git 
a/plugins/idp-basic/src/test/java/org/apache/gravitino/idp/dto/requests/TestAddUserRequest.java
 
b/plugins/idp-basic/src/test/java/org/apache/gravitino/idp/dto/requests/TestAddUserRequest.java
index cbe3618945..8bd598eb9f 100644
--- 
a/plugins/idp-basic/src/test/java/org/apache/gravitino/idp/dto/requests/TestAddUserRequest.java
+++ 
b/plugins/idp-basic/src/test/java/org/apache/gravitino/idp/dto/requests/TestAddUserRequest.java
@@ -26,16 +26,18 @@ import org.junit.jupiter.api.Test;
 
 public class TestAddUserRequest {
 
+  private static final String VALID_PASSWORD = "Passw0rd-1234";
+
   @Test
   public void testAddUserRequestSerDe() throws JsonProcessingException {
-    AddUserRequest request = new AddUserRequest("test_user", "password");
+    AddUserRequest request = new AddUserRequest("test_user", VALID_PASSWORD);
 
     String serJson = JsonUtils.objectMapper().writeValueAsString(request);
     AddUserRequest deserRequest = JsonUtils.objectMapper().readValue(serJson, 
AddUserRequest.class);
 
     Assertions.assertEquals(request, deserRequest);
     Assertions.assertEquals("test_user", deserRequest.getUser());
-    Assertions.assertEquals("password", deserRequest.getPassword());
+    Assertions.assertEquals(VALID_PASSWORD, deserRequest.getPassword());
 
     // Test with null user and password
     AddUserRequest request1 = new AddUserRequest();
@@ -51,17 +53,22 @@ public class TestAddUserRequest {
 
   @Test
   public void testAddUserRequestValidate() {
-    Assertions.assertDoesNotThrow(() -> new AddUserRequest("test_user", 
"password").validate());
+    Assertions.assertDoesNotThrow(() -> new AddUserRequest("test_user", 
VALID_PASSWORD).validate());
     Assertions.assertThrows(IllegalArgumentException.class, () -> new 
AddUserRequest().validate());
     Assertions.assertThrows(
-        IllegalArgumentException.class, () -> new AddUserRequest(" ", 
"password").validate());
+        IllegalArgumentException.class, () -> new AddUserRequest(" ", 
VALID_PASSWORD).validate());
     Assertions.assertThrows(
         IllegalArgumentException.class, () -> new AddUserRequest("test_user", 
" ").validate());
+    Assertions.assertThrows(
+        IllegalArgumentException.class,
+        () -> new AddUserRequest("user:name", VALID_PASSWORD).validate());
+    Assertions.assertThrows(
+        IllegalArgumentException.class, () -> new AddUserRequest("test_user", 
"short").validate());
   }
 
   @Test
   public void testAddUserRequestToStringDoesNotExposePassword() {
-    String requestString = new AddUserRequest("test_user", 
"password").toString();
+    String requestString = new AddUserRequest("test_user", 
VALID_PASSWORD).toString();
 
     Assertions.assertTrue(requestString.contains("test_user"));
     Assertions.assertFalse(requestString.contains("password="));
diff --git 
a/plugins/idp-basic/src/test/java/org/apache/gravitino/idp/dto/requests/TestResetPasswordRequest.java
 
b/plugins/idp-basic/src/test/java/org/apache/gravitino/idp/dto/requests/TestChangePasswordRequest.java
similarity index 56%
rename from 
plugins/idp-basic/src/test/java/org/apache/gravitino/idp/dto/requests/TestResetPasswordRequest.java
rename to 
plugins/idp-basic/src/test/java/org/apache/gravitino/idp/dto/requests/TestChangePasswordRequest.java
index aa6ed997d7..1a6b57a0fb 100644
--- 
a/plugins/idp-basic/src/test/java/org/apache/gravitino/idp/dto/requests/TestResetPasswordRequest.java
+++ 
b/plugins/idp-basic/src/test/java/org/apache/gravitino/idp/dto/requests/TestChangePasswordRequest.java
@@ -24,44 +24,48 @@ import org.apache.gravitino.json.JsonUtils;
 import org.junit.jupiter.api.Assertions;
 import org.junit.jupiter.api.Test;
 
-public class TestResetPasswordRequest {
+public class TestChangePasswordRequest {
+
+  private static final String VALID_PASSWORD = "new_password12";
 
   @Test
-  public void testResetPasswordRequestSerDe() throws JsonProcessingException {
-    ResetPasswordRequest request = new ResetPasswordRequest("new_password");
+  public void testChangePasswordRequestSerDe() throws JsonProcessingException {
+    ChangePasswordRequest request = new ChangePasswordRequest(VALID_PASSWORD);
 
     String serJson = JsonUtils.objectMapper().writeValueAsString(request);
-    ResetPasswordRequest deserRequest =
-        JsonUtils.objectMapper().readValue(serJson, 
ResetPasswordRequest.class);
+    ChangePasswordRequest deserRequest =
+        JsonUtils.objectMapper().readValue(serJson, 
ChangePasswordRequest.class);
 
     Assertions.assertEquals(request, deserRequest);
-    Assertions.assertEquals("new_password", deserRequest.getPassword());
+    Assertions.assertEquals(VALID_PASSWORD, deserRequest.getPassword());
 
     // Test with null password
-    ResetPasswordRequest request1 = new ResetPasswordRequest();
+    ChangePasswordRequest request1 = new ChangePasswordRequest();
 
     String serJson1 = JsonUtils.objectMapper().writeValueAsString(request1);
-    ResetPasswordRequest deserRequest1 =
-        JsonUtils.objectMapper().readValue(serJson1, 
ResetPasswordRequest.class);
+    ChangePasswordRequest deserRequest1 =
+        JsonUtils.objectMapper().readValue(serJson1, 
ChangePasswordRequest.class);
 
     Assertions.assertEquals(request1, deserRequest1);
     Assertions.assertNull(deserRequest1.getPassword());
   }
 
   @Test
-  public void testResetPasswordRequestValidate() {
-    Assertions.assertDoesNotThrow(() -> new 
ResetPasswordRequest("new_password").validate());
+  public void testChangePasswordRequestValidate() {
+    Assertions.assertDoesNotThrow(() -> new 
ChangePasswordRequest(VALID_PASSWORD).validate());
+    Assertions.assertThrows(
+        IllegalArgumentException.class, () -> new 
ChangePasswordRequest().validate());
     Assertions.assertThrows(
-        IllegalArgumentException.class, () -> new 
ResetPasswordRequest().validate());
+        IllegalArgumentException.class, () -> new ChangePasswordRequest(" 
").validate());
     Assertions.assertThrows(
-        IllegalArgumentException.class, () -> new ResetPasswordRequest(" 
").validate());
+        IllegalArgumentException.class, () -> new 
ChangePasswordRequest("short").validate());
   }
 
   @Test
-  public void testResetPasswordRequestToStringDoesNotExposePassword() {
-    String requestString = new ResetPasswordRequest("new_password").toString();
+  public void testChangePasswordRequestToStringDoesNotExposePassword() {
+    String requestString = new 
ChangePasswordRequest(VALID_PASSWORD).toString();
 
-    Assertions.assertFalse(requestString.contains("new_password"));
+    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/storage/mapper/TestIdpUserMetaStorage.java
 
b/plugins/idp-basic/src/test/java/org/apache/gravitino/idp/storage/mapper/TestIdpUserMetaStorage.java
index 189d93aaca..a99430d8d9 100644
--- 
a/plugins/idp-basic/src/test/java/org/apache/gravitino/idp/storage/mapper/TestIdpUserMetaStorage.java
+++ 
b/plugins/idp-basic/src/test/java/org/apache/gravitino/idp/storage/mapper/TestIdpUserMetaStorage.java
@@ -127,7 +127,7 @@ class TestIdpUserMetaStorage extends 
AbstractIdpMetaStorageTest {
     assertEquals("hash-a-2", 
idpUserMetaMapper.selectIdpUser("alice").getPasswordHash());
     assertEquals(3L, 
idpUserMetaMapper.selectIdpUser("alice").getCurrentVersion());
     assertEquals(2L, 
idpUserMetaMapper.selectIdpUser("alice").getLastVersion());
-    assertEquals(0, idpUserMetaMapper.updateIdpUserPassword("alice", 
"hash-a-2"));
+    assertEquals(1, idpUserMetaMapper.updateIdpUserPassword("alice", 
"hash-a-2"));
   }
 
   @ParameterizedTest
diff --git 
a/plugins/idp-basic/src/test/java/org/apache/gravitino/idp/storage/service/TestIdpUserMetaService.java
 
b/plugins/idp-basic/src/test/java/org/apache/gravitino/idp/storage/service/TestIdpUserMetaService.java
index 7af936a0a5..d1c7422f5b 100644
--- 
a/plugins/idp-basic/src/test/java/org/apache/gravitino/idp/storage/service/TestIdpUserMetaService.java
+++ 
b/plugins/idp-basic/src/test/java/org/apache/gravitino/idp/storage/service/TestIdpUserMetaService.java
@@ -19,7 +19,6 @@
 package org.apache.gravitino.idp.storage.service;
 
 import static org.junit.jupiter.api.Assertions.assertEquals;
-import static org.junit.jupiter.api.Assertions.assertFalse;
 import static org.junit.jupiter.api.Assertions.assertIterableEquals;
 import static org.junit.jupiter.api.Assertions.assertNull;
 import static org.junit.jupiter.api.Assertions.assertThrows;
@@ -92,13 +91,16 @@ class TestIdpUserMetaService extends 
AbstractIdpMetaServiceTest {
     insertUsers(1);
     IdpUserMetaService userMetaService = IdpUserMetaService.getInstance();
 
-    runServiceCall(() -> 
assertFalse(userMetaService.updateIdpUserPassword("missing", "hash-2")));
+    closeSession();
+    assertThrows(
+        NotFoundException.class, () -> 
userMetaService.updateIdpUserPassword("missing", "hash-2"));
+    refreshSession();
 
     runServiceCall(() -> 
assertTrue(userMetaService.updateIdpUserPassword("user1", "hash-2")));
     assertEquals("hash-2", 
userMetaService.getIdpUserByUsername("user1").getPasswordHash());
     assertEquals(1L, 
userMetaService.getIdpUserByUsername("user1").getCurrentVersion());
 
-    runServiceCall(() -> 
assertFalse(userMetaService.updateIdpUserPassword("user1", "hash-2")));
+    runServiceCall(() -> 
assertTrue(userMetaService.updateIdpUserPassword("user1", "hash-2")));
   }
 
   @ParameterizedTest
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
new file mode 100644
index 0000000000..5fb8167357
--- /dev/null
+++ 
b/plugins/idp-basic/src/test/java/org/apache/gravitino/idp/web/rest/TestIdpAuthorizationFilter.java
@@ -0,0 +1,36 @@
+/*
+ * 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.web.rest;
+
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.util.List;
+import org.junit.jupiter.api.Test;
+
+class TestIdpAuthorizationFilter {
+
+  @Test
+  void testIsServiceAdmin() {
+    assertFalse(IdpAuthorizationFilter.isServiceAdmin(null, "admin"));
+    assertFalse(IdpAuthorizationFilter.isServiceAdmin(List.of("admin"), null));
+    assertFalse(IdpAuthorizationFilter.isServiceAdmin(List.of("admin"), 
"other"));
+    assertTrue(IdpAuthorizationFilter.isServiceAdmin(List.of("admin", "ops"), 
"admin"));
+  }
+}
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
new file mode 100644
index 0000000000..79427a63e1
--- /dev/null
+++ 
b/plugins/idp-basic/src/test/java/org/apache/gravitino/idp/web/rest/TestIdpOperations.java
@@ -0,0 +1,229 @@
+/*
+ * 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.web.rest;
+
+import static org.mockito.Mockito.doReturn;
+import static org.mockito.Mockito.doThrow;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.reset;
+import static org.mockito.Mockito.when;
+
+import java.io.IOException;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+import javax.servlet.http.HttpServletRequest;
+import javax.ws.rs.client.Entity;
+import javax.ws.rs.client.Invocation;
+import javax.ws.rs.core.Application;
+import javax.ws.rs.core.MediaType;
+import javax.ws.rs.core.Response;
+import org.apache.gravitino.dto.responses.ErrorConstants;
+import org.apache.gravitino.dto.responses.ErrorResponse;
+import org.apache.gravitino.dto.responses.RemoveResponse;
+import org.apache.gravitino.idp.IdpUserGroupManager;
+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.responses.IdpGroupResponse;
+import org.apache.gravitino.idp.dto.responses.IdpUserResponse;
+import org.apache.gravitino.idp.exception.AlreadyExistsException;
+import org.apache.gravitino.idp.exception.NotFoundException;
+import org.apache.gravitino.idp.model.IdpGroup;
+import org.apache.gravitino.idp.model.IdpUser;
+import org.apache.gravitino.rest.RESTUtils;
+import org.glassfish.hk2.utilities.binding.AbstractBinder;
+import org.glassfish.jersey.server.ResourceConfig;
+import org.glassfish.jersey.test.JerseyTest;
+import org.glassfish.jersey.test.TestProperties;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+class TestIdpOperations extends JerseyTest {
+
+  private static final String ACCEPT = "application/vnd.gravitino.v1+json";
+  private static final String VALID_PASSWORD = "Passw0rd-For-User";
+  private static final IdpUserGroupManager MANAGER = 
mock(IdpUserGroupManager.class);
+
+  @BeforeEach
+  void resetManager() {
+    reset(MANAGER);
+  }
+
+  @Override
+  protected Application configure() {
+    try {
+      forceSet(
+          TestProperties.CONTAINER_PORT, 
String.valueOf(RESTUtils.findAvailablePort(2000, 4000)));
+    } catch (IOException e) {
+      throw new RuntimeException(e);
+    }
+
+    HttpServletRequest request = mock(HttpServletRequest.class);
+    when(request.getRemoteUser()).thenReturn(null);
+
+    ResourceConfig resourceConfig = new ResourceConfig();
+    resourceConfig.register(IdpUserOperations.class);
+    resourceConfig.register(IdpGroupOperations.class);
+    resourceConfig.register(new IdpAuthorizationFilter(() -> List.of("admin"), 
() -> "admin"));
+    resourceConfig.register(
+        new AbstractBinder() {
+          @Override
+          protected void configure() {
+            bind(MANAGER).to(IdpUserGroupManager.class);
+            bind(request).to(HttpServletRequest.class);
+          }
+        });
+    return resourceConfig;
+  }
+
+  @Test
+  void testAddUser() throws Exception {
+    AddUserRequest req = new AddUserRequest("user1", VALID_PASSWORD);
+    doReturn(buildUser("user1")).when(MANAGER).addUser("user1", 
VALID_PASSWORD);
+
+    assertError(
+        Response.Status.BAD_REQUEST,
+        post("/idp/users", new AddUserRequest("", VALID_PASSWORD)),
+        ErrorConstants.ILLEGAL_ARGUMENTS_CODE);
+
+    Assertions.assertEquals(
+        "user1", post("/idp/users", 
req).readEntity(IdpUserResponse.class).getUser().name());
+
+    doThrow(new AlreadyExistsException("mock error"))
+        .when(MANAGER)
+        .addUser("user1", VALID_PASSWORD);
+    assertStatus(Response.Status.CONFLICT, post("/idp/users", req));
+  }
+
+  @Test
+  void testGetUser() {
+    when(MANAGER.getUser("user1")).thenReturn(buildUser("user1"));
+    Assertions.assertEquals(
+        "user1", 
get("/idp/users/user1").readEntity(IdpUserResponse.class).getUser().name());
+
+    reset(MANAGER);
+    when(MANAGER.getUser("user1")).thenThrow(new NotFoundException("mock 
error"));
+    assertStatus(Response.Status.NOT_FOUND, get("/idp/users/user1"));
+  }
+
+  @Test
+  void testChangePasswordAndRemoveUser() {
+    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());
+    
Assertions.assertTrue(delete("/idp/users/user1").readEntity(RemoveResponse.class).removed());
+  }
+
+  @Test
+  void testAddAndGetGroup() throws Exception {
+    AddGroupRequest req = new AddGroupRequest("group1");
+    doReturn(buildGroup("group1")).when(MANAGER).addGroup("group1");
+    when(MANAGER.getGroup("group1")).thenReturn(buildGroup("group1"));
+
+    assertStatus(Response.Status.OK, post("/idp/groups", req));
+    Assertions.assertEquals(
+        "group1", 
get("/idp/groups/group1").readEntity(IdpGroupResponse.class).getGroup().name());
+
+    doThrow(new AlreadyExistsException("mock 
error")).when(MANAGER).addGroup("group1");
+    assertStatus(Response.Status.CONFLICT, post("/idp/groups", req));
+  }
+
+  @Test
+  void testGetGroupNotFound() {
+    when(MANAGER.getGroup("group1")).thenThrow(new NotFoundException("mock 
error"));
+    assertError(
+        Response.Status.NOT_FOUND, get("/idp/groups/group1"), 
ErrorConstants.NOT_FOUND_CODE);
+  }
+
+  @Test
+  void testAddAndRemoveGroupUsers() {
+    GroupMembershipChangeRequest addReq =
+        new GroupMembershipChangeRequest(new String[] {"user1", "user2"}, 
null);
+    when(MANAGER.changeGroupMembership("group1", Arrays.asList("user1", 
"user2"), null))
+        .thenReturn(buildGroup("group1", Arrays.asList("user1", "user2")));
+    assertStatus(Response.Status.OK, put("/idp/groups/group1/users", addReq));
+
+    GroupMembershipChangeRequest removeReq =
+        new GroupMembershipChangeRequest(null, new String[] {"user1", 
"user2"});
+    when(MANAGER.changeGroupMembership("group1", null, Arrays.asList("user1", 
"user2")))
+        .thenReturn(buildGroup("group1"));
+    assertStatus(Response.Status.OK, put("/idp/groups/group1/users", 
removeReq));
+  }
+
+  @Test
+  void testRemoveGroup() {
+    when(MANAGER.removeGroup("group1", true)).thenReturn(true);
+    Assertions.assertTrue(
+        target("/idp/groups/group1")
+            .queryParam("force", true)
+            .request(MediaType.APPLICATION_JSON_TYPE)
+            .accept(ACCEPT)
+            .delete()
+            .readEntity(RemoveResponse.class)
+            .removed());
+  }
+
+  private Invocation.Builder request(String path) {
+    return 
target(path).request(MediaType.APPLICATION_JSON_TYPE).accept(ACCEPT);
+  }
+
+  private Response get(String path) {
+    return request(path).get();
+  }
+
+  private Response post(String path, Object body) {
+    return request(path).post(Entity.entity(body, 
MediaType.APPLICATION_JSON_TYPE));
+  }
+
+  private Response put(String path, Object body) {
+    return request(path).put(Entity.entity(body, 
MediaType.APPLICATION_JSON_TYPE));
+  }
+
+  private Response delete(String path) {
+    return request(path).delete();
+  }
+
+  private void assertStatus(Response.Status expected, Response response) {
+    Assertions.assertEquals(expected.getStatusCode(), response.getStatus());
+  }
+
+  private void assertError(Response.Status expected, Response response, int 
code) {
+    assertStatus(expected, response);
+    Assertions.assertEquals(code, 
response.readEntity(ErrorResponse.class).getCode());
+  }
+
+  private IdpUser buildUser(String user) {
+    return new IdpUser(user, Collections.emptyList());
+  }
+
+  private IdpGroup buildGroup(String group) {
+    return buildGroup(group, Collections.emptyList());
+  }
+
+  private IdpGroup buildGroup(String group, List<String> users) {
+    return new IdpGroup(group, users);
+  }
+}
diff --git 
a/plugins/idp-basic/src/test/java/org/apache/gravitino/idp/web/rest/TestIdpRestExtension.java
 
b/plugins/idp-basic/src/test/java/org/apache/gravitino/idp/web/rest/TestIdpRestExtension.java
new file mode 100644
index 0000000000..b13c97ca07
--- /dev/null
+++ 
b/plugins/idp-basic/src/test/java/org/apache/gravitino/idp/web/rest/TestIdpRestExtension.java
@@ -0,0 +1,180 @@
+/*
+ * 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.web.rest;
+
+import static org.apache.gravitino.Configs.CACHE_ENABLED;
+import static org.apache.gravitino.Configs.ENABLE_AUTHORIZATION;
+import static 
org.apache.gravitino.Configs.ENTITY_RELATIONAL_JDBC_BACKEND_DRIVER;
+import static 
org.apache.gravitino.Configs.ENTITY_RELATIONAL_JDBC_BACKEND_MAX_CONNECTIONS;
+import static org.apache.gravitino.Configs.ENTITY_RELATIONAL_JDBC_BACKEND_URL;
+import static 
org.apache.gravitino.Configs.ENTITY_RELATIONAL_JDBC_BACKEND_WAIT_MILLISECONDS;
+import static org.apache.gravitino.Configs.ENTITY_STORE;
+import static org.apache.gravitino.Configs.RELATIONAL_ENTITY_STORE;
+import static org.apache.gravitino.Configs.REST_API_EXTENSION_PACKAGES;
+import static org.apache.gravitino.Configs.STORE_DELETE_AFTER_TIME;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.google.common.collect.ImmutableMap;
+import java.io.IOException;
+import java.net.URI;
+import java.net.http.HttpClient;
+import java.net.http.HttpRequest;
+import java.net.http.HttpResponse;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import org.apache.commons.io.FileUtils;
+import org.apache.gravitino.Configs;
+import org.apache.gravitino.GravitinoEnv;
+import org.apache.gravitino.auxiliary.AuxiliaryServiceManager;
+import org.apache.gravitino.dto.responses.ErrorConstants;
+import org.apache.gravitino.json.JsonUtils;
+import org.apache.gravitino.rest.RESTUtils;
+import org.apache.gravitino.server.GravitinoServer;
+import org.apache.gravitino.server.ServerConfig;
+import org.apache.gravitino.server.web.JettyServerConfig;
+import org.junit.jupiter.api.AfterAll;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.TestInstance;
+import org.junit.jupiter.api.TestInstance.Lifecycle;
+import org.mockito.Mockito;
+
+/** Verifies IdP REST resources load via {@link 
Configs#REST_API_EXTENSION_PACKAGES}. */
+@TestInstance(Lifecycle.PER_CLASS)
+public class TestIdpRestExtension {
+
+  private static final String ACCEPT = "application/vnd.gravitino.v1+json";
+  private static final String IDP_REST_EXTENSION_PACKAGE =
+      "org.apache.gravitino.idp.web.rest.feature";
+  private static final HttpClient HTTP = HttpClient.newHttpClient();
+
+  private int httpPort;
+  private Path h2Path;
+
+  @BeforeAll
+  void init() throws IOException {
+    httpPort = RESTUtils.findAvailablePort(5000, 6000);
+    h2Path = Files.createTempDirectory("gravitino_idp_rest_extension_it_");
+  }
+
+  @AfterAll
+  void cleanup() throws IOException {
+    if (h2Path != null) {
+      FileUtils.deleteDirectory(h2Path.toFile());
+    }
+  }
+
+  @Test
+  public void testWithoutExtension() throws Exception {
+    try (ServerHarness harness = startServer(newConfig(false))) {
+      HttpResponse<String> response = getUser(harness.port(), "missing-user");
+      assertEquals(404, response.statusCode());
+      assertFalse(isNotFoundError(response.body()));
+    }
+  }
+
+  @Test
+  public void testWithExtensionWithoutBasicAuthenticator() throws Exception {
+    try (ServerHarness harness = startServer(newConfig(true))) {
+      HttpResponse<String> response = getUser(harness.port(), "missing-user");
+      assertEquals(404, response.statusCode());
+      assertFalse(
+          isNotFoundError(response.body()),
+          "IdP REST routes should not be registered when basic authenticator 
is disabled");
+    }
+  }
+
+  private ServerConfig newConfig(boolean enableExtension) {
+    ImmutableMap.Builder<String, String> builder =
+        ImmutableMap.<String, String>builder()
+            .put(
+                GravitinoServer.WEBSERVER_CONF_PREFIX
+                    + JettyServerConfig.WEBSERVER_HTTP_PORT.getKey(),
+                String.valueOf(httpPort))
+            .put(ENTITY_STORE.getKey(), RELATIONAL_ENTITY_STORE)
+            .put(
+                ENTITY_RELATIONAL_JDBC_BACKEND_URL.getKey(),
+                String.format("jdbc:h2:file:%s;DB_CLOSE_DELAY=-1;MODE=MYSQL", 
h2Path))
+            .put(ENTITY_RELATIONAL_JDBC_BACKEND_DRIVER.getKey(), 
"org.h2.Driver")
+            .put(ENTITY_RELATIONAL_JDBC_BACKEND_MAX_CONNECTIONS.getKey(), 
"100")
+            .put(ENTITY_RELATIONAL_JDBC_BACKEND_WAIT_MILLISECONDS.getKey(), 
"1000")
+            .put(STORE_DELETE_AFTER_TIME.getKey(), String.valueOf(20 * 60 * 
1000L))
+            .put(CACHE_ENABLED.getKey(), "false")
+            .put(ENABLE_AUTHORIZATION.getKey(), "false");
+    if (enableExtension) {
+      builder.put(REST_API_EXTENSION_PACKAGES.getKey(), 
IDP_REST_EXTENSION_PACKAGE);
+    }
+
+    ServerConfig serverConfig = new ServerConfig();
+    serverConfig.loadFromMap(builder.build(), key -> true);
+    ServerConfig spyServerConfig = Mockito.spy(serverConfig);
+    Mockito.when(
+            spyServerConfig.getConfigsWithPrefix(
+                AuxiliaryServiceManager.GRAVITINO_AUX_SERVICE_PREFIX))
+        .thenReturn(ImmutableMap.of(AuxiliaryServiceManager.AUX_SERVICE_NAMES, 
""));
+    return spyServerConfig;
+  }
+
+  private ServerHarness startServer(ServerConfig serverConfig) throws 
Exception {
+    GravitinoServer gravitinoServer = new GravitinoServer(serverConfig, 
GravitinoEnv.getInstance());
+    gravitinoServer.initialize();
+    gravitinoServer.start();
+    return new ServerHarness(gravitinoServer, httpPort);
+  }
+
+  private HttpResponse<String> getUser(int port, String user) throws Exception 
{
+    return send(
+        HttpRequest.newBuilder()
+            
.uri(URI.create(String.format("http://localhost:%d/api/idp/users/%s";, port, 
user)))
+            .header("Accept", ACCEPT)
+            .GET()
+            .build());
+  }
+
+  private HttpResponse<String> send(HttpRequest request) throws Exception {
+    return HTTP.send(request, HttpResponse.BodyHandlers.ofString());
+  }
+
+  private boolean isNotFoundError(String body) throws IOException {
+    JsonNode root = JsonUtils.objectMapper().readTree(body);
+    return root.has("code") && root.get("code").asInt() == 
ErrorConstants.NOT_FOUND_CODE;
+  }
+
+  private static final class ServerHarness implements AutoCloseable {
+
+    private final GravitinoServer gravitinoServer;
+    private final int port;
+
+    private ServerHarness(GravitinoServer gravitinoServer, int port) {
+      this.gravitinoServer = gravitinoServer;
+      this.port = port;
+    }
+
+    private int port() {
+      return port;
+    }
+
+    @Override
+    public void close() throws IOException {
+      gravitinoServer.stop();
+    }
+  }
+}
diff --git 
a/plugins/idp-basic/src/test/java/org/apache/gravitino/idp/web/rest/feature/TestIdpRESTFeature.java
 
b/plugins/idp-basic/src/test/java/org/apache/gravitino/idp/web/rest/feature/TestIdpRESTFeature.java
new file mode 100644
index 0000000000..b9ca933e72
--- /dev/null
+++ 
b/plugins/idp-basic/src/test/java/org/apache/gravitino/idp/web/rest/feature/TestIdpRESTFeature.java
@@ -0,0 +1,37 @@
+/*
+ * 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.web.rest.feature;
+
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.util.List;
+import org.junit.jupiter.api.Test;
+
+class TestIdpRESTFeature {
+
+  @Test
+  void testBasicAuthenticatorEnabled() {
+    assertFalse(IdpRESTFeature.basicAuthenticatorEnabled(null));
+    assertFalse(IdpRESTFeature.basicAuthenticatorEnabled(List.of("simple")));
+    assertTrue(
+        IdpRESTFeature.basicAuthenticatorEnabled(
+            List.of("simple", IdpRESTFeature.BASIC_AUTHENTICATOR)));
+  }
+}

Reply via email to