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 fe7ab17f2e [#10294] feat(authz): Support scoped MANAGE_GRANTS for 
delegated privilege management (#10276)
fe7ab17f2e is described below

commit fe7ab17f2e4c9e6b8db4d42a952a8b722cb6710d
Author: Bharath Krishna <[email protected]>
AuthorDate: Tue Mar 10 09:24:02 2026 -0700

    [#10294] feat(authz): Support scoped MANAGE_GRANTS for delegated privilege 
management (#10276)
    
    ### What changes were proposed in this pull request?
    
    Allow MANAGE_GRANTS to be bound to CATALOG/SCHEMA/TABLE/VIEW in addition
    to METALAKE, enabling SQL WITH GRANT OPTION-style delegation.
    
    ### Why are the changes needed?
    
    Otherwise, before , the grants were only possible from users who have
    MANAGE_GRANT at metalake level.
    There was no option of assigning MANAGE_GRANT at catalog/schema/table
    level
    
    https://github.com/apache/gravitino/discussions/10267
    
    Fix: #10294
    
    ### Does this PR introduce _any_ user-facing change?
    
    No
    
    ### How was this patch tested?
    
    Added unit tests
---
 .../apache/gravitino/authorization/Privileges.java |  28 +++-
 .../authorization/TestSecurableObjects.java        |  14 +-
 docs/security/access-control.md                    |   6 +-
 .../authorization/jcasbin/JcasbinAuthorizer.java   |  35 +++--
 .../jcasbin/TestJcasbinAuthorizer.java             | 142 +++++++++++++++++++++
 5 files changed, 205 insertions(+), 20 deletions(-)

diff --git 
a/api/src/main/java/org/apache/gravitino/authorization/Privileges.java 
b/api/src/main/java/org/apache/gravitino/authorization/Privileges.java
index 34944e3664..29b273189d 100644
--- a/api/src/main/java/org/apache/gravitino/authorization/Privileges.java
+++ b/api/src/main/java/org/apache/gravitino/authorization/Privileges.java
@@ -64,6 +64,23 @@ public class Privileges {
           MetadataObject.Type.SCHEMA,
           MetadataObject.Type.VIEW);
 
+  /**
+   * Object types that {@link ManageGrants} can be bound to.
+   *
+   * <p>Binding at a parent level implicitly covers all children — for 
example, a grant on a SCHEMA
+   * lets the holder manage privileges on every TABLE, VIEW, TOPIC, FILESET, 
and MODEL inside it.
+   */
+  private static final Set<MetadataObject.Type> MANAGE_GRANTS_SUPPORTED_TYPES =
+      Sets.immutableEnumSet(
+          MetadataObject.Type.METALAKE,
+          MetadataObject.Type.CATALOG,
+          MetadataObject.Type.SCHEMA,
+          MetadataObject.Type.TABLE,
+          MetadataObject.Type.VIEW,
+          MetadataObject.Type.TOPIC,
+          MetadataObject.Type.FILESET,
+          MetadataObject.Type.MODEL);
+
   /**
    * Returns the Privilege with allow condition from the string representation.
    *
@@ -836,7 +853,14 @@ public class Privileges {
     }
   }
 
-  /** The privilege to grant or revoke a role for the user or the group. */
+  /**
+   * The privilege to grant or revoke privileges on securable objects. If 
bound on the metalake, we
+   * can grant or revoke the role for users or groups.
+   *
+   * <p>Unlike most privileges, this can be bound at any level of the object 
hierarchy — METALAKE,
+   * CATALOG, SCHEMA, TABLE, VIEW, TOPIC, FILESET, or MODEL. A grant at a 
parent level implicitly
+   * covers all descendants within it.
+   */
   public static class ManageGrants extends GenericPrivilege<ManageGrants> {
     private static final ManageGrants ALLOW_INSTANCE =
         new ManageGrants(Condition.ALLOW, Name.MANAGE_GRANTS);
@@ -863,7 +887,7 @@ public class Privileges {
 
     @Override
     public boolean canBindTo(MetadataObject.Type type) {
-      return type == MetadataObject.Type.METALAKE;
+      return MANAGE_GRANTS_SUPPORTED_TYPES.contains(type);
     }
   }
 
diff --git 
a/api/src/test/java/org/apache/gravitino/authorization/TestSecurableObjects.java
 
b/api/src/test/java/org/apache/gravitino/authorization/TestSecurableObjects.java
index c622fd7d63..f0bf9b90dc 100644
--- 
a/api/src/test/java/org/apache/gravitino/authorization/TestSecurableObjects.java
+++ 
b/api/src/test/java/org/apache/gravitino/authorization/TestSecurableObjects.java
@@ -372,13 +372,15 @@ public class TestSecurableObjects {
     Assertions.assertFalse(manageGroups.canBindTo(MetadataObject.Type.ROLE));
     Assertions.assertFalse(manageGroups.canBindTo(MetadataObject.Type.COLUMN));
 
-    // Test manager grants
+    // Test manager grants — MANAGE_GRANTS can be scoped to any object level
     
Assertions.assertTrue(manageGrants.canBindTo(MetadataObject.Type.METALAKE));
-    
Assertions.assertFalse(manageGrants.canBindTo(MetadataObject.Type.CATALOG));
-    Assertions.assertFalse(manageGrants.canBindTo(MetadataObject.Type.SCHEMA));
-    Assertions.assertFalse(manageGrants.canBindTo(MetadataObject.Type.TABLE));
-    Assertions.assertFalse(manageGrants.canBindTo(MetadataObject.Type.TOPIC));
-    
Assertions.assertFalse(manageGrants.canBindTo(MetadataObject.Type.FILESET));
+    Assertions.assertTrue(manageGrants.canBindTo(MetadataObject.Type.CATALOG));
+    Assertions.assertTrue(manageGrants.canBindTo(MetadataObject.Type.SCHEMA));
+    Assertions.assertTrue(manageGrants.canBindTo(MetadataObject.Type.TABLE));
+    Assertions.assertTrue(manageGrants.canBindTo(MetadataObject.Type.TOPIC));
+    Assertions.assertTrue(manageGrants.canBindTo(MetadataObject.Type.FILESET));
+    Assertions.assertTrue(manageGrants.canBindTo(MetadataObject.Type.VIEW));
+    Assertions.assertTrue(manageGrants.canBindTo(MetadataObject.Type.MODEL));
     Assertions.assertFalse(manageGrants.canBindTo(MetadataObject.Type.ROLE));
     Assertions.assertFalse(manageGrants.canBindTo(MetadataObject.Type.COLUMN));
 
diff --git a/docs/security/access-control.md b/docs/security/access-control.md
index 1724cdde07..bed791f403 100644
--- a/docs/security/access-control.md
+++ b/docs/security/access-control.md
@@ -272,7 +272,7 @@ Gravitino provides a comprehensive set of privileges 
organized by the type of op
 
 | Name          | Supports Securable Object | Operation                        
                                                                             |
 
|---------------|---------------------------|---------------------------------------------------------------------------------------------------------------|
-| MANAGE_GRANTS | Metalake                  | Manages roles granted to or 
revoked from the user or group, and privilege granted to or revoked from the 
role |
+| MANAGE_GRANTS | Metalake, Catalog, Schema, Table, View, Topic, Fileset, 
Model | Grants the ability to manage privileges on securable objects. When 
bound to a **Metalake**, also allows assigning and revoking roles for users and 
groups across the entire metalake. When bound to a **Catalog, Schema, Table, 
View, Topic, Fileset, or Model**, privilege management is scoped to that object 
and its descendants only. |
 
 ### Catalog privileges
 
@@ -1324,8 +1324,8 @@ The following table lists the required privileges for 
each API.
 | list roles                        | `MANAGE_GRANTS` on the metalake or the 
owner of the metalake can see all the roles. Others can see his granted roles 
or owned roles.                                                                 
                                         |
 | grant role                        | `MANAGE_GRANTS` on the metalake          
                                                                                
                                                                                
                                     |
 | revoke role                       | `MANAGE_GRANTS` on the metalake          
                                                                                
                                                                                
                                     |
-| grant privilege                   | `MANAGE_GRANTS` on the metalake or the 
owner of the securable object or the metalake                                   
                                                                                
                                       |
-| revoke privilege                  | `MANAGE_GRANTS` on the metalake or the 
owner of the securable object or the metalake                                   
                                                                                
                                       |
+| grant privilege                   | `MANAGE_GRANTS` on the securable object, 
or any ancestor of it (Schema, Catalog, Metalake), or the owner of the 
securable object or the metalake                                                
                                              |
+| revoke privilege                  | `MANAGE_GRANTS` on the securable object, 
or any ancestor of it (Schema, Catalog, Metalake), or the owner of the 
securable object or the metalake                                                
                                             |
 | override privilege                | `MANAGE_GRANTS` on the metalake or the 
owner of the metalake                                                           
                                                                                
                                       |
 | set owner                         | The owner of the securable object        
                                                                                
                                                                                
                                     |
 | list tags                         | The owner of the metalake can see all 
the tags, others can see the tags which they can load.                          
                                                                                
                                        |
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 cea47e353b..7630f71ae3 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
@@ -371,15 +371,32 @@ public class JcasbinAuthorizer implements 
GravitinoAuthorizer {
   public boolean hasMetadataPrivilegePermission(
       String metalake, String type, String fullName, 
AuthorizationRequestContext requestContext) {
     Principal currentPrincipal = PrincipalUtils.getCurrentPrincipal();
-    MetadataObject metalakeMetadataObject =
-        MetadataObjects.of(ImmutableList.of(metalake), 
MetadataObject.Type.METALAKE);
-    return authorize(
-            currentPrincipal,
-            metalake,
-            metalakeMetadataObject,
-            Privilege.Name.MANAGE_GRANTS,
-            requestContext)
-        || hasSetOwnerPermission(metalake, type, fullName, requestContext);
+    // Check whether the principal holds MANAGE_GRANTS on the target object or 
any ancestor.
+    // A grant at a broader level (e.g. CATALOG or SCHEMA) implicitly covers 
all objects beneath it.
+    MetadataObject.Type metadataType;
+    try {
+      metadataType = MetadataObject.Type.valueOf(type.toUpperCase());
+    } catch (IllegalArgumentException e) {
+      throw new IllegalArgumentException("Unknown metadata object type: " + 
type, e);
+    }
+    // Build the full ancestor chain from the target object up to and 
including the metalake.
+    // MetadataObjects.parent(CATALOG) returns null (CATALOG is a root in the 
parent API), so the
+    // metalake is appended manually at the end.
+    List<MetadataObject> chain = new ArrayList<>();
+    for (MetadataObject obj = MetadataObjects.parse(fullName, metadataType);
+        obj != null;
+        obj = MetadataObjects.parent(obj)) {
+      chain.add(obj);
+    }
+    chain.add(MetadataObjects.of(ImmutableList.of(metalake), 
MetadataObject.Type.METALAKE));
+
+    for (MetadataObject obj : chain) {
+      if (authorize(
+          currentPrincipal, metalake, obj, Privilege.Name.MANAGE_GRANTS, 
requestContext)) {
+        return true;
+      }
+    }
+    return hasSetOwnerPermission(metalake, type, fullName, requestContext);
   }
 
   @Override
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 4388ef0952..7bafa045db 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
@@ -21,6 +21,7 @@ import static 
org.apache.gravitino.authorization.Privilege.Name.USE_CATALOG;
 import static org.junit.jupiter.api.Assertions.assertFalse;
 import static org.junit.jupiter.api.Assertions.assertNotNull;
 import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
 import static org.junit.jupiter.api.Assertions.assertTrue;
 import static org.mockito.ArgumentMatchers.any;
 import static org.mockito.ArgumentMatchers.eq;
@@ -446,6 +447,147 @@ public class TestJcasbinAuthorizer {
     assertNotNull(ownerRel, "ownerRel cache should be initialized");
   }
 
+  /** Tests {@link JcasbinAuthorizer#hasMetadataPrivilegePermission} hierarchy 
walk */
+  @Test
+  public void testHasMetadataPrivilegePermission() throws Exception {
+    makeCompletableFutureUseCurrentThread(jcasbinAuthorizer);
+    NameIdentifier userNameIdentifier = NameIdentifierUtil.ofUser(METALAKE, 
USERNAME);
+
+    // --- Case 1: no MANAGE_GRANTS anywhere → false ---
+    when(supportsRelationOperations.listEntitiesByRelation(
+            eq(SupportsRelationOperations.Type.ROLE_USER_REL),
+            eq(userNameIdentifier),
+            eq(Entity.EntityType.USER)))
+        .thenReturn(ImmutableList.of());
+    assertFalse(
+        jcasbinAuthorizer.hasMetadataPrivilegePermission(
+            METALAKE,
+            "TABLE",
+            "testCatalog.testSchema.testTable",
+            new AuthorizationRequestContext()),
+        "No MANAGE_GRANTS grants should return false");
+
+    // --- Case 2: METALAKE-level MANAGE_GRANTS covers a TABLE ---
+    Long metalakeGrantRoleId = 201L;
+    RoleEntity metalakeGrantRole =
+        getRoleEntity(
+            metalakeGrantRoleId,
+            "metalakeGrantRole",
+            ImmutableList.of(
+                buildManageGrantsSecurableObject(
+                    metalakeGrantRoleId, MetadataObject.Type.METALAKE, 
METALAKE)));
+    when(entityStore.get(
+            eq(NameIdentifierUtil.ofRole(METALAKE, metalakeGrantRole.name())),
+            eq(Entity.EntityType.ROLE),
+            eq(RoleEntity.class)))
+        .thenReturn(metalakeGrantRole);
+    when(supportsRelationOperations.listEntitiesByRelation(
+            eq(SupportsRelationOperations.Type.ROLE_USER_REL),
+            eq(userNameIdentifier),
+            eq(Entity.EntityType.USER)))
+        .thenReturn(ImmutableList.of(metalakeGrantRole));
+    assertTrue(
+        jcasbinAuthorizer.hasMetadataPrivilegePermission(
+            METALAKE,
+            "TABLE",
+            "testCatalog.testSchema.testTable",
+            new AuthorizationRequestContext()),
+        "METALAKE-level MANAGE_GRANTS should cover TABLE within it");
+
+    // --- Case 3: CATALOG-level MANAGE_GRANTS covers TABLE/SCHEMA ---
+    Long catalogGrantRoleId = 200L;
+    RoleEntity catalogGrantRole =
+        getRoleEntity(
+            catalogGrantRoleId,
+            "catalogGrantRole",
+            ImmutableList.of(
+                buildManageGrantsSecurableObject(
+                    catalogGrantRoleId, MetadataObject.Type.CATALOG, 
"testCatalog")));
+    when(entityStore.get(
+            eq(NameIdentifierUtil.ofRole(METALAKE, catalogGrantRole.name())),
+            eq(Entity.EntityType.ROLE),
+            eq(RoleEntity.class)))
+        .thenReturn(catalogGrantRole);
+    when(supportsRelationOperations.listEntitiesByRelation(
+            eq(SupportsRelationOperations.Type.ROLE_USER_REL),
+            eq(userNameIdentifier),
+            eq(Entity.EntityType.USER)))
+        .thenReturn(ImmutableList.of(catalogGrantRole));
+    assertTrue(
+        jcasbinAuthorizer.hasMetadataPrivilegePermission(
+            METALAKE,
+            "TABLE",
+            "testCatalog.testSchema.testTable",
+            new AuthorizationRequestContext()),
+        "CATALOG-level MANAGE_GRANTS should cover TABLE within it");
+    assertTrue(
+        jcasbinAuthorizer.hasMetadataPrivilegePermission(
+            METALAKE, "SCHEMA", "testCatalog.testSchema", new 
AuthorizationRequestContext()),
+        "CATALOG-level MANAGE_GRANTS should cover SCHEMA within it");
+
+    // --- Case 4: TABLE-level MANAGE_GRANTS covers the table itself ---
+    Long tableGrantRoleId = 202L;
+    RoleEntity tableGrantRole =
+        getRoleEntity(
+            tableGrantRoleId,
+            "tableGrantRole",
+            ImmutableList.of(
+                buildManageGrantsSecurableObject(
+                    tableGrantRoleId,
+                    MetadataObject.Type.TABLE,
+                    "testCatalog.testSchema.testTable")));
+    when(entityStore.get(
+            eq(NameIdentifierUtil.ofRole(METALAKE, tableGrantRole.name())),
+            eq(Entity.EntityType.ROLE),
+            eq(RoleEntity.class)))
+        .thenReturn(tableGrantRole);
+    when(supportsRelationOperations.listEntitiesByRelation(
+            eq(SupportsRelationOperations.Type.ROLE_USER_REL),
+            eq(userNameIdentifier),
+            eq(Entity.EntityType.USER)))
+        .thenReturn(ImmutableList.of(tableGrantRole));
+    assertTrue(
+        jcasbinAuthorizer.hasMetadataPrivilegePermission(
+            METALAKE,
+            "TABLE",
+            "testCatalog.testSchema.testTable",
+            new AuthorizationRequestContext()),
+        "TABLE-level MANAGE_GRANTS should cover itself");
+
+    // --- Case 5: invalid type string → IllegalArgumentException ---
+    assertThrows(
+        IllegalArgumentException.class,
+        () ->
+            jcasbinAuthorizer.hasMetadataPrivilegePermission(
+                METALAKE, "INVALID_TYPE", "testCatalog", new 
AuthorizationRequestContext()));
+  }
+
+  /**
+   * Builds a {@link SecurableObject} carrying an ALLOW {@code MANAGE_GRANTS} 
privilege bound to
+   * {@code type} with the shared test metadata ID ({@link #CATALOG_ID}).
+   */
+  private static SecurableObject buildManageGrantsSecurableObject(
+      Long roleId, MetadataObject.Type type, String objectName) {
+    try {
+      ImmutableList<String> privilegeNames = ImmutableList.of("MANAGE_GRANTS");
+      ImmutableList<String> conditions = ImmutableList.of("ALLOW");
+      SecurableObjectPO po =
+          SecurableObjectPO.builder()
+              .withType(String.valueOf(type))
+              .withMetadataObjectId(CATALOG_ID)
+              .withRoleId(roleId)
+              
.withPrivilegeNames(objectMapper.writeValueAsString(privilegeNames))
+              
.withPrivilegeConditions(objectMapper.writeValueAsString(conditions))
+              .withDeletedAt(0L)
+              .withCurrentVersion(1L)
+              .withLastVersion(1L)
+              .build();
+      return POConverters.fromSecurableObjectPO(objectName, po, type);
+    } catch (JsonProcessingException e) {
+      throw new RuntimeException(e);
+    }
+  }
+
   @SuppressWarnings("unchecked")
   private static Cache<Long, Boolean> getLoadedRolesCache(JcasbinAuthorizer 
authorizer)
       throws Exception {

Reply via email to