This is an automated email from the ASF dual-hosted git repository.
jerryshao pushed a commit to branch branch-1.3
in repository https://gitbox.apache.org/repos/asf/gravitino.git
The following commit(s) were added to refs/heads/branch-1.3 by this push:
new e9e3c54fda [Cherry-pick to branch-1.3] [#13977] fix(credential): Scope
and validate cloud credentials (#13987) (#14129)
e9e3c54fda is described below
commit e9e3c54fdaa904cbce32134ba761179c4989eabf
Author: github-actions[bot]
<41898282+github-actions[bot]@users.noreply.github.com>
AuthorDate: Mon Oct 26 16:24:31 2026 +0800
[Cherry-pick to branch-1.3] [#13977] fix(credential): Scope and validate
cloud credentials (#13987) (#14129)
**Cherry-pick information**
- Original PR: #13987 (commit
`bbb73f1a656842969ab603f1a5978a6c5d2acb60`)
- Target branch: `branch-1.3`
- Status: Documentation conflict resolved; no conflict markers remain.
### What changes were proposed in this pull request?
- Scope path-based AWS IRSA credentials with an STS session policy and
skip catalog-level IRSA vending.
- Normalize AWS and OSS role session names, reject unsupported `wasb(s)`
locations, and include every requested OSS path in the listing policy.
- Add regression tests and update the credential-vending documentation.
### Why are the changes needed?
Catalog-level IRSA requests could expose the role's full permissions.
Invalid session names, unsupported ADLS locations, and incomplete OSS
listing policies could prevent correct credential vending.
Fix: #13977
### Does this PR introduce _any_ user-facing change?
Catalog-level requests no longer return `aws-irsa` credentials.
`wasb(s)` locations fail with an unsupported-location error. STS session
names are normalized. No property keys change.
### How was this patch tested?
- Original PR #13987 ran focused tests in `:common:test`,
`:bundles:aws:test`, `:bundles:azure:test`, and `:bundles:aliyun:test`,
plus Spotless.
- For this cherry-pick, checked the resolved documentation with `git
diff --cached --check` and confirmed no conflict markers remain.
---------
Co-authored-by: roryqi <[email protected]>
Co-authored-by: roryqi <[email protected]>
Co-authored-by: Jerry Shao <[email protected]>
---
.../oss/credential/OSSTokenGenerator.java | 42 ++++++---------
.../oss/credential/TestOSSTokenGenerator.java | 45 ++++++++++++++++
.../s3/credential/AwsIrsaCredentialGenerator.java | 60 ++--------------------
.../gravitino/s3/credential/S3TokenGenerator.java | 3 +-
.../credential/TestAwsIrsaCredentialGenerator.java | 45 ++++++++--------
.../abs/credential/ADLSTokenGenerator.java | 17 +++++-
.../abs/credential/TestADLSTokenGenerator.java | 34 ++++++++++++
.../credential/CredentialProviderDelegator.java | 2 +
.../gravitino/credential/RoleSessionNameUtils.java | 51 ++++++++++++++++++
.../credential/TestRoleSessionNameUtils.java | 53 +++++++++++++++++++
docs/security/credential-vending.md | 7 ++-
.../TestMetadataObjectCredentialOperations.java | 24 +++++++++
12 files changed, 271 insertions(+), 112 deletions(-)
diff --git
a/bundles/aliyun/src/main/java/org/apache/gravitino/oss/credential/OSSTokenGenerator.java
b/bundles/aliyun/src/main/java/org/apache/gravitino/oss/credential/OSSTokenGenerator.java
index 9ad6b8bf84..915fe08649 100644
---
a/bundles/aliyun/src/main/java/org/apache/gravitino/oss/credential/OSSTokenGenerator.java
+++
b/bundles/aliyun/src/main/java/org/apache/gravitino/oss/credential/OSSTokenGenerator.java
@@ -37,6 +37,7 @@ import org.apache.gravitino.credential.CredentialContext;
import org.apache.gravitino.credential.CredentialGenerator;
import org.apache.gravitino.credential.OSSTokenCredential;
import org.apache.gravitino.credential.PathBasedCredentialContext;
+import org.apache.gravitino.credential.RoleSessionNameUtils;
import org.apache.gravitino.credential.config.OSSCredentialConfig;
import org.apache.gravitino.oss.credential.policy.Condition;
import org.apache.gravitino.oss.credential.policy.Effect;
@@ -103,8 +104,7 @@ public class OSSTokenGenerator implements
CredentialGenerator<OSSTokenCredential
return client.getCredential();
}
- private String createPolicy(
- Set<String> readLocations, Set<String> writeLocations, String region) {
+ String createPolicy(Set<String> readLocations, Set<String> writeLocations,
String region) {
Policy.Builder policyBuilder = Policy.builder().version("1");
Statement.Builder allowGetObjectStatementBuilder =
@@ -113,7 +113,7 @@ public class OSSTokenGenerator implements
CredentialGenerator<OSSTokenCredential
.addAction("oss:GetObject")
.addAction("oss:GetObjectVersion");
- Map<String, Statement.Builder> bucketListStatementBuilder = new
HashMap<>();
+ Map<String, StringLike.Builder> bucketListPrefixes = new HashMap<>();
Map<String, Statement.Builder> bucketMetadataStatementBuilder = new
HashMap<>();
String arnPrefix = getArnPrefix(region);
@@ -124,14 +124,9 @@ public class OSSTokenGenerator implements
CredentialGenerator<OSSTokenCredential
URI uri = URI.create(location);
allowGetObjectStatementBuilder.addResource(getOssUriWithArn(arnPrefix, uri));
String bucketArn = arnPrefix + getBucketName(uri);
- bucketListStatementBuilder.computeIfAbsent(
- bucketArn,
- key ->
- Statement.builder()
- .effect(Effect.ALLOW)
- .addAction("oss:ListObjects")
- .addResource(key)
- .condition(getCondition(uri)));
+ bucketListPrefixes
+ .computeIfAbsent(bucketArn, key -> StringLike.builder())
+
.addPrefix(concatPathWithSep(trimLeadingSlash(uri.getPath()), "*", "/"));
bucketMetadataStatementBuilder.computeIfAbsent(
bucketArn,
key ->
@@ -156,10 +151,16 @@ public class OSSTokenGenerator implements
CredentialGenerator<OSSTokenCredential
policyBuilder.addStatement(allowPutObjectStatementBuilder.build());
}
- if (!bucketListStatementBuilder.isEmpty()) {
- bucketListStatementBuilder
- .values()
- .forEach(statementBuilder ->
policyBuilder.addStatement(statementBuilder.build()));
+ if (!bucketListPrefixes.isEmpty()) {
+ bucketListPrefixes.forEach(
+ (bucketArn, prefixes) ->
+ policyBuilder.addStatement(
+ Statement.builder()
+ .effect(Effect.ALLOW)
+ .addAction("oss:ListObjects")
+ .addResource(bucketArn)
+
.condition(Condition.builder().stringLike(prefixes.build()).build())
+ .build()));
} else {
policyBuilder.addStatement(
Statement.builder().effect(Effect.ALLOW).addAction("oss:ListBucket").build());
@@ -176,15 +177,6 @@ public class OSSTokenGenerator implements
CredentialGenerator<OSSTokenCredential
}
}
- private Condition getCondition(URI uri) {
- return Condition.builder()
- .stringLike(
- StringLike.builder()
- .addPrefix(concatPathWithSep(trimLeadingSlash(uri.getPath()),
"*", "/"))
- .build())
- .build();
- }
-
private String getArnPrefix(String region) {
if (StringUtils.isNotEmpty(region)) {
return "acs:oss:" + region + ":*:";
@@ -222,7 +214,7 @@ public class OSSTokenGenerator implements
CredentialGenerator<OSSTokenCredential
}
private String getRoleName(String userName) {
- return "gravitino_" + userName;
+ return RoleSessionNameUtils.create("gravitino_", userName);
}
@Override
diff --git
a/bundles/aliyun/src/test/java/org/apache/gravitino/oss/credential/TestOSSTokenGenerator.java
b/bundles/aliyun/src/test/java/org/apache/gravitino/oss/credential/TestOSSTokenGenerator.java
new file mode 100644
index 0000000000..92678906d0
--- /dev/null
+++
b/bundles/aliyun/src/test/java/org/apache/gravitino/oss/credential/TestOSSTokenGenerator.java
@@ -0,0 +1,45 @@
+/*
+ * 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.gravitino.oss.credential;
+
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.HashSet;
+import org.junit.jupiter.api.Test;
+
+class TestOSSTokenGenerator {
+ @Test
+ void listPolicyIncludesEveryPathInTheSameBucket() throws Exception {
+ OSSTokenGenerator generator = new OSSTokenGenerator();
+ String policy =
+ generator.createPolicy(
+ Collections.singleton("oss://lake/warehouse/db/orders"),
+ new HashSet<>(Arrays.asList("oss://lake/data/orders",
"oss://lake/metadata/orders")),
+ "cn-hangzhou");
+ JsonNode prefixes = new
ObjectMapper().readTree(policy).findValue("oss:Prefix");
+ assertTrue(prefixes.toString().contains("warehouse/db/orders/*"));
+ assertTrue(prefixes.toString().contains("data/orders/*"));
+ assertTrue(prefixes.toString().contains("metadata/orders/*"));
+ }
+}
diff --git
a/bundles/aws/src/main/java/org/apache/gravitino/s3/credential/AwsIrsaCredentialGenerator.java
b/bundles/aws/src/main/java/org/apache/gravitino/s3/credential/AwsIrsaCredentialGenerator.java
index 2026afbb56..cc5f33bf64 100644
---
a/bundles/aws/src/main/java/org/apache/gravitino/s3/credential/AwsIrsaCredentialGenerator.java
+++
b/bundles/aws/src/main/java/org/apache/gravitino/s3/credential/AwsIrsaCredentialGenerator.java
@@ -27,7 +27,6 @@ import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
-import java.util.concurrent.ConcurrentHashMap;
import java.util.function.Supplier;
import java.util.stream.Stream;
import org.apache.commons.lang3.StringUtils;
@@ -35,11 +34,10 @@ import org.apache.gravitino.credential.AwsIrsaCredential;
import org.apache.gravitino.credential.CredentialContext;
import org.apache.gravitino.credential.CredentialGenerator;
import org.apache.gravitino.credential.PathBasedCredentialContext;
+import org.apache.gravitino.credential.RoleSessionNameUtils;
import org.apache.gravitino.credential.config.S3CredentialConfig;
import org.apache.gravitino.s3.credential.webidentity.WebIdentityTokenSource;
import org.apache.gravitino.s3.credential.webidentity.WebIdentityTokenSources;
-import software.amazon.awssdk.auth.credentials.AwsCredentials;
-import software.amazon.awssdk.auth.credentials.AwsSessionCredentials;
import software.amazon.awssdk.policybuilder.iam.IamConditionOperator;
import software.amazon.awssdk.policybuilder.iam.IamEffect;
import software.amazon.awssdk.policybuilder.iam.IamPolicy;
@@ -48,7 +46,6 @@ import software.amazon.awssdk.policybuilder.iam.IamStatement;
import software.amazon.awssdk.regions.Region;
import software.amazon.awssdk.services.sts.StsClient;
import software.amazon.awssdk.services.sts.StsClientBuilder;
-import
software.amazon.awssdk.services.sts.auth.StsAssumeRoleWithWebIdentityCredentialsProvider;
import
software.amazon.awssdk.services.sts.model.AssumeRoleWithWebIdentityRequest;
import
software.amazon.awssdk.services.sts.model.AssumeRoleWithWebIdentityResponse;
import software.amazon.awssdk.services.sts.model.Credentials;
@@ -63,8 +60,6 @@ public class AwsIrsaCredentialGenerator implements
CredentialGenerator<AwsIrsaCr
private String region;
private String stsEndpoint;
private boolean listLocationPrefix;
- private StsClient basicModeStsClient;
- private Map<String, StsAssumeRoleWithWebIdentityCredentialsProvider>
basicModeProviders;
/** Creates an AWS IRSA credential generator. */
public AwsIrsaCredentialGenerator() {
@@ -88,14 +83,12 @@ public class AwsIrsaCredentialGenerator implements
CredentialGenerator<AwsIrsaCr
this.region = s3CredentialConfig.region();
this.stsEndpoint = s3CredentialConfig.stsEndpoint();
this.listLocationPrefix = s3CredentialConfig.listLocationPrefix();
- this.basicModeStsClient = createStsClient();
- this.basicModeProviders = new ConcurrentHashMap<>();
}
@Override
public AwsIrsaCredential generate(CredentialContext context) {
if (!(context instanceof PathBasedCredentialContext)) {
- return
toAwsIrsaCredential(createBasicModeCredentials(context.getUserName()));
+ return null;
}
PathBasedCredentialContext pathBasedCredentialContext =
(PathBasedCredentialContext) context;
@@ -122,26 +115,6 @@ public class AwsIrsaCredentialGenerator implements
CredentialGenerator<AwsIrsaCr
return assumeRoleWithWebIdentity(effectiveRoleArn, userName, tokenContent,
sessionPolicy);
}
- private AwsCredentials createBasicModeCredentials(String userName) {
- return basicModeProviders
- .computeIfAbsent(userName, this::createBasicModeCredentialsProvider)
- .resolveCredentials();
- }
-
- private StsAssumeRoleWithWebIdentityCredentialsProvider
createBasicModeCredentialsProvider(
- String userName) {
- return StsAssumeRoleWithWebIdentityCredentialsProvider.builder()
- .stsClient(basicModeStsClient)
- .refreshRequest(
- () ->
- createAssumeRoleWithWebIdentityRequest(
- getValidatedRoleArn(roleArn),
- userName,
- tokenSource.getToken(),
- Optional.empty()))
- .build();
- }
-
private AwsIrsaCredential toAwsIrsaCredential(Credentials credentials) {
return new AwsIrsaCredential(
credentials.accessKeyId(),
@@ -150,26 +123,6 @@ public class AwsIrsaCredentialGenerator implements
CredentialGenerator<AwsIrsaCr
credentials.expiration().toEpochMilli());
}
- private AwsIrsaCredential toAwsIrsaCredential(AwsCredentials credentials) {
- if (!(credentials instanceof AwsSessionCredentials)) {
- throw new IllegalStateException(
- "AWS IRSA credentials must be of type AwsSessionCredentials. "
- + "Check your EKS/IRSA configuration. Got: "
- + credentials.getClass().getName());
- }
- AwsSessionCredentials sessionCredentials = (AwsSessionCredentials)
credentials;
- if (!sessionCredentials.expirationTime().isPresent()) {
- throw new IllegalStateException(
- "AWS IRSA session credentials must include an expiration time for
vended credential"
- + " refresh");
- }
- return new AwsIrsaCredential(
- sessionCredentials.accessKeyId(),
- sessionCredentials.secretAccessKey(),
- sessionCredentials.sessionToken(),
- sessionCredentials.expirationTime().get().toEpochMilli());
- }
-
private IamPolicy createSessionPolicy(
Set<String> readLocations, Set<String> writeLocations, String region) {
IamPolicy.Builder policyBuilder = IamPolicy.builder();
@@ -389,7 +342,7 @@ public class AwsIrsaCredentialGenerator implements
CredentialGenerator<AwsIrsaCr
AssumeRoleWithWebIdentityRequest.Builder requestBuilder =
AssumeRoleWithWebIdentityRequest.builder()
.roleArn(roleArn)
- .roleSessionName("gravitino_irsa_session_" + userName)
+
.roleSessionName(RoleSessionNameUtils.create("gravitino_irsa_session_",
userName))
.durationSeconds(tokenExpireSecs)
.webIdentityToken(webIdentityToken);
sessionPolicy.ifPresent(policy -> requestBuilder.policy(policy.toJson()));
@@ -398,13 +351,6 @@ public class AwsIrsaCredentialGenerator implements
CredentialGenerator<AwsIrsaCr
@Override
public void close() throws IOException {
- if (basicModeProviders != null) {
-
basicModeProviders.values().forEach(StsAssumeRoleWithWebIdentityCredentialsProvider::close);
- basicModeProviders.clear();
- }
- if (basicModeStsClient != null) {
- basicModeStsClient.close();
- }
if (tokenSource != null) {
tokenSource.close();
}
diff --git
a/bundles/aws/src/main/java/org/apache/gravitino/s3/credential/S3TokenGenerator.java
b/bundles/aws/src/main/java/org/apache/gravitino/s3/credential/S3TokenGenerator.java
index 7cb3bc14e8..d234915e2c 100644
---
a/bundles/aws/src/main/java/org/apache/gravitino/s3/credential/S3TokenGenerator.java
+++
b/bundles/aws/src/main/java/org/apache/gravitino/s3/credential/S3TokenGenerator.java
@@ -32,6 +32,7 @@ import org.apache.commons.lang3.StringUtils;
import org.apache.gravitino.credential.CredentialContext;
import org.apache.gravitino.credential.CredentialGenerator;
import org.apache.gravitino.credential.PathBasedCredentialContext;
+import org.apache.gravitino.credential.RoleSessionNameUtils;
import org.apache.gravitino.credential.S3TokenCredential;
import org.apache.gravitino.credential.config.S3CredentialConfig;
import software.amazon.awssdk.auth.credentials.AwsBasicCredentials;
@@ -109,7 +110,7 @@ public class S3TokenGenerator implements
CredentialGenerator<S3TokenCredential>
AssumeRoleRequest.Builder builder =
AssumeRoleRequest.builder()
.roleArn(roleArn)
- .roleSessionName("gravitino_" + userName)
+ .roleSessionName(RoleSessionNameUtils.create("gravitino_",
userName))
.durationSeconds(tokenExpireSecs)
.policy(policy.toJson());
diff --git
a/bundles/aws/src/test/java/org/apache/gravitino/s3/credential/TestAwsIrsaCredentialGenerator.java
b/bundles/aws/src/test/java/org/apache/gravitino/s3/credential/TestAwsIrsaCredentialGenerator.java
index ca261f02d8..08f5369310 100644
---
a/bundles/aws/src/test/java/org/apache/gravitino/s3/credential/TestAwsIrsaCredentialGenerator.java
+++
b/bundles/aws/src/test/java/org/apache/gravitino/s3/credential/TestAwsIrsaCredentialGenerator.java
@@ -19,7 +19,7 @@
package org.apache.gravitino.s3.credential;
import static org.junit.jupiter.api.Assertions.assertEquals;
-import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
@@ -66,26 +66,16 @@ class TestAwsIrsaCredentialGenerator {
}
@Test
- void basicModeUsesConfiguredTokenSourceWithoutSessionPolicy(@TempDir Path
dir)
- throws IOException {
+ void catalogRequestDoesNotVendUnscopedCredentials(@TempDir Path dir) throws
IOException {
try (AwsIrsaCredentialGenerator generator = new
AwsIrsaCredentialGenerator()) {
generator.initialize(createProperties(dir));
-
- AwsIrsaCredential credential = generator.generate(() -> "test-user");
-
- assertEquals("access-key", credential.accessKeyId());
- assertEquals("secret-key", credential.secretAccessKey());
- assertEquals("session-token", credential.sessionToken());
+ assertNull(generator.generate(() -> "test-user"));
}
-
- String body = URLDecoder.decode(requestBody.get(),
StandardCharsets.UTF_8.name());
- assertTrue(body.contains("WebIdentityToken=configured-token"));
-
assertTrue(body.contains("RoleSessionName=gravitino_irsa_session_test-user"));
- assertFalse(body.contains("Policy="));
+ assertEquals(0, requestCount.get());
}
@Test
- void
basicModeFallsBackToEnvironmentRoleArnWhenConfiguredRoleIsBlank(@TempDir Path
dir)
+ void pathModeFallsBackToEnvironmentRoleArnWhenConfiguredRoleIsBlank(@TempDir
Path dir)
throws IOException {
Map<String, String> properties = createProperties(dir);
properties.put("s3-role-arn", " ");
@@ -94,7 +84,7 @@ class TestAwsIrsaCredentialGenerator {
new AwsIrsaCredentialGenerator(() ->
"arn:aws:iam::123456789012:role/environment-role")) {
generator.initialize(properties);
- AwsIrsaCredential credential = generator.generate(() -> "test-user");
+ AwsIrsaCredential credential = generator.generate(pathContext());
assertEquals("access-key", credential.accessKeyId());
}
@@ -126,7 +116,7 @@ class TestAwsIrsaCredentialGenerator {
}
@Test
- void basicModeFailsWhenNoRoleArnIsAvailable(@TempDir Path dir) throws
IOException {
+ void pathModeFailsWhenNoRoleArnIsAvailable(@TempDir Path dir) throws
IOException {
Map<String, String> properties = createProperties(dir);
properties.remove("s3-role-arn");
@@ -134,21 +124,23 @@ class TestAwsIrsaCredentialGenerator {
generator.initialize(properties);
IllegalStateException exception =
- assertThrows(IllegalStateException.class, () ->
generator.generate(() -> "test-user"));
+ assertThrows(IllegalStateException.class, () ->
generator.generate(pathContext()));
assertTrue(exception.getMessage().contains("No role ARN available"));
}
}
@Test
- void basicModeReusesCachedStsCredentials(@TempDir Path dir) throws
IOException {
+ void pathRequestUsesSessionPolicy(@TempDir Path dir) throws IOException {
try (AwsIrsaCredentialGenerator generator = new
AwsIrsaCredentialGenerator()) {
generator.initialize(createProperties(dir));
-
- generator.generate(() -> "test-user");
- generator.generate(() -> "test-user");
+ AwsIrsaCredential credential = generator.generate(pathContext());
+ assertEquals("access-key", credential.accessKeyId());
}
-
+ String body = URLDecoder.decode(requestBody.get(),
StandardCharsets.UTF_8.name());
+ assertTrue(body.contains("WebIdentityToken=configured-token"));
+
assertTrue(body.contains("RoleSessionName=gravitino_irsa_session_test-user"));
+ assertTrue(body.contains("Policy="));
assertEquals(1, requestCount.get());
}
@@ -160,7 +152,7 @@ class TestAwsIrsaCredentialGenerator {
try (AwsIrsaCredentialGenerator generator = new
AwsIrsaCredentialGenerator()) {
generator.initialize(properties);
- AwsIrsaCredential credential = generator.generate(() -> "test-user");
+ AwsIrsaCredential credential = generator.generate(pathContext());
assertEquals("access-key", credential.accessKeyId());
assertEquals("secret-key", credential.secretAccessKey());
@@ -200,6 +192,11 @@ class TestAwsIrsaCredentialGenerator {
return properties;
}
+ private PathBasedCredentialContext pathContext() {
+ return new PathBasedCredentialContext(
+ "test-user", Collections.emptySet(),
Collections.singleton("s3://bucket/object"));
+ }
+
private void handleAssumeRoleWithWebIdentity(HttpExchange exchange) throws
IOException {
requestCount.incrementAndGet();
requestBody.set(new String(exchange.getRequestBody().readAllBytes(),
StandardCharsets.UTF_8));
diff --git
a/bundles/azure/src/main/java/org/apache/gravitino/abs/credential/ADLSTokenGenerator.java
b/bundles/azure/src/main/java/org/apache/gravitino/abs/credential/ADLSTokenGenerator.java
index fbc69c343a..73611b31fc 100644
---
a/bundles/azure/src/main/java/org/apache/gravitino/abs/credential/ADLSTokenGenerator.java
+++
b/bundles/azure/src/main/java/org/apache/gravitino/abs/credential/ADLSTokenGenerator.java
@@ -28,6 +28,7 @@ import
com.azure.storage.file.datalake.models.UserDelegationKey;
import com.azure.storage.file.datalake.sas.DataLakeServiceSasSignatureValues;
import com.azure.storage.file.datalake.sas.PathSasPermission;
import java.io.IOException;
+import java.net.URI;
import java.time.OffsetDateTime;
import java.util.ArrayList;
import java.util.LinkedHashMap;
@@ -131,7 +132,7 @@ public class ADLSTokenGenerator implements
CredentialGenerator<ADLSTokenCredenti
}
List<ADLSLocationUtils.ADLSLocationParts> parsedLocations =
-
paths.stream().map(ADLSLocationUtils::parseLocation).collect(Collectors.toList());
+
paths.stream().map(ADLSTokenGenerator::parseSupportedLocation).collect(Collectors.toList());
ADLSLocationUtils.ADLSLocationParts first = parsedLocations.get(0);
boolean hasDifferentScope =
parsedLocations.stream()
@@ -175,6 +176,20 @@ public class ADLSTokenGenerator implements
CredentialGenerator<ADLSTokenCredenti
return rootLocations.get(0);
}
+ private static ADLSLocationUtils.ADLSLocationParts
parseSupportedLocation(String path) {
+ URI uri = URI.create(path);
+ if (!"abfs".equalsIgnoreCase(uri.getScheme()) &&
!"abfss".equalsIgnoreCase(uri.getScheme())) {
+ throw new IllegalArgumentException(
+ "adls-token requires an abfs or abfss location; unsupported
location: " + path);
+ }
+ ADLSLocationUtils.ADLSLocationParts location =
ADLSLocationUtils.parseLocation(path);
+ if (!(location.getAccountName() + "." + ADLSTokenCredential.ADLS_DOMAIN)
+ .equalsIgnoreCase(uri.getHost())) {
+ throw new IllegalArgumentException("Invalid ADLS storage account in
location: " + path);
+ }
+ return location;
+ }
+
private static boolean isDescendantOf(
ADLSLocationUtils.ADLSLocationParts location,
ADLSLocationUtils.ADLSLocationParts possibleParent) {
diff --git
a/bundles/azure/src/test/java/org/apache/gravitino/abs/credential/TestADLSTokenGenerator.java
b/bundles/azure/src/test/java/org/apache/gravitino/abs/credential/TestADLSTokenGenerator.java
index 98bd98c56d..da5ac37514 100644
---
a/bundles/azure/src/test/java/org/apache/gravitino/abs/credential/TestADLSTokenGenerator.java
+++
b/bundles/azure/src/test/java/org/apache/gravitino/abs/credential/TestADLSTokenGenerator.java
@@ -168,6 +168,40 @@ class TestADLSTokenGenerator {
}
}
+ @Test
+ void rejectsBlobHostLocationsBeforeVendingSas() throws Exception {
+ String path =
"wasbs://[email protected]/warehouse/table";
+ try (ADLSTokenGenerator generator = new TestableADLSTokenGenerator()) {
+ generator.initialize(createProperties());
+ PathBasedCredentialContext context =
+ new PathBasedCredentialContext(
+ "test-user", Collections.emptySet(),
Collections.singleton(path));
+
+ IllegalArgumentException exception =
+ assertThrows(IllegalArgumentException.class, () ->
generator.generate(context));
+
+ assertTrue(exception.getMessage().contains("abfs or abfss"));
+ assertTrue(exception.getMessage().contains(path));
+ }
+ }
+
+ @Test
+ void providerPreservesUnsupportedLocationError() throws Exception {
+ String path =
"wasbs://[email protected]/warehouse/table";
+ try (ADLSTokenProvider provider = new ADLSTokenProvider()) {
+ provider.initialize(createProperties());
+ PathBasedCredentialContext context =
+ new PathBasedCredentialContext(
+ "test-user", Collections.emptySet(),
Collections.singleton(path));
+
+ IllegalArgumentException exception =
+ assertThrows(IllegalArgumentException.class, () ->
provider.getCredential(context));
+
+ assertTrue(exception.getMessage().contains("abfs or abfss"));
+ assertTrue(exception.getMessage().contains(path));
+ }
+ }
+
private static Map<String, String> createProperties() {
Map<String, String> properties = new HashMap<>();
properties.put(AzureProperties.GRAVITINO_AZURE_STORAGE_ACCOUNT_NAME,
"account");
diff --git
a/common/src/main/java/org/apache/gravitino/credential/CredentialProviderDelegator.java
b/common/src/main/java/org/apache/gravitino/credential/CredentialProviderDelegator.java
index 50da186baa..99a2ceb36c 100644
---
a/common/src/main/java/org/apache/gravitino/credential/CredentialProviderDelegator.java
+++
b/common/src/main/java/org/apache/gravitino/credential/CredentialProviderDelegator.java
@@ -63,6 +63,8 @@ public abstract class CredentialProviderDelegator<T extends
Credential>
public Credential getCredential(CredentialContext context) {
try {
return generator.generate(context);
+ } catch (IllegalArgumentException e) {
+ throw e;
} catch (Exception e) {
throw new RuntimeException(
"Failed to generate credential using " + getGeneratorClassName(), e);
diff --git
a/common/src/main/java/org/apache/gravitino/credential/RoleSessionNameUtils.java
b/common/src/main/java/org/apache/gravitino/credential/RoleSessionNameUtils.java
new file mode 100644
index 0000000000..3fb5ede5ae
--- /dev/null
+++
b/common/src/main/java/org/apache/gravitino/credential/RoleSessionNameUtils.java
@@ -0,0 +1,51 @@
+/*
+ * 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.gravitino.credential;
+
+import java.nio.charset.StandardCharsets;
+import java.util.UUID;
+
+/** Builds cloud STS role session names from arbitrary user names. */
+public final class RoleSessionNameUtils {
+ private static final int MAX_LENGTH = 64;
+
+ private RoleSessionNameUtils() {}
+
+ /**
+ * Builds an ASCII session name with a stable suffix identifying the
original user.
+ *
+ * @param prefix the provider's session name prefix
+ * @param userName the authenticated user name
+ * @return a session name accepted by AWS and Aliyun STS
+ */
+ public static String create(String prefix, String userName) {
+ String suffix =
+ "_"
+ + UUID.nameUUIDFromBytes(userName.getBytes(StandardCharsets.UTF_8))
+ .toString()
+ .substring(0, 16);
+ String cleaned = userName.replaceAll("[^[email protected]]", "_");
+ int available = MAX_LENGTH - prefix.length() - suffix.length();
+ if (available < 1) {
+ throw new IllegalArgumentException("Role session prefix is too long");
+ }
+ return prefix + cleaned.substring(0, Math.min(cleaned.length(),
available)) + suffix;
+ }
+}
diff --git
a/common/src/test/java/org/apache/gravitino/credential/TestRoleSessionNameUtils.java
b/common/src/test/java/org/apache/gravitino/credential/TestRoleSessionNameUtils.java
new file mode 100644
index 0000000000..1dcf0da0c1
--- /dev/null
+++
b/common/src/test/java/org/apache/gravitino/credential/TestRoleSessionNameUtils.java
@@ -0,0 +1,53 @@
+/*
+ * 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.gravitino.credential;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import org.junit.jupiter.api.Test;
+
+class TestRoleSessionNameUtils {
+ @Test
+ void sessionNamesAreValidAndDistinguishable() {
+ String nonAscii = new String(Character.toChars(0x1F600));
+ String first = RoleSessionNameUtils.create("gravitino_irsa_session_",
"first name/" + nonAscii);
+ String second =
+ RoleSessionNameUtils.create("gravitino_irsa_session_", "first name:" +
nonAscii);
+ assertTrue(first.matches("[[email protected]]{2,64}"));
+ assertNotEquals(first, second);
+ assertEquals(
+ first, RoleSessionNameUtils.create("gravitino_irsa_session_", "first
name/" + nonAscii));
+
+ String longName = "[email protected]";
+ assertTrue(RoleSessionNameUtils.create("gravitino_irsa_session_",
longName).length() <= 64);
+ assertTrue(RoleSessionNameUtils.create("gravitino_", longName).length() <=
64);
+ }
+
+ @Test
+ void replacesCharactersRejectedByAliyun() {
+ String sessionName =
+ RoleSessionNameUtils.create("gravitino_",
"alice+etl=prod,[email protected]");
+
+ assertTrue(sessionName.matches("[[email protected]]{2,64}"));
+
assertTrue(sessionName.startsWith("[email protected]_"));
+ }
+}
diff --git a/docs/security/credential-vending.md
b/docs/security/credential-vending.md
index dcae62aa75..5567634ccd 100644
--- a/docs/security/credential-vending.md
+++ b/docs/security/credential-vending.md
@@ -46,11 +46,10 @@ If a catalog does not set `credential-providers`, Gravitino
infers providers fro
#### S3 IRSA Credential
-A credential using AWS IAM Roles for Service Accounts (IRSA) to access S3 with
temporary credentials, typically used in EKS environments. This provider
supports both basic IRSA credentials and fine-grained path-based access control
with dynamically generated IAM policies.
+A credential using AWS IAM Roles for Service Accounts (IRSA) to access S3 with
temporary credentials, typically used in EKS environments. This provider scopes
path-based requests with dynamically generated IAM session policies.
**Features:**
-- **Basic IRSA mode**: Returns credentials with full permissions of the
associated IAM role (for non-path-based contexts)
-- **Fine-grained mode**: Generates path-specific credentials with minimal
required permissions (for table access with `X-Iceberg-Access-Delegation:
vended-credentials`)
+- **Path-based access**: Generates credentials scoped to requested paths (for
table access with `X-Iceberg-Access-Delegation: vended-credentials`)
- **Automatic policy generation**: Creates custom IAM policies scoped to
specific table paths including data, metadata, and write locations
- **EKS integration**: Leverages existing IRSA setup while providing enhanced
security through path-based restrictions
@@ -63,7 +62,7 @@ A credential using AWS IAM Roles for Service Accounts (IRSA)
to access S3 with t
| `s3-token-expire-in-secs` |
`gravitino.iceberg-rest.s3-token-expire-in-secs` | Token expiration time in
seconds for fine-grained credentials. Cannot exceed role's max session
duration. | 3600 | No |
| `s3-token-service-endpoint` |
`gravitino.iceberg-rest.s3-token-service-endpoint` | Alternative STS endpoint
for fine-grained credential generation. Useful for S3-compatible services.
| (none) | No |
-**Note**: Set `s3-role-arn` or provide `AWS_ROLE_ARN` in the pod environment.
Gravitino selects that role when credentials are requested. For table-path
requests, it adds a session policy covering the requested data, metadata, and
write locations. Catalog-level requests have no path to scope and return
credentials with the selected role's full permissions.
+**Note**: Set `s3-role-arn` or provide `AWS_ROLE_ARN` in the pod environment.
Gravitino selects that role when credentials are requested. For path-based
requests, it adds a session policy covering the requested data, metadata, and
write locations. Catalog-level requests do not vend `aws-irsa` credentials
because they provide no paths for a session policy.
**Prerequisites for fine-grained mode:**
- EKS cluster with IRSA properly configured
diff --git
a/server/src/test/java/org/apache/gravitino/server/web/rest/TestMetadataObjectCredentialOperations.java
b/server/src/test/java/org/apache/gravitino/server/web/rest/TestMetadataObjectCredentialOperations.java
index c1e814f0d3..8e9d06c95d 100644
---
a/server/src/test/java/org/apache/gravitino/server/web/rest/TestMetadataObjectCredentialOperations.java
+++
b/server/src/test/java/org/apache/gravitino/server/web/rest/TestMetadataObjectCredentialOperations.java
@@ -34,6 +34,7 @@ import org.apache.gravitino.credential.Credential;
import org.apache.gravitino.credential.CredentialOperationDispatcher;
import org.apache.gravitino.credential.S3SecretKeyCredential;
import org.apache.gravitino.dto.responses.CredentialResponse;
+import org.apache.gravitino.dto.responses.ErrorResponse;
import org.apache.gravitino.dto.util.DTOConverters;
import org.apache.gravitino.rest.RESTUtils;
import org.glassfish.jersey.internal.inject.AbstractBinder;
@@ -94,6 +95,29 @@ public class TestMetadataObjectCredentialOperations extends
JerseyTest {
MetadataObjects.parse("catalog.schema.fileset",
MetadataObject.Type.FILESET));
}
+ @Test
+ public void testInvalidCredentialLocationResponse() {
+ String path =
"wasbs://[email protected]/warehouse/table";
+ when(credentialOperationDispatcher.getCredentials(any(), any()))
+ .thenThrow(
+ new IllegalArgumentException(
+ "adls-token requires an abfs or abfss location; unsupported
location: " + path));
+
+ Response response =
+ target(basePath(metalake))
+ .path("fileset")
+ .path("catalog.schema.fileset")
+ .path("credentials")
+ .request(MediaType.APPLICATION_JSON_TYPE)
+ .accept("application/vnd.gravitino.v1+json")
+ .get();
+
+ Assertions.assertEquals(Response.Status.BAD_REQUEST.getStatusCode(),
response.getStatus());
+ ErrorResponse error = response.readEntity(ErrorResponse.class);
+ Assertions.assertTrue(error.getMessage().contains("abfs or abfss"));
+ Assertions.assertTrue(error.getMessage().contains(path));
+ }
+
private void testGetCredentialsForObject(MetadataObject metadataObject) {
S3SecretKeyCredential credential = new S3SecretKeyCredential("access-id",
"secret-key");