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 551c23378b [#12095] feat(auth): reject assumption of unheld active 
roles with 403 (#12167)
551c23378b is described below

commit 551c23378b92729a69b31c40be68b90253252526
Author: Bharath Krishna <[email protected]>
AuthorDate: Mon Aug 3 18:30:38 2026 -0700

    [#12095] feat(auth): reject assumption of unheld active roles with 403 
(#12167)
    
    ### What changes were proposed in this pull request?
    
    Validate the `X-Gravitino-Active-Roles` header against the roles the
    caller actually holds. A request that names an active role the caller
    does **not** hold (directly or via a group) is now rejected with `403
    Forbidden` before authorization, instead of the previous behavior of
    silently dropping the unheld name. This validates the *declaration* only
    (like SQL `SET ROLE`): you can activate only roles you were granted —
    the object-level access decision is unchanged.
    
    - `GravitinoAuthorizer`: add `findUnheldRoles(...)`, defaulting to empty
    so authorizers without role membership (e.g. `PassThroughAuthorizer`
    when authorization is disabled) never block role assumption.
    - `JcasbinAuthorizer`: resolve the caller's held role ids (direct +
    group-inherited) and return the declared names not held; fails closed if
    membership can't be resolved.
    - `GravitinoInterceptionService` (native REST) and
    `BaseMetadataAuthorizationMethodInterceptor` (Iceberg REST): reject
    `NAMED` declarations with unheld roles (`403`). `ALL`/`NONE` name
    nothing and need no check.
    
    ### Why are the changes needed?
    
    #11967 added the narrowing enforcement and #12096 wired the header onto
    the principal, but a declared role the caller does not hold was silently
    dropped. This completes subtask #12095 by rejecting it with `403`.
    
    ### Does this PR introduce any user-facing change?
    
    Yes. Sending `X-Gravitino-Active-Roles` with a role the caller does not
    hold now returns `403 Forbidden` (previously ignored). An absent header,
    `ALL`, and `NONE` are unaffected; when authorization is disabled, role
    assumption is never rejected.
    
    ### How was this patch tested?
    
    New unit tests:
    - `TestJcasbinAuthorizer`: `findUnheldRoles` — all declared roles held
    (direct + group-inherited), a declared-but-unassigned role, and a
    non-existent role name.
    - `TestGravitinoInterceptionService` and
    `TestIcebergMetadataAuthorizationMethodInterceptor`: a `NAMED`
    declaration with an unheld role returns `403` and does not proceed to
    the method.
    
    Verified `test` + `spotlessCheck` for `core`, `server-common`, `server`,
    and `iceberg-rest-server`.
    
    Part of #11965.
---
 .../authorization/GravitinoAuthorizer.java         | 22 +++++
 ...BaseMetadataAuthorizationMethodInterceptor.java | 31 ++++++-
 ...bergMetadataAuthorizationMethodInterceptor.java | 56 +++++++++++++
 .../authorization/jcasbin/JcasbinAuthorizer.java   | 44 ++++++++++
 .../jcasbin/TestJcasbinAuthorizer.java             | 95 ++++++++++++++++++++++
 .../web/filter/GravitinoInterceptionService.java   | 24 ++++++
 .../filter/TestGravitinoInterceptionService.java   | 50 ++++++++++++
 7 files changed, 318 insertions(+), 4 deletions(-)

diff --git 
a/core/src/main/java/org/apache/gravitino/authorization/GravitinoAuthorizer.java
 
b/core/src/main/java/org/apache/gravitino/authorization/GravitinoAuthorizer.java
index 5b121f5abf..1b28ab2062 100644
--- 
a/core/src/main/java/org/apache/gravitino/authorization/GravitinoAuthorizer.java
+++ 
b/core/src/main/java/org/apache/gravitino/authorization/GravitinoAuthorizer.java
@@ -19,6 +19,7 @@ package org.apache.gravitino.authorization;
 
 import java.io.Closeable;
 import java.security.Principal;
+import java.util.Collections;
 import java.util.Set;
 import javax.annotation.Nullable;
 import org.apache.gravitino.Entity;
@@ -139,6 +140,27 @@ public interface GravitinoAuthorizer extends Closeable {
     return true;
   }
 
+  /**
+   * Returns the declared active-role names that the given principal does not 
hold (directly or via
+   * a group) in the metalake; empty when the caller holds all of them.
+   *
+   * <p>The default returns an empty set, so authorizers without role 
membership never reject role
+   * assumption.
+   *
+   * @param principal the user principal
+   * @param metalake the metalake
+   * @param declaredRoleNames the active-role names the caller declared
+   * @param requestContext authorization request context
+   * @return the declared role names the caller does not hold; empty when all 
are held
+   */
+  default Set<String> findUnheldRoles(
+      Principal principal,
+      String metalake,
+      Set<String> declaredRoleNames,
+      AuthorizationRequestContext requestContext) {
+    return Collections.emptySet();
+  }
+
   /**
    * Determine whether the user can set owner
    *
diff --git 
a/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/server/web/filter/BaseMetadataAuthorizationMethodInterceptor.java
 
b/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/server/web/filter/BaseMetadataAuthorizationMethodInterceptor.java
index d3ad8f0c73..c4b7b13c52 100644
--- 
a/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/server/web/filter/BaseMetadataAuthorizationMethodInterceptor.java
+++ 
b/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/server/web/filter/BaseMetadataAuthorizationMethodInterceptor.java
@@ -23,14 +23,17 @@ import java.lang.reflect.Method;
 import java.lang.reflect.Parameter;
 import java.util.Map;
 import java.util.Optional;
+import java.util.Set;
 import org.aopalliance.intercept.MethodInterceptor;
 import org.aopalliance.intercept.MethodInvocation;
 import org.apache.gravitino.Entity;
 import org.apache.gravitino.MetadataObject;
 import org.apache.gravitino.NameIdentifier;
+import org.apache.gravitino.auth.ActiveRoles;
 import org.apache.gravitino.authorization.AuthorizationRequestContext;
 import org.apache.gravitino.authorization.AuthorizationUtils;
 import org.apache.gravitino.iceberg.service.IcebergExceptionMapper;
+import org.apache.gravitino.server.authorization.GravitinoAuthorizerProvider;
 import 
org.apache.gravitino.server.authorization.annotations.AuthorizationExpression;
 import 
org.apache.gravitino.server.authorization.expression.AuthorizationExpressionEvaluator;
 import org.apache.gravitino.server.web.Utils;
@@ -137,6 +140,7 @@ public abstract class 
BaseMetadataAuthorizationMethodInterceptor implements Meth
 
         // Check if current user exists in the metalake.
         NameIdentifier metalakeIdent = 
nameIdentifierMap.get(Entity.EntityType.METALAKE);
+        AuthorizationRequestContext authorizationRequestContext = new 
AuthorizationRequestContext();
 
         if (!skipStandardCheck && metalakeIdent != null) {
           String currentUser = PrincipalUtils.getCurrentUserName();
@@ -158,6 +162,28 @@ public abstract class 
BaseMetadataAuthorizationMethodInterceptor implements Meth
             return IcebergExceptionMapper.toRESTResponse(
                 new RuntimeException("Failed to validate user", ex));
           }
+
+          // Role assumption: reject a NAMED declaration that names roles the 
caller does not hold
+          // (403); ALL/NONE need no membership check.
+          ActiveRoles activeRoles = 
authorizationRequestContext.getActiveRoles();
+          if (activeRoles.mode() == ActiveRoles.Mode.NAMED) {
+            Set<String> unheldRoles =
+                GravitinoAuthorizerProvider.getInstance()
+                    .getGravitinoAuthorizer()
+                    .findUnheldRoles(
+                        PrincipalUtils.getCurrentPrincipal(),
+                        metalakeIdent.name(),
+                        activeRoles.roleNames(),
+                        authorizationRequestContext);
+            if (!unheldRoles.isEmpty()) {
+              String message =
+                  String.format(
+                      "User '%s' cannot assume active role(s) that are not 
held: %s",
+                      currentUser, unheldRoles);
+              LOG.info(message);
+              return IcebergExceptionMapper.toRESTResponse(new 
ForbiddenException(message));
+            }
+          }
         }
 
         // Process custom authorization if handler exists
@@ -177,10 +203,7 @@ public abstract class 
BaseMetadataAuthorizationMethodInterceptor implements Meth
               new AuthorizationExpressionEvaluator(expression);
           boolean authorizeResult =
               authorizationExpressionEvaluator.evaluate(
-                  nameIdentifierMap,
-                  pathParams,
-                  new AuthorizationRequestContext(),
-                  Optional.empty());
+                  nameIdentifierMap, pathParams, authorizationRequestContext, 
Optional.empty());
           if (!authorizeResult) {
             MetadataObject.Type type = 
expressionAnnotation.accessMetadataType();
             NameIdentifier accessMetadataName =
diff --git 
a/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/server/web/filter/TestIcebergMetadataAuthorizationMethodInterceptor.java
 
b/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/server/web/filter/TestIcebergMetadataAuthorizationMethodInterceptor.java
index cd0be14d34..c51052b3c3 100644
--- 
a/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/server/web/filter/TestIcebergMetadataAuthorizationMethodInterceptor.java
+++ 
b/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/server/web/filter/TestIcebergMetadataAuthorizationMethodInterceptor.java
@@ -27,6 +27,7 @@ import static org.junit.jupiter.api.Assertions.assertTrue;
 
 import java.lang.reflect.Method;
 import java.lang.reflect.Parameter;
+import java.util.Collections;
 import java.util.Map;
 import java.util.Optional;
 import javax.ws.rs.core.Response;
@@ -37,15 +38,20 @@ import org.apache.gravitino.Configs;
 import org.apache.gravitino.Entity;
 import org.apache.gravitino.GravitinoEnv;
 import org.apache.gravitino.NameIdentifier;
+import org.apache.gravitino.UserPrincipal;
+import org.apache.gravitino.auth.ActiveRoles;
+import org.apache.gravitino.authorization.GravitinoAuthorizer;
 import org.apache.gravitino.exceptions.NoSuchCatalogException;
 import org.apache.gravitino.iceberg.service.CatalogWrapperForREST;
 import org.apache.gravitino.iceberg.service.IcebergCatalogWrapperManager;
 import org.apache.gravitino.iceberg.service.IcebergRESTUtils;
 import 
org.apache.gravitino.iceberg.service.authorization.IcebergRESTServerContext;
 import org.apache.gravitino.iceberg.service.provider.IcebergConfigProvider;
+import org.apache.gravitino.server.authorization.GravitinoAuthorizerProvider;
 import 
org.apache.gravitino.server.authorization.annotations.AuthorizationExpression;
 import 
org.apache.gravitino.server.authorization.annotations.AuthorizationMetadata;
 import org.apache.gravitino.utils.NameIdentifierUtil;
+import org.apache.gravitino.utils.PrincipalUtils;
 import org.apache.iceberg.catalog.Catalog;
 import org.apache.iceberg.catalog.Namespace;
 import org.apache.iceberg.exceptions.ForbiddenException;
@@ -55,6 +61,7 @@ import org.apache.iceberg.rest.responses.ErrorResponse;
 import org.junit.jupiter.api.BeforeAll;
 import org.junit.jupiter.api.Test;
 import org.junit.jupiter.api.TestInstance;
+import org.mockito.MockedStatic;
 import org.mockito.Mockito;
 
 /** Test for {@link IcebergMetadataAuthorizationMethodInterceptor}. */
@@ -474,6 +481,55 @@ public class 
TestIcebergMetadataAuthorizationMethodInterceptor {
         errorResponse.message().contains("Authorization failed due to system 
internal error"));
   }
 
+  @Test
+  public void testInvokeRejectsUnheldActiveRolesWith403() throws Throwable {
+    // Standard authorization runs (not proxying to a REST backend), so the 
membership check
+    // applies.
+    resetContext(null, false);
+
+    Method method =
+        TestOperations.class.getMethod(
+            "testTableOperationWithAuthorizationExpression",
+            String.class,
+            String.class,
+            String.class);
+    MethodInvocation invocation = Mockito.mock(MethodInvocation.class);
+    Mockito.when(invocation.getMethod()).thenReturn(method);
+    Mockito.when(invocation.getArguments())
+        .thenReturn(new Object[] {TEST_CATALOG + "/", TEST_SCHEMA, "tbl"});
+
+    // The caller declares an active role via the header; the authorizer 
reports it as unheld.
+    UserPrincipal principal =
+        new UserPrincipal("tester")
+            
.withActiveRoles(ActiveRoles.of(Collections.singletonList("ghostRole")));
+    GravitinoAuthorizer authorizer = Mockito.mock(GravitinoAuthorizer.class);
+    Mockito.when(
+            authorizer.findUnheldRoles(
+                Mockito.any(), Mockito.eq(TEST_METALAKE), Mockito.any(), 
Mockito.any()))
+        .thenReturn(Collections.singleton("ghostRole"));
+
+    try (MockedStatic<PrincipalUtils> principalUtils = 
Mockito.mockStatic(PrincipalUtils.class);
+        MockedStatic<GravitinoAuthorizerProvider> providerStatic =
+            Mockito.mockStatic(GravitinoAuthorizerProvider.class)) {
+      
principalUtils.when(PrincipalUtils::getCurrentPrincipal).thenReturn(principal);
+      
principalUtils.when(PrincipalUtils::getCurrentUserName).thenReturn("tester");
+      GravitinoAuthorizerProvider provider = 
Mockito.mock(GravitinoAuthorizerProvider.class);
+      
providerStatic.when(GravitinoAuthorizerProvider::getInstance).thenReturn(provider);
+      Mockito.when(provider.getGravitinoAuthorizer()).thenReturn(authorizer);
+
+      IcebergMetadataAuthorizationMethodInterceptor interceptor =
+          new IcebergMetadataAuthorizationMethodInterceptor();
+      Object result = interceptor.invoke(invocation);
+
+      assertTrue(result instanceof Response);
+      Response response = (Response) result;
+      assertEquals(403, response.getStatus());
+      ErrorResponse errorResponse = (ErrorResponse) response.getEntity();
+      assertTrue(errorResponse.message().contains("ghostRole"));
+      Mockito.verify(invocation, Mockito.never()).proceed();
+    }
+  }
+
   /** Test operations class to provide method annotations for testing. */
   @SuppressWarnings("unused")
   public static class TestOperations {
diff --git 
a/server-common/src/main/java/org/apache/gravitino/server/authorization/jcasbin/JcasbinAuthorizer.java
 
b/server-common/src/main/java/org/apache/gravitino/server/authorization/jcasbin/JcasbinAuthorizer.java
index 66583526e1..a5bd48bee7 100644
--- 
a/server-common/src/main/java/org/apache/gravitino/server/authorization/jcasbin/JcasbinAuthorizer.java
+++ 
b/server-common/src/main/java/org/apache/gravitino/server/authorization/jcasbin/JcasbinAuthorizer.java
@@ -484,6 +484,50 @@ public class JcasbinAuthorizer implements 
GravitinoAuthorizer {
     throw new UnsupportedOperationException("Unsupported Entity Type.");
   }
 
+  @Override
+  public Set<String> findUnheldRoles(
+      Principal principal,
+      String metalake,
+      Set<String> declaredRoleNames,
+      AuthorizationRequestContext requestContext) {
+    if (declaredRoleNames == null || declaredRoleNames.isEmpty()) {
+      return new LinkedHashSet<>();
+    }
+    String username = principal.getName();
+    Set<String> heldRoleNames;
+    try {
+      List<String> groupNames = principalGroupNames(principal);
+      // Prime the role caches and versions that the downstream authorize() 
call will reuse.
+      Optional<UserUpdatedAt> userInfoOpt =
+          prefetchUserAndGroupInfo(metalake, username, groupNames, 
requestContext);
+      if (!userInfoOpt.isPresent()) {
+        // No user record => the caller holds no roles, so every declared role 
is unheld.
+        return new LinkedHashSet<>(declaredRoleNames);
+      }
+      Map<Long, RoleUpdatedAt> roleVersions = 
requestContext.getPrefetchedRoleVersions();
+      heldRoleNames =
+          roleVersions.values().stream()
+              .map(RoleUpdatedAt::getRoleName)
+              .collect(Collectors.toSet());
+    } catch (Exception e) {
+      // Fail closed: if membership cannot be resolved, treat every declared 
role as unheld.
+      LOG.warn(
+          "Failed to resolve held roles for user {} in metalake {}; rejecting 
role assumption",
+          username,
+          metalake,
+          e);
+      return new LinkedHashSet<>(declaredRoleNames);
+    }
+
+    Set<String> unheldRoles = new LinkedHashSet<>();
+    for (String roleName : declaredRoleNames) {
+      if (!heldRoleNames.contains(roleName)) {
+        unheldRoles.add(roleName);
+      }
+    }
+    return unheldRoles;
+  }
+
   @Override
   public boolean hasSetOwnerPermission(
       String metalake, String type, String fullName, 
AuthorizationRequestContext requestContext) {
diff --git 
a/server-common/src/test/java/org/apache/gravitino/server/authorization/jcasbin/TestJcasbinAuthorizer.java
 
b/server-common/src/test/java/org/apache/gravitino/server/authorization/jcasbin/TestJcasbinAuthorizer.java
index 177fda2d7b..e38aa7a349 100644
--- 
a/server-common/src/test/java/org/apache/gravitino/server/authorization/jcasbin/TestJcasbinAuthorizer.java
+++ 
b/server-common/src/test/java/org/apache/gravitino/server/authorization/jcasbin/TestJcasbinAuthorizer.java
@@ -20,6 +20,7 @@ package org.apache.gravitino.server.authorization.jcasbin;
 import static org.apache.gravitino.authorization.Privilege.Name.SELECT_TABLE;
 import static org.apache.gravitino.authorization.Privilege.Name.USE_CATALOG;
 import static org.apache.gravitino.authorization.Privilege.Name.USE_SCHEMA;
+import static org.junit.jupiter.api.Assertions.assertEquals;
 import static org.junit.jupiter.api.Assertions.assertFalse;
 import static org.junit.jupiter.api.Assertions.assertNotNull;
 import static org.junit.jupiter.api.Assertions.assertThrows;
@@ -51,6 +52,7 @@ import java.util.List;
 import java.util.Map;
 import java.util.Objects;
 import java.util.Optional;
+import java.util.Set;
 import java.util.concurrent.atomic.AtomicLong;
 import java.util.function.Function;
 import java.util.stream.Collectors;
@@ -1460,6 +1462,99 @@ public class TestJcasbinAuthorizer {
     getLoadedRolesCache(jcasbinAuthorizer).invalidateAll();
   }
 
+  /** All declared active roles are held (directly), so nothing is unheld. */
+  @Test
+  public void testFindUnheldRolesEmptyWhenAllRolesHeld() throws Exception {
+    getLoadedRolesCache(jcasbinAuthorizer).invalidateAll();
+    Principal principal = setCurrentPrincipalWithGroup(null);
+    RoleEntity held = mockRoleInStore(ALLOW_ROLE_ID, "heldRole", 
ImmutableList.of());
+    mockDirectUserRoles(held);
+    Mockito.clearInvocations(userMetaMapper);
+    metadataIdConverterMockedStatic.clearInvocations();
+
+    AuthorizationRequestContext requestContext = new 
AuthorizationRequestContext();
+    Set<String> unheld =
+        jcasbinAuthorizer.findUnheldRoles(
+            principal, METALAKE, ImmutableSet.of("heldRole"), requestContext);
+
+    assertTrue(unheld.isEmpty());
+    metadataIdConverterMockedStatic.verify(
+        () -> MetadataIdConverter.getID(any(), eq(METALAKE)), Mockito.never());
+    jcasbinAuthorizer.authorize(
+        principal,
+        METALAKE,
+        MetadataObjects.of(null, "testCatalog", MetadataObject.Type.CATALOG),
+        USE_CATALOG,
+        requestContext);
+    verify(userMetaMapper).batchGetAuthSubjectsForUser(eq(METALAKE), 
eq(USERNAME), anyList());
+    restoreDefaultPrincipal();
+    getLoadedRolesCache(jcasbinAuthorizer).invalidateAll();
+  }
+
+  /** A declared role that exists but is not assigned to the caller is 
reported as unheld. */
+  @Test
+  public void testFindUnheldRolesReturnsRolesNotAssigned() throws Exception {
+    getLoadedRolesCache(jcasbinAuthorizer).invalidateAll();
+    Principal principal = setCurrentPrincipalWithGroup(null);
+    RoleEntity held = mockRoleInStore(ALLOW_ROLE_ID, "heldRole", 
ImmutableList.of());
+    mockDirectUserRoles(held);
+
+    Set<String> unheld =
+        jcasbinAuthorizer.findUnheldRoles(
+            principal,
+            METALAKE,
+            ImmutableSet.of("heldRole", "otherRole"),
+            new AuthorizationRequestContext());
+
+    assertEquals(ImmutableSet.of("otherRole"), unheld);
+    restoreDefaultPrincipal();
+    getLoadedRolesCache(jcasbinAuthorizer).invalidateAll();
+  }
+
+  /** A declared role that does not exist (no id) is treated as unheld. */
+  @Test
+  public void testFindUnheldRolesTreatsNonExistentRoleAsUnheld() throws 
Exception {
+    getLoadedRolesCache(jcasbinAuthorizer).invalidateAll();
+    Principal principal = setCurrentPrincipalWithGroup(null);
+    RoleEntity held = mockRoleInStore(ALLOW_ROLE_ID, "heldRole", 
ImmutableList.of());
+    mockDirectUserRoles(held);
+
+    Set<String> unheld =
+        jcasbinAuthorizer.findUnheldRoles(
+            principal, METALAKE, ImmutableSet.of("ghostRole"), new 
AuthorizationRequestContext());
+
+    assertEquals(ImmutableSet.of("ghostRole"), unheld);
+    restoreDefaultPrincipal();
+    getLoadedRolesCache(jcasbinAuthorizer).invalidateAll();
+  }
+
+  /** A group-inherited role counts as held, so it is not reported as unheld. 
*/
+  @Test
+  public void testFindUnheldRolesCoversGroupInheritedRole() throws Exception {
+    getLoadedRolesCache(jcasbinAuthorizer).invalidateAll();
+    UserPrincipal groupPrincipal = setCurrentPrincipalWithGroup(GROUP_NAME);
+    Long groupRoleId = 31L;
+    mockRoleInStore(groupRoleId, "groupRole", ImmutableList.of());
+    mockNoDirectUserRoles();
+    mockGroupWithRoles(GROUP_NAME, ImmutableList.of(groupRoleId), 
ImmutableList.of("groupRole"));
+    Mockito.clearInvocations(userMetaMapper);
+    metadataIdConverterMockedStatic.clearInvocations();
+
+    Set<String> unheld =
+        jcasbinAuthorizer.findUnheldRoles(
+            groupPrincipal,
+            METALAKE,
+            ImmutableSet.of("groupRole"),
+            new AuthorizationRequestContext());
+
+    assertTrue(unheld.isEmpty());
+    verify(userMetaMapper).batchGetAuthSubjectsForUser(eq(METALAKE), 
eq(USERNAME), anyList());
+    metadataIdConverterMockedStatic.verify(
+        () -> MetadataIdConverter.getID(any(), eq(METALAKE)), Mockito.never());
+    restoreDefaultPrincipal();
+    getLoadedRolesCache(jcasbinAuthorizer).invalidateAll();
+  }
+
   /**
    * Sets the current principal mock to a {@link UserPrincipal} with the given 
group, or with no
    * groups when {@code groupName} is null. Returns the principal for use in 
assertions.
diff --git 
a/server/src/main/java/org/apache/gravitino/server/web/filter/GravitinoInterceptionService.java
 
b/server/src/main/java/org/apache/gravitino/server/web/filter/GravitinoInterceptionService.java
index 82135aa463..e3e057ffc4 100644
--- 
a/server/src/main/java/org/apache/gravitino/server/web/filter/GravitinoInterceptionService.java
+++ 
b/server/src/main/java/org/apache/gravitino/server/web/filter/GravitinoInterceptionService.java
@@ -41,11 +41,13 @@ import org.apache.gravitino.Entity;
 import org.apache.gravitino.GravitinoEnv;
 import org.apache.gravitino.MetadataObject;
 import org.apache.gravitino.NameIdentifier;
+import org.apache.gravitino.auth.ActiveRoles;
 import org.apache.gravitino.authorization.AuthorizationRequestContext;
 import org.apache.gravitino.authorization.AuthorizationUtils;
 import org.apache.gravitino.exceptions.ForbiddenException;
 import org.apache.gravitino.exceptions.NoSuchMetalakeException;
 import 
org.apache.gravitino.listener.api.event.server.AuthorizationDenialFailureEvent;
+import org.apache.gravitino.server.authorization.GravitinoAuthorizerProvider;
 import 
org.apache.gravitino.server.authorization.annotations.AuthorizationExpression;
 import 
org.apache.gravitino.server.authorization.annotations.AuthorizationRequest;
 import 
org.apache.gravitino.server.authorization.annotations.ExpressionCondition;
@@ -189,6 +191,28 @@ public class GravitinoInterceptionService implements 
InterceptionService {
                   ex);
               return Utils.internalError("Failed to validate user", ex);
             }
+
+            // Role assumption: reject a NAMED declaration that names roles 
the caller does not
+            // hold (403); ALL/NONE need no membership check.
+            ActiveRoles activeRoles = 
authorizationRequestContext.getActiveRoles();
+            if (activeRoles.mode() == ActiveRoles.Mode.NAMED) {
+              Set<String> unheldRoles =
+                  GravitinoAuthorizerProvider.getInstance()
+                      .getGravitinoAuthorizer()
+                      .findUnheldRoles(
+                          PrincipalUtils.getCurrentPrincipal(),
+                          metalakeIdent.name(),
+                          activeRoles.roleNames(),
+                          authorizationRequestContext);
+              if (!unheldRoles.isEmpty()) {
+                dispatchAuthzDenialEvent(currentUser, metalakeIdent, 
method.getName(), expression);
+                return Utils.forbidden(
+                    String.format(
+                        "User '%s' cannot assume active role(s) that are not 
held: %s",
+                        currentUser, unheldRoles),
+                    null);
+              }
+            }
           }
 
           // If expression is empty, skip authorization check (method handles 
its own filtering)
diff --git 
a/server/src/test/java/org/apache/gravitino/server/web/filter/TestGravitinoInterceptionService.java
 
b/server/src/test/java/org/apache/gravitino/server/web/filter/TestGravitinoInterceptionService.java
index 3439287d8a..3a74305ce5 100644
--- 
a/server/src/test/java/org/apache/gravitino/server/web/filter/TestGravitinoInterceptionService.java
+++ 
b/server/src/test/java/org/apache/gravitino/server/web/filter/TestGravitinoInterceptionService.java
@@ -39,6 +39,7 @@ import org.apache.gravitino.GravitinoEnv;
 import org.apache.gravitino.MetadataObject;
 import org.apache.gravitino.NameIdentifier;
 import org.apache.gravitino.UserPrincipal;
+import org.apache.gravitino.auth.ActiveRoles;
 import org.apache.gravitino.authorization.AuthorizationRequestContext;
 import org.apache.gravitino.authorization.AuthorizationUtils;
 import org.apache.gravitino.authorization.GravitinoAuthorizer;
@@ -121,6 +122,55 @@ public class TestGravitinoInterceptionService {
     }
   }
 
+  @Test
+  public void testRejectsUnheldActiveRolesWith403() throws Throwable {
+    try (MockedStatic<PrincipalUtils> principalUtilsMocked = 
mockStatic(PrincipalUtils.class);
+        MockedStatic<GravitinoAuthorizerProvider> mockStatic =
+            mockStatic(GravitinoAuthorizerProvider.class);
+        MockedStatic<GravitinoEnv> envMocked = mockStatic(GravitinoEnv.class);
+        MockedStatic<MetalakeManager> metalakeManagerMocked = 
mockStatic(MetalakeManager.class)) {
+      // The caller declares an active role via the header; the authorizer 
reports it as unheld.
+      UserPrincipal principal =
+          new UserPrincipal("tester")
+              
.withActiveRoles(ActiveRoles.of(Collections.singletonList("ghostRole")));
+      
principalUtilsMocked.when(PrincipalUtils::getCurrentPrincipal).thenReturn(principal);
+      
principalUtilsMocked.when(PrincipalUtils::getCurrentUserName).thenReturn("tester");
+
+      MethodInvocation methodInvocation = mock(MethodInvocation.class);
+      GravitinoAuthorizerProvider mockedProvider = 
mock(GravitinoAuthorizerProvider.class);
+      
mockStatic.when(GravitinoAuthorizerProvider::getInstance).thenReturn(mockedProvider);
+      GravitinoAuthorizer authorizer = mock(GravitinoAuthorizer.class);
+      when(mockedProvider.getGravitinoAuthorizer()).thenReturn(authorizer);
+      when(authorizer.findUnheldRoles(
+              ArgumentMatchers.any(),
+              ArgumentMatchers.eq("testMetalake"),
+              ArgumentMatchers.any(),
+              ArgumentMatchers.any()))
+          .thenReturn(Collections.singleton("ghostRole"));
+
+      GravitinoEnv mockEnv = mock(GravitinoEnv.class);
+      EntityStore mockStore = mock(EntityStore.class);
+      envMocked.when(GravitinoEnv::getInstance).thenReturn(mockEnv);
+      when(mockEnv.entityStore()).thenReturn(mockStore);
+      metalakeManagerMocked
+          .when(() -> MetalakeManager.checkMetalake(ArgumentMatchers.any(), 
ArgumentMatchers.any()))
+          .thenAnswer(invocation -> null);
+
+      GravitinoInterceptionService service = new 
GravitinoInterceptionService();
+      Method testMethod = TestOperations.class.getMethods()[0];
+      MethodInterceptor interceptor = 
service.getMethodInterceptors(testMethod).get(0);
+      when(methodInvocation.getMethod()).thenReturn(testMethod);
+      when(methodInvocation.getArguments()).thenReturn(new Object[] 
{"testMetalake"});
+
+      Response response = (Response) interceptor.invoke(methodInvocation);
+
+      assertEquals(Response.Status.FORBIDDEN.getStatusCode(), 
response.getStatus());
+      Assertions.assertTrue(
+          ((ErrorResponse) 
response.getEntity()).getMessage().contains("ghostRole"));
+      verify(methodInvocation, never()).proceed();
+    }
+  }
+
   @Test
   public void testSystemInternalErrorHandling() throws Throwable {
     try (MockedStatic<PrincipalUtils> principalUtilsMocked = 
mockStatic(PrincipalUtils.class);

Reply via email to