jerryshao commented on code in PR #13262:
URL: https://github.com/apache/gravitino/pull/13262#discussion_r4035771852


##########
server-common/src/main/java/org/apache/gravitino/server/authorization/MetadataAuthzHelper.java:
##########
@@ -108,6 +108,24 @@ public class MetadataAuthzHelper {
   private static final Map<Entity.EntityType, Map<String, 
List<ParentScopeAccessPath>>>
       LIST_SHORT_CIRCUITS =
           Map.of(
+              Entity.EntityType.USER,
+              Map.of(
+                  
AuthorizationExpressionConstants.LOAD_USER_AUTHORIZATION_EXPRESSION,
+                  List.of(
+                      parentOwnerPath(CATALOG_PARENT_SCOPES),

Review Comment:
   **conventions**: `CATALOG_PARENT_SCOPES` (originally meaning "scopes above a 
catalog") is reused verbatim as the parent scope for the new USER/GROUP/ROLE 
entries here and below, purely because both happen to resolve to the same 
string (`"METALAKE"`) today. If Gravitino ever introduces an intermediate scope 
between metalake and catalog and a maintainer updates this constant to reflect 
catalog's new parent chain, the USER/GROUP/ROLE short-circuits — which have 
nothing to do with catalogs — would silently change behavior too, since neither 
the map entries nor the constant's name signal this incidental coupling. Worth 
a distinctly-named constant (e.g. `METALAKE_ONLY_SCOPE`) for the principal-type 
entries.



##########
server-common/src/main/java/org/apache/gravitino/server/authorization/MetadataAuthzHelper.java:
##########
@@ -108,6 +108,24 @@ public class MetadataAuthzHelper {
   private static final Map<Entity.EntityType, Map<String, 
List<ParentScopeAccessPath>>>
       LIST_SHORT_CIRCUITS =
           Map.of(
+              Entity.EntityType.USER,

Review Comment:
   **efficiency / altitude**: This short-circuit (and the perf win it enables) 
only fires when the caller has a metalake-level grant (ownership or 
`MANAGE_USERS`/`MANAGE_GROUPS`/`MANAGE_GRANTS`). For the common case — a 
regular, non-admin caller relying on `USER::SELF`/`GROUP::SELF`/`ROLE::SELF` 
visibility — there's no batched equivalent, so every such request still falls 
through to the unchanged O(N) per-entity loop in `doFilter`, regardless of list 
size. Worse, `isSelf(ROLE)` in `JcasbinAuthorizer.java` calls 
`MetadataIdConverter.getID(...)` directly per role, bypassing the cache 
`isOwner` uses — an uncached single-entity DB fetch per role. Not introduced by 
this PR, but the title ("Avoid per-principal lookups during list filtering") 
reads as a general fix when the actual benefit is narrower — worth calling out 
in the PR description that this speeds up metalake-admin callers specifically, 
not the common self/membership-visibility case.
   
   ---
   
   **altitude**: Separately, 
`server/src/main/java/org/apache/gravitino/server/web/rest/MetadataObjectRoleOperations.java`
 lists `ROLE` entities using its own local expression constant 
(`"METALAKE::OWNER || ROLE::OWNER || ROLE::SELF"`), a different string than 
`LOAD_ROLE_AUTHORIZATION_EXPRESSION` registered here. Since this map matches by 
exact expression string, that endpoint never hits the new short-circuit and 
always pays full per-object cost, unlike `RoleOperations.listRoles`. Not a 
correctness bug, but worth confirming intentional — and it shows the registry's 
exact-string-match design is fragile: any caller not using the registered 
constant silently gets no benefit.
   
   ---
   
   **reuse**: The three new entries (USER/GROUP/ROLE) are structurally 
identical copy-paste (`Map.of(expr, List.of(parentOwnerPath(...), 
parentPrivilegePath(...)))`), unlike the pre-existing TABLE entry which already 
got a dedicated helper (`tableLikeParentPrivilegePath`) for a similar repeated 
shape. A `principalListPaths(expression, managePrivilege)` helper would 
collapse each block to a one-line call and prevent a future partial edit 
(fixing the pattern for one type but forgetting the others) from silently 
drifting.



##########
server-common/src/test/java/org/apache/gravitino/server/authorization/TestPrincipalListQueryCount.java:
##########
@@ -0,0 +1,262 @@
+/*
+ * 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.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.AuthorizationUtils;
+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.server.authorization.expression.AuthorizationExpressionConstants;
+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.Test;
+import org.junit.jupiter.api.function.Executable;
+import org.junit.jupiter.api.io.TempDir;
+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;
+
+  @Test
+  void testManagementListsHaveBoundedQueries() 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 the storage/list-filter path: the caller has a metalake 
management grant.
+      when(authorizer.authorize(any(), any(), any(), any(), any()))
+          .thenAnswer(
+              call -> {
+                MetadataObject object = call.getArgument(2);
+                Privilege.Name privilege = call.getArgument(3);
+                return object.type() == MetadataObject.Type.METALAKE
+                    && (privilege == Privilege.Name.MANAGE_USERS
+                        || privilege == Privilege.Name.MANAGE_GROUPS
+                        || privilege == Privilege.Name.MANAGE_GRANTS);
+              });
+      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)) {

Review Comment:
   **test-coverage**: This regression test is hand-scoped to exactly 
USER/GROUP/ROLE via a hardcoded enumeration. If a later change extends 
owner-preload skipping (or the `LIST_SHORT_CIRCUITS` registry) to another 
entity type, or a regression reintroduces per-principal lookups for a type 
outside this trio, nothing here catches it. Compounds the gap noted on line 114 
— the test doesn't even reach the logic for the types it does enumerate.



##########
server-common/src/test/java/org/apache/gravitino/server/authorization/TestPrincipalListQueryCount.java:
##########
@@ -0,0 +1,262 @@
+/*
+ * 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.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.AuthorizationUtils;
+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.server.authorization.expression.AuthorizationExpressionConstants;
+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.Test;
+import org.junit.jupiter.api.function.Executable;
+import org.junit.jupiter.api.io.TempDir;
+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;
+
+  @Test
+  void testManagementListsHaveBoundedQueries() 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 the storage/list-filter path: the caller has a metalake 
management grant.
+      when(authorizer.authorize(any(), any(), any(), any(), any()))

Review Comment:
   **test-coverage (high)**: This mock makes `authorizer.authorize(...)` always 
grant METALAKE-level `MANAGE_USERS`/`MANAGE_GROUPS`/`MANAGE_GRANTS`, so 
`allVisibleViaParentScope` always returns `true` and `filterByExpression` 
short-circuits and returns *before* `preloadToCache`/`preloadOwner` are ever 
called. This elaborate H2-backed test (1/1003/10000 entries, asserting exact 
SELECT counts) never actually reaches the `preloadOwner` skip logic this PR 
adds.
   
   The "query count independent of list size" claim is only proven for the 
pre-existing parent-scope shortcut path (already correct before this PR). The 
"2 SELECTs vs 1" difference it measures traces to an unrelated, pre-existing 
cost in `UserMetaService.listUsersByNamespace` (an extra metalake-id lookup 
when `details=true`), not to the owner-preload fix at all. Only the simpler 
mock-based `testPrincipalListDoesNotPreloadOwners` in 
`TestMetadataAuthzHelper.java` (via `verifyNoInteractions(store)`) actually 
proves the skip branch is taken — but it doesn't measure real query counts at 
scale. So the PR's actual performance claim for the owner-preload fix is 
unverified by any test in this diff, and a future regression reintroducing 
per-principal owner lookups for USER/GROUP would not be caught here.



##########
server-common/src/main/java/org/apache/gravitino/server/authorization/MetadataAuthzHelper.java:
##########
@@ -597,7 +615,10 @@ private static void preloadToCache(
   }
 
   private static void preloadOwner(Entity.EntityType entityType, 
NameIdentifier[] nameIdentifiers) {
-    if (!GravitinoEnv.getInstance().cacheEnabled()) {
+    // Only metadata objects can have owners. Resolving every user/group ID 
here adds two store
+    // lookups per entry even though their visibility expressions never 
consult an object owner.
+    if (!METADATA_OBJECT_ENTITY_TYPES.contains(entityType)

Review Comment:
   **altitude**: This guard is keyed on `entityType` membership in 
`METADATA_OBJECT_ENTITY_TYPES`, not on whether the specific authorization 
expression being evaluated actually references `OWNER`. If a future 
authorization expression for USER or GROUP ever gains a 
`USER::OWNER`/`GROUP::OWNER` token, this stays silently disabled for that type 
because the guard only inspects `entityType` — reintroducing the exact 
per-principal N+1 lookups this PR was written to eliminate, invisibly, until 
someone profiles it again. "Has an owner" is really asserted in two 
disconnected places (a comment in `OwnerManager.java` and this set), with 
nothing keeping them in sync.
   
   Separately, `METADATA_OBJECT_ENTITY_TYPES` is re-derived via 
`Entity.EntityType.valueOf(type.name())` instead of reusing 
`MetadataObjectUtil`'s existing authoritative `TYPE_TO_TYPE_MAP`. If a future 
`MetadataObject.Type`/`Entity.EntityType` name pair ever diverges (the exact 
case `TYPE_TO_TYPE_MAP` was built to handle safely), this ad hoc reconstruction 
throws `IllegalArgumentException` at class-init instead of failing the 
documented way.
   
   ---
   
   **simplification**: `METADATA_OBJECT_ENTITY_TYPES.contains(entityType)` is 
now evaluated twice for the same call — once at line ~403 (gating 
`checkMetadataObjectName`) and again here — with no shared variable connecting 
them. A reader auditing which types get owner-preload treatment has to notice 
these are the same condition checked in two unrelated-looking places; if 
someone changes the semantics of one without noticing the other, the two checks 
could silently diverge on which entity types they treat as metadata objects.



##########
server-common/src/test/java/org/apache/gravitino/server/authorization/TestMetadataAuthzHelper.java:
##########
@@ -821,4 +979,33 @@ private static void 
makeCompletableFutureUseCurrentThread() {
       throw new RuntimeException(e);
     }
   }
+
+  private static NameIdentifier[] principalIdentifiers(Entity.EntityType type, 
int count) {
+    return IntStream.range(0, count)
+        .mapToObj(
+            i ->
+                switch (type) {
+                  case USER -> NameIdentifierUtil.ofUser("testMetalake", 
"user" + i);
+                  case GROUP -> NameIdentifierUtil.ofGroup("testMetalake", 
"group" + i);
+                  default -> NameIdentifierUtil.ofRole("testMetalake", "role" 
+ i);
+                })
+        .toArray(NameIdentifier[]::new);
+  }
+
+  private static String principalListExpression(Entity.EntityType type) {

Review Comment:
   **reuse**: `principalListExpression(Entity.EntityType)` and the parallel 
identifier/privilege switch helpers here duplicate logic that 
`TestPrincipalListQueryCount.java` (same PR) reimplements inline via its own 
type-keyed switch statements, rather than sharing one small helper or test 
fixture. If a fourth principal-like list type is ever added, or one of the 
`LOAD_*_AUTHORIZATION_EXPRESSION` constants is renamed, someone has to remember 
to update both copies; the two test files currently agree only by accident.



##########
server-common/src/main/java/org/apache/gravitino/server/authorization/MetadataAuthzHelper.java:
##########
@@ -597,7 +615,10 @@ private static void preloadToCache(
   }
 
   private static void preloadOwner(Entity.EntityType entityType, 
NameIdentifier[] nameIdentifiers) {
-    if (!GravitinoEnv.getInstance().cacheEnabled()) {
+    // Only metadata objects can have owners. Resolving every user/group ID 
here adds two store

Review Comment:
   **conventions**: This comment says resolving owners "adds two store lookups 
per entry," but both `preloadToCache` and `preloadOwner` issue a single batched 
call per list request (`batchGet`/`batchListEntitiesByRelation`), not one 
lookup per identifier — confirmed by this PR's own 
`TestPrincipalListQueryCount` test asserting the count stays constant (1-2) 
regardless of list size. A future maintainer reading this comment while 
deciding whether a similar preload is safe to add elsewhere may believe the 
cost model is O(n) per entry and over-engineer a fix for a cost that's actually 
O(1) round-trips. Suggested rewording: "adds two extra batched store 
round-trips per list request."



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]

Reply via email to