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 14e281ef73 [#13261] fix(auth): Reduce owner lookups in user/group 
lists (#13262)
14e281ef73 is described below

commit 14e281ef731d9d63bf8e93b5e730725d680bdd2f
Author: Qi Yu <[email protected]>
AuthorDate: Thu Sep 17 21:17:58 2026 +0800

    [#13261] fix(auth): Reduce owner lookups in user/group lists (#13262)
    
    ### What changes were proposed in this pull request?
    
    Skip owner preloading for users and groups, which are not metadata
    objects and cannot have owners. Add user/group/role load expressions to
    the existing parent-scope shortcut for metalake owners and callers with
    the corresponding management privilege. Preserve the deny guard and
    per-object visibility fallback.
    
    ### Why are the changes needed?
    
    Although owner preloading exposes a batch API, its relational
    implementation resolves the metalake ID and principal ID separately for
    each identifier before querying owner relations. Skipping this work
    removes two unnecessary SELECTs per listed user/group, plus the
    owner-relation queries, even for callers without management access.
    
    Fix: #13261
    
    ### Does this PR introduce _any_ user-facing change?
    
    Fewer storage lookups for user/group lists; qualifying metalake
    ownership or management grants also bypass per-object filtering for
    registered user/group/role list expressions. No API, configuration, or
    visibility changes.
    
    Ordinary self/membership filtering still iterates over the list. In
    particular, per-role ID lookups in JcasbinAuthorizer are not addressed.
    MetadataObjectRoleOperations uses a different expression without
    MANAGE_GRANTS and intentionally remains on its existing filtering path.
    
    ### How was this patch tested?
    
    - H2 query-count tests exercise both management shortcuts and user/group
    self-filtering fallback with authorization/cache enabled, for 1, 1,003,
    and 10,000 entries. Name lists use one SELECT; user/group details use
    two. The authorizer is mocked to isolate storage/list-filter queries;
    counts exclude real caller/role authorization lookups and HTTP
    serialization.
    - Mutation check: temporarily removing the owner-preload guard makes the
    fallback test fail. For 1,003 users/groups, name/detail queries rise
    from 1/2 to 2,009/2,010; the management case still passes. The guard was
    restored before final validation.
    - Regression tests retain role owner preloading and ensure the distinct
    metadata-object role expression cannot gain visibility from
    MANAGE_GRANTS.
    - 361 server-common tests and 22 related REST tests passed; full
    repository formatting passed. The first full run hit the previously
    observed TLS missing-client-certificate assertion; the full
    server-common rerun passed. Docker integration tests were excluded.
---
 .../server/authorization/MetadataAuthzHelper.java  |  35 ++-
 .../authorization/PrincipalListTestUtils.java      |  69 ++++++
 .../authorization/TestMetadataAuthzHelper.java     | 213 ++++++++++++++++
 .../authorization/TestPrincipalListQueryCount.java | 267 +++++++++++++++++++++
 4 files changed, 581 insertions(+), 3 deletions(-)

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 02506e97d4..3599ffb54d 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
@@ -92,11 +92,12 @@ public class MetadataAuthzHelper {
 
   private static final Set<Entity.EntityType> METADATA_OBJECT_ENTITY_TYPES =
       Arrays.stream(MetadataObject.Type.values())
-          .map(type -> Entity.EntityType.valueOf(type.name()))
+          .map(MetadataObjectUtil::toEntityType)
           .collect(Collectors.toUnmodifiableSet());
 
   private static final String TABLE_PARENT_SCOPES = "METALAKE, CATALOG, 
SCHEMA";
   private static final String SCHEMA_PARENT_SCOPES = "METALAKE, CATALOG";
+  private static final String METALAKE_ONLY_SCOPE = "METALAKE";
   private static final String CATALOG_PARENT_SCOPES = "METALAKE";
 
   /**
@@ -108,6 +109,18 @@ public class MetadataAuthzHelper {
   private static final Map<Entity.EntityType, Map<String, 
List<ParentScopeAccessPath>>>
       LIST_SHORT_CIRCUITS =
           Map.of(
+              Entity.EntityType.USER,
+              principalListPaths(
+                  
AuthorizationExpressionConstants.LOAD_USER_AUTHORIZATION_EXPRESSION,
+                  Privilege.Name.MANAGE_USERS),
+              Entity.EntityType.GROUP,
+              principalListPaths(
+                  
AuthorizationExpressionConstants.LOAD_GROUP_AUTHORIZATION_EXPRESSION,
+                  Privilege.Name.MANAGE_GROUPS),
+              Entity.EntityType.ROLE,
+              principalListPaths(
+                  
AuthorizationExpressionConstants.LOAD_ROLE_AUTHORIZATION_EXPRESSION,
+                  Privilege.Name.MANAGE_GRANTS),
               Entity.EntityType.TABLE,
               Map.of(
                   
AuthorizationExpressionConstants.FILTER_TABLE_AUTHORIZATION_EXPRESSION,
@@ -149,6 +162,15 @@ public class MetadataAuthzHelper {
 
   private MetadataAuthzHelper() {}
 
+  private static Map<String, List<ParentScopeAccessPath>> principalListPaths(
+      String expression, Privilege.Name managementPrivilege) {
+    return Map.of(
+        expression,
+        List.of(
+            parentOwnerPath(METALAKE_ONLY_SCOPE),
+            parentPrivilegePath(managementPrivilege, METALAKE_ONLY_SCOPE)));
+  }
+
   private static ParentScopeAccessPath parentOwnerPath(String parentScopes) {
     return new ParentScopeAccessPath("ANY(OWNER, " + parentScopes + ")", 
Set.of());
   }
@@ -381,8 +403,9 @@ public class MetadataAuthzHelper {
     // per-object loop over every catalog in the metalake.
     NameIdentifier[] nameIdentifiers =
         
Arrays.stream(entities).map(toNameIdentifier).toArray(NameIdentifier[]::new);
+    boolean isMetadataObject = 
METADATA_OBJECT_ENTITY_TYPES.contains(entityType);
     if (enableAuthorization() && nameIdentifiers.length > 0) {
-      if (METADATA_OBJECT_ENTITY_TYPES.contains(entityType)) {
+      if (isMetadataObject) {
         Arrays.stream(nameIdentifiers)
             .forEach(
                 identifier -> 
NameIdentifierUtil.checkMetadataObjectName(identifier, entityType));
@@ -412,7 +435,13 @@ public class MetadataAuthzHelper {
           nameIdentifiers.length);
     }
     preloadToCache(entityType, nameIdentifiers);
-    preloadOwner(entityType, nameIdentifiers);
+    // Ownership is defined on metadata objects, independently of the filter 
expression.
+    // Users/groups are not metadata objects. OwnerMetaService.batchGetOwner 
resolves IDs per
+    // identifier, so calling it for users/groups would still perform two 
SELECTs per entry
+    // before the batched owner-relation queries.
+    if (isMetadataObject) {
+      preloadOwner(entityType, nameIdentifiers);
+    }
 
     GravitinoAuthorizer authorizer =
         GravitinoAuthorizerProvider.getInstance().getGravitinoAuthorizer();
diff --git 
a/server-common/src/test/java/org/apache/gravitino/server/authorization/PrincipalListTestUtils.java
 
b/server-common/src/test/java/org/apache/gravitino/server/authorization/PrincipalListTestUtils.java
new file mode 100644
index 0000000000..1c05a3fb8a
--- /dev/null
+++ 
b/server-common/src/test/java/org/apache/gravitino/server/authorization/PrincipalListTestUtils.java
@@ -0,0 +1,69 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *  http://www.apache.org/licenses/LICENSE-2.0
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.apache.gravitino.server.authorization;
+
+import java.util.Locale;
+import java.util.stream.IntStream;
+import org.apache.gravitino.Entity;
+import org.apache.gravitino.NameIdentifier;
+import org.apache.gravitino.Namespace;
+import org.apache.gravitino.authorization.AuthorizationUtils;
+import org.apache.gravitino.authorization.Privilege;
+import 
org.apache.gravitino.server.authorization.expression.AuthorizationExpressionConstants;
+
+/** Shared inputs for principal-list authorization tests. */
+final class PrincipalListTestUtils {
+  private PrincipalListTestUtils() {}
+
+  static NameIdentifier[] principalIdentifiers(Entity.EntityType type, int 
count) {
+    return IntStream.range(0, count)
+        .mapToObj(
+            i ->
+                NameIdentifier.of(
+                    principalNamespace(type, "testMetalake"),
+                    type.name().toLowerCase(Locale.ROOT) + i))
+        .toArray(NameIdentifier[]::new);
+  }
+
+  static Namespace principalNamespace(Entity.EntityType type, String metalake) 
{
+    return switch (type) {
+      case USER -> AuthorizationUtils.ofUserNamespace(metalake);
+      case GROUP -> AuthorizationUtils.ofGroupNamespace(metalake);
+      case ROLE -> AuthorizationUtils.ofRoleNamespace(metalake);
+      default -> throw new IllegalArgumentException("Not a principal type: " + 
type);
+    };
+  }
+
+  static String principalListExpression(Entity.EntityType type) {
+    return switch (type) {
+      case USER -> 
AuthorizationExpressionConstants.LOAD_USER_AUTHORIZATION_EXPRESSION;
+      case GROUP -> 
AuthorizationExpressionConstants.LOAD_GROUP_AUTHORIZATION_EXPRESSION;
+      case ROLE -> 
AuthorizationExpressionConstants.LOAD_ROLE_AUTHORIZATION_EXPRESSION;
+      default -> throw new IllegalArgumentException("Not a principal type: " + 
type);
+    };
+  }
+
+  static Privilege.Name principalManagementPrivilege(Entity.EntityType type) {
+    return switch (type) {
+      case USER -> Privilege.Name.MANAGE_USERS;
+      case GROUP -> Privilege.Name.MANAGE_GROUPS;
+      case ROLE -> Privilege.Name.MANAGE_GRANTS;
+      default -> throw new IllegalArgumentException("Not a principal type: " + 
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 3fe7fc6e89..c6f7b722e8 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
@@ -17,6 +17,9 @@
 
 package org.apache.gravitino.server.authorization;
 
+import static 
org.apache.gravitino.server.authorization.PrincipalListTestUtils.principalIdentifiers;
+import static 
org.apache.gravitino.server.authorization.PrincipalListTestUtils.principalListExpression;
+import static 
org.apache.gravitino.server.authorization.PrincipalListTestUtils.principalManagementPrivilege;
 import static org.mockito.ArgumentMatchers.any;
 import static org.mockito.ArgumentMatchers.anySet;
 import static org.mockito.ArgumentMatchers.eq;
@@ -25,6 +28,7 @@ import static org.mockito.Mockito.mock;
 import static org.mockito.Mockito.mockStatic;
 import static org.mockito.Mockito.times;
 import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyNoInteractions;
 import static org.mockito.Mockito.when;
 
 import java.lang.reflect.Field;
@@ -35,9 +39,11 @@ import java.util.concurrent.Executor;
 import org.apache.gravitino.Config;
 import org.apache.gravitino.Configs;
 import org.apache.gravitino.Entity;
+import org.apache.gravitino.EntityStore;
 import org.apache.gravitino.GravitinoEnv;
 import org.apache.gravitino.MetadataObject;
 import org.apache.gravitino.NameIdentifier;
+import org.apache.gravitino.SupportsRelationOperations;
 import org.apache.gravitino.UserPrincipal;
 import org.apache.gravitino.authorization.AccessControlDispatcher;
 import org.apache.gravitino.authorization.GravitinoAuthorizer;
@@ -276,6 +282,213 @@ public class TestMetadataAuthzHelper {
     }
   }
 
+  /** Users and groups have no owners, even when the entity cache is enabled. 
*/
+  @ParameterizedTest
+  @EnumSource(
+      value = Entity.EntityType.class,
+      names = {"USER", "GROUP"})
+  public void testPrincipalListDoesNotPreloadOwners(Entity.EntityType type) {
+    EntityStore store = mock(EntityStore.class);
+    when(gravitinoEnv.entityStore()).thenReturn(store);
+    when(gravitinoEnv.cacheEnabled()).thenReturn(true);
+    GravitinoAuthorizer authorizer = mock(GravitinoAuthorizer.class);
+    NameIdentifier[] identifiers = principalIdentifiers(type, 1003);
+    when(authorizer.isSelf(eq(type), eq(identifiers[1]), 
any())).thenReturn(true);
+    try {
+      withAuthorizer(
+          authorizer,
+          () -> {
+            NameIdentifier[] filtered =
+                MetadataAuthzHelper.filterByExpression(
+                    "testMetalake", principalListExpression(type), type, 
identifiers);
+            Assertions.assertArrayEquals(new NameIdentifier[] 
{identifiers[1]}, filtered);
+            verifyNoInteractions(store);
+          });
+    } finally {
+      when(gravitinoEnv.cacheEnabled()).thenReturn(false);
+      when(gravitinoEnv.entityStore()).thenReturn(null);
+    }
+  }
+
+  /** A metalake management grant authorizes the entire list with constant 
work. */
+  @ParameterizedTest
+  @EnumSource(
+      value = Entity.EntityType.class,
+      names = {"USER", "GROUP", "ROLE"})
+  public void 
testPrincipalListManagementGrantSkipsPerObjectWork(Entity.EntityType type) {
+    EntityStore store = mock(EntityStore.class);
+    when(gravitinoEnv.entityStore()).thenReturn(store);
+    when(gravitinoEnv.cacheEnabled()).thenReturn(true);
+    Privilege.Name privilege = principalManagementPrivilege(type);
+    GravitinoAuthorizer authorizer =
+        mockParentGrantAuthorizer(MetadataObject.Type.METALAKE, privilege);
+    NameIdentifier[] identifiers = principalIdentifiers(type, 10000);
+    try {
+      withAuthorizer(
+          authorizer,
+          () -> {
+            NameIdentifier[] filtered =
+                MetadataAuthzHelper.filterByExpression(
+                    "testMetalake", principalListExpression(type), type, 
identifiers);
+            Assertions.assertSame(identifiers, filtered);
+            verify(authorizer, times(1))
+                .authorize(any(), eq("testMetalake"), any(), eq(privilege), 
any());
+            verifyNoInteractions(store);
+          });
+    } finally {
+      when(gravitinoEnv.cacheEnabled()).thenReturn(false);
+      when(gravitinoEnv.entityStore()).thenReturn(null);
+    }
+  }
+
+  /** A possible management deny forces per-object evaluation even if the 
parent grants access. */
+  @ParameterizedTest
+  @EnumSource(
+      value = Entity.EntityType.class,
+      names = {"USER", "GROUP", "ROLE"})
+  public void 
testPrincipalListManagementGrantWithDenyFallsBack(Entity.EntityType type) {
+    Privilege.Name privilege = principalManagementPrivilege(type);
+    GravitinoAuthorizer authorizer =
+        mockParentGrantAuthorizer(MetadataObject.Type.METALAKE, privilege);
+    when(authorizer.hasDenyPolicy(any(), eq("testMetalake"), 
eq(Set.of(privilege)), any()))
+        .thenReturn(true);
+    NameIdentifier[] identifiers = principalIdentifiers(type, 3);
+    withAuthorizer(
+        authorizer,
+        () -> {
+          NameIdentifier[] filtered =
+              MetadataAuthzHelper.filterByExpression(
+                  "testMetalake", principalListExpression(type), type, 
identifiers);
+          // A deny elsewhere need not hide these principals, but it must 
disable the shortcut.
+          Assertions.assertArrayEquals(identifiers, filtered);
+          Assertions.assertNotSame(identifiers, filtered);
+          verify(authorizer).hasDenyPolicy(any(), eq("testMetalake"), 
eq(Set.of(privilege)), any());
+          verify(authorizer, times(identifiers.length + 1))
+              .authorize(any(), eq("testMetalake"), any(), eq(privilege), 
any());
+        });
+  }
+
+  /** A denied management privilege still permits self or role-membership 
visibility. */
+  @ParameterizedTest
+  @EnumSource(
+      value = Entity.EntityType.class,
+      names = {"USER", "GROUP", "ROLE"})
+  public void 
testPrincipalListDeniedManagementRetainsSelfVisibility(Entity.EntityType type) {
+    GravitinoAuthorizer authorizer = mock(GravitinoAuthorizer.class);
+    // authorize() returns false for an effective deny, as well as for an 
absent grant.
+    NameIdentifier[] identifiers = principalIdentifiers(type, 3);
+    when(authorizer.isSelf(eq(type), eq(identifiers[1]), 
any())).thenReturn(true);
+    withAuthorizer(
+        authorizer,
+        () ->
+            Assertions.assertArrayEquals(
+                new NameIdentifier[] {identifiers[1]},
+                MetadataAuthzHelper.filterByExpression(
+                    "testMetalake", principalListExpression(type), type, 
identifiers)));
+  }
+
+  /** Without a metalake grant, role ownership and role membership still 
filter individual roles. */
+  @Test
+  public void testRoleListRetainsPerRoleVisibility() {
+    GravitinoAuthorizer authorizer = mock(GravitinoAuthorizer.class);
+    NameIdentifier[] identifiers = 
principalIdentifiers(Entity.EntityType.ROLE, 3);
+    when(authorizer.isSelf(eq(Entity.EntityType.ROLE), eq(identifiers[1]), 
any())).thenReturn(true);
+    when(authorizer.isOwner(any(), eq("testMetalake"), any(), any()))
+        .thenAnswer(
+            call -> {
+              MetadataObject object = call.getArgument(2);
+              return object.type() == MetadataObject.Type.ROLE && 
object.name().equals("role2");
+            });
+    withAuthorizer(
+        authorizer,
+        () -> {
+          NameIdentifier[] filtered =
+              MetadataAuthzHelper.filterByExpression(
+                  "testMetalake",
+                  principalListExpression(Entity.EntityType.ROLE),
+                  Entity.EntityType.ROLE,
+                  identifiers);
+          Assertions.assertArrayEquals(
+              new NameIdentifier[] {identifiers[1], identifiers[2]}, filtered);
+        });
+  }
+
+  /** Roles still preload owners when no parent path authorizes the whole 
list. */
+  @Test
+  public void testRoleListFallbackPreloadsOwners() throws Exception {
+    EntityStore store = mock(EntityStore.class);
+    SupportsRelationOperations relations = 
mock(SupportsRelationOperations.class);
+    when(gravitinoEnv.entityStore()).thenReturn(store);
+    when(gravitinoEnv.cacheEnabled()).thenReturn(true);
+    when(store.relationOperations()).thenReturn(relations);
+    NameIdentifier[] identifiers = 
principalIdentifiers(Entity.EntityType.ROLE, 3);
+    try {
+      withAuthorizer(
+          mock(GravitinoAuthorizer.class),
+          () -> {
+            Assertions.assertEquals(
+                0,
+                MetadataAuthzHelper.filterByExpression(
+                        "testMetalake",
+                        principalListExpression(Entity.EntityType.ROLE),
+                        Entity.EntityType.ROLE,
+                        identifiers)
+                    .length);
+          });
+      verify(relations)
+          .batchListEntitiesByRelation(
+              SupportsRelationOperations.Type.OWNER_REL,
+              Arrays.asList(identifiers),
+              Entity.EntityType.ROLE);
+    } finally {
+      when(gravitinoEnv.cacheEnabled()).thenReturn(false);
+      when(gravitinoEnv.entityStore()).thenReturn(null);
+    }
+  }
+
+  /** A role expression without MANAGE_GRANTS must not inherit that list 
shortcut. */
+  @Test
+  public void testDifferentRoleExpressionDoesNotUseManagementGrant() {
+    GravitinoAuthorizer authorizer =
+        mockParentGrantAuthorizer(MetadataObject.Type.METALAKE, 
Privilege.Name.MANAGE_GRANTS);
+    NameIdentifier[] identifiers = 
principalIdentifiers(Entity.EntityType.ROLE, 3);
+    when(authorizer.isSelf(eq(Entity.EntityType.ROLE), eq(identifiers[1]), 
any())).thenReturn(true);
+    withAuthorizer(
+        authorizer,
+        () ->
+            Assertions.assertArrayEquals(
+                new NameIdentifier[] {identifiers[1]},
+                MetadataAuthzHelper.filterByExpression(
+                    "testMetalake",
+                    "METALAKE::OWNER || ROLE::OWNER || ROLE::SELF",
+                    Entity.EntityType.ROLE,
+                    identifiers)));
+  }
+
+  /** A metalake owner sees every principal without loading per-principal 
relations. */
+  @ParameterizedTest
+  @EnumSource(
+      value = Entity.EntityType.class,
+      names = {"USER", "GROUP", "ROLE"})
+  public void testPrincipalListMetalakeOwner(Entity.EntityType type) {
+    GravitinoAuthorizer authorizer = mock(GravitinoAuthorizer.class);
+    when(authorizer.isOwner(any(), eq("testMetalake"), any(), any()))
+        .thenAnswer(
+            call -> ((MetadataObject) call.getArgument(2)).type() == 
MetadataObject.Type.METALAKE);
+    when(authorizer.hasDenyPolicy(any(), eq("testMetalake"), anySet(), 
any())).thenReturn(true);
+    NameIdentifier[] identifiers = principalIdentifiers(type, 3);
+    withAuthorizer(
+        authorizer,
+        () -> {
+          Assertions.assertSame(
+              identifiers,
+              MetadataAuthzHelper.filterByExpression(
+                  "testMetalake", principalListExpression(type), type, 
identifiers));
+          verify(authorizer, times(1)).isOwner(any(), eq("testMetalake"), 
any(), any());
+          verify(authorizer, times(0)).hasDenyPolicy(any(), any(), anySet(), 
any());
+        });
+  }
+
   /**
    * 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
diff --git 
a/server-common/src/test/java/org/apache/gravitino/server/authorization/TestPrincipalListQueryCount.java
 
b/server-common/src/test/java/org/apache/gravitino/server/authorization/TestPrincipalListQueryCount.java
new file mode 100644
index 0000000000..1bdaa859d3
--- /dev/null
+++ 
b/server-common/src/test/java/org/apache/gravitino/server/authorization/TestPrincipalListQueryCount.java
@@ -0,0 +1,267 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *  http://www.apache.org/licenses/LICENSE-2.0
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.apache.gravitino.server.authorization;
+
+import static 
org.apache.gravitino.server.authorization.PrincipalListTestUtils.principalListExpression;
+import static 
org.apache.gravitino.server.authorization.PrincipalListTestUtils.principalNamespace;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyList;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.mockStatic;
+import static org.mockito.Mockito.when;
+
+import java.nio.file.Path;
+import java.sql.Connection;
+import java.sql.DriverManager;
+import java.sql.PreparedStatement;
+import java.sql.ResultSet;
+import java.sql.Statement;
+import java.time.Instant;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Locale;
+import java.util.concurrent.Executor;
+import org.apache.commons.lang3.reflect.FieldUtils;
+import org.apache.gravitino.Config;
+import org.apache.gravitino.Configs;
+import org.apache.gravitino.Entity;
+import org.apache.gravitino.EntityStore;
+import org.apache.gravitino.GravitinoEnv;
+import org.apache.gravitino.HasIdentifier;
+import org.apache.gravitino.MetadataObject;
+import org.apache.gravitino.NameIdentifier;
+import org.apache.gravitino.Namespace;
+import org.apache.gravitino.SupportsRelationOperations;
+import org.apache.gravitino.UserPrincipal;
+import org.apache.gravitino.authorization.GravitinoAuthorizer;
+import org.apache.gravitino.authorization.Privilege;
+import org.apache.gravitino.json.JsonUtils;
+import org.apache.gravitino.meta.AuditInfo;
+import org.apache.gravitino.meta.EntityIdResolver;
+import org.apache.gravitino.storage.relational.JDBCBackend;
+import org.apache.gravitino.storage.relational.RelationalEntityStoreIdResolver;
+import org.apache.gravitino.storage.relational.service.EntityIdService;
+import org.apache.gravitino.utils.PrincipalUtils;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.function.Executable;
+import org.junit.jupiter.api.io.TempDir;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
+import org.mockito.MockedStatic;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/** Counts real database queries in list loading and filtering, independently 
of list size. */
+class TestPrincipalListQueryCount {
+  private static final Logger LOG = 
LoggerFactory.getLogger(TestPrincipalListQueryCount.class);
+
+  @TempDir Path tempDir;
+
+  @ParameterizedTest
+  @ValueSource(booleans = {true, false})
+  void testPrincipalListsHaveBoundedQueries(boolean managementGrant) throws 
Exception {
+    Config config = new Config(false) {};
+    config.set(
+        Configs.ENTITY_RELATIONAL_JDBC_BACKEND_URL,
+        "jdbc:h2:file:" + tempDir.resolve("metadata") + 
";MODE=MYSQL;AUTO_SERVER=FALSE");
+    config.set(Configs.ENTITY_RELATIONAL_JDBC_BACKEND_DRIVER, "org.h2.Driver");
+    config.set(Configs.ENTITY_RELATIONAL_JDBC_BACKEND_USER, "root");
+    config.set(Configs.ENTITY_RELATIONAL_JDBC_BACKEND_PASSWORD, "test");
+    config.set(Configs.ENABLE_AUTHORIZATION, true);
+    EntityIdResolver previousResolver =
+        (EntityIdResolver)
+            FieldUtils.readStaticField(EntityIdService.class, 
"entityIdResolver", true);
+    Object previousExecutor =
+        FieldUtils.readStaticField(MetadataAuthzHelper.class, "executor", 
true);
+    List<Executable> queryCountAssertions = new ArrayList<>();
+    try (MockedStatic<GravitinoEnv> envStatic = mockStatic(GravitinoEnv.class);
+        MockedStatic<GravitinoAuthorizerProvider> providerStatic =
+            mockStatic(GravitinoAuthorizerProvider.class);
+        JDBCBackend backend = new JDBCBackend()) {
+      GravitinoEnv env = mock(GravitinoEnv.class);
+      envStatic.when(GravitinoEnv::getInstance).thenReturn(env);
+      when(env.config()).thenReturn(config);
+      when(env.cacheEnabled()).thenReturn(true);
+      EntityStore store = mock(EntityStore.class);
+      SupportsRelationOperations relations = 
mock(SupportsRelationOperations.class);
+      when(env.entityStore()).thenReturn(store);
+      when(store.relationOperations()).thenReturn(relations);
+      when(relations.batchListEntitiesByRelation(
+              eq(SupportsRelationOperations.Type.OWNER_REL), anyList(), any()))
+          .thenAnswer(
+              call ->
+                  backend.batchListEntitiesByRelation(
+                      call.getArgument(0), call.getArgument(1), 
call.getArgument(2)));
+      GravitinoAuthorizerProvider provider = 
mock(GravitinoAuthorizerProvider.class);
+      
providerStatic.when(GravitinoAuthorizerProvider::getInstance).thenReturn(provider);
+      GravitinoAuthorizer authorizer = mock(GravitinoAuthorizer.class);
+      when(provider.getGravitinoAuthorizer()).thenReturn(authorizer);
+      // Isolate storage queries while exercising both parent grants and 
per-object self filtering.
+      when(authorizer.authorize(any(), any(), any(), any(), any()))
+          .thenAnswer(
+              call -> {
+                MetadataObject object = call.getArgument(2);
+                Privilege.Name privilege = call.getArgument(3);
+                return managementGrant
+                    && object.type() == MetadataObject.Type.METALAKE
+                    && (privilege == Privilege.Name.MANAGE_USERS
+                        || privilege == Privilege.Name.MANAGE_GROUPS
+                        || privilege == Privilege.Name.MANAGE_GRANTS);
+              });
+      when(authorizer.isSelf(any(), any(), any()))
+          .thenAnswer(call -> ((NameIdentifier) 
call.getArgument(1)).name().endsWith("0"));
+      FieldUtils.writeStaticField(
+          MetadataAuthzHelper.class, "executor", (Executor) Runnable::run, 
true);
+      backend.initialize(config);
+      EntityIdService.initialize(new RelationalEntityStoreIdResolver());
+      try (Connection connection =
+          DriverManager.getConnection(
+              config.get(Configs.ENTITY_RELATIONAL_JDBC_BACKEND_URL), "root", 
"test")) {
+        for (int size : new int[] {1, 1003, 10000}) {
+          String metalake = "scale" + size;
+          insertPrincipals(connection, size, metalake);
+          for (Entity.EntityType type :
+              List.of(Entity.EntityType.USER, Entity.EntityType.GROUP, 
Entity.EntityType.ROLE)) {
+            // Ordinary role membership still performs real per-role lookups 
in JcasbinAuthorizer;
+            // this test only claims bounded fallback storage queries for 
users and groups.
+            if (!managementGrant && type == Entity.EntityType.ROLE) {
+              continue;
+            }
+            for (boolean details : new boolean[] {false, true}) {
+              try (Statement statement = connection.createStatement()) {
+                statement.execute("SET QUERY_STATISTICS FALSE");
+                statement.execute("SET QUERY_STATISTICS TRUE");
+              }
+              long start = System.nanoTime();
+              Namespace namespace = principalNamespace(type, metalake);
+              List<? extends HasIdentifier> entities = backend.list(namespace, 
type, details);
+              String expression = principalListExpression(type);
+              int returned =
+                  PrincipalUtils.doAs(
+                      new UserPrincipal("manager"),
+                      () -> {
+                        if (details) {
+                          return MetadataAuthzHelper.filterByExpression(
+                                  metalake,
+                                  expression,
+                                  type,
+                                  entities.toArray(new HasIdentifier[0]),
+                                  HasIdentifier::nameIdentifier)
+                              .length;
+                        }
+                        NameIdentifier[] identifiers =
+                            entities.stream()
+                                .map(HasIdentifier::nameIdentifier)
+                                .toArray(NameIdentifier[]::new);
+                        return MetadataAuthzHelper.filterByExpression(
+                                metalake, expression, type, identifiers)
+                            .length;
+                      });
+              long millis = (System.nanoTime() - start) / 1_000_000;
+              long selects = countSelects(connection);
+              LOG.info(
+                  "Principal list: managementGrant={}, type={}, size={}, 
details={}, SELECTs={}, elapsedMs={}",
+                  managementGrant,
+                  type,
+                  size,
+                  details,
+                  selects,
+                  millis);
+              Assertions.assertEquals(managementGrant ? size : (size + 9) / 
10, returned);
+              queryCountAssertions.add(
+                  () ->
+                      Assertions.assertEquals(
+                          type == Entity.EntityType.ROLE || !details ? 1 : 2,
+                          selects,
+                          type
+                              + " size="
+                              + size
+                              + " details="
+                              + details
+                              + " managementGrant="
+                              + managementGrant));
+            }
+          }
+        }
+      }
+      Assertions.assertAll(queryCountAssertions);
+    } finally {
+      EntityIdService.initialize(previousResolver);
+      FieldUtils.writeStaticField(MetadataAuthzHelper.class, "executor", 
previousExecutor, true);
+    }
+  }
+
+  private static long countSelects(Connection connection) throws Exception {
+    long count = 0;
+    try (Statement statement = connection.createStatement();
+        ResultSet rows =
+            statement.executeQuery(
+                "SELECT SQL_STATEMENT, EXECUTION_COUNT FROM 
INFORMATION_SCHEMA.QUERY_STATISTICS")) {
+      while (rows.next()) {
+        String sql = rows.getString(1).trim().toUpperCase(Locale.ROOT);
+        if (sql.startsWith("SELECT") && !sql.contains("INFORMATION_SCHEMA")) {
+          count += rows.getLong(2);
+        }
+      }
+    }
+    return count;
+  }
+
+  private static void insertPrincipals(Connection connection, int size, String 
metalake)
+      throws Exception {
+    String audit =
+        JsonUtils.anyFieldMapper()
+            .writeValueAsString(
+                
AuditInfo.builder().withCreator("test").withCreateTime(Instant.now()).build());
+    try (PreparedStatement insert =
+        connection.prepareStatement(
+            "INSERT INTO metalake_meta (metalake_id, metalake_name, 
audit_info, schema_version) VALUES (?, ?, ?, '{}')")) {
+      insert.setLong(1, size);
+      insert.setString(2, metalake);
+      insert.setString(3, audit);
+      insert.executeUpdate();
+    }
+    for (String kind : List.of("user", "group", "role")) {
+      try (PreparedStatement insert =
+          connection.prepareStatement(
+              "INSERT INTO "
+                  + kind
+                  + "_meta ("
+                  + kind
+                  + "_id, "
+                  + kind
+                  + "_name, metalake_id, audit_info) VALUES (?, ?, ?, ?)")) {
+        for (int i = 0; i < size; i++) {
+          insert.setLong(1, size * 100000L + i);
+          insert.setString(2, kind + i);
+          insert.setLong(3, size);
+          insert.setString(4, audit);
+          insert.addBatch();
+        }
+        insert.executeBatch();
+      }
+      if (kind.equals("role")) {
+        try (Statement statement = connection.createStatement()) {
+          statement.executeUpdate(
+              "UPDATE role_meta SET properties = '{}' WHERE properties IS 
NULL");
+        }
+      }
+    }
+  }
+}

Reply via email to