diff --git a/core/src/main/java/com/datastrato/gravitino/Configs.java b/core/src/main/java/com/datastrato/gravitino/Configs.java index 95f9780d725..90373760870 100644 --- a/core/src/main/java/com/datastrato/gravitino/Configs.java +++ b/core/src/main/java/com/datastrato/gravitino/Configs.java @@ -9,6 +9,7 @@ import com.datastrato.gravitino.config.ConfigEntry; import com.google.common.collect.Lists; import java.io.File; +import java.util.List; import org.apache.commons.lang3.StringUtils; public interface Configs { @@ -239,4 +240,20 @@ public interface Configs { .version(ConfigConstants.VERSION_0_4_0) .longConf() .createWithDefault(CLEAN_INTERVAL_IN_SECS); + + ConfigEntry ENABLE_AUTHORIZATION = + new ConfigBuilder("gravitino.authorization.enable") + .doc("Enable the authorization") + .version(ConfigConstants.VERSION_0_5_0) + .booleanConf() + .createWithDefault(false); + + ConfigEntry> SERVICE_ADMINS = + new ConfigBuilder("gravitino.authorization.serviceAdmins") + .doc("The admins of Gravitino service") + .version(ConfigConstants.VERSION_0_5_0) + .stringConf() + .checkValue(StringUtils::isNotBlank, ConfigConstants.NOT_BLANK_ERROR_MSG) + .toSequence() + .create(); } diff --git a/core/src/main/java/com/datastrato/gravitino/Entity.java b/core/src/main/java/com/datastrato/gravitino/Entity.java index 64e9632a0bb..af2c1872000 100644 --- a/core/src/main/java/com/datastrato/gravitino/Entity.java +++ b/core/src/main/java/com/datastrato/gravitino/Entity.java @@ -13,6 +13,28 @@ /** This interface defines an entity within the Gravitino framework. */ public interface Entity extends Serializable { + // The below constants are used for virtual metalakes, catalogs and schemas + // The system doesn't need to create them. The system uses these constants + // to organize the system information better. + + /** The system reserved metalake name. */ + String SYSTEM_METALAKE_RESERVED_NAME = "system"; + + /** The system reserved catalog name. */ + String SYSTEM_CATALOG_RESERVED_NAME = "system"; + + /** The authorization catalog name in the system metalake. */ + String AUTHORIZATION_CATALOG_NAME = "authorization"; + + /** The user schema name in the system catalog. */ + String USER_SCHEMA_NAME = "user"; + + /** The group schema name in the system catalog. */ + String GROUP_SCHEMA_NAME = "group"; + + /** The admin schema name in the authorization catalog of the system metalake. */ + String ADMIN_SCHEMA_NAME = "admin"; + /** Enumeration defining the types of entities in the Gravitino framework. */ @Getter enum EntityType { diff --git a/core/src/main/java/com/datastrato/gravitino/GravitinoEnv.java b/core/src/main/java/com/datastrato/gravitino/GravitinoEnv.java index 2eba11037df..fca1abec7e9 100644 --- a/core/src/main/java/com/datastrato/gravitino/GravitinoEnv.java +++ b/core/src/main/java/com/datastrato/gravitino/GravitinoEnv.java @@ -116,7 +116,12 @@ public void initialize(Config config) { new TopicOperationDispatcher(catalogManager, entityStore, idGenerator); // Create and initialize access control related modules - this.accessControlManager = new AccessControlManager(entityStore, idGenerator); + boolean enableAuthorization = config.get(Configs.ENABLE_AUTHORIZATION); + if (enableAuthorization) { + this.accessControlManager = new AccessControlManager(entityStore, idGenerator, config); + } else { + this.accessControlManager = null; + } this.auxServiceManager = new AuxiliaryServiceManager(); this.auxServiceManager.serviceInit( diff --git a/core/src/main/java/com/datastrato/gravitino/authorization/AccessControlManager.java b/core/src/main/java/com/datastrato/gravitino/authorization/AccessControlManager.java index 749f8adcb29..ede33c6e5cc 100644 --- a/core/src/main/java/com/datastrato/gravitino/authorization/AccessControlManager.java +++ b/core/src/main/java/com/datastrato/gravitino/authorization/AccessControlManager.java @@ -4,6 +4,7 @@ */ package com.datastrato.gravitino.authorization; +import com.datastrato.gravitino.Config; import com.datastrato.gravitino.EntityStore; import com.datastrato.gravitino.exceptions.GroupAlreadyExistsException; import com.datastrato.gravitino.exceptions.NoSuchGroupException; @@ -12,15 +13,17 @@ import com.datastrato.gravitino.storage.IdGenerator; /** - * AccessControlManager is used for manage users, roles, grant information, this class is an + * AccessControlManager is used for manage users, roles, admin, grant information, this class is an * entrance class for tenant management. */ public class AccessControlManager { private final UserGroupManager userGroupManager; + private final AdminManager adminManager; - public AccessControlManager(EntityStore store, IdGenerator idGenerator) { + public AccessControlManager(EntityStore store, IdGenerator idGenerator, Config config) { this.userGroupManager = new UserGroupManager(store, idGenerator); + this.adminManager = new AdminManager(store, idGenerator, config); } /** @@ -98,4 +101,47 @@ public boolean removeGroup(String metalake, String group) { public Group getGroup(String metalake, String group) throws NoSuchGroupException { return userGroupManager.getGroup(metalake, group); } + + /** + * Adds a new metalake admin. + * + * @param user The name of the User. + * @return The added User instance. + * @throws UserAlreadyExistsException If a User with the same identifier already exists. + * @throws RuntimeException If adding the User encounters storage issues. + */ + public User addMetalakeAdmin(String user) { + return adminManager.addMetalakeAdmin(user); + } + + /** + * Removes a metalake admin. + * + * @param user The name of the User. + * @return `true` if the User was successfully removed, `false` otherwise. + * @throws RuntimeException If removing the User encounters storage issues. + */ + public boolean removeMetalakeAdmin(String user) { + return adminManager.removeMetalakeAdmin(user); + } + + /** + * Judges whether the user is the service admin. + * + * @param user the name of the user + * @return true, if the user is service admin, otherwise false. + */ + public boolean isServiceAdmin(String user) { + return adminManager.isServiceAdmin(user); + } + + /** + * Judges whether the user is the metalake admin. + * + * @param user the name of the user + * @return true, if the user is metalake admin, otherwise false. + */ + public boolean isMetalakeAdmin(String user) { + return adminManager.isMetalakeAdmin(user); + } } diff --git a/core/src/main/java/com/datastrato/gravitino/authorization/AdminManager.java b/core/src/main/java/com/datastrato/gravitino/authorization/AdminManager.java new file mode 100644 index 00000000000..3198ca1080f --- /dev/null +++ b/core/src/main/java/com/datastrato/gravitino/authorization/AdminManager.java @@ -0,0 +1,135 @@ +/* + * Copyright 2024 Datastrato Pvt Ltd. + * This software is licensed under the Apache License version 2. + */ +package com.datastrato.gravitino.authorization; + +import com.datastrato.gravitino.Config; +import com.datastrato.gravitino.Configs; +import com.datastrato.gravitino.Entity; +import com.datastrato.gravitino.EntityAlreadyExistsException; +import com.datastrato.gravitino.EntityStore; +import com.datastrato.gravitino.NameIdentifier; +import com.datastrato.gravitino.Namespace; +import com.datastrato.gravitino.exceptions.UserAlreadyExistsException; +import com.datastrato.gravitino.meta.AuditInfo; +import com.datastrato.gravitino.meta.UserEntity; +import com.datastrato.gravitino.storage.IdGenerator; +import com.datastrato.gravitino.utils.PrincipalUtils; +import com.google.common.collect.Lists; +import java.io.IOException; +import java.time.Instant; +import java.util.List; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * There are two kinds of admin roles in the system: service admin and metalake admin. The service + * admin is configured instead of managing by APIs. It is responsible for creating metalake admin. + * If Gravitino enables authorization, service admin is required. Metalake admin can create a + * metalake or drops its metalake. The metalake admin will be responsible for managing the access + * control. AdminManager operates underlying store using the lock because kv storage needs the lock. + */ +public class AdminManager { + + private static final Logger LOG = LoggerFactory.getLogger(AdminManager.class); + + private final EntityStore store; + private final IdGenerator idGenerator; + private final List serviceAdmins; + + public AdminManager(EntityStore store, IdGenerator idGenerator, Config config) { + this.store = store; + this.idGenerator = idGenerator; + this.serviceAdmins = config.get(Configs.SERVICE_ADMINS); + } + + /** + * Adds a new metalake admin. + * + * @param user The name of the User. + * @return The added User instance. + * @throws UserAlreadyExistsException If a User with the same identifier already exists. + * @throws RuntimeException If adding the User encounters storage issues. + */ + public synchronized User addMetalakeAdmin(String user) { + + UserEntity userEntity = + UserEntity.builder() + .withId(idGenerator.nextId()) + .withName(user) + .withNamespace( + Namespace.of( + Entity.SYSTEM_METALAKE_RESERVED_NAME, + Entity.AUTHORIZATION_CATALOG_NAME, + Entity.ADMIN_SCHEMA_NAME)) + .withRoles(Lists.newArrayList()) + .withAuditInfo( + AuditInfo.builder() + .withCreator(PrincipalUtils.getCurrentPrincipal().getName()) + .withCreateTime(Instant.now()) + .build()) + .build(); + try { + store.put(userEntity, false /* overwritten */); + return userEntity; + } catch (EntityAlreadyExistsException e) { + LOG.warn("User {} in the metalake admin already exists", user, e); + throw new UserAlreadyExistsException("User %s in the metalake admin already exists", user); + } catch (IOException ioe) { + LOG.error("Adding user {} failed to the metalake admin due to storage issues", user, ioe); + throw new RuntimeException(ioe); + } + } + + /** + * Removes a metalake admin. + * + * @param user The name of the User. + * @return `true` if the User was successfully removed, `false` otherwise. + * @throws RuntimeException If removing the User encounters storage issues. + */ + public synchronized boolean removeMetalakeAdmin(String user) { + try { + return store.delete(ofMetalakeAdmin(user), Entity.EntityType.USER); + } catch (IOException ioe) { + LOG.error( + "Removing user {} from the metalake admin {} failed due to storage issues", user, ioe); + throw new RuntimeException(ioe); + } + } + + /** + * Judges whether the user is the service admin. + * + * @param user the name of the user + * @return true, if the user is service admin, otherwise false. + */ + public boolean isServiceAdmin(String user) { + return serviceAdmins.contains(user); + } + + /** + * Judges whether the user is the metalake admin. + * + * @param user the name of the user + * @return true, if the user is metalake admin, otherwise false. + */ + public synchronized boolean isMetalakeAdmin(String user) { + try { + return store.exists(ofMetalakeAdmin(user), Entity.EntityType.USER); + } catch (IOException ioe) { + LOG.error( + "Fail to check whether {} is the metalake admin {} due to storage issues", user, ioe); + throw new RuntimeException(ioe); + } + } + + private NameIdentifier ofMetalakeAdmin(String user) { + return NameIdentifier.of( + Entity.SYSTEM_METALAKE_RESERVED_NAME, + Entity.AUTHORIZATION_CATALOG_NAME, + Entity.ADMIN_SCHEMA_NAME, + user); + } +} diff --git a/core/src/main/java/com/datastrato/gravitino/authorization/UserGroupManager.java b/core/src/main/java/com/datastrato/gravitino/authorization/UserGroupManager.java index 4233b4ec99a..e036030382c 100644 --- a/core/src/main/java/com/datastrato/gravitino/authorization/UserGroupManager.java +++ b/core/src/main/java/com/datastrato/gravitino/authorization/UserGroupManager.java @@ -15,7 +15,6 @@ import com.datastrato.gravitino.exceptions.NoSuchUserException; import com.datastrato.gravitino.exceptions.UserAlreadyExistsException; import com.datastrato.gravitino.meta.AuditInfo; -import com.datastrato.gravitino.meta.CatalogEntity; import com.datastrato.gravitino.meta.GroupEntity; import com.datastrato.gravitino.meta.UserEntity; import com.datastrato.gravitino.storage.IdGenerator; @@ -65,9 +64,7 @@ public User addUser(String metalake, String name) throws UserAlreadyExistsExcept .withName(name) .withNamespace( Namespace.of( - metalake, - CatalogEntity.SYSTEM_CATALOG_RESERVED_NAME, - UserEntity.USER_SCHEMA_NAME)) + metalake, Entity.SYSTEM_CATALOG_RESERVED_NAME, Entity.USER_SCHEMA_NAME)) .withRoles(Lists.newArrayList()) .withAuditInfo( AuditInfo.builder() @@ -145,9 +142,7 @@ public Group addGroup(String metalake, String group) throws GroupAlreadyExistsEx .withName(group) .withNamespace( Namespace.of( - metalake, - CatalogEntity.SYSTEM_CATALOG_RESERVED_NAME, - GroupEntity.GROUP_SCHEMA_NAME)) + metalake, Entity.SYSTEM_CATALOG_RESERVED_NAME, Entity.GROUP_SCHEMA_NAME)) .withRoles(Collections.emptyList()) .withAuditInfo( AuditInfo.builder() @@ -213,11 +208,11 @@ public Group getGroup(String metalake, String group) { private NameIdentifier ofUser(String metalake, String user) { return NameIdentifier.of( - metalake, CatalogEntity.SYSTEM_CATALOG_RESERVED_NAME, UserEntity.USER_SCHEMA_NAME, user); + metalake, Entity.SYSTEM_CATALOG_RESERVED_NAME, Entity.USER_SCHEMA_NAME, user); } private NameIdentifier ofGroup(String metalake, String group) { return NameIdentifier.of( - metalake, CatalogEntity.SYSTEM_CATALOG_RESERVED_NAME, GroupEntity.GROUP_SCHEMA_NAME, group); + metalake, Entity.SYSTEM_CATALOG_RESERVED_NAME, Entity.GROUP_SCHEMA_NAME, group); } } diff --git a/core/src/main/java/com/datastrato/gravitino/catalog/CatalogManager.java b/core/src/main/java/com/datastrato/gravitino/catalog/CatalogManager.java index 9f2447640eb..093fc89dfd1 100644 --- a/core/src/main/java/com/datastrato/gravitino/catalog/CatalogManager.java +++ b/core/src/main/java/com/datastrato/gravitino/catalog/CatalogManager.java @@ -15,6 +15,7 @@ import com.datastrato.gravitino.CatalogProvider; import com.datastrato.gravitino.Config; import com.datastrato.gravitino.Configs; +import com.datastrato.gravitino.Entity; import com.datastrato.gravitino.Entity.EntityType; import com.datastrato.gravitino.EntityAlreadyExistsException; import com.datastrato.gravitino.EntityStore; @@ -287,7 +288,7 @@ public Catalog createCatalog( Map properties) throws NoSuchMetalakeException, CatalogAlreadyExistsException { - if (CatalogEntity.SYSTEM_CATALOG_RESERVED_NAME.equals(ident.name())) { + if (Entity.SYSTEM_CATALOG_RESERVED_NAME.equals(ident.name())) { throw new IllegalArgumentException("Can't create a catalog with with reserved name `system`"); } @@ -678,7 +679,7 @@ private CatalogEntity.Builder updateEntity( if (change instanceof CatalogChange.RenameCatalog) { CatalogChange.RenameCatalog rename = (CatalogChange.RenameCatalog) change; - if (CatalogEntity.SYSTEM_CATALOG_RESERVED_NAME.equals( + if (Entity.SYSTEM_CATALOG_RESERVED_NAME.equals( ((CatalogChange.RenameCatalog) change).getNewName())) { throw new IllegalArgumentException( "Can't rename a catalog with with reserved name `system`"); diff --git a/core/src/main/java/com/datastrato/gravitino/meta/CatalogEntity.java b/core/src/main/java/com/datastrato/gravitino/meta/CatalogEntity.java index 606555fc82e..28b270a1038 100644 --- a/core/src/main/java/com/datastrato/gravitino/meta/CatalogEntity.java +++ b/core/src/main/java/com/datastrato/gravitino/meta/CatalogEntity.java @@ -24,8 +24,6 @@ @ToString public class CatalogEntity implements Entity, Auditable, HasIdentifier { - public static final String SYSTEM_CATALOG_RESERVED_NAME = "system"; - public static final Field ID = Field.required("id", Long.class, "The catalog's unique identifier"); public static final Field NAME = Field.required("name", String.class, "The catalog's name"); diff --git a/core/src/main/java/com/datastrato/gravitino/meta/GroupEntity.java b/core/src/main/java/com/datastrato/gravitino/meta/GroupEntity.java index 7d198b79471..9ae0d8cee07 100644 --- a/core/src/main/java/com/datastrato/gravitino/meta/GroupEntity.java +++ b/core/src/main/java/com/datastrato/gravitino/meta/GroupEntity.java @@ -18,8 +18,6 @@ public class GroupEntity implements Group, Entity, Auditable, HasIdentifier { - public static final String GROUP_SCHEMA_NAME = "group"; - public static final Field ID = Field.required("id", Long.class, " The unique id of the group entity."); diff --git a/core/src/main/java/com/datastrato/gravitino/meta/UserEntity.java b/core/src/main/java/com/datastrato/gravitino/meta/UserEntity.java index f6d9f05b931..11a6fe6978a 100644 --- a/core/src/main/java/com/datastrato/gravitino/meta/UserEntity.java +++ b/core/src/main/java/com/datastrato/gravitino/meta/UserEntity.java @@ -21,8 +21,6 @@ @ToString public class UserEntity implements User, Entity, Auditable, HasIdentifier { - public static final String USER_SCHEMA_NAME = "user"; - public static final Field ID = Field.required("id", Long.class, " The unique id of the user entity."); diff --git a/core/src/main/java/com/datastrato/gravitino/metalake/MetalakeManager.java b/core/src/main/java/com/datastrato/gravitino/metalake/MetalakeManager.java index f98a82a4b33..d67bc8aa27b 100644 --- a/core/src/main/java/com/datastrato/gravitino/metalake/MetalakeManager.java +++ b/core/src/main/java/com/datastrato/gravitino/metalake/MetalakeManager.java @@ -4,6 +4,7 @@ */ package com.datastrato.gravitino.metalake; +import com.datastrato.gravitino.Entity; import com.datastrato.gravitino.Entity.EntityType; import com.datastrato.gravitino.EntityAlreadyExistsException; import com.datastrato.gravitino.EntityStore; @@ -105,6 +106,11 @@ public BaseMetalake createMetalake( long uid = idGenerator.nextId(); StringIdentifier stringId = StringIdentifier.fromId(uid); + if (Entity.SYSTEM_METALAKE_RESERVED_NAME.equals(ident.name())) { + throw new IllegalArgumentException( + "Can't create a metalake with with reserved name `system`"); + } + BaseMetalake metalake = BaseMetalake.builder() .withId(uid) diff --git a/core/src/test/java/com/datastrato/gravitino/authorization/TestAccessControlManager.java b/core/src/test/java/com/datastrato/gravitino/authorization/TestAccessControlManager.java index 03c51ad03e9..d73b0afa4df 100644 --- a/core/src/test/java/com/datastrato/gravitino/authorization/TestAccessControlManager.java +++ b/core/src/test/java/com/datastrato/gravitino/authorization/TestAccessControlManager.java @@ -4,6 +4,8 @@ */ package com.datastrato.gravitino.authorization; +import static com.datastrato.gravitino.Configs.SERVICE_ADMINS; + import com.datastrato.gravitino.Config; import com.datastrato.gravitino.EntityStore; import com.datastrato.gravitino.exceptions.GroupAlreadyExistsException; @@ -15,6 +17,7 @@ import com.datastrato.gravitino.meta.SchemaVersion; import com.datastrato.gravitino.storage.RandomIdGenerator; import com.datastrato.gravitino.storage.memory.TestMemoryEntityStore; +import com.google.common.collect.Lists; import java.io.IOException; import java.time.Instant; import org.junit.jupiter.api.AfterAll; @@ -44,6 +47,7 @@ public class TestAccessControlManager { @BeforeAll public static void setUp() throws Exception { config = new Config(false) {}; + config.set(SERVICE_ADMINS, Lists.newArrayList("admin1", "admin2")); entityStore = new TestMemoryEntityStore.InMemoryEntityStore(); entityStore.initialize(config); @@ -51,7 +55,7 @@ public static void setUp() throws Exception { entityStore.put(metalakeEntity, true); - accessControlManager = new AccessControlManager(entityStore, new RandomIdGenerator()); + accessControlManager = new AccessControlManager(entityStore, new RandomIdGenerator(), config); } @AfterAll @@ -150,4 +154,32 @@ public void testRemoveGroup() { boolean removed1 = accessControlManager.removeUser("metalake", "no-exist"); Assertions.assertFalse(removed1); } + + @Test + public void testMetalakeAdmin() { + User user = accessControlManager.addMetalakeAdmin("test"); + Assertions.assertEquals("test", user.name()); + Assertions.assertTrue(user.roles().isEmpty()); + Assertions.assertTrue(accessControlManager.isMetalakeAdmin("test")); + + // Test with UserAlreadyExistsException + Assertions.assertThrows( + UserAlreadyExistsException.class, () -> accessControlManager.addMetalakeAdmin("test")); + + // Test to remove admin + boolean removed = accessControlManager.removeMetalakeAdmin("test"); + Assertions.assertTrue(removed); + Assertions.assertFalse(accessControlManager.isMetalakeAdmin("test")); + + // Test to remove non-existed admin + boolean removed1 = accessControlManager.removeMetalakeAdmin("no-exist"); + Assertions.assertFalse(removed1); + } + + @Test + public void testServiceAdmin() { + Assertions.assertTrue(accessControlManager.isServiceAdmin("admin1")); + Assertions.assertTrue(accessControlManager.isServiceAdmin("admin2")); + Assertions.assertFalse(accessControlManager.isServiceAdmin("admin3")); + } } diff --git a/core/src/test/java/com/datastrato/gravitino/storage/TestEntityStorage.java b/core/src/test/java/com/datastrato/gravitino/storage/TestEntityStorage.java index 48064870943..4246048c9df 100644 --- a/core/src/test/java/com/datastrato/gravitino/storage/TestEntityStorage.java +++ b/core/src/test/java/com/datastrato/gravitino/storage/TestEntityStorage.java @@ -969,8 +969,7 @@ private static UserEntity createUser(String metalake, String name, AuditInfo aud return UserEntity.builder() .withId(1L) .withNamespace( - Namespace.of( - metalake, CatalogEntity.SYSTEM_CATALOG_RESERVED_NAME, UserEntity.USER_SCHEMA_NAME)) + Namespace.of(metalake, Entity.SYSTEM_CATALOG_RESERVED_NAME, Entity.USER_SCHEMA_NAME)) .withName(name) .withAuditInfo(auditInfo) .withRoles(Lists.newArrayList()) @@ -981,10 +980,7 @@ private static GroupEntity createGroup(String metalake, String name, AuditInfo a return GroupEntity.builder() .withId(1L) .withNamespace( - Namespace.of( - metalake, - CatalogEntity.SYSTEM_CATALOG_RESERVED_NAME, - GroupEntity.GROUP_SCHEMA_NAME)) + Namespace.of(metalake, Entity.SYSTEM_CATALOG_RESERVED_NAME, Entity.GROUP_SCHEMA_NAME)) .withName(name) .withAuditInfo(auditInfo) .withRoles(Lists.newArrayList()) diff --git a/server/src/main/java/com/datastrato/gravitino/server/GravitinoServer.java b/server/src/main/java/com/datastrato/gravitino/server/GravitinoServer.java index 55702776567..0e8a98d7a8e 100644 --- a/server/src/main/java/com/datastrato/gravitino/server/GravitinoServer.java +++ b/server/src/main/java/com/datastrato/gravitino/server/GravitinoServer.java @@ -4,6 +4,7 @@ */ package com.datastrato.gravitino.server; +import com.datastrato.gravitino.Configs; import com.datastrato.gravitino.GravitinoEnv; import com.datastrato.gravitino.authorization.AccessControlManager; import com.datastrato.gravitino.catalog.CatalogManager; @@ -14,6 +15,7 @@ import com.datastrato.gravitino.metrics.MetricsSystem; import com.datastrato.gravitino.metrics.source.MetricsSource; import com.datastrato.gravitino.server.auth.ServerAuthenticator; +import com.datastrato.gravitino.server.web.AccessControlNotAllowedFilter; import com.datastrato.gravitino.server.web.ConfigServlet; import com.datastrato.gravitino.server.web.HttpServerMetricsSource; import com.datastrato.gravitino.server.web.JettyServer; @@ -21,7 +23,9 @@ import com.datastrato.gravitino.server.web.ObjectMapperProvider; import com.datastrato.gravitino.server.web.VersioningFilter; import com.datastrato.gravitino.server.web.ui.WebUIFilter; +import com.google.common.collect.Lists; import java.io.File; +import java.util.List; import java.util.Properties; import javax.servlet.Servlet; import org.glassfish.hk2.utilities.binding.AbstractBinder; @@ -70,13 +74,16 @@ public void initialize() { private void initializeRestApi() { packages("com.datastrato.gravitino.server.web.rest"); + boolean enableAuthorization = serverConfig.get(Configs.ENABLE_AUTHORIZATION); register( new AbstractBinder() { @Override protected void configure() { bind(gravitinoEnv.metalakesManager()).to(MetalakeManager.class).ranked(1); bind(gravitinoEnv.catalogManager()).to(CatalogManager.class).ranked(1); - bind(gravitinoEnv.accessControlManager()).to(AccessControlManager.class).ranked(1); + if (enableAuthorization) { + bind(gravitinoEnv.accessControlManager()).to(AccessControlManager.class).ranked(1); + } bind(gravitinoEnv.schemaOperationDispatcher()) .to(SchemaOperationDispatcher.class) .ranked(1); @@ -105,6 +112,14 @@ protected void configure() { server.addCustomFilters(API_ANY_PATH); server.addFilter(new VersioningFilter(), API_ANY_PATH); server.addSystemFilters(API_ANY_PATH); + + if (!enableAuthorization) { + List accessControlPaths = Lists.newArrayList("/api/metalakes/*", "/api/admins/*"); + for (String path : accessControlPaths) { + server.addFilter(new AccessControlNotAllowedFilter(), path); + } + } + server.addFilter(new WebUIFilter(), "/"); // Redirect to the /ui/index html page. server.addFilter(new WebUIFilter(), "/ui/*"); // Redirect to the static html file. } diff --git a/server/src/main/java/com/datastrato/gravitino/server/web/AccessControlNotAllowedFilter.java b/server/src/main/java/com/datastrato/gravitino/server/web/AccessControlNotAllowedFilter.java new file mode 100644 index 00000000000..f8dae1f9482 --- /dev/null +++ b/server/src/main/java/com/datastrato/gravitino/server/web/AccessControlNotAllowedFilter.java @@ -0,0 +1,71 @@ +/* + * Copyright 2024 Datastrato Pvt Ltd. + * This software is licensed under the Apache License version 2. + */ +package com.datastrato.gravitino.server.web; + +import static javax.servlet.http.HttpServletResponse.SC_METHOD_NOT_ALLOWED; + +import com.datastrato.gravitino.Configs; +import java.io.IOException; +import javax.servlet.Filter; +import javax.servlet.FilterChain; +import javax.servlet.FilterConfig; +import javax.servlet.ServletException; +import javax.servlet.ServletRequest; +import javax.servlet.ServletResponse; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; + +/** + * AccessControlNotAllowedFilter is used for filter the requests related to access control if + * Gravitino doesn't enable authorization. The filter return 405 error code. You can refer to + * https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/405. No methods will be returned in the + * allow methods. + */ +public class AccessControlNotAllowedFilter implements Filter { + + public static final String ALLOW = "Allow"; + public static final String API_METALAKES = "/api/metalakes"; + public static final String USERS = "users"; + public static final String GROUPS = "groups"; + + @Override + public void init(FilterConfig filterConfig) throws ServletException {} + + @Override + public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) + throws IOException, ServletException { + HttpServletRequest req = (HttpServletRequest) request; + String path = req.getRequestURI(); + if (isAccessControlPath(path)) { + HttpServletResponse resp = (HttpServletResponse) response; + resp.setHeader(ALLOW, ""); + resp.sendError( + SC_METHOD_NOT_ALLOWED, + String.format( + "You should set '%s' to true in the server side `gravitino.conf`" + + " to enable the authorization of the system, otherwise these interfaces can't work.", + Configs.ENABLE_AUTHORIZATION.getKey())); + } else { + chain.doFilter(request, response); + } + } + + boolean isAccessControlPath(String path) { + if (path.startsWith(API_METALAKES)) { + String[] segments = path.substring(API_METALAKES.length()).split("/"); + + if (segments.length > 2) { + return USERS.equalsIgnoreCase(segments[2]) || GROUPS.equalsIgnoreCase(segments[2]); + } else { + return false; + } + } else { + return true; + } + } + + @Override + public void destroy() {} +} diff --git a/server/src/main/java/com/datastrato/gravitino/server/web/rest/ExceptionHandlers.java b/server/src/main/java/com/datastrato/gravitino/server/web/rest/ExceptionHandlers.java index 9b32d3c82e8..220ccc9a139 100644 --- a/server/src/main/java/com/datastrato/gravitino/server/web/rest/ExceptionHandlers.java +++ b/server/src/main/java/com/datastrato/gravitino/server/web/rest/ExceptionHandlers.java @@ -278,9 +278,15 @@ private static class UserExceptionHandler extends BaseExceptionHandler { private static String getUserErrorMsg( String user, String operation, String metalake, String reason) { - return String.format( - "Failed to operate user %s operation [%s] under metalake [%s], reason [%s]", - user, operation, metalake, reason); + if (metalake == null) { + return String.format( + "Failed to operate metalake admin user %s operation [%s], reason [%s]", + user, operation, reason); + } else { + return String.format( + "Failed to operate user %s operation [%s] under metalake [%s], reason [%s]", + user, operation, metalake, reason); + } } @Override diff --git a/server/src/main/java/com/datastrato/gravitino/server/web/rest/GroupOperations.java b/server/src/main/java/com/datastrato/gravitino/server/web/rest/GroupOperations.java index 692d7bf31c9..a49382431af 100644 --- a/server/src/main/java/com/datastrato/gravitino/server/web/rest/GroupOperations.java +++ b/server/src/main/java/com/datastrato/gravitino/server/web/rest/GroupOperations.java @@ -6,6 +6,7 @@ import com.codahale.metrics.annotation.ResponseMetered; import com.codahale.metrics.annotation.Timed; +import com.datastrato.gravitino.Entity; import com.datastrato.gravitino.NameIdentifier; import com.datastrato.gravitino.authorization.AccessControlManager; import com.datastrato.gravitino.dto.requests.GroupAddRequest; @@ -14,8 +15,6 @@ import com.datastrato.gravitino.dto.util.DTOConverters; import com.datastrato.gravitino.lock.LockType; import com.datastrato.gravitino.lock.TreeLockUtils; -import com.datastrato.gravitino.meta.CatalogEntity; -import com.datastrato.gravitino.meta.GroupEntity; import com.datastrato.gravitino.metrics.MetricNames; import com.datastrato.gravitino.server.web.Utils; import javax.inject.Inject; @@ -119,6 +118,6 @@ public Response removeGroup( private NameIdentifier ofGroup(String metalake, String group) { return NameIdentifier.of( - metalake, CatalogEntity.SYSTEM_CATALOG_RESERVED_NAME, GroupEntity.GROUP_SCHEMA_NAME, group); + metalake, Entity.SYSTEM_CATALOG_RESERVED_NAME, Entity.GROUP_SCHEMA_NAME, group); } } diff --git a/server/src/main/java/com/datastrato/gravitino/server/web/rest/MetalakeAdminOperations.java b/server/src/main/java/com/datastrato/gravitino/server/web/rest/MetalakeAdminOperations.java new file mode 100644 index 00000000000..512fbaab790 --- /dev/null +++ b/server/src/main/java/com/datastrato/gravitino/server/web/rest/MetalakeAdminOperations.java @@ -0,0 +1,80 @@ +/* + * Copyright 2024 Datastrato Pvt Ltd. + * This software is licensed under the Apache License version 2. + */ +package com.datastrato.gravitino.server.web.rest; + +import com.codahale.metrics.annotation.ResponseMetered; +import com.codahale.metrics.annotation.Timed; +import com.datastrato.gravitino.authorization.AccessControlManager; +import com.datastrato.gravitino.dto.requests.UserAddRequest; +import com.datastrato.gravitino.dto.responses.RemoveResponse; +import com.datastrato.gravitino.dto.responses.UserResponse; +import com.datastrato.gravitino.dto.util.DTOConverters; +import com.datastrato.gravitino.metrics.MetricNames; +import com.datastrato.gravitino.server.web.Utils; +import javax.inject.Inject; +import javax.servlet.http.HttpServletRequest; +import javax.ws.rs.DELETE; +import javax.ws.rs.POST; +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.slf4j.Logger; +import org.slf4j.LoggerFactory; + +@Path("/admins") +public class MetalakeAdminOperations { + private static final Logger LOG = LoggerFactory.getLogger(MetalakeAdminOperations.class); + + private final AccessControlManager accessControlManager; + + @Context private HttpServletRequest httpRequest; + + @Inject + public MetalakeAdminOperations(AccessControlManager accessControlManager) { + this.accessControlManager = accessControlManager; + } + + @POST + @Produces("application/vnd.gravitino.v1+json") + @Timed(name = "add-admin." + MetricNames.HTTP_PROCESS_DURATION, absolute = true) + @ResponseMetered(name = "add-admin", absolute = true) + public Response addAdmin(UserAddRequest request) { + + try { + return Utils.doAs( + httpRequest, + () -> + Utils.ok( + new UserResponse( + DTOConverters.toDTO( + accessControlManager.addMetalakeAdmin(request.getName()))))); + } catch (Exception e) { + return ExceptionHandlers.handleUserException(OperationType.ADD, request.getName(), null, e); + } + } + + @DELETE + @Path("{user}") + @Produces("application/vnd.gravitino.v1+json") + @Timed(name = "remove-admin." + MetricNames.HTTP_PROCESS_DURATION, absolute = true) + @ResponseMetered(name = "remove-admin", absolute = true) + public Response removeAdmin(@PathParam("user") String user) { + try { + return Utils.doAs( + httpRequest, + () -> { + boolean removed = accessControlManager.removeMetalakeAdmin(user); + if (!removed) { + LOG.warn("Failed to remove metalake admin user {}", user); + } + return Utils.ok(new RemoveResponse(removed)); + }); + } catch (Exception e) { + return ExceptionHandlers.handleUserException(OperationType.REMOVE, user, null, e); + } + } +} diff --git a/server/src/main/java/com/datastrato/gravitino/server/web/rest/UserOperations.java b/server/src/main/java/com/datastrato/gravitino/server/web/rest/UserOperations.java index 759b71b3f12..5c5cbaac874 100644 --- a/server/src/main/java/com/datastrato/gravitino/server/web/rest/UserOperations.java +++ b/server/src/main/java/com/datastrato/gravitino/server/web/rest/UserOperations.java @@ -6,6 +6,7 @@ import com.codahale.metrics.annotation.ResponseMetered; import com.codahale.metrics.annotation.Timed; +import com.datastrato.gravitino.Entity; import com.datastrato.gravitino.NameIdentifier; import com.datastrato.gravitino.authorization.AccessControlManager; import com.datastrato.gravitino.dto.requests.UserAddRequest; @@ -14,8 +15,6 @@ import com.datastrato.gravitino.dto.util.DTOConverters; import com.datastrato.gravitino.lock.LockType; import com.datastrato.gravitino.lock.TreeLockUtils; -import com.datastrato.gravitino.meta.CatalogEntity; -import com.datastrato.gravitino.meta.UserEntity; import com.datastrato.gravitino.metrics.MetricNames; import com.datastrato.gravitino.server.web.Utils; import javax.inject.Inject; @@ -118,6 +117,6 @@ public Response removeUser( private NameIdentifier ofUser(String metalake, String user) { return NameIdentifier.of( - metalake, CatalogEntity.SYSTEM_CATALOG_RESERVED_NAME, UserEntity.USER_SCHEMA_NAME, user); + metalake, Entity.SYSTEM_CATALOG_RESERVED_NAME, Entity.USER_SCHEMA_NAME, user); } } diff --git a/server/src/test/java/com/datastrato/gravitino/server/web/TestAccessControlNotAllowedFilter.java b/server/src/test/java/com/datastrato/gravitino/server/web/TestAccessControlNotAllowedFilter.java new file mode 100644 index 00000000000..17c59901957 --- /dev/null +++ b/server/src/test/java/com/datastrato/gravitino/server/web/TestAccessControlNotAllowedFilter.java @@ -0,0 +1,66 @@ +/* + * Copyright 2024 Datastrato Pvt Ltd. + * This software is licensed under the Apache License version 2. + */ +package com.datastrato.gravitino.server.web; + +import static javax.servlet.http.HttpServletResponse.SC_METHOD_NOT_ALLOWED; +import static org.mockito.ArgumentMatchers.any; + +import java.io.IOException; +import javax.servlet.FilterChain; +import javax.servlet.ServletException; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.mockito.InOrder; +import org.mockito.Mockito; + +public class TestAccessControlNotAllowedFilter { + + @Test + public void testAccessControlPath() { + AccessControlNotAllowedFilter filter = new AccessControlNotAllowedFilter(); + Assertions.assertTrue(filter.isAccessControlPath("/api/admins")); + Assertions.assertTrue(filter.isAccessControlPath("/api/admins/")); + Assertions.assertFalse(filter.isAccessControlPath("/api/metalakes/")); + Assertions.assertFalse(filter.isAccessControlPath("/api/metalakes/metalake")); + Assertions.assertFalse(filter.isAccessControlPath("/api/metalakes/metalake/")); + Assertions.assertTrue(filter.isAccessControlPath("/api/metalakes/metalake/users")); + Assertions.assertTrue(filter.isAccessControlPath("/api/metalakes/metalake/users/")); + Assertions.assertTrue(filter.isAccessControlPath("/api/metalakes/metalake/users/user")); + Assertions.assertTrue(filter.isAccessControlPath("/api/metalakes/metalake/users/userRandom/")); + Assertions.assertTrue(filter.isAccessControlPath("/api/metalakes/metalake/groups")); + Assertions.assertTrue(filter.isAccessControlPath("/api/metalakes/metalake/groups/")); + Assertions.assertTrue(filter.isAccessControlPath("/api/metalakes/metalake/groups/group1")); + Assertions.assertTrue( + filter.isAccessControlPath("/api/metalakes/metalake/groups/groupRandom/")); + Assertions.assertFalse(filter.isAccessControlPath("/api/metalakes/metalake/catalogs")); + Assertions.assertFalse(filter.isAccessControlPath("/api/metalakes/metalake/catalogs/")); + Assertions.assertFalse(filter.isAccessControlPath("/api/metalakes/metalake/catalogs/catalog1")); + } + + @Test + public void testAccessControlNotAllowed() throws ServletException, IOException { + AccessControlNotAllowedFilter filter = new AccessControlNotAllowedFilter(); + FilterChain mockChain = Mockito.mock(FilterChain.class); + HttpServletRequest mockRequest = Mockito.mock(HttpServletRequest.class); + HttpServletResponse mockResponse = Mockito.mock(HttpServletResponse.class); + + Mockito.when(mockRequest.getRequestURI()).thenReturn("/api/admins/"); + + filter.doFilter(mockRequest, mockResponse, mockChain); + Mockito.verify(mockChain, Mockito.never()).doFilter(any(), any()); + InOrder order = Mockito.inOrder(mockResponse); + order.verify(mockResponse).setHeader("Allow", ""); + order + .verify(mockResponse) + .sendError( + SC_METHOD_NOT_ALLOWED, + "You should set 'gravitino.authorization.enable'" + + " to true in the server side `gravitino.conf`" + + " to enable the authorization of the system, otherwise these interfaces can't work."); + order.verifyNoMoreInteractions(); + } +} diff --git a/server/src/test/java/com/datastrato/gravitino/server/web/rest/TestMetalakeAdminOperations.java b/server/src/test/java/com/datastrato/gravitino/server/web/rest/TestMetalakeAdminOperations.java new file mode 100644 index 00000000000..1bd08e62b61 --- /dev/null +++ b/server/src/test/java/com/datastrato/gravitino/server/web/rest/TestMetalakeAdminOperations.java @@ -0,0 +1,216 @@ +/* + * Copyright 2024 Datastrato Pvt Ltd. + * This software is licensed under the Apache License version 2. + */ +package com.datastrato.gravitino.server.web.rest; + +import static com.datastrato.gravitino.Configs.TREE_LOCK_CLEAN_INTERVAL; +import static com.datastrato.gravitino.Configs.TREE_LOCK_MAX_NODE_IN_MEMORY; +import static com.datastrato.gravitino.Configs.TREE_LOCK_MIN_NODE_IN_MEMORY; +import static org.mockito.ArgumentMatchers.any; + +import com.datastrato.gravitino.Config; +import com.datastrato.gravitino.GravitinoEnv; +import com.datastrato.gravitino.authorization.AccessControlManager; +import com.datastrato.gravitino.authorization.User; +import com.datastrato.gravitino.dto.authorization.UserDTO; +import com.datastrato.gravitino.dto.requests.UserAddRequest; +import com.datastrato.gravitino.dto.responses.ErrorConstants; +import com.datastrato.gravitino.dto.responses.ErrorResponse; +import com.datastrato.gravitino.dto.responses.RemoveResponse; +import com.datastrato.gravitino.dto.responses.UserResponse; +import com.datastrato.gravitino.exceptions.NoSuchMetalakeException; +import com.datastrato.gravitino.exceptions.UserAlreadyExistsException; +import com.datastrato.gravitino.lock.LockManager; +import com.datastrato.gravitino.meta.AuditInfo; +import com.datastrato.gravitino.meta.UserEntity; +import com.datastrato.gravitino.rest.RESTUtils; +import java.io.IOException; +import java.time.Instant; +import java.util.Collections; +import javax.servlet.http.HttpServletRequest; +import javax.ws.rs.client.Entity; +import javax.ws.rs.core.Application; +import javax.ws.rs.core.MediaType; +import javax.ws.rs.core.Response; +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.BeforeAll; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; + +public class TestMetalakeAdminOperations extends JerseyTest { + + private final AccessControlManager manager = Mockito.mock(AccessControlManager.class); + + private static class MockServletRequestFactory extends ServletRequestFactoryBase { + @Override + public HttpServletRequest get() { + HttpServletRequest request = Mockito.mock(HttpServletRequest.class); + Mockito.when(request.getRemoteUser()).thenReturn(null); + return request; + } + } + + @BeforeAll + public static void setup() { + Config config = Mockito.mock(Config.class); + Mockito.doReturn(100000L).when(config).get(TREE_LOCK_MAX_NODE_IN_MEMORY); + Mockito.doReturn(1000L).when(config).get(TREE_LOCK_MIN_NODE_IN_MEMORY); + Mockito.doReturn(36000L).when(config).get(TREE_LOCK_CLEAN_INTERVAL); + GravitinoEnv.getInstance().setLockManager(new LockManager(config)); + } + + @Override + protected Application configure() { + try { + forceSet( + TestProperties.CONTAINER_PORT, String.valueOf(RESTUtils.findAvailablePort(2000, 3000))); + } catch (IOException e) { + throw new RuntimeException(e); + } + + ResourceConfig resourceConfig = new ResourceConfig(); + resourceConfig.register(MetalakeAdminOperations.class); + resourceConfig.register( + new AbstractBinder() { + @Override + protected void configure() { + bind(manager).to(AccessControlManager.class).ranked(2); + bindFactory(MockServletRequestFactory.class).to(HttpServletRequest.class); + } + }); + + return resourceConfig; + } + + @Test + public void testAddMetalakeAdmin() { + UserAddRequest req = new UserAddRequest("user1"); + User user = buildUser("user1"); + + Mockito.when(manager.addMetalakeAdmin(any())).thenReturn(user); + + Response resp = + target("/admins") + .request(MediaType.APPLICATION_JSON_TYPE) + .accept("application/vnd.gravitino.v1+json") + .post(Entity.entity(req, MediaType.APPLICATION_JSON_TYPE)); + + Assertions.assertEquals(Response.Status.OK.getStatusCode(), resp.getStatus()); + Assertions.assertEquals(MediaType.APPLICATION_JSON_TYPE, resp.getMediaType()); + + UserResponse userResponse = resp.readEntity(UserResponse.class); + Assertions.assertEquals(0, userResponse.getCode()); + + UserDTO userDTO = userResponse.getUser(); + Assertions.assertEquals("user1", userDTO.name()); + Assertions.assertNotNull(userDTO.roles()); + Assertions.assertTrue(userDTO.roles().isEmpty()); + + // Test to throw NoSuchMetalakeException + Mockito.doThrow(new NoSuchMetalakeException("mock error")) + .when(manager) + .addMetalakeAdmin(any()); + Response resp1 = + target("/admins") + .request(MediaType.APPLICATION_JSON_TYPE) + .accept("application/vnd.gravitino.v1+json") + .post(Entity.entity(req, MediaType.APPLICATION_JSON_TYPE)); + + Assertions.assertEquals(Response.Status.NOT_FOUND.getStatusCode(), resp1.getStatus()); + Assertions.assertEquals(MediaType.APPLICATION_JSON_TYPE, resp1.getMediaType()); + + ErrorResponse errorResponse = resp1.readEntity(ErrorResponse.class); + Assertions.assertEquals(ErrorConstants.NOT_FOUND_CODE, errorResponse.getCode()); + Assertions.assertEquals(NoSuchMetalakeException.class.getSimpleName(), errorResponse.getType()); + + // Test to throw UserAlreadyExistsException + Mockito.doThrow(new UserAlreadyExistsException("mock error")) + .when(manager) + .addMetalakeAdmin(any()); + Response resp2 = + target("/admins") + .request(MediaType.APPLICATION_JSON_TYPE) + .accept("application/vnd.gravitino.v1+json") + .post(Entity.entity(req, MediaType.APPLICATION_JSON_TYPE)); + + Assertions.assertEquals(Response.Status.CONFLICT.getStatusCode(), resp2.getStatus()); + + ErrorResponse errorResponse1 = resp2.readEntity(ErrorResponse.class); + Assertions.assertEquals(ErrorConstants.ALREADY_EXISTS_CODE, errorResponse1.getCode()); + Assertions.assertEquals( + UserAlreadyExistsException.class.getSimpleName(), errorResponse1.getType()); + + // Test to throw internal RuntimeException + Mockito.doThrow(new RuntimeException("mock error")).when(manager).addMetalakeAdmin(any()); + Response resp3 = + target("/admins") + .request(MediaType.APPLICATION_JSON_TYPE) + .accept("application/vnd.gravitino.v1+json") + .post(Entity.entity(req, MediaType.APPLICATION_JSON_TYPE)); + + Assertions.assertEquals( + Response.Status.INTERNAL_SERVER_ERROR.getStatusCode(), resp3.getStatus()); + + ErrorResponse errorResponse2 = resp3.readEntity(ErrorResponse.class); + Assertions.assertEquals(ErrorConstants.INTERNAL_ERROR_CODE, errorResponse2.getCode()); + Assertions.assertEquals(RuntimeException.class.getSimpleName(), errorResponse2.getType()); + } + + private User buildUser(String user) { + return UserEntity.builder() + .withId(1L) + .withName(user) + .withRoles(Collections.emptyList()) + .withAuditInfo( + AuditInfo.builder().withCreator("creator").withCreateTime(Instant.now()).build()) + .build(); + } + + @Test + public void testRemoveMetalakeAdmin() { + Mockito.when(manager.removeMetalakeAdmin(any())).thenReturn(true); + + Response resp = + target("/admins/user1") + .request(MediaType.APPLICATION_JSON_TYPE) + .accept("application/vnd.gravitino.v1+json") + .delete(); + + Assertions.assertEquals(Response.Status.OK.getStatusCode(), resp.getStatus()); + RemoveResponse removeResponse = resp.readEntity(RemoveResponse.class); + Assertions.assertEquals(0, removeResponse.getCode()); + Assertions.assertTrue(removeResponse.removed()); + + // Test when failed to remove user + Mockito.when(manager.removeMetalakeAdmin(any())).thenReturn(false); + Response resp2 = + target("/admins/user1") + .request(MediaType.APPLICATION_JSON_TYPE) + .accept("application/vnd.gravitino.v1+json") + .delete(); + + Assertions.assertEquals(Response.Status.OK.getStatusCode(), resp2.getStatus()); + RemoveResponse removeResponse2 = resp2.readEntity(RemoveResponse.class); + Assertions.assertEquals(0, removeResponse2.getCode()); + Assertions.assertFalse(removeResponse2.removed()); + + Mockito.doThrow(new RuntimeException("mock error")).when(manager).removeMetalakeAdmin(any()); + Response resp3 = + target("/admins/user1") + .request(MediaType.APPLICATION_JSON_TYPE) + .accept("application/vnd.gravitino.v1+json") + .delete(); + + Assertions.assertEquals( + Response.Status.INTERNAL_SERVER_ERROR.getStatusCode(), resp3.getStatus()); + + ErrorResponse errorResponse = resp3.readEntity(ErrorResponse.class); + Assertions.assertEquals(ErrorConstants.INTERNAL_ERROR_CODE, errorResponse.getCode()); + Assertions.assertEquals(RuntimeException.class.getSimpleName(), errorResponse.getType()); + } +}