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");
+ }
+ }
+ }
+ }
+}