This is an automated email from the ASF dual-hosted git repository.
yuqi1129 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 9696477781 [#11775] improvement(authz): Short-circuit list
authorization via parent-scope grant (#11778)
9696477781 is described below
commit 96964777819dc92a60f58cfe2fd400dc5f9d6108
Author: Qi Yu <[email protected]>
AuthorDate: Wed Jul 1 09:12:56 2026 +0800
[#11775] improvement(authz): Short-circuit list authorization via
parent-scope grant (#11778)
### What changes were proposed in this pull request?
When authorization is enabled, list operations evaluated one per-object
permission check per result, producing an N+1 explosion. This PR
evaluates the **ancestor-only portion** of the filter expression
**once** per list request: if a privilege is granted (or the object is
owned) at a parent scope (metalake/catalog/schema), every child is
visible unless an object-level deny overrides it.
- New parent-scope expressions in `AuthorizationExpressionConstants` for
`TABLE`/`SCHEMA`/`CATALOG`.
- `MetadataAuthzHelper.filterByExpression(…, NameIdentifier[])` attempts
the short-circuit before the per-object loop. One insertion point covers
both Gravitino REST (`listTables`/`listSchemas`/`listCatalogs`) and
Iceberg REST (`listTable`/`listNamespace`), since they all route through
this helper.
- New SPI method `GravitinoAuthorizer#hasDenyPolicyOnType` (conservative
default `true`); `JcasbinAuthorizer` implements it as an in-memory scan
of the current user's deny-enforcer policies. The short-circuit is taken
only when no object-level deny may exist for the relevant
type/privileges, otherwise it falls back to the existing per-object
filtering.
### Why are the changes needed?
Per #11775, with authorization on, listing a schema of ~10,496 tables
took ~134s (vs ~4s with auth off) — a ~70x regression — because
permission was checked once per table. Owner/grant at a parent scope
makes every child visible (OR semantics); only an object-level deny can
subtract, so the ancestor part is a loop-invariant that can be lifted
out of the per-object loop. Ancestor-level denies are already handled
inside the parent-scope expression's `!ANY(DENY_..., METALAKE, CATALOG,
SCHEMA)` terms.
Fix: #11775
### Does this PR introduce _any_ user-facing change?
No. The new method on the `GravitinoAuthorizer` SPI is a
backward-compatible default method (default returns `true`, i.e. no
short-circuit), and authorization decisions are unchanged.
### How was this patch tested?
New unit tests:
- `TestMetadataAuthzHelper`: parent grant + no deny returns the whole
list; parent grant + possible deny falls back and excludes the denied
table; schema list short-circuits via a catalog-level grant; catalog
list short-circuits via a metalake-level grant.
- `TestJcasbinAuthorizer#testHasDenyPolicyOnType`: detects an
object-level deny for the matching type/privilege and ignores
non-matching type/privilege.
Full `:server-common` test suite passes; `:server`,
`:iceberg:iceberg-rest-server`, and `:core` compile clean.
---
.../authorization/GravitinoAuthorizer.java | 32 +++
.../server/authorization/MetadataAuthzHelper.java | 92 ++++++
.../AuthorizationExpressionConstants.java | 42 +++
.../authorization/jcasbin/JcasbinAuthorizer.java | 55 ++++
.../authorization/TestMetadataAuthzHelper.java | 314 +++++++++++++++++++++
.../jcasbin/TestJcasbinAuthorizer.java | 65 +++++
6 files changed, 600 insertions(+)
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 f072c15684..beb9a50c41 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.Set;
import javax.annotation.Nullable;
import org.apache.gravitino.Entity;
import org.apache.gravitino.GravitinoEnv;
@@ -107,6 +108,37 @@ public interface GravitinoAuthorizer extends Closeable {
*/
boolean isMetalakeUser(String metalake, AuthorizationRequestContext
requestContext);
+ /**
+ * Determines whether the given principal may have any {@code DENY} policy,
for any of the given
+ * privileges, at any scope (metalake, catalog, schema or the object itself)
within the metalake.
+ *
+ * <p>This supports list-authorization short-circuiting: when a privilege is
granted at a parent
+ * scope (metalake, catalog or schema), every child object is visible
<em>unless</em> a {@code
+ * DENY} overrides it at some scope. A {@code false} return therefore lets
the caller skip
+ * per-object authorization for the whole list and return every identifier;
a {@code true} return
+ * forces the caller to fall back to per-object authorization.
+ *
+ * <p>The query is scope-agnostic on purpose: a deny granted at a parent
scope hides the whole
+ * subtree, while a deny granted on a single object hides just that object.
Both cases must
+ * disable the short-circuit, so the privilege match is not restricted to a
metadata type.
+ *
+ * <p>The default implementation conservatively returns {@code true} so that
authorizers which
+ * cannot answer the question never enable an unsafe short-circuit.
+ *
+ * @param principal the user principal
+ * @param metalake the metalake
+ * @param privileges the privileges whose denies would affect visibility
+ * @param requestContext authorization request context
+ * @return whether a deny for any of the given privileges may exist
+ */
+ default boolean hasDenyPolicy(
+ Principal principal,
+ String metalake,
+ Set<Privilege.Name> privileges,
+ AuthorizationRequestContext requestContext) {
+ return true;
+ }
+
/**
* Determine whether the user can set owner
*
diff --git
a/server-common/src/main/java/org/apache/gravitino/server/authorization/MetadataAuthzHelper.java
b/server-common/src/main/java/org/apache/gravitino/server/authorization/MetadataAuthzHelper.java
index b63251fd96..b71fb460ba 100644
---
a/server-common/src/main/java/org/apache/gravitino/server/authorization/MetadataAuthzHelper.java
+++
b/server-common/src/main/java/org/apache/gravitino/server/authorization/MetadataAuthzHelper.java
@@ -26,6 +26,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.CompletableFuture;
import java.util.concurrent.Executor;
import java.util.concurrent.Executors;
@@ -42,6 +43,7 @@ import org.apache.gravitino.Namespace;
import org.apache.gravitino.SupportsRelationOperations;
import org.apache.gravitino.authorization.AuthorizationRequestContext;
import org.apache.gravitino.authorization.GravitinoAuthorizer;
+import org.apache.gravitino.authorization.Privilege;
import org.apache.gravitino.dto.tag.MetadataObjectDTO;
import
org.apache.gravitino.server.authorization.expression.AuthorizationExpressionConstants;
import
org.apache.gravitino.server.authorization.expression.AuthorizationExpressionEvaluator;
@@ -87,6 +89,43 @@ public class MetadataAuthzHelper {
private static final List<Entity.EntityType> REQUIRE_SCHEMA_EXISTS =
Arrays.asList(Entity.EntityType.TABLE, Entity.EntityType.TOPIC);
+ /**
+ * Registry of list-authorization short-circuits keyed by the listed
object's entity type. Each
+ * entry pairs the per-object filter expression it applies to with the
parent-scope expression to
+ * evaluate once and the privileges whose object-level denies would defeat
the short-circuit.
+ */
+ private static final Map<Entity.EntityType, ListShortCircuit>
LIST_SHORT_CIRCUITS =
+ Map.of(
+ Entity.EntityType.TABLE,
+ new ListShortCircuit(
+
AuthorizationExpressionConstants.FILTER_TABLE_AUTHORIZATION_EXPRESSION,
+
AuthorizationExpressionConstants.TABLE_LIST_PARENT_SCOPE_AUTHORIZATION_EXPRESSION,
+ Set.of(Privilege.Name.SELECT_TABLE,
Privilege.Name.MODIFY_TABLE)),
+ Entity.EntityType.SCHEMA,
+ new ListShortCircuit(
+
AuthorizationExpressionConstants.FILTER_SCHEMA_AUTHORIZATION_EXPRESSION,
+
AuthorizationExpressionConstants.SCHEMA_LIST_PARENT_SCOPE_AUTHORIZATION_EXPRESSION,
+ Set.of(Privilege.Name.USE_SCHEMA)),
+ Entity.EntityType.CATALOG,
+ new ListShortCircuit(
+
AuthorizationExpressionConstants.LOAD_CATALOG_AUTHORIZATION_EXPRESSION,
+
AuthorizationExpressionConstants.CATALOG_LIST_PARENT_SCOPE_AUTHORIZATION_EXPRESSION,
+ Set.of(Privilege.Name.USE_CATALOG)));
+
+ /** Immutable description of a single list-authorization short-circuit. */
+ private static final class ListShortCircuit {
+ private final String filterExpression;
+ private final String parentScopeExpression;
+ private final Set<Privilege.Name> denyPrivileges;
+
+ private ListShortCircuit(
+ String filterExpression, String parentScopeExpression,
Set<Privilege.Name> denyPrivileges) {
+ this.filterExpression = filterExpression;
+ this.parentScopeExpression = parentScopeExpression;
+ this.denyPrivileges = denyPrivileges;
+ }
+ }
+
private MetadataAuthzHelper() {}
public static Metalake[] filterMetalakes(Metalake[] metalakes, String
expression) {
@@ -167,11 +206,64 @@ public class MetadataAuthzHelper {
String expression,
Entity.EntityType entityType,
NameIdentifier[] nameIdentifiers) {
+ if (enableAuthorization()
+ && nameIdentifiers.length > 0
+ && allVisibleViaParentScope(metalake, expression, entityType,
nameIdentifiers)) {
+ // A privilege granted at a parent scope (metalake/catalog/schema) makes
every object in the
+ // list visible, and no object-level deny exists, so the per-object
authorization loop is
+ // skipped entirely. See
AuthorizationExpressionConstants.*_LIST_PARENT_SCOPE_*.
+ return nameIdentifiers;
+ }
preloadToCache(entityType, nameIdentifiers);
preloadOwner(entityType, nameIdentifiers);
return filterByExpression(metalake, expression, entityType,
nameIdentifiers, e -> e);
}
+ /**
+ * Attempts the list-authorization short-circuit: when the listed objects
all share one parent and
+ * the matching filter expression is granted at a parent scope, the whole
list is visible unless
+ * an object-level deny may exist. Returns {@code true} only when it is safe
to return every
+ * identifier without per-object authorization.
+ */
+ private static boolean allVisibleViaParentScope(
+ String metalake,
+ String expression,
+ Entity.EntityType entityType,
+ NameIdentifier[] nameIdentifiers) {
+ ListShortCircuit spec = LIST_SHORT_CIRCUITS.get(entityType);
+ if (spec == null || !spec.filterExpression.equals(expression)) {
+ return false;
+ }
+
+ // The short-circuit reasons about a single parent scope, so every
identifier must share it.
+ Namespace parent = nameIdentifiers[0].namespace();
+ for (NameIdentifier ident : nameIdentifiers) {
+ if (!ident.namespace().equals(parent)) {
+ return false;
+ }
+ }
+
+ Principal principal = PrincipalUtils.getCurrentPrincipal();
+ GravitinoAuthorizer authorizer =
+ GravitinoAuthorizerProvider.getInstance().getGravitinoAuthorizer();
+ AuthorizationRequestContext requestContext = new
AuthorizationRequestContext();
+
requestContext.setOriginalAuthorizationExpression(spec.parentScopeExpression);
+ Map<Entity.EntityType, NameIdentifier> metadataNames =
+ NameIdentifierUtil.splitNameIdentifier(metalake, entityType,
nameIdentifiers[0]);
+
+ boolean parentGrantsAccess =
+ new AuthorizationExpressionEvaluator(spec.parentScopeExpression,
authorizer)
+ .evaluate(metadataNames, requestContext, principal,
Optional.empty());
+ if (!parentGrantsAccess) {
+ return false;
+ }
+
+ // Parent scope grants access to every object; the only thing that can
still hide one is a
+ // deny on these privileges (at the parent scope or on an individual
object), so the
+ // short-circuit is only safe when no such deny may exist.
+ return !authorizer.hasDenyPolicy(principal, metalake, spec.denyPrivileges,
requestContext);
+ }
+
/**
* Call {@link AuthorizationExpressionEvaluator} to check access
*
diff --git
a/server-common/src/main/java/org/apache/gravitino/server/authorization/expression/AuthorizationExpressionConstants.java
b/server-common/src/main/java/org/apache/gravitino/server/authorization/expression/AuthorizationExpressionConstants.java
index 122bdfcd01..c8fd3d07d1 100644
---
a/server-common/src/main/java/org/apache/gravitino/server/authorization/expression/AuthorizationExpressionConstants.java
+++
b/server-common/src/main/java/org/apache/gravitino/server/authorization/expression/AuthorizationExpressionConstants.java
@@ -166,6 +166,48 @@ public class AuthorizationExpressionConstants {
ANY_MODIFY_TABLE
""";
+ /**
+ * Parent-scope expression used to short-circuit table-list authorization.
It is the ancestor-only
+ * portion of {@link #FILTER_TABLE_AUTHORIZATION_EXPRESSION}: ownership of,
or a SELECT/MODIFY
+ * grant at, the metalake/catalog/schema scope means every table in the
schema is visible.
+ * Evaluated once per list request instead of once per table.
+ *
+ * <p>This expression only checks the positive parent-scope grant; deny
coverage (at any scope) is
+ * handled separately by {@link
+ * org.apache.gravitino.authorization.GravitinoAuthorizer#hasDenyPolicy} so
the short-circuit
+ * falls back to per-object authorization whenever a relevant deny may exist.
+ */
+ public static final String TABLE_LIST_PARENT_SCOPE_AUTHORIZATION_EXPRESSION =
+ """
+ ANY(OWNER, METALAKE, CATALOG, SCHEMA) ||
+ ANY(SELECT_TABLE, METALAKE, CATALOG, SCHEMA) ||
+ ANY(MODIFY_TABLE, METALAKE, CATALOG, SCHEMA)
+ """;
+
+ /**
+ * Parent-scope expression used to short-circuit schema-list authorization.
Ancestor-only portion
+ * of {@link #FILTER_SCHEMA_AUTHORIZATION_EXPRESSION}: ownership of, or a
USE_SCHEMA grant at, the
+ * metalake/catalog scope means every schema in the catalog is visible. Deny
coverage is handled
+ * by {@link
org.apache.gravitino.authorization.GravitinoAuthorizer#hasDenyPolicy}.
+ */
+ public static final String SCHEMA_LIST_PARENT_SCOPE_AUTHORIZATION_EXPRESSION
=
+ """
+ ANY(OWNER, METALAKE, CATALOG) ||
+ ANY(USE_SCHEMA, METALAKE, CATALOG)
+ """;
+
+ /**
+ * Parent-scope expression used to short-circuit catalog-list authorization.
Ancestor-only portion
+ * of {@link #LOAD_CATALOG_AUTHORIZATION_EXPRESSION}: metalake ownership, or
a USE_CATALOG grant
+ * at the metalake scope, means every catalog is visible. Deny coverage is
handled by {@link
+ * org.apache.gravitino.authorization.GravitinoAuthorizer#hasDenyPolicy}.
+ */
+ public static final String
CATALOG_LIST_PARENT_SCOPE_AUTHORIZATION_EXPRESSION =
+ """
+ METALAKE::OWNER ||
+ ANY(USE_CATALOG, METALAKE)
+ """;
+
public static final String FILTER_VIEW_AUTHORIZATION_EXPRESSION =
"""
ANY(OWNER, METALAKE, CATALOG, SCHEMA, VIEW) ||
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 347900e606..231f092bbf 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
@@ -115,6 +115,16 @@ public class JcasbinAuthorizer implements
GravitinoAuthorizer {
private static final Logger LOG =
LoggerFactory.getLogger(JcasbinAuthorizer.class);
+ /**
+ * Field index of {@code sub} (the role/user/group id) in a jcasbin {@code
p} policy row. See the
+ * {@code policy_definition} in {@code jcasbin_model.conf}: {@code p = sub,
metadataType,
+ * metadataId, act, eft}.
+ */
+ private static final int POLICY_SUBJECT_FIELD_INDEX = 0;
+
+ /** Field index of {@code act} (the privilege) in a jcasbin {@code p} policy
row. */
+ private static final int POLICY_ACTION_FIELD_INDEX = 3;
+
/** Jcasbin enforcer is used for metadata authorization. */
private Enforcer allowEnforcer;
@@ -363,6 +373,51 @@ public class JcasbinAuthorizer implements
GravitinoAuthorizer {
return loadUserInfo(metalake, currentUserName, requestContext).isPresent();
}
+ @Override
+ public boolean hasDenyPolicy(
+ Principal principal,
+ String metalake,
+ Set<Privilege.Name> privileges,
+ AuthorizationRequestContext requestContext) {
+ Optional<UserUpdatedAt> userInfoOpt =
+ loadUserInfo(metalake, principal.getName(), requestContext);
+ if (!userInfoOpt.isPresent()) {
+ // An unknown user holds no roles and therefore no deny policies.
+ return false;
+ }
+ UserUpdatedAt userInfo = userInfoOpt.get();
+ long userId = userInfo.getUserId();
+ // Bind the user's (direct + group-inherited) roles into the enforcers so
the in-memory scan
+ // below sees every policy the user can carry. Idempotent within a request.
+ loadRolePrivilege(metalake, principal.getName(), userId, userInfo,
requestContext);
+
+ Set<String> privilegeNames =
privileges.stream().map(Enum::name).collect(Collectors.toSet());
+ String userIdStr = String.valueOf(userId);
+ // This is an existence query, not a per-object check: it answers "does
any deny on these
+ // privileges exist for the user's roles, at any scope?" The standard
enforce path needs a
+ // concrete metadataId, so reusing it would mean iterating every listed
object and defeat the
+ // short-circuit. Filtering the deny enforcer's policies by role keeps the
scan bounded by the
+ // user's role/policy count, never by the number of listed objects. The
match is intentionally
+ // scope-agnostic (no metadataType filter): a parent-scope deny hides the
whole subtree and an
+ // object-scope deny hides one object, and both must disable the
short-circuit.
+ for (String roleId : denyEnforcer.getRolesForUser(userIdStr)) {
+ // getFilteredNamedPolicy returns every "p" row (p = sub, metadataType,
metadataId, act, eft)
+ // whose field at POLICY_SUBJECT_FIELD_INDEX (sub) equals roleId, i.e.
all rules carried by
+ // this role. denyEnforcer is a dedicated enforcer that is only ever
loaded with privileges
+ // whose condition is DENY (see loadPolicyByRoleEntity), so every row
here represents a deny
+ // regardless of its stored eft string. Each returned row is the list of
those five fields,
+ // so we read field POLICY_ACTION_FIELD_INDEX (act) to compare the
denied privilege.
+ for (List<String> policy :
+ denyEnforcer.getFilteredNamedPolicy("p", POLICY_SUBJECT_FIELD_INDEX,
roleId)) {
+ if (policy.size() > POLICY_ACTION_FIELD_INDEX
+ && privilegeNames.contains(policy.get(POLICY_ACTION_FIELD_INDEX)))
{
+ return true;
+ }
+ }
+ }
+ return false;
+ }
+
@Override
public boolean isSelf(
Entity.EntityType type,
diff --git
a/server-common/src/test/java/org/apache/gravitino/server/authorization/TestMetadataAuthzHelper.java
b/server-common/src/test/java/org/apache/gravitino/server/authorization/TestMetadataAuthzHelper.java
index 81e7d858ca..a5019085cb 100644
---
a/server-common/src/test/java/org/apache/gravitino/server/authorization/TestMetadataAuthzHelper.java
+++
b/server-common/src/test/java/org/apache/gravitino/server/authorization/TestMetadataAuthzHelper.java
@@ -18,12 +18,15 @@
package org.apache.gravitino.server.authorization;
import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anySet;
import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.mockStatic;
import static org.mockito.Mockito.when;
import java.lang.reflect.Field;
+import java.util.Arrays;
import java.util.concurrent.Executor;
import org.apache.gravitino.Config;
import org.apache.gravitino.Configs;
@@ -32,7 +35,10 @@ import org.apache.gravitino.GravitinoEnv;
import org.apache.gravitino.MetadataObject;
import org.apache.gravitino.NameIdentifier;
import org.apache.gravitino.UserPrincipal;
+import org.apache.gravitino.authorization.GravitinoAuthorizer;
+import org.apache.gravitino.authorization.Privilege;
import org.apache.gravitino.dto.tag.MetadataObjectDTO;
+import
org.apache.gravitino.server.authorization.expression.AuthorizationExpressionConstants;
import org.apache.gravitino.utils.NameIdentifierUtil;
import org.apache.gravitino.utils.PrincipalUtils;
import org.junit.jupiter.api.AfterAll;
@@ -190,6 +196,314 @@ public class TestMetadataAuthzHelper {
}
}
+ /**
+ * Builds three table identifiers under the same schema, where a parent
(schema) level
+ * SELECT_TABLE grant exists and the middle table additionally carries a
table-level deny. The
+ * deny gate is controlled separately so each branch of the list
short-circuit can be exercised.
+ */
+ private GravitinoAuthorizer mockTableListAuthorizer(boolean owner, boolean
denyGate) {
+ GravitinoAuthorizer authorizer = mock(GravitinoAuthorizer.class);
+ // Parent-scope SELECT_TABLE grant at the schema level (applies to every
table in the schema).
+ lenient()
+ .when(authorizer.authorize(any(), eq("testMetalake"), any(), any(),
any()))
+ .thenAnswer(
+ invocation -> {
+ MetadataObject object = invocation.getArgument(2);
+ Privilege.Name privilege = invocation.getArgument(3);
+ return object.type() == MetadataObject.Type.SCHEMA
+ && privilege == Privilege.Name.SELECT_TABLE;
+ });
+ // The middle table (t2) has a table-level deny on SELECT_TABLE.
+ lenient()
+ .when(authorizer.deny(any(), eq("testMetalake"), any(), any(), any()))
+ .thenAnswer(
+ invocation -> {
+ MetadataObject object = invocation.getArgument(2);
+ Privilege.Name privilege = invocation.getArgument(3);
+ return object.type() == MetadataObject.Type.TABLE
+ && "t2".equals(object.name())
+ && privilege == Privilege.Name.SELECT_TABLE;
+ });
+ lenient().when(authorizer.isOwner(any(), eq("testMetalake"), any(),
any())).thenReturn(owner);
+ lenient()
+ .when(authorizer.hasDenyPolicy(any(), eq("testMetalake"), anySet(),
any()))
+ .thenReturn(denyGate);
+ return authorizer;
+ }
+
+ /**
+ * Builds an authorizer that grants {@code grantPrivilege} at a single
ancestor scope ({@code
+ * grantType}) and reports no object-level deny, so a list of children under
that ancestor is
+ * fully visible via the parent-scope short-circuit.
+ */
+ private GravitinoAuthorizer mockParentGrantAuthorizer(
+ MetadataObject.Type grantType, Privilege.Name grantPrivilege) {
+ GravitinoAuthorizer authorizer = mock(GravitinoAuthorizer.class);
+ lenient()
+ .when(authorizer.authorize(any(), eq("testMetalake"), any(), any(),
any()))
+ .thenAnswer(
+ invocation -> {
+ MetadataObject object = invocation.getArgument(2);
+ Privilege.Name privilege = invocation.getArgument(3);
+ return object.type() == grantType && privilege == grantPrivilege;
+ });
+ lenient()
+ .when(authorizer.deny(any(), eq("testMetalake"), any(), any(), any()))
+ .thenReturn(false);
+ lenient().when(authorizer.isOwner(any(), eq("testMetalake"), any(),
any())).thenReturn(false);
+ lenient()
+ .when(authorizer.hasDenyPolicy(any(), eq("testMetalake"), anySet(),
any()))
+ .thenReturn(false);
+ return authorizer;
+ }
+
+ @Test
+ public void testListShortCircuitSchemaViaCatalogGrant() {
+ makeCompletableFutureUseCurrentThread();
+ try (MockedStatic<PrincipalUtils> principalUtilsMocked =
mockStatic(PrincipalUtils.class);
+ MockedStatic<GravitinoAuthorizerProvider> mockStatic =
+ mockStatic(GravitinoAuthorizerProvider.class)) {
+ principalUtilsMocked
+ .when(PrincipalUtils::getCurrentPrincipal)
+ .thenReturn(new UserPrincipal("tester"));
+ principalUtilsMocked.when(() -> PrincipalUtils.doAs(any(),
any())).thenCallRealMethod();
+ GravitinoAuthorizerProvider mockedProvider =
mock(GravitinoAuthorizerProvider.class);
+
mockStatic.when(GravitinoAuthorizerProvider::getInstance).thenReturn(mockedProvider);
+ GravitinoAuthorizer authorizer =
+ mockParentGrantAuthorizer(MetadataObject.Type.CATALOG,
Privilege.Name.USE_SCHEMA);
+ when(mockedProvider.getGravitinoAuthorizer()).thenReturn(authorizer);
+
+ NameIdentifier[] schemas =
+ new NameIdentifier[] {
+ NameIdentifierUtil.ofSchema("testMetalake", "testCatalog", "s1"),
+ NameIdentifierUtil.ofSchema("testMetalake", "testCatalog", "s2")
+ };
+ NameIdentifier[] filtered =
+ MetadataAuthzHelper.filterByExpression(
+ "testMetalake",
+
AuthorizationExpressionConstants.FILTER_SCHEMA_AUTHORIZATION_EXPRESSION,
+ Entity.EntityType.SCHEMA,
+ schemas);
+
+ Assertions.assertEquals(2, filtered.length);
+ }
+ }
+
+ @Test
+ public void testListShortCircuitCatalogViaMetalakeGrant() {
+ makeCompletableFutureUseCurrentThread();
+ try (MockedStatic<PrincipalUtils> principalUtilsMocked =
mockStatic(PrincipalUtils.class);
+ MockedStatic<GravitinoAuthorizerProvider> mockStatic =
+ mockStatic(GravitinoAuthorizerProvider.class)) {
+ principalUtilsMocked
+ .when(PrincipalUtils::getCurrentPrincipal)
+ .thenReturn(new UserPrincipal("tester"));
+ principalUtilsMocked.when(() -> PrincipalUtils.doAs(any(),
any())).thenCallRealMethod();
+ GravitinoAuthorizerProvider mockedProvider =
mock(GravitinoAuthorizerProvider.class);
+
mockStatic.when(GravitinoAuthorizerProvider::getInstance).thenReturn(mockedProvider);
+ GravitinoAuthorizer authorizer =
+ mockParentGrantAuthorizer(MetadataObject.Type.METALAKE,
Privilege.Name.USE_CATALOG);
+ when(mockedProvider.getGravitinoAuthorizer()).thenReturn(authorizer);
+
+ NameIdentifier[] catalogs =
+ new NameIdentifier[] {
+ NameIdentifierUtil.ofCatalog("testMetalake", "c1"),
+ NameIdentifierUtil.ofCatalog("testMetalake", "c2")
+ };
+ NameIdentifier[] filtered =
+ MetadataAuthzHelper.filterByExpression(
+ "testMetalake",
+
AuthorizationExpressionConstants.LOAD_CATALOG_AUTHORIZATION_EXPRESSION,
+ Entity.EntityType.CATALOG,
+ catalogs);
+
+ Assertions.assertEquals(2, filtered.length);
+ }
+ }
+
+ /**
+ * Builds an authorizer that grants {@code SELECT_TABLE} at the schema
scope, but only for the
+ * schema whose simple name equals {@code grantedSchema}. Used to prove the
short-circuit never
+ * applies one parent's grant to siblings under a different parent.
+ */
+ private GravitinoAuthorizer mockSchemaScopedTableAuthorizer(String
grantedSchema) {
+ GravitinoAuthorizer authorizer = mock(GravitinoAuthorizer.class);
+ lenient()
+ .when(authorizer.authorize(any(), eq("testMetalake"), any(), any(),
any()))
+ .thenAnswer(
+ invocation -> {
+ MetadataObject object = invocation.getArgument(2);
+ Privilege.Name privilege = invocation.getArgument(3);
+ return object.type() == MetadataObject.Type.SCHEMA
+ && grantedSchema.equals(object.name())
+ && privilege == Privilege.Name.SELECT_TABLE;
+ });
+ lenient()
+ .when(authorizer.deny(any(), eq("testMetalake"), any(), any(), any()))
+ .thenReturn(false);
+ lenient().when(authorizer.isOwner(any(), eq("testMetalake"), any(),
any())).thenReturn(false);
+ lenient()
+ .when(authorizer.hasDenyPolicy(any(), eq("testMetalake"), anySet(),
any()))
+ .thenReturn(false);
+ return authorizer;
+ }
+
+ /**
+ * Builds an authorizer that grants no parent-scope privilege at all and
only owns a single table
+ * ({@code ownedTable}). No ancestor grant means the short-circuit must not
trigger, falling back
+ * to per-object filtering that exposes only the owned table.
+ */
+ private GravitinoAuthorizer mockTableOwnerOnlyAuthorizer(String ownedTable) {
+ GravitinoAuthorizer authorizer = mock(GravitinoAuthorizer.class);
+ lenient()
+ .when(authorizer.authorize(any(), eq("testMetalake"), any(), any(),
any()))
+ .thenReturn(false);
+ lenient()
+ .when(authorizer.deny(any(), eq("testMetalake"), any(), any(), any()))
+ .thenReturn(false);
+ lenient()
+ .when(authorizer.isOwner(any(), eq("testMetalake"), any(), any()))
+ .thenAnswer(
+ invocation -> {
+ MetadataObject object = invocation.getArgument(2);
+ return object.type() == MetadataObject.Type.TABLE &&
ownedTable.equals(object.name());
+ });
+ lenient()
+ .when(authorizer.hasDenyPolicy(any(), eq("testMetalake"), anySet(),
any()))
+ .thenReturn(false);
+ return authorizer;
+ }
+
+ @Test
+ public void testListShortCircuitDoesNotLeakAcrossParents() {
+ makeCompletableFutureUseCurrentThread();
+ try (MockedStatic<PrincipalUtils> principalUtilsMocked =
mockStatic(PrincipalUtils.class);
+ MockedStatic<GravitinoAuthorizerProvider> mockStatic =
+ mockStatic(GravitinoAuthorizerProvider.class)) {
+ principalUtilsMocked
+ .when(PrincipalUtils::getCurrentPrincipal)
+ .thenReturn(new UserPrincipal("tester"));
+ principalUtilsMocked.when(() -> PrincipalUtils.doAs(any(),
any())).thenCallRealMethod();
+ GravitinoAuthorizerProvider mockedProvider =
mock(GravitinoAuthorizerProvider.class);
+
mockStatic.when(GravitinoAuthorizerProvider::getInstance).thenReturn(mockedProvider);
+ // Grant SELECT_TABLE only at schema s1; t2 lives under a different
schema s2.
+ GravitinoAuthorizer authorizer = mockSchemaScopedTableAuthorizer("s1");
+ when(mockedProvider.getGravitinoAuthorizer()).thenReturn(authorizer);
+
+ NameIdentifier[] tables =
+ new NameIdentifier[] {
+ NameIdentifierUtil.ofTable("testMetalake", "testCatalog", "s1",
"t1"),
+ NameIdentifierUtil.ofTable("testMetalake", "testCatalog", "s2",
"t2")
+ };
+ NameIdentifier[] filtered =
+ MetadataAuthzHelper.filterByExpression(
+ "testMetalake",
+
AuthorizationExpressionConstants.FILTER_TABLE_AUTHORIZATION_EXPRESSION,
+ Entity.EntityType.TABLE,
+ tables);
+
+ // Identifiers span two schemas, so the single-parent short-circuit must
not fire. Per-object
+ // filtering keeps only t1 (granted via s1) and never leaks t2 from the
ungranted schema s2.
+ Assertions.assertEquals(1, filtered.length);
+ Assertions.assertEquals("t1", filtered[0].name());
+ }
+ }
+
+ @Test
+ public void testListNoParentGrantFallsBackToPerObject() {
+ makeCompletableFutureUseCurrentThread();
+ try (MockedStatic<PrincipalUtils> principalUtilsMocked =
mockStatic(PrincipalUtils.class);
+ MockedStatic<GravitinoAuthorizerProvider> mockStatic =
+ mockStatic(GravitinoAuthorizerProvider.class)) {
+ principalUtilsMocked
+ .when(PrincipalUtils::getCurrentPrincipal)
+ .thenReturn(new UserPrincipal("tester"));
+ principalUtilsMocked.when(() -> PrincipalUtils.doAs(any(),
any())).thenCallRealMethod();
+ GravitinoAuthorizerProvider mockedProvider =
mock(GravitinoAuthorizerProvider.class);
+
mockStatic.when(GravitinoAuthorizerProvider::getInstance).thenReturn(mockedProvider);
+ GravitinoAuthorizer authorizer = mockTableOwnerOnlyAuthorizer("t1");
+ when(mockedProvider.getGravitinoAuthorizer()).thenReturn(authorizer);
+
+ NameIdentifier[] filtered =
+ MetadataAuthzHelper.filterByExpression(
+ "testMetalake",
+
AuthorizationExpressionConstants.FILTER_TABLE_AUTHORIZATION_EXPRESSION,
+ Entity.EntityType.TABLE,
+ threeTables());
+
+ // No ancestor grant exists, so the short-circuit is skipped and
per-object filtering returns
+ // only the single table the user owns.
+ Assertions.assertEquals(1, filtered.length);
+ Assertions.assertEquals("t1", filtered[0].name());
+ }
+ }
+
+ private static NameIdentifier[] threeTables() {
+ return new NameIdentifier[] {
+ NameIdentifierUtil.ofTable("testMetalake", "testCatalog", "testSchema",
"t1"),
+ NameIdentifierUtil.ofTable("testMetalake", "testCatalog", "testSchema",
"t2"),
+ NameIdentifierUtil.ofTable("testMetalake", "testCatalog", "testSchema",
"t3")
+ };
+ }
+
+ @Test
+ public void testListShortCircuitParentGrantWithoutDenyReturnsAll() {
+ makeCompletableFutureUseCurrentThread();
+ try (MockedStatic<PrincipalUtils> principalUtilsMocked =
mockStatic(PrincipalUtils.class);
+ MockedStatic<GravitinoAuthorizerProvider> mockStatic =
+ mockStatic(GravitinoAuthorizerProvider.class)) {
+ principalUtilsMocked
+ .when(PrincipalUtils::getCurrentPrincipal)
+ .thenReturn(new UserPrincipal("tester"));
+ principalUtilsMocked.when(() -> PrincipalUtils.doAs(any(),
any())).thenCallRealMethod();
+ GravitinoAuthorizerProvider mockedProvider =
mock(GravitinoAuthorizerProvider.class);
+
mockStatic.when(GravitinoAuthorizerProvider::getInstance).thenReturn(mockedProvider);
+ GravitinoAuthorizer authorizer = mockTableListAuthorizer(false, false);
+ when(mockedProvider.getGravitinoAuthorizer()).thenReturn(authorizer);
+
+ NameIdentifier[] filtered =
+ MetadataAuthzHelper.filterByExpression(
+ "testMetalake",
+
AuthorizationExpressionConstants.FILTER_TABLE_AUTHORIZATION_EXPRESSION,
+ Entity.EntityType.TABLE,
+ threeTables());
+
+ // The schema-level grant covers every table and no object-level deny is
reported, so the
+ // whole list is returned without consulting the per-table deny on t2.
+ Assertions.assertEquals(3, filtered.length);
+ }
+ }
+
+ @Test
+ public void testListShortCircuitFallsBackWhenDenyMayExist() {
+ makeCompletableFutureUseCurrentThread();
+ try (MockedStatic<PrincipalUtils> principalUtilsMocked =
mockStatic(PrincipalUtils.class);
+ MockedStatic<GravitinoAuthorizerProvider> mockStatic =
+ mockStatic(GravitinoAuthorizerProvider.class)) {
+ principalUtilsMocked
+ .when(PrincipalUtils::getCurrentPrincipal)
+ .thenReturn(new UserPrincipal("tester"));
+ principalUtilsMocked.when(() -> PrincipalUtils.doAs(any(),
any())).thenCallRealMethod();
+ GravitinoAuthorizerProvider mockedProvider =
mock(GravitinoAuthorizerProvider.class);
+
mockStatic.when(GravitinoAuthorizerProvider::getInstance).thenReturn(mockedProvider);
+ GravitinoAuthorizer authorizer = mockTableListAuthorizer(false, true);
+ when(mockedProvider.getGravitinoAuthorizer()).thenReturn(authorizer);
+
+ NameIdentifier[] filtered =
+ MetadataAuthzHelper.filterByExpression(
+ "testMetalake",
+
AuthorizationExpressionConstants.FILTER_TABLE_AUTHORIZATION_EXPRESSION,
+ Entity.EntityType.TABLE,
+ threeTables());
+
+ // A deny may exist, so the short-circuit is disabled and per-table
filtering excludes t2.
+ Assertions.assertEquals(2, filtered.length);
+ Assertions.assertTrue(
+ Arrays.stream(filtered).noneMatch(id -> "t2".equals(id.name())),
+ "t2 must be filtered out by its table-level deny");
+ }
+ }
+
private static void makeCompletableFutureUseCurrentThread() {
try {
Executor currentThread = Runnable::run;
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 5f28bfc7dd..9932a73858 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
@@ -17,6 +17,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.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
@@ -37,6 +38,7 @@ import static org.mockito.Mockito.when;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableSet;
import java.io.IOException;
import java.lang.reflect.Field;
import java.lang.reflect.Method;
@@ -460,6 +462,69 @@ public class TestJcasbinAuthorizer {
assertFalse(doAuthorize(currentPrincipal));
}
+ @Test
+ public void testHasDenyPolicy() throws Exception {
+ makeCompletableFutureUseCurrentThread(jcasbinAuthorizer);
+ Principal currentPrincipal = PrincipalUtils.getCurrentPrincipal();
+
+ // With no roles assigned, the user can hold no deny policy.
+ assertFalse(
+ jcasbinAuthorizer.hasDenyPolicy(
+ currentPrincipal,
+ METALAKE,
+ ImmutableSet.of(USE_CATALOG),
+ new AuthorizationRequestContext()));
+
+ // Assign a role that DENIES USE_CATALOG at the catalog scope.
+ mockRoleInStore(DENY_ROLE_ID, "denyRole",
ImmutableList.of(getDenySecurableObject()));
+ when(roleMetaMapper.listRolesByUserId(eq(USER_ID)))
+ .thenReturn(ImmutableList.of(buildRolePO(DENY_ROLE_ID, "denyRole")));
+ when(userMetaMapper.getUserUpdatedAt(eq(METALAKE), eq(USERNAME)))
+ .thenReturn(new UserUpdatedAt(USER_ID, nextUserVersion()));
+
+ // The deny on USE_CATALOG is detected. The match is scope-agnostic: the
deny lives on a
+ // catalog, which is a parent scope for a schema/catalog list, so it must
be reported and
+ // disable the short-circuit.
+ assertTrue(
+ jcasbinAuthorizer.hasDenyPolicy(
+ currentPrincipal,
+ METALAKE,
+ ImmutableSet.of(USE_CATALOG),
+ new AuthorizationRequestContext()));
+
+ // A deny on USE_CATALOG must not be reported when querying a different
privilege.
+ assertFalse(
+ jcasbinAuthorizer.hasDenyPolicy(
+ currentPrincipal,
+ METALAKE,
+ ImmutableSet.of(SELECT_TABLE),
+ new AuthorizationRequestContext()));
+ }
+
+ @Test
+ public void testHasDenyPolicyDetectsGroupInheritedDeny() throws Exception {
+ makeCompletableFutureUseCurrentThread(jcasbinAuthorizer);
+ getLoadedRolesCache(jcasbinAuthorizer).invalidateAll();
+
+ // The user holds no direct roles; the deny role is only reachable through
group membership.
+ UserPrincipal groupPrincipal = setCurrentPrincipalWithGroup(GROUP_NAME);
+ mockRoleInStore(DENY_ROLE_ID, "denyRole",
ImmutableList.of(getDenySecurableObject()));
+ mockNoDirectUserRoles();
+ mockGroupWithRoles(GROUP_NAME, ImmutableList.of(DENY_ROLE_ID),
ImmutableList.of("denyRole"));
+
+ // A deny inherited via a group must still be detected, otherwise the list
short-circuit would
+ // over-expose objects that a group-level deny is meant to hide.
+ assertTrue(
+ jcasbinAuthorizer.hasDenyPolicy(
+ groupPrincipal,
+ METALAKE,
+ ImmutableSet.of(USE_CATALOG),
+ new AuthorizationRequestContext()));
+
+ restoreDefaultPrincipal();
+ getLoadedRolesCache(jcasbinAuthorizer).invalidateAll();
+ }
+
@Test
public void testUserRoleCacheDoesNotReuseRolesAfterUsernameRecreate() throws
Exception {
makeCompletableFutureUseCurrentThread(jcasbinAuthorizer);