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