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

dimas 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 e37219df4 Sanitize principal names in AWS STS role session names 
(#3525)
e37219df4 is described below

commit e37219df4f555024ce98128d906714e4be8b15c4
Author: Prathyush Shankar <[email protected]>
AuthorDate: Tue Feb 3 17:14:47 2026 -0800

    Sanitize principal names in AWS STS role session names (#3525)
    
    Principal names containing invalid characters (spaces, parentheses,
    etc.) were causing AWS STS AssumeRole requests to fail with validation
    errors. AWS STS role session names must match the pattern [\w+=,.@-]*.
    
    This change:
    - Adds AwsRoleSessionNameSanitizer utility class to sanitize strings
      for use as AWS STS role session names
    - Replaces invalid characters with underscores and truncates to 64
      characters (AWS maximum)
    - Updates AwsCredentialsStorageIntegration to sanitize principal names
      when INCLUDE_PRINCIPAL_NAME_IN_SUBSCOPED_CREDENTIAL is enabled
    - Adds tests to verify sanitization behavior and AWS pattern compliance
    
    Fixes issue where principal names like "Joe (local)" would produce
    invalid role session names like "polaris-Joe (local)" and cause
    AssumeRole to fail. Now sanitized to "polaris-Joe__local_".
    
    Co-authored-by: carc-prathyush-shankar <[email protected]>
---
 .../aws/AwsCredentialsStorageIntegration.java      |  6 +-
 .../storage/aws/AwsRoleSessionNameSanitizer.java   | 98 ++++++++++++++++++++++
 .../aws/AwsRoleSessionNameSanitizerTest.java       | 70 ++++++++++++++++
 .../aws/AwsCredentialsStorageIntegrationTest.java  | 78 +++++++++++++++++
 4 files changed, 248 insertions(+), 4 deletions(-)

diff --git 
a/polaris-core/src/main/java/org/apache/polaris/core/storage/aws/AwsCredentialsStorageIntegration.java
 
b/polaris-core/src/main/java/org/apache/polaris/core/storage/aws/AwsCredentialsStorageIntegration.java
index 0fe618284..136d95cfa 100644
--- 
a/polaris-core/src/main/java/org/apache/polaris/core/storage/aws/AwsCredentialsStorageIntegration.java
+++ 
b/polaris-core/src/main/java/org/apache/polaris/core/storage/aws/AwsCredentialsStorageIntegration.java
@@ -102,17 +102,15 @@ public class AwsCredentialsStorageIntegration
 
     String roleSessionName =
         includePrincipalNameInSubscopedCredential
-            ? "polaris-" + polarisPrincipal.getName()
+            ? AwsRoleSessionNameSanitizer.sanitize("polaris-" + 
polarisPrincipal.getName())
             : "PolarisAwsCredentialsStorageIntegration";
-    String cappedRoleSessionName =
-        roleSessionName.substring(0, Math.min(roleSessionName.length(), 64));
 
     if (shouldUseSts(storageConfig)) {
       AssumeRoleRequest.Builder request =
           AssumeRoleRequest.builder()
               .externalId(storageConfig.getExternalId())
               .roleArn(storageConfig.getRoleARN())
-              .roleSessionName(cappedRoleSessionName)
+              .roleSessionName(roleSessionName)
               .policy(
                   policyString(
                           storageConfig,
diff --git 
a/polaris-core/src/main/java/org/apache/polaris/core/storage/aws/AwsRoleSessionNameSanitizer.java
 
b/polaris-core/src/main/java/org/apache/polaris/core/storage/aws/AwsRoleSessionNameSanitizer.java
new file mode 100644
index 000000000..febad28b9
--- /dev/null
+++ 
b/polaris-core/src/main/java/org/apache/polaris/core/storage/aws/AwsRoleSessionNameSanitizer.java
@@ -0,0 +1,98 @@
+/*
+ * 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.polaris.core.storage.aws;
+
+import jakarta.annotation.Nonnull;
+import java.util.regex.Pattern;
+
+/**
+ * Utility class for sanitizing AWS STS role session names.
+ *
+ * <p>AWS STS role session names must conform to the pattern {@code 
[\w+=,.@-]*} and have a maximum
+ * length of 64 characters. This class provides methods to sanitize arbitrary 
strings (such as
+ * principal names) into valid role session names.
+ *
+ * @see <a 
href="https://docs.aws.amazon.com/STS/latest/APIReference/API_AssumeRole.html";>AWS
 STS
+ *     AssumeRole API</a>
+ */
+public final class AwsRoleSessionNameSanitizer {
+
+  /**
+   * AWS STS role session name maximum length. While the AssumedRoleId can be 
up to 193 characters,
+   * the roleSessionName parameter itself is limited to 64 characters.
+   */
+  static final int MAX_ROLE_SESSION_NAME_LENGTH = 64;
+
+  /**
+   * Pattern matching characters that are NOT allowed in AWS STS role session 
names. AWS allows:
+   * alphanumeric characters (a-z, A-Z, 0-9), underscore (_), plus (+), equals 
(=), comma (,),
+   * period (.), at sign (@), and hyphen (-).
+   *
+   * <p>This pattern matches any character outside this allowed set.
+   */
+  private static final Pattern INVALID_ROLE_SESSION_NAME_CHARS =
+      Pattern.compile("[^a-zA-Z0-9_+=,.@-]");
+
+  /** Default replacement character for invalid characters. */
+  private static final String DEFAULT_REPLACEMENT = "_";
+
+  private AwsRoleSessionNameSanitizer() {
+    // Utility class to prevent instantiation
+  }
+
+  /**
+   * Sanitizes a string for use as an AWS STS role session name.
+   *
+   * <p>This method:
+   *
+   * <ol>
+   *   <li>Replaces any characters not matching {@code [\w+=,.@-]} with 
underscores
+   *   <li>Truncates the result to 64 characters (AWS maximum)
+   * </ol>
+   *
+   * <p>The underscore replacement character was chosen because:
+   *
+   * <ul>
+   *   <li>It is always valid in role session names
+   *   <li>It is visually distinct and indicates a substitution occurred
+   *   <li>It does not introduce ambiguity (unlike hyphen which is common in 
names)
+   * </ul>
+   *
+   * @param input the string to sanitize (typically a principal name)
+   * @return a sanitized string safe for use as an AWS STS role session name
+   */
+  public static @Nonnull String sanitize(@Nonnull String input) {
+    String sanitized =
+        
INVALID_ROLE_SESSION_NAME_CHARS.matcher(input).replaceAll(DEFAULT_REPLACEMENT);
+    return truncate(sanitized);
+  }
+
+  /**
+   * Truncates a string to the maximum allowed role session name length.
+   *
+   * @param input the string to truncate
+   * @return the truncated string, or the original if already within limits
+   */
+  static @Nonnull String truncate(@Nonnull String input) {
+    if (input.length() <= MAX_ROLE_SESSION_NAME_LENGTH) {
+      return input;
+    }
+    return input.substring(0, MAX_ROLE_SESSION_NAME_LENGTH);
+  }
+}
diff --git 
a/polaris-core/src/test/java/org/apache/polaris/core/storage/aws/AwsRoleSessionNameSanitizerTest.java
 
b/polaris-core/src/test/java/org/apache/polaris/core/storage/aws/AwsRoleSessionNameSanitizerTest.java
new file mode 100644
index 000000000..0e9d7979b
--- /dev/null
+++ 
b/polaris-core/src/test/java/org/apache/polaris/core/storage/aws/AwsRoleSessionNameSanitizerTest.java
@@ -0,0 +1,70 @@
+/*
+ * 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.polaris.core.storage.aws;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+import java.util.regex.Pattern;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.CsvSource;
+
+class AwsRoleSessionNameSanitizerTest {
+
+  /** AWS STS role session name validation pattern. */
+  private static final Pattern AWS_ROLE_SESSION_NAME_PATTERN = 
Pattern.compile("[\\w+=,.@-]*");
+
+  @ParameterizedTest
+  @CsvSource({
+    "polaris-Invalid (local),polaris-Invalid__local_",
+    "service/account:readonly,service_account_readonly",
+    "user name,user_name",
+    "polaris-test-principal,polaris-test-principal",
+    "[email protected],[email protected]",
+    "key=value,key=value"
+  })
+  void testSanitize(String input, String expected) {
+    
assertThat(AwsRoleSessionNameSanitizer.sanitize(input)).isEqualTo(expected);
+  }
+
+  @Test
+  void testSanitizeTruncatesToMaxLength() {
+    String longInput = "a".repeat(100);
+    String result = AwsRoleSessionNameSanitizer.sanitize(longInput);
+    
assertThat(result).hasSize(AwsRoleSessionNameSanitizer.MAX_ROLE_SESSION_NAME_LENGTH);
+  }
+
+  @Test
+  void testSanitizeOutputMatchesAwsPattern() {
+    String[] inputs = {
+      "polaris-Invalid (local)",
+      "special!@#$%chars",
+      "path/to/resource",
+      "very-long-name-" + "x".repeat(100)
+    };
+
+    for (String input : inputs) {
+      String sanitized = AwsRoleSessionNameSanitizer.sanitize(input);
+      assertThat(AWS_ROLE_SESSION_NAME_PATTERN.matcher(sanitized).matches())
+          .as("Sanitized '%s' should match AWS pattern", sanitized)
+          .isTrue();
+      assertThat(sanitized.length()).isLessThanOrEqualTo(64);
+    }
+  }
+}
diff --git 
a/polaris-core/src/test/java/org/apache/polaris/service/storage/aws/AwsCredentialsStorageIntegrationTest.java
 
b/polaris-core/src/test/java/org/apache/polaris/service/storage/aws/AwsCredentialsStorageIntegrationTest.java
index 316624557..fdea3b0b5 100644
--- 
a/polaris-core/src/test/java/org/apache/polaris/service/storage/aws/AwsCredentialsStorageIntegrationTest.java
+++ 
b/polaris-core/src/test/java/org/apache/polaris/service/storage/aws/AwsCredentialsStorageIntegrationTest.java
@@ -1045,6 +1045,84 @@ class AwsCredentialsStorageIntegrationTest extends 
BaseStorageIntegrationTest {
             CredentialVendingContext.empty());
   }
 
+  @Test
+  public void testGetSubscopedCredsPrincipalNameWithInvalidCharacters() {
+    StsClient stsClient = Mockito.mock(StsClient.class);
+    String roleARN = "arn:aws:iam::012345678901:role/jdoe";
+    String externalId = "externalId";
+    // Principal name with spaces and parentheses - invalid for AWS STS
+    PolarisPrincipal polarisPrincipalWithInvalidChars =
+        PolarisPrincipal.of("Invalid Principal (local)", Map.of(), Set.of());
+
+    Mockito.when(stsClient.assumeRole(Mockito.isA(AssumeRoleRequest.class)))
+        .thenAnswer(
+            invocation -> {
+              assertThat(invocation.getArguments()[0])
+                  .isInstanceOf(AssumeRoleRequest.class)
+                  
.asInstanceOf(InstanceOfAssertFactories.type(AssumeRoleRequest.class))
+                  .returns(externalId, AssumeRoleRequest::externalId)
+                  .returns(roleARN, AssumeRoleRequest::roleArn)
+                  // Spaces and parentheses should be replaced with underscores
+                  .returns("polaris-Invalid_Principal__local_", 
AssumeRoleRequest::roleSessionName);
+              return ASSUME_ROLE_RESPONSE;
+            });
+    String warehouseDir = "s3://bucket/path/to/warehouse";
+    new AwsCredentialsStorageIntegration(
+            AwsStorageConfigurationInfo.builder()
+                .addAllowedLocation(warehouseDir)
+                .roleARN(roleARN)
+                .externalId(externalId)
+                .build(),
+            stsClient)
+        .getSubscopedCreds(
+            PRINCIPAL_INCLUDER_REALM_CONFIG,
+            true,
+            Set.of(warehouseDir + "/namespace/table"),
+            Set.of(warehouseDir + "/namespace/table"),
+            polarisPrincipalWithInvalidChars,
+            Optional.of("/namespace/table/credentials"),
+            CredentialVendingContext.empty());
+  }
+
+  @Test
+  public void testGetSubscopedCredsPrincipalNameWithSpecialCharacters() {
+    StsClient stsClient = Mockito.mock(StsClient.class);
+    String roleARN = "arn:aws:iam::012345678901:role/jdoe";
+    String externalId = "externalId";
+    // Principal name with slashes and colons
+    PolarisPrincipal polarisPrincipalWithSpecialChars =
+        PolarisPrincipal.of("service/account:readonly", Map.of(), Set.of());
+
+    Mockito.when(stsClient.assumeRole(Mockito.isA(AssumeRoleRequest.class)))
+        .thenAnswer(
+            invocation -> {
+              assertThat(invocation.getArguments()[0])
+                  .isInstanceOf(AssumeRoleRequest.class)
+                  
.asInstanceOf(InstanceOfAssertFactories.type(AssumeRoleRequest.class))
+                  .returns(externalId, AssumeRoleRequest::externalId)
+                  .returns(roleARN, AssumeRoleRequest::roleArn)
+                  // Slashes and colons should be replaced with underscores
+                  .returns("polaris-service_account_readonly", 
AssumeRoleRequest::roleSessionName);
+              return ASSUME_ROLE_RESPONSE;
+            });
+    String warehouseDir = "s3://bucket/path/to/warehouse";
+    new AwsCredentialsStorageIntegration(
+            AwsStorageConfigurationInfo.builder()
+                .addAllowedLocation(warehouseDir)
+                .roleARN(roleARN)
+                .externalId(externalId)
+                .build(),
+            stsClient)
+        .getSubscopedCreds(
+            PRINCIPAL_INCLUDER_REALM_CONFIG,
+            true,
+            Set.of(warehouseDir + "/namespace/table"),
+            Set.of(warehouseDir + "/namespace/table"),
+            polarisPrincipalWithSpecialChars,
+            Optional.of("/namespace/table/credentials"),
+            CredentialVendingContext.empty());
+  }
+
   private static @Nonnull String s3Arn(String partition, String bucket, String 
keyPrefix) {
     String bucketArn = "arn:" + partition + ":s3:::" + bucket;
     if (keyPrefix == null) {

Reply via email to