From 8ae94ba01247839c0e24698d04d8b98b355f25e2 Mon Sep 17 00:00:00 2001 From: Aman Agrawal Date: Sat, 1 Aug 2026 21:53:54 +0530 Subject: [PATCH 1/2] GCP: add pluggable GCS token credential provider --- .../org/apache/iceberg/gcp/GCPProperties.java | 28 ++++ .../gcp/gcs/GcsTokenCredentialProvider.java | 34 +++++ .../gcp/gcs/GcsTokenCredentialProviders.java | 101 +++++++++++++ .../iceberg/gcp/gcs/PrefixedStorage.java | 4 + .../apache/iceberg/gcp/TestGCPProperties.java | 46 ++++++ .../gcs/TestGcsTokenCredentialProviders.java | 138 ++++++++++++++++++ 6 files changed, 351 insertions(+) create mode 100644 gcp/src/main/java/org/apache/iceberg/gcp/gcs/GcsTokenCredentialProvider.java create mode 100644 gcp/src/main/java/org/apache/iceberg/gcp/gcs/GcsTokenCredentialProviders.java create mode 100644 gcp/src/test/java/org/apache/iceberg/gcp/gcs/TestGcsTokenCredentialProviders.java diff --git a/gcp/src/main/java/org/apache/iceberg/gcp/GCPProperties.java b/gcp/src/main/java/org/apache/iceberg/gcp/GCPProperties.java index 2702ce565d4e..e30e1e26fcc6 100644 --- a/gcp/src/main/java/org/apache/iceberg/gcp/GCPProperties.java +++ b/gcp/src/main/java/org/apache/iceberg/gcp/GCPProperties.java @@ -67,6 +67,22 @@ public class GCPProperties implements Serializable { public static final String GCS_OAUTH2_REFRESH_CREDENTIALS_ENABLED = "gcs.oauth2.refresh-credentials-enabled"; + /** + * Class name of a custom {@link org.apache.iceberg.gcp.gcs.GcsTokenCredentialProvider} + * implementation. When set, {@code PrefixedStorage} uses this provider to obtain {@link + * com.google.auth.oauth2.GoogleCredentials} instead of reading a static {@link #GCS_OAUTH2_TOKEN} + * property. The provider class must have a no-arg constructor. + */ + public static final String GCS_TOKEN_CREDENTIAL_PROVIDER = "gcs.token-credential-provider"; + + /** + * Property prefix for initializing a custom {@link + * org.apache.iceberg.gcp.gcs.GcsTokenCredentialProvider}. All properties under this prefix are + * extracted (without the prefix) and passed to {@link + * org.apache.iceberg.gcp.gcs.GcsTokenCredentialProvider#initialize(Map)}. + */ + public static final String GCS_TOKEN_PROVIDER_PREFIX = "gcs.token-credential-provider."; + /** Configure the batch size used when deleting multiple files from a given GCS bucket */ public static final String GCS_DELETE_BATCH_SIZE = "gcs.delete.batch-size"; @@ -98,6 +114,7 @@ public class GCPProperties implements Serializable { private String gcsOauth2RefreshCredentialsEndpoint; private boolean gcsOauth2RefreshCredentialsEnabled; private boolean gcsAnalyticsCoreEnabled; + private String gcsTokenCredentialProvider; private String gcsImpersonateServiceAccount; private int gcsImpersonateLifetimeSeconds; @@ -167,6 +184,8 @@ public GCPProperties(Map properties) { new Date(Long.parseLong(properties.get(GCS_OAUTH2_TOKEN_EXPIRES_AT))); } + gcsTokenCredentialProvider = properties.get(GCS_TOKEN_CREDENTIAL_PROVIDER); + gcsOauth2RefreshCredentialsEndpoint = RESTUtil.resolveEndpoint( properties.get(CatalogProperties.URI), @@ -179,6 +198,11 @@ public GCPProperties(Map properties) { "Invalid auth settings: must not configure %s and %s", GCS_NO_AUTH, GCS_OAUTH2_TOKEN); + Preconditions.checkState( + !(gcsTokenCredentialProvider != null && gcsNoAuth), + "Invalid auth settings: must not configure %s and %s", + GCS_NO_AUTH, + GCS_TOKEN_CREDENTIAL_PROVIDER); gcsDeleteBatchSize = PropertyUtil.propertyAsInt( @@ -235,6 +259,10 @@ public Optional oauth2Token() { return Optional.ofNullable(gcsOAuth2Token); } + public Optional tokenCredentialProvider() { + return Optional.ofNullable(gcsTokenCredentialProvider); + } + public boolean noAuth() { return gcsNoAuth; } diff --git a/gcp/src/main/java/org/apache/iceberg/gcp/gcs/GcsTokenCredentialProvider.java b/gcp/src/main/java/org/apache/iceberg/gcp/gcs/GcsTokenCredentialProvider.java new file mode 100644 index 000000000000..4abce9ccbce7 --- /dev/null +++ b/gcp/src/main/java/org/apache/iceberg/gcp/gcs/GcsTokenCredentialProvider.java @@ -0,0 +1,34 @@ +/* + * 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.gcp.gcs; + +import com.google.auth.oauth2.GoogleCredentials; +import java.util.Map; + +public interface GcsTokenCredentialProvider { + + GoogleCredentials credential(); + + /** + * Initialize GCS credential provider from provider properties. + * + * @param properties credential provider properties + */ + void initialize(Map properties); +} diff --git a/gcp/src/main/java/org/apache/iceberg/gcp/gcs/GcsTokenCredentialProviders.java b/gcp/src/main/java/org/apache/iceberg/gcp/gcs/GcsTokenCredentialProviders.java new file mode 100644 index 000000000000..186c8a0a92b9 --- /dev/null +++ b/gcp/src/main/java/org/apache/iceberg/gcp/gcs/GcsTokenCredentialProviders.java @@ -0,0 +1,101 @@ +/* + * 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.gcp.gcs; + +import com.google.auth.oauth2.GoogleCredentials; +import java.io.IOException; +import java.io.UncheckedIOException; +import java.util.Map; +import org.apache.iceberg.common.DynConstructors; +import org.apache.iceberg.gcp.GCPProperties; +import org.apache.iceberg.relocated.com.google.common.base.Strings; +import org.apache.iceberg.util.PropertyUtil; + +public class GcsTokenCredentialProviders { + + private static final DefaultGcsTokenCredentialProvider DEFAULT_PROVIDER = + new DefaultGcsTokenCredentialProvider(); + + private GcsTokenCredentialProviders() {} + + public static GcsTokenCredentialProvider defaultFactory() { + return DEFAULT_PROVIDER; + } + + public static GcsTokenCredentialProvider from(Map properties) { + String providerImpl = + PropertyUtil.propertyAsString( + properties, GCPProperties.GCS_TOKEN_CREDENTIAL_PROVIDER, null); + Map credentialProviderProperties = + PropertyUtil.propertiesWithPrefix(properties, GCPProperties.GCS_TOKEN_PROVIDER_PREFIX); + return loadCredentialProvider(providerImpl, credentialProviderProperties); + } + + private static GcsTokenCredentialProvider loadCredentialProvider( + String impl, Map properties) { + if (Strings.isNullOrEmpty(impl)) { + GcsTokenCredentialProvider provider = defaultFactory(); + provider.initialize(properties); + return provider; + } + + DynConstructors.Ctor ctor; + try { + ctor = + DynConstructors.builder(GcsTokenCredentialProvider.class) + .loader(GcsTokenCredentialProviders.class.getClassLoader()) + .hiddenImpl(impl) + .buildChecked(); + } catch (NoSuchMethodException e) { + throw new IllegalArgumentException( + String.format( + "Cannot initialize GcsTokenCredentialProvider, missing no-arg constructor: %s", impl), + e); + } + + GcsTokenCredentialProvider provider; + try { + provider = ctor.newInstance(); + } catch (ClassCastException e) { + throw new IllegalArgumentException( + String.format( + "Cannot initialize GcsTokenCredentialProvider, %s does not implement GcsTokenCredentialProvider.", + impl), + e); + } + + provider.initialize(properties); + return provider; + } + + static class DefaultGcsTokenCredentialProvider implements GcsTokenCredentialProvider { + + @Override + public GoogleCredentials credential() { + try { + return GoogleCredentials.getApplicationDefault(); + } catch (IOException e) { + throw new UncheckedIOException("Failed to get application default GCS credentials", e); + } + } + + @Override + public void initialize(Map properties) {} + } +} diff --git a/gcp/src/main/java/org/apache/iceberg/gcp/gcs/PrefixedStorage.java b/gcp/src/main/java/org/apache/iceberg/gcp/gcs/PrefixedStorage.java index a442269c09cb..71c07e940993 100644 --- a/gcp/src/main/java/org/apache/iceberg/gcp/gcs/PrefixedStorage.java +++ b/gcp/src/main/java/org/apache/iceberg/gcp/gcs/PrefixedStorage.java @@ -158,6 +158,10 @@ private Credentials credentials(GCPProperties properties) { return NoCredentials.getInstance(); } else if (properties.impersonateServiceAccount().isPresent()) { return buildImpersonatedCredentials(properties); + } else if (properties.tokenCredentialProvider().isPresent()) { + // A custom provider yields a self-refreshing GoogleCredentials (e.g. built from a + // caller-supplied source credential), addressing static-token expiry for non-vended setups. + return GcsTokenCredentialProviders.from(properties.properties()).credential(); } else { return null; } diff --git a/gcp/src/test/java/org/apache/iceberg/gcp/TestGCPProperties.java b/gcp/src/test/java/org/apache/iceberg/gcp/TestGCPProperties.java index 0ec2183fc355..dcdec4e9ce8d 100644 --- a/gcp/src/test/java/org/apache/iceberg/gcp/TestGCPProperties.java +++ b/gcp/src/test/java/org/apache/iceberg/gcp/TestGCPProperties.java @@ -22,6 +22,7 @@ import static org.apache.iceberg.gcp.GCPProperties.GCS_OAUTH2_REFRESH_CREDENTIALS_ENABLED; import static org.apache.iceberg.gcp.GCPProperties.GCS_OAUTH2_REFRESH_CREDENTIALS_ENDPOINT; import static org.apache.iceberg.gcp.GCPProperties.GCS_OAUTH2_TOKEN; +import static org.apache.iceberg.gcp.GCPProperties.GCS_TOKEN_CREDENTIAL_PROVIDER; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatIllegalStateException; @@ -50,6 +51,51 @@ public void testOAuthWithNoAuth() { assertThat(gcpProperties.oauth2Token()).isNotPresent(); } + @Test + public void testTokenCredentialProviderWithNoAuth() { + assertThatIllegalStateException() + .isThrownBy( + () -> + new GCPProperties( + ImmutableMap.of( + GCS_TOKEN_CREDENTIAL_PROVIDER, + "org.example.Provider", + GCS_NO_AUTH, + "true"))) + .withMessage( + String.format( + "Invalid auth settings: must not configure %s and %s", + GCS_NO_AUTH, GCS_TOKEN_CREDENTIAL_PROVIDER)); + } + + @Test + public void testTokenCredentialProviderWithOAuth2Token() { + // Provider and oauth2 token may coexist (the vended case) - construction must not throw; + // PrefixedStorage resolves precedence. + GCPProperties gcpProperties = + new GCPProperties( + ImmutableMap.of( + GCS_TOKEN_CREDENTIAL_PROVIDER, "org.example.Provider", GCS_OAUTH2_TOKEN, "oauth")); + assertThat(gcpProperties.tokenCredentialProvider()) + .isPresent() + .get() + .isEqualTo("org.example.Provider"); + assertThat(gcpProperties.oauth2Token()).isPresent().get().isEqualTo("oauth"); + assertThat(gcpProperties.noAuth()).isFalse(); + } + + @Test + public void testTokenCredentialProviderSet() { + GCPProperties gcpProperties = + new GCPProperties(ImmutableMap.of(GCS_TOKEN_CREDENTIAL_PROVIDER, "org.example.Provider")); + assertThat(gcpProperties.tokenCredentialProvider()) + .isPresent() + .get() + .isEqualTo("org.example.Provider"); + assertThat(gcpProperties.oauth2Token()).isNotPresent(); + assertThat(gcpProperties.noAuth()).isFalse(); + } + @Test public void refreshCredentialsEndpointSet() { GCPProperties gcpProperties = diff --git a/gcp/src/test/java/org/apache/iceberg/gcp/gcs/TestGcsTokenCredentialProviders.java b/gcp/src/test/java/org/apache/iceberg/gcp/gcs/TestGcsTokenCredentialProviders.java new file mode 100644 index 000000000000..7a8ec5758bbf --- /dev/null +++ b/gcp/src/test/java/org/apache/iceberg/gcp/gcs/TestGcsTokenCredentialProviders.java @@ -0,0 +1,138 @@ +/* + * 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.gcp.gcs; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; + +import com.google.auth.oauth2.GoogleCredentials; +import java.util.Map; +import org.apache.iceberg.gcp.GCPProperties; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableMap; +import org.junit.jupiter.api.Test; + +public class TestGcsTokenCredentialProviders { + + @Test + public void useDefaultFactory() { + assertThat(GcsTokenCredentialProviders.defaultFactory()) + .isNotNull() + .isInstanceOf(GcsTokenCredentialProviders.DefaultGcsTokenCredentialProvider.class); + } + + @Test + public void emptyPropertiesWithNoProvider() { + assertThat(GcsTokenCredentialProviders.from(ImmutableMap.of())) + .isNotNull() + .isInstanceOf(GcsTokenCredentialProviders.DefaultGcsTokenCredentialProvider.class); + } + + @Test + public void emptyCredentialProvider() { + Map properties = + ImmutableMap.of(GCPProperties.GCS_TOKEN_CREDENTIAL_PROVIDER, ""); + assertThat(GcsTokenCredentialProviders.from(properties)) + .isNotNull() + .isInstanceOf(GcsTokenCredentialProviders.DefaultGcsTokenCredentialProvider.class); + } + + @Test + public void defaultProviderAsCredentialProvider() { + Map properties = + ImmutableMap.of( + GCPProperties.GCS_TOKEN_CREDENTIAL_PROVIDER, + GcsTokenCredentialProviders.DefaultGcsTokenCredentialProvider.class.getName()); + assertThat(GcsTokenCredentialProviders.from(properties)) + .isNotNull() + .isInstanceOf(GcsTokenCredentialProviders.DefaultGcsTokenCredentialProvider.class); + } + + @Test + public void customProviderAsCredentialProvider() { + Map properties = + ImmutableMap.of( + GCPProperties.GCS_TOKEN_CREDENTIAL_PROVIDER, + DummyGcsTokenCredentialProvider.class.getName()); + GcsTokenCredentialProvider provider = GcsTokenCredentialProviders.from(properties); + + assertThat(provider).isNotNull().isInstanceOf(DummyGcsTokenCredentialProvider.class); + assertThat(provider.credential()).isNull(); + } + + @Test + public void nonExistentCredentialProvider() { + Map properties = + ImmutableMap.of( + GCPProperties.GCS_TOKEN_CREDENTIAL_PROVIDER, + "org.apache.iceberg.gcp.gcs.NonExistentProvider"); + + assertThatIllegalArgumentException() + .isThrownBy(() -> GcsTokenCredentialProviders.from(properties)) + .withMessageContaining( + "Cannot initialize GcsTokenCredentialProvider, missing no-arg constructor"); + } + + @Test + public void nonImplementingClassAsCredentialProvider() { + Map properties = + ImmutableMap.of(GCPProperties.GCS_TOKEN_CREDENTIAL_PROVIDER, "java.lang.String"); + assertThatIllegalArgumentException() + .isThrownBy(() -> GcsTokenCredentialProviders.from(properties)) + .withMessageContaining("java.lang.String does not implement GcsTokenCredentialProvider"); + } + + @Test + public void loadCredentialProviderWithProperties() { + Map properties = + ImmutableMap.of( + GCPProperties.GCS_TOKEN_CREDENTIAL_PROVIDER, + DummyGcsTokenCredentialProvider.class.getName(), + GCPProperties.GCS_TOKEN_PROVIDER_PREFIX + "service-account-key", + "keyValue", + "custom.property", + "custom.value"); + + GcsTokenCredentialProvider provider = GcsTokenCredentialProviders.from(properties); + assertThat(provider).isInstanceOf(DummyGcsTokenCredentialProvider.class); + DummyGcsTokenCredentialProvider credentialProvider = (DummyGcsTokenCredentialProvider) provider; + assertThat(credentialProvider.properties()) + .containsEntry("service-account-key", "keyValue") + .doesNotContainKey("custom.property") + .doesNotContainKey(GCPProperties.GCS_TOKEN_CREDENTIAL_PROVIDER); + } + + static class DummyGcsTokenCredentialProvider implements GcsTokenCredentialProvider { + + private Map properties; + + @Override + public GoogleCredentials credential() { + return null; + } + + @Override + public void initialize(Map credentialProperties) { + this.properties = credentialProperties; + } + + public Map properties() { + return properties; + } + } +} From a49425969a4df2eb49e498b351832177d18a6094 Mon Sep 17 00:00:00 2001 From: Aman Agrawal Date: Mon, 3 Aug 2026 23:29:37 +0530 Subject: [PATCH 2/2] GCP: improve token credential provider documentation, test coverage and robustness check Adds precedence documentation, robustness checks (null-gaurd, LOG.warn for shadowed provider), and new tests verifying property parsing and credential selection precedence. --- .../org/apache/iceberg/gcp/GCPProperties.java | 6 +++ .../gcp/gcs/GcsTokenCredentialProvider.java | 5 ++ .../gcp/gcs/GcsTokenCredentialProviders.java | 3 +- .../iceberg/gcp/gcs/PrefixedStorage.java | 18 ++++++- .../gcs/TestGcsTokenCredentialProviders.java | 2 +- .../iceberg/gcp/gcs/TestPrefixedStorage.java | 53 +++++++++++++++++++ 6 files changed, 84 insertions(+), 3 deletions(-) diff --git a/gcp/src/main/java/org/apache/iceberg/gcp/GCPProperties.java b/gcp/src/main/java/org/apache/iceberg/gcp/GCPProperties.java index e30e1e26fcc6..1cca8686f8c2 100644 --- a/gcp/src/main/java/org/apache/iceberg/gcp/GCPProperties.java +++ b/gcp/src/main/java/org/apache/iceberg/gcp/GCPProperties.java @@ -72,6 +72,12 @@ public class GCPProperties implements Serializable { * implementation. When set, {@code PrefixedStorage} uses this provider to obtain {@link * com.google.auth.oauth2.GoogleCredentials} instead of reading a static {@link #GCS_OAUTH2_TOKEN} * property. The provider class must have a no-arg constructor. + * + *

Precedence: This property is ignored if {@link #GCS_OAUTH2_TOKEN} or {@link + * #GCS_IMPERSONATE_SERVICE_ACCOUNT} is also set. The vended credential path sets both {@link + * #GCS_OAUTH2_TOKEN} (per-prefix token) and this provider (refresh source), so the token takes + * precedence. Impersonation similarly takes precedence to maintain deterministic credential + * selection. */ public static final String GCS_TOKEN_CREDENTIAL_PROVIDER = "gcs.token-credential-provider"; diff --git a/gcp/src/main/java/org/apache/iceberg/gcp/gcs/GcsTokenCredentialProvider.java b/gcp/src/main/java/org/apache/iceberg/gcp/gcs/GcsTokenCredentialProvider.java index 4abce9ccbce7..077afa4b8291 100644 --- a/gcp/src/main/java/org/apache/iceberg/gcp/gcs/GcsTokenCredentialProvider.java +++ b/gcp/src/main/java/org/apache/iceberg/gcp/gcs/GcsTokenCredentialProvider.java @@ -23,6 +23,11 @@ public interface GcsTokenCredentialProvider { + /** + * Returns a GoogleCredentials instance for authenticating GCS requests. + * + * @return a GoogleCredentials instance, must not be null + */ GoogleCredentials credential(); /** diff --git a/gcp/src/main/java/org/apache/iceberg/gcp/gcs/GcsTokenCredentialProviders.java b/gcp/src/main/java/org/apache/iceberg/gcp/gcs/GcsTokenCredentialProviders.java index 186c8a0a92b9..5cd37d120682 100644 --- a/gcp/src/main/java/org/apache/iceberg/gcp/gcs/GcsTokenCredentialProviders.java +++ b/gcp/src/main/java/org/apache/iceberg/gcp/gcs/GcsTokenCredentialProviders.java @@ -65,7 +65,8 @@ private static GcsTokenCredentialProvider loadCredentialProvider( } catch (NoSuchMethodException e) { throw new IllegalArgumentException( String.format( - "Cannot initialize GcsTokenCredentialProvider, missing no-arg constructor: %s", impl), + "Cannot initialize GcsTokenCredentialProvider, cannot load class or missing no-arg constructor: %s", + impl), e); } diff --git a/gcp/src/main/java/org/apache/iceberg/gcp/gcs/PrefixedStorage.java b/gcp/src/main/java/org/apache/iceberg/gcp/gcs/PrefixedStorage.java index 71c07e940993..2874be8e747a 100644 --- a/gcp/src/main/java/org/apache/iceberg/gcp/gcs/PrefixedStorage.java +++ b/gcp/src/main/java/org/apache/iceberg/gcp/gcs/PrefixedStorage.java @@ -36,8 +36,11 @@ import org.apache.iceberg.relocated.com.google.common.base.Strings; import org.apache.iceberg.relocated.com.google.common.collect.ImmutableMap; import org.apache.iceberg.util.SerializableSupplier; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; class PrefixedStorage implements AutoCloseable { + private static final Logger LOG = LoggerFactory.getLogger(PrefixedStorage.class); private static final String GCS_FILE_IO_USER_AGENT = "gcsfileio/" + EnvironmentContext.get(); private final String storagePrefix; private final GCPProperties gcpProperties; @@ -157,11 +160,24 @@ private Credentials credentials(GCPProperties properties) { // Explicitly allow "no credentials" for testing purposes return NoCredentials.getInstance(); } else if (properties.impersonateServiceAccount().isPresent()) { + if (gcpProperties.tokenCredentialProvider().isPresent()) { + LOG.warn( + "Both {} and {} are set; {} takes precedence and the provider is ignored", + GCPProperties.GCS_IMPERSONATE_SERVICE_ACCOUNT, + GCPProperties.GCS_TOKEN_CREDENTIAL_PROVIDER, + GCPProperties.GCS_IMPERSONATE_SERVICE_ACCOUNT); + } return buildImpersonatedCredentials(properties); } else if (properties.tokenCredentialProvider().isPresent()) { // A custom provider yields a self-refreshing GoogleCredentials (e.g. built from a // caller-supplied source credential), addressing static-token expiry for non-vended setups. - return GcsTokenCredentialProviders.from(properties.properties()).credential(); + GoogleCredentials credentials = + GcsTokenCredentialProviders.from(properties.properties()).credential(); + Preconditions.checkState( + credentials != null, + "Provider %s returned null credentials", + gcpProperties.tokenCredentialProvider().get()); + return credentials; } else { return null; } diff --git a/gcp/src/test/java/org/apache/iceberg/gcp/gcs/TestGcsTokenCredentialProviders.java b/gcp/src/test/java/org/apache/iceberg/gcp/gcs/TestGcsTokenCredentialProviders.java index 7a8ec5758bbf..83b0154d4240 100644 --- a/gcp/src/test/java/org/apache/iceberg/gcp/gcs/TestGcsTokenCredentialProviders.java +++ b/gcp/src/test/java/org/apache/iceberg/gcp/gcs/TestGcsTokenCredentialProviders.java @@ -85,7 +85,7 @@ public void nonExistentCredentialProvider() { assertThatIllegalArgumentException() .isThrownBy(() -> GcsTokenCredentialProviders.from(properties)) .withMessageContaining( - "Cannot initialize GcsTokenCredentialProvider, missing no-arg constructor"); + "Cannot initialize GcsTokenCredentialProvider, cannot load class or missing no-arg constructor"); } @Test diff --git a/gcp/src/test/java/org/apache/iceberg/gcp/gcs/TestPrefixedStorage.java b/gcp/src/test/java/org/apache/iceberg/gcp/gcs/TestPrefixedStorage.java index 0a06fcdd0c1f..5b0d8d45be56 100644 --- a/gcp/src/test/java/org/apache/iceberg/gcp/gcs/TestPrefixedStorage.java +++ b/gcp/src/test/java/org/apache/iceberg/gcp/gcs/TestPrefixedStorage.java @@ -25,6 +25,7 @@ import com.google.cloud.gcs.analyticscore.client.GcsFileSystem; import com.google.cloud.gcs.analyticscore.client.GcsFileSystemOptions; import com.google.cloud.gcs.analyticscore.client.GcsReadOptions; +import java.io.UncheckedIOException; import java.util.Map; import org.apache.iceberg.EnvironmentContext; import org.apache.iceberg.gcp.GCPProperties; @@ -160,4 +161,56 @@ public void gcsFileSystem() { assertThat(fileSystem.getGcsClient()).isNotNull(); assertThat(fileSystem.getFileSystemOptions()).isEqualTo(expectedOptions); } + + @Test + public void tokenCredentialProviderSet() { + // Verify that GCPProperties correctly parses gcs.token-credential-provider. + Map properties = + ImmutableMap.of( + GCPProperties.GCS_PROJECT_ID, + "myProject", + GCPProperties.GCS_TOKEN_CREDENTIAL_PROVIDER, + TestGcsTokenCredentialProviders.DummyGcsTokenCredentialProvider.class.getName()); + PrefixedStorage storage = new PrefixedStorage("gs://bucket", properties, null); + + assertThat(storage.storagePrefix()).isEqualTo("gs://bucket"); + assertThat(storage.gcpProperties().tokenCredentialProvider()).isPresent(); + } + + @Test + public void oauth2TokenTakesPrecedenceOverProvider() { + // Vended path: both token and provider are set. Token branch wins, provider ignored. + Map properties = + ImmutableMap.of( + GCPProperties.GCS_PROJECT_ID, + "myProject", + GCPProperties.GCS_OAUTH2_TOKEN, + "token", + GCPProperties.GCS_TOKEN_CREDENTIAL_PROVIDER, + TestGcsTokenCredentialProviders.DummyGcsTokenCredentialProvider.class.getName()); + PrefixedStorage storage = new PrefixedStorage("gs://bucket", properties, null); + + assertThat(storage.storage()).isNotNull(); + assertThat(storage.storage().getOptions().getCredentials()) + .isInstanceOf(com.google.auth.oauth2.OAuth2Credentials.class); + } + + @Test + public void impersonateTakesPrecedenceOverProvider() { + // Impersonation + provider: impersonation branch reached first (verified by exception). + Map properties = + ImmutableMap.of( + GCPProperties.GCS_PROJECT_ID, + "myProject", + GCPProperties.GCS_IMPERSONATE_SERVICE_ACCOUNT, + "sa@project.iam.gserviceaccount.com", + GCPProperties.GCS_TOKEN_CREDENTIAL_PROVIDER, + TestGcsTokenCredentialProviders.DummyGcsTokenCredentialProvider.class.getName()); + PrefixedStorage storage = new PrefixedStorage("gs://bucket", properties, null); + + // Local placeholder throws to prevent production use; verifies impersonation branch executed. + assertThatThrownBy(storage::storage) + .isInstanceOf(UncheckedIOException.class) + .hasMessageContaining("Failed to create impersonated credentials for GCS"); + } }