This is an automated email from the ASF dual-hosted git repository.

roryqi 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 9f8197917b [#13845] fix(server): Support authorized existence probing 
for view loads (#13846)
9f8197917b is described below

commit 9f8197917b6918e7f62ae7e2eac4c825d537de60
Author: jarred0214 <[email protected]>
AuthorDate: Wed Oct 21 14:48:40 2026 +0800

    [#13845] fix(server): Support authorized existence probing for view loads 
(#13846)
    
    ### What changes were proposed in this pull request?
    
    Add authorized existence probing to view loads using the existing
    table-like probe expression, and close the analogous absence-probe
    authorization gap for table loads.
    
    After the primary load permission is denied, a caller with probe
    permission uses the existing internal `loadTable` or `loadView` path. A
    successful internal load still returns an authorization denial. A
    missing object produces a terminal, standard 404 response in the
    interceptor without entering the REST load handler. View probes against
    catalogs without view support retain the original 403 behavior; other
    backend errors propagate.
    
    Both paths retain the existing internal loading and metadata import
    behavior. No new Core probe interface is introduced.
    
    ### Why are the changes needed?
    
    During Flink qualified name resolution, a missing candidate table can
    fall back to a view lookup. Returning 403 for that missing candidate
    view prevents resolution from continuing even when the actual source
    table is accessible.
    
    An absence observation must not authorize a later load: another request
    could create the same-named table or view between the probe and the
    handler, exposing metadata without load permission. Terminal 404
    responses close this gap for both table and view loads.
    
    Fix: #13845
    
    ### Does this PR introduce _any_ user-facing change?
    
    Authorized probes of missing tables and views return 404 without
    invoking the normal load handler. Existing objects remain forbidden
    without their primary load permission, and callers without probe
    permission cannot check existence. Probe-only requests cannot read
    metadata from an object created after the absence observation.
    
    ### How was this patch tested?
    
    Added table and view race regressions using real authorization
    executors, the interceptor, and REST resource methods with deterministic
    mocked reads: the internal load observes absence while a concurrent
    create makes the object available for a subsequent load. Both requests
    return their standard not-found error and never enter the handler.
    
    Coverage also checks successful internal loads remaining forbidden,
    denied probes avoiding dispatcher calls, primary permission bypassing
    probes, unsupported view operations, backend error propagation, and
    endpoint wiring.
    
    Passed `./gradlew spotlessApply` and `./gradlew :server:test
    spotlessCheck -PskipITs --no-parallel` (671 non-Docker Server tests).
    Docker-based integration validation remains pending CI.
---
 .../annotations/AuthorizationRequest.java          |   2 +
 .../web/filter/GravitinoInterceptionService.java   |  26 ++
 .../authorization/AuthorizeExecutorFactory.java    |   2 +
 .../LoadTableAuthorizationExecutor.java            |   6 +-
 .../LoadViewAuthorizationExecutor.java             |  78 ++++++
 .../gravitino/server/web/rest/ViewOperations.java  |   8 +-
 .../filter/TestGravitinoInterceptionService.java   | 282 ++++++++++++++++++++-
 .../TestLoadTableAuthorizationExecutor.java        |  33 ++-
 .../TestLoadViewAuthorizationExecutor.java         | 169 ++++++++++++
 9 files changed, 594 insertions(+), 12 deletions(-)

diff --git 
a/server-common/src/main/java/org/apache/gravitino/server/authorization/annotations/AuthorizationRequest.java
 
b/server-common/src/main/java/org/apache/gravitino/server/authorization/annotations/AuthorizationRequest.java
index 33e2a79b25..7eb664f30d 100644
--- 
a/server-common/src/main/java/org/apache/gravitino/server/authorization/annotations/AuthorizationRequest.java
+++ 
b/server-common/src/main/java/org/apache/gravitino/server/authorization/annotations/AuthorizationRequest.java
@@ -34,6 +34,8 @@ public @interface AuthorizationRequest {
     RUN_JOB,
     LINEAGE,
     LOAD_TABLE,
+    /** Loads a view with authorized existence probing. */
+    LOAD_VIEW,
     CREATE_SCHEMA,
     TEST_CATALOG_CONNECTION
   }
diff --git 
a/server/src/main/java/org/apache/gravitino/server/web/filter/GravitinoInterceptionService.java
 
b/server/src/main/java/org/apache/gravitino/server/web/filter/GravitinoInterceptionService.java
index 026413e4ce..e9d0395aff 100644
--- 
a/server/src/main/java/org/apache/gravitino/server/web/filter/GravitinoInterceptionService.java
+++ 
b/server/src/main/java/org/apache/gravitino/server/web/filter/GravitinoInterceptionService.java
@@ -49,6 +49,8 @@ import org.apache.gravitino.exceptions.ForbiddenException;
 import org.apache.gravitino.exceptions.IllegalMetadataObjectException;
 import org.apache.gravitino.exceptions.IllegalNameIdentifierException;
 import org.apache.gravitino.exceptions.NoSuchMetalakeException;
+import org.apache.gravitino.exceptions.NoSuchTableException;
+import org.apache.gravitino.exceptions.NoSuchViewException;
 import org.apache.gravitino.lineage.source.rest.LineageOperations;
 import 
org.apache.gravitino.listener.api.event.server.AuthorizationDenialFailureEvent;
 import org.apache.gravitino.server.authorization.AuthorizationRequestScope;
@@ -61,6 +63,7 @@ import 
org.apache.gravitino.server.web.filter.authorization.AuthorizationExecuto
 import 
org.apache.gravitino.server.web.filter.authorization.AuthorizeExecutorFactory;
 import org.apache.gravitino.server.web.rest.BulkOperations;
 import org.apache.gravitino.server.web.rest.CatalogOperations;
+import org.apache.gravitino.server.web.rest.ExceptionHandlers;
 import org.apache.gravitino.server.web.rest.FilesetOperations;
 import org.apache.gravitino.server.web.rest.FunctionOperations;
 import org.apache.gravitino.server.web.rest.GroupOperations;
@@ -71,6 +74,7 @@ import 
org.apache.gravitino.server.web.rest.MetadataObjectSecretOperations;
 import org.apache.gravitino.server.web.rest.MetadataObjectTagOperations;
 import org.apache.gravitino.server.web.rest.MetalakeOperations;
 import org.apache.gravitino.server.web.rest.ModelOperations;
+import org.apache.gravitino.server.web.rest.OperationType;
 import org.apache.gravitino.server.web.rest.OwnerOperations;
 import org.apache.gravitino.server.web.rest.PartitionOperations;
 import org.apache.gravitino.server.web.rest.PermissionOperations;
@@ -85,6 +89,7 @@ import org.apache.gravitino.server.web.rest.TagOperations;
 import org.apache.gravitino.server.web.rest.TopicOperations;
 import org.apache.gravitino.server.web.rest.UserOperations;
 import org.apache.gravitino.server.web.rest.ViewOperations;
+import org.apache.gravitino.utils.NameIdentifierUtil;
 import org.apache.gravitino.utils.PrincipalUtils;
 import org.glassfish.hk2.api.Descriptor;
 import org.glassfish.hk2.api.Filter;
@@ -242,6 +247,27 @@ public class GravitinoInterceptionService implements 
InterceptionService {
             boolean authorizeResult;
             try {
               authorizeResult = executor.execute(authorizationRequestContext);
+            } catch (NoSuchTableException exception) {
+              NameIdentifier tableIdent = 
metadataContext.get(Entity.EntityType.TABLE);
+              if (tableIdent == null) {
+                throw exception;
+              }
+              return ExceptionHandlers.handleTableException(
+                  OperationType.LOAD,
+                  tableIdent.name(),
+                  NameIdentifierUtil.getSchemaIdentifier(tableIdent).name(),
+                  exception);
+            } catch (NoSuchViewException exception) {
+              NameIdentifier viewIdent = 
metadataContext.get(Entity.EntityType.VIEW);
+              if (viewIdent == null) {
+                throw exception;
+              }
+              // An authorized absence probe must terminate before the 
resource can load a view.
+              return ExceptionHandlers.handleViewException(
+                  OperationType.LOAD,
+                  viewIdent.name(),
+                  NameIdentifierUtil.getSchemaIdentifier(viewIdent).name(),
+                  exception);
             } catch (BadRequestException exception) {
               LOG.warn("Invalid authorization request", exception);
               return Utils.illegalArguments(exception.getMessage(), exception);
diff --git 
a/server/src/main/java/org/apache/gravitino/server/web/filter/authorization/AuthorizeExecutorFactory.java
 
b/server/src/main/java/org/apache/gravitino/server/web/filter/authorization/AuthorizeExecutorFactory.java
index 8f4e4ad466..ea3a683525 100644
--- 
a/server/src/main/java/org/apache/gravitino/server/web/filter/authorization/AuthorizeExecutorFactory.java
+++ 
b/server/src/main/java/org/apache/gravitino/server/web/filter/authorization/AuthorizeExecutorFactory.java
@@ -56,6 +56,8 @@ public class AuthorizeExecutorFactory {
           secondaryExpression,
           secondaryExpressionCondition,
           allowCheckExistenceExpression);
+      case LOAD_VIEW -> new LoadViewAuthorizationExecutor(
+          expression, metadataContext, pathParams, entityType, 
allowCheckExistenceExpression);
       case CREATE_SCHEMA -> new CreateSchemaAuthorizationExecutor(
           parameters, args, expression, metadataContext, pathParams, 
entityType);
       case TEST_CATALOG_CONNECTION -> new 
CatalogConnectionTestAuthorizationExecutor(
diff --git 
a/server/src/main/java/org/apache/gravitino/server/web/filter/authorization/LoadTableAuthorizationExecutor.java
 
b/server/src/main/java/org/apache/gravitino/server/web/filter/authorization/LoadTableAuthorizationExecutor.java
index aa0b1f2207..36b49e43e1 100644
--- 
a/server/src/main/java/org/apache/gravitino/server/web/filter/authorization/LoadTableAuthorizationExecutor.java
+++ 
b/server/src/main/java/org/apache/gravitino/server/web/filter/authorization/LoadTableAuthorizationExecutor.java
@@ -87,8 +87,10 @@ public class LoadTableAuthorizationExecutor extends 
CommonAuthorizerExecutor {
     }
 
     NameIdentifier tableIdentifier = 
metadataContext.get(Entity.EntityType.TABLE);
-    return tableIdentifier != null
-        && 
!GravitinoEnv.getInstance().internalTableDispatcher().tableExists(tableIdentifier);
+    if (tableIdentifier != null) {
+      
GravitinoEnv.getInstance().internalTableDispatcher().loadTable(tableIdentifier);
+    }
+    return false;
   }
 
   private static boolean shouldCheckModifyTablePrivilege(
diff --git 
a/server/src/main/java/org/apache/gravitino/server/web/filter/authorization/LoadViewAuthorizationExecutor.java
 
b/server/src/main/java/org/apache/gravitino/server/web/filter/authorization/LoadViewAuthorizationExecutor.java
new file mode 100644
index 0000000000..8b22d12362
--- /dev/null
+++ 
b/server/src/main/java/org/apache/gravitino/server/web/filter/authorization/LoadViewAuthorizationExecutor.java
@@ -0,0 +1,78 @@
+/*
+ * 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.web.filter.authorization;
+
+import java.util.Map;
+import java.util.Optional;
+import org.apache.commons.lang3.StringUtils;
+import org.apache.gravitino.Entity;
+import org.apache.gravitino.GravitinoEnv;
+import org.apache.gravitino.NameIdentifier;
+import org.apache.gravitino.authorization.AuthorizationRequestContext;
+import 
org.apache.gravitino.server.authorization.expression.AuthorizationExpressionEvaluator;
+
+/** Authorizes view loads and permits authorized probes of absent views. */
+public class LoadViewAuthorizationExecutor extends CommonAuthorizerExecutor {
+  private final AuthorizationExpressionEvaluator allowCheckExistenceEvaluator;
+
+  /**
+   * Creates an executor for loading views.
+   *
+   * @param expression the primary authorization expression
+   * @param metadataContext the metadata identifiers
+   * @param pathParams the request path parameters
+   * @param entityType the optional entity type
+   * @param allowCheckExistenceExpression the expression permitting existence 
probes
+   */
+  public LoadViewAuthorizationExecutor(
+      String expression,
+      Map<Entity.EntityType, NameIdentifier> metadataContext,
+      Map<String, Object> pathParams,
+      Optional<String> entityType,
+      String allowCheckExistenceExpression) {
+    super(expression, metadataContext, pathParams, entityType);
+    allowCheckExistenceEvaluator =
+        StringUtils.isBlank(allowCheckExistenceExpression)
+            ? null
+            : new 
AuthorizationExpressionEvaluator(allowCheckExistenceExpression);
+  }
+
+  /** {@inheritDoc} */
+  @Override
+  public boolean execute(AuthorizationRequestContext 
authorizationRequestContext) throws Exception {
+    if (super.execute(authorizationRequestContext)) {
+      return true;
+    }
+
+    if (allowCheckExistenceEvaluator == null
+        || !allowCheckExistenceEvaluator.evaluate(
+            metadataContext, pathParams, authorizationRequestContext, 
entityType)) {
+      return false;
+    }
+
+    NameIdentifier viewIdentifier = 
metadataContext.get(Entity.EntityType.VIEW);
+    if (viewIdentifier == null) {
+      return false;
+    }
+    try {
+      
GravitinoEnv.getInstance().internalViewDispatcher().loadView(viewIdentifier);
+    } catch (UnsupportedOperationException e) {
+      // Preserve the primary denial when the connector cannot perform the 
existence probe.
+    }
+    return false;
+  }
+}
diff --git 
a/server/src/main/java/org/apache/gravitino/server/web/rest/ViewOperations.java 
b/server/src/main/java/org/apache/gravitino/server/web/rest/ViewOperations.java
index 794298b25f..786d41ea29 100644
--- 
a/server/src/main/java/org/apache/gravitino/server/web/rest/ViewOperations.java
+++ 
b/server/src/main/java/org/apache/gravitino/server/web/rest/ViewOperations.java
@@ -49,6 +49,7 @@ import org.apache.gravitino.rel.ViewChange;
 import org.apache.gravitino.server.authorization.MetadataAuthzHelper;
 import 
org.apache.gravitino.server.authorization.annotations.AuthorizationExpression;
 import 
org.apache.gravitino.server.authorization.annotations.AuthorizationMetadata;
+import 
org.apache.gravitino.server.authorization.annotations.AuthorizationRequest;
 import 
org.apache.gravitino.server.authorization.expression.AuthorizationExpressionConstants;
 import org.apache.gravitino.server.web.Utils;
 import org.apache.gravitino.utils.NameIdentifierUtil;
@@ -163,13 +164,18 @@ public class ViewOperations {
   @ResponseMetered(name = "load-view", absolute = true)
   @AuthorizationExpression(
       expression = 
AuthorizationExpressionConstants.LOAD_VIEW_AUTHORIZATION_EXPRESSION,
+      allowCheckExistence =
+          
AuthorizationExpressionConstants.PROBE_TABLE_LIKE_AUTHORIZATION_EXPRESSION,
       accessMetadataType = MetadataObject.Type.VIEW)
   public Response loadView(
       @PathParam("metalake") @AuthorizationMetadata(type = 
Entity.EntityType.METALAKE)
           String metalake,
       @PathParam("catalog") @AuthorizationMetadata(type = 
Entity.EntityType.CATALOG) String catalog,
       @PathParam("schema") @AuthorizationMetadata(type = 
Entity.EntityType.SCHEMA) String schema,
-      @PathParam("view") @AuthorizationMetadata(type = Entity.EntityType.VIEW) 
String view) {
+      @PathParam("view")
+          @AuthorizationMetadata(type = Entity.EntityType.VIEW)
+          @AuthorizationRequest(type = 
AuthorizationRequest.RequestType.LOAD_VIEW)
+          String view) {
     LOG.info("Received load view request for view: {}.{}.{}.{}", metalake, 
catalog, schema, view);
     try {
       return Utils.doAs(
diff --git 
a/server/src/test/java/org/apache/gravitino/server/web/filter/TestGravitinoInterceptionService.java
 
b/server/src/test/java/org/apache/gravitino/server/web/filter/TestGravitinoInterceptionService.java
index 16df186d96..8bc3aeab59 100644
--- 
a/server/src/test/java/org/apache/gravitino/server/web/filter/TestGravitinoInterceptionService.java
+++ 
b/server/src/test/java/org/apache/gravitino/server/web/filter/TestGravitinoInterceptionService.java
@@ -35,6 +35,8 @@ import com.google.common.collect.ImmutableList;
 import java.io.IOException;
 import java.lang.reflect.Method;
 import java.security.Principal;
+import java.security.PrivilegedExceptionAction;
+import java.time.Instant;
 import java.util.Collections;
 import java.util.List;
 import java.util.concurrent.CopyOnWriteArrayList;
@@ -68,10 +70,22 @@ import org.apache.gravitino.dto.responses.ErrorConstants;
 import org.apache.gravitino.dto.responses.ErrorResponse;
 import org.apache.gravitino.exceptions.ForbiddenException;
 import org.apache.gravitino.exceptions.NoSuchMetalakeException;
+import org.apache.gravitino.exceptions.NoSuchTableException;
+import org.apache.gravitino.exceptions.NoSuchViewException;
 import org.apache.gravitino.json.JsonUtils;
 import org.apache.gravitino.listener.EventBus;
 import 
org.apache.gravitino.listener.api.event.server.AuthorizationDenialFailureEvent;
+import org.apache.gravitino.meta.AuditInfo;
 import org.apache.gravitino.metalake.MetalakeManager;
+import org.apache.gravitino.rel.Column;
+import org.apache.gravitino.rel.Representation;
+import org.apache.gravitino.rel.SQLRepresentation;
+import org.apache.gravitino.rel.Table;
+import org.apache.gravitino.rel.View;
+import org.apache.gravitino.rel.expressions.distributions.Distributions;
+import org.apache.gravitino.rel.expressions.sorts.SortOrder;
+import org.apache.gravitino.rel.expressions.transforms.Transform;
+import org.apache.gravitino.rel.indexes.Index;
 import org.apache.gravitino.server.authorization.AuthorizationRequestScope;
 import org.apache.gravitino.server.authorization.GravitinoAuthorizerProvider;
 import 
org.apache.gravitino.server.authorization.annotations.AuthorizationExpression;
@@ -962,7 +976,7 @@ public class TestGravitinoInterceptionService {
   }
 
   @Test
-  public void 
testLoadTableAuthorizationProceedsWhenProbeAllowedTableDoesNotExist()
+  public void 
testLoadTableAuthorizationTerminatesWhenProbeAllowedTableDoesNotExist()
       throws Throwable {
     try (MockedStatic<PrincipalUtils> principalUtilsMocked = 
mockStatic(PrincipalUtils.class);
         MockedStatic<GravitinoAuthorizerProvider> authorizerMocked =
@@ -992,7 +1006,8 @@ public class TestGravitinoInterceptionService {
       envMocked.when(GravitinoEnv::getInstance).thenReturn(mockEnv);
       when(mockEnv.internalTableDispatcher()).thenReturn(tableDispatcher);
       when(mockEnv.eventBus()).thenReturn(mockEventBus);
-      
when(tableDispatcher.tableExists(ArgumentMatchers.any())).thenReturn(false);
+      when(tableDispatcher.loadTable(ArgumentMatchers.any()))
+          .thenThrow(new NoSuchTableException("Table is absent"));
 
       MethodInterceptor interceptor = tableLoadInterceptor();
       MethodInvocation invocation = tableLoadInvocation();
@@ -1002,8 +1017,11 @@ public class TestGravitinoInterceptionService {
       Response response = (Response) interceptor.invoke(invocation);
 
       assertEquals(Response.Status.NOT_FOUND.getStatusCode(), 
response.getStatus());
-      verify(invocation).proceed();
-      verify(tableDispatcher).tableExists(ArgumentMatchers.any());
+      verify(invocation, never()).proceed();
+      assertEquals(
+          NoSuchTableException.class.getSimpleName(),
+          ((ErrorResponse) response.getEntity()).getType());
+      verify(tableDispatcher).loadTable(ArgumentMatchers.any());
       verify(mockEventBus, never()).dispatchEvent(ArgumentMatchers.any());
     }
   }
@@ -1039,7 +1057,7 @@ public class TestGravitinoInterceptionService {
       envMocked.when(GravitinoEnv::getInstance).thenReturn(mockEnv);
       when(mockEnv.internalTableDispatcher()).thenReturn(tableDispatcher);
       when(mockEnv.eventBus()).thenReturn(mockEventBus);
-      
when(tableDispatcher.tableExists(ArgumentMatchers.any())).thenReturn(true);
+      
when(tableDispatcher.loadTable(ArgumentMatchers.any())).thenReturn(mock(Table.class));
 
       MethodInterceptor interceptor = tableLoadInterceptor();
       MethodInvocation invocation = tableLoadInvocation();
@@ -1048,12 +1066,264 @@ public class TestGravitinoInterceptionService {
 
       assertEquals(Response.Status.FORBIDDEN.getStatusCode(), 
response.getStatus());
       verify(invocation, never()).proceed();
-      verify(tableDispatcher).tableExists(ArgumentMatchers.any());
+      verify(tableDispatcher).loadTable(ArgumentMatchers.any());
+      verify(mockEventBus)
+          
.dispatchEvent(ArgumentMatchers.any(AuthorizationDenialFailureEvent.class));
+    }
+  }
+
+  /**
+   * Unsupported view probes preserve the primary denial and its authorization 
event.
+   *
+   * @throws Throwable If authorization interception fails.
+   */
+  @Test
+  public void 
testLoadViewAuthorizationReturnsForbiddenWhenViewsAreUnsupported() throws 
Throwable {
+    try (MockedStatic<PrincipalUtils> principalUtilsMocked = 
mockStatic(PrincipalUtils.class);
+        MockedStatic<GravitinoAuthorizerProvider> authorizerMocked =
+            mockStatic(GravitinoAuthorizerProvider.class);
+        MockedStatic<AuthorizationUtils> authUtilsMocked = 
mockStatic(AuthorizationUtils.class);
+        MockedStatic<GravitinoEnv> envMocked = mockStatic(GravitinoEnv.class)) 
{
+      principalUtilsMocked
+          .when(PrincipalUtils::getCurrentPrincipal)
+          .thenReturn(new UserPrincipal("tester"));
+      
principalUtilsMocked.when(PrincipalUtils::getCurrentUserName).thenReturn("tester");
+
+      authUtilsMocked
+          .when(
+              () ->
+                  AuthorizationUtils.checkCurrentUser(
+                      ArgumentMatchers.any(), ArgumentMatchers.any(), 
ArgumentMatchers.any()))
+          .thenAnswer(invocation -> null);
+
+      GravitinoAuthorizerProvider mockedProvider = 
mock(GravitinoAuthorizerProvider.class);
+      GravitinoAuthorizer authorizer = tableProbeAuthorizer();
+      
authorizerMocked.when(GravitinoAuthorizerProvider::getInstance).thenReturn(mockedProvider);
+      when(mockedProvider.getGravitinoAuthorizer()).thenReturn(authorizer);
+
+      GravitinoEnv mockEnv = mock(GravitinoEnv.class);
+      ViewDispatcher viewDispatcher = mock(ViewDispatcher.class);
+      EventBus mockEventBus = spy(new EventBus(Collections.emptyList()));
+      envMocked.when(GravitinoEnv::getInstance).thenReturn(mockEnv);
+      when(mockEnv.internalViewDispatcher()).thenReturn(viewDispatcher);
+      when(mockEnv.eventBus()).thenReturn(mockEventBus);
+      when(viewDispatcher.loadView(ArgumentMatchers.any()))
+          .thenThrow(new UnsupportedOperationException("Catalog does not 
support view operations"));
+
+      Method method =
+          ViewOperations.class.getMethod(
+              "loadView", String.class, String.class, String.class, 
String.class);
+      MethodInterceptor interceptor =
+          new 
GravitinoInterceptionService().getMethodInterceptors(method).get(0);
+      MethodInvocation invocation = mock(MethodInvocation.class);
+      when(invocation.getMethod()).thenReturn(method);
+      when(invocation.getArguments())
+          .thenReturn(new Object[] {"testMetalake", "testCatalog", 
"testSchema", "missingView"});
+
+      Response response = (Response) interceptor.invoke(invocation);
+
+      assertEquals(Response.Status.FORBIDDEN.getStatusCode(), 
response.getStatus());
+      verify(invocation, never()).proceed();
+      verify(viewDispatcher).loadView(ArgumentMatchers.any());
       verify(mockEventBus)
           
.dispatchEvent(ArgumentMatchers.any(AuthorizationDenialFailureEvent.class));
     }
   }
 
+  /**
+   * An absent view probe is terminal even when a concurrent create makes the 
view loadable.
+   *
+   * @throws Throwable If authorization interception fails.
+   */
+  @Test
+  public void testMissingViewProbeDoesNotLoadConcurrentlyCreatedView() throws 
Throwable {
+    try (MockedStatic<PrincipalUtils> principalUtilsMocked = 
mockStatic(PrincipalUtils.class);
+        MockedStatic<GravitinoAuthorizerProvider> authorizerMocked =
+            mockStatic(GravitinoAuthorizerProvider.class);
+        MockedStatic<AuthorizationUtils> authUtilsMocked = 
mockStatic(AuthorizationUtils.class);
+        MockedStatic<GravitinoEnv> envMocked = mockStatic(GravitinoEnv.class)) 
{
+      principalUtilsMocked
+          .when(PrincipalUtils::getCurrentPrincipal)
+          .thenReturn(new UserPrincipal("tester"));
+      
principalUtilsMocked.when(PrincipalUtils::getCurrentUserName).thenReturn("tester");
+
+      authUtilsMocked
+          .when(
+              () ->
+                  AuthorizationUtils.checkCurrentUser(
+                      ArgumentMatchers.any(), ArgumentMatchers.any(), 
ArgumentMatchers.any()))
+          .thenAnswer(invocation -> null);
+
+      GravitinoAuthorizerProvider mockedProvider = 
mock(GravitinoAuthorizerProvider.class);
+      GravitinoAuthorizer authorizer = tableProbeAuthorizer();
+      
authorizerMocked.when(GravitinoAuthorizerProvider::getInstance).thenReturn(mockedProvider);
+      when(mockedProvider.getGravitinoAuthorizer()).thenReturn(authorizer);
+
+      GravitinoEnv mockEnv = mock(GravitinoEnv.class);
+      ViewDispatcher viewDispatcher = mock(ViewDispatcher.class);
+      EventBus mockEventBus = spy(new EventBus(Collections.emptyList()));
+      envMocked.when(GravitinoEnv::getInstance).thenReturn(mockEnv);
+      when(mockEnv.internalViewDispatcher()).thenReturn(viewDispatcher);
+      when(mockEnv.eventBus()).thenReturn(mockEventBus);
+      AtomicReference<View> created = new AtomicReference<>();
+      View view = mock(View.class);
+      when(view.name()).thenReturn("missingView");
+      when(view.columns()).thenReturn(new Column[0]);
+      when(view.representations())
+          .thenReturn(
+              new Representation[] {
+                SQLRepresentation.builder()
+                    .withDialect("spark")
+                    .withSql("SELECT secret FROM private_table")
+                    .build()
+              });
+      when(view.properties()).thenReturn(Collections.emptyMap());
+      when(view.auditInfo())
+          .thenReturn(
+              
AuditInfo.builder().withCreator("owner").withCreateTime(Instant.now()).build());
+      when(viewDispatcher.loadView(ArgumentMatchers.any()))
+          .thenAnswer(
+              ignored -> {
+                if (created.get() == null) {
+                  // The load observed absence; another request creates the 
view before the handler
+                  // can run.
+                  created.set(view);
+                  throw new NoSuchViewException("View is absent");
+                }
+                return created.get();
+              });
+      ViewOperations operations = new ViewOperations(viewDispatcher);
+      FieldUtils.writeField(operations, "httpRequest", 
mock(HttpServletRequest.class), true);
+      principalUtilsMocked
+          .when(() -> PrincipalUtils.doAs(any(), any()))
+          .thenAnswer(
+              invocation -> ((PrivilegedExceptionAction<?>) 
invocation.getArgument(1)).run());
+
+      Method method =
+          ViewOperations.class.getMethod(
+              "loadView", String.class, String.class, String.class, 
String.class);
+      MethodInterceptor interceptor =
+          new 
GravitinoInterceptionService().getMethodInterceptors(method).get(0);
+      MethodInvocation invocation = mock(MethodInvocation.class);
+      when(invocation.getMethod()).thenReturn(method);
+      when(invocation.getArguments())
+          .thenReturn(new Object[] {"testMetalake", "testCatalog", 
"testSchema", "missingView"});
+
+      when(invocation.proceed())
+          .thenAnswer(
+              ignored ->
+                  operations.loadView("testMetalake", "testCatalog", 
"testSchema", "missingView"));
+      Response response = (Response) interceptor.invoke(invocation);
+
+      assertEquals(Response.Status.NOT_FOUND.getStatusCode(), 
response.getStatus());
+      verify(invocation, never()).proceed();
+      verify(viewDispatcher).loadView(ArgumentMatchers.any());
+      Assertions.assertSame(view, created.get());
+      Assertions.assertTrue(response.getEntity() instanceof ErrorResponse);
+      assertEquals(
+          NoSuchViewException.class.getSimpleName(),
+          ((ErrorResponse) response.getEntity()).getType());
+
+      verify(mockEventBus, never()).dispatchEvent(ArgumentMatchers.any());
+    }
+  }
+
+  /**
+   * An absent table probe is terminal even when a concurrent create makes the 
table loadable.
+   *
+   * @throws Throwable If authorization interception fails.
+   */
+  @Test
+  public void testMissingTableProbeDoesNotLoadConcurrentlyCreatedTable() 
throws Throwable {
+    try (MockedStatic<PrincipalUtils> principalUtilsMocked = 
mockStatic(PrincipalUtils.class);
+        MockedStatic<GravitinoAuthorizerProvider> authorizerMocked =
+            mockStatic(GravitinoAuthorizerProvider.class);
+        MockedStatic<AuthorizationUtils> authUtilsMocked = 
mockStatic(AuthorizationUtils.class);
+        MockedStatic<GravitinoEnv> envMocked = mockStatic(GravitinoEnv.class)) 
{
+      principalUtilsMocked
+          .when(PrincipalUtils::getCurrentPrincipal)
+          .thenReturn(new UserPrincipal("tester"));
+      
principalUtilsMocked.when(PrincipalUtils::getCurrentUserName).thenReturn("tester");
+
+      authUtilsMocked
+          .when(
+              () ->
+                  AuthorizationUtils.checkCurrentUser(
+                      ArgumentMatchers.any(), ArgumentMatchers.any(), 
ArgumentMatchers.any()))
+          .thenAnswer(invocation -> null);
+
+      GravitinoAuthorizerProvider mockedProvider = 
mock(GravitinoAuthorizerProvider.class);
+      GravitinoAuthorizer authorizer = tableProbeAuthorizer();
+      
authorizerMocked.when(GravitinoAuthorizerProvider::getInstance).thenReturn(mockedProvider);
+      when(mockedProvider.getGravitinoAuthorizer()).thenReturn(authorizer);
+
+      GravitinoEnv mockEnv = mock(GravitinoEnv.class);
+      TableDispatcher tableDispatcher = mock(TableDispatcher.class);
+      EventBus mockEventBus = spy(new EventBus(Collections.emptyList()));
+      envMocked.when(GravitinoEnv::getInstance).thenReturn(mockEnv);
+      when(mockEnv.internalTableDispatcher()).thenReturn(tableDispatcher);
+      when(mockEnv.eventBus()).thenReturn(mockEventBus);
+      AtomicReference<Table> created = new AtomicReference<>();
+      Table table = mock(Table.class);
+      when(table.name()).thenReturn("missingTable");
+      when(table.columns()).thenReturn(new Column[0]);
+      when(table.sortOrder()).thenReturn(new SortOrder[0]);
+      when(table.partitioning()).thenReturn(new Transform[0]);
+      when(table.index()).thenReturn(new Index[0]);
+      when(table.distribution()).thenReturn(Distributions.NONE);
+      when(table.properties()).thenReturn(Collections.emptyMap());
+      when(table.auditInfo())
+          .thenReturn(
+              
AuditInfo.builder().withCreator("owner").withCreateTime(Instant.now()).build());
+      when(tableDispatcher.loadTable(ArgumentMatchers.any()))
+          .thenAnswer(
+              ignored -> {
+                if (created.get() == null) {
+                  // The load observed absence; another request creates the 
table before the handler
+                  // can run.
+                  created.set(table);
+                  throw new NoSuchTableException("Table is absent");
+                }
+                return created.get();
+              });
+      TableOperations operations = new TableOperations(tableDispatcher);
+      FieldUtils.writeField(operations, "httpRequest", 
mock(HttpServletRequest.class), true);
+      principalUtilsMocked
+          .when(() -> PrincipalUtils.doAs(any(), any()))
+          .thenAnswer(
+              invocation -> ((PrivilegedExceptionAction<?>) 
invocation.getArgument(1)).run());
+
+      Method method =
+          TableOperations.class.getMethod(
+              "loadTable", String.class, String.class, String.class, 
String.class, String.class);
+      MethodInterceptor interceptor =
+          new 
GravitinoInterceptionService().getMethodInterceptors(method).get(0);
+      MethodInvocation invocation = mock(MethodInvocation.class);
+      when(invocation.getMethod()).thenReturn(method);
+      when(invocation.getArguments())
+          .thenReturn(
+              new Object[] {"testMetalake", "testCatalog", "testSchema", 
"missingTable", null});
+
+      when(invocation.proceed())
+          .thenAnswer(
+              ignored ->
+                  operations.loadTable(
+                      "testMetalake", "testCatalog", "testSchema", 
"missingTable", null));
+      Response response = (Response) interceptor.invoke(invocation);
+
+      assertEquals(Response.Status.NOT_FOUND.getStatusCode(), 
response.getStatus());
+      verify(invocation, never()).proceed();
+      verify(tableDispatcher).loadTable(ArgumentMatchers.any());
+      Assertions.assertSame(table, created.get());
+      Assertions.assertTrue(response.getEntity() instanceof ErrorResponse);
+      assertEquals(
+          NoSuchTableException.class.getSimpleName(),
+          ((ErrorResponse) response.getEntity()).getType());
+
+      verify(mockEventBus, never()).dispatchEvent(ArgumentMatchers.any());
+    }
+  }
+
   /**
    * When {@code checkCurrentUser} throws {@link NoSuchMetalakeException}, the 
403 is a
    * resource-not-found masquerading as forbidden — no {@link 
AuthorizationDenialFailureEvent} is
diff --git 
a/server/src/test/java/org/apache/gravitino/server/web/filter/authorization/TestLoadTableAuthorizationExecutor.java
 
b/server/src/test/java/org/apache/gravitino/server/web/filter/authorization/TestLoadTableAuthorizationExecutor.java
index 2382155146..73a7507ea8 100644
--- 
a/server/src/test/java/org/apache/gravitino/server/web/filter/authorization/TestLoadTableAuthorizationExecutor.java
+++ 
b/server/src/test/java/org/apache/gravitino/server/web/filter/authorization/TestLoadTableAuthorizationExecutor.java
@@ -20,12 +20,20 @@
 package org.apache.gravitino.server.web.filter.authorization;
 
 import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertSame;
+import static org.junit.jupiter.api.Assertions.assertThrows;
 import static org.junit.jupiter.api.Assertions.assertTrue;
 import static org.mockito.ArgumentMatchers.any;
 import static org.mockito.ArgumentMatchers.anyMap;
+import static org.mockito.Mockito.doReturn;
+import static org.mockito.Mockito.doThrow;
 import static org.mockito.Mockito.mock;
 import static org.mockito.Mockito.mockStatic;
+import static org.mockito.Mockito.reset;
+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;
@@ -39,6 +47,8 @@ import org.apache.gravitino.GravitinoEnv;
 import org.apache.gravitino.NameIdentifier;
 import org.apache.gravitino.authorization.AuthorizationRequestContext;
 import org.apache.gravitino.catalog.TableDispatcher;
+import org.apache.gravitino.exceptions.NoSuchTableException;
+import org.apache.gravitino.rel.Table;
 import 
org.apache.gravitino.server.authorization.annotations.AuthorizationRequest;
 import 
org.apache.gravitino.server.authorization.annotations.ExpressionCondition;
 import 
org.apache.gravitino.server.authorization.expression.AuthorizationExpressionConstants;
@@ -109,14 +119,31 @@ public class TestLoadTableAuthorizationExecutor {
     GravitinoEnv env = mock(GravitinoEnv.class);
     TableDispatcher internalTableDispatcher = mock(TableDispatcher.class);
     when(env.internalTableDispatcher()).thenReturn(internalTableDispatcher);
-    
when(internalTableDispatcher.tableExists(tableIdentifier)).thenReturn(false);
+    when(internalTableDispatcher.loadTable(tableIdentifier))
+        .thenThrow(new NoSuchTableException("Table is absent"));
 
     try (MockedStatic<GravitinoEnv> mockedEnv = 
mockStatic(GravitinoEnv.class)) {
       mockedEnv.when(GravitinoEnv::getInstance).thenReturn(env);
+      assertThrows(
+          NoSuchTableException.class, () -> executor.execute(new 
AuthorizationRequestContext()));
+      
doReturn(mock(Table.class)).when(internalTableDispatcher).loadTable(tableIdentifier);
+      assertFalse(executor.execute(new AuthorizationRequestContext()));
+      IllegalStateException failure = new IllegalStateException("Table backend 
unavailable");
+      
doThrow(failure).when(internalTableDispatcher).loadTable(tableIdentifier);
+      assertSame(
+          failure,
+          assertThrows(
+              IllegalStateException.class,
+              () -> executor.execute(new AuthorizationRequestContext())));
+      verify(internalTableDispatcher, times(3)).loadTable(tableIdentifier);
+      reset(internalTableDispatcher);
+      when(existenceEvaluator.evaluate(anyMap(), anyMap(), any(), 
any())).thenReturn(false);
+      assertFalse(executor.execute(new AuthorizationRequestContext()));
+      verifyNoInteractions(internalTableDispatcher);
+      when(primaryEvaluator.evaluate(anyMap(), anyMap(), any(), 
any())).thenReturn(true);
       assertTrue(executor.execute(new AuthorizationRequestContext()));
+      verifyNoInteractions(internalTableDispatcher);
     }
-
-    verify(internalTableDispatcher).tableExists(tableIdentifier);
   }
 
   private static LoadTableAuthorizationExecutor createExecutor(
diff --git 
a/server/src/test/java/org/apache/gravitino/server/web/filter/authorization/TestLoadViewAuthorizationExecutor.java
 
b/server/src/test/java/org/apache/gravitino/server/web/filter/authorization/TestLoadViewAuthorizationExecutor.java
new file mode 100644
index 0000000000..0de15b0ebb
--- /dev/null
+++ 
b/server/src/test/java/org/apache/gravitino/server/web/filter/authorization/TestLoadViewAuthorizationExecutor.java
@@ -0,0 +1,169 @@
+/*
+ * 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.web.filter.authorization;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertInstanceOf;
+import static org.junit.jupiter.api.Assertions.assertSame;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyMap;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.mockStatic;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyNoInteractions;
+import static org.mockito.Mockito.when;
+
+import java.util.Map;
+import java.util.Optional;
+import javax.annotation.Nullable;
+import org.apache.commons.lang3.reflect.FieldUtils;
+import org.apache.gravitino.Entity;
+import org.apache.gravitino.GravitinoEnv;
+import org.apache.gravitino.NameIdentifier;
+import org.apache.gravitino.authorization.AuthorizationRequestContext;
+import org.apache.gravitino.catalog.ViewDispatcher;
+import org.apache.gravitino.exceptions.NoSuchViewException;
+import org.apache.gravitino.rel.View;
+import 
org.apache.gravitino.server.authorization.annotations.AuthorizationExpression;
+import 
org.apache.gravitino.server.authorization.annotations.AuthorizationRequest;
+import 
org.apache.gravitino.server.authorization.annotations.ExpressionCondition;
+import 
org.apache.gravitino.server.authorization.expression.AuthorizationExpressionConstants;
+import 
org.apache.gravitino.server.authorization.expression.AuthorizationExpressionEvaluator;
+import org.apache.gravitino.server.web.rest.ViewOperations;
+import org.junit.jupiter.api.Test;
+import org.mockito.MockedStatic;
+
+public class TestLoadViewAuthorizationExecutor {
+  @Test
+  public void testAuthorizedMissingViewProbe() throws Exception {
+    assertThrows(NoSuchViewException.class, () -> check(false, true, false, 
true, true));
+  }
+
+  @Test
+  public void testExistingViewRemainsForbidden() throws Exception {
+    check(false, true, true, false, true);
+  }
+
+  @Test
+  public void testNoProbePermissionDoesNotCheckExistence() throws Exception {
+    check(false, false, false, false, false);
+  }
+
+  @Test
+  public void testPrimaryPermissionDoesNotProbe() throws Exception {
+    check(true, false, true, true, false);
+  }
+
+  @Test
+  public void testViewEndpointSelectsProbeExecutor() throws Exception {
+    var method =
+        ViewOperations.class.getMethod(
+            "loadView", String.class, String.class, String.class, 
String.class);
+    var annotation = method.getAnnotation(AuthorizationExpression.class);
+    var request = 
method.getParameters()[3].getAnnotation(AuthorizationRequest.class);
+    assertEquals(AuthorizationRequest.RequestType.LOAD_VIEW, request.type());
+    assertEquals(
+        
AuthorizationExpressionConstants.PROBE_TABLE_LIKE_AUTHORIZATION_EXPRESSION,
+        annotation.allowCheckExistence());
+    assertInstanceOf(
+        LoadViewAuthorizationExecutor.class,
+        AuthorizeExecutorFactory.create(
+            annotation.expression(),
+            request.type(),
+            Map.of(),
+            Map.of(),
+            Optional.empty(),
+            method.getParameters(),
+            new Object[] {"m", "c", "s", "v"},
+            "",
+            ExpressionCondition.NEVER,
+            annotation.allowCheckExistence()));
+  }
+
+  @Test
+  void testUnsupportedViewOperationsRemainForbidden() throws Exception {
+    check(
+        false,
+        true,
+        false,
+        false,
+        true,
+        new UnsupportedOperationException("Catalog does not support view 
operations"));
+  }
+
+  @Test
+  void testProbeBackendFailureIsNotTreatedAsMissingView() {
+    IllegalStateException failure = new IllegalStateException("View backend 
unavailable");
+    assertSame(
+        failure,
+        assertThrows(
+            IllegalStateException.class, () -> check(false, true, false, 
false, true, failure)));
+  }
+
+  private void check(
+      boolean primary, boolean probe, boolean exists, boolean expected, 
boolean shouldProbe)
+      throws Exception {
+    check(primary, probe, exists, expected, shouldProbe, null);
+  }
+
+  private void check(
+      boolean primary,
+      boolean probe,
+      boolean exists,
+      boolean expected,
+      boolean shouldProbe,
+      @Nullable RuntimeException probeFailure)
+      throws Exception {
+    NameIdentifier ident = NameIdentifier.of("m", "c", "s", "v");
+    var executor =
+        new LoadViewAuthorizationExecutor(
+            
AuthorizationExpressionConstants.LOAD_VIEW_AUTHORIZATION_EXPRESSION,
+            Map.of(Entity.EntityType.VIEW, ident),
+            Map.of(),
+            Optional.empty(),
+            
AuthorizationExpressionConstants.PROBE_TABLE_LIKE_AUTHORIZATION_EXPRESSION);
+    var primaryEvaluator = mock(AuthorizationExpressionEvaluator.class);
+    var probeEvaluator = mock(AuthorizationExpressionEvaluator.class);
+    when(primaryEvaluator.evaluate(anyMap(), anyMap(), any(), 
any())).thenReturn(primary);
+    when(probeEvaluator.evaluate(anyMap(), anyMap(), any(), 
any())).thenReturn(probe);
+    executor.authorizationExpressionEvaluator = primaryEvaluator;
+    FieldUtils.writeField(executor, "allowCheckExistenceEvaluator", 
probeEvaluator, true);
+    var env = mock(GravitinoEnv.class);
+    var dispatcher = mock(ViewDispatcher.class);
+    when(env.internalViewDispatcher()).thenReturn(dispatcher);
+    if (probeFailure != null) {
+      when(dispatcher.loadView(ident)).thenThrow(probeFailure);
+    } else if (exists) {
+      when(dispatcher.loadView(ident)).thenReturn(mock(View.class));
+    } else {
+      when(dispatcher.loadView(ident)).thenThrow(new NoSuchViewException("View 
is absent"));
+    }
+    try (MockedStatic<GravitinoEnv> mocked = mockStatic(GravitinoEnv.class)) {
+      mocked.when(GravitinoEnv::getInstance).thenReturn(env);
+      assertEquals(expected, executor.execute(new 
AuthorizationRequestContext()));
+    }
+    if (shouldProbe) {
+      verify(dispatcher).loadView(ident);
+    } else {
+      verifyNoInteractions(dispatcher);
+    }
+  }
+}

Reply via email to