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 {