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 f8cbcdd3f4 [#13980] fix(iceberg): Authorize table metrics reports 
(#13981)
f8cbcdd3f4 is described below

commit f8cbcdd3f444c783774df1d4c2cfc5f3d9dfc9bf
Author: roryqi <[email protected]>
AuthorDate: Wed Oct 21 09:36:28 2026 +0800

    [#13980] fix(iceberg): Authorize table metrics reports (#13981)
    
    ### What changes were proposed in this pull request?
    
    - Require table read authorization before accepting Iceberg REST metrics
    reports.
    - Verify that the table exists and that the report names the addressed
    table, including supported qualified names.
    - Deny intercepted methods that lack an authorization expression, and
    keep local authorization for metrics stored by a REST-backed catalog.
    
    ### Why are the changes needed?
    
    An authenticated caller without table access could submit fabricated
    metrics because the report endpoint had no authorization annotation.
    
    Fix: #13980
    
    ### Does this PR introduce _any_ user-facing change?
    
    Unauthorized reports return 403. Reports for nonexistent tables return
    404. Reports naming another table return 400.
    
    ### How was this patch tested?
    
    - Ran Spotless for `server-common` and `iceberg-rest-server`.
    - Ran `TestBaseMetadataAuthorizationMethodInterceptor`,
    `TestIcebergMetadataAuthorizationMethodInterceptor`, and
    `TestIcebergTableOperations.testReportTableMetrics` with Gradle; all
    passed.
---
 .../service/rest/IcebergTableOperations.java       | 40 +++++++++++++++---
 ...bergMetadataAuthorizationMethodInterceptor.java | 12 ++++++
 .../service/rest/TestIcebergTableOperations.java   | 49 ++++++++++++++++++++++
 ...bergMetadataAuthorizationMethodInterceptor.java | 27 ++++++++++++
 ...BaseMetadataAuthorizationMethodInterceptor.java | 23 +++++++++-
 ...BaseMetadataAuthorizationMethodInterceptor.java | 31 +++++++++++++-
 6 files changed, 175 insertions(+), 7 deletions(-)

diff --git 
a/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/rest/IcebergTableOperations.java
 
b/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/rest/IcebergTableOperations.java
index 552eb2726e..26bb0ca47f 100644
--- 
a/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/rest/IcebergTableOperations.java
+++ 
b/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/rest/IcebergTableOperations.java
@@ -77,6 +77,10 @@ import org.apache.iceberg.SnapshotRef;
 import org.apache.iceberg.TableMetadata;
 import org.apache.iceberg.catalog.Namespace;
 import org.apache.iceberg.catalog.TableIdentifier;
+import org.apache.iceberg.exceptions.NoSuchTableException;
+import org.apache.iceberg.metrics.CommitReport;
+import org.apache.iceberg.metrics.MetricsReport;
+import org.apache.iceberg.metrics.ScanReport;
 import org.apache.iceberg.rest.RESTUtil;
 import org.apache.iceberg.rest.requests.CreateTableRequest;
 import org.apache.iceberg.rest.requests.FetchScanTasksRequest;
@@ -405,10 +409,14 @@ public class IcebergTableOperations {
   @Produces(MediaType.APPLICATION_JSON)
   @Timed(name = "report-table-metrics." + MetricNames.HTTP_PROCESS_DURATION, 
absolute = true)
   @ResponseMetered(name = "report-table-metrics", absolute = true)
+  @AuthorizationExpression(
+      expression = LOAD_TABLE_AUTHORIZATION_EXPRESSION,
+      accessMetadataType = MetadataObject.Type.TABLE)
   public Response reportTableMetrics(
-      @PathParam("prefix") String prefix,
-      @Encoded() @PathParam("namespace") String namespace,
-      @Encoded() @PathParam("table") String table,
+      @AuthorizationMetadata(type = EntityType.CATALOG) @PathParam("prefix") 
String prefix,
+      @AuthorizationMetadata(type = EntityType.SCHEMA) @Encoded() 
@PathParam("namespace")
+          String namespace,
+      @AuthorizationMetadata(type = EntityType.TABLE) @Encoded() 
@PathParam("table") String table,
       ReportMetricsRequest request) {
     String catalogName = IcebergRESTUtils.getCatalogName(prefix);
     Namespace icebergNS =
@@ -423,8 +431,30 @@ public class IcebergTableOperations {
       return Utils.doAs(
           httpRequest,
           () -> {
-            boolean accepted =
-                icebergMetricsManager.recordMetric(catalogName, icebergNS, 
request.report());
+            IcebergRequestContext context =
+                new IcebergRequestContext(httpServletRequest(), catalogName);
+            TableIdentifier tableIdentifier = TableIdentifier.of(icebergNS, 
tableName);
+            if (!tableOperationDispatcher.tableExists(context, 
tableIdentifier)) {
+              throw new NoSuchTableException("Table %s not found", 
tableIdentifier);
+            }
+            MetricsReport report = request.report();
+            String reportedTableName;
+            if (report instanceof CommitReport) {
+              reportedTableName = ((CommitReport) report).tableName();
+            } else if (report instanceof ScanReport) {
+              reportedTableName = ((ScanReport) report).tableName();
+            } else {
+              throw new IllegalArgumentException("Unsupported metrics report 
type");
+            }
+            String qualifiedTableName = tableIdentifier.toString();
+            if (reportedTableName == null
+                || (!tableName.equals(reportedTableName)
+                    && !qualifiedTableName.equals(reportedTableName)
+                    && !reportedTableName.endsWith("." + qualifiedTableName))) 
{
+              throw new IllegalArgumentException(
+                  "Metrics report table name does not match the requested 
table");
+            }
+            boolean accepted = icebergMetricsManager.recordMetric(catalogName, 
icebergNS, report);
             if (accepted) {
               return IcebergRESTUtils.noContent();
             } else {
diff --git 
a/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/server/web/filter/IcebergMetadataAuthorizationMethodInterceptor.java
 
b/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/server/web/filter/IcebergMetadataAuthorizationMethodInterceptor.java
index 31275cc75e..c6c67f24b5 100644
--- 
a/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/server/web/filter/IcebergMetadataAuthorizationMethodInterceptor.java
+++ 
b/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/server/web/filter/IcebergMetadataAuthorizationMethodInterceptor.java
@@ -35,6 +35,7 @@ import 
org.apache.gravitino.iceberg.service.IcebergCatalogWrapperManager;
 import org.apache.gravitino.iceberg.service.IcebergExceptionMapper;
 import org.apache.gravitino.iceberg.service.IcebergRESTUtils;
 import 
org.apache.gravitino.iceberg.service.authorization.IcebergRESTServerContext;
+import org.apache.gravitino.iceberg.service.rest.IcebergTableOperations;
 import 
org.apache.gravitino.server.authorization.annotations.AuthorizationExpression;
 import 
org.apache.gravitino.server.authorization.annotations.AuthorizationMetadata;
 import 
org.apache.gravitino.server.authorization.annotations.IcebergAuthorizationMetadata;
@@ -206,4 +207,15 @@ public class IcebergMetadataAuthorizationMethodInterceptor
     // authorization. IRC2 handles authorization.
     return catalogWrapper.isRESTCatalog();
   }
+
+  /** Metrics are stored locally, so a downstream REST catalog cannot 
authorize the report. */
+  @Override
+  protected boolean shouldSkipAuthorization(
+      Method method, Map<EntityType, NameIdentifier> nameIdentifierMap) {
+    if (method.getDeclaringClass() == IcebergTableOperations.class
+        && method.getName().equals("reportTableMetrics")) {
+      return false;
+    }
+    return super.shouldSkipAuthorization(method, nameIdentifierMap);
+  }
 }
diff --git 
a/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/iceberg/service/rest/TestIcebergTableOperations.java
 
b/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/iceberg/service/rest/TestIcebergTableOperations.java
index d11ed1e1af..3d67e2c8f0 100644
--- 
a/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/iceberg/service/rest/TestIcebergTableOperations.java
+++ 
b/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/iceberg/service/rest/TestIcebergTableOperations.java
@@ -511,6 +511,55 @@ public class TestIcebergTableOperations extends 
IcebergNamespaceTestBase {
             .post(Entity.entity(request, MediaType.APPLICATION_JSON_TYPE));
 
     Assertions.assertEquals(Status.NO_CONTENT.getStatusCode(), 
response.getStatus());
+
+    ReportMetricsRequest qualifiedRequest =
+        ReportMetricsRequest.of(
+            ImmutableCommitReport.builder()
+                .tableName("client_catalog." + namespace + ".metrics_foo1")
+                .snapshotId(-1)
+                .sequenceNumber(-1)
+                .operation("append")
+                .commitMetrics(commitMetrics)
+                .build());
+    Response qualifiedResponse =
+        getReportMetricsClientBuilder("metrics_foo1", namespace)
+            .post(Entity.entity(qualifiedRequest, 
MediaType.APPLICATION_JSON_TYPE));
+    Assertions.assertEquals(Status.NO_CONTENT.getStatusCode(), 
qualifiedResponse.getStatus());
+
+    Response missingTableResponse =
+        getReportMetricsClientBuilder("missing_metrics_table", namespace)
+            .post(Entity.entity(request, MediaType.APPLICATION_JSON_TYPE));
+    Assertions.assertEquals(Status.NOT_FOUND.getStatusCode(), 
missingTableResponse.getStatus());
+
+    ReportMetricsRequest mismatchedRequest =
+        ReportMetricsRequest.of(
+            ImmutableCommitReport.builder()
+                .tableName("another_table")
+                .snapshotId(-1)
+                .sequenceNumber(-1)
+                .operation("append")
+                .commitMetrics(commitMetrics)
+                .build());
+    Response mismatchedTableResponse =
+        getReportMetricsClientBuilder("metrics_foo1", namespace)
+            .post(Entity.entity(mismatchedRequest, 
MediaType.APPLICATION_JSON_TYPE));
+    Assertions.assertEquals(
+        Status.BAD_REQUEST.getStatusCode(), 
mismatchedTableResponse.getStatus());
+
+    ReportMetricsRequest mismatchedNamespaceRequest =
+        ReportMetricsRequest.of(
+            ImmutableCommitReport.builder()
+                .tableName("client_catalog.other_namespace.metrics_foo1")
+                .snapshotId(-1)
+                .sequenceNumber(-1)
+                .operation("append")
+                .commitMetrics(commitMetrics)
+                .build());
+    Response mismatchedNamespaceResponse =
+        getReportMetricsClientBuilder("metrics_foo1", namespace)
+            .post(Entity.entity(mismatchedNamespaceRequest, 
MediaType.APPLICATION_JSON_TYPE));
+    Assertions.assertEquals(
+        Status.BAD_REQUEST.getStatusCode(), 
mismatchedNamespaceResponse.getStatus());
   }
 
   @ParameterizedTest
diff --git 
a/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/server/web/filter/TestIcebergMetadataAuthorizationMethodInterceptor.java
 
b/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/server/web/filter/TestIcebergMetadataAuthorizationMethodInterceptor.java
index c51052b3c3..990783110f 100644
--- 
a/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/server/web/filter/TestIcebergMetadataAuthorizationMethodInterceptor.java
+++ 
b/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/server/web/filter/TestIcebergMetadataAuthorizationMethodInterceptor.java
@@ -47,6 +47,7 @@ import 
org.apache.gravitino.iceberg.service.IcebergCatalogWrapperManager;
 import org.apache.gravitino.iceberg.service.IcebergRESTUtils;
 import 
org.apache.gravitino.iceberg.service.authorization.IcebergRESTServerContext;
 import org.apache.gravitino.iceberg.service.provider.IcebergConfigProvider;
+import org.apache.gravitino.iceberg.service.rest.IcebergTableOperations;
 import org.apache.gravitino.server.authorization.GravitinoAuthorizerProvider;
 import 
org.apache.gravitino.server.authorization.annotations.AuthorizationExpression;
 import 
org.apache.gravitino.server.authorization.annotations.AuthorizationMetadata;
@@ -57,6 +58,7 @@ import org.apache.iceberg.catalog.Namespace;
 import org.apache.iceberg.exceptions.ForbiddenException;
 import org.apache.iceberg.rest.RESTCatalog;
 import org.apache.iceberg.rest.RESTUtil;
+import org.apache.iceberg.rest.requests.ReportMetricsRequest;
 import org.apache.iceberg.rest.responses.ErrorResponse;
 import org.junit.jupiter.api.BeforeAll;
 import org.junit.jupiter.api.Test;
@@ -302,6 +304,31 @@ public class 
TestIcebergMetadataAuthorizationMethodInterceptor {
     Mockito.verify(invocation, Mockito.times(1)).proceed();
   }
 
+  @Test
+  public void testMetricsReportDoesNotSkipAuthorizationForRestCatalog() throws 
Exception {
+    IcebergCatalogWrapperManager wrapperManager = 
Mockito.mock(IcebergCatalogWrapperManager.class);
+    CatalogWrapperForREST wrapper = Mockito.mock(CatalogWrapperForREST.class);
+    
Mockito.when(wrapperManager.getCatalogWrapper(TEST_CATALOG)).thenReturn(wrapper);
+    Mockito.when(wrapper.isRESTCatalog()).thenReturn(true);
+    resetContext(wrapperManager, true);
+
+    Method method =
+        IcebergTableOperations.class.getMethod(
+            "reportTableMetrics",
+            String.class,
+            String.class,
+            String.class,
+            ReportMetricsRequest.class);
+    IcebergMetadataAuthorizationMethodInterceptor interceptor =
+        new IcebergMetadataAuthorizationMethodInterceptor();
+
+    assertFalse(
+        interceptor.shouldSkipAuthorization(
+            method,
+            Map.of(Entity.EntityType.CATALOG, NameIdentifier.of(TEST_METALAKE, 
TEST_CATALOG))));
+    Mockito.verifyNoInteractions(wrapperManager);
+  }
+
   @Test
   public void testInvokeDoesNotSkipAuthorizationForNonRestCatalog() throws 
Throwable {
     IcebergCatalogWrapperManager wrapperManager = 
Mockito.mock(IcebergCatalogWrapperManager.class);
diff --git 
a/server-common/src/main/java/org/apache/gravitino/server/web/filter/BaseMetadataAuthorizationMethodInterceptor.java
 
b/server-common/src/main/java/org/apache/gravitino/server/web/filter/BaseMetadataAuthorizationMethodInterceptor.java
index 3d76832d86..d7de2531dd 100644
--- 
a/server-common/src/main/java/org/apache/gravitino/server/web/filter/BaseMetadataAuthorizationMethodInterceptor.java
+++ 
b/server-common/src/main/java/org/apache/gravitino/server/web/filter/BaseMetadataAuthorizationMethodInterceptor.java
@@ -19,12 +19,14 @@
 
 package org.apache.gravitino.server.web.filter;
 
+import java.lang.annotation.Annotation;
 import java.lang.reflect.Method;
 import java.lang.reflect.Parameter;
 import java.util.Map;
 import java.util.Objects;
 import java.util.Optional;
 import java.util.Set;
+import javax.ws.rs.HttpMethod;
 import org.apache.gravitino.Entity;
 import org.apache.gravitino.NameIdentifier;
 import org.apache.gravitino.auth.ActiveRoles;
@@ -196,6 +198,18 @@ public abstract class 
BaseMetadataAuthorizationMethodInterceptor {
     return false;
   }
 
+  /**
+   * Returns whether authorization may be delegated for this operation.
+   *
+   * @param method invoked protocol method
+   * @param nameIdentifierMap identifiers resolved from the request
+   * @return {@code true} if the local authorization pipeline may be skipped
+   */
+  protected boolean shouldSkipAuthorization(
+      Method method, Map<Entity.EntityType, NameIdentifier> nameIdentifierMap) 
{
+    return shouldSkipAuthorization(nameIdentifierMap);
+  }
+
   /**
    * Hook for requests that must validate the current user and active roles 
but do not have a
    * metadata object on which to evaluate an expression. A protocol root-list 
request is a typical
@@ -240,7 +254,7 @@ public abstract class 
BaseMetadataAuthorizationMethodInterceptor {
         AuthorizationTarget target =
             resolveAuthorizationTarget(method, expressionAnnotation, 
parameters, args);
         Map<Entity.EntityType, NameIdentifier> nameIdentifierMap = 
target.nameIdentifiers();
-        boolean skipStandardCheck = shouldSkipAuthorization(nameIdentifierMap);
+        boolean skipStandardCheck = shouldSkipAuthorization(method, 
nameIdentifierMap);
 
         NameIdentifier metalakeIdent = 
nameIdentifierMap.get(Entity.EntityType.METALAKE);
         AuthorizationRequestContext authorizationRequestContext = new 
AuthorizationRequestContext();
@@ -328,6 +342,13 @@ public abstract class 
BaseMetadataAuthorizationMethodInterceptor {
         // A skipped standard check authorized nothing that list filtering 
could reuse.
         scope.bindIfRead(
             method, skipStandardCheck ? null : metalakeIdent, 
authorizationRequestContext);
+      } else {
+        for (Annotation annotation : method.getAnnotations()) {
+          if 
(annotation.annotationType().isAnnotationPresent(HttpMethod.class)) {
+            throw new ForbiddenException(
+                "Authorization expression is missing for operation '%s'", 
method.getName());
+          }
+        }
       }
     } catch (Exception ex) {
       if (ex instanceof ForbiddenException || isExceptionPropagate(ex)) {
diff --git 
a/server-common/src/test/java/org/apache/gravitino/server/web/filter/TestBaseMetadataAuthorizationMethodInterceptor.java
 
b/server-common/src/test/java/org/apache/gravitino/server/web/filter/TestBaseMetadataAuthorizationMethodInterceptor.java
index d100e1d405..7a4c5e4a33 100644
--- 
a/server-common/src/test/java/org/apache/gravitino/server/web/filter/TestBaseMetadataAuthorizationMethodInterceptor.java
+++ 
b/server-common/src/test/java/org/apache/gravitino/server/web/filter/TestBaseMetadataAuthorizationMethodInterceptor.java
@@ -307,12 +307,31 @@ public class 
TestBaseMetadataAuthorizationMethodInterceptor {
   public void testOperationFailureUsesProtocolMapper() throws Throwable {
     IllegalStateException failure = new IllegalStateException("operation 
failed");
     TestInterceptor interceptor = new 
TestInterceptor(Entity.EntityType.SCHEMA);
-    TestInvocation invocation = invocation("unannotated", null);
+    TestInvocation invocation = invocation("operationFails", null);
     when(invocation.proceed()).thenThrow(failure);
 
     assertSame(failure, interceptor.invoke(invocation));
   }
 
+  @Test
+  public void testMissingAuthorizationExpressionIsDenied() throws Throwable {
+    TestInterceptor interceptor = new 
TestInterceptor(Entity.EntityType.SCHEMA);
+    TestInvocation invocation = invocation("unannotated", "authorized");
+
+    ForbiddenException response =
+        assertInstanceOf(ForbiddenException.class, 
interceptor.invoke(invocation));
+    assertTrue(response.getMessage().contains("unannotated"));
+    verify(invocation, never()).proceed();
+  }
+
+  @Test
+  public void testUnannotatedHelperProceeds() throws Throwable {
+    TestInvocation invocation = invocation("helper", "helper result");
+
+    assertEquals("helper result", new 
TestInterceptor(Entity.EntityType.SCHEMA).invoke(invocation));
+    verify(invocation).proceed();
+  }
+
   /** Entry authorization, parent checks and worker filtering share one user 
lookup. */
   @ParameterizedTest
   @ValueSource(booleans = {false, true})
@@ -540,8 +559,18 @@ public class 
TestBaseMetadataAuthorizationMethodInterceptor {
       return "unused";
     }
 
+    @GET
     private String unannotated() {
       return "unused";
     }
+
+    private String helper() {
+      return "unused";
+    }
+
+    @AuthorizationExpression(expression = "true")
+    private String operationFails() {
+      return "unused";
+    }
   }
 }

Reply via email to