You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@ranger.apache.org by vy...@apache.org on 2026/08/20 05:44:57 UTC

(ranger) branch master updated: RANGER-5735: Improve authorization consistency across RoleREST endpoints (#1149)

This is an automated email from the ASF dual-hosted git repository.

vyommani pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/ranger.git


The following commit(s) were added to refs/heads/master by this push:
     new e7aef11f8 RANGER-5735: Improve authorization consistency across RoleREST endpoints (#1149)
e7aef11f8 is described below

commit e7aef11f89d873b803e830acc1871516ab26e26e
Author: Vyom Mani Tiwari <vy...@gmail.com>
AuthorDate: Thu Aug 20 11:14:51 2026 +0530

    RANGER-5735: Improve authorization consistency across RoleREST endpoints (#1149)
    
    * RANGER-5735: Improve authorization consistency across RoleREST endpoints
    
    * RANGER-5735: added more tests
---
 .../main/java/org/apache/ranger/rest/RoleREST.java |  41 ++-
 .../java/org/apache/ranger/rest/TestRoleREST.java  | 286 +++++++++++++++++++++
 2 files changed, 322 insertions(+), 5 deletions(-)

diff --git a/security-admin/src/main/java/org/apache/ranger/rest/RoleREST.java b/security-admin/src/main/java/org/apache/ranger/rest/RoleREST.java
index df19ce431..b6e1a9c1a 100644
--- a/security-admin/src/main/java/org/apache/ranger/rest/RoleREST.java
+++ b/security-admin/src/main/java/org/apache/ranger/rest/RoleREST.java
@@ -349,7 +349,17 @@ public RangerRole getRole(@PathParam("id") Long id) {
         RangerRole ret;
 
         try {
+            UserSessionBase usb = ContextUtil.getCurrentUserSession();
+            String loggedInUser = usb != null ? usb.getLoginId() : null;
             ret = roleStore.getRole(id);
+            if (ret != null && !bizUtil.isUserRangerAdmin(loggedInUser)) {
+                try {
+                    ensureRoleAccess(loggedInUser, userMgr.getGroupsForUser(loggedInUser), ret);
+                } catch (Exception accessExcp) {
+                    LOG.error("User {} does not have permission to get details for role(id={})", loggedInUser, id);
+                    throw restErrorUtil.createRESTException("User doesn't have permissions to get details for role(id=" + id + ")");
+                }
+            }
         } catch (WebApplicationException excp) {
             throw excp;
         } catch (Throwable excp) {
@@ -869,6 +879,8 @@ public RESTResponse grantRole(@PathParam("serviceName") String serviceName, Gran
         RESTResponse ret = new RESTResponse();
 
         try {
+            bizUtil.blockAuditorRoleUser();
+
             validateUsersGroupsAndRoles(grantRoleRequest);
 
             String userName = grantRoleRequest.getGrantor();
@@ -884,8 +896,7 @@ public RESTResponse grantRole(@PathParam("serviceName") String serviceName, Gran
                  * else deny the operation
                  * This logic is implemented as part of getRoleIfAccessible(roleName, serviceName, userName, userGroups)
                  */
-                Set<String> userGroups   = CollectionUtils.isNotEmpty(grantRoleRequest.getGrantorGroups()) ? grantRoleRequest.getGrantorGroups() : userMgr.getGroupsForUser(userName);
-                RangerRole  existingRole = getRoleIfAccessible(roleName, serviceName, userName, userGroups);
+                RangerRole existingRole = getRoleIfAccessible(roleName, serviceName, userName, grantRoleRequest.getGrantorGroups());
 
                 if (existingRole == null) {
                     throw restErrorUtil.createRESTException("User doesn't have permissions to grant role " + roleName);
@@ -927,6 +938,8 @@ public RESTResponse revokeRole(@PathParam("serviceName") String serviceName, Gra
         RESTResponse ret = new RESTResponse();
 
         try {
+            bizUtil.blockAuditorRoleUser();
+
             validateUsersGroupsAndRoles(revokeRoleRequest);
 
             String userName = revokeRoleRequest.getGrantor();
@@ -942,8 +955,7 @@ public RESTResponse revokeRole(@PathParam("serviceName") String serviceName, Gra
                  * else deny the operation
                  * This logic is implemented as part of getRoleIfAccessible(roleName, serviceName, userName, userGroups)
                  */
-                Set<String> userGroups   = CollectionUtils.isNotEmpty(revokeRoleRequest.getGrantorGroups()) ? revokeRoleRequest.getGrantorGroups() : userMgr.getGroupsForUser(userName);
-                RangerRole  existingRole = getRoleIfAccessible(roleName, serviceName, userName, userGroups);
+                RangerRole existingRole = getRoleIfAccessible(roleName, serviceName, userName, revokeRoleRequest.getGrantorGroups());
 
                 if (existingRole == null) {
                     throw restErrorUtil.createRESTException("User doesn't have permissions to revoke role " + roleName);
@@ -987,6 +999,13 @@ public List<String> getUserRoles(@PathParam("user") String userName, @Context Ht
         LOG.debug("==> getUserRoles()");
 
         try {
+            UserSessionBase usb = ContextUtil.getCurrentUserSession();
+            String loggedInUser = usb != null ? usb.getLoginId() : null;
+            if (!StringUtil.equals(userName, loggedInUser) && !bizUtil.isUserRangerAdmin(loggedInUser)) {
+                LOG.error("User {} does not have permission to get roles for user {}", loggedInUser, userName);
+                throw restErrorUtil.createRESTException(HttpServletResponse.SC_FORBIDDEN, "User does not have permission for this operation", true);
+            }
+
             if (xUserService.getXUserByUserName(userName) == null) {
                 throw restErrorUtil.createRESTException(HttpServletResponse.SC_NOT_FOUND, "User:" + userName + " not found", false);
             }
@@ -1254,7 +1273,7 @@ private void ensureAdminAccess(String serviceName, String userName) throws Excep
         }
     }
 
-    private RangerRole getRoleIfAccessible(String roleName, String serviceName, String userName, Set<String> userGroups) {
+    private RangerRole getRoleIfAccessible(String roleName, String serviceName, String userName, Set<String> callerAssertedGroups) {
         /* If userName (execUser) is not same as logged in user then check
          * If logged-in user is not ranger admin/service admin/service user, then deny the operation
          * effective User is execUser
@@ -1265,6 +1284,7 @@ private RangerRole getRoleIfAccessible(String roleName, String serviceName, Stri
          */
         RangerRole      existingRole;
         String          effectiveUser;
+        Set<String>     userGroups;
         UserSessionBase usb          = ContextUtil.getCurrentUserSession();
         String          loggedInUser = usb != null ? usb.getLoginId() : null;
 
@@ -1276,8 +1296,19 @@ private RangerRole getRoleIfAccessible(String roleName, String serviceName, Stri
             }
 
             effectiveUser = userName != null ? userName : loggedInUser;
+
+            /* effectiveUser differs from the logged-in caller only because the caller has already
+             * been confirmed above to be a trusted principal (ranger admin/service admin/service user)
+             * acting on behalf of effectiveUser, so it is safe to fall back to a caller-supplied group
+             * set for effectiveUser when Ranger has no synced group info for them yet. */
+            userGroups = CollectionUtils.isNotEmpty(callerAssertedGroups) ? callerAssertedGroups : userMgr.getGroupsForUser(effectiveUser);
         } else {
             effectiveUser = loggedInUser;
+
+            /* self-service case: an unprivileged, authenticated caller must never be able to assert
+             * their own group membership via request input for an authorization decision -- always
+             * resolve it server-side. */
+            userGroups = userMgr.getGroupsForUser(loggedInUser);
         }
 
         try {
diff --git a/security-admin/src/test/java/org/apache/ranger/rest/TestRoleREST.java b/security-admin/src/test/java/org/apache/ranger/rest/TestRoleREST.java
index d81ad8afb..2656dbb0d 100644
--- a/security-admin/src/test/java/org/apache/ranger/rest/TestRoleREST.java
+++ b/security-admin/src/test/java/org/apache/ranger/rest/TestRoleREST.java
@@ -614,6 +614,37 @@ public void test6bGetRoleById() {
         Assertions.assertThrows(Throwable.class, () -> roleRest.getRole(eq(rangerRole.getId())));
     }
 
+    @Test
+    public void test6cGetRoleByIdUnauthorized() {
+        Assertions.assertThrows(Throwable.class, () -> {
+            RangerRole rangerRole = createRangerRole("someOtherUser", false);
+            Mockito.when(bizUtil.isUserRangerAdmin(Mockito.anyString())).thenReturn(false);
+            Mockito.when(userMgr.getGroupsForUser(Mockito.anyString())).thenReturn(new HashSet<>());
+            try {
+                Mockito.when(roleStore.getRole(Mockito.anyLong())).thenReturn(rangerRole);
+            } catch (Exception e) {
+                throw new RuntimeException(e);
+            }
+            roleRest.getRole(rangerRole.getId());
+            Mockito.verify(restErrorUtil, Mockito.times(1)).createRESTException(Mockito.anyString());
+        });
+    }
+
+    @Test
+    public void test6dGetRoleByIdAllowedForRoleAdminOptionMember() {
+        RangerRole rangerRole = createRole(); // "admin" is an admin-option user member of this role
+        Mockito.when(bizUtil.isUserRangerAdmin(Mockito.anyString())).thenReturn(false);
+        Mockito.when(userMgr.getGroupsForUser(Mockito.anyString())).thenReturn(new HashSet<>());
+        try {
+            Mockito.when(roleStore.getRole(Mockito.anyLong())).thenReturn(rangerRole);
+        } catch (Exception e) {
+            throw new RuntimeException(e);
+        }
+        RangerRole returnedRole = roleRest.getRole(rangerRole.getId());
+        Assertions.assertNotNull(returnedRole);
+        Assertions.assertEquals(returnedRole.getId(), rangerRole.getId());
+    }
+
     @Test
     public void test7bGetAllRoles() {
         Assertions.assertThrows(Throwable.class, () -> {
@@ -725,6 +756,93 @@ public void test13bGrantRole() {
         });
     }
 
+    @Test
+    public void test13cGrantRoleForgedGrantorGroupsRejected() {
+        Assertions.assertThrows(Throwable.class, () -> {
+            String serviceName = "serviceName";
+            RangerRole.RoleMember groupMember = new RangerRole.RoleMember("forged-admin-group", true);
+            RangerRole rangerRole = new RangerRole("target-role", "target-role", null, new ArrayList<>(), new ArrayList<>(Collections.singletonList(groupMember)));
+            GrantRevokeRoleRequest grantRevokeRoleRequest = createGrantRevokeRoleRequest();
+            rangerRole.setCreatedByUser("target-role");
+            rangerRole.setId(roleId);
+            grantRevokeRoleRequest.setGrantor(adminLoginID);
+            grantRevokeRoleRequest.setGrantorGroups(new HashSet<>(Collections.singletonList("forged-admin-group")));
+            Mockito.when(bizUtil.isUserRangerAdmin(Mockito.anyString())).thenReturn(false);
+            Mockito.when(userMgr.getGroupsForUser(Mockito.anyString())).thenReturn(new HashSet<>());
+            try {
+                Mockito.when(roleStore.getRole(Mockito.anyString())).thenReturn(rangerRole);
+            } catch (Exception e) {
+                throw new RuntimeException(e);
+            }
+            roleRest.grantRole(serviceName, grantRevokeRoleRequest, Mockito.mock(HttpServletRequest.class));
+            Mockito.verify(restErrorUtil, Mockito.times(1)).createRESTException(Mockito.anyString());
+        });
+    }
+
+    @Test
+    public void test13eGrantRoleRealGroupMembershipAllowed() {
+        String serviceName = "serviceName";
+        boolean isRefTableCleanupRequired = true;
+        RangerRole.RoleMember groupMember = new RangerRole.RoleMember("real-admin-group", true);
+        RangerRole rangerRole = new RangerRole("target-role", "target-role", null, new ArrayList<>(), new ArrayList<>(Collections.singletonList(groupMember)));
+        GrantRevokeRoleRequest grantRevokeRoleRequest = createGrantRevokeRoleRequest();
+        rangerRole.setCreatedByUser("target-role");
+        rangerRole.setId(roleId);
+        grantRevokeRoleRequest.setGrantor(adminLoginID);
+        Mockito.when(bizUtil.isUserRangerAdmin(Mockito.anyString())).thenReturn(false);
+        Mockito.when(userMgr.getGroupsForUser(adminLoginID)).thenReturn(new HashSet<>(Collections.singletonList("real-admin-group")));
+        try {
+            Mockito.when(roleStore.getRole(Mockito.anyString())).thenReturn(rangerRole);
+            Mockito.when(roleStore.updateRole(Mockito.any(RangerRole.class), Mockito.anyBoolean(), eq(isRefTableCleanupRequired))).then(AdditionalAnswers.returnsFirstArg());
+        } catch (Exception e) {
+            throw new RuntimeException(e);
+        }
+        RESTResponse resp = roleRest.grantRole(serviceName, grantRevokeRoleRequest, Mockito.mock(HttpServletRequest.class));
+        Assertions.assertNotNull(resp);
+        Assertions.assertEquals(resp.getStatusCode(), RESTResponse.STATUS_SUCCESS);
+    }
+
+    @Test
+    public void test13fGrantRoleRealGroupMembershipMismatchRejected() {
+        String serviceName = "serviceName";
+        RangerRole.RoleMember groupMember = new RangerRole.RoleMember("real-admin-group", true);
+        RangerRole rangerRole = new RangerRole("target-role", "target-role", null, new ArrayList<>(), new ArrayList<>(Collections.singletonList(groupMember)));
+        GrantRevokeRoleRequest grantRevokeRoleRequest = createGrantRevokeRoleRequest();
+        rangerRole.setCreatedByUser("target-role");
+        rangerRole.setId(roleId);
+        grantRevokeRoleRequest.setGrantor(adminLoginID);
+        Mockito.when(bizUtil.isUserRangerAdmin(Mockito.anyString())).thenReturn(false);
+        Mockito.when(userMgr.getGroupsForUser(adminLoginID)).thenReturn(new HashSet<>(Arrays.asList("users", "developers")));
+        try {
+            Mockito.when(roleStore.getRole(Mockito.anyString())).thenReturn(rangerRole);
+        } catch (Exception e) {
+            throw new RuntimeException(e);
+        }
+        boolean denied = false;
+        try {
+            roleRest.grantRole(serviceName, grantRevokeRoleRequest, Mockito.mock(HttpServletRequest.class));
+        } catch (Throwable expected) {
+            denied = true;
+        }
+        Assertions.assertTrue(denied, "expected grantRole to deny access for mismatched real groups");
+        Mockito.verify(userMgr, Mockito.atLeastOnce()).getGroupsForUser(adminLoginID);
+        try {
+            Mockito.verify(roleStore, Mockito.never()).updateRole(Mockito.any(RangerRole.class), Mockito.anyBoolean(), Mockito.anyBoolean());
+        } catch (Exception e) {
+            throw new RuntimeException(e);
+        }
+    }
+
+    @Test
+    public void test13dGrantRoleBlockedForAuditor() {
+        Assertions.assertThrows(WebApplicationException.class, () -> {
+            String serviceName = "serviceName";
+            GrantRevokeRoleRequest grantRevokeRoleRequest = createGrantRevokeRoleRequest();
+            Mockito.doThrow(new WebApplicationException()).when(bizUtil).blockAuditorRoleUser();
+            roleRest.grantRole(serviceName, grantRevokeRoleRequest, Mockito.mock(HttpServletRequest.class));
+        });
+    }
+
     @Test
     public void test14bRevokeRole() {
         RangerRole             rangerRole             = createRole();
@@ -760,6 +878,96 @@ public void test14cRevokeRole() {
         });
     }
 
+    @Test
+    public void test14dRevokeRoleForgedGrantorGroupsRejected() {
+        Assertions.assertThrows(Throwable.class, () -> {
+            String serviceName = "serviceName";
+            RangerRole.RoleMember groupMember = new RangerRole.RoleMember("forged-admin-group", true);
+            RangerRole rangerRole = new RangerRole("target-role", "target-role", null, new ArrayList<>(), new ArrayList<>(Collections.singletonList(groupMember)));
+            GrantRevokeRoleRequest grantRevokeRoleRequest = createGrantRevokeRoleRequest();
+            rangerRole.setCreatedByUser("target-role");
+            rangerRole.setId(roleId);
+            grantRevokeRoleRequest.setGrantor(adminLoginID);
+            grantRevokeRoleRequest.setGrantOption(Boolean.TRUE);
+            grantRevokeRoleRequest.setGrantorGroups(new HashSet<>(Collections.singletonList("forged-admin-group")));
+            Mockito.when(bizUtil.isUserRangerAdmin(Mockito.anyString())).thenReturn(false);
+            Mockito.when(userMgr.getGroupsForUser(Mockito.anyString())).thenReturn(new HashSet<>());
+            try {
+                Mockito.when(roleStore.getRole(Mockito.anyString())).thenReturn(rangerRole);
+            } catch (Exception e) {
+                throw new RuntimeException(e);
+            }
+            roleRest.revokeRole(serviceName, grantRevokeRoleRequest, Mockito.mock(HttpServletRequest.class));
+            Mockito.verify(restErrorUtil, Mockito.times(1)).createRESTException(Mockito.anyString());
+        });
+    }
+
+    @Test
+    public void test14fRevokeRoleRealGroupMembershipAllowed() {
+        String serviceName = "serviceName";
+        boolean isRefTableCleanupRequired = true;
+        RangerRole.RoleMember groupMember = new RangerRole.RoleMember("real-admin-group", true);
+        RangerRole rangerRole = new RangerRole("target-role", "target-role", null, new ArrayList<>(), new ArrayList<>(Collections.singletonList(groupMember)));
+        GrantRevokeRoleRequest grantRevokeRoleRequest = createGrantRevokeRoleRequest();
+        rangerRole.setCreatedByUser("target-role");
+        rangerRole.setId(roleId);
+        grantRevokeRoleRequest.setGrantor(adminLoginID);
+        grantRevokeRoleRequest.setGrantOption(Boolean.TRUE);
+        Mockito.when(bizUtil.isUserRangerAdmin(Mockito.anyString())).thenReturn(false);
+        Mockito.when(userMgr.getGroupsForUser(adminLoginID)).thenReturn(new HashSet<>(Collections.singletonList("real-admin-group")));
+        try {
+            Mockito.when(roleStore.getRole(Mockito.anyString())).thenReturn(rangerRole);
+            Mockito.when(roleStore.updateRole(Mockito.any(RangerRole.class), Mockito.anyBoolean(), eq(isRefTableCleanupRequired))).then(AdditionalAnswers.returnsFirstArg());
+        } catch (Exception e) {
+            throw new RuntimeException(e);
+        }
+        RESTResponse resp = roleRest.revokeRole(serviceName, grantRevokeRoleRequest, Mockito.mock(HttpServletRequest.class));
+        Assertions.assertNotNull(resp);
+        Assertions.assertEquals(resp.getStatusCode(), RESTResponse.STATUS_SUCCESS);
+    }
+
+    @Test
+    public void test14gRevokeRoleRealGroupMembershipMismatchRejected() {
+        String serviceName = "serviceName";
+        RangerRole.RoleMember groupMember = new RangerRole.RoleMember("real-admin-group", true);
+        RangerRole rangerRole = new RangerRole("target-role", "target-role", null, new ArrayList<>(), new ArrayList<>(Collections.singletonList(groupMember)));
+        GrantRevokeRoleRequest grantRevokeRoleRequest = createGrantRevokeRoleRequest();
+        rangerRole.setCreatedByUser("target-role");
+        rangerRole.setId(roleId);
+        grantRevokeRoleRequest.setGrantor(adminLoginID);
+        grantRevokeRoleRequest.setGrantOption(Boolean.TRUE);
+        Mockito.when(bizUtil.isUserRangerAdmin(Mockito.anyString())).thenReturn(false);
+        Mockito.when(userMgr.getGroupsForUser(adminLoginID)).thenReturn(new HashSet<>(Arrays.asList("users", "developers")));
+        try {
+            Mockito.when(roleStore.getRole(Mockito.anyString())).thenReturn(rangerRole);
+        } catch (Exception e) {
+            throw new RuntimeException(e);
+        }
+        boolean denied = false;
+        try {
+            roleRest.revokeRole(serviceName, grantRevokeRoleRequest, Mockito.mock(HttpServletRequest.class));
+        } catch (Throwable expected) {
+            denied = true;
+        }
+        Assertions.assertTrue(denied, "expected revokeRole to deny access for mismatched real groups");
+        Mockito.verify(userMgr, Mockito.atLeastOnce()).getGroupsForUser(adminLoginID);
+        try {
+            Mockito.verify(roleStore, Mockito.never()).updateRole(Mockito.any(RangerRole.class), Mockito.anyBoolean(), Mockito.anyBoolean());
+        } catch (Exception e) {
+            throw new RuntimeException(e);
+        }
+    }
+
+    @Test
+    public void test14eRevokeRoleBlockedForAuditor() {
+        Assertions.assertThrows(WebApplicationException.class, () -> {
+            String serviceName = "serviceName";
+            GrantRevokeRoleRequest grantRevokeRoleRequest = createGrantRevokeRoleRequest();
+            Mockito.doThrow(new WebApplicationException()).when(bizUtil).blockAuditorRoleUser();
+            roleRest.revokeRole(serviceName, grantRevokeRoleRequest, Mockito.mock(HttpServletRequest.class));
+        });
+    }
+
     @Test
     public void test15bGetUserRoles() {
         Assertions.assertThrows(Throwable.class, () -> {
@@ -775,6 +983,84 @@ public void test15bGetUserRoles() {
         });
     }
 
+    @Test
+    public void test15cGetUserRolesUnauthorized() {
+        Assertions.assertThrows(Throwable.class, () -> {
+            Mockito.when(bizUtil.isUserRangerAdmin(Mockito.anyString())).thenReturn(false);
+            roleRest.getUserRoles("someOtherUser", Mockito.mock(HttpServletRequest.class));
+            Mockito.verify(restErrorUtil, Mockito.times(1)).createRESTException(Mockito.anyString());
+        });
+    }
+
+    @Test
+    public void test15dGetUserRolesSelfLookupAllowedWithoutAdmin() {
+        Set<RangerRole> rangerRoles = new HashSet<>();
+        RangerRole rangerRole = createRole();
+        rangerRoles.add(rangerRole);
+        Set<String> groups = new HashSet<>();
+        Mockito.when(xUserService.getXUserByUserName(Mockito.anyString())).thenReturn(createVXUser());
+        Mockito.when(userMgr.getGroupsForUser(Mockito.anyString())).thenReturn(groups);
+        try {
+            Mockito.when(roleStore.getRoleNames(Mockito.anyString(), eq(groups))).thenReturn(rangerRoles);
+        } catch (Exception e) {
+            throw new RuntimeException(e);
+        }
+        List<String> returnedRoles = roleRest.getUserRoles(adminLoginID, Mockito.mock(HttpServletRequest.class));
+        Assertions.assertNotNull(returnedRoles);
+        Assertions.assertEquals(returnedRoles.size(), rangerRoles.size());
+    }
+
+    @Test
+    public void test13gGrantRoleImpersonationByPrivilegedCallerHonorsCallerAssertedGroups() {
+        String serviceName = "serviceName";
+        String impersonatedGrantor = "proxy-service-admin";
+        boolean isRefTableCleanupRequired = true;
+        RangerRole.RoleMember groupMember = new RangerRole.RoleMember("asserted-group", true);
+        RangerRole rangerRole = new RangerRole("target-role", "target-role", null, new ArrayList<>(), new ArrayList<>(Collections.singletonList(groupMember)));
+        GrantRevokeRoleRequest grantRevokeRoleRequest = createGrantRevokeRoleRequest();
+
+        rangerRole.setCreatedByUser("target-role");
+        rangerRole.setId(roleId);
+
+        // grantor != the real logged-in user ("admin", per @BeforeEach) -- this is the impersonation branch.
+        grantRevokeRoleRequest.setGrantor(impersonatedGrantor);
+        grantRevokeRoleRequest.setGrantorGroups(new HashSet<>(Collections.singletonList("asserted-group")));
+
+        // the REAL caller (loggedInUser="admin") is a proven ranger admin -- only then is entering the
+        // impersonation branch, and trusting its group assertion for the impersonated user, safe.
+        Mockito.when(bizUtil.isUserRangerAdmin(adminLoginID)).thenReturn(true);
+        Mockito.when(bizUtil.isUserRangerAdmin(Mockito.argThat(u -> !adminLoginID.equals(u)))).thenReturn(false);
+        try {
+            Mockito.when(roleStore.getRole(Mockito.anyString())).thenReturn(rangerRole);
+            Mockito.when(roleStore.updateRole(Mockito.any(RangerRole.class), Mockito.anyBoolean(), eq(isRefTableCleanupRequired))).then(AdditionalAnswers.returnsFirstArg());
+        } catch (Exception e) {
+            throw new RuntimeException(e);
+        }
+        RESTResponse resp = roleRest.grantRole(serviceName, grantRevokeRoleRequest, Mockito.mock(HttpServletRequest.class));
+        Assertions.assertNotNull(resp);
+        Assertions.assertEquals(RESTResponse.STATUS_SUCCESS, resp.getStatusCode());
+        // the impersonated user's groups came from the request, not a directory lookup
+        Mockito.verify(userMgr, Mockito.never()).getGroupsForUser(impersonatedGrantor);
+    }
+
+    @Test
+    public void test13hGrantRoleImpersonationByUnprivilegedCallerRejectedRegardlessOfAssertedGroups() {
+        Assertions.assertThrows(Throwable.class, () -> {
+            String                 serviceName            = "serviceName";
+            String                 impersonatedGrantor    = "proxy-service-admin";
+            GrantRevokeRoleRequest grantRevokeRoleRequest = createGrantRevokeRoleRequest();
+            grantRevokeRoleRequest.setGrantor(impersonatedGrantor);
+            grantRevokeRoleRequest.setGrantorGroups(new HashSet<>(Collections.singletonList("asserted-group")));
+            // the REAL caller ("admin") is NOT a ranger admin and NOT a service admin/service user for
+            // this service -- the impersonation gate rejects before the target role is even looked up
+            // (roleStore.getRole() is never reached on this path), so callerAssertedGroups is never read.
+            Mockito.when(bizUtil.isUserRangerAdmin(Mockito.anyString())).thenReturn(false);
+            Mockito.when(svcStore.isServiceAdminUser(Mockito.anyString(), Mockito.anyString())).thenReturn(false);
+            roleRest.grantRole(serviceName, grantRevokeRoleRequest, Mockito.mock(HttpServletRequest.class));
+            Mockito.verify(restErrorUtil, Mockito.times(1)).createRESTException(Mockito.anyString());
+        });
+    }
+
     @Test
     public void test16bGetRangerRolesIfUpdated() {
         Assertions.assertThrows(Throwable.class, () -> {