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)));
+ }
+}