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 e211d267a0 [#10685] feat(iceberg-rest): Support vended credentials on 
planTableScan endpoint (#10800)
e211d267a0 is described below

commit e211d267a0ef31d4844dfe2165a8c33bc8ed90c6
Author: Sachin Ranjalkar <[email protected]>
AuthorDate: Mon Jul 13 07:01:01 2026 +0530

    [#10685] feat(iceberg-rest): Support vended credentials on planTableScan 
endpoint (#10800)
    
    ### What changes were proposed in this pull request?
    
    Add `X-Iceberg-Access-Delegation` header support to the `planTableScan`
    endpoint and return storage credentials in the response when
    `vended-credentials` is requested. Mirrors the existing
    credential-vending flow from `createTable`/`loadTable`/`registerTable`
    (#10684).
    
    Changes:
    - `IcebergTableOperations.planTableScan`: read
    `@HeaderParam(X_ICEBERG_ACCESS_DELEGATION)`, compute
    `isCredentialVending`, build `IcebergRequestContext` with credential
    flag
    - `IcebergTableOperationExecutor.planTableScan`: resolve
    `CredentialPrivilege` and pass credential-vending flag through the
    dispatcher to `CatalogWrapperForREST`
    - `CatalogWrapperForREST.planTableScan`: add 4-arg overload that accepts
    `requestCredentialVending` and `CredentialPrivilege`; inject credentials
    via `PlanTableScanResponse.Builder.withCredentials()` *after* cache
    lookup so cached plans remain credential-free
    - `CatalogWrapperForREST.injectScanCredentials`: reuses the
    already-loaded `Table` object from scan planning (no redundant
    `loadTable`), catches only `ServiceUnavailableException`
    - `CatalogWrapperForREST.getCredentialFromTable`: extracts credentials
    from a `Table` object directly, following the same location/property
    logic as `getCredential(TableMetadata, ...)`
    - `FederatedCatalogWrapper.planTableScan`: overrides to fetch
    credentials from the remote REST catalog instead of the local credential
    manager
    -
    `TestCatalogWrapperForREST.testPlanTableScanCacheDoesNotLeakCredentials`:
    verifies cached plans don't leak credentials to non-vended requests and
    that vended requests on cache hits get fresh credentials
    - `TestIcebergTableOperations`: three new tests — credential vending (no
    header / local / S3 with prefix verification), remote-signing rejection
    (406), invalid header (400)
    
    ### Why are the changes needed?
    
    The Iceberg REST spec defines `X-Iceberg-Access-Delegation` as a valid
    header on `planTableScan` and `CompletedPlanningResult` includes a
    `storage-credentials` field. Currently, clients performing server-side
    scan planning must make a separate `GET .../credentials` call to obtain
    storage access credentials before reading the data files returned in the
    scan plan.
    
    Fixes #10685
    
    ### Does this PR introduce _any_ user-facing change?
    
    Yes. The `planTableScan` REST endpoint now accepts the
    `X-Iceberg-Access-Delegation` header and returns vended credentials in
    the response `storage-credentials` field when requested. Backward
    compatible -- clients that do not send the header get existing behavior.
    
    ### How was this patch tested?
    
    Added unit tests:
    -
    `TestCatalogWrapperForREST.testPlanTableScanCacheDoesNotLeakCredentials`
    -- vended request returns credentials, non-vended cache hit returns
    none, vended cache hit returns fresh credentials
    - `TestIcebergTableOperations.testPlanTableScanWithCredentialVending` --
    no vending without header, no vending for `file://` location, vending
    present for `s3://` location with correct credential type and prefix
    -
    `TestIcebergTableOperations.testPlanTableScanRemoteSigningNotSupported`
    -- 406 response
    - `TestIcebergTableOperations.testPlanTableScanInvalidAccessDelegation`
    -- 400 response
    
    All existing tests pass (no regressions).
---
 .../iceberg/service/CatalogWrapperForREST.java     | 115 ++++++--
 .../iceberg/service/FederatedCatalogWrapper.java   | 124 +++++++++
 .../iceberg/service/IcebergRESTUtils.java          |  44 +++
 .../dispatcher/IcebergTableOperationExecutor.java  |   6 +-
 .../service/rest/IcebergTableOperations.java       |  13 +-
 .../iceberg/service/TestCatalogWrapperForREST.java | 305 +++++++++++++++++++++
 .../service/rest/TestIcebergTableOperations.java   | 110 ++++++++
 7 files changed, 682 insertions(+), 35 deletions(-)

diff --git 
a/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/CatalogWrapperForREST.java
 
b/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/CatalogWrapperForREST.java
index e02c6d4f55..a2749325ae 100644
--- 
a/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/CatalogWrapperForREST.java
+++ 
b/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/CatalogWrapperForREST.java
@@ -39,6 +39,7 @@ import 
org.apache.gravitino.credential.CatalogCredentialManager;
 import org.apache.gravitino.credential.Credential;
 import org.apache.gravitino.credential.CredentialConstants;
 import org.apache.gravitino.credential.CredentialPrivilege;
+import org.apache.gravitino.credential.CredentialPropertyUtils;
 import org.apache.gravitino.credential.PathBasedCredentialContext;
 import org.apache.gravitino.iceberg.common.IcebergConfig;
 import org.apache.gravitino.iceberg.common.ops.IcebergCatalogWrapper;
@@ -48,6 +49,7 @@ import org.apache.gravitino.storage.GCSProperties;
 import org.apache.gravitino.utils.ClassUtils;
 import org.apache.gravitino.utils.MapUtils;
 import org.apache.gravitino.utils.PrincipalUtils;
+import org.apache.iceberg.BaseTable;
 import org.apache.iceberg.FileScanTask;
 import org.apache.iceberg.IncrementalAppendScan;
 import org.apache.iceberg.Scan;
@@ -373,7 +375,19 @@ public class CatalogWrapperForREST extends 
IcebergCatalogWrapper {
   }
 
   /**
-   * Plan table scan and return scan tasks.
+   * Plan table scan without credential vending.
+   *
+   * @param tableIdentifier The table identifier.
+   * @param scanRequest The scan request parameters.
+   * @return PlanTableScanResponse with status=COMPLETED and file scan tasks.
+   */
+  public PlanTableScanResponse planTableScan(
+      TableIdentifier tableIdentifier, PlanTableScanRequest scanRequest) {
+    return planTableScan(tableIdentifier, scanRequest, false, 
CredentialPrivilege.READ);
+  }
+
+  /**
+   * Plan table scan and optionally inject vended storage credentials.
    *
    * <p>This method performs server-side scan planning to optimize query 
performance by reducing
    * client-side metadata loading and enabling parallel task execution.
@@ -384,18 +398,27 @@ public class CatalogWrapperForREST extends 
IcebergCatalogWrapper {
    * different from asynchronous mode (SUBMITTED status) where a plan ID is 
returned for later
    * retrieval.
    *
+   * <p>When {@code requestCredentialVending} is true and the table is 
eligible (non-local,
+   * non-HDFS), storage credentials are injected directly into the response 
using the table already
+   * loaded for scan planning -- avoiding a redundant {@code loadTable} call.
+   *
    * <p>Referenced from Iceberg PR #13400 for scan planning implementation.
    *
    * @param tableIdentifier The table identifier.
    * @param scanRequest The scan request parameters including filters, 
projections, snapshot-id,
    *     etc.
+   * @param requestCredentialVending whether the client requested credential 
vending
+   * @param privilege the credential privilege level for vending
    * @return PlanTableScanResponse with status=COMPLETED and file scan tasks.
    * @throws IllegalArgumentException if scan request validation fails
    * @throws org.apache.gravitino.exceptions.NoSuchTableException if table 
doesn't exist
    * @throws RuntimeException for other scan planning failures
    */
   public PlanTableScanResponse planTableScan(
-      TableIdentifier tableIdentifier, PlanTableScanRequest scanRequest) {
+      TableIdentifier tableIdentifier,
+      PlanTableScanRequest scanRequest,
+      boolean requestCredentialVending,
+      CredentialPrivilege privilege) {
 
     LOG.debug(
         "Planning scan for table: {}, snapshotId: {}, startSnapshotId: {}, 
endSnapshotId: {}, select: {}, caseSensitive: {}",
@@ -410,43 +433,47 @@ public class CatalogWrapperForREST extends 
IcebergCatalogWrapper {
       Table table = getCatalog().loadTable(tableIdentifier);
       Optional<PlanTableScanResponse> cachedResponse =
           scanPlanCache.get(ScanPlanCacheKey.create(tableIdentifier, table, 
scanRequest));
+
+      PlanTableScanResponse response;
       if (cachedResponse.isPresent()) {
         LOG.info("Using cached scan plan for table: {}", tableIdentifier);
-        return cachedResponse.get();
-      }
+        response = cachedResponse.get();
+      } else {
+        List<FileScanTask> fileScanTasks = new ArrayList<>();
+
+        try (CloseableIterable<FileScanTask> scanTasks =
+            createFilePlanScanTasks(table, tableIdentifier, scanRequest)) {
+          for (FileScanTask fileScanTask : scanTasks) {
+            fileScanTasks.add(fileScanTask);
+          }
+        } catch (IOException e) {
+          LOG.error("Failed to close scan task iterator for table: {}", 
tableIdentifier, e);
+          throw new RuntimeException("Failed to plan scan tasks: " + 
e.getMessage(), e);
+        }
 
-      List<FileScanTask> fileScanTasks = new ArrayList<>();
+        if (fileScanTasks.isEmpty()) {
+          LOG.info(
+              "Scan planning returned no tasks for table: {}. Table may be 
empty or fully filtered.",
+              tableIdentifier);
+        }
 
-      try (CloseableIterable<FileScanTask> scanTasks =
-          createFilePlanScanTasks(table, tableIdentifier, scanRequest)) {
-        for (FileScanTask fileScanTask : scanTasks) {
-          fileScanTasks.add(fileScanTask);
+        try {
+          response = buildCompletedPlanTableScanResponse(table, fileScanTasks);
+        } catch (Exception e) {
+          LOG.error("Failed to build scan plan response for table: {}", 
tableIdentifier, e);
+          throw new RuntimeException(
+              String.format(
+                  "Failed to build scan plan response for table: %s. Error: 
%s",
+                  tableIdentifier, e.getMessage()),
+              e);
         }
-      } catch (IOException e) {
-        LOG.error("Failed to close scan task iterator for table: {}", 
tableIdentifier, e);
-        throw new RuntimeException("Failed to plan scan tasks: " + 
e.getMessage(), e);
-      }
 
-      if (fileScanTasks.isEmpty()) {
-        LOG.info(
-            "Scan planning returned no tasks for table: {}. Table may be empty 
or fully filtered.",
-            tableIdentifier);
+        scanPlanCache.put(ScanPlanCacheKey.create(tableIdentifier, table, 
scanRequest), response);
       }
 
-      PlanTableScanResponse response;
-      try {
-        response = buildCompletedPlanTableScanResponse(table, fileScanTasks);
-      } catch (Exception e) {
-        LOG.error("Failed to build scan plan response for table: {}", 
tableIdentifier, e);
-        throw new RuntimeException(
-            String.format(
-                "Failed to build scan plan response for table: %s. Error: %s",
-                tableIdentifier, e.getMessage()),
-            e);
+      if (requestCredentialVending && 
!isLocalOrHdfsLocation(table.location())) {
+        response = injectScanCredentials(tableIdentifier, table, response, 
privilege);
       }
-
-      // Cache the scan plan response
-      scanPlanCache.put(ScanPlanCacheKey.create(tableIdentifier, table, 
scanRequest), response);
       return response;
 
     } catch (IllegalArgumentException e) {
@@ -462,6 +489,34 @@ public class CatalogWrapperForREST extends 
IcebergCatalogWrapper {
     }
   }
 
+  /**
+   * Inject vended credentials into a scan response using the already-loaded 
table, avoiding a
+   * redundant {@code loadTable} call. Follows the same eligibility logic as 
{@link
+   * #shouldGenerateCredential} and the same credential generation as {@link 
#getCredential}.
+   */
+  private PlanTableScanResponse injectScanCredentials(
+      TableIdentifier tableIdentifier,
+      Table table,
+      PlanTableScanResponse response,
+      CredentialPrivilege privilege) {
+    try {
+      validateCredentialLocation(table.location());
+      TableMetadata metadata = ((BaseTable) table).operations().current();
+      Credential credential = getCredential(metadata, privilege);
+      Map<String, String> config =
+          new 
HashMap<>(CredentialPropertyUtils.toIcebergProperties(credential));
+      config.putAll(
+          IcebergRESTUtils.buildRefreshProps(
+              catalogCredentialManager.catalogName(), tableIdentifier, 
config));
+      org.apache.iceberg.rest.credentials.Credential restCred =
+          IcebergRESTUtils.toRESTCredential(table.location(), config);
+      return IcebergRESTUtils.copyWithCredentials(response, 
Collections.singletonList(restCred));
+    } catch (ServiceUnavailableException e) {
+      LOG.warn("Failed to generate scan credentials for table: {}", 
tableIdentifier, e);
+      return response;
+    }
+  }
+
   /**
    * Builds a synchronous COMPLETED scan plan response for Iceberg 1.11+ REST 
clients only.
    *
diff --git 
a/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/FederatedCatalogWrapper.java
 
b/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/FederatedCatalogWrapper.java
index d11da80c98..b4128ff724 100644
--- 
a/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/FederatedCatalogWrapper.java
+++ 
b/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/FederatedCatalogWrapper.java
@@ -21,6 +21,7 @@ package org.apache.gravitino.iceberg.service;
 
 import com.google.common.annotations.VisibleForTesting;
 import com.google.common.base.Preconditions;
+import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.Maps;
 import java.time.OffsetDateTime;
 import java.time.ZoneOffset;
@@ -57,6 +58,7 @@ import org.apache.iceberg.io.FileIO;
 import org.apache.iceberg.rest.CatalogHandlers;
 import org.apache.iceberg.rest.ErrorHandlers;
 import org.apache.iceberg.rest.HTTPClient;
+import org.apache.iceberg.rest.ParserContext;
 import org.apache.iceberg.rest.RESTCatalog;
 import org.apache.iceberg.rest.RESTClient;
 import org.apache.iceberg.rest.RESTUtil;
@@ -66,10 +68,12 @@ import org.apache.iceberg.rest.auth.AuthManagers;
 import org.apache.iceberg.rest.auth.AuthSession;
 import org.apache.iceberg.rest.credentials.Credential;
 import org.apache.iceberg.rest.requests.CreateTableRequest;
+import org.apache.iceberg.rest.requests.PlanTableScanRequest;
 import org.apache.iceberg.rest.requests.RegisterTableRequest;
 import org.apache.iceberg.rest.requests.UpdateTableRequest;
 import org.apache.iceberg.rest.responses.LoadCredentialsResponse;
 import org.apache.iceberg.rest.responses.LoadTableResponse;
+import org.apache.iceberg.rest.responses.PlanTableScanResponse;
 
 /**
  * A {@link CatalogWrapperForREST} for a federated Iceberg REST catalog (the 
underlying catalog is a
@@ -153,6 +157,37 @@ public class FederatedCatalogWrapper extends 
CatalogWrapperForREST {
         catalogCredentialManager.catalogName(), identifier, upstream);
   }
 
+  /**
+   * Delegates scan planning to the remote REST catalog instead of executing 
it locally.
+   *
+   * <p>When credential vending is requested, the {@code 
X-Iceberg-Access-Delegation:
+   * vended-credentials} header is forwarded so the remote catalog returns 
credentials inline. Any
+   * upstream credential refresh endpoints are rewritten to point at this IRC 
instance.
+   *
+   * @param tableIdentifier the table to scan.
+   * @param scanRequest the scan request parameters.
+   * @param requestCredentialVending whether the client requested vended 
credentials.
+   * @param privilege ignored; the remote REST catalog decides what to vend.
+   * @return the scan response from the remote catalog, with rewritten 
credential refresh endpoints.
+   */
+  @Override
+  public PlanTableScanResponse planTableScan(
+      TableIdentifier tableIdentifier,
+      PlanTableScanRequest scanRequest,
+      boolean requestCredentialVending,
+      CredentialPrivilege privilege) {
+    Table table = getCatalog().loadTable(tableIdentifier);
+    PlanTableScanResponse response =
+        getRESTTablePlanScan(
+            (RESTCatalog) getCatalog(),
+            tableIdentifier,
+            scanRequest,
+            requestCredentialVending,
+            table.specs());
+    return IcebergRESTUtils.rewriteScanPlanCredentials(
+        catalogCredentialManager.catalogName(), tableIdentifier, response);
+  }
+
   private static LoadCredentialsResponse getRESTTableCredentials(
       RESTCatalog restCatalog, TableIdentifier identifier) {
     Map<String, String> properties = Maps.newHashMap(restCatalog.properties());
@@ -207,6 +242,95 @@ public class FederatedCatalogWrapper extends 
CatalogWrapperForREST {
     }
   }
 
+  /**
+   * Sends a {@code POST {table}/plan} request to the remote REST catalog.
+   *
+   * <p>Follows the same HTTP client lifecycle as {@link 
#getRESTTableCredentials}. When credential
+   * vending is requested, the {@code X-Iceberg-Access-Delegation: 
vended-credentials} header is
+   * included so the remote catalog returns credentials inline in the plan 
response.
+   *
+   * <p>The Iceberg response deserializer requires pre-loaded partition specs 
to parse {@code
+   * file-scan-tasks}. These are supplied via a {@link ParserContext} built 
from the caller-provided
+   * {@code specsById} map (typically obtained from a prior {@code loadTable} 
call).
+   *
+   * @param restCatalog the underlying REST catalog whose properties supply 
the URI and auth config.
+   * @param identifier the table to plan.
+   * @param scanRequest the scan request parameters.
+   * @param requestCredentialVending whether to include the access-delegation 
header.
+   * @param specsById partition specs for the table, needed for response 
deserialization.
+   * @return the plan response from the remote catalog.
+   */
+  private static PlanTableScanResponse getRESTTablePlanScan(
+      RESTCatalog restCatalog,
+      TableIdentifier identifier,
+      PlanTableScanRequest scanRequest,
+      boolean requestCredentialVending,
+      Map<Integer, PartitionSpec> specsById) {
+    Map<String, String> properties = Maps.newHashMap(restCatalog.properties());
+    String planPath = 
ResourcePaths.forCatalogProperties(properties).planTableScan(identifier);
+
+    Map<String, String> headers =
+        requestCredentialVending
+            ? ImmutableMap.of("X-Iceberg-Access-Delegation", 
"vended-credentials")
+            : Collections.emptyMap();
+
+    ParserContext parserContext =
+        ParserContext.builder()
+            .add("specsById", specsById)
+            .add("caseSensitive", scanRequest.caseSensitive())
+            .build();
+
+    AuthManager authManager = null;
+    RESTClient client = null;
+    AuthSession authSession = null;
+    try {
+      authManager = AuthManagers.loadAuthManager(restCatalog.name(), 
properties);
+      client =
+          HTTPClient.builder(properties)
+              .uri(properties.get(CatalogProperties.URI))
+              .withHeaders(RESTUtil.configHeaders(properties))
+              .build();
+      authSession = authManager.catalogSession(client, properties);
+      return client
+          .withAuthSession(authSession)
+          .post(
+              planPath,
+              scanRequest,
+              PlanTableScanResponse.class,
+              headers,
+              ErrorHandlers.planErrorHandler(),
+              ignored -> {},
+              parserContext);
+    } finally {
+      if (authSession != null) {
+        try {
+          authSession.close();
+        } catch (Exception e) {
+          LOG.warn(
+              "Failed to close auth session when planning table scan for 
table: {}", identifier, e);
+        }
+      }
+
+      if (client != null) {
+        try {
+          client.close();
+        } catch (Exception e) {
+          LOG.warn(
+              "Failed to close REST client when planning table scan for table: 
{}", identifier, e);
+        }
+      }
+
+      if (authManager != null) {
+        try {
+          authManager.close();
+        } catch (Exception e) {
+          LOG.warn(
+              "Failed to close auth manager when planning table scan for 
table: {}", identifier, e);
+        }
+      }
+    }
+  }
+
   /**
    * Federation-aware {@code createTable}: creates the table on the underlying 
(remote) catalog and
    * extracts client-facing FileIO/credential properties from {@code 
table.io()}.
diff --git 
a/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/IcebergRESTUtils.java
 
b/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/IcebergRESTUtils.java
index 9a4d74091d..34e80d2453 100644
--- 
a/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/IcebergRESTUtils.java
+++ 
b/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/IcebergRESTUtils.java
@@ -58,6 +58,7 @@ import org.apache.iceberg.rest.responses.ErrorResponse;
 import org.apache.iceberg.rest.responses.ImmutableLoadCredentialsResponse;
 import org.apache.iceberg.rest.responses.LoadCredentialsResponse;
 import org.apache.iceberg.rest.responses.LoadTableResponse;
+import org.apache.iceberg.rest.responses.PlanTableScanResponse;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
@@ -232,6 +233,49 @@ public class IcebergRESTUtils {
     return toRESTCredential(prefix, ImmutableMap.copyOf(filteredConfig));
   }
 
+  /**
+   * Rewrites credentials in a {@link PlanTableScanResponse} so their {@code
+   * refresh-credentials-endpoint} entries point at this IRC instance instead 
of the upstream
+   * catalog. Returns the response unchanged when no credentials are present.
+   *
+   * @param catalogName IRC catalog name used to build refresh paths
+   * @param tableIdentifier table receiving the credentials
+   * @param response the scan plan response returned by the upstream REST 
catalog
+   * @return the scan plan response with IRC-local refresh endpoints on any 
credentials
+   */
+  @SuppressWarnings("deprecation")
+  public static PlanTableScanResponse rewriteScanPlanCredentials(
+      String catalogName, TableIdentifier tableIdentifier, 
PlanTableScanResponse response) {
+    if (response.credentials() == null || response.credentials().isEmpty()) {
+      return response;
+    }
+    List<org.apache.iceberg.rest.credentials.Credential> rewritten = new 
ArrayList<>();
+    for (org.apache.iceberg.rest.credentials.Credential cred : 
response.credentials()) {
+      rewritten.add(rewriteCredential(catalogName, tableIdentifier, 
cred.prefix(), cred.config()));
+    }
+    return copyWithCredentials(response, rewritten);
+  }
+
+  /**
+   * Copies a {@link PlanTableScanResponse} with replacement credentials.
+   *
+   * <p>TODO: Remove when PlanTableScanResponse supports a native 
copy/toBuilder method.
+   */
+  @SuppressWarnings("deprecation")
+  static PlanTableScanResponse copyWithCredentials(
+      PlanTableScanResponse response,
+      List<org.apache.iceberg.rest.credentials.Credential> credentials) {
+    return PlanTableScanResponse.builder()
+        .withPlanStatus(response.planStatus())
+        .withPlanId(response.planId())
+        .withPlanTasks(response.planTasks())
+        .withFileScanTasks(response.fileScanTasks())
+        .withSpecsById(response.specsById())
+        .withErrorResponse(response.errorResponse())
+        .withCredentials(credentials)
+        .build();
+  }
+
   public static <T> Response ok(T t) {
     return 
Response.status(Response.Status.OK).entity(t).type(MediaType.APPLICATION_JSON).build();
   }
diff --git 
a/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/dispatcher/IcebergTableOperationExecutor.java
 
b/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/dispatcher/IcebergTableOperationExecutor.java
index 6307013315..954d2a8c27 100644
--- 
a/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/dispatcher/IcebergTableOperationExecutor.java
+++ 
b/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/dispatcher/IcebergTableOperationExecutor.java
@@ -222,9 +222,13 @@ public class IcebergTableOperationExecutor implements 
IcebergTableOperationDispa
       IcebergRequestContext context,
       TableIdentifier tableIdentifier,
       PlanTableScanRequest scanRequest) {
+    CredentialPrivilege privilege = CredentialPrivilege.READ;
+    if (context.requestCredentialVending()) {
+      privilege = getCredentialPrivilege(context, tableIdentifier);
+    }
     return icebergCatalogWrapperManager
         .getCatalogWrapper(context.catalogName())
-        .planTableScan(tableIdentifier, scanRequest);
+        .planTableScan(tableIdentifier, scanRequest, 
context.requestCredentialVending(), privilege);
   }
 
   @Override
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 029722cfd8..4bedb7c41e 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
@@ -524,16 +524,21 @@ public class IcebergTableOperations {
       @Encoded() @PathParam("namespace") @AuthorizationMetadata(type = 
EntityType.SCHEMA)
           String namespace,
       @Encoded() @PathParam("table") @AuthorizationMetadata(type = 
EntityType.TABLE) String table,
-      PlanTableScanRequest scanRequest) {
+      PlanTableScanRequest scanRequest,
+      @HeaderParam(X_ICEBERG_ACCESS_DELEGATION) String accessDelegation) {
+    boolean isCredentialVending = isCredentialVending(accessDelegation);
     String catalogName = IcebergRESTUtils.getCatalogName(prefix);
     Namespace icebergNS =
         RESTUtil.decodeNamespace(namespace, 
IcebergRESTUtils.NAMESPACE_SEPARATOR_URLENCODED_UTF_8);
     String tableName = RESTUtil.decodeString(table);
     LOG.info(
-        "Plan table scan, catalog: {}, namespace: {}, table: {}",
+        "Plan table scan, catalog: {}, namespace: {}, table: {}, "
+            + "accessDelegation: {}, isCredentialVending: {}",
         catalogName,
         icebergNS,
-        tableName);
+        tableName,
+        accessDelegation,
+        isCredentialVending);
 
     try {
       return Utils.doAs(
@@ -541,7 +546,7 @@ public class IcebergTableOperations {
           () -> {
             TableIdentifier tableIdentifier = TableIdentifier.of(icebergNS, 
tableName);
             IcebergRequestContext context =
-                new IcebergRequestContext(httpServletRequest(), catalogName);
+                new IcebergRequestContext(httpServletRequest(), catalogName, 
isCredentialVending);
 
             PlanTableScanResponse scanResponse =
                 tableOperationDispatcher.planTableScan(context, 
tableIdentifier, scanRequest);
diff --git 
a/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/iceberg/service/TestCatalogWrapperForREST.java
 
b/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/iceberg/service/TestCatalogWrapperForREST.java
index 2607eae063..713091a9e4 100644
--- 
a/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/iceberg/service/TestCatalogWrapperForREST.java
+++ 
b/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/iceberg/service/TestCatalogWrapperForREST.java
@@ -47,6 +47,7 @@ import 
org.apache.gravitino.catalog.lakehouse.iceberg.IcebergConstants;
 import org.apache.gravitino.credential.CredentialConstants;
 import org.apache.gravitino.credential.CredentialPrivilege;
 import org.apache.gravitino.iceberg.common.IcebergConfig;
+import org.apache.gravitino.iceberg.service.cache.LocalScanPlanCache;
 import org.apache.gravitino.iceberg.service.extension.DummyCredentialProvider;
 import org.apache.iceberg.BaseTable;
 import org.apache.iceberg.BaseTransaction;
@@ -72,15 +73,19 @@ import org.apache.iceberg.io.FileIO;
 import org.apache.iceberg.io.ResolvingFileIO;
 import org.apache.iceberg.io.StorageCredential;
 import org.apache.iceberg.io.SupportsStorageCredentials;
+import org.apache.iceberg.rest.PlanStatus;
 import org.apache.iceberg.rest.RESTCatalog;
 import org.apache.iceberg.rest.auth.AuthProperties;
 import org.apache.iceberg.rest.credentials.Credential;
 import org.apache.iceberg.rest.requests.CreateTableRequest;
 import org.apache.iceberg.rest.requests.ImmutableRegisterTableRequest;
+import org.apache.iceberg.rest.requests.PlanTableScanRequest;
 import org.apache.iceberg.rest.requests.RegisterTableRequest;
 import org.apache.iceberg.rest.requests.UpdateTableRequest;
 import org.apache.iceberg.rest.responses.LoadCredentialsResponse;
 import org.apache.iceberg.rest.responses.LoadTableResponse;
+import org.apache.iceberg.rest.responses.PlanTableScanResponse;
+import org.apache.iceberg.rest.responses.PlanTableScanResponseParser;
 import org.apache.iceberg.types.Types;
 import org.junit.jupiter.api.Assertions;
 import org.junit.jupiter.api.Test;
@@ -417,6 +422,306 @@ public class TestCatalogWrapperForREST {
     Assertions.assertEquals("s3://bucket/wh/db/tbl", 
response.credentials().get(0).prefix());
   }
 
+  @Test
+  void testPlanTableScanCacheDoesNotLeakCredentials() {
+    IcebergConfig config =
+        new IcebergConfig(
+            ImmutableMap.of(
+                IcebergConstants.CATALOG_BACKEND,
+                "memory",
+                IcebergConstants.WAREHOUSE,
+                "/tmp/warehouse",
+                CredentialConstants.CREDENTIAL_PROVIDERS,
+                DummyCredentialProvider.DUMMY_CREDENTIAL_TYPE,
+                IcebergConstants.SCAN_PLAN_CACHE_IMPL,
+                LocalScanPlanCache.class.getName()));
+
+    CatalogWrapperForREST wrapper = new CatalogWrapperForREST("cache-test", 
config);
+    Namespace namespace = Namespace.of("db");
+    Catalog catalog = wrapper.getCatalog();
+    ((SupportsNamespaces) catalog).createNamespace(namespace);
+    TableIdentifier tableId = TableIdentifier.of(namespace, "tbl");
+    Schema schema = new Schema(Types.NestedField.required(1, "id", 
Types.IntegerType.get()));
+    catalog.createTable(
+        tableId,
+        schema,
+        PartitionSpec.unpartitioned(),
+        "s3://bucket/db/tbl",
+        Collections.emptyMap());
+
+    PlanTableScanRequest request = PlanTableScanRequest.builder().build();
+
+    PlanTableScanResponse vended1 =
+        wrapper.planTableScan(tableId, request, true, 
CredentialPrivilege.READ);
+    Assertions.assertFalse(
+        vended1.credentials().isEmpty(), "Vended request should return 
credentials");
+
+    PlanTableScanResponse nonVended =
+        wrapper.planTableScan(tableId, request, false, 
CredentialPrivilege.READ);
+    Assertions.assertTrue(
+        nonVended.credentials().isEmpty(),
+        "Non-vended request should not return credentials from cache");
+
+    PlanTableScanResponse vended2 =
+        wrapper.planTableScan(tableId, request, true, 
CredentialPrivilege.READ);
+    Assertions.assertFalse(
+        vended2.credentials().isEmpty(),
+        "Vended request on cache hit should return fresh credentials");
+  }
+
+  @SuppressWarnings("deprecation")
+  @Test
+  void testFederatedPlanTableScanDelegatesToRemote() throws Exception {
+    TableIdentifier table = TableIdentifier.of(Namespace.of("db"), "tbl");
+    String expectedPath = "/v1/upstream/namespaces/db/tables/tbl/plan";
+
+    org.apache.iceberg.rest.credentials.Credential cred =
+        IcebergRESTUtils.toRESTCredential(
+            "s3://bucket/db/tbl/",
+            ImmutableMap.of(
+                "s3.access-key-id", "upstream-key",
+                "s3.secret-access-key", "upstream-secret",
+                "s3.session-token", "upstream-token",
+                "client.refresh-credentials-endpoint",
+                    "v1/upstream/namespaces/db/tables/tbl/credentials"));
+    PlanTableScanResponse upstreamResponse =
+        PlanTableScanResponse.builder()
+            .withPlanStatus(PlanStatus.COMPLETED)
+            .withSpecsById(ImmutableMap.of(0, PartitionSpec.unpartitioned()))
+            .withCredentials(Collections.singletonList(cred))
+            .build();
+    String upstreamJson = PlanTableScanResponseParser.toJson(upstreamResponse);
+
+    AtomicReference<String> requestPath = new AtomicReference<>();
+    AtomicReference<String> requestMethod = new AtomicReference<>();
+    AtomicReference<String> accessDelegationHeader = new AtomicReference<>();
+    HttpServer server = HttpServer.create(new InetSocketAddress(0), 0);
+    server.createContext(
+        "/",
+        exchange -> {
+          requestPath.set(exchange.getRequestURI().getPath());
+          requestMethod.set(exchange.getRequestMethod());
+          accessDelegationHeader.set(
+              
exchange.getRequestHeaders().getFirst("X-Iceberg-Access-Delegation"));
+          byte[] body = upstreamJson.getBytes(StandardCharsets.UTF_8);
+          exchange.getResponseHeaders().add("Content-Type", 
"application/json");
+          exchange.sendResponseHeaders(200, body.length);
+          try (OutputStream os = exchange.getResponseBody()) {
+            os.write(body);
+          }
+        });
+    server.start();
+    try {
+      String uri = "http://127.0.0.1:"; + server.getAddress().getPort();
+      RESTCatalog restCatalog = mock(RESTCatalog.class);
+      when(restCatalog.name()).thenReturn("upstream");
+      when(restCatalog.properties())
+          .thenReturn(
+              ImmutableMap.of(
+                  CatalogProperties.URI,
+                  uri,
+                  AuthProperties.AUTH_TYPE,
+                  AuthProperties.AUTH_TYPE_NONE,
+                  "prefix",
+                  "upstream"));
+      Table mockTable = mock(Table.class);
+      when(mockTable.specs()).thenReturn(ImmutableMap.of(0, 
PartitionSpec.unpartitioned()));
+      when(restCatalog.loadTable(table)).thenReturn(mockTable);
+
+      IcebergConfig config =
+          new IcebergConfig(
+              ImmutableMap.of(
+                  IcebergConstants.CATALOG_BACKEND,
+                  "memory",
+                  IcebergConstants.WAREHOUSE,
+                  "/tmp/warehouse"));
+      CatalogWrapperForREST wrapper = new StaticCatalogWrapperForREST("local", 
config, restCatalog);
+
+      PlanTableScanRequest scanRequest = 
PlanTableScanRequest.builder().build();
+      PlanTableScanResponse response =
+          wrapper.planTableScan(table, scanRequest, true, 
CredentialPrivilege.READ);
+
+      Assertions.assertEquals(expectedPath, requestPath.get());
+      Assertions.assertEquals("POST", requestMethod.get());
+      Assertions.assertEquals("vended-credentials", 
accessDelegationHeader.get());
+      Assertions.assertFalse(response.credentials().isEmpty());
+      Credential credential = response.credentials().get(0);
+      Assertions.assertEquals("s3://bucket/db/tbl/", credential.prefix());
+      Assertions.assertEquals("upstream-key", 
credential.config().get("s3.access-key-id"));
+      Assertions.assertEquals(
+          "v1/local/namespaces/db/tables/tbl/credentials",
+          credential.config().get("client.refresh-credentials-endpoint"));
+    } finally {
+      server.stop(0);
+    }
+  }
+
+  @SuppressWarnings("deprecation")
+  @Test
+  void testFederatedPlanTableScanNoCredentials() throws Exception {
+    TableIdentifier table = TableIdentifier.of(Namespace.of("db"), "tbl");
+    PlanTableScanResponse upstreamResponse =
+        PlanTableScanResponse.builder()
+            .withPlanStatus(PlanStatus.COMPLETED)
+            .withSpecsById(ImmutableMap.of(0, PartitionSpec.unpartitioned()))
+            .build();
+    String upstreamJson = PlanTableScanResponseParser.toJson(upstreamResponse);
+
+    AtomicReference<String> accessDelegationHeader = new AtomicReference<>();
+    HttpServer server = HttpServer.create(new InetSocketAddress(0), 0);
+    server.createContext(
+        "/",
+        exchange -> {
+          accessDelegationHeader.set(
+              
exchange.getRequestHeaders().getFirst("X-Iceberg-Access-Delegation"));
+          byte[] body = upstreamJson.getBytes(StandardCharsets.UTF_8);
+          exchange.getResponseHeaders().add("Content-Type", 
"application/json");
+          exchange.sendResponseHeaders(200, body.length);
+          try (OutputStream os = exchange.getResponseBody()) {
+            os.write(body);
+          }
+        });
+    server.start();
+    try {
+      String uri = "http://127.0.0.1:"; + server.getAddress().getPort();
+      RESTCatalog restCatalog = mock(RESTCatalog.class);
+      when(restCatalog.name()).thenReturn("upstream");
+      when(restCatalog.properties())
+          .thenReturn(
+              ImmutableMap.of(
+                  CatalogProperties.URI,
+                  uri,
+                  AuthProperties.AUTH_TYPE,
+                  AuthProperties.AUTH_TYPE_NONE,
+                  "prefix",
+                  "upstream"));
+      Table mockTable = mock(Table.class);
+      when(mockTable.specs()).thenReturn(ImmutableMap.of(0, 
PartitionSpec.unpartitioned()));
+      when(restCatalog.loadTable(table)).thenReturn(mockTable);
+
+      IcebergConfig config =
+          new IcebergConfig(
+              ImmutableMap.of(
+                  IcebergConstants.CATALOG_BACKEND,
+                  "memory",
+                  IcebergConstants.WAREHOUSE,
+                  "/tmp/warehouse"));
+      CatalogWrapperForREST wrapper = new StaticCatalogWrapperForREST("local", 
config, restCatalog);
+
+      PlanTableScanRequest scanRequest = 
PlanTableScanRequest.builder().build();
+      PlanTableScanResponse response =
+          wrapper.planTableScan(table, scanRequest, false, 
CredentialPrivilege.READ);
+
+      Assertions.assertNull(
+          accessDelegationHeader.get(),
+          "X-Iceberg-Access-Delegation header should not be sent without 
credential vending");
+      Assertions.assertTrue(
+          response.credentials() == null || response.credentials().isEmpty(),
+          "Non-vended request should not return credentials");
+    } finally {
+      server.stop(0);
+    }
+  }
+
+  @Test
+  void testFederatedPlanTableScanOnFailure() throws Exception {
+    TableIdentifier tableId = TableIdentifier.of(Namespace.of("db"), "tbl");
+    HttpServer server = HttpServer.create(new InetSocketAddress(0), 0);
+    server.createContext(
+        "/",
+        exchange -> {
+          exchange.sendResponseHeaders(500, -1);
+          exchange.close();
+        });
+    server.start();
+    try {
+      String uri = "http://127.0.0.1:"; + server.getAddress().getPort();
+      RESTCatalog restCatalog = mock(RESTCatalog.class);
+      when(restCatalog.name()).thenReturn("upstream");
+      when(restCatalog.properties())
+          .thenReturn(
+              ImmutableMap.of(
+                  CatalogProperties.URI,
+                  uri,
+                  AuthProperties.AUTH_TYPE,
+                  AuthProperties.AUTH_TYPE_NONE,
+                  "prefix",
+                  "upstream"));
+      Table mockTable = mock(Table.class);
+      when(mockTable.specs()).thenReturn(ImmutableMap.of(0, 
PartitionSpec.unpartitioned()));
+      when(restCatalog.loadTable(tableId)).thenReturn(mockTable);
+
+      IcebergConfig config =
+          new IcebergConfig(
+              ImmutableMap.of(
+                  IcebergConstants.CATALOG_BACKEND,
+                  "memory",
+                  IcebergConstants.WAREHOUSE,
+                  "/tmp/warehouse"));
+      CatalogWrapperForREST wrapper = new StaticCatalogWrapperForREST("local", 
config, restCatalog);
+
+      PlanTableScanRequest scanRequest = 
PlanTableScanRequest.builder().build();
+      Assertions.assertThrows(
+          ServiceFailureException.class,
+          () -> wrapper.planTableScan(tableId, scanRequest, true, 
CredentialPrivilege.READ));
+    } finally {
+      server.stop(0);
+    }
+  }
+
+  @Test
+  void testFederatedPlanTableScanNoSuchTable() throws Exception {
+    TableIdentifier tableId = TableIdentifier.of(Namespace.of("db"), 
"missing");
+    String errorJson =
+        "{\"error\":{\"message\":\"Table not 
found\",\"type\":\"NoSuchTableException\","
+            + "\"code\":404,\"stack\":[]}}";
+    HttpServer server = HttpServer.create(new InetSocketAddress(0), 0);
+    server.createContext(
+        "/",
+        exchange -> {
+          byte[] body = errorJson.getBytes(StandardCharsets.UTF_8);
+          exchange.getResponseHeaders().add("Content-Type", 
"application/json");
+          exchange.sendResponseHeaders(404, body.length);
+          try (OutputStream os = exchange.getResponseBody()) {
+            os.write(body);
+          }
+        });
+    server.start();
+    try {
+      String uri = "http://127.0.0.1:"; + server.getAddress().getPort();
+      RESTCatalog restCatalog = mock(RESTCatalog.class);
+      when(restCatalog.name()).thenReturn("upstream");
+      when(restCatalog.properties())
+          .thenReturn(
+              ImmutableMap.of(
+                  CatalogProperties.URI,
+                  uri,
+                  AuthProperties.AUTH_TYPE,
+                  AuthProperties.AUTH_TYPE_NONE,
+                  "prefix",
+                  "upstream"));
+      Table mockTable = mock(Table.class);
+      when(mockTable.specs()).thenReturn(ImmutableMap.of(0, 
PartitionSpec.unpartitioned()));
+      when(restCatalog.loadTable(tableId)).thenReturn(mockTable);
+
+      IcebergConfig config =
+          new IcebergConfig(
+              ImmutableMap.of(
+                  IcebergConstants.CATALOG_BACKEND,
+                  "memory",
+                  IcebergConstants.WAREHOUSE,
+                  "/tmp/warehouse"));
+      CatalogWrapperForREST wrapper = new StaticCatalogWrapperForREST("local", 
config, restCatalog);
+
+      PlanTableScanRequest scanRequest = 
PlanTableScanRequest.builder().build();
+      Assertions.assertThrows(
+          NoSuchTableException.class,
+          () -> wrapper.planTableScan(tableId, scanRequest, true, 
CredentialPrivilege.READ));
+    } finally {
+      server.stop(0);
+    }
+  }
+
   @Test
   void testValidateCredentialLocation() {
     Assertions.assertDoesNotThrow(
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 14d2782b4f..0abaa7305c 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
@@ -1115,4 +1115,114 @@ public class TestIcebergTableOperations extends 
IcebergNamespaceTestBase {
             NameIdentifier.of("metalake", "catalog", "schema"));
     Assertions.assertEquals(-1, event.resultCount());
   }
+
+  @ParameterizedTest
+  
@MethodSource("org.apache.gravitino.iceberg.service.rest.IcebergRestTestUtil#testNamespaces")
+  void testPlanTableScanWithCredentialVending(Namespace namespace) {
+    verifyCreateNamespaceSucc(namespace);
+    PlanTableScanRequest emptyRequest = PlanTableScanRequest.builder().build();
+
+    // scan without credential vending -- no storage-credentials in response
+    String tableName = "scan_cred_no_header";
+    verifyCreateTableSucc(namespace, tableName);
+    Response noCredResponse = doPlanTableScan(namespace, tableName, 
emptyRequest);
+    Assertions.assertEquals(Status.OK.getStatusCode(), 
noCredResponse.getStatus());
+    try {
+      JsonNode noCredJson = 
JsonUtil.mapper().readTree(noCredResponse.readEntity(String.class));
+      Assertions.assertFalse(
+          noCredJson.has("storage-credentials"),
+          "Response should not have storage-credentials without header");
+    } catch (Exception e) {
+      throw new RuntimeException(e);
+    }
+
+    // scan with credential vending on local table -- no storage-credentials
+    String localTableName = "scan_cred_local";
+    Response localCreateResponse =
+        doCreateTableWithCredentialVending(
+            namespace, localTableName, "file:///tmp/" + localTableName);
+    Assertions.assertEquals(Status.OK.getStatusCode(), 
localCreateResponse.getStatus());
+    Response localScanResponse =
+        doPlanTableScanWithCredentialVending(namespace, localTableName, 
emptyRequest);
+    Assertions.assertEquals(Status.OK.getStatusCode(), 
localScanResponse.getStatus());
+    try {
+      JsonNode localScanJson =
+          
JsonUtil.mapper().readTree(localScanResponse.readEntity(String.class));
+      Assertions.assertFalse(
+          localScanJson.has("storage-credentials"),
+          "Local table should not have storage-credentials");
+    } catch (Exception e) {
+      throw new RuntimeException(e);
+    }
+
+    // scan with credential vending on S3 table -- should have 
storage-credentials
+    String s3TableName = "scan_cred_s3";
+    String s3Location = "s3://dummy-bucket/" + s3TableName;
+    Response s3CreateResponse =
+        doCreateTableWithCredentialVending(namespace, s3TableName, s3Location);
+    Assertions.assertEquals(Status.OK.getStatusCode(), 
s3CreateResponse.getStatus());
+    Response s3ScanResponse =
+        doPlanTableScanWithCredentialVending(namespace, s3TableName, 
emptyRequest);
+    Assertions.assertEquals(Status.OK.getStatusCode(), 
s3ScanResponse.getStatus());
+    try {
+      JsonNode s3ScanJson = 
JsonUtil.mapper().readTree(s3ScanResponse.readEntity(String.class));
+      Assertions.assertTrue(
+          s3ScanJson.has("storage-credentials"), "S3 table should have 
storage-credentials");
+      JsonNode credentials = s3ScanJson.get("storage-credentials");
+      Assertions.assertTrue(credentials.isArray() && credentials.size() > 0);
+      JsonNode firstCred = credentials.get(0);
+      Assertions.assertTrue(firstCred.has("config"));
+      Assertions.assertEquals(
+          DummyCredentialProvider.DUMMY_CREDENTIAL_TYPE,
+          firstCred.get("config").get(Credential.CREDENTIAL_TYPE).asText());
+      Assertions.assertTrue(firstCred.has("prefix"), "Credential should have a 
prefix field");
+      Assertions.assertEquals(
+          s3Location,
+          firstCred.get("prefix").asText(),
+          "Credential prefix should match the table location");
+    } catch (Exception e) {
+      throw new RuntimeException(e);
+    }
+  }
+
+  @ParameterizedTest
+  
@MethodSource("org.apache.gravitino.iceberg.service.rest.IcebergRestTestUtil#testNamespaces")
+  void testPlanTableScanRemoteSigningNotSupported(Namespace namespace) {
+    verifyCreateNamespaceSucc(namespace);
+    verifyCreateTableSucc(namespace, "scan_remote_signing");
+    PlanTableScanRequest request = PlanTableScanRequest.builder().build();
+    Response response =
+        getTableClientBuilder(namespace, 
Optional.of("scan_remote_signing/plan"))
+            .header(IcebergTableOperations.X_ICEBERG_ACCESS_DELEGATION, 
"remote-signing")
+            .post(Entity.entity(request, MediaType.APPLICATION_JSON_TYPE));
+    Assertions.assertEquals(406, response.getStatus());
+    String errorBody = response.readEntity(String.class);
+    Assertions.assertTrue(
+        errorBody.contains("remote signing") || 
errorBody.contains("remote-signing"),
+        "Error message should mention remote signing: " + errorBody);
+  }
+
+  @ParameterizedTest
+  
@MethodSource("org.apache.gravitino.iceberg.service.rest.IcebergRestTestUtil#testNamespaces")
+  void testPlanTableScanInvalidAccessDelegation(Namespace namespace) {
+    verifyCreateNamespaceSucc(namespace);
+    verifyCreateTableSucc(namespace, "scan_invalid_delegation");
+    PlanTableScanRequest request = PlanTableScanRequest.builder().build();
+    Response response =
+        getTableClientBuilder(namespace, 
Optional.of("scan_invalid_delegation/plan"))
+            .header(IcebergTableOperations.X_ICEBERG_ACCESS_DELEGATION, 
"invalid-value")
+            .post(Entity.entity(request, MediaType.APPLICATION_JSON_TYPE));
+    Assertions.assertEquals(400, response.getStatus());
+    String errorBody = response.readEntity(String.class);
+    Assertions.assertTrue(
+        errorBody.contains("vended-credentials") && 
errorBody.contains("illegal"),
+        "Error message should mention valid values: " + errorBody);
+  }
+
+  private Response doPlanTableScanWithCredentialVending(
+      Namespace ns, String tableName, PlanTableScanRequest request) {
+    return getTableClientBuilder(ns, Optional.of(tableName + "/plan"))
+        .header(IcebergTableOperations.X_ICEBERG_ACCESS_DELEGATION, 
"vended-credentials")
+        .post(Entity.entity(request, MediaType.APPLICATION_JSON_TYPE));
+  }
 }

Reply via email to