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

jerryshao pushed a commit to branch branch-1.3
in repository https://gitbox.apache.org/repos/asf/gravitino.git

commit af13eb94298610f723237b3bc391fa414e666547
Author: MaSai <[email protected]>
AuthorDate: Wed Sep 9 19:59:28 2026 +0800

    [#12949] fix(iceberg-rest): Forward access-delegation header on federated 
loadTable (#12950)
    
    Forward `X-Iceberg-Access-Delegation: vended-credentials` when a
    federated Iceberg REST catalog (`catalog-backend: rest`) loads a table,
    and rewrite remote credential refresh endpoints to this IRC catalog.
    
    `FederatedCatalogWrapper.loadTable` previously ignored
    `requestCredential` and used Iceberg's `RESTCatalog.loadTable`, which
    does not send the access-delegation header. Scan-plan federation already
    forwarded the header; load table now uses the same authenticated REST
    GET path.
    
    Fix: #12949
    
    A REST-backend Iceberg catalog dropped the client's credential-vending
    request. The near-end IRC logged `credential vending: true`, but the
    forwarded load arrived at the remote catalog with `access delegation:
    null`. Direct loads against the remote returned `storage-credentials`;
    federated loads returned only metadata, so engines failed on the first
    data read.
    
    Yes. Clients that send `X-Iceberg-Access-Delegation: vended-credentials`
    through a federated Iceberg REST catalog now receive remote
    `storage-credentials` on `loadTable`, matching a direct load against the
    remote catalog.
    
    No new APIs or property keys.
    
    - `./gradlew :iceberg:iceberg-rest-server:test --tests
    org.apache.gravitino.iceberg.service.TestCatalogWrapperForREST --tests
    org.apache.gravitino.iceberg.service.TestIcebergRESTUtils -PskipITs`
    - New unit tests cover header forwarding on vended federated load,
    omitting the header when vending is not requested, and rewriting
    upstream refresh endpoints.
    
    Made with [Cursor](https://cursor.com)
    
    ---------
    
    Co-authored-by: Cursor <[email protected]>
---
 .../iceberg/service/FederatedCatalogWrapper.java   |  403 ++++----
 .../iceberg/service/IcebergRESTUtils.java          |   57 ++
 .../service/rest/IcebergTableOperations.java       |   12 +-
 .../iceberg/service/TestCatalogWrapperForREST.java | 1016 +++++++++++++++-----
 .../iceberg/service/TestIcebergRESTUtils.java      |   99 ++
 .../service/rest/TestIcebergTableOperations.java   |   79 +-
 6 files changed, 1240 insertions(+), 426 deletions(-)

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 cbe5724921..eb3892004a 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,28 +21,20 @@ 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;
 import java.util.Collections;
-import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
 import java.util.Optional;
+import java.util.function.Function;
 import java.util.stream.Collectors;
 import org.apache.gravitino.credential.CredentialPrivilege;
-import org.apache.gravitino.credential.CredentialPropertyUtils;
 import org.apache.gravitino.iceberg.common.IcebergConfig;
-import org.apache.gravitino.utils.MapUtils;
-import org.apache.iceberg.BaseMetadataTable;
-import org.apache.iceberg.BaseTable;
 import org.apache.iceberg.BaseTransaction;
 import org.apache.iceberg.CatalogProperties;
 import org.apache.iceberg.MetadataUpdate;
-import org.apache.iceberg.PartitionSpec;
 import org.apache.iceberg.Schema;
-import org.apache.iceberg.SortOrder;
-import org.apache.iceberg.Table;
 import org.apache.iceberg.TableMetadata;
 import org.apache.iceberg.TableOperations;
 import org.apache.iceberg.Transaction;
@@ -50,10 +42,6 @@ import org.apache.iceberg.UpdateRequirement;
 import org.apache.iceberg.catalog.Catalog;
 import org.apache.iceberg.catalog.Namespace;
 import org.apache.iceberg.catalog.TableIdentifier;
-import org.apache.iceberg.exceptions.AlreadyExistsException;
-import org.apache.iceberg.exceptions.NoSuchTableException;
-import org.apache.iceberg.inmemory.InMemoryFileIO;
-import org.apache.iceberg.io.FileIO;
 import org.apache.iceberg.rest.CatalogHandlers;
 import org.apache.iceberg.rest.ErrorHandlers;
 import org.apache.iceberg.rest.HTTPClient;
@@ -64,7 +52,6 @@ import org.apache.iceberg.rest.ResourcePaths;
 import org.apache.iceberg.rest.auth.AuthManager;
 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.RegisterTableRequest;
 import org.apache.iceberg.rest.requests.UpdateTableRequest;
@@ -76,12 +63,12 @@ import org.apache.iceberg.rest.responses.LoadTableResponse;
  * {@link RESTCatalog}).
  *
  * <p>Federation-specific behavior is expressed through polymorphic overrides 
instead of {@code
- * instanceof RESTCatalog} checks scattered across the base class. Table 
operations are routed to
- * federation-aware {@code *Internal} methods so client-facing FileIO and 
credential properties are
- * extracted from the remote catalog's {@code table.io()}. Credentials are 
vended by the remote
- * catalog, so this wrapper never injects Gravitino-generated credentials.
+ * instanceof RESTCatalog} checks scattered across the base class. Table load, 
create and register
+ * use authenticated REST calls so {@code X-Iceberg-Access-Delegation} can be 
forwarded when the
+ * client requested credential vending. Update still uses the Iceberg Catalog 
API. This wrapper
+ * never injects Gravitino-generated credentials.
  *
- * <p>Portions of the table create and update handling are derived from Apache 
Iceberg's {@code
+ * <p>Portions of the table update handling are derived from Apache Iceberg's 
{@code
  * org.apache.iceberg.rest.CatalogHandlers}:
  * 
https://github.com/apache/iceberg/blob/2abac79fcae94b5ad039bd09f7235be191b0761e/core/src/main/java/org/apache/iceberg/rest/CatalogHandlers.java
  */
@@ -89,6 +76,8 @@ public class FederatedCatalogWrapper extends 
CatalogWrapperForREST {
 
   private static final String FORMAT_VERSION = "format-version";
   private static final Schema EMPTY_SCHEMA = new Schema();
+  private static final String X_ICEBERG_ACCESS_DELEGATION = 
"X-Iceberg-Access-Delegation";
+  private static final String VENDED_CREDENTIALS = "vended-credentials";
 
   /**
    * Creates a federated wrapper.
@@ -100,24 +89,63 @@ public class FederatedCatalogWrapper extends 
CatalogWrapperForREST {
     super(catalogName, config);
   }
 
+  /**
+   * Creates a table on the remote REST catalog.
+   *
+   * <p>Always uses a dedicated REST POST, including staged create, rather 
than Iceberg's Catalog
+   * API. When credential vending is requested the {@code 
X-Iceberg-Access-Delegation:
+   * vended-credentials} header is forwarded so the remote catalog returns 
{@code
+   * storage-credentials} inline. Upstream credential refresh endpoints are 
rewritten to this IRC
+   * catalog.
+   *
+   * @param namespace the namespace that will own the table.
+   * @param request the create-table request.
+   * @param requestCredential whether the client requested vended credentials.
+   * @return the create response, including rewritten remote credentials when 
requested.
+   */
   @Override
   public LoadTableResponse createTable(
       Namespace namespace, CreateTableRequest request, boolean 
requestCredential) {
-    // The remote REST catalog vends its own credentials, so the 
requestCredential flag is not used
-    // here; FileIO-derived client config is extracted by createTableInternal.
-    return createTableInternal(namespace, request);
+    return createTableViaREST(namespace, request, requestCredential);
   }
 
+  /**
+   * Loads a table from the remote REST catalog.
+   *
+   * <p>Always uses a dedicated REST GET rather than Iceberg's {@link 
RESTCatalog#loadTable}, which
+   * cannot send {@code X-Iceberg-Access-Delegation}. When credential vending 
is requested the
+   * header is forwarded so the remote catalog returns {@code 
storage-credentials} inline. Upstream
+   * credential refresh endpoints are rewritten to this IRC catalog. The 
{@code privilege} is
+   * ignored because the remote catalog decides what to vend.
+   *
+   * @param identifier the table identifier.
+   * @param requestCredential whether the client requested vended credentials.
+   * @param privilege ignored; the remote REST catalog vends its own 
credentials.
+   * @return the load-table response, including rewritten remote credentials 
when requested.
+   */
   @Override
   public LoadTableResponse loadTable(
       TableIdentifier identifier, boolean requestCredential, 
CredentialPrivilege privilege) {
-    return loadTableInternal(identifier);
+    return loadTableViaREST(identifier, requestCredential);
   }
 
+  /**
+   * Registers a table on the remote REST catalog.
+   *
+   * <p>Always uses a dedicated REST POST rather than Iceberg's Catalog API. 
When credential vending
+   * is requested the {@code X-Iceberg-Access-Delegation: vended-credentials} 
header is forwarded so
+   * the remote catalog returns {@code storage-credentials} inline. Upstream 
credential refresh
+   * endpoints are rewritten to this IRC catalog.
+   *
+   * @param namespace the namespace that will own the table.
+   * @param request the register-table request.
+   * @param requestCredential whether the client requested vended credentials.
+   * @return the register response, including rewritten remote credentials 
when requested.
+   */
   @Override
   public LoadTableResponse registerTable(
       Namespace namespace, RegisterTableRequest request, boolean 
requestCredential) {
-    return registerTableInternal(namespace, request);
+    return registerTableViaREST(namespace, request, requestCredential);
   }
 
   @Override
@@ -159,6 +187,34 @@ public class FederatedCatalogWrapper extends 
CatalogWrapperForREST {
     String credentialsPath =
         ResourcePaths.forCatalogProperties(properties).table(identifier) + 
"/credentials";
 
+    return callRemoteCatalog(
+        restCatalog,
+        String.format("loading credentials for table: %s", identifier),
+        client ->
+            client.get(
+                credentialsPath,
+                LoadCredentialsResponse.class,
+                Collections.emptyMap(),
+                ErrorHandlers.tableErrorHandler()));
+  }
+
+  /**
+   * Runs an action against the remote REST catalog through a short-lived 
authenticated client.
+   *
+   * <p>Centralizes the auth manager, HTTP client and auth session lifecycle 
shared by the federated
+   * credential and load/create/register requests. Resources are closed in 
reverse order of
+   * creation, and a close failure on one does not prevent the others from 
being closed.
+   *
+   * @param restCatalog the underlying REST catalog whose properties supply 
the URI and auth config.
+   * @param description what the action is doing, used in close-failure log 
messages.
+   * @param action invoked with a client bound to an authenticated session.
+   * @param <T> the action's result type.
+   * @return the action's result.
+   */
+  private static <T> T callRemoteCatalog(
+      RESTCatalog restCatalog, String description, Function<RESTClient, T> 
action) {
+    Map<String, String> properties = Maps.newHashMap(restCatalog.properties());
+
     AuthManager authManager = null;
     RESTClient client = null;
     AuthSession authSession = null;
@@ -170,145 +226,146 @@ public class FederatedCatalogWrapper extends 
CatalogWrapperForREST {
               .withHeaders(RESTUtil.configHeaders(properties))
               .build();
       authSession = authManager.catalogSession(client, properties);
-      return client
-          .withAuthSession(authSession)
-          .get(
-              credentialsPath,
-              LoadCredentialsResponse.class,
-              Collections.emptyMap(),
-              ErrorHandlers.tableErrorHandler());
+      return action.apply(client.withAuthSession(authSession));
     } finally {
-      if (authSession != null) {
-        try {
-          authSession.close();
-        } catch (Exception e) {
-          LOG.warn(
-              "Failed to close auth session when loading credentials for 
table: {}", identifier, e);
-        }
-      }
+      closeQuietly(authSession, "auth session", description);
+      closeQuietly(client, "REST client", description);
+      closeQuietly(authManager, "auth manager", description);
+    }
+  }
 
-      if (client != null) {
-        try {
-          client.close();
-        } catch (Exception e) {
-          LOG.warn(
-              "Failed to close REST client when loading credentials for table: 
{}", identifier, e);
-        }
-      }
+  private static void closeQuietly(
+      AutoCloseable closeable, String resourceName, String description) {
+    if (closeable == null) {
+      return;
+    }
 
-      if (authManager != null) {
-        try {
-          authManager.close();
-        } catch (Exception e) {
-          LOG.warn(
-              "Failed to close auth manager when loading credentials for 
table: {}", identifier, e);
-        }
-      }
+    try {
+      closeable.close();
+    } catch (Exception e) {
+      LOG.warn("Failed to close {} when {}", resourceName, description, e);
     }
   }
 
   /**
-   * Federation-aware {@code createTable}: creates the table on the underlying 
(remote) catalog and
-   * extracts client-facing FileIO/credential properties from {@code 
table.io()}.
+   * Sends a {@code GET {table}} 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 
load-table response.
+   *
+   * @param restCatalog the underlying REST catalog whose properties supply 
the URI and auth config.
+   * @param identifier the table to load.
+   * @param requestCredentialVending whether to include the access-delegation 
header.
+   * @return the load-table response from the remote catalog.
    */
-  private LoadTableResponse createTableInternal(Namespace namespace, 
CreateTableRequest request) {
-    Catalog loadedCatalog = getCatalog();
-
-    request.validate();
-
-    if (request.stageCreate()) {
-      return stageTableCreateInternal(namespace, request);
-    }
-
-    TableIdentifier ident = TableIdentifier.of(namespace, request.name());
-    Table table =
-        loadedCatalog
-            .buildTable(ident, request.schema())
-            .withLocation(request.location())
-            .withPartitionSpec(request.spec())
-            .withSortOrder(request.writeOrder())
-            .withProperties(request.properties())
-            .create();
-
-    if (table instanceof BaseTable) {
-      return buildLoadTableResponseFromFileIo(ident, (BaseTable) table);
-    }
+  private static LoadTableResponse getRESTLoadTable(
+      RESTCatalog restCatalog, TableIdentifier identifier, boolean 
requestCredentialVending) {
+    Map<String, String> properties = Maps.newHashMap(restCatalog.properties());
+    String tablePath = 
ResourcePaths.forCatalogProperties(properties).table(identifier);
+    Map<String, String> queryParams = ImmutableMap.of("snapshots", 
IcebergRESTUtils.SNAPSHOT_ALL);
+
+    return callRemoteCatalog(
+        restCatalog,
+        String.format("loading table: %s", identifier),
+        client ->
+            client.get(
+                tablePath,
+                queryParams,
+                LoadTableResponse.class,
+                accessDelegationHeaders(requestCredentialVending),
+                ErrorHandlers.tableErrorHandler()));
+  }
 
-    throw new IllegalStateException("Cannot wrap catalog that does not produce 
BaseTable");
+  /**
+   * Sends a {@code POST {namespace}/tables} request to the remote REST 
catalog.
+   *
+   * @param restCatalog the underlying REST catalog whose properties supply 
the URI and auth config.
+   * @param namespace the namespace that will own the table.
+   * @param request the create-table request (including staged create).
+   * @param requestCredentialVending whether to include the access-delegation 
header.
+   * @return the create response from the remote catalog.
+   */
+  private static LoadTableResponse getRESTCreateTable(
+      RESTCatalog restCatalog,
+      Namespace namespace,
+      CreateTableRequest request,
+      boolean requestCredentialVending) {
+    Map<String, String> properties = Maps.newHashMap(restCatalog.properties());
+    String tablesPath = 
ResourcePaths.forCatalogProperties(properties).tables(namespace);
+
+    return callRemoteCatalog(
+        restCatalog,
+        String.format("creating table: %s.%s", namespace, request.name()),
+        client ->
+            client.post(
+                tablesPath,
+                request,
+                LoadTableResponse.class,
+                accessDelegationHeaders(requestCredentialVending),
+                ErrorHandlers.createTableErrorHandler()));
   }
 
-  private LoadTableResponse stageTableCreateInternal(
-      Namespace namespace, CreateTableRequest request) {
-    Catalog loadedCatalog = getCatalog();
-    TableIdentifier ident = TableIdentifier.of(namespace, request.name());
-    if (loadedCatalog.tableExists(ident)) {
-      throw new AlreadyExistsException("Table already exists: %s", ident);
-    }
+  /**
+   * Sends a {@code POST {namespace}/register} request to the remote REST 
catalog.
+   *
+   * @param restCatalog the underlying REST catalog whose properties supply 
the URI and auth config.
+   * @param namespace the namespace that will own the table.
+   * @param request the register-table request.
+   * @param requestCredentialVending whether to include the access-delegation 
header.
+   * @return the register response from the remote catalog.
+   */
+  private static LoadTableResponse getRESTRegisterTable(
+      RESTCatalog restCatalog,
+      Namespace namespace,
+      RegisterTableRequest request,
+      boolean requestCredentialVending) {
+    Map<String, String> properties = Maps.newHashMap(restCatalog.properties());
+    String registerPath = 
ResourcePaths.forCatalogProperties(properties).register(namespace);
+
+    return callRemoteCatalog(
+        restCatalog,
+        String.format("registering table: %s.%s", namespace, request.name()),
+        client ->
+            client.post(
+                registerPath,
+                request,
+                LoadTableResponse.class,
+                accessDelegationHeaders(requestCredentialVending),
+                ErrorHandlers.tableErrorHandler()));
+  }
 
-    Map<String, String> properties = Maps.newHashMap();
-    properties.put("created-at", 
OffsetDateTime.now(ZoneOffset.UTC).toString());
-    properties.putAll(request.properties());
-
-    Map<String, String> config = Maps.newHashMap();
-    Catalog.TableBuilder tableBuilder =
-        loadedCatalog
-            .buildTable(ident, request.schema())
-            .withPartitionSpec(request.spec())
-            .withSortOrder(request.writeOrder())
-            .withProperties(properties);
-
-    Table table;
-    if (request.location() != null) {
-      table = 
tableBuilder.withLocation(request.location()).createTransaction().table();
-    } else {
-      table = tableBuilder.createTransaction().table();
-    }
+  private static Map<String, String> accessDelegationHeaders(boolean 
requestCredentialVending) {
+    return requestCredentialVending
+        ? ImmutableMap.of(X_ICEBERG_ACCESS_DELEGATION, VENDED_CREDENTIALS)
+        : Collections.emptyMap();
+  }
 
-    Map<String, String> tableProperties = retrieveFileIOProperties(table.io());
-    Map<String, String> filteredCredentialProperties =
-        CredentialPropertyUtils.filterCredentialProperties(tableProperties);
-    config.putAll(
-        MapUtils.getFilteredMap(
-            tableProperties, key -> 
catalogPropertiesToClientKeys.contains(key)));
-    config.putAll(filteredCredentialProperties);
-    config.putAll(
-        IcebergRESTUtils.buildRefreshProps(
-            catalogCredentialManager.catalogName(), ident, 
filteredCredentialProperties));
-
-    List<Credential> credentials =
-        IcebergRESTUtils.buildStorageCreds(
-            catalogCredentialManager.catalogName(), ident, table.io());
-
-    TableMetadata metadata =
-        TableMetadata.newTableMetadata(
-            request.schema(),
-            request.spec() != null ? request.spec() : 
PartitionSpec.unpartitioned(),
-            request.writeOrder() != null ? request.writeOrder() : 
SortOrder.unsorted(),
-            table.location(),
-            properties);
-
-    return LoadTableResponse.builder()
-        .withTableMetadata(metadata)
-        .addAllConfig(config)
-        .addAllCredentials(credentials)
-        .build();
+  private LoadTableResponse createTableViaREST(
+      Namespace namespace, CreateTableRequest request, boolean 
requestCredential) {
+    LoadTableResponse upstream =
+        getRESTCreateTable((RESTCatalog) getCatalog(), namespace, request, 
requestCredential);
+    return rewriteRemoteLoadTable(TableIdentifier.of(namespace, 
request.name()), upstream);
   }
 
-  /**
-   * Federation-aware {@code registerTable}: registers the existing table 
metadata on the underlying
-   * (remote) catalog and extracts client-facing FileIO/credential properties 
from {@code
-   * table.io()}, mirroring {@link #loadTableInternal(TableIdentifier)}.
-   */
-  private LoadTableResponse registerTableInternal(
-      Namespace namespace, RegisterTableRequest request) {
-    TableIdentifier ident = TableIdentifier.of(namespace, request.name());
-    Table table = getCatalog().registerTable(ident, 
request.metadataLocation());
+  private LoadTableResponse loadTableViaREST(
+      TableIdentifier identifier, boolean requestCredential) {
+    LoadTableResponse upstream =
+        getRESTLoadTable((RESTCatalog) getCatalog(), identifier, 
requestCredential);
+    return rewriteRemoteLoadTable(identifier, upstream);
+  }
 
-    if (table instanceof BaseTable) {
-      return buildLoadTableResponseFromFileIo(ident, (BaseTable) table);
-    }
+  private LoadTableResponse registerTableViaREST(
+      Namespace namespace, RegisterTableRequest request, boolean 
requestCredential) {
+    LoadTableResponse upstream =
+        getRESTRegisterTable((RESTCatalog) getCatalog(), namespace, request, 
requestCredential);
+    return rewriteRemoteLoadTable(TableIdentifier.of(namespace, 
request.name()), upstream);
+  }
 
-    throw new IllegalStateException("Cannot wrap catalog that does not produce 
BaseTable");
+  private LoadTableResponse rewriteRemoteLoadTable(
+      TableIdentifier identifier, LoadTableResponse upstream) {
+    return IcebergRESTUtils.rewriteLoadTableCredentials(
+        catalogCredentialManager.catalogName(), identifier, upstream);
   }
 
   /**
@@ -361,52 +418,6 @@ public class FederatedCatalogWrapper extends 
CatalogWrapperForREST {
     }
   }
 
-  /**
-   * Federation-aware {@code loadTable}: loads the table from the underlying 
(remote) catalog and
-   * extracts client-facing FileIO/credential properties from {@code 
table.io()}.
-   */
-  private LoadTableResponse loadTableInternal(TableIdentifier ident) {
-    Table table = getCatalog().loadTable(ident);
-
-    if (table instanceof BaseTable) {
-      return buildLoadTableResponseFromFileIo(ident, (BaseTable) table);
-    } else if (table instanceof BaseMetadataTable) {
-      // metadata tables are loaded on the client side, return 
NoSuchTableException for now
-      throw new NoSuchTableException("Table does not exist: %s", 
ident.toString());
-    }
-
-    throw new IllegalStateException("Cannot wrap catalog that does not produce 
BaseTable");
-  }
-
-  /**
-   * Builds a {@link LoadTableResponse} from a remote {@link BaseTable}, 
exposing the client-facing
-   * FileIO and credential properties extracted from {@code table.io()}, 
including the refreshable
-   * vended credentials and refresh properties for the remote storage.
-   *
-   * @param ident the table identifier, used to build the credential-refresh 
endpoint.
-   * @param table the remote base table whose {@code io()} carries the storage 
credentials.
-   * @return the load-table response including FileIO-derived client config 
and vended credentials.
-   */
-  private LoadTableResponse buildLoadTableResponseFromFileIo(
-      TableIdentifier ident, BaseTable table) {
-    Map<String, String> properties = retrieveFileIOProperties(table.io());
-    Map<String, String> filteredCredentialProperties =
-        CredentialPropertyUtils.filterCredentialProperties(properties);
-    return LoadTableResponse.builder()
-        .withTableMetadata(table.operations().current())
-        .addAllConfig(
-            MapUtils.getFilteredMap(properties, key -> 
catalogPropertiesToClientKeys.contains(key)))
-        // Keep only credential fields from FileIO properties before returning 
them to the client.
-        .addAllConfig(filteredCredentialProperties)
-        .addAllConfig(
-            IcebergRESTUtils.buildRefreshProps(
-                catalogCredentialManager.catalogName(), ident, 
filteredCredentialProperties))
-        .addAllCredentials(
-            IcebergRESTUtils.buildStorageCreds(
-                catalogCredentialManager.catalogName(), ident, table.io()))
-        .build();
-  }
-
   private static boolean isCreate(UpdateTableRequest request) {
     boolean isCreate =
         request.requirements().stream()
@@ -501,10 +512,4 @@ public class FederatedCatalogWrapper extends 
CatalogWrapperForREST {
 
     return true;
   }
-
-  private static Map<String, String> retrieveFileIOProperties(FileIO fileIO) {
-    return fileIO instanceof InMemoryFileIO
-        ? Maps.newHashMap()
-        : new HashMap<>(fileIO.properties());
-  }
 }
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..bedd609d37 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
@@ -21,6 +21,7 @@ package org.apache.gravitino.iceberg.service;
 import com.fasterxml.jackson.databind.ObjectMapper;
 import com.google.common.base.Preconditions;
 import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.ImmutableSet;
 import java.io.IOException;
 import java.nio.charset.StandardCharsets;
 import java.security.MessageDigest;
@@ -36,6 +37,7 @@ import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
 import java.util.Optional;
+import java.util.Set;
 import java.util.stream.Stream;
 import javax.servlet.http.HttpServletRequest;
 import javax.ws.rs.core.EntityTag;
@@ -77,6 +79,18 @@ public class IcebergRESTUtils {
 
   public static final String SNAPSHOT_REFS = "refs";
 
+  /**
+   * Iceberg refresh-endpoint keys that may appear in {@link 
LoadTableResponse#config()}. Kept in
+   * sync with {@link CredentialPropertyUtils#buildRefreshProps}; they are not 
retained by {@link
+   * CredentialPropertyUtils#filterCredentialProperties}, so top-level config 
must drop them
+   * explicitly before IRC-local endpoints are re-applied.
+   */
+  private static final Set<String> REFRESH_CREDENTIALS_ENDPOINT_KEYS =
+      ImmutableSet.of(
+          "client.refresh-credentials-endpoint",
+          "gcs.oauth2.refresh-credentials-endpoint",
+          "adls.refresh-credentials-endpoint");
+
   /** Snapshot modes for the Iceberg loadTable endpoint. */
   public enum SnapshotMode {
     ALL(SNAPSHOT_ALL),
@@ -232,6 +246,49 @@ public class IcebergRESTUtils {
     return toRESTCredential(prefix, ImmutableMap.copyOf(filteredConfig));
   }
 
+  /**
+   * Rewrites credentials in a federated {@link LoadTableResponse} so their 
{@code
+   * refresh-credentials-endpoint} entries, including any flattened into 
{@code config}, point at
+   * this IRC instance instead of the upstream catalog.
+   *
+   * <p>Upstream refresh endpoints are removed from top-level {@code config} 
before IRC-local ones
+   * are re-applied, matching {@link #rewriteCredential}. Without that step a 
refresh URL that lived
+   * only in {@code config} (with tokens only in {@code storage-credentials}) 
would leak.
+   *
+   * @param catalogName IRC catalog name used to build refresh paths
+   * @param tableIdentifier table receiving the credentials
+   * @param upstream the load-table response returned by the upstream REST 
catalog
+   * @return a load-table response with IRC-local refresh endpoints
+   */
+  public static LoadTableResponse rewriteLoadTableCredentials(
+      String catalogName, TableIdentifier tableIdentifier, LoadTableResponse 
upstream) {
+    Map<String, String> config = new HashMap<>();
+    if (upstream.config() != null) {
+      config.putAll(upstream.config());
+    }
+    // filterCredentialProperties drops refresh endpoints from its return 
value but putAll does not
+    // remove keys already copied from upstream.config(). Drop them first so a 
refresh URL that
+    // lived only in config (tokens only in storage-credentials) cannot leak 
to clients.
+    config.keySet().removeAll(REFRESH_CREDENTIALS_ENDPOINT_KEYS);
+    Map<String, String> filteredCredentialProperties =
+        CredentialPropertyUtils.filterCredentialProperties(config);
+    config.putAll(filteredCredentialProperties);
+    config.putAll(buildRefreshProps(catalogName, tableIdentifier, 
filteredCredentialProperties));
+
+    LoadTableResponse.Builder builder =
+        LoadTableResponse.builder()
+            .withTableMetadata(upstream.tableMetadata())
+            .addAllConfig(config);
+    if (upstream.credentials() != null) {
+      for (org.apache.iceberg.rest.credentials.Credential credential : 
upstream.credentials()) {
+        builder.addCredential(
+            rewriteCredential(
+                catalogName, tableIdentifier, credential.prefix(), 
credential.config()));
+      }
+    }
+    return builder.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/rest/IcebergTableOperations.java
 
b/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/rest/IcebergTableOperations.java
index 24d567e6c7..1782fe8ec9 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
@@ -567,10 +567,14 @@ public class IcebergTableOperations {
     }
     TableMetadata filteredMetadata =
         
TableMetadata.buildFrom(metadata).suppressHistoricalSnapshots().build();
-    return LoadTableResponse.builder()
-        .withTableMetadata(filteredMetadata)
-        .addAllConfig(loadTableResponse.config())
-        .build();
+    LoadTableResponse.Builder builder =
+        LoadTableResponse.builder()
+            .withTableMetadata(filteredMetadata)
+            .addAllConfig(loadTableResponse.config());
+    if (loadTableResponse.credentials() != null) {
+      builder.addAllCredentials(loadTableResponse.credentials());
+    }
+    return builder.build();
   }
 
   private static Response buildResponseWithETag(LoadTableResponse 
loadTableResponse) {
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 e372170585..612a618524 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
@@ -20,14 +20,12 @@
 package org.apache.gravitino.iceberg.service;
 
 import static org.mockito.Mockito.any;
-import static org.mockito.Mockito.anyMap;
 import static org.mockito.Mockito.anyString;
 import static org.mockito.Mockito.doAnswer;
 import static org.mockito.Mockito.mock;
 import static org.mockito.Mockito.never;
 import static org.mockito.Mockito.verify;
 import static org.mockito.Mockito.when;
-import static org.mockito.Mockito.withSettings;
 
 import com.google.common.collect.ImmutableMap;
 import com.sun.net.httpserver.HttpServer;
@@ -47,17 +45,15 @@ 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.extension.DummyCredentialProvider;
-import org.apache.iceberg.BaseTable;
 import org.apache.iceberg.BaseTransaction;
 import org.apache.iceberg.CatalogProperties;
 import org.apache.iceberg.MetadataUpdate;
 import org.apache.iceberg.PartitionSpec;
 import org.apache.iceberg.Schema;
 import org.apache.iceberg.SortOrder;
-import org.apache.iceberg.Table;
 import org.apache.iceberg.TableMetadata;
+import org.apache.iceberg.TableMetadataParser;
 import org.apache.iceberg.TableOperations;
-import org.apache.iceberg.Transaction;
 import org.apache.iceberg.UpdateRequirement;
 import org.apache.iceberg.catalog.Catalog;
 import org.apache.iceberg.catalog.Namespace;
@@ -67,10 +63,7 @@ import org.apache.iceberg.exceptions.ForbiddenException;
 import org.apache.iceberg.exceptions.NoSuchTableException;
 import org.apache.iceberg.exceptions.NotAuthorizedException;
 import org.apache.iceberg.exceptions.ServiceFailureException;
-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.RESTCatalog;
 import org.apache.iceberg.rest.auth.AuthProperties;
 import org.apache.iceberg.rest.credentials.Credential;
@@ -80,6 +73,7 @@ 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.LoadTableResponseParser;
 import org.apache.iceberg.types.Types;
 import org.junit.jupiter.api.Assertions;
 import org.junit.jupiter.api.Test;
@@ -431,70 +425,93 @@ public class TestCatalogWrapperForREST {
   }
 
   @Test
-  void testLoadTableRefreshEndpoint() {
+  void testLoadTableRefreshEndpoint() throws Exception {
     TableIdentifier ident = TableIdentifier.of(Namespace.of("db"), "tbl");
-    RESTCatalog catalog = mock(RESTCatalog.class);
-    BaseTable baseTable = mock(BaseTable.class);
-    TableOperations ops = mock(TableOperations.class);
-    FileIO fileIO = mock(FileIO.class);
     TableMetadata metadata =
-        TableMetadata.newTableMetadata(
-            new Schema(Types.NestedField.required(1, "id", 
Types.IntegerType.get())),
-            PartitionSpec.unpartitioned(),
-            SortOrder.unsorted(),
-            "s3://bucket/db/tbl",
-            Collections.emptyMap());
-
-    when(catalog.loadTable(ident)).thenReturn(baseTable);
-    when(baseTable.operations()).thenReturn(ops);
-    when(ops.current()).thenReturn(metadata);
-    when(baseTable.io()).thenReturn(fileIO);
-    when(fileIO.properties())
-        .thenReturn(
-            ImmutableMap.of(
-                "s3.session-token",
-                "token",
-                "s3.session-token-expires-at-ms",
-                "123",
-                "client.refresh-credentials-endpoint",
-                "v1/upstream/namespaces/db/tables/tbl/credentials"));
+        TableMetadataParser.fromJson(
+            "s3://bucket/db/tbl/metadata/v1.metadata.json",
+            TableMetadataParser.toJson(
+                TableMetadata.newTableMetadata(
+                    new Schema(Types.NestedField.required(1, "id", 
Types.IntegerType.get())),
+                    PartitionSpec.unpartitioned(),
+                    SortOrder.unsorted(),
+                    "s3://bucket/db/tbl",
+                    Collections.emptyMap())));
+    LoadTableResponse upstreamResponse =
+        LoadTableResponse.builder()
+            .withTableMetadata(metadata)
+            .addAllConfig(
+                ImmutableMap.of(
+                    "s3.session-token",
+                    "token",
+                    "s3.session-token-expires-at-ms",
+                    "123",
+                    "client.refresh-credentials-endpoint",
+                    "v1/upstream/namespaces/db/tables/tbl/credentials"))
+            .build();
+    String upstreamJson = LoadTableResponseParser.toJson(upstreamResponse);
 
-    IcebergConfig config =
-        new IcebergConfig(
-            ImmutableMap.of(
-                IcebergConstants.CATALOG_BACKEND,
-                "memory",
-                IcebergConstants.WAREHOUSE,
-                "/tmp/warehouse"));
-    CatalogWrapperForREST wrapper = new StaticCatalogWrapperForREST("irc1", 
config, catalog);
+    HttpServer server = HttpServer.create(new InetSocketAddress(0), 0);
+    server.createContext(
+        "/",
+        exchange -> {
+          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 catalog = mock(RESTCatalog.class);
+      when(catalog.name()).thenReturn("upstream");
+      when(catalog.properties())
+          .thenReturn(
+              ImmutableMap.of(
+                  CatalogProperties.URI,
+                  uri,
+                  AuthProperties.AUTH_TYPE,
+                  AuthProperties.AUTH_TYPE_NONE,
+                  "prefix",
+                  "upstream"));
+
+      IcebergConfig config =
+          new IcebergConfig(
+              ImmutableMap.of(
+                  IcebergConstants.CATALOG_BACKEND,
+                  "memory",
+                  IcebergConstants.WAREHOUSE,
+                  "/tmp/warehouse"));
+      CatalogWrapperForREST wrapper = new StaticCatalogWrapperForREST("irc1", 
config, catalog);
 
-    LoadTableResponse response = wrapper.loadTable(ident, false, 
CredentialPrivilege.READ);
+      LoadTableResponse response = wrapper.loadTable(ident, true, 
CredentialPrivilege.READ);
 
-    Assertions.assertEquals(
-        "v1/irc1/namespaces/db/tables/tbl/credentials",
-        response.config().get("client.refresh-credentials-endpoint"));
-    Assertions.assertEquals("token", 
response.config().get("s3.session-token"));
+      Assertions.assertEquals(
+          "v1/irc1/namespaces/db/tables/tbl/credentials",
+          response.config().get("client.refresh-credentials-endpoint"));
+      Assertions.assertEquals("token", 
response.config().get("s3.session-token"));
+    } finally {
+      server.stop(0);
+    }
   }
 
   @Test
-  void testLoadTableStorageCreds() {
+  void testLoadTableStorageCreds() throws Exception {
     TableIdentifier ident = TableIdentifier.of(Namespace.of("db"), "tbl");
-    RESTCatalog catalog = mock(RESTCatalog.class);
-    BaseTable baseTable = mock(BaseTable.class);
-    TableOperations ops = mock(TableOperations.class);
-    FileIO fileIO =
-        mock(FileIO.class, 
withSettings().extraInterfaces(SupportsStorageCredentials.class));
-    SupportsStorageCredentials storageCredentialsFileIO = 
(SupportsStorageCredentials) fileIO;
     TableMetadata metadata =
-        TableMetadata.newTableMetadata(
-            new Schema(Types.NestedField.required(1, "id", 
Types.IntegerType.get())),
-            PartitionSpec.unpartitioned(),
-            SortOrder.unsorted(),
-            "s3://bucket/db/tbl",
-            Collections.emptyMap());
-
-    StorageCredential upstreamCredential =
-        StorageCredential.create(
+        TableMetadataParser.fromJson(
+            "s3://bucket/db/tbl/metadata/v1.metadata.json",
+            TableMetadataParser.toJson(
+                TableMetadata.newTableMetadata(
+                    new Schema(Types.NestedField.required(1, "id", 
Types.IntegerType.get())),
+                    PartitionSpec.unpartitioned(),
+                    SortOrder.unsorted(),
+                    "s3://bucket/db/tbl",
+                    Collections.emptyMap())));
+    Credential upstreamCredential =
+        IcebergRESTUtils.toRESTCredential(
             "s3://bucket/db/tbl/",
             ImmutableMap.of(
                 "s3.access-key-id",
@@ -507,33 +524,61 @@ public class TestCatalogWrapperForREST {
                 "123",
                 "client.refresh-credentials-endpoint",
                 "v1/upstream/namespaces/db/tables/tbl/credentials"));
+    LoadTableResponse upstreamResponse =
+        LoadTableResponse.builder()
+            .withTableMetadata(metadata)
+            .addCredential(upstreamCredential)
+            .build();
+    String upstreamJson = LoadTableResponseParser.toJson(upstreamResponse);
 
-    when(catalog.loadTable(ident)).thenReturn(baseTable);
-    when(baseTable.operations()).thenReturn(ops);
-    when(ops.current()).thenReturn(metadata);
-    when(baseTable.io()).thenReturn(fileIO);
-    when(fileIO.properties()).thenReturn(Collections.emptyMap());
-    
when(storageCredentialsFileIO.credentials()).thenReturn(List.of(upstreamCredential));
+    HttpServer server = HttpServer.create(new InetSocketAddress(0), 0);
+    server.createContext(
+        "/",
+        exchange -> {
+          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 catalog = mock(RESTCatalog.class);
+      when(catalog.name()).thenReturn("upstream");
+      when(catalog.properties())
+          .thenReturn(
+              ImmutableMap.of(
+                  CatalogProperties.URI,
+                  uri,
+                  AuthProperties.AUTH_TYPE,
+                  AuthProperties.AUTH_TYPE_NONE,
+                  "prefix",
+                  "upstream"));
 
-    IcebergConfig config =
-        new IcebergConfig(
-            ImmutableMap.of(
-                IcebergConstants.CATALOG_BACKEND,
-                "memory",
-                IcebergConstants.WAREHOUSE,
-                "/tmp/warehouse"));
-    CatalogWrapperForREST wrapper = new StaticCatalogWrapperForREST("irc1", 
config, catalog);
+      IcebergConfig config =
+          new IcebergConfig(
+              ImmutableMap.of(
+                  IcebergConstants.CATALOG_BACKEND,
+                  "memory",
+                  IcebergConstants.WAREHOUSE,
+                  "/tmp/warehouse"));
+      CatalogWrapperForREST wrapper = new StaticCatalogWrapperForREST("irc1", 
config, catalog);
 
-    LoadTableResponse response = wrapper.loadTable(ident, false, 
CredentialPrivilege.READ);
+      LoadTableResponse response = wrapper.loadTable(ident, true, 
CredentialPrivilege.READ);
 
-    Assertions.assertEquals(1, response.credentials().size());
-    Credential credential = response.credentials().get(0);
-    Assertions.assertEquals("s3://bucket/db/tbl/", credential.prefix());
-    Assertions.assertEquals("upstream-token", 
credential.config().get("s3.session-token"));
-    Assertions.assertEquals(
-        "v1/irc1/namespaces/db/tables/tbl/credentials",
-        credential.config().get("client.refresh-credentials-endpoint"));
-    
Assertions.assertFalse(response.config().containsKey("client.refresh-credentials-endpoint"));
+      Assertions.assertEquals(1, response.credentials().size());
+      Credential credential = response.credentials().get(0);
+      Assertions.assertEquals("s3://bucket/db/tbl/", credential.prefix());
+      Assertions.assertEquals("upstream-token", 
credential.config().get("s3.session-token"));
+      Assertions.assertEquals(
+          "v1/irc1/namespaces/db/tables/tbl/credentials",
+          credential.config().get("client.refresh-credentials-endpoint"));
+      
Assertions.assertFalse(response.config().containsKey("client.refresh-credentials-endpoint"));
+    } finally {
+      server.stop(0);
+    }
   }
 
   @Test
@@ -621,47 +666,6 @@ public class TestCatalogWrapperForREST {
         () -> CatalogWrapperForREST.filterCatalogConfigForClients(source));
   }
 
-  @Test
-  void testFederatedRegisterTableIncludesFileIo() {
-    RESTCatalog catalog = mock(RESTCatalog.class);
-    BaseTable table = mock(BaseTable.class);
-    TableOperations ops = mock(TableOperations.class);
-    FileIO fileIO = mock(FileIO.class);
-    when(catalog.registerTable(any(TableIdentifier.class), 
anyString())).thenReturn(table);
-    when(table.operations()).thenReturn(ops);
-    when(ops.current()).thenReturn(minimalTableMetadataForStagedCreateTest());
-    when(table.io()).thenReturn(fileIO);
-    when(fileIO.properties())
-        .thenReturn(
-            ImmutableMap.of(
-                IcebergConstants.IO_IMPL,
-                "org.apache.iceberg.aws.s3.S3FileIO",
-                IcebergConstants.ICEBERG_S3_ENDPOINT,
-                "http://localhost:9000";));
-
-    IcebergConfig config =
-        new IcebergConfig(
-            ImmutableMap.of(
-                IcebergConstants.CATALOG_BACKEND,
-                "memory",
-                IcebergConstants.WAREHOUSE,
-                "/tmp/warehouse"));
-    CatalogWrapperForREST wrapper = new StaticCatalogWrapperForREST("test", 
config, catalog);
-
-    RegisterTableRequest request =
-        ImmutableRegisterTableRequest.builder()
-            .name("tbl")
-            
.metadataLocation("s3://bucket/warehouse/tbl/metadata/v1.metadata.json")
-            .build();
-
-    LoadTableResponse response = wrapper.registerTable(Namespace.of("db"), 
request, false);
-
-    Assertions.assertEquals(
-        "org.apache.iceberg.aws.s3.S3FileIO", 
response.config().get(IcebergConstants.IO_IMPL));
-    Assertions.assertEquals(
-        "http://localhost:9000";, 
response.config().get(IcebergConstants.ICEBERG_S3_ENDPOINT));
-  }
-
   @Test
   void testWrapperLazyLoadsCatalog() {
     IcebergConfig config =
@@ -680,102 +684,6 @@ public class TestCatalogWrapperForREST {
     }
   }
 
-  @Test
-  void testStageCreateWithLocationIncludesFileIo() throws Exception {
-    RESTCatalog catalog = mock(RESTCatalog.class);
-    Catalog.TableBuilder tableBuilder = mock(Catalog.TableBuilder.class);
-    Transaction transaction = mock(Transaction.class);
-    Table table = mock(Table.class);
-    FileIO fileIO = mock(FileIO.class);
-    when(catalog.buildTable(any(TableIdentifier.class), 
any())).thenReturn(tableBuilder);
-    when(tableBuilder.withPartitionSpec(any())).thenReturn(tableBuilder);
-    when(tableBuilder.withSortOrder(any())).thenReturn(tableBuilder);
-    when(tableBuilder.withProperties(anyMap())).thenReturn(tableBuilder);
-    
when(tableBuilder.withLocation("s3://bucket/warehouse/table")).thenReturn(tableBuilder);
-    when(tableBuilder.createTransaction()).thenReturn(transaction);
-    when(transaction.table()).thenReturn(table);
-    when(table.io()).thenReturn(fileIO);
-    when(table.location()).thenReturn("s3://bucket/warehouse/table");
-    when(fileIO.properties())
-        .thenReturn(
-            ImmutableMap.of(
-                IcebergConstants.IO_IMPL,
-                "org.apache.iceberg.aws.s3.S3FileIO",
-                IcebergConstants.ICEBERG_S3_ENDPOINT,
-                "http://localhost:9000";));
-
-    IcebergConfig config =
-        new IcebergConfig(
-            ImmutableMap.of(
-                IcebergConstants.CATALOG_BACKEND,
-                "memory",
-                IcebergConstants.WAREHOUSE,
-                "/tmp/warehouse"));
-    CatalogWrapperForREST wrapper = new StaticCatalogWrapperForREST("test", 
config, catalog);
-
-    Schema schema = new Schema(Types.NestedField.required(1, "id", 
Types.IntegerType.get()));
-    CreateTableRequest request =
-        CreateTableRequest.builder()
-            .withName("tbl")
-            .withSchema(schema)
-            .withLocation("s3://bucket/warehouse/table")
-            .stageCreate()
-            .build();
-
-    LoadTableResponse response = wrapper.createTable(Namespace.of("db"), 
request, false);
-
-    Assertions.assertEquals(
-        "org.apache.iceberg.aws.s3.S3FileIO", 
response.config().get(IcebergConstants.IO_IMPL));
-    Assertions.assertEquals(
-        "http://localhost:9000";, 
response.config().get(IcebergConstants.ICEBERG_S3_ENDPOINT));
-    verify(tableBuilder).withLocation("s3://bucket/warehouse/table");
-  }
-
-  @Test
-  void testStageCreateNullLocationSkipsWithLocation() {
-    RESTCatalog catalog = mock(RESTCatalog.class);
-    Catalog.TableBuilder tableBuilder = mock(Catalog.TableBuilder.class);
-    Transaction transaction = mock(Transaction.class);
-    Table table = mock(Table.class);
-    FileIO fileIO = mock(FileIO.class);
-    when(catalog.buildTable(any(TableIdentifier.class), 
any())).thenReturn(tableBuilder);
-    when(tableBuilder.withPartitionSpec(any())).thenReturn(tableBuilder);
-    when(tableBuilder.withSortOrder(any())).thenReturn(tableBuilder);
-    when(tableBuilder.withProperties(anyMap())).thenReturn(tableBuilder);
-    when(tableBuilder.createTransaction()).thenReturn(transaction);
-    when(transaction.table()).thenReturn(table);
-    when(table.io()).thenReturn(fileIO);
-    
when(table.location()).thenReturn("s3://bucket/warehouse/default-location");
-    when(fileIO.properties())
-        .thenReturn(
-            ImmutableMap.of(
-                IcebergConstants.IO_IMPL,
-                "org.apache.iceberg.aws.s3.S3FileIO",
-                IcebergConstants.ICEBERG_S3_ENDPOINT,
-                "http://localhost:9000";));
-
-    IcebergConfig config =
-        new IcebergConfig(
-            ImmutableMap.of(
-                IcebergConstants.CATALOG_BACKEND,
-                "memory",
-                IcebergConstants.WAREHOUSE,
-                "/tmp/warehouse"));
-    CatalogWrapperForREST wrapper = new StaticCatalogWrapperForREST("test", 
config, catalog);
-
-    Schema schema = new Schema(Types.NestedField.required(1, "id", 
Types.IntegerType.get()));
-    CreateTableRequest request =
-        
CreateTableRequest.builder().withName("tbl").withSchema(schema).stageCreate().build();
-
-    LoadTableResponse response = wrapper.createTable(Namespace.of("db"), 
request, false);
-
-    Assertions.assertEquals(
-        "org.apache.iceberg.aws.s3.S3FileIO", 
response.config().get(IcebergConstants.IO_IMPL));
-    Assertions.assertEquals(
-        "http://localhost:9000";, 
response.config().get(IcebergConstants.ICEBERG_S3_ENDPOINT));
-    verify(tableBuilder, never()).withLocation(any());
-  }
-
   @Test
   void testStagedCreateRejectsExtraRequirements() {
     RESTCatalog catalog = mock(RESTCatalog.class);
@@ -1027,4 +935,668 @@ public class TestCatalogWrapperForREST {
       return catalog;
     }
   }
+
+  @Test
+  void testFederatedLoadTableDelegatesToRemote() throws Exception {
+    TableIdentifier table = TableIdentifier.of(Namespace.of("db"), "tbl");
+    String expectedPath = "/v1/upstream/namespaces/db/tables/tbl";
+
+    TableMetadata metadata =
+        TableMetadataParser.fromJson(
+            "s3://bucket/db/tbl/metadata/v1.metadata.json",
+            TableMetadataParser.toJson(
+                TableMetadata.newTableMetadata(
+                    new Schema(Types.NestedField.required(1, "id", 
Types.IntegerType.get())),
+                    PartitionSpec.unpartitioned(),
+                    SortOrder.unsorted(),
+                    "s3://bucket/db/tbl",
+                    Collections.emptyMap())));
+    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"));
+    LoadTableResponse upstreamResponse =
+        LoadTableResponse.builder()
+            .withTableMetadata(metadata)
+            .addAllConfig(ImmutableMap.of("io-impl", 
"org.apache.iceberg.aws.s3.S3FileIO"))
+            .addCredential(cred)
+            .build();
+    String upstreamJson = LoadTableResponseParser.toJson(upstreamResponse);
+
+    AtomicReference<String> requestPath = new AtomicReference<>();
+    AtomicReference<String> requestMethod = new AtomicReference<>();
+    AtomicReference<String> requestQuery = 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());
+          requestQuery.set(exchange.getRequestURI().getQuery());
+          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"));
+
+      IcebergConfig config =
+          new IcebergConfig(
+              ImmutableMap.of(
+                  IcebergConstants.CATALOG_BACKEND,
+                  "memory",
+                  IcebergConstants.WAREHOUSE,
+                  "/tmp/warehouse"));
+      CatalogWrapperForREST wrapper = new StaticCatalogWrapperForREST("local", 
config, restCatalog);
+
+      LoadTableResponse response = wrapper.loadTable(table, true, 
CredentialPrivilege.READ);
+
+      Assertions.assertEquals(expectedPath, requestPath.get());
+      Assertions.assertEquals("GET", requestMethod.get());
+      Assertions.assertEquals("snapshots=all", requestQuery.get());
+      Assertions.assertEquals("vended-credentials", 
accessDelegationHeader.get());
+      verify(restCatalog, never()).loadTable(table);
+      Assertions.assertEquals(1, response.credentials().size());
+      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("upstream-token", 
credential.config().get("s3.session-token"));
+      Assertions.assertEquals(
+          "v1/local/namespaces/db/tables/tbl/credentials",
+          credential.config().get("client.refresh-credentials-endpoint"));
+      Assertions.assertEquals(
+          "org.apache.iceberg.aws.s3.S3FileIO", 
response.config().get("io-impl"));
+    } finally {
+      server.stop(0);
+    }
+  }
+
+  @Test
+  void testFederatedLoadTableNoCredentials() throws Exception {
+    TableIdentifier table = TableIdentifier.of(Namespace.of("db"), "tbl");
+    TableMetadata metadata =
+        TableMetadataParser.fromJson(
+            "s3://bucket/db/tbl/metadata/v1.metadata.json",
+            TableMetadataParser.toJson(
+                TableMetadata.newTableMetadata(
+                    new Schema(Types.NestedField.required(1, "id", 
Types.IntegerType.get())),
+                    PartitionSpec.unpartitioned(),
+                    SortOrder.unsorted(),
+                    "s3://bucket/db/tbl",
+                    Collections.emptyMap())));
+    LoadTableResponse upstreamResponse =
+        LoadTableResponse.builder()
+            .withTableMetadata(metadata)
+            .addAllConfig(ImmutableMap.of("io-impl", 
"org.apache.iceberg.aws.s3.S3FileIO"))
+            .build();
+    String upstreamJson = LoadTableResponseParser.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"));
+
+      IcebergConfig config =
+          new IcebergConfig(
+              ImmutableMap.of(
+                  IcebergConstants.CATALOG_BACKEND,
+                  "memory",
+                  IcebergConstants.WAREHOUSE,
+                  "/tmp/warehouse"));
+      CatalogWrapperForREST wrapper = new StaticCatalogWrapperForREST("local", 
config, restCatalog);
+
+      LoadTableResponse response = wrapper.loadTable(table, false, 
CredentialPrivilege.READ);
+
+      Assertions.assertNull(
+          accessDelegationHeader.get(),
+          "X-Iceberg-Access-Delegation header should not be sent without 
credential vending");
+      verify(restCatalog, never()).loadTable(table);
+      Assertions.assertTrue(
+          response.credentials() == null || response.credentials().isEmpty(),
+          "Non-vended request should not return remote storage-credentials");
+      Assertions.assertEquals(
+          "org.apache.iceberg.aws.s3.S3FileIO", 
response.config().get("io-impl"));
+    } finally {
+      server.stop(0);
+    }
+  }
+
+  @Test
+  void testFederatedCreateTableWithCredentials() throws Exception {
+    Namespace namespace = Namespace.of("db");
+    TableMetadata metadata =
+        TableMetadataParser.fromJson(
+            "s3://bucket/db/tbl/metadata/v1.metadata.json",
+            TableMetadataParser.toJson(
+                TableMetadata.newTableMetadata(
+                    new Schema(Types.NestedField.required(1, "id", 
Types.IntegerType.get())),
+                    PartitionSpec.unpartitioned(),
+                    SortOrder.unsorted(),
+                    "s3://bucket/db/tbl",
+                    Collections.emptyMap())));
+    Credential cred =
+        IcebergRESTUtils.toRESTCredential(
+            "s3://bucket/db/tbl/",
+            ImmutableMap.of(
+                "s3.session-token",
+                "upstream-token",
+                "client.refresh-credentials-endpoint",
+                "v1/upstream/namespaces/db/tables/tbl/credentials"));
+    LoadTableResponse upstreamResponse =
+        
LoadTableResponse.builder().withTableMetadata(metadata).addCredential(cred).build();
+    String upstreamJson = LoadTableResponseParser.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"));
+
+      IcebergConfig config =
+          new IcebergConfig(
+              ImmutableMap.of(
+                  IcebergConstants.CATALOG_BACKEND,
+                  "memory",
+                  IcebergConstants.WAREHOUSE,
+                  "/tmp/warehouse"));
+      CatalogWrapperForREST wrapper = new StaticCatalogWrapperForREST("local", 
config, restCatalog);
+      CreateTableRequest request =
+          CreateTableRequest.builder()
+              .withName("tbl")
+              .withSchema(new Schema(Types.NestedField.required(1, "id", 
Types.IntegerType.get())))
+              .build();
+
+      LoadTableResponse response = wrapper.createTable(namespace, request, 
true);
+
+      Assertions.assertEquals("/v1/upstream/namespaces/db/tables", 
requestPath.get());
+      Assertions.assertEquals("POST", requestMethod.get());
+      Assertions.assertEquals("vended-credentials", 
accessDelegationHeader.get());
+      Assertions.assertEquals(1, response.credentials().size());
+      Assertions.assertEquals(
+          "v1/local/namespaces/db/tables/tbl/credentials",
+          
response.credentials().get(0).config().get("client.refresh-credentials-endpoint"));
+    } finally {
+      server.stop(0);
+    }
+  }
+
+  @Test
+  void testFederatedCreateTableNoCredentials() throws Exception {
+    Namespace namespace = Namespace.of("db");
+    TableMetadata metadata =
+        TableMetadataParser.fromJson(
+            "s3://bucket/db/tbl/metadata/v1.metadata.json",
+            TableMetadataParser.toJson(
+                TableMetadata.newTableMetadata(
+                    new Schema(Types.NestedField.required(1, "id", 
Types.IntegerType.get())),
+                    PartitionSpec.unpartitioned(),
+                    SortOrder.unsorted(),
+                    "s3://bucket/db/tbl",
+                    Collections.emptyMap())));
+    LoadTableResponse upstreamResponse =
+        LoadTableResponse.builder()
+            .withTableMetadata(metadata)
+            .addAllConfig(ImmutableMap.of("io-impl", 
"org.apache.iceberg.aws.s3.S3FileIO"))
+            .build();
+    String upstreamJson = LoadTableResponseParser.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"));
+
+      IcebergConfig config =
+          new IcebergConfig(
+              ImmutableMap.of(
+                  IcebergConstants.CATALOG_BACKEND,
+                  "memory",
+                  IcebergConstants.WAREHOUSE,
+                  "/tmp/warehouse"));
+      CatalogWrapperForREST wrapper = new StaticCatalogWrapperForREST("local", 
config, restCatalog);
+      CreateTableRequest request =
+          CreateTableRequest.builder()
+              .withName("tbl")
+              .withSchema(new Schema(Types.NestedField.required(1, "id", 
Types.IntegerType.get())))
+              .build();
+
+      LoadTableResponse response = wrapper.createTable(namespace, request, 
false);
+
+      Assertions.assertEquals("/v1/upstream/namespaces/db/tables", 
requestPath.get());
+      Assertions.assertEquals("POST", requestMethod.get());
+      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 remote storage-credentials");
+      Assertions.assertEquals(
+          "org.apache.iceberg.aws.s3.S3FileIO", 
response.config().get("io-impl"));
+    } finally {
+      server.stop(0);
+    }
+  }
+
+  @Test
+  void testFederatedCreateTableForwardsStageCreate() throws Exception {
+    Namespace namespace = Namespace.of("db");
+    TableMetadata metadata =
+        TableMetadataParser.fromJson(
+            "s3://bucket/db/tbl/metadata/v1.metadata.json",
+            TableMetadataParser.toJson(
+                TableMetadata.newTableMetadata(
+                    new Schema(Types.NestedField.required(1, "id", 
Types.IntegerType.get())),
+                    PartitionSpec.unpartitioned(),
+                    SortOrder.unsorted(),
+                    "s3://bucket/db/tbl",
+                    Collections.emptyMap())));
+    LoadTableResponse upstreamResponse =
+        LoadTableResponse.builder().withTableMetadata(metadata).build();
+    String upstreamJson = LoadTableResponseParser.toJson(upstreamResponse);
+
+    AtomicReference<String> requestBody = new AtomicReference<>();
+    HttpServer server = HttpServer.create(new InetSocketAddress(0), 0);
+    server.createContext(
+        "/",
+        exchange -> {
+          requestBody.set(
+              new String(exchange.getRequestBody().readAllBytes(), 
StandardCharsets.UTF_8));
+          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"));
+
+      IcebergConfig config =
+          new IcebergConfig(
+              ImmutableMap.of(
+                  IcebergConstants.CATALOG_BACKEND,
+                  "memory",
+                  IcebergConstants.WAREHOUSE,
+                  "/tmp/warehouse"));
+      CatalogWrapperForREST wrapper = new StaticCatalogWrapperForREST("local", 
config, restCatalog);
+      Schema schema = new Schema(Types.NestedField.required(1, "id", 
Types.IntegerType.get()));
+      CreateTableRequest request =
+          CreateTableRequest.builder()
+              .withName("tbl")
+              .withSchema(schema)
+              .withLocation("s3://bucket/warehouse/table")
+              .stageCreate()
+              .build();
+
+      wrapper.createTable(namespace, request, false);
+
+      CreateTableRequest forwarded =
+          IcebergObjectMapper.getInstance().readValue(requestBody.get(), 
CreateTableRequest.class);
+      Assertions.assertTrue(forwarded.stageCreate());
+      Assertions.assertEquals("s3://bucket/warehouse/table", 
forwarded.location());
+    } finally {
+      server.stop(0);
+    }
+  }
+
+  @Test
+  void testFederatedRegisterTableWithCredentials() throws Exception {
+    Namespace namespace = Namespace.of("db");
+    TableMetadata metadata =
+        TableMetadataParser.fromJson(
+            "s3://bucket/db/tbl/metadata/v1.metadata.json",
+            TableMetadataParser.toJson(
+                TableMetadata.newTableMetadata(
+                    new Schema(Types.NestedField.required(1, "id", 
Types.IntegerType.get())),
+                    PartitionSpec.unpartitioned(),
+                    SortOrder.unsorted(),
+                    "s3://bucket/db/tbl",
+                    Collections.emptyMap())));
+    Credential cred =
+        IcebergRESTUtils.toRESTCredential(
+            "s3://bucket/db/tbl/",
+            ImmutableMap.of(
+                "s3.session-token",
+                "upstream-token",
+                "client.refresh-credentials-endpoint",
+                "v1/upstream/namespaces/db/tables/tbl/credentials"));
+    LoadTableResponse upstreamResponse =
+        
LoadTableResponse.builder().withTableMetadata(metadata).addCredential(cred).build();
+    String upstreamJson = LoadTableResponseParser.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"));
+
+      IcebergConfig config =
+          new IcebergConfig(
+              ImmutableMap.of(
+                  IcebergConstants.CATALOG_BACKEND,
+                  "memory",
+                  IcebergConstants.WAREHOUSE,
+                  "/tmp/warehouse"));
+      CatalogWrapperForREST wrapper = new StaticCatalogWrapperForREST("local", 
config, restCatalog);
+      RegisterTableRequest request =
+          ImmutableRegisterTableRequest.builder()
+              .name("tbl")
+              .metadataLocation("s3://bucket/db/tbl/metadata/v1.metadata.json")
+              .build();
+
+      LoadTableResponse response = wrapper.registerTable(namespace, request, 
true);
+
+      Assertions.assertEquals("/v1/upstream/namespaces/db/register", 
requestPath.get());
+      Assertions.assertEquals("POST", requestMethod.get());
+      Assertions.assertEquals("vended-credentials", 
accessDelegationHeader.get());
+      Assertions.assertEquals(1, response.credentials().size());
+      Assertions.assertEquals(
+          "v1/local/namespaces/db/tables/tbl/credentials",
+          
response.credentials().get(0).config().get("client.refresh-credentials-endpoint"));
+    } finally {
+      server.stop(0);
+    }
+  }
+
+  @Test
+  void testFederatedRegisterTableIncludesRemoteConfig() throws Exception {
+    Namespace namespace = Namespace.of("db");
+    TableMetadata metadata =
+        TableMetadataParser.fromJson(
+            "s3://bucket/db/tbl/metadata/v1.metadata.json",
+            TableMetadataParser.toJson(
+                TableMetadata.newTableMetadata(
+                    new Schema(Types.NestedField.required(1, "id", 
Types.IntegerType.get())),
+                    PartitionSpec.unpartitioned(),
+                    SortOrder.unsorted(),
+                    "s3://bucket/db/tbl",
+                    Collections.emptyMap())));
+    LoadTableResponse upstreamResponse =
+        LoadTableResponse.builder()
+            .withTableMetadata(metadata)
+            .addAllConfig(
+                ImmutableMap.of(
+                    IcebergConstants.IO_IMPL,
+                    "org.apache.iceberg.aws.s3.S3FileIO",
+                    IcebergConstants.ICEBERG_S3_ENDPOINT,
+                    "http://localhost:9000";))
+            .build();
+    String upstreamJson = LoadTableResponseParser.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 catalog = mock(RESTCatalog.class);
+      when(catalog.name()).thenReturn("upstream");
+      when(catalog.properties())
+          .thenReturn(
+              ImmutableMap.of(
+                  CatalogProperties.URI,
+                  uri,
+                  AuthProperties.AUTH_TYPE,
+                  AuthProperties.AUTH_TYPE_NONE,
+                  "prefix",
+                  "upstream"));
+
+      IcebergConfig config =
+          new IcebergConfig(
+              ImmutableMap.of(
+                  IcebergConstants.CATALOG_BACKEND,
+                  "memory",
+                  IcebergConstants.WAREHOUSE,
+                  "/tmp/warehouse"));
+      CatalogWrapperForREST wrapper = new StaticCatalogWrapperForREST("test", 
config, catalog);
+
+      RegisterTableRequest request =
+          ImmutableRegisterTableRequest.builder()
+              .name("tbl")
+              
.metadataLocation("s3://bucket/warehouse/tbl/metadata/v1.metadata.json")
+              .build();
+
+      LoadTableResponse response = wrapper.registerTable(namespace, request, 
false);
+
+      Assertions.assertEquals("/v1/upstream/namespaces/db/register", 
requestPath.get());
+      Assertions.assertEquals("POST", requestMethod.get());
+      Assertions.assertNull(accessDelegationHeader.get());
+      Assertions.assertEquals(
+          "org.apache.iceberg.aws.s3.S3FileIO", 
response.config().get(IcebergConstants.IO_IMPL));
+      Assertions.assertEquals(
+          "http://localhost:9000";, 
response.config().get(IcebergConstants.ICEBERG_S3_ENDPOINT));
+    } finally {
+      server.stop(0);
+    }
+  }
+
+  @Test
+  void testFederatedRegisterTableOverwrite() throws Exception {
+    Namespace namespace = Namespace.of("db");
+    TableMetadata metadata =
+        TableMetadataParser.fromJson(
+            "s3://bucket/db/tbl/metadata/v1.metadata.json",
+            TableMetadataParser.toJson(
+                TableMetadata.newTableMetadata(
+                    new Schema(Types.NestedField.required(1, "id", 
Types.IntegerType.get())),
+                    PartitionSpec.unpartitioned(),
+                    SortOrder.unsorted(),
+                    "s3://bucket/db/tbl",
+                    Collections.emptyMap())));
+    LoadTableResponse upstreamResponse =
+        LoadTableResponse.builder().withTableMetadata(metadata).build();
+    String upstreamJson = LoadTableResponseParser.toJson(upstreamResponse);
+
+    AtomicReference<String> requestBody = new AtomicReference<>();
+    HttpServer server = HttpServer.create(new InetSocketAddress(0), 0);
+    server.createContext(
+        "/",
+        exchange -> {
+          requestBody.set(
+              new String(exchange.getRequestBody().readAllBytes(), 
StandardCharsets.UTF_8));
+          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 catalog = mock(RESTCatalog.class);
+      when(catalog.name()).thenReturn("upstream");
+      when(catalog.properties())
+          .thenReturn(
+              ImmutableMap.of(
+                  CatalogProperties.URI,
+                  uri,
+                  AuthProperties.AUTH_TYPE,
+                  AuthProperties.AUTH_TYPE_NONE,
+                  "prefix",
+                  "upstream"));
+
+      IcebergConfig config =
+          new IcebergConfig(
+              ImmutableMap.of(
+                  IcebergConstants.CATALOG_BACKEND,
+                  "memory",
+                  IcebergConstants.WAREHOUSE,
+                  "/tmp/warehouse"));
+      CatalogWrapperForREST wrapper = new StaticCatalogWrapperForREST("test", 
config, catalog);
+
+      RegisterTableRequest request =
+          ImmutableRegisterTableRequest.builder()
+              .name("tbl")
+              
.metadataLocation("s3://bucket/warehouse/tbl/metadata/v2.metadata.json")
+              .overwrite(true)
+              .build();
+
+      wrapper.registerTable(namespace, request, false);
+
+      RegisterTableRequest forwarded =
+          IcebergObjectMapper.getInstance()
+              .readValue(requestBody.get(), RegisterTableRequest.class);
+      Assertions.assertTrue(forwarded.overwrite());
+      Assertions.assertEquals(
+          "s3://bucket/warehouse/tbl/metadata/v2.metadata.json", 
forwarded.metadataLocation());
+    } finally {
+      server.stop(0);
+    }
+  }
 }
diff --git 
a/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/iceberg/service/TestIcebergRESTUtils.java
 
b/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/iceberg/service/TestIcebergRESTUtils.java
index 2ad1f85c3d..e3e4f3225d 100644
--- 
a/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/iceberg/service/TestIcebergRESTUtils.java
+++ 
b/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/iceberg/service/TestIcebergRESTUtils.java
@@ -24,6 +24,7 @@ import static org.mockito.Mockito.when;
 import static org.mockito.Mockito.withSettings;
 
 import com.google.common.collect.ImmutableMap;
+import java.util.Collections;
 import java.util.List;
 import java.util.Map;
 import org.apache.gravitino.NameIdentifier;
@@ -36,7 +37,9 @@ import org.apache.gravitino.credential.S3SecretKeyCredential;
 import org.apache.gravitino.credential.S3TokenCredential;
 import 
org.apache.gravitino.iceberg.service.authorization.IcebergRESTServerContext;
 import org.apache.gravitino.iceberg.service.provider.IcebergConfigProvider;
+import org.apache.iceberg.PartitionSpec;
 import org.apache.iceberg.Schema;
+import org.apache.iceberg.SortOrder;
 import org.apache.iceberg.TableMetadata;
 import org.apache.iceberg.catalog.Namespace;
 import org.apache.iceberg.catalog.TableIdentifier;
@@ -47,6 +50,7 @@ import org.apache.iceberg.rest.credentials.Credential;
 import org.apache.iceberg.rest.requests.CreateTableRequest;
 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.types.Types.IntegerType;
 import org.apache.iceberg.types.Types.NestedField;
 import org.apache.iceberg.types.Types.StringType;
@@ -350,4 +354,99 @@ public class TestIcebergRESTUtils {
         "v1/irc1/namespaces/db/tables/tbl/credentials",
         credential.config().get("client.refresh-credentials-endpoint"));
   }
+
+  @Test
+  void testRewriteLoadTableCredentials() {
+    TableIdentifier table = TableIdentifier.of(Namespace.of("db"), "tbl");
+    TableMetadata metadata =
+        TableMetadata.newTableMetadata(
+            new Schema(NestedField.required(1, "id", IntegerType.get())),
+            PartitionSpec.unpartitioned(),
+            SortOrder.unsorted(),
+            "s3://bucket/db/tbl",
+            Collections.emptyMap());
+    LoadTableResponse upstream =
+        LoadTableResponse.builder()
+            .withTableMetadata(metadata)
+            .addAllConfig(
+                ImmutableMap.of(
+                    "io-impl",
+                    "org.apache.iceberg.aws.s3.S3FileIO",
+                    "s3.session-token",
+                    "upstream-token",
+                    "client.refresh-credentials-endpoint",
+                    "v1/upstream/namespaces/db/tables/tbl/credentials"))
+            .addCredential(
+                IcebergRESTUtils.toRESTCredential(
+                    "s3://bucket/db/tbl/",
+                    ImmutableMap.of(
+                        "s3.session-token",
+                        "upstream-token",
+                        "client.refresh-credentials-endpoint",
+                        "v1/upstream/namespaces/db/tables/tbl/credentials")))
+            .build();
+
+    LoadTableResponse rewritten =
+        IcebergRESTUtils.rewriteLoadTableCredentials("irc1", table, upstream);
+
+    Assertions.assertEquals("s3://bucket/db/tbl", 
rewritten.tableMetadata().location());
+    Assertions.assertEquals(
+        "org.apache.iceberg.aws.s3.S3FileIO", 
rewritten.config().get("io-impl"));
+    Assertions.assertEquals("upstream-token", 
rewritten.config().get("s3.session-token"));
+    Assertions.assertEquals(
+        "v1/irc1/namespaces/db/tables/tbl/credentials",
+        rewritten.config().get("client.refresh-credentials-endpoint"));
+    Assertions.assertEquals(1, rewritten.credentials().size());
+    Credential credential = rewritten.credentials().get(0);
+    Assertions.assertEquals("s3://bucket/db/tbl/", credential.prefix());
+    Assertions.assertEquals("upstream-token", 
credential.config().get("s3.session-token"));
+    Assertions.assertEquals(
+        "v1/irc1/namespaces/db/tables/tbl/credentials",
+        credential.config().get("client.refresh-credentials-endpoint"));
+  }
+
+  @Test
+  void testRewriteLoadTableCredentialsDropsConfigRefreshWithoutTokens() {
+    TableIdentifier table = TableIdentifier.of(Namespace.of("db"), "tbl");
+    TableMetadata metadata =
+        TableMetadata.newTableMetadata(
+            new Schema(NestedField.required(1, "id", IntegerType.get())),
+            PartitionSpec.unpartitioned(),
+            SortOrder.unsorted(),
+            "s3://bucket/db/tbl",
+            Collections.emptyMap());
+    // Upstream put the refresh URL in top-level config while the session 
token lives only in
+    // storage-credentials. The config refresh key must be dropped, not left 
pointing upstream.
+    LoadTableResponse upstream =
+        LoadTableResponse.builder()
+            .withTableMetadata(metadata)
+            .addAllConfig(
+                ImmutableMap.of(
+                    "io-impl",
+                    "org.apache.iceberg.aws.s3.S3FileIO",
+                    "client.refresh-credentials-endpoint",
+                    "v1/upstream/namespaces/db/tables/tbl/credentials"))
+            .addCredential(
+                IcebergRESTUtils.toRESTCredential(
+                    "s3://bucket/db/tbl/",
+                    ImmutableMap.of(
+                        "s3.session-token",
+                        "upstream-token",
+                        "client.refresh-credentials-endpoint",
+                        "v1/upstream/namespaces/db/tables/tbl/credentials")))
+            .build();
+
+    LoadTableResponse rewritten =
+        IcebergRESTUtils.rewriteLoadTableCredentials("irc1", table, upstream);
+
+    Assertions.assertEquals(
+        "org.apache.iceberg.aws.s3.S3FileIO", 
rewritten.config().get("io-impl"));
+    Assertions.assertFalse(
+        rewritten.config().containsKey("client.refresh-credentials-endpoint"),
+        "Top-level config must not keep an upstream refresh endpoint when 
tokens are absent");
+    Assertions.assertEquals(1, rewritten.credentials().size());
+    Assertions.assertEquals(
+        "v1/irc1/namespaces/db/tables/tbl/credentials",
+        
rewritten.credentials().get(0).config().get("client.refresh-credentials-endpoint"));
+  }
 }
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..8cb0557837 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
@@ -542,6 +542,11 @@ public class TestIcebergTableOperations extends 
IcebergNamespaceTestBase {
   }
 
   private Response doLoadTableWithSnapshots(Namespace ns, String name, String 
snapshots) {
+    return doLoadTableWithSnapshots(ns, name, snapshots, false);
+  }
+
+  private Response doLoadTableWithSnapshots(
+      Namespace ns, String name, String snapshots, boolean credentialVending) {
     String path =
         IcebergRestTestUtil.NAMESPACE_PATH
             + "/"
@@ -549,7 +554,12 @@ public class TestIcebergTableOperations extends 
IcebergNamespaceTestBase {
             + "/tables/"
             + name;
     Map<String, String> queryParams = ImmutableMap.of("snapshots", snapshots);
-    return getIcebergClientBuilder(path, Optional.of(queryParams)).get();
+    Invocation.Builder builder = getIcebergClientBuilder(path, 
Optional.of(queryParams));
+    if (credentialVending) {
+      builder =
+          builder.header(IcebergTableOperations.X_ICEBERG_ACCESS_DELEGATION, 
"vended-credentials");
+    }
+    return builder.get();
   }
 
   private Response doPlanTableScan(Namespace ns, String tableName, 
PlanTableScanRequest request) {
@@ -1083,6 +1093,73 @@ public class TestIcebergTableOperations extends 
IcebergNamespaceTestBase {
         "Refs should be preserved in filtered response");
   }
 
+  @ParameterizedTest
+  
@MethodSource("org.apache.gravitino.iceberg.service.rest.IcebergRestTestUtil#testNamespaces")
+  void testLoadTableSnapshotsRefsKeepsCredentials(Namespace namespace) {
+    verifyCreateNamespaceSucc(namespace);
+    String tableName = "snapshots_refs_creds";
+    CreateTableRequest createTableRequest =
+        CreateTableRequest.builder()
+            .withName(tableName)
+            .withSchema(tableSchema)
+            .withLocation("s3://bucket/" + tableName)
+            .setProperties(
+                ImmutableMap.of(
+                    CatalogWrapperForTest.GENERATE_PLAN_TASKS_DATA_PROP, 
Boolean.TRUE.toString()))
+            .build();
+    Response createResponse =
+        getTableClientBuilder(namespace, Optional.empty())
+            .header(IcebergTableOperations.X_ICEBERG_ACCESS_DELEGATION, 
"vended-credentials")
+            .post(Entity.entity(createTableRequest, 
MediaType.APPLICATION_JSON_TYPE));
+    Assertions.assertEquals(Status.OK.getStatusCode(), 
createResponse.getStatus());
+
+    Response refsResponse = doLoadTableWithSnapshots(namespace, tableName, 
"refs", true);
+    Assertions.assertEquals(Status.OK.getStatusCode(), 
refsResponse.getStatus());
+    LoadTableResponse refsTableResponse = 
refsResponse.readEntity(LoadTableResponse.class);
+
+    Assertions.assertTrue(
+        refsTableResponse.tableMetadata().snapshots().size() >= 1,
+        "Filtered response should keep at least the current ref snapshot");
+    Assertions.assertEquals(
+        DummyCredentialProvider.DUMMY_CREDENTIAL_TYPE,
+        refsTableResponse.config().get(Credential.CREDENTIAL_TYPE),
+        "snapshots=refs must keep vended credentials after filtering");
+    Assertions.assertFalse(
+        refsTableResponse.credentials().isEmpty(),
+        "snapshots=refs must keep storage-credentials after filtering");
+  }
+
+  @Test
+  void testFilterSnapshotsByRefsKeepsCredentials() {
+    TableMetadata metadata =
+        TableMetadata.newTableMetadata(
+            tableSchema,
+            org.apache.iceberg.PartitionSpec.unpartitioned(),
+            "s3://bucket/db/tbl",
+            ImmutableMap.of());
+    org.apache.iceberg.rest.credentials.Credential credential =
+        IcebergRESTUtils.toRESTCredential(
+            "s3://bucket/db/tbl/",
+            ImmutableMap.of(
+                "s3.session-token",
+                "token",
+                "client.refresh-credentials-endpoint",
+                "v1/c/ns/t/credentials"));
+    LoadTableResponse original =
+        LoadTableResponse.builder()
+            .withTableMetadata(metadata)
+            .addAllConfig(ImmutableMap.of("io-impl", 
"org.apache.iceberg.aws.s3.S3FileIO"))
+            .addCredential(credential)
+            .build();
+
+    LoadTableResponse filtered = 
IcebergTableOperations.filterSnapshotsByRefs(original);
+
+    Assertions.assertEquals(1, filtered.credentials().size());
+    Assertions.assertEquals(
+        "token", 
filtered.credentials().get(0).config().get("s3.session-token"));
+    Assertions.assertEquals("org.apache.iceberg.aws.s3.S3FileIO", 
filtered.config().get("io-impl"));
+  }
+
   @ParameterizedTest
   
@MethodSource("org.apache.gravitino.iceberg.service.rest.IcebergRestTestUtil#testNamespaces")
   void testLoadTableSnapshotsAllReturnsAllSnapshots(Namespace namespace) {

Reply via email to