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

roryqi pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/gravitino.git


The following commit(s) were added to refs/heads/main by this push:
     new e692475f36 [#10684] feat(iceberg-rest): Support vended credentials on 
registerTable endpoint (#10699)
e692475f36 is described below

commit e692475f36e11f671bf05a15e0893aabf73e4246
Author: Sachin Ranjalkar <[email protected]>
AuthorDate: Wed Apr 29 16:21:08 2026 +0530

    [#10684] feat(iceberg-rest): Support vended credentials on registerTable 
endpoint (#10699)
    
    Resubmitting per @laserninja's comment on #10684. This revision aligns
    the implementation with the established `createTable`/`loadTable`
    credential-vending pattern in `CatalogWrapperForREST`: the new 3-arg
    `registerTable` calls `super.registerTable(2-arg)` and uses the same
    inline `shouldGenerateCredential` / `injectCredentialConfig` conditional
    that `createTable` and `loadTable` already use, and
    `CatalogWrapperForTest` now overrides at the **3-arg** level (matching
    how it overrides `createTable`).
    
    ### What changes were proposed in this pull request?
    
    Add `X-Iceberg-Access-Delegation` header support to the `registerTable`
    endpoint, enabling credential vending in the response. Mirrors the
    existing pattern from `createTable` and `loadTable`.
    
    Changes:
    - `IcebergNamespaceOperations.registerTable`: add
    `@HeaderParam(X_ICEBERG_ACCESS_DELEGATION)`, compute
    `isCredentialVending`, build the 3-arg `IcebergRequestContext`
    - `CatalogWrapperForREST`: add 3-arg `registerTable` that calls
    `super.registerTable(2-arg)` then conditionally injects credentials
    inline — structurally identical to the existing 3-arg `createTable` and
    `loadTable`
    - `CatalogWrapperForREST`: widen `shouldGenerateCredential` and
    `injectCredentialConfig` from `private` to `protected` so subclasses
    (e.g. test wrappers that mock the underlying table operation) can
    participate in the credential vending flow
    - `IcebergNamespaceOperationExecutor.registerTable`: pass
    `context.requestCredentialVending()` through to the 3-arg wrapper form
    - `IcebergTableOperations.isCredentialVending`: widen `private` →
    package-private `static` so `IcebergNamespaceOperations` can reuse it
    without duplicating the validation logic
    - `CatalogWrapperForTest`: override the **3-arg** `registerTable`
    (matching the 3-arg `createTable` override level), build the mock
    response, and inline the same `shouldGenerateCredential` /
    `injectCredentialConfig` conditional. Honors cloud URIs in
    `metadataLocation` so vending tests can verify the vended path. The
    legacy 2-arg override is removed (no longer reachable via the dispatcher
    chain).
    
    ### Why are the changes needed?
    
    The Iceberg REST spec defines `X-Iceberg-Access-Delegation` as a valid
    header on `registerTable`, but the current implementation does not
    accept it or vend credentials. Clients that register a table and
    immediately attempt to read its data must make a separate `loadTable`
    call to obtain credentials.
    
    Fix: #10684
    
    ### Does this PR introduce _any_ user-facing change?
    
    Yes. The `registerTable` REST endpoint now accepts the
    `X-Iceberg-Access-Delegation` header and returns vended credentials in
    the response config when requested. Backward compatible — clients that
    do not send the header get existing behavior.
    
    ### How was this patch tested?
    
    Added unit tests in `TestIcebergNamespaceOperations`:
    - `testRegisterTableWithCredentialVending` — verifies no vending without
    header, no vending for local URI, vending for `s3://` URI
    - `testRegisterTableRemoteSigningNotSupported` — verifies 406 response
    for `remote-signing`
    - `testRegisterTableInvalidAccessDelegation` — verifies 400 response for
    invalid header values
    
    All existing `TestIcebergNamespaceOperations` tests still pass with no
    regressions.
---
 .../iceberg/service/CatalogWrapperForREST.java     | 22 ++++++-
 .../IcebergNamespaceOperationExecutor.java         |  2 +-
 .../service/rest/IcebergNamespaceOperations.java   | 14 +++--
 .../service/rest/IcebergTableOperations.java       |  8 ++-
 .../service/rest/CatalogWrapperForTest.java        | 32 ++++++++--
 .../service/rest/IcebergNamespaceTestBase.java     | 12 ++++
 .../rest/TestIcebergNamespaceOperations.java       | 72 ++++++++++++++++++++++
 7 files changed, 150 insertions(+), 12 deletions(-)

diff --git 
a/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/CatalogWrapperForREST.java
 
b/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/CatalogWrapperForREST.java
index f69dd481d7..5baf43481d 100644
--- 
a/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/CatalogWrapperForREST.java
+++ 
b/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/CatalogWrapperForREST.java
@@ -80,6 +80,7 @@ import org.apache.iceberg.rest.PlanStatus;
 import org.apache.iceberg.rest.RESTCatalog;
 import org.apache.iceberg.rest.requests.CreateTableRequest;
 import org.apache.iceberg.rest.requests.PlanTableScanRequest;
+import org.apache.iceberg.rest.requests.RegisterTableRequest;
 import org.apache.iceberg.rest.requests.UpdateTableRequest;
 import org.apache.iceberg.rest.responses.ImmutableLoadCredentialsResponse;
 import org.apache.iceberg.rest.responses.LoadCredentialsResponse;
@@ -156,6 +157,21 @@ public class CatalogWrapperForREST extends 
IcebergCatalogWrapper {
     return loadTableResponse;
   }
 
+  public LoadTableResponse registerTable(
+      Namespace namespace, RegisterTableRequest request, boolean 
requestCredential) {
+    LoadTableResponse loadTableResponse = super.registerTable(namespace, 
request);
+    if (shouldGenerateCredential(loadTableResponse, requestCredential)) {
+      // Vend WRITE credentials: the registering user becomes the table owner
+      // (IcebergNamespaceHookDispatcher.setTableOwner runs after this call
+      // returns), consistent with createTable which also vends WRITE.
+      return injectCredentialConfig(
+          TableIdentifier.of(namespace, request.name()),
+          loadTableResponse,
+          CredentialPrivilege.WRITE);
+    }
+    return loadTableResponse;
+  }
+
   @Override
   public LoadTableResponse updateTable(
       TableIdentifier tableIdentifier, UpdateTableRequest updateTableRequest) {
@@ -286,7 +302,8 @@ public class CatalogWrapperForREST extends 
IcebergCatalogWrapper {
     return false;
   }
 
-  private LoadTableResponse injectCredentialConfig(
+  @VisibleForTesting
+  protected LoadTableResponse injectCredentialConfig(
       TableIdentifier tableIdentifier,
       LoadTableResponse loadTableResponse,
       CredentialPrivilege privilege) {
@@ -336,7 +353,8 @@ public class CatalogWrapperForREST extends 
IcebergCatalogWrapper {
     return credential;
   }
 
-  private boolean shouldGenerateCredential(
+  @VisibleForTesting
+  protected boolean shouldGenerateCredential(
       LoadTableResponse loadTableResponse, boolean requestCredential) {
     if (!requestCredential) {
       return false;
diff --git 
a/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/dispatcher/IcebergNamespaceOperationExecutor.java
 
b/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/dispatcher/IcebergNamespaceOperationExecutor.java
index 35bd179292..ebb9c6aac4 100644
--- 
a/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/dispatcher/IcebergNamespaceOperationExecutor.java
+++ 
b/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/dispatcher/IcebergNamespaceOperationExecutor.java
@@ -123,6 +123,6 @@ public class IcebergNamespaceOperationExecutor implements 
IcebergNamespaceOperat
       RegisterTableRequest registerTableRequest) {
     return icebergCatalogWrapperManager
         .getCatalogWrapper(context.catalogName())
-        .registerTable(namespace, registerTableRequest);
+        .registerTable(namespace, registerTableRequest, 
context.requestCredentialVending());
   }
 }
diff --git 
a/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/rest/IcebergNamespaceOperations.java
 
b/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/rest/IcebergNamespaceOperations.java
index ff09be9828..ddc5c100ff 100644
--- 
a/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/rest/IcebergNamespaceOperations.java
+++ 
b/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/rest/IcebergNamespaceOperations.java
@@ -33,6 +33,7 @@ import javax.ws.rs.DefaultValue;
 import javax.ws.rs.Encoded;
 import javax.ws.rs.GET;
 import javax.ws.rs.HEAD;
+import javax.ws.rs.HeaderParam;
 import javax.ws.rs.POST;
 import javax.ws.rs.Path;
 import javax.ws.rs.PathParam;
@@ -300,20 +301,25 @@ public class IcebergNamespaceOperations {
       @AuthorizationMetadata(type = Entity.EntityType.CATALOG) 
@PathParam("prefix") String prefix,
       @AuthorizationMetadata(type = Entity.EntityType.SCHEMA) @Encoded() 
@PathParam("namespace")
           String namespace,
-      RegisterTableRequest registerTableRequest) {
+      RegisterTableRequest registerTableRequest,
+      @HeaderParam(IcebergTableOperations.X_ICEBERG_ACCESS_DELEGATION) String 
accessDelegation) {
+    boolean isCredentialVending = 
IcebergTableOperations.isCredentialVending(accessDelegation);
     String catalogName = IcebergRESTUtils.getCatalogName(prefix);
     Namespace icebergNS = RESTUtil.decodeNamespace(namespace);
     LOG.info(
-        "Register Iceberg table, catalog: {}, namespace: {}, 
registerTableRequest: {}",
+        "Register Iceberg table, catalog: {}, namespace: {}, 
registerTableRequest: {}, "
+            + "accessDelegation: {}, isCredentialVending: {}",
         catalogName,
         icebergNS,
-        registerTableRequest);
+        registerTableRequest,
+        accessDelegation,
+        isCredentialVending);
     try {
       return Utils.doAs(
           httpRequest,
           () -> {
             IcebergRequestContext context =
-                new IcebergRequestContext(httpServletRequest(), catalogName);
+                new IcebergRequestContext(httpServletRequest(), catalogName, 
isCredentialVending);
             LoadTableResponse loadTableResponse =
                 namespaceOperationDispatcher.registerTable(
                     context, icebergNS, registerTableRequest);
diff --git 
a/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/rest/IcebergTableOperations.java
 
b/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/rest/IcebergTableOperations.java
index ce3fbc8f24..98c73e2da6 100644
--- 
a/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/rest/IcebergTableOperations.java
+++ 
b/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/rest/IcebergTableOperations.java
@@ -603,7 +603,13 @@ public class IcebergTableOperations {
     return etag.getValue().equals(clientEtag);
   }
 
-  private boolean isCredentialVending(String accessDelegation) {
+  /**
+   * Parses the {@code X-Iceberg-Access-Delegation} header value and returns 
whether the client is
+   * requesting credential vending. Package-private and static so that {@link
+   * IcebergNamespaceOperations#registerTable} can reuse the same parsing 
logic from the same
+   * package.
+   */
+  static boolean isCredentialVending(String accessDelegation) {
     if (StringUtils.isBlank(accessDelegation)) {
       return false;
     }
diff --git 
a/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/iceberg/service/rest/CatalogWrapperForTest.java
 
b/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/iceberg/service/rest/CatalogWrapperForTest.java
index d5eccaf853..5af252880f 100644
--- 
a/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/iceberg/service/rest/CatalogWrapperForTest.java
+++ 
b/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/iceberg/service/rest/CatalogWrapperForTest.java
@@ -22,6 +22,7 @@ import com.google.common.collect.ImmutableMap;
 import java.io.IOException;
 import java.nio.file.Files;
 import java.nio.file.Path;
+import org.apache.gravitino.credential.CredentialPrivilege;
 import org.apache.gravitino.iceberg.common.IcebergConfig;
 import org.apache.gravitino.iceberg.service.CatalogWrapperForREST;
 import org.apache.iceberg.DataFile;
@@ -41,7 +42,9 @@ import org.apache.iceberg.rest.responses.LoadTableResponse;
 import org.apache.iceberg.types.Types.NestedField;
 import org.apache.iceberg.types.Types.StringType;
 
-// Used to override registerTable
+// Test wrapper that mocks operations the in-memory catalog cannot perform 
natively
+// (e.g. registerTable, which requires a real metadata.json file at the given 
location),
+// and adds test-only hooks (e.g. plan-task data generation for createTable).
 @SuppressWarnings("deprecation")
 public class CatalogWrapperForTest extends CatalogWrapperForREST {
   public static final String GENERATE_PLAN_TASKS_DATA_PROP = 
"test.generate-plan-data";
@@ -61,23 +64,44 @@ public class CatalogWrapperForTest extends 
CatalogWrapperForREST {
   }
 
   @Override
-  public LoadTableResponse registerTable(Namespace namespace, 
RegisterTableRequest request) {
+  public LoadTableResponse registerTable(
+      Namespace namespace, RegisterTableRequest request, boolean 
requestCredential) {
     if (request.name().contains("fail")) {
       throw new AlreadyExistsException("Already exits exception for test");
     }
 
+    // The in-memory test catalog cannot natively registerTable (it would need 
a real
+    // metadata.json file at the given location), so build a mock 
LoadTableResponse here.
+    // Honor cloud URIs (e.g. s3://) in metadataLocation so credential vending 
tests can
+    // verify the vended path; default to /mock otherwise for existing tests.
+    String location =
+        request.metadataLocation().contains("://") ? 
request.metadataLocation() : "/mock";
     Schema mockSchema = new Schema(NestedField.of(1, false, "foo_string", 
StringType.get()));
     TableMetadata baseMetadata =
         TableMetadata.newTableMetadata(
-            mockSchema, PartitionSpec.unpartitioned(), "/mock", 
ImmutableMap.of());
+            mockSchema, PartitionSpec.unpartitioned(), location, 
ImmutableMap.of());
     String json = TableMetadataParser.toJson(baseMetadata);
     TableMetadata tableMetadata =
-        TableMetadataParser.fromJson("/mock/metadata/v1.metadata.json", json);
+        TableMetadataParser.fromJson(location + "/metadata/v1.metadata.json", 
json);
     LoadTableResponse loadTableResponse =
         LoadTableResponse.builder()
             .withTableMetadata(tableMetadata)
             .addAllConfig(ImmutableMap.of())
             .build();
+    // We must replicate the credential-vending check + injection here (rather 
than reuse
+    // CatalogWrapperForREST.registerTable via super) because the in-memory 
test catalog
+    // cannot natively perform registerTable. Above, we synthesized a mock 
LoadTableResponse
+    // instead of delegating to super.registerTable; that means the parent 
class never sees
+    // this call and therefore never runs its vending logic. Calling 
shouldGenerateCredential
+    // / injectCredentialConfig directly (now protected on the parent) 
re-applies the exact
+    // same vending behavior to the mock response, so credential-vending tests 
exercise the
+    // production code path end-to-end. Privilege must match the production 
wrapper (WRITE).
+    if (shouldGenerateCredential(loadTableResponse, requestCredential)) {
+      return injectCredentialConfig(
+          TableIdentifier.of(namespace, request.name()),
+          loadTableResponse,
+          CredentialPrivilege.WRITE);
+    }
     return loadTableResponse;
   }
 
diff --git 
a/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/iceberg/service/rest/IcebergNamespaceTestBase.java
 
b/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/iceberg/service/rest/IcebergNamespaceTestBase.java
index bd749821fa..4cd8ac46bd 100644
--- 
a/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/iceberg/service/rest/IcebergNamespaceTestBase.java
+++ 
b/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/iceberg/service/rest/IcebergNamespaceTestBase.java
@@ -59,6 +59,18 @@ public class IcebergNamespaceTestBase extends 
IcebergTestBase {
         .post(Entity.entity(request, MediaType.APPLICATION_JSON_TYPE));
   }
 
+  protected Response doRegisterTableWithCredentialVending(
+      String tableName, Namespace ns, String metadataLocation) {
+    RegisterTableRequest request =
+        ImmutableRegisterTableRequest.builder()
+            .name(tableName)
+            .metadataLocation(metadataLocation)
+            .build();
+    return getNamespaceClientBuilder(Optional.of(ns), Optional.of("register"), 
Optional.empty())
+        .header(IcebergTableOperations.X_ICEBERG_ACCESS_DELEGATION, 
"vended-credentials")
+        .post(Entity.entity(request, MediaType.APPLICATION_JSON_TYPE));
+  }
+
   private Response doListNamespace(Optional<Namespace> parent) {
     Optional<Map<String, String>> queryParam =
         parent.isPresent()
diff --git 
a/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/iceberg/service/rest/TestIcebergNamespaceOperations.java
 
b/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/iceberg/service/rest/TestIcebergNamespaceOperations.java
index 2bd62af968..3c83534a18 100644
--- 
a/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/iceberg/service/rest/TestIcebergNamespaceOperations.java
+++ 
b/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/iceberg/service/rest/TestIcebergNamespaceOperations.java
@@ -21,10 +21,15 @@ package org.apache.gravitino.iceberg.service.rest;
 import java.util.Arrays;
 import java.util.Optional;
 import javax.servlet.http.HttpServletRequest;
+import javax.ws.rs.client.Entity;
 import javax.ws.rs.core.Application;
 import javax.ws.rs.core.EntityTag;
+import javax.ws.rs.core.MediaType;
 import javax.ws.rs.core.Response;
+import javax.ws.rs.core.Response.Status;
+import org.apache.gravitino.credential.Credential;
 import org.apache.gravitino.iceberg.service.IcebergRESTUtils;
+import org.apache.gravitino.iceberg.service.extension.DummyCredentialProvider;
 import org.apache.gravitino.listener.api.event.Event;
 import org.apache.gravitino.listener.api.event.IcebergCreateNamespaceEvent;
 import 
org.apache.gravitino.listener.api.event.IcebergCreateNamespaceFailureEvent;
@@ -44,6 +49,9 @@ import 
org.apache.gravitino.listener.api.event.IcebergUpdateNamespaceEvent;
 import 
org.apache.gravitino.listener.api.event.IcebergUpdateNamespaceFailureEvent;
 import org.apache.gravitino.listener.api.event.IcebergUpdateNamespacePreEvent;
 import org.apache.iceberg.catalog.Namespace;
+import org.apache.iceberg.rest.requests.ImmutableRegisterTableRequest;
+import org.apache.iceberg.rest.requests.RegisterTableRequest;
+import org.apache.iceberg.rest.responses.LoadTableResponse;
 import org.glassfish.jersey.internal.inject.AbstractBinder;
 import org.glassfish.jersey.server.ResourceConfig;
 import org.junit.jupiter.api.Assertions;
@@ -257,4 +265,68 @@ public class TestIcebergNamespaceOperations extends 
IcebergNamespaceTestBase {
     dummyEventListener.clearEvent();
     verifyUpdateNamespaceSucc(Namespace.of("update_foo3", "a"));
   }
+
+  @Test
+  void testRegisterTableWithCredentialVending() {
+    // register without credential vending -- no credentials in response
+    verifyRegisterTableSucc("register_cred_foo1", 
Namespace.of("register_cred_ns"));
+
+    // register with credential vending but local location -- should NOT vend
+    Response response =
+        doRegisterTableWithCredentialVending(
+            "register_cred_foo2", Namespace.of("register_cred_ns2"), "mock");
+    Assertions.assertEquals(Status.OK.getStatusCode(), response.getStatus());
+    LoadTableResponse loadTableResponse = 
response.readEntity(LoadTableResponse.class);
+    
Assertions.assertFalse(loadTableResponse.config().containsKey(Credential.CREDENTIAL_TYPE));
+
+    // register with credential vending and S3 location -- SHOULD vend
+    String s3Location = "s3://dummy-bucket/register_cred_foo3";
+    response =
+        doRegisterTableWithCredentialVending(
+            "register_cred_foo3", Namespace.of("register_cred_ns3"), 
s3Location);
+    Assertions.assertEquals(Status.OK.getStatusCode(), response.getStatus());
+    loadTableResponse = response.readEntity(LoadTableResponse.class);
+    Assertions.assertEquals(
+        DummyCredentialProvider.DUMMY_CREDENTIAL_TYPE,
+        loadTableResponse.config().get(Credential.CREDENTIAL_TYPE));
+    // DummyCredentialProvider.SimpleCredential is not one of the typed 
credentials handled
+    // in CredentialPropertyUtils#toIcebergProperties, so it falls through to
+    // Credential#toProperties, which always emits credential-type and 
expire-time-in-ms.
+    // Asserting both guards against partial-injection regressions.
+    Assertions.assertEquals("0", 
loadTableResponse.config().get(Credential.EXPIRE_TIME_IN_MS));
+  }
+
+  @Test
+  void testRegisterTableRemoteSigningNotSupported() {
+    RegisterTableRequest request =
+        ImmutableRegisterTableRequest.builder()
+            .name("remote_signing_test")
+            .metadataLocation("mock")
+            .build();
+    Response response =
+        getNamespaceClientBuilder(
+                Optional.of(Namespace.of("register_remote_ns")),
+                Optional.of("register"),
+                Optional.empty())
+            .header(IcebergTableOperations.X_ICEBERG_ACCESS_DELEGATION, 
"remote-signing")
+            .post(Entity.entity(request, MediaType.APPLICATION_JSON_TYPE));
+    Assertions.assertEquals(406, response.getStatus());
+  }
+
+  @Test
+  void testRegisterTableInvalidAccessDelegation() {
+    RegisterTableRequest request =
+        ImmutableRegisterTableRequest.builder()
+            .name("invalid_delegation_test")
+            .metadataLocation("mock")
+            .build();
+    Response response =
+        getNamespaceClientBuilder(
+                Optional.of(Namespace.of("register_invalid_ns")),
+                Optional.of("register"),
+                Optional.empty())
+            .header(IcebergTableOperations.X_ICEBERG_ACCESS_DELEGATION, 
"invalid-value")
+            .post(Entity.entity(request, MediaType.APPLICATION_JSON_TYPE));
+    Assertions.assertEquals(400, response.getStatus());
+  }
 }

Reply via email to