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

difin pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/hive.git


The following commit(s) were added to refs/heads/master by this push:
     new abf94a59937 HIVE-29359: Support Credential Vending in Hive Iceberg 
REST Catalog Client (#6474)
abf94a59937 is described below

commit abf94a5993763523f5249df69dc7ef5ca35bff89
Author: Dmitriy Fingerman <[email protected]>
AuthorDate: Wed Jul 15 18:48:05 2026 -0400

    HIVE-29359: Support Credential Vending in Hive Iceberg REST Catalog Client 
(#6474)
    
    * HIVE-29359: Support S3 credential vending in Hive Iceberg REST catalog 
client.
    
    ---------
    
    Co-authored-by: Dmitriy Fingerman <[email protected]>
    Co-authored-by: Dmitriy Fingerman <[email protected]>
---
 .../java/org/apache/hadoop/hive/conf/HiveConf.java |   5 +
 .../iceberg/hive/IcebergCatalogProperties.java     |  15 +
 .../hive/client/RestAccessDelegationMode.java      |  80 ++
 .../hive/client/TestRestAccessDelegationMode.java  |  75 ++
 iceberg/iceberg-handler/pom.xml                    |   8 +
 .../main/java/org/apache/iceberg/mr/Catalogs.java  |  15 +-
 .../org/apache/iceberg/mr/InputFormatConfig.java   |  12 +
 .../mr/hive/HiveIcebergOutputCommitter.java        |   8 +-
 .../iceberg/mr/hive/HiveIcebergStorageHandler.java | 100 ++-
 .../org/apache/iceberg/mr/hive/HiveTableUtil.java  |   7 +-
 .../mr/hive/IcebergVendedCredentialUtil.java       | 499 ++++++++++++
 .../iceberg/mr/hive/writer/HiveIcebergWriter.java  |   3 +
 .../mr/hive/writer/HiveIcebergWriterBase.java      |   5 +
 .../iceberg/mr/mapreduce/IcebergInputFormat.java   |   2 +
 .../mr/hive/TestIcebergVendedCredentialUtil.java   | 885 +++++++++++++++++++++
 .../positive/iceberg_rest_catalog_gravitino.q      |  14 +-
 .../llap/iceberg_rest_catalog_gravitino.q.out      |  14 +-
 itests/qtest-iceberg/pom.xml                       |  26 +
 ...bergRESTCatalogGravitinoLlapLocalCliDriver.java | 257 +++---
 .../gravitino-s3-vended-oauth-template.conf        |  37 +
 itests/util/pom.xml                                |   4 +
 .../hadoop/hive/cli/OzoneS3GatewayContainers.java  | 203 +++++
 .../apache/hadoop/hive/ql/exec/FetchOperator.java  |   7 +
 .../hadoop/hive/ql/exec/FileSinkOperator.java      |  10 +
 .../org/apache/hadoop/hive/ql/exec/Utilities.java  |   8 +-
 25 files changed, 2126 insertions(+), 173 deletions(-)

diff --git a/common/src/java/org/apache/hadoop/hive/conf/HiveConf.java 
b/common/src/java/org/apache/hadoop/hive/conf/HiveConf.java
index b10509b85f1..65ad8a82bbe 100644
--- a/common/src/java/org/apache/hadoop/hive/conf/HiveConf.java
+++ b/common/src/java/org/apache/hadoop/hive/conf/HiveConf.java
@@ -5600,6 +5600,11 @@ public static enum ConfVars {
         + ",fs.s3a.access.key"
         + ",fs.s3a.secret.key"
         + ",fs.s3a.proxy.password"
+        + ",iceberg.vended.storage.credentials"
+        // Iceberg FileIO vended credential keys (S3FileIOProperties)
+        + ",s3.access-key-id"
+        + ",s3.secret-access-key"
+        + ",s3.session-token"
         + ",dfs.adls.oauth2.credential"
         + ",fs.adl.oauth2.credential"
         + ",fs.azure.account.oauth2.client.secret"
diff --git 
a/iceberg/iceberg-catalog/src/main/java/org/apache/iceberg/hive/IcebergCatalogProperties.java
 
b/iceberg/iceberg-catalog/src/main/java/org/apache/iceberg/hive/IcebergCatalogProperties.java
index 424f8e10c35..6d7f047c05d 100644
--- 
a/iceberg/iceberg-catalog/src/main/java/org/apache/iceberg/hive/IcebergCatalogProperties.java
+++ 
b/iceberg/iceberg-catalog/src/main/java/org/apache/iceberg/hive/IcebergCatalogProperties.java
@@ -27,6 +27,7 @@
 import org.apache.hadoop.hive.metastore.conf.MetastoreConf;
 import org.apache.iceberg.CatalogProperties;
 import org.apache.iceberg.CatalogUtil;
+import org.apache.iceberg.hive.client.RestAccessDelegationMode;
 import org.apache.iceberg.relocated.com.google.common.collect.Maps;
 
 public class IcebergCatalogProperties {
@@ -38,6 +39,7 @@ public class IcebergCatalogProperties {
   public static final String ICEBERG_HADOOP_TABLE_NAME = 
"location_based_table";
   public static final String ICEBERG_DEFAULT_CATALOG_NAME = "default_iceberg";
   public static final String NO_CATALOG_TYPE = "no catalog";
+  public static final String REST_ACCESS_DELEGATION_HEADER_PROPERTY = 
"header.X-Iceberg-Access-Delegation";
 
   private IcebergCatalogProperties() {
 
@@ -104,6 +106,19 @@ public static String catalogPropertyConfigKey(String 
catalogName, String catalog
     return String.format("%s%s.%s", CATALOG_CONFIG_PREFIX, catalogName, 
catalogProperty);
   }
 
+  /**
+   * Returns true when the catalog is configured to request REST vended 
storage credentials via
+   * {@link #REST_ACCESS_DELEGATION_HEADER_PROPERTY}.
+   */
+  public static boolean requestsVendedCredentials(String catalogName, 
Configuration conf) {
+    if (conf == null || StringUtils.isEmpty(catalogName)) {
+      return false;
+    }
+    String headerValue =
+        conf.get(catalogPropertyConfigKey(catalogName, 
REST_ACCESS_DELEGATION_HEADER_PROPERTY));
+    return 
RestAccessDelegationMode.headerRequestsVendedCredentials(headerValue);
+  }
+
   /**
    * Return the catalog type based on the catalog name.
    * <p>
diff --git 
a/iceberg/iceberg-catalog/src/main/java/org/apache/iceberg/hive/client/RestAccessDelegationMode.java
 
b/iceberg/iceberg-catalog/src/main/java/org/apache/iceberg/hive/client/RestAccessDelegationMode.java
new file mode 100644
index 00000000000..dd2d435a9b0
--- /dev/null
+++ 
b/iceberg/iceberg-catalog/src/main/java/org/apache/iceberg/hive/client/RestAccessDelegationMode.java
@@ -0,0 +1,80 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.apache.iceberg.hive.client;
+
+import java.util.Arrays;
+import java.util.stream.Collectors;
+import org.apache.commons.lang3.StringUtils;
+import org.apache.iceberg.hive.IcebergCatalogProperties;
+
+/**
+ * Values for the Iceberg REST catalog {@code X-Iceberg-Access-Delegation} 
request header. The header
+ * accepts a comma-separated list of these modes; configure via
+ * {@link IcebergCatalogProperties#REST_ACCESS_DELEGATION_HEADER_PROPERTY}.
+ *
+ * @see <a 
href="https://github.com/apache/iceberg/blob/main/open-api/rest-catalog-open-api.yaml";>REST
 catalog spec</a>
+ */
+public enum RestAccessDelegationMode {
+  VENDED_CREDENTIALS("vended-credentials"),
+  REMOTE_SIGNING("remote-signing");
+
+  private final String modeName;
+
+  RestAccessDelegationMode(String modeName) {
+    this.modeName = modeName;
+  }
+
+  /** Spec-defined header token for this delegation mode. */
+  public String modeName() {
+    return modeName;
+  }
+
+  /** Comma-separated list suitable for {@link 
IcebergCatalogProperties#REST_ACCESS_DELEGATION_HEADER_PROPERTY}. */
+  public static String toHeaderValue(RestAccessDelegationMode... modes) {
+    return 
Arrays.stream(modes).map(RestAccessDelegationMode::modeName).collect(Collectors.joining(","));
+  }
+
+  /** Parses a single mode name (case-insensitive); throws if unknown. */
+  public static RestAccessDelegationMode fromModeName(String modeName) {
+    for (RestAccessDelegationMode mode : values()) {
+      if (mode.modeName.equalsIgnoreCase(modeName.trim())) {
+        return mode;
+      }
+    }
+    throw new IllegalArgumentException(
+        String.format(
+            "Unknown REST access delegation mode: %s. Valid values are: %s",
+            modeName,
+            
Arrays.stream(values()).map(RestAccessDelegationMode::modeName).collect(Collectors.joining(",
 "))));
+  }
+
+  /** Returns true if the {@code X-Iceberg-Access-Delegation} header value 
includes vended credentials. */
+  public static boolean headerRequestsVendedCredentials(String headerValue) {
+    if (StringUtils.isBlank(headerValue)) {
+      return false;
+    }
+    for (String token : headerValue.split(",")) {
+      if (VENDED_CREDENTIALS.modeName.equalsIgnoreCase(token.trim())) {
+        return true;
+      }
+    }
+    return false;
+  }
+}
diff --git 
a/iceberg/iceberg-catalog/src/test/java/org/apache/iceberg/hive/client/TestRestAccessDelegationMode.java
 
b/iceberg/iceberg-catalog/src/test/java/org/apache/iceberg/hive/client/TestRestAccessDelegationMode.java
new file mode 100644
index 00000000000..dce5106b5cf
--- /dev/null
+++ 
b/iceberg/iceberg-catalog/src/test/java/org/apache/iceberg/hive/client/TestRestAccessDelegationMode.java
@@ -0,0 +1,75 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.apache.iceberg.hive.client;
+
+import org.apache.iceberg.hive.IcebergCatalogProperties;
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+class TestRestAccessDelegationMode {
+
+  @Test
+  void vendedCredentialsModeName() {
+    
assertThat(RestAccessDelegationMode.VENDED_CREDENTIALS.modeName()).isEqualTo("vended-credentials");
+  }
+
+  @Test
+  void toHeaderValueJoinsModes() {
+    assertThat(
+            RestAccessDelegationMode.toHeaderValue(
+                RestAccessDelegationMode.VENDED_CREDENTIALS, 
RestAccessDelegationMode.REMOTE_SIGNING))
+        .isEqualTo("vended-credentials,remote-signing");
+  }
+
+  @Test
+  void fromModeNameParsesCaseInsensitive() {
+    assertThat(RestAccessDelegationMode.fromModeName("VENDED-CREDENTIALS"))
+        .isEqualTo(RestAccessDelegationMode.VENDED_CREDENTIALS);
+  }
+
+  @Test
+  void fromModeNameRejectsUnknown() {
+    assertThatThrownBy(() -> RestAccessDelegationMode.fromModeName("unknown"))
+        .isInstanceOf(IllegalArgumentException.class)
+        .hasMessageContaining("unknown");
+  }
+
+  @Test
+  void headerRequestsVendedCredentials() {
+    
assertThat(RestAccessDelegationMode.headerRequestsVendedCredentials(null)).isFalse();
+    
assertThat(RestAccessDelegationMode.headerRequestsVendedCredentials("remote-signing")).isFalse();
+    
assertThat(RestAccessDelegationMode.headerRequestsVendedCredentials("vended-credentials")).isTrue();
+    
assertThat(RestAccessDelegationMode.headerRequestsVendedCredentials("VENDED-CREDENTIALS,remote-signing"))
+        .isTrue();
+  }
+
+  @Test
+  void requestsVendedCredentialsFromConfiguration() {
+    org.apache.hadoop.conf.Configuration conf = new 
org.apache.hadoop.conf.Configuration();
+    assertThat(IcebergCatalogProperties.requestsVendedCredentials("ice01", 
conf)).isFalse();
+
+    conf.set(
+        "iceberg.catalog.ice01.header.X-Iceberg-Access-Delegation",
+        RestAccessDelegationMode.VENDED_CREDENTIALS.modeName());
+    assertThat(IcebergCatalogProperties.requestsVendedCredentials("ice01", 
conf)).isTrue();
+  }
+}
diff --git a/iceberg/iceberg-handler/pom.xml b/iceberg/iceberg-handler/pom.xml
index ef20ab1aab4..f00415ecbe2 100644
--- a/iceberg/iceberg-handler/pom.xml
+++ b/iceberg/iceberg-handler/pom.xml
@@ -34,6 +34,14 @@
       <artifactId>hive-iceberg-catalog</artifactId>
       <optional>true</optional>
     </dependency>
+    <dependency>
+      <!-- compile-time only, for S3FileIOProperties/AwsClientProperties 
string constants
+           (inlined by javac); the classes are not referenced at runtime -->
+      <groupId>org.apache.iceberg</groupId>
+      <artifactId>iceberg-aws</artifactId>
+      <version>${iceberg.version}</version>
+      <scope>provided</scope>
+    </dependency>
     <dependency>
       <groupId>org.apache.hadoop</groupId>
       <artifactId>hadoop-client</artifactId>
diff --git 
a/iceberg/iceberg-handler/src/main/java/org/apache/iceberg/mr/Catalogs.java 
b/iceberg/iceberg-handler/src/main/java/org/apache/iceberg/mr/Catalogs.java
index 9a416f8277a..7b6f0810461 100644
--- a/iceberg/iceberg-handler/src/main/java/org/apache/iceberg/mr/Catalogs.java
+++ b/iceberg/iceberg-handler/src/main/java/org/apache/iceberg/mr/Catalogs.java
@@ -229,16 +229,27 @@ public static void renameTable(Configuration conf, 
Properties props, TableIdenti
   }
 
   static Optional<Catalog> loadCatalog(Configuration conf, String catalogName) 
{
-    String catalogType = IcebergCatalogProperties.getCatalogType(conf, 
catalogName);
+    String resolvedName = catalogName != null ? catalogName : 
configuredDefaultCatalogName(conf);
+    String catalogType = IcebergCatalogProperties.getCatalogType(conf, 
resolvedName);
     if (NO_CATALOG_TYPE.equalsIgnoreCase(catalogType)) {
       return Optional.empty();
     } else {
-      String name = catalogName == null ? ICEBERG_DEFAULT_CATALOG_NAME : 
catalogName;
+      String name = resolvedName == null ? ICEBERG_DEFAULT_CATALOG_NAME : 
resolvedName;
       return Optional.of(CatalogUtil.buildIcebergCatalog(name,
           IcebergCatalogProperties.getCatalogProperties(conf, name), conf));
     }
   }
 
+  /**
+   * Tables created without an explicit catalog in their properties belong to 
the session default
+   * catalog when the configuration defines one (e.g. a REST catalog); returns 
{@code null}
+   * otherwise, preserving the legacy default-catalog behavior.
+   */
+  private static String configuredDefaultCatalogName(Configuration conf) {
+    String defaultCatalogName = IcebergCatalogProperties.getCatalogName(conf);
+    return IcebergCatalogProperties.getCatalogType(conf, defaultCatalogName) 
!= null ? defaultCatalogName : null;
+  }
+
   /**
    * Parse the table schema from the properties
    * @param props the controlling properties
diff --git 
a/iceberg/iceberg-handler/src/main/java/org/apache/iceberg/mr/InputFormatConfig.java
 
b/iceberg/iceberg-handler/src/main/java/org/apache/iceberg/mr/InputFormatConfig.java
index 34ba9f2404c..46c281ce0f0 100644
--- 
a/iceberg/iceberg-handler/src/main/java/org/apache/iceberg/mr/InputFormatConfig.java
+++ 
b/iceberg/iceberg-handler/src/main/java/org/apache/iceberg/mr/InputFormatConfig.java
@@ -80,6 +80,18 @@ private InputFormatConfig() {
 
   public static final String CATALOG_CONFIG_PREFIX = "iceberg.catalog.";
 
+  /**
+   * Base64-serialized list of {@link org.apache.iceberg.io.StorageCredential} 
for Tez/LLAP executors.
+   * Stored in {@code TableDesc#jobSecrets} (HIVE-20651), not in job 
properties. REST catalogs mint
+   * credentials per table, so entries are keyed via {@link 
#vendedCredentialsKey(String)}; this
+   * constant is the key prefix (and the {@code hive.conf.hidden.list} entry, 
matched by prefix).
+   */
+  public static final String VENDED_STORAGE_CREDENTIALS = 
"iceberg.vended.storage.credentials";
+
+  public static String vendedCredentialsKey(String tableName) {
+    return VENDED_STORAGE_CREDENTIALS + "." + tableName;
+  }
+
   public static final String SORT_ORDER = "sort.order";
   public static final String SORT_COLUMNS = "sort.columns";
   public static final String ZORDER = "ZORDER";
diff --git 
a/iceberg/iceberg-handler/src/main/java/org/apache/iceberg/mr/hive/HiveIcebergOutputCommitter.java
 
b/iceberg/iceberg-handler/src/main/java/org/apache/iceberg/mr/hive/HiveIcebergOutputCommitter.java
index 9fabe10488b..2a870137908 100644
--- 
a/iceberg/iceberg-handler/src/main/java/org/apache/iceberg/mr/hive/HiveIcebergOutputCommitter.java
+++ 
b/iceberg/iceberg-handler/src/main/java/org/apache/iceberg/mr/hive/HiveIcebergOutputCommitter.java
@@ -175,9 +175,12 @@ public void commitTask(TaskAttemptContext originalContext) 
throws IOException {
                   referencedDataFiles.addAll(files.referencedDataFiles());
                   rewrittenDeleteFiles.addAll(files.rewrittenDeleteFiles());
                 }
+                // the writers' FileIO carries the table storage credentials; 
the table deserialized
+                // from the committer conf does not, since that conf is built 
at DAG time (secret-free)
+                FileIO io = writers.get(output).get(0).io();
                 createFileForCommit(
                     new FilesForCommit(dataFiles, deleteFiles, 
replacedDataFiles, referencedDataFiles,
-                        rewrittenDeleteFiles, mergedPaths), 
fileForCommitLocation, table.io());
+                        rewrittenDeleteFiles, mergedPaths), 
fileForCommitLocation, io);
               } else {
                 LOG.info("CommitTask found no writer for specific table: {}, 
attemptID: {}", output, attemptID);
                 createFileForCommit(FilesForCommit.empty(), 
fileForCommitLocation, table.io());
@@ -313,6 +316,9 @@ private static Multimap<OutputTable, JobContext> 
collectOutputs(List<JobContext>
             .orElseGet(() -> 
HiveTableUtil.deserializeTable(jobContext.getJobConf(), output));
         if (table != null) {
           String catalogName = catalogName(jobContext.getJobConf(), output);
+          // pass the per-table catalog name: the job-level CATALOG_NAME is 
absent in the DAG conf,
+          // and without it the session endpoint override is not applied to 
the commit-side FileIO
+          IcebergVendedCredentialUtil.applyFromJobConf(table, catalogName, 
jobContext.getJobConf());
           outputs.put(new OutputTable(catalogName, output, table), jobContext);
         } else {
           LOG.info("Found no table object in QueryState or conf for: {}. 
Skipping job commit.", output);
diff --git 
a/iceberg/iceberg-handler/src/main/java/org/apache/iceberg/mr/hive/HiveIcebergStorageHandler.java
 
b/iceberg/iceberg-handler/src/main/java/org/apache/iceberg/mr/hive/HiveIcebergStorageHandler.java
index 0fa610e6f3d..2875890efdc 100644
--- 
a/iceberg/iceberg-handler/src/main/java/org/apache/iceberg/mr/hive/HiveIcebergStorageHandler.java
+++ 
b/iceberg/iceberg-handler/src/main/java/org/apache/iceberg/mr/hive/HiveIcebergStorageHandler.java
@@ -284,6 +284,21 @@ public HiveAuthorizationProvider 
getAuthorizationProvider() {
     return null;
   }
 
+  @Override
+  public void configureInputJobCredentials(TableDesc tableDesc, Map<String, 
String> secrets) {
+    if 
(!IcebergVendedCredentialUtil.requestsVendedCredentials(tableDesc.getProperties(),
 conf)) {
+      return;
+    }
+    try {
+      Table table =
+          
IcebergVendedCredentialUtil.getTableWithVendedCredentials(tableDesc.getProperties(),
 conf);
+      String catalogName = 
tableDesc.getProperties().getProperty(InputFormatConfig.CATALOG_NAME);
+      IcebergVendedCredentialUtil.propagateToJob(table, catalogName, null, 
secrets, conf);
+    } catch (NoSuchTableException ex) {
+      // Table may not exist yet for CTAS; credentials will not be available.
+    }
+  }
+
   @Override
   public void configureInputJobProperties(TableDesc tableDesc, Map<String, 
String> map) {
     overlayTableProperties(conf, tableDesc, map);
@@ -335,32 +350,9 @@ public void commitJob(JobContext originalContext) {
   @Override
   public void configureJobConf(TableDesc tableDesc, JobConf jobConf) {
     setCommonJobConf(jobConf);
-    if (tableDesc != null && tableDesc.getProperties() != null &&
-        tableDesc.getProperties().get(InputFormatConfig.OPERATION_TYPE_PREFIX 
+ tableDesc.getTableName()) != null) {
-      String tableName = tableDesc.getTableName();
-      String opKey = InputFormatConfig.OPERATION_TYPE_PREFIX + tableName;
-      // set operation type into job conf too
-      jobConf.set(opKey, tableDesc.getProperties().getProperty(opKey));
-      Preconditions.checkArgument(!tableName.contains(TABLE_NAME_SEPARATOR),
-          "Can not handle table " + tableName + ". Its name contains '" + 
TABLE_NAME_SEPARATOR + "'");
-      if 
(HiveCustomStorageHandlerUtils.getWriteOperation(tableDesc.getProperties()::getProperty,
 tableName) != null) {
-        HiveCustomStorageHandlerUtils.setWriteOperation(jobConf, tableName,
-            Operation.valueOf(tableDesc.getProperties().getProperty(
-                HiveCustomStorageHandlerUtils.WRITE_OPERATION_CONFIG_PREFIX + 
tableName)));
-      }
-      boolean isMergeTaskEnabled = Boolean.parseBoolean(tableDesc.getProperty(
-          HiveCustomStorageHandlerUtils.MERGE_TASK_ENABLED + tableName));
-      if (isMergeTaskEnabled) {
-        HiveCustomStorageHandlerUtils.setMergeTaskEnabled(jobConf, tableName, 
true);
-      }
-      String tables = jobConf.get(InputFormatConfig.OUTPUT_TABLES);
-      tables = (tables == null) ? tableName : tables + TABLE_NAME_SEPARATOR + 
tableName;
-      jobConf.set(InputFormatConfig.OUTPUT_TABLES, tables);
-
-      String catalogName = 
tableDesc.getProperties().getProperty(InputFormatConfig.CATALOG_NAME);
-      if (catalogName != null) {
-        jobConf.set(InputFormatConfig.TABLE_CATALOG_PREFIX + tableName, 
catalogName);
-      }
+    configureOutputTableJobConf(tableDesc, jobConf);
+    if 
(IcebergVendedCredentialUtil.requestsVendedCredentials(tableDesc.getProperties(),
 conf)) {
+      IcebergVendedCredentialUtil.refreshVendedCredentialsIfMissing(tableDesc, 
jobConf, conf);
     }
     try {
       if (!jobConf.getBoolean(ConfVars.HIVE_IN_TEST_IDE.varname, false)) {
@@ -373,6 +365,37 @@ public void configureJobConf(TableDesc tableDesc, JobConf 
jobConf) {
     }
   }
 
+  private static void configureOutputTableJobConf(TableDesc tableDesc, JobConf 
jobConf) {
+    if (tableDesc == null || tableDesc.getProperties() == null ||
+        tableDesc.getProperties().get(InputFormatConfig.OPERATION_TYPE_PREFIX 
+ tableDesc.getTableName()) == null) {
+      return;
+    }
+    String tableName = tableDesc.getTableName();
+    String opKey = InputFormatConfig.OPERATION_TYPE_PREFIX + tableName;
+    // set operation type into job conf too
+    jobConf.set(opKey, tableDesc.getProperties().getProperty(opKey));
+    Preconditions.checkArgument(!tableName.contains(TABLE_NAME_SEPARATOR),
+        "Can not handle table " + tableName + ". Its name contains '" + 
TABLE_NAME_SEPARATOR + "'");
+    if 
(HiveCustomStorageHandlerUtils.getWriteOperation(tableDesc.getProperties()::getProperty,
 tableName) != null) {
+      HiveCustomStorageHandlerUtils.setWriteOperation(jobConf, tableName,
+          Operation.valueOf(tableDesc.getProperties().getProperty(
+              HiveCustomStorageHandlerUtils.WRITE_OPERATION_CONFIG_PREFIX + 
tableName)));
+    }
+    boolean isMergeTaskEnabled = Boolean.parseBoolean(tableDesc.getProperty(
+        HiveCustomStorageHandlerUtils.MERGE_TASK_ENABLED + tableName));
+    if (isMergeTaskEnabled) {
+      HiveCustomStorageHandlerUtils.setMergeTaskEnabled(jobConf, tableName, 
true);
+    }
+    String tables = jobConf.get(InputFormatConfig.OUTPUT_TABLES);
+    tables = (tables == null) ? tableName : tables + TABLE_NAME_SEPARATOR + 
tableName;
+    jobConf.set(InputFormatConfig.OUTPUT_TABLES, tables);
+
+    String catalogName = 
tableDesc.getProperties().getProperty(InputFormatConfig.CATALOG_NAME);
+    if (catalogName != null) {
+      jobConf.set(InputFormatConfig.TABLE_CATALOG_PREFIX + tableName, 
catalogName);
+    }
+  }
+
   @Override
   public boolean directInsert() {
     return true;
@@ -1681,13 +1704,12 @@ static void overlayTableProperties(Configuration 
configuration, TableDesc tableD
     PartitionSpec spec;
     String bytes;
     try {
-      Table table = IcebergTableUtil.getTable(configuration, props);
+      boolean isVendedCredentials =
+          IcebergVendedCredentialUtil.requestsVendedCredentials(props, 
configuration);
+      Table table = isVendedCredentials ?
+          IcebergVendedCredentialUtil.getTableWithVendedCredentials(props, 
configuration) :
+          IcebergTableUtil.getTable(configuration, props);
       location = table.location();
-      // set table format-version and write-mode information from tableDesc
-      bytes = HiveTableUtil.serializeTable(table, configuration, props,
-          ImmutableList.of(
-              TableProperties.FORMAT_VERSION,
-              TableProperties.DELETE_MODE, TableProperties.UPDATE_MODE, 
TableProperties.MERGE_MODE));
       schema = table.schema();
       spec = table.spec();
 
@@ -1704,6 +1726,20 @@ static void overlayTableProperties(Configuration 
configuration, TableDesc tableD
         }
       }
 
+      if (isVendedCredentials) {
+        String catalogName = props.getProperty(InputFormatConfig.CATALOG_NAME);
+        IcebergVendedCredentialUtil.propagateToJob(table, catalogName, map, 
null, configuration);
+        // Serializing the table as-is would embed its FileIO, and with it the 
vended credentials,
+        // into plain job properties: ship a copy over a secret-free FileIO 
instead; executors
+        // restore the credentials from the Credentials channel 
(setCredentials).
+        table = IcebergVendedCredentialUtil.secretFreeCopy(table, 
configuration);
+      }
+      // set table format-version and write-mode information from tableDesc
+      bytes = HiveTableUtil.serializeTable(table, configuration, props,
+          ImmutableList.of(
+              TableProperties.FORMAT_VERSION,
+              TableProperties.DELETE_MODE, TableProperties.UPDATE_MODE, 
TableProperties.MERGE_MODE));
+
     } catch (NoSuchTableException ex) {
       if (!HiveTableUtil.isCtas(props)) {
         throw ex;
diff --git 
a/iceberg/iceberg-handler/src/main/java/org/apache/iceberg/mr/hive/HiveTableUtil.java
 
b/iceberg/iceberg-handler/src/main/java/org/apache/iceberg/mr/hive/HiveTableUtil.java
index a129266f6db..94e7217cefc 100644
--- 
a/iceberg/iceberg-handler/src/main/java/org/apache/iceberg/mr/hive/HiveTableUtil.java
+++ 
b/iceberg/iceberg-handler/src/main/java/org/apache/iceberg/mr/hive/HiveTableUtil.java
@@ -246,6 +246,7 @@ public static Table deserializeTable(Configuration config, 
String name) {
       table = readTableObjectFromFile(location, config);
     }
     checkAndSetIoConfig(config, table);
+    IcebergVendedCredentialUtil.applyFromJobConf(table, config);
 
     // For intra-txn read-after-write: if a metadata file was written for 
uncommitted in-txn state,
     // reconstruct a BaseTable from it so the Tez side sees changes from prior 
statements.
@@ -362,8 +363,10 @@ static void cleanupTableObjectFile(String location, 
Configuration configuration)
     try {
       FileSystem fs = Util.getFs(toDelete, configuration);
       fs.delete(toDelete, true);
-    } catch (IOException ex) {
-      throw new UncheckedIOException(ex);
+    } catch (IOException e) {
+      // best effort: never fail the commit over the temp table-object file; 
with catalog-vended
+      // credentials the Hadoop filesystem has no keys and the query-scoped 
file is left behind
+      LOG.warn("Could not remove temp table object file: {}", filePath, e);
     }
   }
 
diff --git 
a/iceberg/iceberg-handler/src/main/java/org/apache/iceberg/mr/hive/IcebergVendedCredentialUtil.java
 
b/iceberg/iceberg-handler/src/main/java/org/apache/iceberg/mr/hive/IcebergVendedCredentialUtil.java
new file mode 100644
index 00000000000..8d110f40f70
--- /dev/null
+++ 
b/iceberg/iceberg-handler/src/main/java/org/apache/iceberg/mr/hive/IcebergVendedCredentialUtil.java
@@ -0,0 +1,499 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.apache.iceberg.mr.hive;
+
+import java.util.Base64;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Properties;
+import org.apache.commons.lang3.StringUtils;
+import org.apache.hadoop.conf.Configuration;
+import org.apache.hadoop.hive.conf.HiveConfUtil;
+import org.apache.hadoop.hive.ql.plan.TableDesc;
+import org.apache.hadoop.mapred.JobConf;
+import org.apache.iceberg.BaseMetadataTable;
+import org.apache.iceberg.BaseTable;
+import org.apache.iceberg.CatalogUtil;
+import org.apache.iceberg.HasTableOperations;
+import org.apache.iceberg.MetadataTableType;
+import org.apache.iceberg.MetadataTableUtils;
+import org.apache.iceberg.StaticTableOperations;
+import org.apache.iceberg.Table;
+import org.apache.iceberg.TableMetadata;
+import org.apache.iceberg.aws.AwsClientProperties;
+import org.apache.iceberg.aws.s3.S3FileIOProperties;
+import org.apache.iceberg.exceptions.NoSuchTableException;
+import org.apache.iceberg.hive.IcebergCatalogProperties;
+import org.apache.iceberg.io.FileIO;
+import org.apache.iceberg.io.StorageCredential;
+import org.apache.iceberg.io.SupportsStorageCredentials;
+import org.apache.iceberg.mr.InputFormatConfig;
+import org.apache.iceberg.relocated.com.google.common.base.Preconditions;
+import org.apache.iceberg.relocated.com.google.common.collect.Lists;
+import org.apache.iceberg.util.SerializationUtil;
+
+/**
+ * Propagates vended storage credentials from an Iceberg {@link Table}'s 
{@link FileIO} to Hive job
+ * configuration so Tez/LLAP executors can access object storage without 
static catalog keys.
+ *
+ * <p>Limitation: credentials are minted at compile/job launch and are not 
refreshed — work that
+ * outlives the vended token lifetime fails authentication.
+ */
+public final class IcebergVendedCredentialUtil {
+
+  private IcebergVendedCredentialUtil() {
+  }
+
+  /**
+   * Copies vended credentials from the table FileIO into Hive job 
configuration.
+   *
+   * <p>Follows the HIVE-20651 split used by {@code JdbcStorageHandler}: 
sensitive values (including
+   * the serialized {@link StorageCredential} list) go to {@code jobSecrets}; 
non-secret config such
+   * as endpoint and path-style access go to {@code jobProperties}.
+   *
+   * @param table loaded Iceberg table; its catalog-qualified name keys the 
credentials blob,
+   *        since REST catalogs mint credentials per table (the table pointer 
carries the same
+   *        name, so executors resolve the matching key)
+   * @param catalogName Hive catalog name ({@link 
InputFormatConfig#CATALOG_NAME})
+   * @param jobProperties Tez/MR non-secret job properties; may be {@code null}
+   * @param jobSecrets sensitive keys and serialized credentials; may be 
{@code null}
+   * @param conf session conf used to preserve host-side endpoint overrides
+   */
+  public static void propagateToJob(Table table, String catalogName, 
Map<String, String> jobProperties,
+      Map<String, String> jobSecrets, Configuration conf) {
+
+    List<StorageCredential> credentials =
+        withConfigurationOverrides(catalogName, extractCredentials(table), 
conf);
+
+    if (credentials.isEmpty()) {
+      return;
+    }
+
+    if (jobSecrets != null) {
+      jobSecrets.put(
+          InputFormatConfig.vendedCredentialsKey(table.name()),
+          serializeToSingleLineBase64(Lists.newArrayList(credentials)));
+    }
+
+    for (StorageCredential credential : credentials) {
+      addCredentialEntries(catalogName, credential, jobProperties, jobSecrets, 
conf);
+    }
+  }
+
+  /**
+   * Writes each key in one vended {@link StorageCredential} into job 
configuration.
+   *
+   * <p>Derives the S3 bucket from {@link StorageCredential#prefix()} and 
delegates to
+   * {@link #addCredentialEntry} for every entry in {@link 
StorageCredential#config()}, which maps
+   * Iceberg keys to catalog-level and per-bucket S3A job properties or 
secrets.
+   */
+  private static void addCredentialEntries(String catalogName, 
StorageCredential credential,
+      Map<String, String> jobProperties, Map<String, String> jobSecrets, 
Configuration conf) {
+
+    String bucket = bucketFromPrefix(credential.prefix());
+    for (Map.Entry<String, String> entry : credential.config().entrySet()) {
+      addCredentialEntry(
+          catalogName, bucket, entry.getKey(), entry.getValue(), 
jobProperties, jobSecrets, conf);
+    }
+  }
+
+  /**
+   * Routes one Iceberg credential config entry into job properties or secrets.
+   *
+   * <p>Skips blank values, applies session catalog overrides via {@link 
#resolveCredentialValue},
+   * then sends non-secret keys (endpoint, path-style access, etc.) to {@code 
jobProperties} and
+   * secret keys (access key, secret key, session token) to {@code 
jobSecrets}. Either map may be
+   * {@code null} when {@link #propagateToJob} is called for only properties 
or only secrets.
+   */
+  private static void addCredentialEntry(String catalogName, String bucket, 
String icebergKey, String value,
+      Map<String, String> jobProperties, Map<String, String> jobSecrets, 
Configuration conf) {
+
+    if (StringUtils.isBlank(value)) {
+      return;
+    }
+    String resolvedValue = resolveCredentialValue(catalogName, icebergKey, 
value, conf);
+
+    if (jobProperties != null && !isSecretKey(icebergKey, conf)) {
+      addNonSecretCredentialEntry(catalogName, bucket, icebergKey, 
resolvedValue, jobProperties);
+    }
+
+    if (jobSecrets != null && isSecretKey(icebergKey, conf)) {
+      addSecretCredentialEntry(bucket, icebergKey, resolvedValue, jobSecrets);
+    }
+  }
+
+  /**
+   * Adds one non-secret vended value to {@code jobProperties} for Iceberg and 
Hadoop S3A.
+   *
+   * <p>When {@code catalogName} is set, writes {@code 
iceberg.catalog.&lt;catalog&gt;.&lt;key&gt;}.
+   * When {@code bucket} is set, also writes the matching {@code 
fs.s3a.bucket.&lt;bucket&gt;.*}
+   * key if {@link #toS3aBucketProperty} maps the Iceberg key.
+   */
+  private static void addNonSecretCredentialEntry(String catalogName, String 
bucket, String icebergKey, String value,
+      Map<String, String> jobProperties) {
+
+    if (catalogName != null) {
+      String catalogConfigKey =
+          IcebergCatalogProperties.catalogPropertyConfigKey(catalogName, 
icebergKey);
+      jobProperties.putIfAbsent(catalogConfigKey, value);
+    }
+
+    if (bucket != null) {
+      String s3aKey = toS3aBucketProperty(bucket, icebergKey);
+      if (s3aKey != null) {
+        jobProperties.putIfAbsent(s3aKey, value);
+      }
+    }
+  }
+
+  /** Writes Hadoop S3A per-bucket keys only; Iceberg secrets are carried in 
the serialized blob.
+   * First table wins on a shared bucket, matching the non-secret entries, so 
an endpoint and its
+   * key always come from the same credential. */
+  private static void addSecretCredentialEntry(String bucket, String 
icebergKey, String value,
+      Map<String, String> jobSecrets) {
+    if (bucket != null) {
+      String s3aSecretKey = toS3aBucketProperty(bucket, icebergKey);
+      if (s3aSecretKey != null) {
+        jobSecrets.putIfAbsent(s3aSecretKey, value);
+      }
+    }
+  }
+
+  /**
+   * Applies vended credentials to the table FileIO, merging session/catalog 
conf overrides (e.g. S3 endpoint).
+   * Used on executors after deserialization and on HS2 commit when the table 
is taken from query state.
+   */
+  public static void applyFromJobConf(Table table, Configuration conf) {
+    applyFromJobConf(table, conf != null ? 
conf.get(InputFormatConfig.CATALOG_NAME) : null, conf);
+  }
+
+  /** Variant for callers that resolve the catalog name per table (HS2 commit 
paths, where the
+   * job-level {@link InputFormatConfig#CATALOG_NAME} is not set). */
+  public static void applyFromJobConf(Table table, String catalogName, 
Configuration conf) {
+    if (table == null || conf == null) {
+      return;
+    }
+
+    if (shouldSkipApplyFromJobConf(table.name(), catalogName, conf)) {
+      return;
+    }
+
+    FileIO io = table.io();
+    if (!(io instanceof SupportsStorageCredentials credentialIo)) {
+      return;
+    }
+
+    List<StorageCredential> credentials = resolveCredentialsForApply(table, 
credentialIo, conf);
+    if (!credentials.isEmpty()) {
+      credentialIo.setCredentials(withConfigurationOverrides(catalogName, 
credentials, conf));
+    }
+  }
+
+  /**
+   * Returns true when the job carries no serialized vended credentials and 
the catalog is not
+   * configured for credential vending (no {@code vended-credentials} REST 
delegation header).
+   * Otherwise apply may restore credentials from the job conf or from the 
table FileIO.
+   */
+  private static boolean shouldSkipApplyFromJobConf(String tableName, String 
catalogName, Configuration conf) {
+    return 
StringUtils.isBlank(conf.get(InputFormatConfig.vendedCredentialsKey(tableName)))
 &&
+        !IcebergCatalogProperties.requestsVendedCredentials(catalogName, conf);
+  }
+
+  /**
+   * Chooses which vended credentials {@link #applyFromJobConf} should install 
on the FileIO.
+   *
+   * <p>Uses the first non-empty source: credentials already on the FileIO 
(typical on HS2 after
+   * table load), else the base64 list from {@link 
InputFormatConfig#VENDED_STORAGE_CREDENTIALS} on
+   * the task {@code conf} (restored from the HIVE-20651 Credentials channel 
into table properties
+   * by {@code Utilities#copyJobSecretToTableProperties} and copied to the 
task-local conf), else
+   * {@link #extractCredentials(Table)} from the table (including FileIO 
property fallbacks).
+   */
+  private static List<StorageCredential> resolveCredentialsForApply(
+      Table table, SupportsStorageCredentials credentialIo, Configuration 
conf) {
+
+    List<StorageCredential> credentials = credentialIo.credentials();
+    if (credentials != null && !credentials.isEmpty()) {
+      return credentials;
+    }
+    String serialized = 
conf.get(InputFormatConfig.vendedCredentialsKey(table.name()));
+    if (StringUtils.isNotBlank(serialized)) {
+      return SerializationUtil.deserializeFromBase64(serialized);
+    }
+    return extractCredentials(table);
+  }
+
+  /**
+   * Returns true when the table catalog is configured for REST vended storage 
credentials.
+   */
+  static boolean requestsVendedCredentials(Properties properties, 
Configuration configuration) {
+    if (properties == null) {
+      return false;
+    }
+    return IcebergCatalogProperties.requestsVendedCredentials(
+        properties.getProperty(InputFormatConfig.CATALOG_NAME), configuration);
+  }
+
+  /**
+   * Loads a table and, if needed, bypasses the query-level cache so REST 
vended credentials are present on the FileIO.
+   * When vended credentials are not requested for the catalog, returns the 
cached table without an extra load.
+   */
+  static Table getTableWithVendedCredentials(Properties properties, 
Configuration configuration) {
+    Table table = IcebergTableUtil.getTable(configuration, properties);
+    if (requestsVendedCredentials(properties, configuration) && 
extractCredentials(table).isEmpty()) {
+      table = IcebergTableUtil.getTable(configuration, properties, true);
+    }
+    return table;
+  }
+
+  /**
+   * Reloads vended credentials at job launch when compile-time propagation 
missed them.
+   * Non-secret config is written to {@code jobConf}; secrets are merged into 
{@code TableDesc#getJobSecrets()}
+   * for {@link org.apache.hadoop.hive.ql.plan.PlanUtils#configureJobConf} 
(HIVE-20651).
+   */
+  static void refreshVendedCredentialsIfMissing(TableDesc tableDesc, JobConf 
jobConf, Configuration configuration) {
+    if (tableDesc == null || tableDesc.getProperties() == null ||
+        hasSerializedCredentials(tableDesc.getJobSecrets())) {
+      return;
+    }
+
+    Properties props = tableDesc.getProperties();
+    if (!requestsVendedCredentials(props, configuration)) {
+      return;
+    }
+
+    String catalogName = props.getProperty(InputFormatConfig.CATALOG_NAME);
+    if (catalogName == null) {
+      return;
+    }
+
+    try {
+      Table table = getTableWithVendedCredentials(props, configuration);
+      Map<String, String> jobProps = new LinkedHashMap<>();
+      Map<String, String> secrets = new LinkedHashMap<>();
+      propagateToJob(table, catalogName, jobProps, secrets, configuration);
+      jobProps.forEach(jobConf::set);
+      mergeJobSecrets(tableDesc, secrets);
+    } catch (NoSuchTableException ex) {
+      // Table may not exist yet for CTAS; credentials will not be available.
+    }
+  }
+
+  /**
+   * Returns a copy of the table over a fresh secret-free {@link FileIO}, safe 
to serialize into
+   * job properties. {@code SerializableTable.copyOf} embeds {@code 
table.io()} — and with it the
+   * vended credentials — into the serialized bytes, so the copy is rebuilt 
from the same table
+   * metadata over a FileIO that keeps only the allowlisted non-secret 
properties: unknown keys
+   * never ship, whatever the storage provider. Executors restore the 
credentials from the
+   * HIVE-20651 Credentials channel via {@link #applyFromJobConf}
+   * ({@code SupportsStorageCredentials#setCredentials}).
+   */
+  static Table secretFreeCopy(Table table, Configuration conf) {
+    if (table instanceof BaseMetadataTable metadataTable) {
+      Table base = secretFreeCopy(metadataTable.table(), conf);
+      return MetadataTableUtils.createMetadataTableInstance(base, 
metadataTableType(metadataTable));
+    }
+    Preconditions.checkState(table instanceof HasTableOperations,
+        "Cannot build a secret-free copy of %s (%s)", table.name(), 
table.getClass().getName());
+    TableMetadata metadata = ((HasTableOperations) 
table).operations().current();
+
+    FileIO io = table.io();
+    Map<String, String> ioProps = new LinkedHashMap<>();
+    io.properties().forEach((k, v) -> {
+      if (!isSecretKey(k, conf)) {
+        ioProps.put(k, v);
+      }
+    });
+    FileIO cleanIo = CatalogUtil.loadFileIO(io.getClass().getName(), ioProps, 
conf);
+
+    return new BaseTable(
+        new StaticTableOperations(metadata, cleanIo, 
table.locationProvider()), table.name());
+  }
+
+  /** {@code metadataTableType()} is package-private; the name suffix is the 
type by construction. */
+  private static MetadataTableType metadataTableType(BaseMetadataTable 
metadataTable) {
+    String name = metadataTable.name();
+    MetadataTableType type = 
MetadataTableType.from(name.substring(name.lastIndexOf('.') + 1));
+    Preconditions.checkState(type != null, "Cannot resolve metadata table type 
from %s", name);
+    return type;
+  }
+
+  /**
+   * Single-line base64, unlike {@link SerializationUtil#serializeToBase64} 
which MIME-wraps with
+   * CR/LF. The blob is restored into table properties and copied to the task 
conf through
+   * {@code Utilities#copyTablePropertiesToConf}, whose {@code escapeJava} 
turns CR/LF into literal
+   * {@code \r\n} — and {@code r}/{@code n} are valid base64 alphabet, 
corrupting the decoded
+   * stream. {@link SerializationUtil#deserializeFromBase64} uses the MIME 
decoder, which accepts
+   * unwrapped input.
+   */
+  private static String serializeToSingleLineBase64(Object obj) {
+    return 
Base64.getEncoder().encodeToString(SerializationUtil.serializeToBytes(obj));
+  }
+
+  private static boolean hasSerializedCredentials(Map<String, String> 
jobSecrets) {
+    return jobSecrets != null && jobSecrets.keySet().stream()
+        .anyMatch(key -> 
key.startsWith(InputFormatConfig.VENDED_STORAGE_CREDENTIALS));
+  }
+
+  private static void mergeJobSecrets(TableDesc tableDesc, Map<String, String> 
secrets) {
+    if (secrets.isEmpty()) {
+      return;
+    }
+    Map<String, String> existing = tableDesc.getJobSecrets();
+    if (existing == null) {
+      tableDesc.setJobSecrets(new LinkedHashMap<>(secrets));
+    } else {
+      secrets.forEach(existing::putIfAbsent);
+    }
+  }
+
+  /**
+   * A vended config key is secret — routed to the Credentials channel, never 
job properties, and
+   * stripped from the serialized table's FileIO — exactly when {@code 
hive.conf.hidden.list} covers
+   * it. That is Hive's own registry of sensitive configuration (the same one 
behind
+   * {@code HiveConf#isHiddenConfig}) that masks values in EXPLAIN, the Tez 
UI, and ATS.
+   */
+  private static boolean isSecretKey(String key, Configuration conf) {
+    return HiveConfUtil.getHiddenSet(conf).stream().anyMatch(key::startsWith);
+  }
+
+  static List<StorageCredential> extractCredentials(Table table) {
+    if (table == null) {
+      return List.of();
+    }
+    FileIO io = table.io();
+    if (io instanceof SupportsStorageCredentials credentialIo) {
+      List<StorageCredential> credentials = credentialIo.credentials();
+      if (credentials != null && !credentials.isEmpty()) {
+        return credentials;
+      }
+    }
+    return credentialsFromFileIoProperties(table, io);
+  }
+
+  private static List<StorageCredential> credentialsFromFileIoProperties(Table 
table, FileIO io) {
+    Map<String, String> props = io.properties();
+    if (props == null || 
StringUtils.isBlank(props.get(S3FileIOProperties.ACCESS_KEY_ID)) ||
+        StringUtils.isBlank(props.get(S3FileIOProperties.SECRET_ACCESS_KEY))) {
+      return List.of();
+    }
+    Map<String, String> config = new LinkedHashMap<>();
+    putIfPresent(config, props, S3FileIOProperties.ACCESS_KEY_ID);
+    putIfPresent(config, props, S3FileIOProperties.SECRET_ACCESS_KEY);
+    putIfPresent(config, props, S3FileIOProperties.SESSION_TOKEN);
+    putIfPresent(config, props, S3FileIOProperties.ENDPOINT);
+    putIfPresent(config, props, S3FileIOProperties.PATH_STYLE_ACCESS);
+    putIfPresent(config, props, AwsClientProperties.CLIENT_REGION);
+    return List.of(StorageCredential.create(credentialPrefix(table), config));
+  }
+
+  private static void putIfPresent(Map<String, String> target, Map<String, 
String> source, String key) {
+    if (source.containsKey(key) && StringUtils.isNotBlank(source.get(key))) {
+      target.put(key, source.get(key));
+    }
+  }
+
+  private static String credentialPrefix(Table table) {
+    String location = table.location();
+    if (StringUtils.isBlank(location)) {
+      return "";
+    }
+    String bucket = bucketFromPrefix(location);
+    if (bucket != null) {
+      return "s3://" + bucket + "/";
+    }
+    return location.endsWith("/") ? location : location + "/";
+  }
+
+  /**
+   * REST catalogs vend credentials together with S3 connectivity settings 
such as endpoint
+   * and path-style access. These settings reflect the catalog's network view 
and may reference
+   * hosts that are not reachable from Hive (for example, an internal {@code 
s3.ozone:9878}
+   * hostname).
+   *
+   * Catalog S3 properties configured in the Hive session (for example,
+   * {@code iceberg.catalog.ice01.s3.endpoint}) override the corresponding 
vended connectivity
+   * settings so the driver and executors use reachable endpoints. Vended 
credentials are
+   * preserved; only non-secret connectivity properties are overridden.
+   */
+  private static List<StorageCredential> withConfigurationOverrides(
+      String catalogName, List<StorageCredential> credentials, Configuration 
conf) {
+
+    if (credentials.isEmpty() || conf == null || catalogName == null) {
+      return credentials;
+    }
+
+    List<StorageCredential> updated = 
Lists.newArrayListWithCapacity(credentials.size());
+    for (StorageCredential credential : credentials) {
+      Map<String, String> credsConfig = new 
LinkedHashMap<>(credential.config());
+      applyCatalogConfigOverrides(catalogName, credsConfig, conf);
+      updated.add(StorageCredential.create(credential.prefix(), credsConfig));
+    }
+
+    return updated;
+  }
+
+  /**
+   * Applies session-level catalog overrides to every entry of the given 
credential configuration,
+   * through the same {@link #resolveCredentialValue} used for the 
job-property entries — one
+   * resolver, one scope, so both channels always carry the same values.
+   */
+  private static void applyCatalogConfigOverrides(
+      String catalogName, Map<String, String> config, Configuration conf) {
+    config.replaceAll((icebergKey, value) -> 
resolveCredentialValue(catalogName, icebergKey, value, conf));
+  }
+
+  private static String resolveCredentialValue(
+      String catalogName, String icebergKey, String vendedValue, Configuration 
conf) {
+    if (conf == null || catalogName == null) {
+      return vendedValue;
+    }
+    String override =
+        
conf.get(IcebergCatalogProperties.catalogPropertyConfigKey(catalogName, 
icebergKey));
+    return StringUtils.isNotBlank(override) ? override : vendedValue;
+  }
+
+  /** Authority (bucket) of a storage prefix such as {@code s3://bucket/path}, 
any scheme.
+   * Plain string parse: {@code URI.getHost()} rejects legal bucket names with 
underscores. */
+  @SuppressWarnings("java:S1075") // storage URI path separator, not a 
filesystem path
+  private static String bucketFromPrefix(String prefix) {
+    int schemeEnd = prefix == null ? -1 : prefix.indexOf("://");
+    if (schemeEnd < 0) {
+      return null;
+    }
+    String withoutScheme = prefix.substring(schemeEnd + 3);
+    int slash = withoutScheme.indexOf('/');
+    String bucket = slash >= 0 ? withoutScheme.substring(0, slash) : 
withoutScheme;
+    return StringUtils.defaultIfBlank(bucket, null);
+  }
+
+  private static String toS3aBucketProperty(String bucket, String icebergKey) {
+    String bucketPrefix = "fs.s3a.bucket." + bucket + ".";
+    return switch (icebergKey) {
+      case S3FileIOProperties.ACCESS_KEY_ID -> bucketPrefix + "access.key";
+      case S3FileIOProperties.SECRET_ACCESS_KEY -> bucketPrefix + "secret.key";
+      case S3FileIOProperties.SESSION_TOKEN -> bucketPrefix + "session.token";
+      case S3FileIOProperties.ENDPOINT -> bucketPrefix + "endpoint";
+      case S3FileIOProperties.PATH_STYLE_ACCESS -> bucketPrefix + 
"path.style.access";
+      case AwsClientProperties.CLIENT_REGION -> bucketPrefix + 
"endpoint.region";
+      default -> null;
+    };
+  }
+}
diff --git 
a/iceberg/iceberg-handler/src/main/java/org/apache/iceberg/mr/hive/writer/HiveIcebergWriter.java
 
b/iceberg/iceberg-handler/src/main/java/org/apache/iceberg/mr/hive/writer/HiveIcebergWriter.java
index 0a3cce06611..d7dfc8b9539 100644
--- 
a/iceberg/iceberg-handler/src/main/java/org/apache/iceberg/mr/hive/writer/HiveIcebergWriter.java
+++ 
b/iceberg/iceberg-handler/src/main/java/org/apache/iceberg/mr/hive/writer/HiveIcebergWriter.java
@@ -24,6 +24,7 @@
 import org.apache.hadoop.io.NullWritable;
 import org.apache.hadoop.mapred.Reporter;
 import org.apache.iceberg.data.Record;
+import org.apache.iceberg.io.FileIO;
 import org.apache.iceberg.mr.hive.FilesForCommit;
 import org.apache.iceberg.mr.mapred.Container;
 
@@ -32,6 +33,8 @@ public interface HiveIcebergWriter extends 
FileSinkOperator.RecordWriter,
 
   FilesForCommit files();
 
+  FileIO io();
+
   default void close(Reporter reporter) throws IOException {
     close(false);
   }
diff --git 
a/iceberg/iceberg-handler/src/main/java/org/apache/iceberg/mr/hive/writer/HiveIcebergWriterBase.java
 
b/iceberg/iceberg-handler/src/main/java/org/apache/iceberg/mr/hive/writer/HiveIcebergWriterBase.java
index ad026a83040..6267a3b9988 100644
--- 
a/iceberg/iceberg-handler/src/main/java/org/apache/iceberg/mr/hive/writer/HiveIcebergWriterBase.java
+++ 
b/iceberg/iceberg-handler/src/main/java/org/apache/iceberg/mr/hive/writer/HiveIcebergWriterBase.java
@@ -69,6 +69,11 @@ abstract class HiveIcebergWriterBase implements 
HiveIcebergWriter {
     this.writer = writer;
   }
 
+  @Override
+  public FileIO io() {
+    return io;
+  }
+
   @Override
   public void close(boolean abort) throws IOException {
     writer.close();
diff --git 
a/iceberg/iceberg-handler/src/main/java/org/apache/iceberg/mr/mapreduce/IcebergInputFormat.java
 
b/iceberg/iceberg-handler/src/main/java/org/apache/iceberg/mr/mapreduce/IcebergInputFormat.java
index fa66b25b349..89f888255b8 100644
--- 
a/iceberg/iceberg-handler/src/main/java/org/apache/iceberg/mr/mapreduce/IcebergInputFormat.java
+++ 
b/iceberg/iceberg-handler/src/main/java/org/apache/iceberg/mr/mapreduce/IcebergInputFormat.java
@@ -162,6 +162,8 @@ public List<InputSplit> getSplits(JobContext context) {
         .orElseGet(() -> {
           Table tbl = Catalogs.loadTable(conf);
           conf.set(InputFormatConfig.TABLE_IDENTIFIER, tbl.name());
+          // planning-local conf only (never shipped): for credential-vending 
catalogs the loaded
+          // table's FileIO carries secrets, which must not reach a serialized 
job configuration
           conf.set(InputFormatConfig.SERIALIZED_TABLE_PREFIX + tbl.name(), 
SerializationUtil.serializeToBase64(tbl));
           return tbl;
         });
diff --git 
a/iceberg/iceberg-handler/src/test/java/org/apache/iceberg/mr/hive/TestIcebergVendedCredentialUtil.java
 
b/iceberg/iceberg-handler/src/test/java/org/apache/iceberg/mr/hive/TestIcebergVendedCredentialUtil.java
new file mode 100644
index 00000000000..1d80244e70d
--- /dev/null
+++ 
b/iceberg/iceberg-handler/src/test/java/org/apache/iceberg/mr/hive/TestIcebergVendedCredentialUtil.java
@@ -0,0 +1,885 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.apache.iceberg.mr.hive;
+
+import java.nio.charset.StandardCharsets;
+import java.security.PrivilegedExceptionAction;
+import java.util.List;
+import java.util.Map;
+import java.util.Properties;
+import org.apache.hadoop.conf.Configuration;
+import org.apache.hadoop.hive.conf.HiveConf;
+import org.apache.hadoop.hive.metastore.api.hive_metastoreConstants;
+import org.apache.hadoop.hive.ql.exec.Utilities;
+import org.apache.hadoop.hive.ql.plan.PlanUtils;
+import org.apache.hadoop.hive.ql.plan.TableDesc;
+import org.apache.hadoop.mapred.JobConf;
+import org.apache.hadoop.security.UserGroupInformation;
+import org.apache.iceberg.BaseTable;
+import org.apache.iceberg.MetadataTableType;
+import org.apache.iceberg.MetadataTableUtils;
+import org.apache.iceberg.PartitionSpec;
+import org.apache.iceberg.Schema;
+import org.apache.iceberg.SerializableTable;
+import org.apache.iceberg.StaticTableOperations;
+import org.apache.iceberg.Table;
+import org.apache.iceberg.TableMetadata;
+import org.apache.iceberg.TableMetadataParser;
+import org.apache.iceberg.aws.s3.S3FileIOProperties;
+import org.apache.iceberg.hadoop.HadoopFileIO;
+import org.apache.iceberg.io.FileIO;
+import org.apache.iceberg.io.StorageCredential;
+import org.apache.iceberg.io.SupportsStorageCredentials;
+import org.apache.iceberg.mr.InputFormatConfig;
+import org.apache.iceberg.relocated.com.google.common.collect.Maps;
+import org.apache.iceberg.types.Types;
+import org.apache.iceberg.util.SerializationUtil;
+import org.junit.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * Tests for {@link IcebergVendedCredentialUtil}: copying REST-vended storage 
credentials into Hive
+ * job configuration at plan time and re-applying them on executors.
+ */
+public class TestIcebergVendedCredentialUtil {
+
+  /**
+   * Tests {@link 
IcebergVendedCredentialUtil#requestsVendedCredentials(Properties, 
Configuration)}.
+   *
+   * <p><b>Setup:</b> table properties name catalog {@code ice01}; session 
{@code conf} has no
+   * delegation header, then gains {@code vended-credentials}.
+   *
+   * <p><b>Expects:</b> {@code false} without the header, {@code true} with it 
— propagation and
+   * apply logic must not run for ordinary REST catalogs.
+   *
+   * <p><b>Why:</b> Iceberg REST only vends storage credentials when the 
client sends
+   * {@code X-Iceberg-Access-Delegation: vended-credentials}; Hive mirrors 
that opt-in via catalog
+   * config so static catalog keys are not replaced unnecessarily.
+   */
+  @Test
+  public void requestsVendedCredentialsRequiresDelegationHeader() {
+    Configuration conf = new Configuration();
+    Properties props = new java.util.Properties();
+    props.setProperty(InputFormatConfig.CATALOG_NAME, "ice01");
+
+    assertThat(IcebergVendedCredentialUtil.requestsVendedCredentials(props, 
conf)).isFalse();
+
+    conf.set(
+        "iceberg.catalog.ice01.header.X-Iceberg-Access-Delegation",
+        "vended-credentials");
+    assertThat(IcebergVendedCredentialUtil.requestsVendedCredentials(props, 
conf)).isTrue();
+  }
+
+  /**
+   * Tests {@link IcebergVendedCredentialUtil#propagateToJob} when both {@code 
jobProperties} and
+   * {@code jobSecrets} are non-null (full compile-time path after both 
storage-handler hooks).
+   *
+   * <p><b>Setup:</b> vended credential with internal endpoint {@code 
http://minio:9000}; session
+   * {@code conf} sets {@code iceberg.catalog.ice01.s3.endpoint} to {@code 
http://host:9000}.
+   *
+   * <p><b>Expects:</b> endpoint and path-style in {@code jobProps} (Iceberg 
catalog keys and S3A
+   * per-bucket keys) with host endpoint; access/secret and serialized blob in 
{@code jobSecrets}
+   * only; blob endpoint also host; no secret material in {@code jobProps}.
+   *
+   * <p><b>Why:</b> HIVE-20651 keeps secrets in {@code TableDesc#jobSecrets} 
(later Hadoop
+   * {@code Credentials}) and non-secrets in job conf. Catalogs often vend an 
internal S3 endpoint;
+   * HS2 overrides with a reachable host endpoint at propagation time so Tez 
tasks and Iceberg FileIO
+   * agree on connectivity while still using vended keys.
+   */
+  @Test
+  public void propagateToJobMapsIcebergAndS3aProperties() {
+    Configuration conf = new Configuration();
+    conf.set("iceberg.catalog.ice01." + S3FileIOProperties.ENDPOINT, 
"http://host:9000";);
+
+    CredentialFileIO fileIO = new CredentialFileIO();
+    fileIO.setCredentials(
+        List.of(
+            StorageCredential.create(
+                "s3://my-bucket/",
+                Map.of(
+                    S3FileIOProperties.ACCESS_KEY_ID, "access",
+                    S3FileIOProperties.SECRET_ACCESS_KEY, "secret",
+                    S3FileIOProperties.ENDPOINT, "http://minio:9000";,
+                    S3FileIOProperties.PATH_STYLE_ACCESS, "true"))));
+
+    Table table =
+        new BaseTable(new StaticTableOperations("s3://my-bucket/t", fileIO), 
"db.t");
+    Map<String, String> jobProps = Maps.newHashMap();
+    Map<String, String> jobSecrets = Maps.newHashMap();
+
+    IcebergVendedCredentialUtil.propagateToJob(table, "ice01", jobProps, 
jobSecrets, conf);
+
+    assertThat(jobProps)
+        .containsEntry(
+            "iceberg.catalog.ice01." + S3FileIOProperties.ENDPOINT,
+            "http://host:9000";)
+        .containsEntry(
+            "fs.s3a.bucket.my-bucket.endpoint",
+            "http://host:9000";)
+        .containsEntry(
+            "fs.s3a.bucket.my-bucket.path.style.access",
+            "true")
+        .doesNotContainKey(InputFormatConfig.vendedCredentialsKey("db.t"))
+        .doesNotContainKey("fs.s3a.bucket.my-bucket.access.key")
+        .doesNotContainKey(
+            "iceberg.catalog.ice01." + S3FileIOProperties.ACCESS_KEY_ID);
+
+    assertThat(jobSecrets)
+        .containsEntry("fs.s3a.bucket.my-bucket.access.key", "access")
+        .containsEntry("fs.s3a.bucket.my-bucket.secret.key", "secret")
+        .doesNotContainKey(
+            "iceberg.catalog.ice01." + S3FileIOProperties.ACCESS_KEY_ID)
+        .doesNotContainKey(
+            "iceberg.catalog.ice01." + S3FileIOProperties.SECRET_ACCESS_KEY)
+        .satisfies(map ->
+            assertThat(map.get(InputFormatConfig.vendedCredentialsKey("db.t")))
+                .isNotBlank());
+
+    List<StorageCredential> serialized =
+        SerializationUtil.deserializeFromBase64(
+            jobSecrets.get(InputFormatConfig.vendedCredentialsKey("db.t")));
+
+    assertThat(serialized.getFirst().prefix()).isEqualTo("s3://my-bucket/");
+    assertThat(serialized.getFirst().config())
+        .containsEntry(S3FileIOProperties.ENDPOINT, "http://host:9000";)
+        .containsEntry(S3FileIOProperties.ACCESS_KEY_ID, "access")
+        .containsEntry(S3FileIOProperties.SECRET_ACCESS_KEY, "secret")
+        .containsEntry(S3FileIOProperties.PATH_STYLE_ACCESS, "true");
+  }
+
+  /**
+   * Tests {@link IcebergVendedCredentialUtil#propagateToJob} with {@code 
jobSecrets == null}.
+   *
+   * <p><b>Setup:</b> same mixed secret/non-secret credential as the full 
test; only
+   * {@code jobProperties} map is passed (models {@code 
configureInputJobProperties} /
+   * {@code overlayTableProperties}).
+   *
+   * <p><b>Expects:</b> overridden endpoint in {@code jobProps}; no serialized 
blob and no Iceberg
+   * access-key property in {@code jobProps}.
+   *
+   * <p><b>Why:</b> Hive calls the job-properties hook without a secrets map. 
This hook must add
+   * connectivity settings for S3A and Iceberg but must not place keys or the 
credential blob in
+   * plain job properties — those are filled later by {@code 
configureInputJobCredentials}.
+   */
+  @Test
+  public void propagateNonSecretsOnlyWhenJobSecretsNull() {
+    Configuration conf = new Configuration();
+    conf.set("iceberg.catalog.ice01." + S3FileIOProperties.ENDPOINT, 
"http://host:9000";);
+
+    CredentialFileIO fileIO = new CredentialFileIO();
+    fileIO.setCredentials(
+        List.of(
+            StorageCredential.create(
+                "s3://my-bucket/",
+                Map.of(
+                    S3FileIOProperties.ACCESS_KEY_ID, "access",
+                    S3FileIOProperties.SECRET_ACCESS_KEY, "secret",
+                    S3FileIOProperties.ENDPOINT, "http://minio:9000";,
+                    S3FileIOProperties.PATH_STYLE_ACCESS, "true"))));
+
+    Table table =
+        new BaseTable(new StaticTableOperations("s3://my-bucket/t", fileIO), 
"db.t");
+    Map<String, String> jobProps = Maps.newHashMap();
+
+    IcebergVendedCredentialUtil.propagateToJob(table, "ice01", jobProps, null, 
conf);
+
+    assertThat(jobProps)
+        .containsEntry(
+            "iceberg.catalog.ice01." + S3FileIOProperties.ENDPOINT,
+            "http://host:9000";)
+        .containsEntry("fs.s3a.bucket.my-bucket.endpoint", "http://host:9000";)
+        .containsEntry("fs.s3a.bucket.my-bucket.path.style.access", "true")
+        .doesNotContainKey(InputFormatConfig.vendedCredentialsKey("db.t"))
+        .doesNotContainKey(
+            "iceberg.catalog.ice01." + S3FileIOProperties.ACCESS_KEY_ID);
+  }
+
+  /**
+   * Tests {@link IcebergVendedCredentialUtil#extractCredentials(Table)} when
+   * {@link SupportsStorageCredentials#credentials()} is empty.
+   *
+   * <p><b>Setup:</b> FileIO initialized with {@code s3.access-key-id} / 
{@code s3.secret-access-key}
+   * in {@link FileIO#properties()} but an empty credential list.
+   *
+   * <p><b>Expects:</b> one synthetic {@link StorageCredential} with the 
access key from properties.
+   *
+   * <p><b>Why:</b> after {@code loadTable}, some REST clients populate {@code 
FileIO} properties
+   * before the credential list; propagation must still find vended keys or 
plan/launch would skip
+   * credential vending even though the table load succeeded.
+   */
+  @Test
+  public void extractCredentialsFromFileIoPropertiesWhenCredentialListEmpty() {
+    CredentialFileIO fileIO = new CredentialFileIO();
+    fileIO.initialize(
+        Map.of(
+            S3FileIOProperties.ACCESS_KEY_ID, "access",
+            S3FileIOProperties.SECRET_ACCESS_KEY, "secret",
+            S3FileIOProperties.ENDPOINT, "http://minio:9000";));
+    Schema schema = new Schema(Types.NestedField.required(1, "x", 
Types.IntegerType.get()));
+    TableMetadata metadata =
+        TableMetadata.newTableMetadata(schema, PartitionSpec.unpartitioned(), 
"s3://my-bucket/warehouse/t", Map.of());
+    Table table = new BaseTable(new StaticTableOperations(metadata, fileIO), 
"db.t");
+
+    StorageCredential extracted = 
IcebergVendedCredentialUtil.extractCredentials(table).getFirst();
+    assertThat(extracted.prefix()).isEqualTo("s3://my-bucket/");
+    assertThat(extracted.config())
+        .containsEntry(S3FileIOProperties.ACCESS_KEY_ID, "access")
+        .containsEntry(S3FileIOProperties.SECRET_ACCESS_KEY, "secret")
+        .containsEntry(S3FileIOProperties.ENDPOINT, "http://minio:9000";);
+  }
+
+  /**
+   * Tests {@link IcebergVendedCredentialUtil#propagateToJob} with {@code 
jobProperties == null}.
+   *
+   * <p><b>Setup:</b> credential with access key and secret only; only {@code 
jobSecrets} map is
+   * passed (models {@code configureInputJobCredentials} for input and output 
tables).
+   *
+   * <p><b>Expects:</b> serialized blob and {@code 
fs.s3a.bucket.*.access.key/secret.key} in
+   * {@code jobSecrets}; no catalog-prefixed secret keys, no endpoint in 
{@code jobSecrets}.
+   *
+   * <p><b>Why:</b> the credentials hook receives only the secrets map. 
Propagation must still run
+   * without NPE, write material for {@code PlanUtils#configureJobConf} to 
move into
+   * {@code Credentials}, and avoid duplicating non-secrets or catalog-level 
secret keys that belong
+   * in job properties or only in the serialized blob.
+   */
+  @Test
+  public void propagateSecretsOnlyWhenJobPropertiesNull() {
+    CredentialFileIO fileIO = new CredentialFileIO();
+    fileIO.setCredentials(
+        List.of(
+            StorageCredential.create(
+                "s3://my-bucket/",
+                Map.of(S3FileIOProperties.ACCESS_KEY_ID, "access",
+                    S3FileIOProperties.SECRET_ACCESS_KEY, "secret"))));
+    Table table =
+        new BaseTable(new StaticTableOperations("s3://my-bucket/t", fileIO), 
"db.t");
+    Map<String, String> jobSecrets = Maps.newHashMap();
+
+    IcebergVendedCredentialUtil.propagateToJob(table, "ice01", null, 
jobSecrets, new Configuration());
+
+    assertThat(jobSecrets)
+        .containsEntry("fs.s3a.bucket.my-bucket.access.key", "access")
+        .containsEntry("fs.s3a.bucket.my-bucket.secret.key", "secret")
+        .doesNotContainKey(
+            "iceberg.catalog.ice01." + S3FileIOProperties.ACCESS_KEY_ID)
+        .doesNotContainKey(
+            "iceberg.catalog.ice01." + S3FileIOProperties.SECRET_ACCESS_KEY)
+        .doesNotContainKey("fs.s3a.bucket.my-bucket.endpoint")
+        .satisfies(map ->
+            assertThat(map.get(InputFormatConfig.vendedCredentialsKey("db.t")))
+                .isNotBlank());
+
+    List<StorageCredential> serialized =
+        SerializationUtil.deserializeFromBase64(
+            jobSecrets.get(InputFormatConfig.vendedCredentialsKey("db.t")));
+
+    assertThat(serialized.getFirst().prefix()).isEqualTo("s3://my-bucket/");
+    assertThat(serialized.getFirst().config())
+        .containsEntry(S3FileIOProperties.ACCESS_KEY_ID, "access")
+        .containsEntry(S3FileIOProperties.SECRET_ACCESS_KEY, "secret");
+  }
+
+  /**
+   * Tests {@link IcebergVendedCredentialUtil#applyFromJobConf} for a 
non-vending catalog.
+   *
+   * <p><b>Setup:</b> task {@code conf} has catalog name only — no serialized 
credentials blob and
+   * no {@code vended-credentials} delegation header.
+   *
+   * <p><b>Expects:</b> FileIO credential list stays empty after apply.
+   *
+   * <p><b>Why:</b> {@link 
IcebergVendedCredentialUtil#shouldSkipApplyFromJobConf} must return early
+   * so tables that use static catalog S3 configuration are not cleared or 
rewritten on executors.
+   */
+  @Test
+  public void applyFromJobConfSkipsWhenVendedCredentialsNotRequested() {
+    CredentialFileIO fileIO = new CredentialFileIO();
+    Table table =
+        new BaseTable(new StaticTableOperations("s3://my-bucket/t", fileIO), 
"db.t");
+
+    Configuration conf = new Configuration();
+    conf.set("iceberg.catalog", "ice01");
+
+    IcebergVendedCredentialUtil.applyFromJobConf(table, conf);
+
+    assertThat(fileIO.credentials()).isEmpty();
+  }
+
+  /**
+   * Tests {@link IcebergVendedCredentialUtil#applyFromJobConf} endpoint 
override on an existing FileIO.
+   *
+   * <p><b>Setup:</b> FileIO already has vended credentials with {@code 
http://minio:9000}; task
+   * {@code conf} requests vending and sets host endpoint {@code 
http://host:9000}.
+   *
+   * <p><b>Expects:</b> after apply, FileIO credential endpoint is {@code 
http://host:9000}.
+   *
+   * <p><b>Why:</b> HS2 may retain credentials from {@code loadTable} with the 
catalog-internal
+   * endpoint; apply must re-merge session catalog S3 settings so commit paths 
and tasks use the
+   * same reachable endpoint as plan-time propagation.
+   */
+  @Test
+  public void applyFromJobConfOverridesEndpointOnExistingCredentials() {
+    CredentialFileIO fileIO = new CredentialFileIO();
+    fileIO.setCredentials(
+        List.of(
+            StorageCredential.create(
+                "s3://my-bucket/",
+                Map.of(
+                    S3FileIOProperties.ACCESS_KEY_ID, "access",
+                    S3FileIOProperties.SECRET_ACCESS_KEY, "secret",
+                    S3FileIOProperties.ENDPOINT, "http://minio:9000";))));
+    Table table =
+        new BaseTable(new StaticTableOperations("s3://my-bucket/t", fileIO), 
"db.t");
+
+    Configuration conf = new Configuration();
+    conf.set("iceberg.catalog", "ice01");
+    conf.set(
+        "iceberg.catalog.ice01.header.X-Iceberg-Access-Delegation",
+        "vended-credentials");
+    conf.set("iceberg.catalog.ice01." + S3FileIOProperties.ENDPOINT, 
"http://host:9000";);
+
+    IcebergVendedCredentialUtil.applyFromJobConf(table, conf);
+
+    StorageCredential applied =
+        ((SupportsStorageCredentials) table.io()).credentials().getFirst();
+    assertThat(applied.prefix()).isEqualTo("s3://my-bucket/");
+    assertThat(applied.config())
+        .containsEntry(S3FileIOProperties.ENDPOINT, "http://host:9000";)
+        .containsEntry(S3FileIOProperties.ACCESS_KEY_ID, "access")
+        .containsEntry(S3FileIOProperties.SECRET_ACCESS_KEY, "secret");
+  }
+
+  /**
+   * Tests {@link IcebergVendedCredentialUtil#applyFromJobConf} on a task with 
an empty FileIO.
+   *
+   * <p><b>Setup:</b> {@code propagateToJob} fills job maps on HS2; keys are 
copied into a task
+   * {@code Configuration} as on Tez/LLAP; executor table is deserialized with 
a fresh FileIO (no
+   * credentials).
+   *
+   * <p><b>Expects:</b> after apply, executor FileIO has one credential with 
the vended access key.
+   *
+   * <p><b>Why:</b> executors do not re-run REST {@code loadTable}; they 
rebuild the table from job
+   * conf. {@link IcebergVendedCredentialUtil#resolveCredentialsForApply} must 
read the serialized
+   * blob (or S3A secret keys) from the task conf and call {@code 
setCredentials} so Iceberg I/O
+   * can access S3 during the task.
+   */
+  @Test
+  public void applyFromJobConfRestoresCredentialsOnExecutor() {
+    CredentialFileIO fileIO = new CredentialFileIO();
+    fileIO.setCredentials(
+        List.of(
+            StorageCredential.create(
+                "s3://my-bucket/",
+                Map.of(S3FileIOProperties.ACCESS_KEY_ID, "access",
+                    S3FileIOProperties.SECRET_ACCESS_KEY, "secret"))));
+    Table table =
+        new BaseTable(new StaticTableOperations("s3://my-bucket/t", fileIO), 
"db.t");
+
+    Map<String, String> jobProps = Maps.newHashMap();
+    Map<String, String> jobSecrets = Maps.newHashMap();
+    IcebergVendedCredentialUtil.propagateToJob(
+        table, "ice01", jobProps, jobSecrets, new Configuration());
+
+    Configuration taskConf = new Configuration();
+    jobProps.forEach(taskConf::set);
+    jobSecrets.forEach(taskConf::set);
+
+    CredentialFileIO executorIo = new CredentialFileIO();
+    Table executorTable =
+        new BaseTable(new StaticTableOperations("s3://my-bucket/t", 
executorIo), "db.t");
+    IcebergVendedCredentialUtil.applyFromJobConf(executorTable, taskConf);
+
+    assertThat(((SupportsStorageCredentials) 
executorTable.io()).credentials()).hasSize(1);
+    StorageCredential applied =
+        ((SupportsStorageCredentials) 
executorTable.io()).credentials().getFirst();
+    assertThat(applied.prefix()).isEqualTo("s3://my-bucket/");
+    assertThat(applied.config())
+        .containsEntry(S3FileIOProperties.ACCESS_KEY_ID, "access")
+        .containsEntry(S3FileIOProperties.SECRET_ACCESS_KEY, "secret");
+  }
+
+  /**
+   * Tests the full HIVE-20651 secret round trip: {@code propagateToJob} →
+   * {@link org.apache.hadoop.hive.ql.plan.PlanUtils#configureJobConf} 
(secrets → Hadoop
+   * {@code Credentials}, cleared from the TableDesc) → task UGI →
+   * {@code Utilities#copyJobSecretToTableProperties} → {@code 
Utilities#copyTablePropertiesToConf}
+   * → {@link IcebergVendedCredentialUtil#applyFromJobConf}.
+   *
+   * <p><b>Setup:</b> the exact chain Hive runs — HS2 fills {@code 
TableDesc#jobSecrets}, PlanUtils
+   * moves them to the job {@code Credentials} (asserted: nothing lands in the 
plain JobConf), the
+   * task restores them into table properties and copies them into the 
task-local conf the same way
+   * {@code MapRecordProcessor}/{@code FileSinkOperator} do.
+   *
+   * <p><b>Expects:</b> the executor FileIO receives the vended credential.
+   *
+   * <p><b>Why:</b> this is the only sanctioned transport for secrets — they 
must survive the real
+   * restore path, including {@code copyTablePropertiesToConf}'s {@code 
escapeJava}, which corrupts
+   * MIME-wrapped base64 (CR/LF become literal {@code \r\n} whose {@code 
r}/{@code n} bytes are
+   * valid base64 alphabet). The blob must therefore be single-line base64.
+   */
+  @Test
+  public void applyFromJobConfRestoresCredentialsViaCredentialsChannel() 
throws Exception {
+    CredentialFileIO hs2Io = new CredentialFileIO();
+    hs2Io.setCredentials(
+        List.of(
+            StorageCredential.create(
+                "s3://my-bucket/",
+                Map.of(S3FileIOProperties.ACCESS_KEY_ID, "access",
+                    S3FileIOProperties.SECRET_ACCESS_KEY, "secret"))));
+    Table hs2Table =
+        new BaseTable(new StaticTableOperations("s3://my-bucket/t", hs2Io), 
"db.t");
+
+    TableDesc tableDesc = new TableDesc();
+    Properties tableProps = new Properties();
+    tableProps.setProperty(hive_metastoreConstants.META_TABLE_NAME, "db.t");
+    tableDesc.setProperties(tableProps);
+
+    Map<String, String> jobSecrets = Maps.newHashMap();
+    IcebergVendedCredentialUtil.propagateToJob(hs2Table, "ice01", null, 
jobSecrets, new Configuration());
+    tableDesc.setJobSecrets(jobSecrets);
+
+    JobConf jobConf = new JobConf();
+    PlanUtils.configureJobConf(tableDesc, jobConf);
+    
assertThat(jobConf.get(InputFormatConfig.vendedCredentialsKey("db.t"))).isNull();
+    assertThat(tableDesc.getJobSecrets()).isEmpty();
+
+    UserGroupInformation taskUgi = 
UserGroupInformation.createRemoteUser("task-user");
+    taskUgi.addCredentials(jobConf.getCredentials());
+
+    JobConf taskConf = new JobConf();
+    taskConf.set(InputFormatConfig.CATALOG_NAME, "ice01");
+    taskConf.set(
+        "iceberg.catalog.ice01.header.X-Iceberg-Access-Delegation",
+        "vended-credentials");
+
+    CredentialFileIO executorIo = new CredentialFileIO();
+    Table executorTable =
+        new BaseTable(new StaticTableOperations("s3://my-bucket/t", 
executorIo), "db.t");
+
+    taskUgi.doAs((PrivilegedExceptionAction<Void>) () -> {
+      Utilities.copyJobSecretToTableProperties(tableDesc);
+      Utilities.copyTablePropertiesToConf(tableDesc, taskConf);
+      IcebergVendedCredentialUtil.applyFromJobConf(executorTable, taskConf);
+      return null;
+    });
+
+    assertThat(executorIo.credentials()).hasSize(1);
+    StorageCredential applied = executorIo.credentials().getFirst();
+    assertThat(applied.prefix()).isEqualTo("s3://my-bucket/");
+    assertThat(applied.config())
+        .containsEntry(S3FileIOProperties.ACCESS_KEY_ID, "access")
+        .containsEntry(S3FileIOProperties.SECRET_ACCESS_KEY, "secret");
+  }
+
+  /**
+   * Tests {@link HiveIcebergStorageHandler#configureJobConf} secret hygiene.
+   *
+   * <p><b>Setup:</b> vending-enabled catalog; {@code TableDesc#jobSecrets} 
already holds the
+   * serialized blob and a per-bucket S3A secret key (filled at compile time by
+   * {@code configureInputJobCredentials}).
+   *
+   * <p><b>Expects:</b> after {@code configureJobConf}, neither the blob nor 
the S3A secret key
+   * appears in the {@code JobConf}; the jobSecrets map is left for
+   * {@code PlanUtils#configureJobConf} to move into Hadoop {@code 
Credentials}.
+   *
+   * <p><b>Why:</b> {@code TezTask} strips {@code hive.conf.hidden.list} 
entries <em>before</em>
+   * storage handlers run ({@code createConfiguration} precedes
+   * {@code configureJobConfAndExtractJars}), so any secret written to the 
JobConf here ships in
+   * plaintext with the DAG plan. Secrets may only travel via the Credentials 
channel.
+   */
+  @Test
+  public void configureJobConfKeepsSecretsOutOfJobConf() {
+    Configuration conf = new Configuration();
+    conf.set(
+        "iceberg.catalog.ice01.header.X-Iceberg-Access-Delegation",
+        "vended-credentials");
+    HiveIcebergStorageHandler handler = new HiveIcebergStorageHandler();
+    handler.setConf(conf);
+
+    Properties props = new Properties();
+    props.setProperty(InputFormatConfig.CATALOG_NAME, "ice01");
+    TableDesc tableDesc = new TableDesc();
+    tableDesc.setProperties(props);
+
+    String secretS3aKey = "fs.s3a.bucket.my-bucket.secret.key";
+    Map<String, String> jobSecrets = Maps.newHashMap();
+    jobSecrets.put(InputFormatConfig.vendedCredentialsKey("db.t"), "blob");
+    jobSecrets.put(secretS3aKey, "secret");
+    tableDesc.setJobSecrets(jobSecrets);
+
+    JobConf jobConf = new JobConf();
+    jobConf.setBoolean(HiveConf.ConfVars.HIVE_IN_TEST_IDE.varname, true);
+
+    handler.configureJobConf(tableDesc, jobConf);
+
+    
assertThat(jobConf.get(InputFormatConfig.vendedCredentialsKey("db.t"))).isNull();
+    assertThat(jobConf.get(secretS3aKey)).isNull();
+    assertThat(tableDesc.getJobSecrets())
+        .containsEntry(InputFormatConfig.vendedCredentialsKey("db.t"), "blob")
+        .containsEntry(secretS3aKey, "secret");
+  }
+
+  /**
+   * Tests per-table credential resolution when two vended tables publish into 
one configuration.
+   *
+   * <p><b>Setup:</b> REST catalogs mint credentials per table; two tables on 
different buckets go
+   * through the full chain — {@code propagateToJob} → {@code 
PlanUtils#configureJobConf} → one
+   * task UGI → per-table restore — and both publish into a <em>shared</em> 
conf the way merged
+   * map-works do. {@code Utilities#copyTableJobPropertiesToConf} is 
skip-if-present for table
+   * properties, so with a single global blob key the second table would read 
the first table's
+   * credentials.
+   *
+   * <p><b>Expects:</b> each executor table's FileIO receives the credential 
vended for it —
+   * table 2 gets the bucket-b credential, not bucket-a's.
+   *
+   * <p><b>Why:</b> the blob key must be table-scoped (like {@code 
iceberg.mr.serialized.table.*})
+   * for multi-table queries; key collisions would silently apply another 
table's credentials.
+   */
+  @Test
+  public void applyFromJobConfResolvesPerTableCredentialsInSharedConf() throws 
Exception {
+    TableDesc tableDesc1 = tableDescNamed("db.t1");
+    Map<String, String> secrets1 = Maps.newHashMap();
+    IcebergVendedCredentialUtil.propagateToJob(
+        tableWithCredential("s3://bucket-a/", "access-a", "db.t1"), "ice01", 
null, secrets1, new Configuration());
+    tableDesc1.setJobSecrets(secrets1);
+
+    TableDesc tableDesc2 = tableDescNamed("db.t2");
+    Map<String, String> secrets2 = Maps.newHashMap();
+    IcebergVendedCredentialUtil.propagateToJob(
+        tableWithCredential("s3://bucket-b/", "access-b", "db.t2"), "ice01", 
null, secrets2, new Configuration());
+    tableDesc2.setJobSecrets(secrets2);
+
+    JobConf jobConf = new JobConf();
+    PlanUtils.configureJobConf(tableDesc1, jobConf);
+    PlanUtils.configureJobConf(tableDesc2, jobConf);
+
+    UserGroupInformation taskUgi = 
UserGroupInformation.createRemoteUser("task-user");
+    taskUgi.addCredentials(jobConf.getCredentials());
+
+    JobConf sharedConf = new JobConf();
+    sharedConf.set(InputFormatConfig.CATALOG_NAME, "ice01");
+    sharedConf.set(
+        "iceberg.catalog.ice01.header.X-Iceberg-Access-Delegation",
+        "vended-credentials");
+
+    CredentialFileIO executorIo1 = new CredentialFileIO();
+    Table executorTable1 =
+        new BaseTable(new StaticTableOperations("s3://bucket-a/t1", 
executorIo1), "db.t1");
+    CredentialFileIO executorIo2 = new CredentialFileIO();
+    Table executorTable2 =
+        new BaseTable(new StaticTableOperations("s3://bucket-b/t2", 
executorIo2), "db.t2");
+
+    taskUgi.doAs((PrivilegedExceptionAction<Void>) () -> {
+      Utilities.copyJobSecretToTableProperties(tableDesc1);
+      Utilities.copyJobSecretToTableProperties(tableDesc2);
+      Utilities.copyTableJobPropertiesToConf(tableDesc1, sharedConf);
+      Utilities.copyTableJobPropertiesToConf(tableDesc2, sharedConf);
+      IcebergVendedCredentialUtil.applyFromJobConf(executorTable1, sharedConf);
+      IcebergVendedCredentialUtil.applyFromJobConf(executorTable2, sharedConf);
+      return null;
+    });
+
+    assertThat(executorIo1.credentials()).hasSize(1);
+    
assertThat(executorIo1.credentials().getFirst().prefix()).isEqualTo("s3://bucket-a/");
+    assertThat(executorIo1.credentials().getFirst().config())
+        .containsEntry(S3FileIOProperties.ACCESS_KEY_ID, "access-a");
+
+    assertThat(executorIo2.credentials()).hasSize(1);
+    
assertThat(executorIo2.credentials().getFirst().prefix()).isEqualTo("s3://bucket-b/");
+    assertThat(executorIo2.credentials().getFirst().config())
+        .containsEntry(S3FileIOProperties.ACCESS_KEY_ID, "access-b");
+  }
+
+  private static TableDesc tableDescNamed(String tableName) {
+    Properties tableProps = new Properties();
+    tableProps.setProperty(hive_metastoreConstants.META_TABLE_NAME, tableName);
+    TableDesc tableDesc = new TableDesc();
+    tableDesc.setProperties(tableProps);
+    return tableDesc;
+  }
+
+  private static Table tableWithCredential(String prefix, String accessKey, 
String tableName) {
+    CredentialFileIO fileIO = new CredentialFileIO();
+    fileIO.setCredentials(
+        List.of(
+            StorageCredential.create(
+                prefix,
+                Map.of(S3FileIOProperties.ACCESS_KEY_ID, accessKey,
+                    S3FileIOProperties.SECRET_ACCESS_KEY, accessKey + 
"-secret"))));
+    return new BaseTable(new StaticTableOperations(prefix + "t", fileIO), 
tableName);
+  }
+
+  /**
+   * Tests the strip → ship → setCredentials flow for vended catalogs.
+   *
+   * <p><b>Setup:</b> HS2-side table with a credentialed FileIO (secret in the 
credential list and
+   * in the FileIO properties) over a real metadata file on local disk. {@code 
secretFreeCopy}
+   * rebuilds the table over a fresh FileIO keeping only allowlisted 
non-secret properties before
+   * {@code serializeTable}; credentials travel separately via {@code 
propagateToJob} (modelling
+   * the HIVE-20651 restore); {@code HiveTableUtil#deserializeTable} restores 
them on the
+   * deserialized FileIO.
+   *
+   * <p><b>Expects:</b> the serialized table carries no secret bytes; the 
deserialized table has
+   * the original qualified name, schema, FileIO class with non-secret 
properties only, and the
+   * vended credentials restored from the Credentials-channel key.
+   *
+   * <p><b>Why:</b> a serialized table embeds its FileIO, so the copy shipped 
through job
+   * properties must carry a FileIO with no secret state; executors re-attach 
the credentials
+   * through {@code SupportsStorageCredentials#setCredentials}.
+   */
+  @Test
+  public void vendedSerializedTableShipsSecretFreeAndRestoresCredentials() 
throws Exception {
+    CredentialFileIO fileIO = new CredentialFileIO();
+    fileIO.initialize(
+        Map.of(
+            S3FileIOProperties.SECRET_ACCESS_KEY, "prop-secret-11bb",
+            S3FileIOProperties.ENDPOINT, "http://minio:9000";));
+    fileIO.setCredentials(
+        List.of(
+            StorageCredential.create(
+                "s3://my-bucket/",
+                Map.of(S3FileIOProperties.ACCESS_KEY_ID, "access",
+                    S3FileIOProperties.SECRET_ACCESS_KEY, 
"cred-secret-11bb"))));
+
+    Schema schema = new Schema(Types.NestedField.required(1, "x", 
Types.IntegerType.get()));
+    TableMetadata metadata =
+        TableMetadata.newTableMetadata(schema, PartitionSpec.unpartitioned(), 
"s3://my-bucket/warehouse/t", Map.of());
+    String metadataLocation = "file://" +
+        
java.nio.file.Files.createTempDirectory("iceberg-strip-test").resolve("v1.metadata.json");
+    TableMetadataParser.write(metadata, 
fileIO.newOutputFile(metadataLocation));
+    Table hs2Table = new BaseTable(new StaticTableOperations(metadataLocation, 
fileIO), "ice01.db.t");
+
+    Configuration conf = new Configuration();
+    String serialized = HiveTableUtil.serializeTable(
+        IcebergVendedCredentialUtil.secretFreeCopy(hs2Table, conf), conf, new 
Properties(), null);
+
+    String rawBytes = new String(
+        java.util.Base64.getMimeDecoder().decode(serialized), 
StandardCharsets.ISO_8859_1);
+    assertThat(rawBytes)
+        .doesNotContain("prop-secret-11bb")
+        .doesNotContain("cred-secret-11bb")
+        .contains("http://minio:9000";);
+
+    Map<String, String> jobSecrets = Maps.newHashMap();
+    IcebergVendedCredentialUtil.propagateToJob(hs2Table, "ice01", null, 
jobSecrets, new Configuration());
+
+    JobConf taskConf = new JobConf();
+    taskConf.set(InputFormatConfig.SERIALIZED_TABLE_PREFIX + "db.t", 
serialized);
+    jobSecrets.forEach(taskConf::set);
+    taskConf.set(InputFormatConfig.CATALOG_NAME, "ice01");
+
+    Table executorTable = HiveTableUtil.deserializeTable(taskConf, "db.t");
+
+    assertThat(executorTable).isNotNull();
+    assertThat(executorTable.name()).isEqualTo("ice01.db.t");
+    assertThat(executorTable.schema().asStruct()).isEqualTo(schema.asStruct());
+    assertThat(executorTable.io()).isInstanceOf(CredentialFileIO.class);
+    assertThat(executorTable.io().properties())
+        .containsEntry(S3FileIOProperties.ENDPOINT, "http://minio:9000";)
+        .doesNotContainKey(S3FileIOProperties.SECRET_ACCESS_KEY);
+    assertThat(((SupportsStorageCredentials) 
executorTable.io()).credentials()).hasSize(1);
+  }
+
+  /**
+   * Tests the strip → ship → setCredentials flow for metadata tables ({@code 
db.t.files}-style
+   * queries).
+   *
+   * <p><b>Setup:</b> a {@code FILES} metadata table over a credentialed base; 
the secret-free
+   * copy is serialized and the table restored through {@code 
HiveTableUtil#deserializeTable}.
+   *
+   * <p><b>Expects:</b> the serialized bytes carry no secrets; the 
deserialized table keeps the
+   * metadata-table name and schema over a secret-free FileIO with the 
credentials restored.
+   *
+   * <p><b>Why:</b> metadata tables share the base table FileIO and would leak 
the same way; the
+   * secret-free copy must recreate the metadata-table view over the clean 
FileIO.
+   */
+  @Test
+  public void vendedMetadataTableSerializesSecretFreeAndRestoresCredentials() 
throws Exception {
+    CredentialFileIO fileIO = new CredentialFileIO();
+    fileIO.initialize(Map.of(S3FileIOProperties.SECRET_ACCESS_KEY, 
"prop-secret-3fd2"));
+    fileIO.setCredentials(
+        List.of(
+            StorageCredential.create(
+                "s3://my-bucket/",
+                Map.of(S3FileIOProperties.SECRET_ACCESS_KEY, 
"cred-secret-3fd2"))));
+
+    Schema schema = new Schema(Types.NestedField.required(1, "x", 
Types.IntegerType.get()));
+    TableMetadata metadata =
+        TableMetadata.newTableMetadata(schema, PartitionSpec.unpartitioned(), 
"s3://my-bucket/warehouse/t", Map.of());
+    String metadataLocation = "file://" +
+        
java.nio.file.Files.createTempDirectory("iceberg-strip-meta-test").resolve("v1.metadata.json");
+    TableMetadataParser.write(metadata, 
fileIO.newOutputFile(metadataLocation));
+    Table baseTable = new BaseTable(new 
StaticTableOperations(metadataLocation, fileIO), "ice01.db.t");
+    Table filesTable = 
MetadataTableUtils.createMetadataTableInstance(baseTable, 
MetadataTableType.FILES);
+
+    Configuration conf = new Configuration();
+    String serialized = HiveTableUtil.serializeTable(
+        IcebergVendedCredentialUtil.secretFreeCopy(filesTable, conf), conf, 
new Properties(), null);
+
+    String rawBytes = new String(
+        java.util.Base64.getMimeDecoder().decode(serialized), 
StandardCharsets.ISO_8859_1);
+    
assertThat(rawBytes).doesNotContain("cred-secret-3fd2").doesNotContain("prop-secret-3fd2");
+
+    Map<String, String> jobSecrets = Maps.newHashMap();
+    IcebergVendedCredentialUtil.propagateToJob(filesTable, "ice01", null, 
jobSecrets, new Configuration());
+
+    JobConf taskConf = new JobConf();
+    taskConf.set(InputFormatConfig.SERIALIZED_TABLE_PREFIX + "db.t.files", 
serialized);
+    jobSecrets.forEach(taskConf::set);
+    taskConf.set(InputFormatConfig.CATALOG_NAME, "ice01");
+
+    Table executorTable = HiveTableUtil.deserializeTable(taskConf, 
"db.t.files");
+
+    assertThat(executorTable).isNotNull();
+    assertThat(executorTable.name()).isEqualTo(filesTable.name());
+    
assertThat(executorTable.schema().asStruct()).isEqualTo(filesTable.schema().asStruct());
+    assertThat(executorTable.io().properties())
+        .doesNotContainKey(S3FileIOProperties.SECRET_ACCESS_KEY);
+    assertThat(((SupportsStorageCredentials) 
executorTable.io()).credentials()).hasSize(1);
+  }
+
+  /**
+   * Tests the HS2 commit-path variant: {@code collectOutputs} resolves the 
catalog name per table
+   * (the job-level {@code CATALOG_NAME} is not set in the DAG conf) and 
session endpoint overrides
+   * must still reach the live table's FileIO through {@code setCredentials}.
+   */
+  @Test
+  public void applyFromJobConfWithExplicitCatalogAppliesEndpointOverride() {
+    CredentialFileIO fileIO = new CredentialFileIO();
+    fileIO.setCredentials(
+        List.of(
+            StorageCredential.create(
+                "s3://my-bucket/",
+                Map.of(S3FileIOProperties.ACCESS_KEY_ID, "commit-access",
+                    S3FileIOProperties.SECRET_ACCESS_KEY, "commit-secret-4fa1",
+                    S3FileIOProperties.ENDPOINT, "http://minio:9000";))));
+    Schema schema = new Schema(Types.NestedField.required(1, "x", 
Types.IntegerType.get()));
+    TableMetadata metadata =
+        TableMetadata.newTableMetadata(schema, PartitionSpec.unpartitioned(), 
"s3://my-bucket/warehouse/t", Map.of());
+    Table table = new BaseTable(new StaticTableOperations(metadata, fileIO), 
"ice01.db.t");
+
+    JobConf jobConf = new JobConf();
+    jobConf.set("iceberg.catalog.ice01.header.X-Iceberg-Access-Delegation", 
"vended-credentials");
+    jobConf.set("iceberg.catalog.ice01." + S3FileIOProperties.ENDPOINT, 
"http://localhost:9100";);
+
+    IcebergVendedCredentialUtil.applyFromJobConf(table, "ice01", jobConf);
+
+    assertThat(fileIO.credentials()).hasSize(1);
+    assertThat(fileIO.credentials().get(0).config())
+        .containsEntry(S3FileIOProperties.ENDPOINT, "http://localhost:9100";)
+        .containsEntry(S3FileIOProperties.SECRET_ACCESS_KEY, 
"commit-secret-4fa1");
+  }
+
+  /**
+   * Pins the leak that makes the secret-free copy before serialization 
necessary.
+   *
+   * <p><b>Setup:</b> {@code SerializableTable.copyOf} stores a 
<em>reference</em> to the live
+   * table's FileIO in a non-transient field, and Java serialization walks it 
— so serializing an
+   * unsanitized copy embeds the FileIO's credential list and secret 
properties in the blob. The
+   * stub mirrors {@code S3FileIO}'s serialized shape: {@code properties} and
+   * {@code storageCredentials} are both non-transient there too (the real 
implementation is
+   * covered by the Gravitino qtest).
+   *
+   * <p><b>Expects:</b> secrets ARE present in the raw serialized bytes when 
no sanitization runs.
+   *
+   * <p><b>Why:</b> guards the premise. If Iceberg ever stops serializing 
FileIO secret state,
+   * this test fails and serializing tables as-is becomes safe again.
+   */
+  @Test
+  public void serializableTableCopyLeaksIoSecretsWithoutSanitization() {
+    CredentialFileIO fileIO = new CredentialFileIO();
+    fileIO.initialize(Map.of(S3FileIOProperties.SECRET_ACCESS_KEY, 
"prop-secret-77aa"));
+    fileIO.setCredentials(
+        List.of(
+            StorageCredential.create(
+                "s3://my-bucket/",
+                Map.of(S3FileIOProperties.ACCESS_KEY_ID, "cred-access-77aa",
+                    S3FileIOProperties.SECRET_ACCESS_KEY, 
"cred-secret-77aa"))));
+    Schema schema = new Schema(Types.NestedField.required(1, "x", 
Types.IntegerType.get()));
+    TableMetadata metadata =
+        TableMetadata.newTableMetadata(schema, PartitionSpec.unpartitioned(), 
"s3://my-bucket/warehouse/t", Map.of());
+    Table table = new BaseTable(new StaticTableOperations(metadata, fileIO), 
"db.t");
+
+    String unsanitized = 
SerializationUtil.serializeToBase64(SerializableTable.copyOf(table));
+
+    String rawBytes = new String(
+        java.util.Base64.getMimeDecoder().decode(unsanitized), 
StandardCharsets.ISO_8859_1);
+    assertThat(rawBytes)
+        .contains("cred-access-77aa")
+        .contains("cred-secret-77aa")
+        .contains("prop-secret-77aa");
+  }
+
+  /** Public with a no-arg constructor: the secret-free copy instantiates it 
reflectively via
+   * {@code CatalogUtil.loadFileIO}. File operations delegate to local Hadoop 
IO so metadata
+   * files can be written and read in tests. */
+  public static class CredentialFileIO implements FileIO, 
SupportsStorageCredentials {
+    private List<StorageCredential> credentials = List.of();
+    private Map<String, String> properties = Map.of();
+    private transient HadoopFileIO localFiles;
+
+    @Override
+    public void initialize(Map<String, String> props) {
+      this.properties = props;
+    }
+
+    private HadoopFileIO localFiles() {
+      if (localFiles == null) {
+        localFiles = new HadoopFileIO(new Configuration());
+      }
+      return localFiles;
+    }
+
+    @Override
+    public org.apache.iceberg.io.InputFile newInputFile(String path) {
+      return localFiles().newInputFile(path);
+    }
+
+    @Override
+    public org.apache.iceberg.io.OutputFile newOutputFile(String path) {
+      return localFiles().newOutputFile(path);
+    }
+
+    @Override
+    public void deleteFile(String path) {
+      localFiles().deleteFile(path);
+    }
+
+    @Override
+    public Map<String, String> properties() {
+      return properties;
+    }
+
+    @Override
+    public void close() {
+      // No-op: test stub does not hold resources.
+    }
+
+    @Override
+    public List<StorageCredential> credentials() {
+      return credentials;
+    }
+
+    @Override
+    public void setCredentials(List<StorageCredential> creds) {
+      this.credentials = creds;
+    }
+  }
+}
diff --git 
a/iceberg/iceberg-handler/src/test/queries/positive/iceberg_rest_catalog_gravitino.q
 
b/iceberg/iceberg-handler/src/test/queries/positive/iceberg_rest_catalog_gravitino.q
index 91aa14fdc17..66d11bbd870 100644
--- 
a/iceberg/iceberg-handler/src/test/queries/positive/iceberg_rest_catalog_gravitino.q
+++ 
b/iceberg/iceberg-handler/src/test/queries/positive/iceberg_rest_catalog_gravitino.q
@@ -3,6 +3,10 @@
 --! qt:replace:/(\s+neededVirtualColumns:\s)(.*)/$1#Masked#/
 -- Mask random uuid
 --! qt:replace:/(\s+'uuid'=')\S+('\s*)/$1#Masked#$2/
+-- Mask Iceberg metadata file name (sequence id + UUID) in show create table
+--! qt:replace:/('metadata_location'=')[^']+(')/$1#Masked#$2/
+-- Mask metadata_location path in describe formatted ('|' delimiter: regex 
contains s3:// so '/' breaks QTestReplaceHandler).
+--! qt:replace:|(metadata_location)(\s+)(s3://\S+)|$1$2#Masked#|
 -- Mask random uuid
 --! qt:replace:/(\s+uuid\s+)\S+(\s*)/$1#Masked#$2/
 -- Mask a random snapshot id
@@ -24,9 +28,10 @@ set hive.stats.autogather=false;
 set metastore.client.impl=org.apache.iceberg.hive.client.HiveRESTCatalogClient;
 set metastore.catalog.default=ice01;
 set iceberg.catalog.ice01.type=rest;
+set 
iceberg.catalog.ice01.header.X-Iceberg-Access-Delegation=vended-credentials;
 
---! This config is set in the driver setup (see 
TestIcebergRESTCatalogLlapLocalCliDriver.java)   
---! conf.set('iceberg.catalog.ice01.uri', <RESTServer URI>);
+--! REST URI, OAuth, MinIO + Gravitino S3 warehouse / credential vending, and 
host S3A are set in
+--! TestIcebergRESTCatalogGravitinoLlapLocalCliDriver.
 
 create database ice_rest;
 use ice_rest;
@@ -64,11 +69,6 @@ show create table ice_orc2;
 insert into ice_orc2 partition (company_id=100) 
 VALUES ('fn1','ln1', 1, 10), ('fn2','ln2', 2, 20), ('fn3','ln3', 3, 30);
 
---! In CI, Testcontainers' .withFileSystemBind() is not able to bind the same 
host path to the same container path,
---! so as a workaround, the .metadata.json files from container are manually 
synced in a daemon process,
---! since the sync can take some time, need to wait for it to happen after the 
insert operation.
-! sleep 20;
-
 describe formatted ice_orc2;
 select * from ice_orc2;
 
diff --git 
a/iceberg/iceberg-handler/src/test/results/positive/llap/iceberg_rest_catalog_gravitino.q.out
 
b/iceberg/iceberg-handler/src/test/results/positive/llap/iceberg_rest_catalog_gravitino.q.out
index 5821007cd3c..db4e5c2129a 100644
--- 
a/iceberg/iceberg-handler/src/test/results/positive/llap/iceberg_rest_catalog_gravitino.q.out
+++ 
b/iceberg/iceberg-handler/src/test/results/positive/llap/iceberg_rest_catalog_gravitino.q.out
@@ -77,14 +77,14 @@ STORED BY
 WITH SERDEPROPERTIES ( 
   'serialization.format'='1')
 LOCATION
-#### A masked pattern was here ####
+  's3://iceberg-vend/warehouse/ice_rest/ice_orc2'
 TBLPROPERTIES (
   'bucketing_version'='2', 
   
'current-schema'='{"type":"struct","schema-id":0,"fields":[{"id":1,"name":"first_name","required":false,"type":"string"},{"id":2,"name":"last_name","required":false,"type":"string"},{"id":3,"name":"dept_id","required":false,"type":"long"},{"id":4,"name":"team_id","required":false,"type":"long"},{"id":5,"name":"company_id","required":false,"type":"long"}]}',
 
   
'default-partition-spec'='{"spec-id":0,"fields":[{"name":"company_id","transform":"identity","source-id":5,"field-id":1000}]}',
 
   'format-version'='2', 
   'iceberg.catalog'='ice01', 
-#### A masked pattern was here ####
+  'metadata_location'='#Masked#', 
   'name'='ice_rest.ice_orc2', 
   'parquet.compression'='zstd', 
   'serialization.format'='1', 
@@ -139,7 +139,7 @@ Table Parameters:
        default-partition-spec  
{\"spec-id\":0,\"fields\":[{\"name\":\"company_id\",\"transform\":\"identity\",\"source-id\":5,\"field-id\":1000}]}
        format-version          2                   
        iceberg.catalog         ice01               
-#### A masked pattern was here ####
+       metadata_location       #Masked#
        name                    ice_rest.ice_orc2   
        numFiles                1                   
        numRows                 3                   
@@ -220,7 +220,7 @@ Table Type:                 VIRTUAL_VIEW
 Table Parameters:               
        bucketing_version       2                   
        current-schema          
{\"type\":\"struct\",\"schema-id\":0,\"fields\":[{\"id\":1,\"name\":\"first_name\",\"required\":false,\"type\":\"string\"},{\"id\":2,\"name\":\"last_name\",\"required\":false,\"type\":\"string\"}]}
-#### A masked pattern was here ####
+       metadata_location       #Masked#
        storage_handler         
org.apache.iceberg.mr.hive.HiveIcebergStorageHandler
        table_type              ICEBERG-VIEW        
        type                    rest                
@@ -279,7 +279,7 @@ Table Type:                 VIRTUAL_VIEW
 Table Parameters:               
        bucketing_version       2                   
        current-schema          
{\"type\":\"struct\",\"schema-id\":0,\"fields\":[{\"id\":1,\"name\":\"first_name\",\"required\":false,\"type\":\"string\"},{\"id\":2,\"name\":\"last_name\",\"required\":false,\"type\":\"string\"}]}
-#### A masked pattern was here ####
+       metadata_location       #Masked#
        storage_handler         
org.apache.iceberg.mr.hive.HiveIcebergStorageHandler
        table_type              ICEBERG-VIEW        
        type                    rest                
@@ -336,7 +336,7 @@ Table Type:                 VIRTUAL_VIEW
 Table Parameters:               
        bucketing_version       2                   
        current-schema          
{\"type\":\"struct\",\"schema-id\":1,\"fields\":[{\"id\":1,\"name\":\"_c0\",\"required\":false,\"type\":\"string\"}]}
-#### A masked pattern was here ####
+       metadata_location       #Masked#
        storage_handler         
org.apache.iceberg.mr.hive.HiveIcebergStorageHandler
        table_type              ICEBERG-VIEW        
        type                    rest                
@@ -405,7 +405,7 @@ Table Type:                 VIRTUAL_VIEW
 Table Parameters:               
        bucketing_version       2                   
        current-schema          
{\"type\":\"struct\",\"schema-id\":0,\"fields\":[{\"id\":1,\"name\":\"first_name\",\"required\":false,\"type\":\"string\"},{\"id\":2,\"name\":\"_c1\",\"required\":false,\"type\":\"string\"}]}
-#### A masked pattern was here ####
+       metadata_location       #Masked#
        storage_handler         
org.apache.iceberg.mr.hive.HiveIcebergStorageHandler
        table_type              ICEBERG-VIEW        
        type                    rest                
diff --git a/itests/qtest-iceberg/pom.xml b/itests/qtest-iceberg/pom.xml
index 3dc736007f4..035c79a1445 100644
--- a/itests/qtest-iceberg/pom.xml
+++ b/itests/qtest-iceberg/pom.xml
@@ -140,6 +140,11 @@
       <artifactId>junit</artifactId>
       <scope>test</scope>
     </dependency>
+    <dependency>
+      <groupId>org.assertj</groupId>
+      <artifactId>assertj-core</artifactId>
+      <scope>test</scope>
+    </dependency>
     <dependency>
       <groupId>com.sun.jersey</groupId>
       <artifactId>jersey-servlet</artifactId>
@@ -156,6 +161,11 @@
       <artifactId>hadoop-archives</artifactId>
       <scope>test</scope>
     </dependency>
+    <dependency>
+      <groupId>org.apache.hadoop</groupId>
+      <artifactId>hadoop-aws</artifactId>
+      <scope>test</scope>
+    </dependency>
     <dependency>
       <groupId>org.apache.hadoop</groupId>
       <artifactId>hadoop-common</artifactId>
@@ -271,6 +281,10 @@
       <artifactId>hadoop-mapreduce-client-core</artifactId>
       <scope>test</scope>
     </dependency>
+    <dependency>
+      <groupId>org.apache.hive</groupId>
+      <artifactId>hive-iceberg-handler</artifactId>
+    </dependency>
     <dependency>
       <groupId>org.apache.hive</groupId>
       <artifactId>hive-iceberg-handler</artifactId>
@@ -364,6 +378,12 @@
       <artifactId>hbase-mapreduce</artifactId>
       <scope>test</scope>
     </dependency>
+    <dependency>
+      <groupId>org.apache.iceberg</groupId>
+      <artifactId>iceberg-aws</artifactId>
+      <version>${iceberg.version}</version>
+      <scope>test</scope>
+    </dependency>
     <dependency>
       <groupId>org.apache.tez</groupId>
       <artifactId>tez-tests</artifactId>
@@ -496,6 +516,12 @@
         </exclusion>
       </exclusions>
     </dependency>
+    <dependency>
+      <groupId>software.amazon.awssdk</groupId>
+      <artifactId>bundle</artifactId>
+      <version>${aws-java-sdk.version}</version>
+      <scope>test</scope>
+    </dependency>
     <dependency>
       <groupId>org.testcontainers</groupId>
       <artifactId>testcontainers</artifactId>
diff --git 
a/itests/qtest-iceberg/src/test/java/org/apache/hadoop/hive/cli/TestIcebergRESTCatalogGravitinoLlapLocalCliDriver.java
 
b/itests/qtest-iceberg/src/test/java/org/apache/hadoop/hive/cli/TestIcebergRESTCatalogGravitinoLlapLocalCliDriver.java
index 89545f83a07..4c04578e244 100644
--- 
a/itests/qtest-iceberg/src/test/java/org/apache/hadoop/hive/cli/TestIcebergRESTCatalogGravitinoLlapLocalCliDriver.java
+++ 
b/itests/qtest-iceberg/src/test/java/org/apache/hadoop/hive/cli/TestIcebergRESTCatalogGravitinoLlapLocalCliDriver.java
@@ -18,9 +18,6 @@
 
 package org.apache.hadoop.hive.cli;
 
-import com.github.dockerjava.api.command.CopyArchiveFromContainerCmd;
-import org.apache.commons.compress.archivers.tar.TarArchiveEntry;
-import org.apache.commons.compress.archivers.tar.TarArchiveInputStream;
 import org.apache.commons.io.FileUtils;
 import org.apache.hadoop.conf.Configuration;
 import org.apache.hadoop.hive.cli.control.CliAdapter;
@@ -28,7 +25,10 @@
 import org.apache.hadoop.hive.metastore.conf.MetastoreConf;
 import org.apache.hadoop.hive.ql.session.SessionState;
 import org.apache.iceberg.CatalogUtil;
+import org.apache.iceberg.aws.AwsClientProperties;
+import org.apache.iceberg.aws.s3.S3FileIOProperties;
 import org.apache.iceberg.hive.IcebergCatalogProperties;
+import org.apache.iceberg.hive.client.RestAccessDelegationMode;
 import org.apache.iceberg.hive.client.HiveRESTCatalogClient;
 import org.apache.iceberg.rest.extension.OAuth2AuthorizationServer;
 import org.junit.After;
@@ -42,13 +42,13 @@
 import org.junit.runners.Parameterized.Parameters;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
+import org.testcontainers.containers.GenericContainer;
 import org.testcontainers.containers.Network;
 import org.testcontainers.containers.output.Slf4jLogConsumer;
 import org.testcontainers.containers.wait.strategy.Wait;
 import org.testcontainers.containers.wait.strategy.WaitAllStrategy;
 import org.testcontainers.utility.DockerImageName;
 import org.testcontainers.utility.MountableFile;
-import org.testcontainers.containers.GenericContainer;
 
 import java.io.File;
 import java.io.IOException;
@@ -59,22 +59,30 @@
 import java.nio.file.Paths;
 import java.time.Duration;
 import java.util.List;
-import java.util.concurrent.Executors;
-import java.util.concurrent.ScheduledExecutorService;
-import java.util.concurrent.TimeUnit;
 
+/**
+ * LLAP {@link CliAdapter} qtests for Hive against the Gravitino Iceberg REST 
server image
+ * ({@link #GRAVITINO_IMAGE}), with OAuth2 on the catalog HTTP API and an 
Iceberg warehouse on
+ * Apache Ozone S3 Gateway using Gravitino {@code s3-secret-key} credential 
vending (see
+ * {@link #GRAVITINO_S3_CONF_TEMPLATE}).
+ *
+ * <p>Table metadata and data live under {@code s3://} in {@link #S3_BUCKET}. 
The host temp directory
+ * {@link #warehouseDir} is used to render the Gravitino server configuration 
and as the source path for
+ * {@code .withCopyFileToContainer} (config + H2 driver JAR).</p>
+ */
 @RunWith(Parameterized.class)
 public class TestIcebergRESTCatalogGravitinoLlapLocalCliDriver {
 
   private static final CliAdapter CLI_ADAPTER =
       new 
CliConfigs.TestIcebergRESTCatalogGravitinoLlapLocalCliDriver().getCliAdapter();
-  
+
   private static final Logger LOG = 
LoggerFactory.getLogger(TestIcebergRESTCatalogGravitinoLlapLocalCliDriver.class);
-  
+
   private static final String CATALOG_NAME = "ice01";
   private static final long GRAVITINO_STARTUP_TIMEOUT_MINUTES = 5L;
   private static final int GRAVITINO_HTTP_PORT = 9001;
-  private static final String GRAVITINO_CONF_FILE_TEMPLATE = 
"gravitino-h2-test-template.conf";
+
+  private static final String GRAVITINO_S3_CONF_TEMPLATE = 
"gravitino-s3-vended-oauth-template.conf";
   private static final String GRAVITINO_ROOT_DIR = 
"/root/gravitino-iceberg-rest-server";
   private static final String GRAVITINO_STARTUP_SCRIPT = GRAVITINO_ROOT_DIR + 
"/bin/start-iceberg-rest-server.sh";
   private static final String GRAVITINO_H2_LIB = GRAVITINO_ROOT_DIR + 
"/libs/h2-driver.jar";
@@ -82,6 +90,8 @@ public class 
TestIcebergRESTCatalogGravitinoLlapLocalCliDriver {
   private static final DockerImageName GRAVITINO_IMAGE =
       DockerImageName.parse("apache/gravitino-iceberg-rest:1.0.0");
 
+  private static final String S3_BUCKET = "iceberg-vend";
+
   private static final String OAUTH2_SERVER_ICEBERG_CLIENT_ID = 
"iceberg-client";
   private static final String OAUTH2_SERVER_ICEBERG_CLIENT_SECRET = 
"iceberg-client-secret";
 
@@ -89,8 +99,8 @@ public class 
TestIcebergRESTCatalogGravitinoLlapLocalCliDriver {
   private final File qfile;
 
   private GenericContainer<?> gravitinoContainer;
+  private OzoneS3GatewayContainers ozoneS3;
   private Path warehouseDir;
-  private final ScheduledExecutorService fileSyncExecutor = 
Executors.newSingleThreadScheduledExecutor();
   private OAuth2AuthorizationServer oAuth2AuthorizationServer;
 
   @Parameters(name = "{0}")
@@ -110,14 +120,15 @@ public 
TestIcebergRESTCatalogGravitinoLlapLocalCliDriver(String name, File qfile
   }
 
   @Before
-  public void setup() throws IOException {
+  public void setup() throws Exception {
     Network dockerNetwork = Network.newNetwork();
-    
+
     startOAuth2AuthorizationServer(dockerNetwork);
     createWarehouseDir();
+    ozoneS3 = new OzoneS3GatewayContainers();
+    ozoneS3.start(dockerNetwork, S3_BUCKET);
     prepareGravitinoConfig();
     startGravitinoContainer(dockerNetwork);
-    fileSyncExecutor.scheduleAtFixedRate(this::syncWarehouseDir, 0, 5, 
TimeUnit.SECONDS);
 
     String host = gravitinoContainer.getHost();
     Integer port = gravitinoContainer.getMappedPort(GRAVITINO_HTTP_PORT);
@@ -137,63 +148,123 @@ public void setup() throws IOException {
     conf.set(restCatalogPrefix + "rest.auth.type", "oauth2");
     conf.set(restCatalogPrefix + "oauth2-server-uri", 
oAuth2AuthorizationServer.getTokenEndpoint());
     conf.set(restCatalogPrefix + "credential", 
oAuth2AuthorizationServer.getClientCredential());
+    conf.set(
+        restCatalogPrefix + 
IcebergCatalogProperties.REST_ACCESS_DELEGATION_HEADER_PROPERTY,
+        RestAccessDelegationMode.VENDED_CREDENTIALS.modeName());
+
+    applyHostS3FilesystemSettings(conf);
+    applyIcebergS3ClientEndpointOverride(conf, restCatalogPrefix);
   }
 
   @After
-  public void teardown() throws IOException {
+  public void teardown() throws Exception {
     if (gravitinoContainer != null) {
       gravitinoContainer.stop();
     }
-    
+
+    if (ozoneS3 != null) {
+      ozoneS3.stop();
+    }
+
     if (oAuth2AuthorizationServer != null) {
       oAuth2AuthorizationServer.stop();
     }
 
-    fileSyncExecutor.shutdownNow();
-    FileUtils.deleteDirectory(warehouseDir.toFile());
+    if (warehouseDir != null) {
+      FileUtils.deleteDirectory(warehouseDir.toFile());
+    }
+  }
+
+  /**
+   * Puts host-reachable Iceberg S3 client settings on the HS2 session.
+   *
+   * <p>Gravitino runs in Docker and vends storage credentials whose endpoint 
is
+   * {@link OzoneS3GatewayContainers#S3_DOCKER_ENDPOINT} (reachable inside the 
compose network only).
+   * Hive runs on the host, so this sets {@code 
iceberg.catalog.&lt;catalog&gt;.s3.endpoint} to the
+   * published Ozone S3G host/port, plus path-style and region, matching what 
operators configure in
+   * {@code hive-site.xml} in a real deployment.
+   *
+   * <p>Those keys are the <em>source</em> for {@link 
org.apache.iceberg.mr.hive.IcebergVendedCredentialUtil}:
+   * at plan time and on tasks it merges them over the vended endpoint in 
credentials and job conf. This
+   * method does not replace that util — it seeds session conf so the util has 
a host endpoint to apply.
+   */
+  private void applyIcebergS3ClientEndpointOverride(Configuration conf, String 
restCatalogPrefix) {
+    String host = ozoneS3.getHost();
+    int port = ozoneS3.getMappedPort();
+    @SuppressWarnings("HttpUrlsUsage")
+    String icebergS3Endpoint = String.format("http://%s:%d";, host, port);
+    conf.set(restCatalogPrefix + S3FileIOProperties.ENDPOINT, 
icebergS3Endpoint);
+    conf.set(restCatalogPrefix + S3FileIOProperties.PATH_STYLE_ACCESS, "true");
+    conf.set(restCatalogPrefix + AwsClientProperties.CLIENT_REGION, 
"us-east-1");
+  }
+
+  /**
+   * Wires Hadoop to use S3A for {@code s3://} on the host-visible Ozone S3G 
endpoint.
+   *
+   * <p><b>What:</b> sets {@code fs.s3}/{@code fs.s3a} implementation classes, 
per-bucket endpoint and path-style
+   * ({@code fs.s3a.bucket.&lt;bucket&gt;.*}), and disables SSL for this local 
Ozone S3G test.
+   *
+   * <p><b>Why:</b> Hive and Tez use Hadoop {@code FileSystem} for many {@code 
s3://} paths, not only Iceberg
+   * {@code S3FileIO}. {@link 
org.apache.iceberg.mr.hive.IcebergVendedCredentialUtil} propagates vended keys 
and
+   * overlapping S3A settings onto <em>job</em> conf at plan time; it does not 
register the S3A filesystem or seed
+   * session defaults. Session wiring is still needed for HS2 and for paths 
that read session conf before job
+   * properties exist.
+   *
+   * <p>Access keys are intentionally omitted here; they are vended per query 
and copied into job secrets by the util.
+   */
+  private void applyHostS3FilesystemSettings(Configuration conf) {
+    String s3Host = ozoneS3.getHost();
+    int s3Port = ozoneS3.getMappedPort();
+    @SuppressWarnings("HttpUrlsUsage")
+    String endpoint = String.format("http://%s:%d";, s3Host, s3Port);
+    conf.set("fs.s3.impl", "org.apache.hadoop.fs.s3a.S3AFileSystem");
+    conf.set("fs.AbstractFileSystem.s3.impl", "org.apache.hadoop.fs.s3a.S3A");
+    conf.set("fs.s3a.impl", "org.apache.hadoop.fs.s3a.S3AFileSystem");
+    String bucketPrefix = "fs.s3a.bucket." + S3_BUCKET + ".";
+    conf.set(bucketPrefix + "endpoint", endpoint);
+    conf.setBoolean(bucketPrefix + "path.style.access", true);
+    conf.setBoolean(bucketPrefix + "connection.ssl.enabled", false);
   }
 
   /**
-   * Starts a Gravitino container with the Iceberg REST server configured for 
testing.
+   * Starts a Gravitino container with the Iceberg REST server configured for 
this test.
    *
    * <p>This method configures the container to:
    * <ul>
-   *   <li>Expose container REST port GRAVITINO_HTTP_PORT and map it to a host 
port.</li>
-   *   <li>Modify the container entrypoint to create the warehouse directory 
before startup.</li>
-   *   <li>Copy a dynamically prepared Gravitino configuration file into the 
container.</li>
-   *   <li>Copy the H2 driver JAR into the server's lib directory.</li>
-   *   <li>Wait for the Gravitino Iceberg REST server to finish starting 
(based on logs and port checks).</li>
-   *   <li>Stream container logs into the test logger for easier 
debugging.</li>
+   *   <li>Expose {@link #GRAVITINO_HTTP_PORT} on the container and map it to 
a host port.</li>
+   *   <li>Adjust the entrypoint so a bootstrap directory exists before {@link 
#GRAVITINO_STARTUP_SCRIPT} runs
+   *       (the Iceberg <em>warehouse</em> itself is {@code s3://} on Ozone 
S3G, not this path).</li>
+   *   <li>Copy the rendered Gravitino configuration from {@link 
#warehouseDir} into the image at
+   *       {@link #GRAVITINO_CONF_FILE}.</li>
+   *   <li>Copy the H2 driver JAR (JDBC catalog backend metadata) into {@link 
#GRAVITINO_H2_LIB}.</li>
+   *   <li>Attach the container to {@code dockerNetwork} so it reaches the 
OAuth2 server and Ozone S3G
+   *       ({@link OzoneS3GatewayContainers#S3_DOCKER_ALIAS}).</li>
+   *   <li>Wait for the Gravitino Iceberg REST server to finish starting (log 
line + listening port).</li>
+   *   <li>Stream container logs into {@link #LOG}.</li>
    * </ul>
    *
-   * <p>Note: The {@code @SuppressWarnings("resource")} annotation is applied 
because
-   * IntelliJ and some compilers flag {@link 
org.testcontainers.containers.GenericContainer}
-   * as a resource that should be managed with try-with-resources. In this 
test setup,
-   * the container lifecycle is managed explicitly: it is started here and 
stopped in
-   * {@code @After} (via {@code gravitinoContainer.stop()}). Using 
try-with-resources
-   * would not work in this context, since the container must remain running 
across
-   * multiple test methods rather than being confined to a single block 
scope.</p>
+   * <p>Note: the {@code @SuppressWarnings("resource")} annotation is applied 
because IntelliJ and some compilers
+   * flag {@link GenericContainer} as a resource that should be used with 
try-with-resources. Here the container
+   * lifecycle is explicit: it is started in this method and stopped in {@link 
#teardown()} via
+   * {@code gravitinoContainer.stop()}.</p>
    */
   @SuppressWarnings("resource")
   private void startGravitinoContainer(Network dockerNetwork) {
     gravitinoContainer = new GenericContainer<>(GRAVITINO_IMAGE)
         .withExposedPorts(GRAVITINO_HTTP_PORT)
-        // Update entrypoint to create the warehouse directory before starting 
the server
+        // Bootstrap dir for the server script; warehouse is s3:// on Ozone 
S3G (see template)
         .withCreateContainerCmdModifier(cmd -> cmd.withEntrypoint("bash", "-c",
-            String.format("mkdir -p %s && exec %s", warehouseDir.toString(), 
GRAVITINO_STARTUP_SCRIPT)))
-        // Mount gravitino configuration file
+            "mkdir -p /tmp/gravitino-bootstrap && exec " + 
GRAVITINO_STARTUP_SCRIPT))
+        // Mount Gravitino configuration file (rendered under warehouseDir on 
the host)
         .withCopyFileToContainer(
-            MountableFile.forHostPath(Paths.get(warehouseDir.toString(), 
GRAVITINO_CONF_FILE_TEMPLATE)),
-            GRAVITINO_CONF_FILE
-        )
+            MountableFile.forHostPath(Paths.get(warehouseDir.toString(), 
GRAVITINO_S3_CONF_TEMPLATE)),
+            GRAVITINO_CONF_FILE)
         // Mount the H2 driver JAR into the server's lib directory
         .withCopyFileToContainer(
             MountableFile.forHostPath(
-                Paths.get("target", "test-dependencies", 
"h2-driver.jar").toAbsolutePath()
-            ),
-            GRAVITINO_H2_LIB
-        )
-        // Use the same Docker network as the OAuth2 server so they can 
communicate
+                Paths.get("target", "test-dependencies", 
"h2-driver.jar").toAbsolutePath()),
+            GRAVITINO_H2_LIB)
+        // Same Docker network as OAuth2 and Ozone S3G (Gravitino uses 
http://s3.ozone:9878 in config)
         .withNetwork(dockerNetwork)
         // Wait for the server to be fully started
         .waitingFor(
@@ -201,123 +272,71 @@ private void startGravitinoContainer(Network 
dockerNetwork) {
                 .withStrategy(Wait.forLogMessage(".*GravitinoIcebergRESTServer 
is running.*\\n", 1)
                     
.withStartupTimeout(Duration.ofMinutes(GRAVITINO_STARTUP_TIMEOUT_MINUTES)))
                 .withStrategy(Wait.forListeningPort()
-                    
.withStartupTimeout(Duration.ofMinutes(GRAVITINO_STARTUP_TIMEOUT_MINUTES)))
-        )
-        .withLogConsumer(new Slf4jLogConsumer(LoggerFactory
-            
.getLogger(TestIcebergRESTCatalogGravitinoLlapLocalCliDriver.class)));
+                    
.withStartupTimeout(Duration.ofMinutes(GRAVITINO_STARTUP_TIMEOUT_MINUTES))))
+        .withLogConsumer(new Slf4jLogConsumer(LOG));
 
     gravitinoContainer.start();
   }
 
-  /**
-   * Starts a background daemon that continuously synchronizes the Iceberg 
warehouse
-   * directory from the running Gravitino container to the host file system.
-   *
-   * <p>In CI environments, Testcontainers' {@code .withFileSystemBind()} 
cannot reliably
-   * bind the same host path to the same path inside the container, especially 
when
-   * using remote Docker hosts or Docker-in-Docker setups. This causes the 
container's
-   * writes (e.g., Iceberg metadata files like {@code .metadata.json}) to be 
invisible
-   * on the host.</p>
-   *
-   * <p>This method works around that limitation by repeatedly copying new 
files from
-   * the container's warehouse directory to the corresponding host directory. 
Existing
-   * files on the host are preserved, and only files that do not yet exist are 
copied.
-   * The sync runs every 1 second while the container is running.</p>
-   *
-   * <p>Each archive copy from the container is extracted using a {@link 
TarArchiveInputStream},
-   * and directories are created as needed. Files that already exist on the 
host are skipped
-   * to avoid overwriting container data.</p>
-   */
-  private void syncWarehouseDir() {
-    if (gravitinoContainer.isRunning()) {
-      try (CopyArchiveFromContainerCmd copyArchiveFromContainerCmd = 
-               gravitinoContainer
-                   .getDockerClient()
-                   
.copyArchiveFromContainerCmd(gravitinoContainer.getContainerId(), 
warehouseDir.toString()); 
-           InputStream tarStream = copyArchiveFromContainerCmd.exec();
-           TarArchiveInputStream tis = new TarArchiveInputStream(tarStream)) {
-
-        TarArchiveEntry entry;
-        while ((entry = tis.getNextEntry()) != null) {
-          // Skip directories because we only want to copy metadata files from 
the container.
-          if (entry.isDirectory()) {
-            continue;
-          }
-
-          /*
-           * Tar entry names include a container-specific top-level folder, 
e.g.:
-           *   
iceberg-test-1759245909247/iceberg_warehouse/ice_rest/.../metadata.json
-           *
-           * Strip the first part so the relative path inside the warehouse is 
preserved
-           * when mapping to the host warehouseDir.
-           */
-          
-          String[] parts = entry.getName().split("/", 2);
-          if (parts.length < 2) {
-            continue; // defensive guard
-          }
-
-          Path relativePath = Paths.get(parts[1]);
-          Path outputPath = warehouseDir.resolve(relativePath);
-
-          // Skip if already present on host to avoid overwriting
-          if (Files.exists(outputPath)) {
-            continue;
-          }
-
-          Files.createDirectories(outputPath.getParent());
-          Files.copy(tis, outputPath);
-        }
-
-      } catch (Exception e) {
-        LOG.error("Warehouse folder sync failed: {}", e.getMessage());
-      }
-    }
-  }
-  
+  /** Keycloak-backed OAuth2 used by Gravitino REST authentication and by the 
Hive REST client. */
   private void startOAuth2AuthorizationServer(Network dockerNetwork) {
     oAuth2AuthorizationServer = new OAuth2AuthorizationServer(dockerNetwork, 
false);
     oAuth2AuthorizationServer.start();
   }
 
+  /**
+   * Host directory used to write the rendered Gravitino config (see {@link 
#prepareGravitinoConfig}) and as the
+   * source path for {@code .withCopyFileToContainer} in {@link 
#startGravitinoContainer}. This is not the Iceberg
+   * warehouse root; the warehouse is {@code s3://}{@link #S3_BUCKET}{@code 
/...} on Ozone S3G.
+   */
   private void createWarehouseDir() {
     try {
       warehouseDir = Paths.get("/tmp", "iceberg-test-" + 
System.currentTimeMillis()).toAbsolutePath();
       Files.createDirectories(warehouseDir);
     } catch (Exception e) {
-      throw new RuntimeException("Failed to create the Iceberg warehouse 
directory", e);
+      throw new RuntimeException("Failed to create temp directory for 
Gravitino config staging", e);
     }
   }
 
+  /**
+   * Reads {@link #GRAVITINO_S3_CONF_TEMPLATE} from the classpath, substitutes 
bucket / Ozone S3 / OAuth placeholders,
+   * and writes the result under {@link #warehouseDir} for copying into the 
Gravitino container.
+   */
   private void prepareGravitinoConfig() throws IOException {
     String content;
     try (InputStream in = 
TestIcebergRESTCatalogGravitinoLlapLocalCliDriver.class.getClassLoader()
-        .getResourceAsStream(GRAVITINO_CONF_FILE_TEMPLATE)) {
+        .getResourceAsStream(GRAVITINO_S3_CONF_TEMPLATE)) {
       if (in == null) {
-        throw new IOException("Resource not found: " + 
GRAVITINO_CONF_FILE_TEMPLATE);
+        throw new IOException("Resource not found: " + 
GRAVITINO_S3_CONF_TEMPLATE);
       }
       content = new String(in.readAllBytes(), StandardCharsets.UTF_8);
     }
 
     String updatedContent = content
-        .replace("/WAREHOUSE_DIR", warehouseDir.toString())
+        .replace("S3_BUCKET", S3_BUCKET)
+        .replace("S3_ACCESS_KEY", OzoneS3GatewayContainers.ACCESS_KEY)
+        .replace("S3_SECRET_KEY", OzoneS3GatewayContainers.SECRET_KEY)
         .replace("OAUTH2_SERVER_URI", oAuth2AuthorizationServer.getIssuer())
         .replace("OAUTH2_JWKS_URI", getJwksUri())
         .replace("OAUTH2_CLIENT_ID", OAUTH2_SERVER_ICEBERG_CLIENT_ID)
         .replace("OAUTH2_CLIENT_SECRET", OAUTH2_SERVER_ICEBERG_CLIENT_SECRET)
         .replace("HTTP_PORT", String.valueOf(GRAVITINO_HTTP_PORT));
 
-    Path configFile = warehouseDir.resolve(GRAVITINO_CONF_FILE_TEMPLATE);
+    Path configFile = warehouseDir.resolve(GRAVITINO_S3_CONF_TEMPLATE);
     Files.writeString(configFile, updatedContent);
   }
 
+  /**
+   * JWKS URL reachable from <em>inside</em> the Gravitino container: 
host/port in the issuer are rewritten to the
+   * Keycloak container hostname and its internal HTTP port.
+   */
   private String getJwksUri() {
     String reachableHost = 
oAuth2AuthorizationServer.getKeycloackContainerDockerInternalHostName();
     int internalPort = 8080; // Keycloak container's internal port
     return oAuth2AuthorizationServer.getIssuer()
         .replace("localhost", reachableHost)
         .replace("127.0.0.1", reachableHost)
-        // replace issuer's mapped port with keyclock container's internal port
+        // Replace issuer's mapped host port with Keycloak's internal port on 
the Docker network
         .replaceFirst(":[0-9]+", ":" + internalPort);
   }
 
diff --git 
a/itests/qtest-iceberg/src/test/resources/gravitino-s3-vended-oauth-template.conf
 
b/itests/qtest-iceberg/src/test/resources/gravitino-s3-vended-oauth-template.conf
new file mode 100644
index 00000000000..9f78ec628da
--- /dev/null
+++ 
b/itests/qtest-iceberg/src/test/resources/gravitino-s3-vended-oauth-template.conf
@@ -0,0 +1,37 @@
+gravitino.iceberg-rest.httpPort = HTTP_PORT
+
+# --- Iceberg REST Catalog Backend (JDBC for catalog metadata) ---
+gravitino.iceberg-rest.catalog-backend = jdbc
+gravitino.iceberg-rest.uri = jdbc:h2:file:/tmp/gravitino_h2_db;AUTO_SERVER=TRUE
+gravitino.iceberg-rest.jdbc-driver = org.h2.Driver
+gravitino.iceberg-rest.jdbc-user = sa
+gravitino.iceberg-rest.jdbc-password = ""
+gravitino.iceberg-rest.jdbc-initialize = true
+
+# --- Warehouse on Ozone S3G + credential vending (s3-secret-key) ---
+gravitino.iceberg-rest.warehouse = s3://S3_BUCKET/warehouse
+gravitino.iceberg-rest.io-impl = org.apache.iceberg.aws.s3.S3FileIO
+gravitino.iceberg-rest.credential-providers = s3-secret-key
+gravitino.iceberg-rest.s3-access-key-id = S3_ACCESS_KEY
+gravitino.iceberg-rest.s3-secret-access-key = S3_SECRET_KEY
+# Docker network only (Gravitino container -> Ozone S3G). Host-side 
Hive/Tez/Iceberg use
+# iceberg.catalog.ice01.s3.endpoint (no static keys); credentials are vended 
per table load.
+gravitino.iceberg-rest.s3-endpoint = http://s3.ozone:9878
+gravitino.iceberg-rest.s3-path-style-access = true
+gravitino.iceberg-rest.s3-region = us-east-1
+
+# --- OAuth2 Authentication (catalog HTTP API) ---
+gravitino.authenticators = oauth
+
+gravitino.authenticator.oauth.serverUri = OAUTH2_SERVER_URI
+gravitino.authenticator.oauth.tokenPath = /protocol/openid-connect/token
+gravitino.authenticator.oauth.clientId = OAUTH2_CLIENT_ID
+gravitino.authenticator.oauth.scope = openid catalog
+gravitino.authenticator.oauth.clientSecret = OAUTH2_CLIENT_SECRET
+
+gravitino.authenticator.oauth.tokenValidatorClass = 
org.apache.gravitino.server.authentication.JwksTokenValidator
+gravitino.authenticator.oauth.jwksUri = 
OAUTH2_JWKS_URI/protocol/openid-connect/certs
+gravitino.authenticator.oauth.provider = default
+gravitino.authenticator.oauth.principalFields = sub
+gravitino.authenticator.oauth.allowSkewSecs = 60
+gravitino.authenticator.oauth.serviceAudience = hive-metastore
diff --git a/itests/util/pom.xml b/itests/util/pom.xml
index 7c26283e502..54b5ab17756 100644
--- a/itests/util/pom.xml
+++ b/itests/util/pom.xml
@@ -197,6 +197,10 @@
       <groupId>junit</groupId>
       <artifactId>junit</artifactId>
     </dependency>
+    <dependency>
+      <groupId>org.testcontainers</groupId>
+      <artifactId>testcontainers</artifactId>
+    </dependency>
     <dependency>
       <groupId>org.testcontainers</groupId>
       <artifactId>mariadb</artifactId>
diff --git 
a/itests/util/src/main/java/org/apache/hadoop/hive/cli/OzoneS3GatewayContainers.java
 
b/itests/util/src/main/java/org/apache/hadoop/hive/cli/OzoneS3GatewayContainers.java
new file mode 100644
index 00000000000..6a6186c70f5
--- /dev/null
+++ 
b/itests/util/src/main/java/org/apache/hadoop/hive/cli/OzoneS3GatewayContainers.java
@@ -0,0 +1,203 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hadoop.hive.cli;
+
+import java.time.Duration;
+import java.util.HashMap;
+import java.util.Map;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.testcontainers.containers.GenericContainer;
+import org.testcontainers.containers.Network;
+import org.testcontainers.containers.wait.strategy.Wait;
+import org.testcontainers.containers.wait.strategy.WaitAllStrategy;
+import org.testcontainers.utility.DockerImageName;
+
+/**
+ * Minimal Apache Ozone cluster with S3 Gateway for q-tests that need an 
S3-compatible endpoint.
+ *
+ * <p>Layout matches {@code 
packaging/src/docker/storage/ozone/docker-compose.yml}: scm, om, datanode,
+ * and s3g on a shared Docker network. S3G is reachable as {@link 
#S3_DOCKER_ALIAS}:{@link #S3G_PORT}
+ * from other containers (for example Gravitino) and via the published host 
port from the test JVM.
+ */
+public final class OzoneS3GatewayContainers {
+
+  private static final Logger LOG = 
LoggerFactory.getLogger(OzoneS3GatewayContainers.class);
+  private static final DockerImageName OZONE_IMAGE = 
DockerImageName.parse("apache/ozone:2.1.0");
+
+  private static final int SCM_PORT = 9876;
+  private static final int OM_PORT = 9874;
+  /** Ozone 2.1.0 HDDS datanode client RPC port ({@code 
hdds.datanode.client.port}). */
+  private static final int DATANODE_CLIENT_PORT = 19864;
+
+  private static final String S3_BUCKET_READY_MARKER = "OZONE_S3_BUCKET_READY";
+
+  /** S3 keys used when Ozone security is disabled (same as {@code 
start-hive.sh --ozone}). */
+  public static final String ACCESS_KEY = "ozone";
+  public static final String SECRET_KEY = "secret";
+
+  public static final int S3G_PORT = 9878;
+  public static final String S3_DOCKER_ALIAS = "s3.ozone";
+  public static final String S3_DOCKER_ENDPOINT =
+      String.format("http://%s:%d";, S3_DOCKER_ALIAS, S3G_PORT);
+
+  private static final Duration STARTUP_TIMEOUT = Duration.ofMinutes(5);
+
+  private GenericContainer<?> scm;
+  private GenericContainer<?> om;
+  private GenericContainer<?> datanode;
+  private GenericContainer<?> s3g;
+
+  /**
+   * Starts scm → om → datanode → s3g on {@code network} and creates {@code 
bucketName} under the
+   * default S3 volume ({@code /s3v}).
+   */
+  @SuppressWarnings("resource")
+  public void start(Network network, String bucketName) {
+    Map<String, String> commonEnv = commonEnv();
+    scm = startScm(network, commonEnv);
+    om = startOm(network, commonEnv);
+    datanode = startDatanode(network, commonEnv);
+    s3g = startS3g(network, commonEnv, bucketName);
+    LOG.info("Ozone S3G ready at {} (host {}:{})", S3_DOCKER_ENDPOINT, 
s3g.getHost(), s3g.getMappedPort(S3G_PORT));
+  }
+
+  public void stop() {
+    stopQuietly(s3g);
+    stopQuietly(datanode);
+    stopQuietly(om);
+    stopQuietly(scm);
+    s3g = null;
+    datanode = null;
+    om = null;
+    scm = null;
+  }
+
+  public String getHost() {
+    return s3g.getHost();
+  }
+
+  public int getMappedPort() {
+    return s3g.getMappedPort(S3G_PORT);
+  }
+
+  private static Map<String, String> commonEnv() {
+    Map<String, String> env = new HashMap<>();
+    env.put("OZONE-SITE.XML_hdds.datanode.dir", "/data/hdds");
+    env.put("OZONE-SITE.XML_ozone.metadata.dirs", "/data/metadata");
+    env.put("OZONE-SITE.XML_ozone.om.address", "om");
+    env.put("OZONE-SITE.XML_ozone.om.http-address", "om:" + OM_PORT);
+    env.put("OZONE-SITE.XML_ozone.replication", "1");
+    env.put("OZONE-SITE.XML_ozone.scm.block.client.address", "scm");
+    env.put("OZONE-SITE.XML_ozone.scm.client.address", "scm");
+    env.put("OZONE-SITE.XML_ozone.scm.datanode.id.dir", "/data/metadata");
+    env.put("OZONE-SITE.XML_ozone.scm.names", "scm");
+    env.put("OZONE-SITE.XML_hdds.scm.safemode.min.datanode", "1");
+    env.put("OZONE-SITE.XML_hdds.scm.safemode.healthy.pipeline.pct", "0");
+    env.put("OZONE-SITE.XML_hdds.scm.safemode.enabled", "false");
+    // Path-style only: host JVM and Gravitino use http://host:9878/bucket/... 
. Setting
+    // ozone.s3g.domain.name would require virtual-host Host headers (e.g. 
bucket.s3.ozone).
+    // Keep CI/docker-desktop friendly (see 
itests/test-docker/helm/ozone/values.yaml).
+    env.put("OZONE-SITE.XML_hdds.datanode.volume.min.free.space", "256MB");
+    env.put("OZONE-SITE.XML_ozone.scm.container.size", "128MB");
+    env.put("OZONE-SITE.XML_ozone.scm.block.size", "32MB");
+    env.put("no_proxy", "om,scm,s3g,localhost,127.0.0.1");
+    return env;
+  }
+
+  @SuppressWarnings("resource")
+  private static GenericContainer<?> startScm(Network network, Map<String, 
String> commonEnv) {
+    GenericContainer<?> container = new GenericContainer<>(OZONE_IMAGE)
+        .withNetwork(network)
+        .withNetworkAliases("scm")
+        .withExposedPorts(SCM_PORT)
+        .withEnv(commonEnv)
+        .withEnv("ENSURE_SCM_INITIALIZED", 
"/data/metadata/scm/current/VERSION")
+        .withCommand("ozone", "scm")
+        
.waitingFor(Wait.forListeningPort().withStartupTimeout(STARTUP_TIMEOUT))
+        .withLogConsumer(outputFrame -> LOG.debug("[ozone-scm] {}", 
outputFrame.getUtf8String().trim()));
+    container.start();
+    return container;
+  }
+
+  @SuppressWarnings("resource")
+  private static GenericContainer<?> startOm(Network network, Map<String, 
String> commonEnv) {
+    GenericContainer<?> container = new GenericContainer<>(OZONE_IMAGE)
+        .withNetwork(network)
+        .withNetworkAliases("om")
+        .withExposedPorts(OM_PORT)
+        .withEnv(commonEnv)
+        .withEnv("CORE-SITE.XML_hadoop.proxyuser.hadoop.hosts", "*")
+        .withEnv("CORE-SITE.XML_hadoop.proxyuser.hadoop.groups", "*")
+        .withEnv("ENSURE_OM_INITIALIZED", "/data/metadata/om/current/VERSION")
+        .withEnv("WAITFOR", "scm:" + SCM_PORT)
+        .withCommand("ozone", "om")
+        
.waitingFor(Wait.forListeningPort().withStartupTimeout(STARTUP_TIMEOUT))
+        .withLogConsumer(outputFrame -> LOG.debug("[ozone-om] {}", 
outputFrame.getUtf8String().trim()));
+    container.start();
+    return container;
+  }
+
+  @SuppressWarnings("resource")
+  private static GenericContainer<?> startDatanode(Network network, 
Map<String, String> commonEnv) {
+    GenericContainer<?> container = new GenericContainer<>(OZONE_IMAGE)
+        .withNetwork(network)
+        .withExposedPorts(DATANODE_CLIENT_PORT)
+        .withEnv(commonEnv)
+        .withCommand("ozone", "datanode")
+        
.waitingFor(Wait.forListeningPort().withStartupTimeout(STARTUP_TIMEOUT))
+        .withLogConsumer(outputFrame -> LOG.debug("[ozone-dn] {}", 
outputFrame.getUtf8String().trim()));
+    container.start();
+    return container;
+  }
+
+  @SuppressWarnings("resource")
+  private static GenericContainer<?> startS3g(Network network, Map<String, 
String> commonEnv, String bucketName) {
+    String bootstrap = ""
+        + "set -e\n"
+        + "ozone s3g &\n"
+        + "s3g_pid=$!\n"
+        + "until ozone sh volume list >/dev/null 2>&1; do echo 'waiting for 
OM...' && sleep 1; done\n"
+        + "ozone sh volume create /s3v || true\n"
+        + "ozone sh bucket delete /s3v/" + bucketName + " || true\n"
+        + "ozone sh bucket create /s3v/" + bucketName + "\n"
+        + "echo " + S3_BUCKET_READY_MARKER + "\n"
+        + "wait \"$s3g_pid\"\n";
+    GenericContainer<?> container = new GenericContainer<>(OZONE_IMAGE)
+        .withNetwork(network)
+        .withNetworkAliases(S3_DOCKER_ALIAS)
+        .withExposedPorts(S3G_PORT)
+        .withEnv(commonEnv)
+        .withEnv("WAITFOR", "om:" + OM_PORT)
+        .withCommand("sh", "-c", bootstrap)
+        .waitingFor(new WaitAllStrategy()
+            
.withStrategy(Wait.forListeningPort().withStartupTimeout(STARTUP_TIMEOUT))
+            .withStrategy(Wait.forLogMessage(".*" + S3_BUCKET_READY_MARKER + 
".*\\n", 1)
+                .withStartupTimeout(STARTUP_TIMEOUT)))
+        .withLogConsumer(outputFrame -> LOG.info("[ozone-s3g] {}", 
outputFrame.getUtf8String().trim()));
+    container.start();
+    return container;
+  }
+
+  private static void stopQuietly(GenericContainer<?> container) {
+    if (container != null) {
+      container.stop();
+    }
+  }
+}
diff --git a/ql/src/java/org/apache/hadoop/hive/ql/exec/FetchOperator.java 
b/ql/src/java/org/apache/hadoop/hive/ql/exec/FetchOperator.java
index 5d4bfd3dffb..8b02ec22ea5 100644
--- a/ql/src/java/org/apache/hadoop/hive/ql/exec/FetchOperator.java
+++ b/ql/src/java/org/apache/hadoop/hive/ql/exec/FetchOperator.java
@@ -375,6 +375,13 @@ private List<FetchInputFormatSplit> getNextSplits() throws 
Exception {
 
       Class<? extends InputFormat> formatter = 
currDesc.getInputFileFormatClass();
       Utilities.copyTableJobPropertiesToConf(currDesc.getTableDesc(), job);
+      // A fetch task runs in-process without a DAG, so storage-handler 
secrets (HIVE-20651) are
+      // never restored from the job Credentials as on a task. Surface them 
into this process-local
+      // conf (never serialized or shown in EXPLAIN) so in-process readers can 
access storage.
+      Map<String, String> jobSecrets = currDesc.getTableDesc().getJobSecrets();
+      if (jobSecrets != null) {
+        jobSecrets.forEach(job::set);
+      }
       InputFormat inputFormat = getInputFormatFromCache(formatter, job);
       if(inputFormat instanceof HiveSequenceFileInputFormat) {
         // input format could be cached, in which case we need to reset the 
list of files to fetch
diff --git a/ql/src/java/org/apache/hadoop/hive/ql/exec/FileSinkOperator.java 
b/ql/src/java/org/apache/hadoop/hive/ql/exec/FileSinkOperator.java
index 5d3c2dccf5c..a3d48f36a31 100644
--- a/ql/src/java/org/apache/hadoop/hive/ql/exec/FileSinkOperator.java
+++ b/ql/src/java/org/apache/hadoop/hive/ql/exec/FileSinkOperator.java
@@ -942,6 +942,11 @@ protected void createBucketForFileIdx(FSPaths fsp, int 
filesIdx)
 
       updateDPCounters(fsp, filesIdx);
 
+      try {
+        Utilities.copyJobSecretToTableProperties(conf.getTableInfo());
+      } catch (IOException e) {
+        throw new HiveException(e);
+      }
       Utilities.copyTableJobPropertiesToConf(conf.getTableInfo(), jc);
       // only create bucket files only if no dynamic partitions,
       // buckets of dynamic partitions will be created for each newly created 
partition
@@ -1669,6 +1674,11 @@ public void checkOutputSpecs(FileSystem ignored, JobConf 
job) throws IOException
 
   private void createHiveOutputFormat(JobConf job) throws HiveException {
     if (hiveOutputFormat == null) {
+      try {
+        Utilities.copyJobSecretToTableProperties(conf.getTableInfo());
+      } catch (IOException e) {
+        throw new HiveException(e);
+      }
       Utilities.copyTableJobPropertiesToConf(conf.getTableInfo(), job);
     }
     try {
diff --git a/ql/src/java/org/apache/hadoop/hive/ql/exec/Utilities.java 
b/ql/src/java/org/apache/hadoop/hive/ql/exec/Utilities.java
index f58fafc8556..8a72a22b97f 100644
--- a/ql/src/java/org/apache/hadoop/hive/ql/exec/Utilities.java
+++ b/ql/src/java/org/apache/hadoop/hive/ql/exec/Utilities.java
@@ -2517,7 +2517,7 @@ public static void 
copyJobSecretToTableProperties(TableDesc tbl) throws IOExcept
         String[] comps = keyString.split(TableDesc.SECRET_DELIMIT);
         String tblName = comps[1];
         String keyName = comps[2];
-        if (tbl.getTableName().equalsIgnoreCase(tblName)) {
+        if (tblName.equalsIgnoreCase(tbl.getTableName())) {
           tbl.getProperties().put(keyName, new 
String(credentials.getSecretKey(key)));
         }
       }
@@ -3899,8 +3899,10 @@ private static void createTmpDirs(Configuration conf,
 
       if (op instanceof FileSinkOperator) {
         FileSinkDesc fdesc = ((FileSinkOperator) op).getConf();
-        if (fdesc.isMmTable() || fdesc.isDirectInsert()) {
-          // No need to create for MM tables, or ACID insert
+        if (fdesc.isMmTable() || fdesc.isDirectInsert() ||
+            (fdesc.getTableInfo() != null && 
fdesc.getTableInfo().isNonNative())) {
+          // No need to create for MM tables or ACID insert; storage-handler 
tables manage
+          // their own output locations and commit
           continue;
         }
         Path tempDir = fdesc.getDirName();


Reply via email to