This is an automated email from the ASF dual-hosted git repository. github-actions[bot] pushed a commit to branch cherry-pick-14e281ef-to-branch-1.3 in repository https://gitbox.apache.org/repos/asf/gravitino.git
commit 9a56a7428c31662c45e78a379021d87af3cbebc8 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 fcb4f2cf5e..36e0f3d698 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, @@ -141,6 +154,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()); } @@ -332,8 +354,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)); @@ -347,7 +370,13 @@ public class MetadataAuthzHelper { } } 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 0b40a63c31..e1ba874a68 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"); + } + } + } + } +}
