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

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


The following commit(s) were added to refs/heads/main by this push:
     new fe5b2e540 Core: Add GCP service account impersonation for credentials. 
(#3246)
fe5b2e540 is described below

commit fe5b2e540ce03e49519ff5d3a8a5531c7051a1e0
Author: Talat UYARER <[email protected]>
AuthorDate: Thu Dec 11 10:05:16 2025 -0800

    Core: Add GCP service account impersonation for credentials. (#3246)
---
 CHANGELOG.md                                       |  1 +
 gradle/libs.versions.toml                          |  1 +
 polaris-core/build.gradle.kts                      |  1 +
 .../gcp/GcpCredentialsStorageIntegration.java      | 72 +++++++++++++++++++++-
 .../gcp/GcpCredentialsStorageIntegrationTest.java  | 67 ++++++++++++++++++++
 5 files changed, 140 insertions(+), 2 deletions(-)

diff --git a/CHANGELOG.md b/CHANGELOG.md
index f755d199b..32eec35cb 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -62,6 +62,7 @@ request adding CHANGELOG notes for breaking (!) changes and 
possibly other secti
 
 ### Changes
 
+- The `gcpServiceAccount` configuration value now affects Polaris behavior 
(enables service account impersonation). This value was previously defined but 
unused. This change may affect existing deployments that have populated this 
property.
 - `client.region` is no longer considered a "credential" property (related to 
Iceberg REST Catalog API).
 - Relaxed the requirements for S3 storage's ARN to allow Polaris to connect to 
more non-AWS S3 storage appliances. 
 - Added checksum to helm deployment so that it will restart when the configmap 
has changed.
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index dc6a954d0..c0c703383 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -52,6 +52,7 @@ cel-bom = { module = "org.projectnessie.cel:cel-bom", version 
= "0.5.3" }
 commons-lang3 = { module = "org.apache.commons:commons-lang3", version = 
"3.20.0" }
 commons-text = { module = "org.apache.commons:commons-text", version = 
"1.15.0" }
 errorprone = { module = "com.google.errorprone:error_prone_core", version = 
"2.45.0" }
+google-cloud-iamcredentials = { module = 
"com.google.cloud:google-cloud-iamcredentials", version = "2.60.0" }
 google-cloud-storage-bom = { module = 
"com.google.cloud:google-cloud-storage-bom", version = "2.60.0" }
 guava = { module = "com.google.guava:guava", version = "33.5.0-jre" }
 h2 = { module = "com.h2database:h2", version = "2.4.240" }
diff --git a/polaris-core/build.gradle.kts b/polaris-core/build.gradle.kts
index e9427e680..b8beb85b1 100644
--- a/polaris-core/build.gradle.kts
+++ b/polaris-core/build.gradle.kts
@@ -69,6 +69,7 @@ dependencies {
   implementation("org.apache.iceberg:iceberg-gcp")
   implementation(platform(libs.google.cloud.storage.bom))
   implementation("com.google.cloud:google-cloud-storage")
+  implementation(libs.google.cloud.iamcredentials)
 
   testCompileOnly(project(":polaris-immutables"))
   testAnnotationProcessor(project(":polaris-immutables", configuration = 
"processor"))
diff --git 
a/polaris-core/src/main/java/org/apache/polaris/core/storage/gcp/GcpCredentialsStorageIntegration.java
 
b/polaris-core/src/main/java/org/apache/polaris/core/storage/gcp/GcpCredentialsStorageIntegration.java
index 5f524d9ae..6964e77c7 100644
--- 
a/polaris-core/src/main/java/org/apache/polaris/core/storage/gcp/GcpCredentialsStorageIntegration.java
+++ 
b/polaris-core/src/main/java/org/apache/polaris/core/storage/gcp/GcpCredentialsStorageIntegration.java
@@ -20,16 +20,25 @@ package org.apache.polaris.core.storage.gcp;
 
 import com.fasterxml.jackson.core.JsonProcessingException;
 import com.fasterxml.jackson.databind.ObjectMapper;
+import com.google.api.gax.core.FixedCredentialsProvider;
 import com.google.auth.http.HttpTransportFactory;
 import com.google.auth.oauth2.AccessToken;
 import com.google.auth.oauth2.CredentialAccessBoundary;
 import com.google.auth.oauth2.DownscopedCredentials;
 import com.google.auth.oauth2.GoogleCredentials;
+import com.google.cloud.iam.credentials.v1.GenerateAccessTokenRequest;
+import com.google.cloud.iam.credentials.v1.GenerateAccessTokenResponse;
+import com.google.cloud.iam.credentials.v1.IamCredentialsClient;
+import com.google.cloud.iam.credentials.v1.IamCredentialsSettings;
 import com.google.common.annotations.VisibleForTesting;
+import com.google.protobuf.Duration;
+import com.google.protobuf.Timestamp;
 import jakarta.annotation.Nonnull;
 import java.io.IOException;
 import java.net.URI;
+import java.time.Instant;
 import java.util.ArrayList;
+import java.util.Date;
 import java.util.HashMap;
 import java.util.HashSet;
 import java.util.List;
@@ -55,6 +64,9 @@ public class GcpCredentialsStorageIntegration
     extends InMemoryStorageIntegration<GcpStorageConfigurationInfo> {
   private static final Logger LOGGER =
       LoggerFactory.getLogger(GcpCredentialsStorageIntegration.class);
+  public static final String SERVICE_ACCOUNT_PREFIX = 
"projects/-/serviceAccounts/";
+  public static final String IMPERSONATION_SCOPE =
+      "https://www.googleapis.com/auth/devstorage.read_write";;
 
   private final GoogleCredentials sourceCredentials;
   private final HttpTransportFactory transportFactory;
@@ -84,18 +96,20 @@ public class GcpCredentialsStorageIntegration
       throw new RuntimeException("Unable to refresh GCP credentials", e);
     }
 
+    GoogleCredentials credentialsToDownscope = getBaseCredentials();
+
     CredentialAccessBoundary accessBoundary =
         generateAccessBoundaryRules(
             allowListOperation, allowedReadLocations, allowedWriteLocations);
     DownscopedCredentials credentials =
         DownscopedCredentials.newBuilder()
             .setHttpTransportFactory(transportFactory)
-            .setSourceCredential(sourceCredentials)
+            .setSourceCredential(credentialsToDownscope)
             .setCredentialAccessBoundary(accessBoundary)
             .build();
     AccessToken token;
     try {
-      token = credentials.refreshAccessToken();
+      token = refreshAccessToken(credentials);
     } catch (IOException e) {
       LOGGER
           .atError()
@@ -123,6 +137,46 @@ public class GcpCredentialsStorageIntegration
     return accessConfig.build();
   }
 
+  /**
+   * Returns the credential to be used as the source for downscoping. If a 
specific service account
+   * is configured, it impersonates that account first.
+   */
+  private GoogleCredentials getBaseCredentials() {
+    if (config().getGcpServiceAccount() != null) {
+      return createImpersonatedCredentials(sourceCredentials, 
config().getGcpServiceAccount());
+    }
+    return sourceCredentials;
+  }
+
+  private GoogleCredentials createImpersonatedCredentials(
+      GoogleCredentials source, String targetServiceAccount) {
+    try (IamCredentialsClient iamCredentialsClient = 
createIamCredentialsClient(source)) {
+      GenerateAccessTokenRequest request =
+          GenerateAccessTokenRequest.newBuilder()
+              .setName(SERVICE_ACCOUNT_PREFIX + targetServiceAccount)
+              .addAllDelegates(new ArrayList<>())
+              // 'cloud-platform' is often preferred for impersonation,
+              // but devstorage.read_write is sufficient for GCS specific 
operations.
+              // See https://docs.cloud.google.com/storage/docs/oauth-scopes
+              .addScope(IMPERSONATION_SCOPE)
+              .setLifetime(Duration.newBuilder().setSeconds(3600).build())
+              .build();
+
+      GenerateAccessTokenResponse response = 
iamCredentialsClient.generateAccessToken(request);
+
+      Timestamp expirationTime = response.getExpireTime();
+      // Use Instant to avoid precision loss or overflow issues with Date 
multiplication
+      Date expirationDate =
+          Date.from(Instant.ofEpochSecond(expirationTime.getSeconds(), 
expirationTime.getNanos()));
+
+      AccessToken accessToken = new AccessToken(response.getAccessToken(), 
expirationDate);
+      return GoogleCredentials.create(accessToken);
+    } catch (IOException e) {
+      throw new RuntimeException(
+          "Unable to impersonate GCP service account: " + 
targetServiceAccount, e);
+    }
+  }
+
   private String convertToString(CredentialAccessBoundary accessBoundary) {
     try {
       return new ObjectMapper().writeValueAsString(accessBoundary);
@@ -211,6 +265,20 @@ public class GcpCredentialsStorageIntegration
     return accessBoundaryBuilder.build();
   }
 
+  @VisibleForTesting
+  protected AccessToken refreshAccessToken(DownscopedCredentials credentials) 
throws IOException {
+    return credentials.refreshAccessToken();
+  }
+
+  @VisibleForTesting
+  protected IamCredentialsClient createIamCredentialsClient(GoogleCredentials 
credentials)
+      throws IOException {
+    return IamCredentialsClient.create(
+        IamCredentialsSettings.newBuilder()
+            
.setCredentialsProvider(FixedCredentialsProvider.create(credentials))
+            .build());
+  }
+
   private static String bucketResource(String bucket) {
     return "//storage.googleapis.com/projects/_/buckets/" + bucket;
   }
diff --git 
a/polaris-core/src/test/java/org/apache/polaris/service/storage/gcp/GcpCredentialsStorageIntegrationTest.java
 
b/polaris-core/src/test/java/org/apache/polaris/service/storage/gcp/GcpCredentialsStorageIntegrationTest.java
index b0be0883d..74ec73303 100644
--- 
a/polaris-core/src/test/java/org/apache/polaris/service/storage/gcp/GcpCredentialsStorageIntegrationTest.java
+++ 
b/polaris-core/src/test/java/org/apache/polaris/service/storage/gcp/GcpCredentialsStorageIntegrationTest.java
@@ -29,13 +29,18 @@ import com.google.api.client.http.javanet.NetHttpTransport;
 import com.google.auth.http.HttpTransportFactory;
 import com.google.auth.oauth2.AccessToken;
 import com.google.auth.oauth2.CredentialAccessBoundary;
+import com.google.auth.oauth2.DownscopedCredentials;
 import com.google.auth.oauth2.GoogleCredentials;
 import com.google.cloud.ServiceOptions;
+import com.google.cloud.iam.credentials.v1.GenerateAccessTokenRequest;
+import com.google.cloud.iam.credentials.v1.GenerateAccessTokenResponse;
+import com.google.cloud.iam.credentials.v1.IamCredentialsClient;
 import com.google.cloud.storage.BlobId;
 import com.google.cloud.storage.BlobInfo;
 import com.google.cloud.storage.Storage;
 import com.google.cloud.storage.StorageException;
 import com.google.cloud.storage.StorageOptions;
+import com.google.protobuf.Timestamp;
 import java.io.IOException;
 import java.io.InputStream;
 import java.util.Arrays;
@@ -55,6 +60,7 @@ import 
org.assertj.core.api.recursive.comparison.RecursiveComparisonConfiguratio
 import org.junit.jupiter.api.Test;
 import org.junit.jupiter.params.ParameterizedTest;
 import org.junit.jupiter.params.provider.ValueSource;
+import org.mockito.Mockito;
 
 class GcpCredentialsStorageIntegrationTest extends BaseStorageIntegrationTest {
 
@@ -309,6 +315,67 @@ class GcpCredentialsStorageIntegrationTest extends 
BaseStorageIntegrationTest {
         .isEqualTo(REFRESH_ENDPOINT);
   }
 
+  @Test
+  public void testImpersonation() throws IOException {
+    String serviceAccount = "[email protected]";
+    GcpStorageConfigurationInfo config =
+        GcpStorageConfigurationInfo.builder()
+            .addAllAllowedLocations(List.of("gs://bucket/path"))
+            .gcpServiceAccount(serviceAccount)
+            .build();
+
+    IamCredentialsClient mockIamClient = 
Mockito.mock(IamCredentialsClient.class);
+    GenerateAccessTokenResponse mockResponse =
+        GenerateAccessTokenResponse.newBuilder()
+            .setAccessToken("impersonated-token")
+            .setExpireTime(
+                Timestamp.newBuilder().setSeconds(System.currentTimeMillis() / 
1000 + 3600).build())
+            .build();
+    
Mockito.when(mockIamClient.generateAccessToken(Mockito.any(GenerateAccessTokenRequest.class)))
+        .thenReturn(mockResponse);
+
+    GoogleCredentials mockCreds = Mockito.mock(GoogleCredentials.class);
+    
Mockito.when(mockCreds.createScoped(Mockito.any(String.class))).thenReturn(mockCreds);
+
+    GcpCredentialsStorageIntegration integration =
+        new GcpCredentialsStorageIntegration(
+            config,
+            mockCreds,
+            ServiceOptions.getFromServiceLoader(
+                HttpTransportFactory.class, NetHttpTransport::new)) {
+          @Override
+          protected IamCredentialsClient 
createIamCredentialsClient(GoogleCredentials credentials) {
+            return mockIamClient;
+          }
+
+          @Override
+          protected AccessToken refreshAccessToken(DownscopedCredentials 
credentials) {
+            return new AccessToken("downscoped-token", new Date());
+          }
+        };
+
+    integration.getSubscopedCreds(
+        EMPTY_REALM_CONFIG,
+        true,
+        Set.of("gs://bucket/path"),
+        Set.of("gs://bucket/path"),
+        Optional.empty());
+
+    Mockito.verify(mockIamClient)
+        .generateAccessToken(
+            Mockito.argThat(
+                request ->
+                    request
+                            .getName()
+                            .equals(
+                                
GcpCredentialsStorageIntegration.SERVICE_ACCOUNT_PREFIX
+                                    + serviceAccount)
+                        && request.getScopeCount() > 0
+                        && request
+                            .getScope(0)
+                            
.equals(GcpCredentialsStorageIntegration.IMPERSONATION_SCOPE)));
+  }
+
   private boolean isNotNull(JsonNode node) {
     return node != null && !node.isNull();
   }

Reply via email to