Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@
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;
Expand Down Expand Up @@ -156,6 +157,21 @@ public LoadTableResponse loadTable(
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) {
Expand Down Expand Up @@ -286,7 +302,8 @@ protected boolean useDifferentClassLoader() {
return false;
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These methods go from privateprotected, which makes them part of the subclass API contract. This is the cleanest option given the constraints but will need to be maintained as a stable interface going forward. A short JavaDoc note on their intended use (subclass credential injection in cases where super can't be called) would clarify intent.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added JavaDoc explaining that the protected visibility is for subclasses that synthesize a LoadTableResponse without delegating to super (test wrappers around in-memory catalogs that can't perform certain operations natively), so they can still apply the same vending and injection logic as production paths.

private LoadTableResponse injectCredentialConfig(
@VisibleForTesting
protected LoadTableResponse injectCredentialConfig(
TableIdentifier tableIdentifier,
LoadTableResponse loadTableResponse,
CredentialPrivilege privilege) {
Expand Down Expand Up @@ -336,7 +353,8 @@ private Credential getCredential(
return credential;
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

same as above

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same JavaDoc rationale added on shouldGenerateCredential.

private boolean shouldGenerateCredential(
@VisibleForTesting
protected boolean shouldGenerateCredential(
LoadTableResponse loadTableResponse, boolean requestCredential) {
if (!requestCredential) {
return false;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,6 @@ public LoadTableResponse registerTable(
RegisterTableRequest registerTableRequest) {
return icebergCatalogWrapperManager
.getCatalogWrapper(context.catalogName())
.registerTable(namespace, registerTableRequest);
.registerTable(namespace, registerTableRequest, context.requestCredentialVending());
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
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;
Expand Down Expand Up @@ -300,20 +301,25 @@ public Response registerTable(
@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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -603,7 +603,13 @@ private static boolean etagMatches(String ifNoneMatch, EntityTag etag) {
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) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We are making this package-private
Should we annotate with @VisibleForTesting

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch on the visibility change being undocumented, but the change is actually for production reuse rather than testing — IcebergNamespaceOperations#registerTable (in the same package) now calls this to parse the same X-Iceberg-Access-Delegation header that IcebergTableOperations#createTable and #loadTable already parse. @VisibleForTesting would be misleading because no test depends on its visibility. I've added a JavaDoc explaining the production-reuse rationale instead.

if (StringUtils.isBlank(accessDelegation)) {
return false;
}
Expand Down

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The PR's new 3-arg registerTable override in CatalogWrapperForTest duplicates the shouldGenerateCredential/injectCredentialConfig pattern from the parent CatalogWrapperForREST. This duplication is necessary because the in-memory catalog can't handle registerTable natively (unlike createTable, where the test calls super.createTable(3-arg) and the parent handles everything).

However, if the credential injection logic ever changes in CatalogWrapperForREST, this test would silently diverge. Consider adding a brief comment in the test explaining why the duplication exists, e.g.:

// Cannot delegate to super.registerTable(3-arg) because the in-memory catalog
// does not support registerTable; must inline credential injection here.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added an inline comment at the call site explaining the duplication: the in-memory test catalog cannot perform registerTable natively, so the override synthesizes a mock LoadTableResponse instead of delegating to super.registerTable. Because we never go through the parent's registerTable, we must explicitly re-run its vending logic so credential-vending tests still exercise the same code path as production. (Also updated this wrapper to use READ to match the production change.)

Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
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;
Expand All @@ -41,7 +42,9 @@
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";
Expand All @@ -61,23 +64,44 @@ public LoadTableResponse createTable(
}

@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;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,18 @@ protected Response doRegisterTable(String tableName, Namespace ns) {
.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()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,10 +21,15 @@
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;
Expand All @@ -44,6 +49,9 @@
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;
Expand Down Expand Up @@ -257,4 +265,68 @@ void testUpdateNamespace() {
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(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The S3 vending case only checks for Credential.CREDENTIAL_TYPE in the config.
Asserting on a couple more expected credential properties (e.g., whatever DummyCredentialProvider puts in the config) would make the test more robust against regressions where the type is present but the actual credential values are missing.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added an additional assertion on Credential.EXPIRE_TIME_IN_MS. DummyCredentialProvider.SimpleCredential is not one of the typed credentials in CredentialPropertyUtils.toIcebergProperties, so it falls through to Credential#toProperties(), which emits exactly credential-type and expire-time-in-ms. Asserting both confirms the full credential block is injected and guards against partial-injection regressions.

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());
}
}
Loading