This is an automated email from the ASF dual-hosted git repository.
yuqi1129 pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/gravitino.git
The following commit(s) were added to refs/heads/main by this push:
new bb482ff0d0 [#12198] feat(credential): support Tencent Cloud COS
credential vending (#12199)
bb482ff0d0 is described below
commit bb482ff0d0ecf8e48fad95456e35f9dbe0ea20db
Author: Gary Wang <[email protected]>
AuthorDate: Mon Aug 31 17:06:10 2026 +0800
[#12198] feat(credential): support Tencent Cloud COS credential vending
(#12199)
### What changes were proposed in this pull request?
Add credential vending support for the Tencent Cloud COS fileset backend
introduced in #11713. This is a follow-up subtask under #6490.
- Add `COSTokenCredential` to the public credential API, alongside the
existing `S3TokenCredential`, `OSSTokenCredential`,
`GCSTokenCredential`, and `ADLSTokenCredential`.
- Add `cos-token` credential provider implementation (`COSTokenProvider`
+ `COSTokenGenerator`) that calls Tencent Cloud STS `AssumeRole` and
returns temporary credentials scoped to the requested table path via a
generated CAM policy.
- Add lightweight CAM policy model classes (`Policy`, `Statement`,
`Effect`, `Condition`, `StringLike`) shared by the token generator and
covered by unit tests.
- Reuse the existing `cos-secret-key` provider so the two providers
share the same `cos-access-key-id` / `cos-secret-access-key`
configuration surface.
- Register both providers via `META-INF/services` so they are picked up
by `CredentialProviderFactory` and by `CredentialFactory` on the client
side.
- Wire `COSCredentialsProvider` (the Hadoop `AWSCredentialsProvider`) to
consume vended `COSTokenCredential`s, in addition to the static
secret-key credential.
- Update `bundles/tencent` and `bundles/tencent-bundle` to include the
new sources and the Tencent STS SDK dependency.
- Update `CredentialConstants`, `COSCredentialConfig`, and
`COSProperties` to expose the new provider name and configuration keys
(`cos-role-arn`, `cos-region`, `cos-app-id`, `cos-external-id`,
`cos-token-expire-in-secs`).
Tests:
- Unit tests for `COSTokenGenerator`, `COSCredentialConfig`,
`COSCredentialProvider` SPI registration, and `TestCredentialFactory`
round-trip.
- Integration test `GravitinoVirtualFileSystemCOSCredentialIT`
exercising the end-to-end vending path against a real COS bucket through
GVFS.
Documentation:
- Add a `## COS` section to `docs/security/credential-vending.md`,
documenting `cos-token` and `cos-secret-key`, their properties, and the
required CAM trust and permission policies.
- Update `docs/fileset-catalog-with-cos.md` with credential-vending
configuration examples.
### Why are the changes needed?
#11713 added COS as a fileset storage backend but only supported the
static access-key path. Without credential vending, every client that
loads a fileset receives long-lived, coarse-grained Tencent Cloud
credentials, which is inconsistent with how Gravitino already handles
S3, OSS, GCS, and ADLS. This PR closes that gap so COS fileset
deployments can hand out temporary, table-scoped STS credentials.
### Does this PR introduce _any_ user-facing change?
Yes. Two new credential providers, `cos-token` and `cos-secret-key`, are
now available for catalogs using the COS storage backend, together with
the associated `cos-role-arn` / `cos-region` / `cos-app-id` /
`cos-external-id` / `cos-token-expire-in-secs` catalog properties.
Existing catalogs continue to work unchanged.
### How was this patch tested?
- New unit tests under `bundles/tencent` and `catalogs/catalog-common`.
- New integration test `GravitinoVirtualFileSystemCOSCredentialIT` run
locally against a Tencent Cloud COS bucket with an assumable CAM role,
covering both read and write paths through GVFS with vended credentials.
Fix: #12198
Related: #6490, #11713, #11748
---
.../gravitino/credential/COSTokenCredential.java | 147 ++++++++++
.../org.apache.gravitino.credential.Credential | 1 +
bundles/tencent-bundle/build.gradle.kts | 12 +-
bundles/tencent/build.gradle.kts | 2 +
.../cos/credential/COSTokenGenerator.java | 311 +++++++++++++++++++++
.../gravitino/cos/credential/COSTokenProvider.java | 47 ++++
.../gravitino/cos/credential/policy/Condition.java | 53 ++++
.../gravitino/cos/credential/policy/Effect.java | 17 +-
.../gravitino/cos/credential/policy/Policy.java | 78 ++++++
.../gravitino/cos/credential/policy/Statement.java | 119 ++++++++
.../cos/credential/policy/StringLike.java | 59 ++++
.../gravitino/cos/fs/COSCredentialsProvider.java | 24 +-
.../gravitino/cos/fs/COSFileSystemProvider.java | 3 +-
.../java/org/apache/gravitino/cos/fs/COSUtils.java | 14 +-
....apache.gravitino.credential.CredentialProvider | 1 +
.../cos/credential/TestCOSCredentialProvider.java | 29 ++
.../credential/TestCOSCredentialProviderSpi.java | 60 ++++
.../cos/credential/TestCOSTokenGenerator.java | 255 +++++++++++++++++
.../cos/fs/TestCOSCredentialsProvider.java | 42 +++
.../cos/fs/TestCOSFileSystemProvider.java | 18 ++
.../gravitino/credential/CredentialConstants.java | 1 +
.../credential/config/COSCredentialConfig.java | 79 +++++-
.../apache/gravitino/storage/COSProperties.java | 10 +
.../credential/config/TestCOSCredentialConfig.java | 179 ++++++++++++
.../filesystem-hadoop3-runtime/build.gradle.kts | 6 +
.../GravitinoVirtualFileSystemCOSCredentialIT.java | 284 +++++++++++++++++++
.../credential/TestCredentialFactory.java | 48 ++++
docs/fileset-catalog-with-cos.md | 58 +++-
docs/security/credential-vending.md | 85 +++++-
gradle/libs.versions.toml | 2 +
30 files changed, 1997 insertions(+), 47 deletions(-)
diff --git
a/api/src/main/java/org/apache/gravitino/credential/COSTokenCredential.java
b/api/src/main/java/org/apache/gravitino/credential/COSTokenCredential.java
new file mode 100644
index 0000000000..fc306ed68f
--- /dev/null
+++ b/api/src/main/java/org/apache/gravitino/credential/COSTokenCredential.java
@@ -0,0 +1,147 @@
+/*
+ * 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 com.google.common.base.Preconditions;
+import com.google.common.collect.ImmutableMap;
+import java.util.Map;
+import org.apache.commons.lang3.StringUtils;
+
+/** Tencent Cloud COS STS token credential. */
+public class COSTokenCredential implements Credential {
+
+ /** COS STS token credential type. */
+ public static final String COS_TOKEN_CREDENTIAL_TYPE = "cos-token";
+ /** The session access key ID (a.k.a. TmpSecretId in Tencent Cloud) used to
access COS data. */
+ public static final String GRAVITINO_COS_SESSION_ACCESS_KEY_ID =
"cos-access-key-id";
+ /**
+ * The session secret access key (a.k.a. TmpSecretKey in Tencent Cloud) used
to access COS data.
+ */
+ public static final String GRAVITINO_COS_SESSION_SECRET_ACCESS_KEY =
"cos-secret-access-key";
+ /** The COS security token (a.k.a. SessionToken in Tencent Cloud). */
+ public static final String GRAVITINO_COS_SESSION_TOKEN =
"cos-security-token";
+
+ private String accessKeyId;
+ private String secretAccessKey;
+ private String securityToken;
+ private long expireTimeInMs;
+
+ /**
+ * Constructs an instance of {@link COSTokenCredential} with session secret
keys and a security
+ * token.
+ *
+ * @param accessKeyId The COS session access key ID.
+ * @param secretAccessKey The COS session secret access key.
+ * @param securityToken The COS security token.
+ * @param expireTimeInMs The COS token expire time in ms.
+ */
+ public COSTokenCredential(
+ String accessKeyId, String secretAccessKey, String securityToken, long
expireTimeInMs) {
+ validate(accessKeyId, secretAccessKey, securityToken, expireTimeInMs);
+ this.accessKeyId = accessKeyId;
+ this.secretAccessKey = secretAccessKey;
+ this.securityToken = securityToken;
+ this.expireTimeInMs = expireTimeInMs;
+ }
+
+ /**
+ * This is the constructor that is used by credential factory to create an
instance of credential
+ * according to the credential information.
+ */
+ public COSTokenCredential() {}
+
+ @Override
+ public String credentialType() {
+ return COS_TOKEN_CREDENTIAL_TYPE;
+ }
+
+ @Override
+ public long expireTimeInMs() {
+ return expireTimeInMs;
+ }
+
+ @Override
+ public Map<String, String> credentialInfo() {
+ return (new ImmutableMap.Builder<String, String>())
+ .put(GRAVITINO_COS_SESSION_ACCESS_KEY_ID, accessKeyId)
+ .put(GRAVITINO_COS_SESSION_SECRET_ACCESS_KEY, secretAccessKey)
+ .put(GRAVITINO_COS_SESSION_TOKEN, securityToken)
+ .build();
+ }
+
+ /**
+ * Initialize the credential with the credential information.
+ *
+ * <p>This method is invoked to deserialize the credential in client side.
+ *
+ * @param credentialInfo The credential information from {@link
#credentialInfo}.
+ * @param expireTimeInMs The expire-time from {@link #expireTimeInMs()}.
+ */
+ @Override
+ public void initialize(Map<String, String> credentialInfo, long
expireTimeInMs) {
+ String accessKeyId =
credentialInfo.get(GRAVITINO_COS_SESSION_ACCESS_KEY_ID);
+ String secretAccessKey =
credentialInfo.get(GRAVITINO_COS_SESSION_SECRET_ACCESS_KEY);
+ String securityToken = credentialInfo.get(GRAVITINO_COS_SESSION_TOKEN);
+ validate(accessKeyId, secretAccessKey, securityToken, expireTimeInMs);
+ this.accessKeyId = accessKeyId;
+ this.secretAccessKey = secretAccessKey;
+ this.securityToken = securityToken;
+ this.expireTimeInMs = expireTimeInMs;
+ }
+
+ /**
+ * Get COS session access key ID.
+ *
+ * @return The COS session access key ID.
+ */
+ public String accessKeyId() {
+ return accessKeyId;
+ }
+
+ /**
+ * Get COS session secret access key.
+ *
+ * @return The COS session secret access key.
+ */
+ public String secretAccessKey() {
+ return secretAccessKey;
+ }
+
+ /**
+ * Get COS security token.
+ *
+ * @return The COS security token.
+ */
+ public String securityToken() {
+ return securityToken;
+ }
+
+ private void validate(
+ String accessKeyId, String secretAccessKey, String securityToken, long
expireTimeInMs) {
+ Preconditions.checkArgument(
+ StringUtils.isNotBlank(accessKeyId), "COS access key Id should not be
empty");
+ Preconditions.checkArgument(
+ StringUtils.isNotBlank(secretAccessKey), "COS secret access key should
not be empty");
+ Preconditions.checkArgument(
+ StringUtils.isNotBlank(securityToken), "COS security token should not
be empty");
+ Preconditions.checkArgument(
+ expireTimeInMs > 0, "The expiration time of COSTokenCredential should
be greater than 0");
+ }
+}
diff --git
a/api/src/main/resources/META-INF/services/org.apache.gravitino.credential.Credential
b/api/src/main/resources/META-INF/services/org.apache.gravitino.credential.Credential
index 3c5672c6cf..5e4f705c5b 100644
---
a/api/src/main/resources/META-INF/services/org.apache.gravitino.credential.Credential
+++
b/api/src/main/resources/META-INF/services/org.apache.gravitino.credential.Credential
@@ -23,6 +23,7 @@ org.apache.gravitino.credential.GCSTokenCredential
org.apache.gravitino.credential.OSSTokenCredential
org.apache.gravitino.credential.OSSSecretKeyCredential
org.apache.gravitino.credential.COSSecretKeyCredential
+org.apache.gravitino.credential.COSTokenCredential
org.apache.gravitino.credential.ADLSTokenCredential
org.apache.gravitino.credential.AzureAccountKeyCredential
org.apache.gravitino.credential.AwsIrsaCredential
diff --git a/bundles/tencent-bundle/build.gradle.kts
b/bundles/tencent-bundle/build.gradle.kts
index 6e421e0a2b..f8c59de939 100644
--- a/bundles/tencent-bundle/build.gradle.kts
+++ b/bundles/tencent-bundle/build.gradle.kts
@@ -33,6 +33,7 @@ dependencies {
implementation(libs.hadoop3.client.runtime)
implementation(libs.hadoop3.cos)
implementation(libs.httpclient)
+ implementation(libs.tencentcloud.sdk.sts)
}
tasks.withType(ShadowJar::class.java) {
@@ -55,13 +56,14 @@ tasks.withType(ShadowJar::class.java) {
exclude(project(":catalogs:hadoop-common"))
}
- // Relocate dependencies to avoid conflicts.
- // hadoop-cos (from com.qcloud.cos:hadoop-cos) bundles the qcloud-cos SDK
and a few common
- // libraries; relocate them under "org.apache.gravitino.tencent.shaded.*"
following the
- // same pattern as the aws/aliyun/azure/gcp bundles.
+ // Relocate transitive utilities to avoid classpath conflicts. Do NOT
relocate `com.qcloud.*`
+ // or `com.tencentcloudapi.*`: `COSCredentialsProvider` extends hadoop-cos's
+ // `AbstractCOSCredentialProvider`, whose `getCredentials()` returns
`com.qcloud.cos.auth.
+ // COSCredentials`; shading would break the override and cause
`AbstractMethodError`.
relocate("com.fasterxml.jackson",
"org.apache.gravitino.tencent.shaded.com.fasterxml.jackson")
relocate("com.google", "org.apache.gravitino.tencent.shaded.com.google")
- relocate("com.qcloud", "org.apache.gravitino.tencent.shaded.com.qcloud")
+ relocate("okhttp3", "org.apache.gravitino.tencent.shaded.okhttp3")
+ relocate("okio", "org.apache.gravitino.tencent.shaded.okio")
relocate("org.apache.commons",
"org.apache.gravitino.tencent.shaded.org.apache.commons")
relocate("org.apache.http",
"org.apache.gravitino.tencent.shaded.org.apache.http")
relocate("org.checkerframework",
"org.apache.gravitino.tencent.shaded.org.checkerframework")
diff --git a/bundles/tencent/build.gradle.kts b/bundles/tencent/build.gradle.kts
index 98aa70196d..734981c91c 100644
--- a/bundles/tencent/build.gradle.kts
+++ b/bundles/tencent/build.gradle.kts
@@ -43,12 +43,14 @@ dependencies {
compileOnly(libs.hadoop3.client.api)
compileOnly(libs.hadoop3.cos)
compileOnly(libs.lombok)
+ compileOnly(libs.tencentcloud.sdk.sts)
testImplementation(libs.hadoop3.client.api)
testImplementation(libs.hadoop3.client.runtime)
testImplementation(libs.hadoop3.cos)
testImplementation(libs.junit.jupiter.api)
testImplementation(libs.junit.jupiter.params)
+ testImplementation(libs.tencentcloud.sdk.sts)
testRuntimeOnly(libs.junit.jupiter.engine)
testRuntimeOnly(libs.bundles.log4j)
}
diff --git
a/bundles/tencent/src/main/java/org/apache/gravitino/cos/credential/COSTokenGenerator.java
b/bundles/tencent/src/main/java/org/apache/gravitino/cos/credential/COSTokenGenerator.java
new file mode 100644
index 0000000000..0912404819
--- /dev/null
+++
b/bundles/tencent/src/main/java/org/apache/gravitino/cos/credential/COSTokenGenerator.java
@@ -0,0 +1,311 @@
+/*
+ * 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.cos.credential;
+
+import com.fasterxml.jackson.core.JsonProcessingException;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.google.common.base.Preconditions;
+import com.tencentcloudapi.common.Credential;
+import com.tencentcloudapi.common.exception.TencentCloudSDKException;
+import com.tencentcloudapi.sts.v20180813.StsClient;
+import com.tencentcloudapi.sts.v20180813.models.AssumeRoleRequest;
+import com.tencentcloudapi.sts.v20180813.models.AssumeRoleResponse;
+import java.io.IOException;
+import java.net.URI;
+import java.util.LinkedHashMap;
+import java.util.Map;
+import java.util.Set;
+import java.util.stream.Stream;
+import org.apache.commons.lang3.StringUtils;
+import org.apache.gravitino.cos.credential.policy.Effect;
+import org.apache.gravitino.cos.credential.policy.Policy;
+import org.apache.gravitino.cos.credential.policy.Statement;
+import org.apache.gravitino.credential.COSTokenCredential;
+import org.apache.gravitino.credential.CredentialContext;
+import org.apache.gravitino.credential.CredentialGenerator;
+import org.apache.gravitino.credential.PathBasedCredentialContext;
+import org.apache.gravitino.credential.config.COSCredentialConfig;
+
+/** Generates Tencent Cloud COS STS tokens scoped to the requested fileset
paths. */
+public class COSTokenGenerator implements
CredentialGenerator<COSTokenCredential> {
+
+ private static final String POLICY_VERSION = "2.0";
+
+ private final ObjectMapper objectMapper = new ObjectMapper();
+
+ private String accessKeyId;
+ private String secretAccessKey;
+ private String roleArn;
+ private String externalId;
+ private String region;
+ private String appId;
+ private int tokenExpireSecs;
+
+ @Override
+ public void initialize(Map<String, String> properties) {
+ COSCredentialConfig config = new COSCredentialConfig(properties);
+ this.accessKeyId = config.accessKeyID();
+ this.secretAccessKey = config.secretAccessKey();
+ this.roleArn = config.cosRoleArn();
+ this.externalId = config.externalID();
+ this.region = config.region();
+ this.appId = config.appID();
+ this.tokenExpireSecs = config.tokenExpireInSecs();
+ }
+
+ @Override
+ public COSTokenCredential generate(CredentialContext context) throws
Exception {
+ if (!(context instanceof PathBasedCredentialContext)) {
+ return null;
+ }
+
+ PathBasedCredentialContext pathContext = (PathBasedCredentialContext)
context;
+
+ AssumeRoleResponse response =
+ callAssumeRole(
+ pathContext.getReadPaths(), pathContext.getWritePaths(),
pathContext.getUserName());
+
+ com.tencentcloudapi.sts.v20180813.models.Credentials credentials =
response.getCredentials();
+ Long expiredTime = response.getExpiredTime();
+ Preconditions.checkState(
+ credentials != null && expiredTime != null,
+ "Tencent STS AssumeRole returned an incomplete response, requestId:
%s",
+ response.getRequestId());
+ // Tencent STS returns ExpiredTime in seconds; the Credential contract
uses ms.
+ long expireTimeInMs = expiredTime * 1000L;
+ return new COSTokenCredential(
+ credentials.getTmpSecretId(),
+ credentials.getTmpSecretKey(),
+ credentials.getToken(),
+ expireTimeInMs);
+ }
+
+ private AssumeRoleResponse callAssumeRole(
+ Set<String> readLocations, Set<String> writeLocations, String userName)
+ throws TencentCloudSDKException {
+ Credential cred = new Credential(accessKeyId, secretAccessKey);
+ StsClient client = new StsClient(cred, region);
+
+ AssumeRoleRequest request = new AssumeRoleRequest();
+ request.setRoleArn(roleArn);
+ request.setRoleSessionName(getRoleSessionName(userName));
+ request.setDurationSeconds((long) tokenExpireSecs);
+ if (StringUtils.isNotBlank(externalId)) {
+ request.setExternalId(externalId);
+ }
+ request.setPolicy(buildPolicy(readLocations, writeLocations));
+
+ return client.AssumeRole(request);
+ }
+
+ private String buildPolicy(Set<String> readLocations, Set<String>
writeLocations) {
+ Preconditions.checkArgument(
+ !readLocations.isEmpty() || !writeLocations.isEmpty(),
+ "COS token generator requires at least one read or write location");
+ Policy.Builder policyBuilder = Policy.builder().version(POLICY_VERSION);
+
+ Statement.Builder readObjectStatement =
+ Statement.builder()
+ .effect(Effect.ALLOW)
+ .addAction("cos:GetObject")
+ .addAction("cos:HeadObject");
+
+ // LinkedHashMap keeps the emitted statements in a deterministic order.
+ Map<String, Statement.Builder> bucketListStatements = new
LinkedHashMap<>();
+ Map<String, Statement.Builder> bucketMetadataStatements = new
LinkedHashMap<>();
+
+ Stream.concat(readLocations.stream(), writeLocations.stream())
+ .distinct()
+ .forEach(
+ location -> {
+ URI uri = URI.create(location);
+ addObjectResources(readObjectStatement, uri);
+ String bucketResource = getBucketResource(uri);
+ String bucketWildcardResource = getBucketWildcardResource(uri);
+ // CAM requires different resource ARN forms per action:
cos:GetBucket needs
+ // bucket/*, whereas cos:HeadBucket / cos:GetBucketLocation need
bucket/.
+ Statement.Builder listStatement =
+ bucketListStatements.computeIfAbsent(
+ bucketWildcardResource,
+ key ->
+ Statement.builder()
+ .effect(Effect.ALLOW)
+ .addAction("cos:GetBucket")
+ .addResource(key));
+ // CredentialOperationDispatcher merges multiple PathContexts of
the same
+ // credential type into one PathBasedCredentialContext, so
cos:prefix must be
+ // accumulated for every URI here — not just the first.
+ addPrefixPatterns(listStatement, uri);
+ // hadoop-cos calls headBucket during FileSystem.initialize();
without
+ // cos:HeadBucket the vended credentials return 403.
+ bucketMetadataStatements.computeIfAbsent(
+ bucketResource,
+ key ->
+ Statement.builder()
+ .effect(Effect.ALLOW)
+ .addAction("cos:GetBucketLocation")
+ .addAction("cos:HeadBucket")
+ .addResource(key));
+ });
+
+ if (!writeLocations.isEmpty()) {
+ Statement.Builder writeObjectStatement =
+ Statement.builder()
+ .effect(Effect.ALLOW)
+ .addAction("cos:PutObject")
+ .addAction("cos:DeleteObject")
+ .addAction("cos:InitiateMultipartUpload")
+ .addAction("cos:UploadPart")
+ .addAction("cos:ListParts")
+ .addAction("cos:CompleteMultipartUpload")
+ .addAction("cos:AbortMultipartUpload");
+ writeLocations.forEach(
+ location -> addObjectResources(writeObjectStatement,
URI.create(location)));
+ policyBuilder.addStatement(writeObjectStatement.build());
+ }
+
+ if (!bucketListStatements.isEmpty()) {
+ bucketListStatements.values().forEach(builder ->
policyBuilder.addStatement(builder.build()));
+ }
+ bucketMetadataStatements
+ .values()
+ .forEach(builder -> policyBuilder.addStatement(builder.build()));
+
+ policyBuilder.addStatement(readObjectStatement.build());
+
+ try {
+ return objectMapper.writeValueAsString(policyBuilder.build());
+ } catch (JsonProcessingException e) {
+ throw new RuntimeException("Failed to serialize COS session policy", e);
+ }
+ }
+
+ /**
+ * Emits the {@code cos:prefix} condition patterns for the given fileset
location. For non-root
+ * paths this is {@code xxx/} and {@code xxx/*}; the trailing slash is
essential — a bare {@code
+ * xxx*} would also match sibling prefixes like {@code xxx_backup/}. For
bucket-root filesets
+ * ({@code cosn://bucket/} or {@code cosn://bucket}) the path is empty and a
bare {@code *} is
+ * emitted, since COS object keys have no leading slash.
+ */
+ private void addPrefixPatterns(Statement.Builder statement, URI uri) {
+ String prefix = trimLeadingSlash(uri.getPath());
+ if (prefix.isEmpty()) {
+ statement.addStringLikePrefix("*");
+ return;
+ }
+ if (!prefix.endsWith("/")) {
+ prefix = prefix + "/";
+ }
+ statement.addStringLikePrefix(prefix);
+ statement.addStringLikePrefix(prefix + "*");
+ }
+
+ /** Bucket ARN with trailing slash, used for cos:HeadBucket /
cos:GetBucketLocation. */
+ private String getBucketResource(URI uri) {
+ return getResourcePrefix() + getBucketWithAppId(uri) + "/";
+ }
+
+ /** Bucket ARN with trailing wildcard, required by CAM for cos:GetBucket. */
+ private String getBucketWildcardResource(URI uri) {
+ return getResourcePrefix() + getBucketWithAppId(uri) + "/*";
+ }
+
+ /**
+ * Emits both {@code bucket/prefix} (matches the fileset key itself, e.g. a
HEAD on the root) and
+ * {@code bucket/prefix/*} (matches everything under it). CAM treats them as
distinct resources.
+ */
+ private void addObjectResources(Statement.Builder statement, URI uri) {
+ String path = trimLeadingSlash(uri.getPath());
+ String bucketPrefix = getResourcePrefix() + getBucketWithAppId(uri) + "/";
+ String fullPrefix = bucketPrefix + path;
+ String prefixArn =
+ fullPrefix.endsWith("/") ? fullPrefix.substring(0, fullPrefix.length()
- 1) : fullPrefix;
+ statement.addResource(prefixArn);
+ statement.addResource(appendWildcard(fullPrefix));
+ }
+
+ private String getResourcePrefix() {
+ return "qcs::cos:" + region + ":uid/" + appId + ":";
+ }
+
+ /** Bucket ARNs require the {@code -<APPID>} suffix; append it unless
already present. */
+ private String getBucketWithAppId(URI uri) {
+ String bucket = uri.getHost();
+ if (bucket == null) {
+ throw new IllegalArgumentException("COS location is missing bucket: " +
uri);
+ }
+ String suffix = "-" + appId;
+ if (bucket.endsWith(suffix)) {
+ return bucket;
+ }
+ return bucket + suffix;
+ }
+
+ private String trimLeadingSlash(String path) {
+ if (path == null) {
+ return "";
+ }
+ return path.startsWith("/") ? path.substring(1) : path;
+ }
+
+ /** Appends {@code /*} to a path, avoiding double slashes. */
+ private static String appendWildcard(String leftPath) {
+ return leftPath.endsWith("/") ? leftPath + "*" : leftPath + "/*";
+ }
+
+ private String getRoleSessionName(String userName) {
+ String safe = userName == null ? "anonymous" :
userName.replaceAll("[^A-Za-z0-9_=.@\\-]", "_");
+ // Tencent Cloud caps the role session name at 64 characters.
+ String name = "gravitino_" + safe;
+ return name.length() > 64 ? name.substring(0, 64) : name;
+ }
+
+ // Visible for tests.
+ String buildPolicyForTest(Set<String> readLocations, Set<String>
writeLocations) {
+ return buildPolicy(readLocations, writeLocations);
+ }
+
+ // Visible for tests.
+ void initializeForTest(
+ String accessKeyId,
+ String secretAccessKey,
+ String roleArn,
+ String externalId,
+ String region,
+ String appId,
+ int tokenExpireSecs) {
+ Preconditions.checkArgument(
+ StringUtils.isNotBlank(region),
+ "COS token generator requires a non-blank region; got '%s'",
+ region);
+ this.accessKeyId = accessKeyId;
+ this.secretAccessKey = secretAccessKey;
+ this.roleArn = roleArn;
+ this.externalId = externalId;
+ this.region = region;
+ this.appId = appId;
+ this.tokenExpireSecs = tokenExpireSecs;
+ }
+
+ @Override
+ public void close() throws IOException {
+ // StsClient has no resources to release.
+ }
+}
diff --git
a/bundles/tencent/src/main/java/org/apache/gravitino/cos/credential/COSTokenProvider.java
b/bundles/tencent/src/main/java/org/apache/gravitino/cos/credential/COSTokenProvider.java
new file mode 100644
index 0000000000..531f96a38f
--- /dev/null
+++
b/bundles/tencent/src/main/java/org/apache/gravitino/cos/credential/COSTokenProvider.java
@@ -0,0 +1,47 @@
+/*
+ * 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.cos.credential;
+
+import org.apache.gravitino.credential.COSTokenCredential;
+import org.apache.gravitino.credential.CredentialProviderDelegator;
+
+/**
+ * A lightweight credential provider for Tencent Cloud COS. It delegates the
actual credential
+ * generation to {@link COSTokenGenerator}, which is loaded via reflection so
that bundles without
+ * the Tencent Cloud STS SDK on the classpath do not fail at class loading
time.
+ */
+public class COSTokenProvider extends
CredentialProviderDelegator<COSTokenCredential> {
+
+ @Override
+ public boolean supportsScheme(String scheme) {
+ // hadoop-cos exposes the `cosn://` scheme, matching
`COSFileSystemProvider#scheme()`.
+ return "cosn".equalsIgnoreCase(scheme);
+ }
+
+ @Override
+ public String credentialType() {
+ return COSTokenCredential.COS_TOKEN_CREDENTIAL_TYPE;
+ }
+
+ @Override
+ public String getGeneratorClassName() {
+ return "org.apache.gravitino.cos.credential.COSTokenGenerator";
+ }
+}
diff --git
a/bundles/tencent/src/main/java/org/apache/gravitino/cos/credential/policy/Condition.java
b/bundles/tencent/src/main/java/org/apache/gravitino/cos/credential/policy/Condition.java
new file mode 100644
index 0000000000..abc2d76b8d
--- /dev/null
+++
b/bundles/tencent/src/main/java/org/apache/gravitino/cos/credential/policy/Condition.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.cos.credential.policy;
+
+import com.fasterxml.jackson.annotation.JsonInclude;
+import com.fasterxml.jackson.annotation.JsonProperty;
+
+/** Tencent Cloud CAM policy condition operator wrapper. Currently only {@code
string_like}. */
+@JsonInclude(JsonInclude.Include.NON_NULL)
+public class Condition {
+
+ @SuppressWarnings("UnusedVariable") // Read reflectively by Jackson via
@JsonProperty.
+ @JsonProperty("string_like")
+ private StringLike stringLike;
+
+ private Condition(Builder builder) {
+ this.stringLike = builder.stringLike;
+ }
+
+ public static Builder builder() {
+ return new Builder();
+ }
+
+ public static class Builder {
+ private StringLike stringLike;
+
+ public Builder stringLike(StringLike stringLike) {
+ this.stringLike = stringLike;
+ return this;
+ }
+
+ public Condition build() {
+ return new Condition(this);
+ }
+ }
+}
diff --git
a/catalogs/catalog-common/src/main/java/org/apache/gravitino/storage/COSProperties.java
b/bundles/tencent/src/main/java/org/apache/gravitino/cos/credential/policy/Effect.java
similarity index 52%
copy from
catalogs/catalog-common/src/main/java/org/apache/gravitino/storage/COSProperties.java
copy to
bundles/tencent/src/main/java/org/apache/gravitino/cos/credential/policy/Effect.java
index 7326ebc335..6a3f6c2a78 100644
---
a/catalogs/catalog-common/src/main/java/org/apache/gravitino/storage/COSProperties.java
+++
b/bundles/tencent/src/main/java/org/apache/gravitino/cos/credential/policy/Effect.java
@@ -16,19 +16,12 @@
* specific language governing permissions and limitations
* under the License.
*/
-package org.apache.gravitino.storage;
-// Properties for Tencent Cloud COS.
-public class COSProperties {
+package org.apache.gravitino.cos.credential.policy;
- // The region of Tencent Cloud COS, e.g. "ap-guangzhou".
- public static final String GRAVITINO_COS_REGION = "cos-region";
- // The endpoint of Tencent Cloud COS (optional, normally derived from
region).
- public static final String GRAVITINO_COS_ENDPOINT = "cos-endpoint";
- // The static access key ID (Tencent Cloud SecretId) used to access COS data.
- public static final String GRAVITINO_COS_ACCESS_KEY_ID = "cos-access-key-id";
- // The static secret access key (Tencent Cloud SecretKey) used to access COS
data.
- public static final String GRAVITINO_COS_ACCESS_KEY_SECRET =
"cos-secret-access-key";
+/** CAM policy statement {@code effect} constants. Only {@code allow} is used
today. */
+public class Effect {
+ public static final String ALLOW = "allow";
- private COSProperties() {}
+ private Effect() {}
}
diff --git
a/bundles/tencent/src/main/java/org/apache/gravitino/cos/credential/policy/Policy.java
b/bundles/tencent/src/main/java/org/apache/gravitino/cos/credential/policy/Policy.java
new file mode 100644
index 0000000000..0ccc01b691
--- /dev/null
+++
b/bundles/tencent/src/main/java/org/apache/gravitino/cos/credential/policy/Policy.java
@@ -0,0 +1,78 @@
+/*
+ * 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.cos.credential.policy;
+
+import com.fasterxml.jackson.annotation.JsonInclude;
+import com.fasterxml.jackson.annotation.JsonProperty;
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * Tencent Cloud CAM policy document used as the {@code Policy} parameter of
{@code sts:AssumeRole}.
+ * The serialized JSON follows the format described in the official Tencent
Cloud CAM documentation
+ * (e.g. {@code {"version":"2.0","statement":[...]}}).
+ */
+@JsonInclude(JsonInclude.Include.NON_NULL)
+public class Policy {
+
+ @JsonProperty("version")
+ private String version;
+
+ @JsonProperty("statement")
+ private List<Statement> statements;
+
+ private Policy(Builder builder) {
+ this.version = builder.version;
+ this.statements = builder.statements;
+ }
+
+ public static Builder builder() {
+ return new Builder();
+ }
+
+ public static class Builder {
+ private String version;
+ private final List<Statement> statements = new ArrayList<>();
+
+ public Builder version(String version) {
+ this.version = version;
+ return this;
+ }
+
+ public Builder addStatement(Statement statement) {
+ this.statements.add(statement);
+ return this;
+ }
+
+ public Policy build() {
+ return new Policy(this);
+ }
+ }
+
+ @SuppressWarnings("unused")
+ public String getVersion() {
+ return version;
+ }
+
+ @SuppressWarnings("unused")
+ public List<Statement> getStatements() {
+ return statements;
+ }
+}
diff --git
a/bundles/tencent/src/main/java/org/apache/gravitino/cos/credential/policy/Statement.java
b/bundles/tencent/src/main/java/org/apache/gravitino/cos/credential/policy/Statement.java
new file mode 100644
index 0000000000..242010e6ce
--- /dev/null
+++
b/bundles/tencent/src/main/java/org/apache/gravitino/cos/credential/policy/Statement.java
@@ -0,0 +1,119 @@
+/*
+ * 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.cos.credential.policy;
+
+import com.fasterxml.jackson.annotation.JsonInclude;
+import com.fasterxml.jackson.annotation.JsonProperty;
+import java.util.ArrayList;
+import java.util.List;
+
+/** A Tencent Cloud CAM policy statement. */
+@JsonInclude(JsonInclude.Include.NON_NULL)
+public class Statement {
+
+ @JsonProperty("effect")
+ private String effect;
+
+ @JsonProperty("action")
+ private List<String> actions;
+
+ @JsonProperty("resource")
+ private List<String> resources;
+
+ @JsonProperty("condition")
+ private Condition condition;
+
+ private Statement(Builder builder) {
+ this.effect = builder.effect;
+ this.actions = builder.actions;
+ this.resources = builder.resources;
+ this.condition = builder.condition;
+ }
+
+ public static Builder builder() {
+ return new Builder();
+ }
+
+ public static class Builder {
+ private String effect;
+ private final List<String> actions = new ArrayList<>();
+ private final List<String> resources = new ArrayList<>();
+ private Condition condition;
+ // Lazily accumulates cos:prefix values across calls to
addStringLikePrefix.
+ private StringLike.Builder stringLikeBuilder;
+
+ public Builder effect(String effect) {
+ this.effect = effect;
+ return this;
+ }
+
+ public Builder addAction(String action) {
+ this.actions.add(action);
+ return this;
+ }
+
+ public Builder addResource(String resource) {
+ this.resources.add(resource);
+ return this;
+ }
+
+ public Builder condition(Condition condition) {
+ this.condition = condition;
+ return this;
+ }
+
+ /** Appends a cos:prefix pattern to the statement's string_like condition.
*/
+ public Builder addStringLikePrefix(String prefix) {
+ if (stringLikeBuilder == null) {
+ stringLikeBuilder = StringLike.builder();
+ }
+ stringLikeBuilder.addPrefix(prefix);
+ return this;
+ }
+
+ public Statement build() {
+ // Explicit condition() wins; only auto-assemble from accumulated
prefixes if unset.
+ if (condition == null && stringLikeBuilder != null) {
+ condition =
Condition.builder().stringLike(stringLikeBuilder.build()).build();
+ }
+ return new Statement(this);
+ }
+ }
+
+ @SuppressWarnings("unused")
+ public String getEffect() {
+ return effect;
+ }
+
+ @SuppressWarnings("unused")
+ public List<String> getActions() {
+ return actions;
+ }
+
+ @SuppressWarnings("unused")
+ public List<String> getResources() {
+ return resources;
+ }
+
+ @SuppressWarnings("unused")
+ public Condition getCondition() {
+ return condition;
+ }
+}
diff --git
a/bundles/tencent/src/main/java/org/apache/gravitino/cos/credential/policy/StringLike.java
b/bundles/tencent/src/main/java/org/apache/gravitino/cos/credential/policy/StringLike.java
new file mode 100644
index 0000000000..7e3febbe27
--- /dev/null
+++
b/bundles/tencent/src/main/java/org/apache/gravitino/cos/credential/policy/StringLike.java
@@ -0,0 +1,59 @@
+/*
+ * 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.cos.credential.policy;
+
+import com.fasterxml.jackson.annotation.JsonInclude;
+import com.fasterxml.jackson.annotation.JsonProperty;
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * CAM {@code string_like} condition value. {@code cos:prefix} is the literal
condition key defined
+ * by Tencent Cloud CAM (analogous to AWS IAM {@code s3:prefix}); it must be
serialized verbatim
+ * into the policy JSON.
+ */
+@JsonInclude(JsonInclude.Include.NON_NULL)
+public class StringLike {
+
+ @SuppressWarnings("UnusedVariable") // Read reflectively by Jackson via
@JsonProperty.
+ @JsonProperty("cos:prefix")
+ private List<String> prefix;
+
+ private StringLike(Builder builder) {
+ this.prefix = builder.prefix;
+ }
+
+ public static Builder builder() {
+ return new Builder();
+ }
+
+ public static class Builder {
+ private final List<String> prefix = new ArrayList<>();
+
+ public Builder addPrefix(String prefix) {
+ this.prefix.add(prefix);
+ return this;
+ }
+
+ public StringLike build() {
+ return new StringLike(this);
+ }
+ }
+}
diff --git
a/bundles/tencent/src/main/java/org/apache/gravitino/cos/fs/COSCredentialsProvider.java
b/bundles/tencent/src/main/java/org/apache/gravitino/cos/fs/COSCredentialsProvider.java
index 038271ffcf..0c72de44c4 100644
---
a/bundles/tencent/src/main/java/org/apache/gravitino/cos/fs/COSCredentialsProvider.java
+++
b/bundles/tencent/src/main/java/org/apache/gravitino/cos/fs/COSCredentialsProvider.java
@@ -20,19 +20,21 @@
package org.apache.gravitino.cos.fs;
import com.qcloud.cos.auth.BasicCOSCredentials;
+import com.qcloud.cos.auth.BasicSessionCredentials;
import com.qcloud.cos.auth.COSCredentials;
import java.net.URI;
import org.apache.gravitino.catalog.hadoop.fs.FileSystemUtils;
import
org.apache.gravitino.catalog.hadoop.fs.GravitinoFileSystemCredentialsProvider;
import org.apache.gravitino.credential.COSSecretKeyCredential;
+import org.apache.gravitino.credential.COSTokenCredential;
import org.apache.gravitino.credential.Credential;
import org.apache.hadoop.conf.Configuration;
import org.apache.hadoop.fs.auth.AbstractCOSCredentialProvider;
/**
* Hadoop-COS credential provider that pulls vended credentials out of
Gravitino and feeds them to
- * the underlying {@code com.qcloud.cos.auth.COSCredentialsProvider} contract.
PR-A only handles
- * static secret-key credentials; STS / token credentials will be added by a
follow-up PR.
+ * the underlying {@code com.qcloud.cos.auth.COSCredentialsProvider} contract.
Handles both static
+ * secret-key credentials and dynamic STS session credentials.
*/
public class COSCredentialsProvider extends AbstractCOSCredentialProvider {
@@ -71,11 +73,21 @@ public class COSCredentialsProvider extends
AbstractCOSCredentialProvider {
this.basicCredentials =
new BasicCOSCredentials(
cosSecretKeyCredential.accessKeyId(),
cosSecretKeyCredential.secretAccessKey());
+ } else if (credential instanceof COSTokenCredential) {
+ COSTokenCredential cosTokenCredential = (COSTokenCredential) credential;
+ // hadoop-cos's BasicSessionCredentials models a temporary STS triple
(TmpSecretId,
+ // TmpSecretKey, SessionToken). The companion
BasicSessionCredentials(appId, ak, sk,
+ // token) constructor is only required when callers explicitly pin an
AppId; here the
+ // AppId is already encoded in the bucket name, so the 3-arg form is
sufficient.
+ this.basicCredentials =
+ new BasicSessionCredentials(
+ cosTokenCredential.accessKeyId(),
+ cosTokenCredential.secretAccessKey(),
+ cosTokenCredential.securityToken());
} else {
- // Defensive: COSUtils#getSuitableCredential currently only returns
- // COSSecretKeyCredential, but a follow-up PR will add STS / token
support. Failing fast
- // here avoids silently leaving {@link #basicCredentials} stale (or
null) if a new
- // credential type is wired into the selector without updating this
branch.
+ // Defensive: any new credential type wired into
COSUtils#getSuitableCredential without
+ // a matching branch here would otherwise silently leave {@link
#basicCredentials} stale
+ // (or null). Failing fast surfaces the wiring bug at refresh time.
throw new RuntimeException(
"Unsupported credential type for COS: " +
credential.getClass().getName());
}
diff --git
a/bundles/tencent/src/main/java/org/apache/gravitino/cos/fs/COSFileSystemProvider.java
b/bundles/tencent/src/main/java/org/apache/gravitino/cos/fs/COSFileSystemProvider.java
index cdc430f27b..916d9068cc 100644
---
a/bundles/tencent/src/main/java/org/apache/gravitino/cos/fs/COSFileSystemProvider.java
+++
b/bundles/tencent/src/main/java/org/apache/gravitino/cos/fs/COSFileSystemProvider.java
@@ -30,6 +30,7 @@ import
org.apache.gravitino.catalog.hadoop.fs.FileSystemProvider;
import org.apache.gravitino.catalog.hadoop.fs.FileSystemUtils;
import org.apache.gravitino.catalog.hadoop.fs.SupportsCredentialVending;
import org.apache.gravitino.credential.COSSecretKeyCredential;
+import org.apache.gravitino.credential.COSTokenCredential;
import org.apache.gravitino.credential.Credential;
import org.apache.gravitino.storage.COSProperties;
import org.apache.hadoop.conf.Configuration;
@@ -84,7 +85,7 @@ public class COSFileSystemProvider implements
FileSystemProvider, SupportsCreden
public Map<String, String> getFileSystemCredentialConf(Credential[]
credentials) {
Credential credential = COSUtils.getSuitableCredential(credentials);
Map<String, String> result = Maps.newHashMap();
- if (credential instanceof COSSecretKeyCredential) {
+ if (credential instanceof COSSecretKeyCredential || credential instanceof
COSTokenCredential) {
result.put(
CosNConfigKeys.COSN_CREDENTIALS_PROVIDER,
COSCredentialsProvider.class.getCanonicalName());
diff --git
a/bundles/tencent/src/main/java/org/apache/gravitino/cos/fs/COSUtils.java
b/bundles/tencent/src/main/java/org/apache/gravitino/cos/fs/COSUtils.java
index 1a1dfcf664..4550e8a64d 100644
--- a/bundles/tencent/src/main/java/org/apache/gravitino/cos/fs/COSUtils.java
+++ b/bundles/tencent/src/main/java/org/apache/gravitino/cos/fs/COSUtils.java
@@ -20,19 +20,27 @@
package org.apache.gravitino.cos.fs;
import org.apache.gravitino.credential.COSSecretKeyCredential;
+import org.apache.gravitino.credential.COSTokenCredential;
import org.apache.gravitino.credential.Credential;
public class COSUtils {
/**
- * Get the credential from the credential array. PR-A only ships static
secret-key support; STS
- * token credentials will be added by a follow-up PR, at which point this
helper should mirror
- * {@code OSSUtils#getSuitableCredential} and prefer dynamic over static
credentials.
+ * Get the credential from the credential array. Using dynamic credential
first, if not found,
+ * uses static credential.
*
* @param credentials The credential array.
* @return A credential. Null if not found.
*/
static Credential getSuitableCredential(Credential[] credentials) {
+ // Use dynamic credential if found.
+ for (Credential credential : credentials) {
+ if (credential instanceof COSTokenCredential) {
+ return credential;
+ }
+ }
+
+ // If dynamic credential not found, use the static one.
for (Credential credential : credentials) {
if (credential instanceof COSSecretKeyCredential) {
return credential;
diff --git
a/bundles/tencent/src/main/resources/META-INF/services/org.apache.gravitino.credential.CredentialProvider
b/bundles/tencent/src/main/resources/META-INF/services/org.apache.gravitino.credential.CredentialProvider
index 8877014411..8b0f89bab9 100644
---
a/bundles/tencent/src/main/resources/META-INF/services/org.apache.gravitino.credential.CredentialProvider
+++
b/bundles/tencent/src/main/resources/META-INF/services/org.apache.gravitino.credential.CredentialProvider
@@ -17,3 +17,4 @@
# under the License.
#
org.apache.gravitino.cos.credential.COSSecretKeyProvider
+org.apache.gravitino.cos.credential.COSTokenProvider
diff --git
a/bundles/tencent/src/test/java/org/apache/gravitino/cos/credential/TestCOSCredentialProvider.java
b/bundles/tencent/src/test/java/org/apache/gravitino/cos/credential/TestCOSCredentialProvider.java
index 1a83c3795e..9a2361619a 100644
---
a/bundles/tencent/src/test/java/org/apache/gravitino/cos/credential/TestCOSCredentialProvider.java
+++
b/bundles/tencent/src/test/java/org/apache/gravitino/cos/credential/TestCOSCredentialProvider.java
@@ -21,6 +21,7 @@ package org.apache.gravitino.cos.credential;
import com.google.common.collect.ImmutableMap;
import org.apache.gravitino.credential.COSSecretKeyCredential;
+import org.apache.gravitino.credential.COSTokenCredential;
import org.apache.gravitino.credential.Credential;
import org.apache.gravitino.storage.COSProperties;
import org.junit.jupiter.api.Assertions;
@@ -60,4 +61,32 @@ public class TestCOSCredentialProvider {
Assertions.assertEquals("ak", typed.accessKeyId());
Assertions.assertEquals("sk", typed.secretAccessKey());
}
+
+ @Test
+ void testTokenProviderMetadata() {
+ COSTokenProvider provider = new COSTokenProvider();
+ Assertions.assertEquals(
+ COSTokenCredential.COS_TOKEN_CREDENTIAL_TYPE,
provider.credentialType());
+ Assertions.assertTrue(provider.supportsScheme("cosn"));
+ Assertions.assertTrue(provider.supportsScheme("COSN"));
+ Assertions.assertFalse(provider.supportsScheme("oss"));
+ Assertions.assertEquals(
+ "org.apache.gravitino.cos.credential.COSTokenGenerator",
provider.getGeneratorClassName());
+ }
+
+ @Test
+ void testTokenProviderRejectsNonPathContext() {
+ COSTokenProvider provider = new COSTokenProvider();
+ provider.initialize(
+ ImmutableMap.of(
+ COSProperties.GRAVITINO_COS_ACCESS_KEY_ID, "ak",
+ COSProperties.GRAVITINO_COS_ACCESS_KEY_SECRET, "sk",
+ COSProperties.GRAVITINO_COS_ROLE_ARN,
"qcs::cam::uin/100:roleName/role",
+ COSProperties.GRAVITINO_COS_APP_ID, "1259000000",
+ COSProperties.GRAVITINO_COS_REGION, "ap-shanghai"));
+ // Pass a non-path CredentialContext (just a username); the generator
should short-circuit and
+ // return null without reaching the STS service.
+ Credential credential = provider.getCredential(() -> "user");
+ Assertions.assertNull(credential);
+ }
}
diff --git
a/bundles/tencent/src/test/java/org/apache/gravitino/cos/credential/TestCOSCredentialProviderSpi.java
b/bundles/tencent/src/test/java/org/apache/gravitino/cos/credential/TestCOSCredentialProviderSpi.java
new file mode 100644
index 0000000000..43f0707bcc
--- /dev/null
+++
b/bundles/tencent/src/test/java/org/apache/gravitino/cos/credential/TestCOSCredentialProviderSpi.java
@@ -0,0 +1,60 @@
+/*
+ * 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.cos.credential;
+
+import java.util.HashSet;
+import java.util.ServiceLoader;
+import java.util.Set;
+import org.apache.gravitino.credential.CredentialProvider;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Guards the SPI wiring in {@code
META-INF/services/org.apache.gravitino.credential
+ * .CredentialProvider}. If a future change accidentally drops {@link
COSTokenProvider} (or {@link
+ * COSSecretKeyProvider}) from that file, the Gravitino server would silently
return an empty {@code
+ * credentials: []} response instead of a proper credential. This test fails
loudly at build time so
+ * the regression cannot slip past CI.
+ */
+public class TestCOSCredentialProviderSpi {
+
+ @Test
+ void testCredentialProviderSpiRegistersBothCosProviders() {
+ Set<Class<?>> loaded = new HashSet<>();
+ for (CredentialProvider provider :
ServiceLoader.load(CredentialProvider.class)) {
+ loaded.add(provider.getClass());
+ }
+
+ // We assert containment (not equality) because other bundles on the test
classpath may
+ // register their own providers via the same SPI file.
+ Assertions.assertTrue(
+ loaded.contains(COSSecretKeyProvider.class),
+ "COSSecretKeyProvider not registered via ServiceLoader; check "
+ + "bundles/tencent/src/main/resources/META-INF/services/"
+ + "org.apache.gravitino.credential.CredentialProvider. Loaded
providers: "
+ + loaded);
+ Assertions.assertTrue(
+ loaded.contains(COSTokenProvider.class),
+ "COSTokenProvider not registered via ServiceLoader; missing this line
means "
+ + "cos-token credential vending will silently return empty
credentials at runtime. "
+ + "Loaded providers: "
+ + loaded);
+ }
+}
diff --git
a/bundles/tencent/src/test/java/org/apache/gravitino/cos/credential/TestCOSTokenGenerator.java
b/bundles/tencent/src/test/java/org/apache/gravitino/cos/credential/TestCOSTokenGenerator.java
new file mode 100644
index 0000000000..b996048415
--- /dev/null
+++
b/bundles/tencent/src/test/java/org/apache/gravitino/cos/credential/TestCOSTokenGenerator.java
@@ -0,0 +1,255 @@
+/*
+ * 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.cos.credential;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.google.common.collect.ImmutableSet;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+
+public class TestCOSTokenGenerator {
+
+ private static final ObjectMapper MAPPER = new ObjectMapper();
+
+ private COSTokenGenerator newGenerator() {
+ COSTokenGenerator generator = new COSTokenGenerator();
+ generator.initializeForTest(
+ "ak", "sk", "qcs::cam::uin/100:roleName/role", null, "ap-shanghai",
"1259000000", 3600);
+ return generator;
+ }
+
+ @Test
+ void testPolicyContainsBucketAppIdSuffix() throws Exception {
+ COSTokenGenerator generator = newGenerator();
+ String json =
+ generator.buildPolicyForTest(
+ ImmutableSet.of("cosn://my-bucket/dataset/foo/"),
ImmutableSet.of());
+ JsonNode root = MAPPER.readTree(json);
+ Assertions.assertEquals("2.0", root.get("version").asText());
+ JsonNode statements = root.get("statement");
+ Assertions.assertTrue(statements.isArray() && statements.size() > 0);
+
+ boolean foundBucketAppIdInResource = false;
+ for (JsonNode stmt : statements) {
+ JsonNode resources = stmt.get("resource");
+ if (resources != null && resources.isArray()) {
+ for (JsonNode r : resources) {
+ if (r.asText().contains("my-bucket-1259000000")) {
+ foundBucketAppIdInResource = true;
+ break;
+ }
+ }
+ }
+ }
+ Assertions.assertTrue(
+ foundBucketAppIdInResource,
+ "Resource ARN should append the APPID suffix to the bucket name.
Policy: " + json);
+ }
+
+ @Test
+ void testPolicyOmitsWriteStatementWhenWriteEmpty() throws Exception {
+ COSTokenGenerator generator = newGenerator();
+ String json =
+
generator.buildPolicyForTest(ImmutableSet.of("cosn://my-bucket/read/"),
ImmutableSet.of());
+ Assertions.assertFalse(json.contains("cos:PutObject"), "Policy: " + json);
+ Assertions.assertFalse(json.contains("cos:DeleteObject"), "Policy: " +
json);
+ }
+
+ @Test
+ void testPolicyIncludesWriteStatementWhenWriteSet() throws Exception {
+ COSTokenGenerator generator = newGenerator();
+ String json =
+ generator.buildPolicyForTest(
+ ImmutableSet.of("cosn://my-bucket/read/"),
ImmutableSet.of("cosn://my-bucket/write/"));
+ Assertions.assertTrue(json.contains("cos:PutObject"), "Policy: " + json);
+ Assertions.assertTrue(json.contains("cos:DeleteObject"), "Policy: " +
json);
+ Assertions.assertTrue(json.contains("cos:CompleteMultipartUpload"),
"Policy: " + json);
+ }
+
+ @Test
+ void testPolicyUsesLowerCaseEffectAllow() throws Exception {
+ COSTokenGenerator generator = newGenerator();
+ String json =
+
generator.buildPolicyForTest(ImmutableSet.of("cosn://my-bucket/data/"),
ImmutableSet.of());
+ Assertions.assertTrue(json.contains("\"effect\":\"allow\""), "Policy: " +
json);
+ Assertions.assertFalse(json.contains("\"Effect\":\"Allow\""), "Policy: " +
json);
+ }
+
+ @Test
+ void testPolicyUsesCosPrefixCondition() throws Exception {
+ COSTokenGenerator generator = newGenerator();
+ String json =
+
generator.buildPolicyForTest(ImmutableSet.of("cosn://my-bucket/data/"),
ImmutableSet.of());
+ Assertions.assertTrue(json.contains("string_like"), "Policy: " + json);
+ Assertions.assertTrue(json.contains("cos:prefix"), "Policy: " + json);
+ Assertions.assertTrue(json.contains("\"data/\""), "Policy: " + json);
+ Assertions.assertTrue(json.contains("\"data/*\""), "Policy: " + json);
+ }
+
+ @Test
+ void testBucketWithExistingAppIdSuffixIsKept() throws Exception {
+ COSTokenGenerator generator = newGenerator();
+ String json =
+ generator.buildPolicyForTest(
+ ImmutableSet.of("cosn://already-1259000000/data/"),
ImmutableSet.of());
+ Assertions.assertTrue(json.contains("already-1259000000"), "Policy: " +
json);
+ Assertions.assertFalse(json.contains("already-1259000000-1259000000"),
"Policy: " + json);
+ }
+
+ @Test
+ void testBuildPolicyRejectsEmptyLocations() {
+ COSTokenGenerator generator = newGenerator();
+ Assertions.assertThrows(
+ IllegalArgumentException.class,
+ () -> generator.buildPolicyForTest(ImmutableSet.of(),
ImmutableSet.of()));
+ }
+
+ @Test
+ void testPolicyAppendsWildcardWhenLocationHasNoTrailingSlash() throws
Exception {
+ // A location without a trailing slash is normalized to end with '/'
before the cos:prefix
+ // patterns are built; a bare 'data*' pattern MUST NOT be emitted,
otherwise sibling
+ // prefixes such as 'data_backup/' would be accidentally covered.
+ COSTokenGenerator generator = newGenerator();
+ String json =
+ generator.buildPolicyForTest(ImmutableSet.of("cosn://my-bucket/data"),
ImmutableSet.of());
+ Assertions.assertTrue(json.contains("data/*"), "Policy: " + json);
+ Assertions.assertTrue(json.contains("\"data/\""), "Policy: " + json);
+ Assertions.assertTrue(json.contains("\"data/*\""), "Policy: " + json);
+ Assertions.assertFalse(json.contains("\"data*\""), "Policy: " + json);
+ }
+
+ @Test
+ void testInitializeForTestRejectsBlankRegion() {
+ // A blank region degrades the resource ARN region segment to '*',
weakening the policy.
+ COSTokenGenerator generator = new COSTokenGenerator();
+ for (String blank : new String[] {null, "", " "}) {
+ Assertions.assertThrows(
+ IllegalArgumentException.class,
+ () ->
+ generator.initializeForTest(
+ "ak", "sk", "qcs::cam::uin/100:roleName/role", null, blank,
"1259000000", 3600),
+ "Blank region should be rejected: [" + blank + "]");
+ }
+ }
+
+ @Test
+ void testPolicyResourceArnCarriesConfiguredRegion() throws Exception {
+ COSTokenGenerator generator = newGenerator();
+ String json =
+
generator.buildPolicyForTest(ImmutableSet.of("cosn://my-bucket/data/"),
ImmutableSet.of());
+ Assertions.assertTrue(json.contains("qcs::cos:ap-shanghai:"), "Policy: " +
json);
+ Assertions.assertFalse(json.contains("qcs::cos:*:"), "Policy: " + json);
+ }
+
+ @Test
+ void testPolicyIncludesHeadBucketAction() throws Exception {
+ // hadoop-cos calls headBucket during FileSystem.initialize(); without
cos:HeadBucket the
+ // vended credentials return 403.
+ COSTokenGenerator generator = newGenerator();
+ String json =
+
generator.buildPolicyForTest(ImmutableSet.of("cosn://my-bucket/data/"),
ImmutableSet.of());
+ Assertions.assertTrue(json.contains("cos:HeadBucket"), "Policy: " + json);
+ }
+
+ @Test
+ void testGetBucketUsesWildcardResource() throws Exception {
+ // CAM needs bucket/* for cos:GetBucket and bucket/ for cos:HeadBucket /
cos:GetBucketLocation.
+ COSTokenGenerator generator = newGenerator();
+ String json =
+
generator.buildPolicyForTest(ImmutableSet.of("cosn://my-bucket/data/"),
ImmutableSet.of());
+ Assertions.assertTrue(json.contains(":my-bucket-1259000000/*"), "Policy: "
+ json);
+ Assertions.assertTrue(json.contains(":my-bucket-1259000000/\""), "Policy:
" + json);
+ }
+
+ @Test
+ void testPrefixConditionKeepsTrailingSlashBoundary() throws Exception {
+ // Guard against dropping the '/' before '*' (would over-grant siblings)
or a double
+ // slash ('data//*') from double-appending the wildcard.
+ COSTokenGenerator generator = newGenerator();
+ String json =
+
generator.buildPolicyForTest(ImmutableSet.of("cosn://my-bucket/data/"),
ImmutableSet.of());
+ Assertions.assertTrue(json.contains("\"data/*\""), "Policy: " + json);
+ Assertions.assertFalse(json.contains("data//*"), "Policy: " + json);
+ Assertions.assertFalse(json.contains("\"data*\""), "Policy: " + json);
+ }
+
+ @Test
+ void testPrefixConditionDoesNotOverGrantSiblingPaths() throws Exception {
+ // Regression guard: 'orders' and 'orders/' must both normalize to
'orders/' + 'orders/*',
+ // never leaking to sibling prefixes like 'orders_backup/'.
+ COSTokenGenerator generator = newGenerator();
+ for (String location : new String[] {"cosn://my-bucket/orders",
"cosn://my-bucket/orders/"}) {
+ String json = generator.buildPolicyForTest(ImmutableSet.of(location),
ImmutableSet.of());
+ Assertions.assertTrue(json.contains("\"orders/\""), location + " Policy:
" + json);
+ Assertions.assertTrue(json.contains("\"orders/*\""), location + "
Policy: " + json);
+ Assertions.assertFalse(json.contains("\"orders*\""), location + "
Policy: " + json);
+ }
+ }
+
+ @Test
+ void testPrefixConditionForBucketRootLocation() throws Exception {
+ // Bucket-root fileset: cos:prefix "/" would never match real COS keys
(they carry no
+ // leading slash), so both cosn://bucket/ and cosn://bucket must emit a
bare "*".
+ COSTokenGenerator generator = newGenerator();
+ for (String location : new String[] {"cosn://my-bucket/",
"cosn://my-bucket"}) {
+ String json = generator.buildPolicyForTest(ImmutableSet.of(location),
ImmutableSet.of());
+ Assertions.assertTrue(json.contains("\"*\""), location + " Policy: " +
json);
+ Assertions.assertFalse(json.contains("\"/\""), location + " Policy: " +
json);
+ Assertions.assertFalse(json.contains("\"/*\""), location + " Policy: " +
json);
+ }
+ }
+
+ @Test
+ void testWritePolicyIncludesFullMultipartActionSet() throws Exception {
+ // hadoop-cos calls ListParts on UploadPart 409 to reconcile part state;
missing any of
+ // these actions turns a recoverable multipart upload into a hard 403.
+ COSTokenGenerator generator = newGenerator();
+ String json =
+ generator.buildPolicyForTest(ImmutableSet.of(),
ImmutableSet.of("cosn://my-bucket/data/"));
+ for (String action :
+ new String[] {
+ "cos:PutObject",
+ "cos:DeleteObject",
+ "cos:InitiateMultipartUpload",
+ "cos:UploadPart",
+ "cos:ListParts",
+ "cos:CompleteMultipartUpload",
+ "cos:AbortMultipartUpload"
+ }) {
+ Assertions.assertTrue(json.contains(action), "missing " + action + " in
policy: " + json);
+ }
+ }
+
+ @Test
+ void testGetBucketConditionCoversAllPrefixesForSameBucket() throws Exception
{
+ // CredentialOperationDispatcher.mergeContexts collects multiple
PathContexts into one
+ // PathBasedCredentialContext, so the GetBucket statement must authorise
every path.
+ COSTokenGenerator generator = newGenerator();
+ String json =
+ generator.buildPolicyForTest(
+ ImmutableSet.of("cosn://my-bucket/path-a/"),
+ ImmutableSet.of("cosn://my-bucket/path-b/"));
+ Assertions.assertTrue(json.contains("\"path-a/\""), "Policy: " + json);
+ Assertions.assertTrue(json.contains("\"path-a/*\""), "Policy: " + json);
+ Assertions.assertTrue(json.contains("\"path-b/\""), "Policy: " + json);
+ Assertions.assertTrue(json.contains("\"path-b/*\""), "Policy: " + json);
+ }
+}
diff --git
a/bundles/tencent/src/test/java/org/apache/gravitino/cos/fs/TestCOSCredentialsProvider.java
b/bundles/tencent/src/test/java/org/apache/gravitino/cos/fs/TestCOSCredentialsProvider.java
index 76ff4d1d11..3bdf06e814 100644
---
a/bundles/tencent/src/test/java/org/apache/gravitino/cos/fs/TestCOSCredentialsProvider.java
+++
b/bundles/tencent/src/test/java/org/apache/gravitino/cos/fs/TestCOSCredentialsProvider.java
@@ -20,12 +20,14 @@
package org.apache.gravitino.cos.fs;
import com.qcloud.cos.auth.BasicCOSCredentials;
+import com.qcloud.cos.auth.BasicSessionCredentials;
import com.qcloud.cos.auth.COSCredentials;
import java.net.URI;
import java.util.concurrent.atomic.AtomicInteger;
import
org.apache.gravitino.catalog.hadoop.fs.GravitinoFileSystemCredentialsProvider;
import
org.apache.gravitino.catalog.hadoop.fs.InMemoryFileSystemCredentialsProvider;
import org.apache.gravitino.credential.COSSecretKeyCredential;
+import org.apache.gravitino.credential.COSTokenCredential;
import org.apache.gravitino.credential.Credential;
import org.apache.hadoop.conf.Configuration;
import org.apache.hadoop.fs.CosNConfigKeys;
@@ -108,6 +110,46 @@ public class TestCOSCredentialsProvider {
"Expected message about no suitable credential, but got: " +
ex.getMessage());
}
+ @Test
+ void testRefreshWithTokenCredential() {
+ // Token credentials must be exposed as BasicSessionCredentials so that
hadoop-cos signs
+ // requests with the SessionToken header (x-cos-security-token).
+ long expireMs = System.currentTimeMillis() + 60_000L;
+ StubGravitinoFileSystemCredentialsProvider.nextCredentials =
+ new Credential[] {new COSTokenCredential("tmp-ak", "tmp-sk",
"sts-token", expireMs)};
+
+ COSCredentialsProvider provider = new COSCredentialsProvider(COS_URI,
newStubConf());
+
+ COSCredentials credentials = provider.getCredentials();
+
+ Assertions.assertTrue(
+ credentials instanceof BasicSessionCredentials,
+ "Expected BasicSessionCredentials, got: " +
credentials.getClass().getName());
+ BasicSessionCredentials session = (BasicSessionCredentials) credentials;
+ Assertions.assertEquals("tmp-ak", session.getCOSAccessKeyId());
+ Assertions.assertEquals("tmp-sk", session.getCOSSecretKey());
+ Assertions.assertEquals("sts-token", session.getSessionToken());
+ }
+
+ @Test
+ void testTokenCredentialPreferredOverSecretKey() {
+ // When both credential types are present, COSUtils#getSuitableCredential
must pick the
+ // dynamic (token) one. This mirrors the OSS bundle's selection policy.
+ long expireMs = System.currentTimeMillis() + 60_000L;
+ StubGravitinoFileSystemCredentialsProvider.nextCredentials =
+ new Credential[] {
+ new COSSecretKeyCredential("static-ak", "static-sk"),
+ new COSTokenCredential("tmp-ak", "tmp-sk", "sts-token", expireMs)
+ };
+
+ COSCredentialsProvider provider = new COSCredentialsProvider(COS_URI,
newStubConf());
+
+ COSCredentials credentials = provider.getCredentials();
+
+ Assertions.assertTrue(credentials instanceof BasicSessionCredentials);
+ Assertions.assertEquals("tmp-ak", credentials.getCOSAccessKeyId());
+ }
+
@Test
void testGetCredentialsIsCachedForNonExpiringCredential() {
// COSSecretKeyCredential#expireTimeInMs() returns 0, so expirationTime
stays at
diff --git
a/bundles/tencent/src/test/java/org/apache/gravitino/cos/fs/TestCOSFileSystemProvider.java
b/bundles/tencent/src/test/java/org/apache/gravitino/cos/fs/TestCOSFileSystemProvider.java
index 399e782754..55bece3d20 100644
---
a/bundles/tencent/src/test/java/org/apache/gravitino/cos/fs/TestCOSFileSystemProvider.java
+++
b/bundles/tencent/src/test/java/org/apache/gravitino/cos/fs/TestCOSFileSystemProvider.java
@@ -22,6 +22,7 @@ package org.apache.gravitino.cos.fs;
import java.util.HashMap;
import java.util.Map;
import org.apache.gravitino.credential.COSSecretKeyCredential;
+import org.apache.gravitino.credential.COSTokenCredential;
import org.apache.gravitino.credential.Credential;
import org.apache.gravitino.storage.COSProperties;
import org.apache.hadoop.fs.CosNConfigKeys;
@@ -65,6 +66,23 @@ public class TestCOSFileSystemProvider {
conf.get(CosNConfigKeys.COSN_CREDENTIALS_PROVIDER));
}
+ @Test
+ void testGetFileSystemCredentialConfWithTokenCredential() {
+ COSFileSystemProvider provider = new COSFileSystemProvider();
+ long expireMs = System.currentTimeMillis() + 60_000L;
+ Credential[] credentials =
+ new Credential[] {new COSTokenCredential("tmp-ak", "tmp-sk",
"sts-token", expireMs)};
+
+ Map<String, String> conf =
provider.getFileSystemCredentialConf(credentials);
+
+ // Token credentials must wire the same COSCredentialsProvider; the actual
session token
+ // travels through Gravitino's GravitinoFileSystemCredentialsProvider
rather than via
+ // hadoop-cos config keys.
+ Assertions.assertEquals(
+ COSCredentialsProvider.class.getCanonicalName(),
+ conf.get(CosNConfigKeys.COSN_CREDENTIALS_PROVIDER));
+ }
+
@Test
void testGetFileSystemCredentialConfWithEmptyCredentials() {
COSFileSystemProvider provider = new COSFileSystemProvider();
diff --git
a/catalogs/catalog-common/src/main/java/org/apache/gravitino/credential/CredentialConstants.java
b/catalogs/catalog-common/src/main/java/org/apache/gravitino/credential/CredentialConstants.java
index ea10725b7b..7de528526b 100644
---
a/catalogs/catalog-common/src/main/java/org/apache/gravitino/credential/CredentialConstants.java
+++
b/catalogs/catalog-common/src/main/java/org/apache/gravitino/credential/CredentialConstants.java
@@ -41,6 +41,7 @@ public class CredentialConstants {
"s3-credential-list-location-prefix";
public static final String OSS_TOKEN_EXPIRE_IN_SECS =
"oss-token-expire-in-secs";
+ public static final String COS_TOKEN_EXPIRE_IN_SECS =
"cos-token-expire-in-secs";
public static final String ADLS_TOKEN_EXPIRE_IN_SECS =
"adls-token-expire-in-secs";
/** The HTTP header used to get the credential from fileset location */
diff --git
a/catalogs/catalog-common/src/main/java/org/apache/gravitino/credential/config/COSCredentialConfig.java
b/catalogs/catalog-common/src/main/java/org/apache/gravitino/credential/config/COSCredentialConfig.java
index ab9b2b0faf..338f686cf0 100644
---
a/catalogs/catalog-common/src/main/java/org/apache/gravitino/credential/config/COSCredentialConfig.java
+++
b/catalogs/catalog-common/src/main/java/org/apache/gravitino/credential/config/COSCredentialConfig.java
@@ -26,12 +26,12 @@ import org.apache.gravitino.Config;
import org.apache.gravitino.config.ConfigBuilder;
import org.apache.gravitino.config.ConfigConstants;
import org.apache.gravitino.config.ConfigEntry;
+import org.apache.gravitino.credential.CredentialConstants;
import org.apache.gravitino.storage.COSProperties;
/**
- * Slim credential config for Tencent Cloud COS, covering only the static
secret-key path. STS /
- * token-related entries (role arn, token expire) will be added by a follow-up
PR that introduces
- * the dynamic credential vending support.
+ * Credential config for Tencent Cloud COS. Covers both the static secret-key
path and the dynamic
+ * STS (Security Token Service) path used by credential vending.
*/
public class COSCredentialConfig extends Config {
@@ -40,6 +40,7 @@ public class COSCredentialConfig extends Config {
.doc("The region of the Tencent Cloud COS service")
.version(ConfigConstants.VERSION_2_0_0)
.stringConf()
+ .checkValue(StringUtils::isNotBlank,
ConfigConstants.NOT_BLANK_ERROR_MSG)
.create();
public static final ConfigEntry<String> COS_ACCESS_KEY_ID =
@@ -58,11 +59,65 @@ public class COSCredentialConfig extends Config {
.checkValue(StringUtils::isNotBlank,
ConfigConstants.NOT_BLANK_ERROR_MSG)
.create();
+ public static final ConfigEntry<String> COS_ROLE_ARN =
+ new ConfigBuilder(COSProperties.GRAVITINO_COS_ROLE_ARN)
+ .doc(
+ "The Cloud Access Management (CAM) role ARN that the server
assumes when issuing"
+ + " STS temporary credentials")
+ .version(ConfigConstants.VERSION_2_0_0)
+ .stringConf()
+ .checkValue(StringUtils::isNotBlank,
ConfigConstants.NOT_BLANK_ERROR_MSG)
+ .create();
+
+ public static final ConfigEntry<String> COS_EXTERNAL_ID =
+ new ConfigBuilder(COSProperties.GRAVITINO_COS_EXTERNAL_ID)
+ .doc("Optional external ID for cross-account assume-role")
+ .version(ConfigConstants.VERSION_2_0_0)
+ .stringConf()
+ .create();
+
+ public static final ConfigEntry<String> COS_APP_ID =
+ new ConfigBuilder(COSProperties.GRAVITINO_COS_APP_ID)
+ .doc("The Tencent Cloud APPID that owns the COS buckets, used to
build resource ARNs")
+ .version(ConfigConstants.VERSION_2_0_0)
+ .stringConf()
+ .checkValue(StringUtils::isNotBlank,
ConfigConstants.NOT_BLANK_ERROR_MSG)
+ .create();
+
+ /**
+ * The maximum {@code DurationSeconds} accepted by Tencent Cloud CAM {@code
AssumeRole}, in
+ * seconds. Sourced from the {@code tencentcloud-sdk-java-sts} v3.1.1239
model {@code
+ * AssumeRoleRequest#DurationSeconds}: default 7200 seconds, maximum 43200
seconds (12 hours). If
+ * a future SDK/API bump raises this ceiling, update the constant below and
the accompanying error
+ * message together.
+ */
+ private static final int COS_TOKEN_EXPIRE_IN_SECS_MAX = 43200;
+
+ private static final String COS_TOKEN_EXPIRE_IN_SECS_ERROR_MSG =
+ "cos-token-expire-in-secs must be a positive integer no greater than "
+ + COS_TOKEN_EXPIRE_IN_SECS_MAX
+ + " seconds (Tencent Cloud CAM AssumeRole DurationSeconds hard
limit)";
+
+ public static final ConfigEntry<Integer> COS_TOKEN_EXPIRE_IN_SECS =
+ new ConfigBuilder(CredentialConstants.COS_TOKEN_EXPIRE_IN_SECS)
+ .doc(
+ "COS STS token expire time in seconds. Must be within Tencent
Cloud CAM"
+ + " AssumeRole limits: (0, 43200]. The effective upper bound
may be further"
+ + " reduced by the CAM role's MaxSessionDuration setting;
the STS API rejects"
+ + " values above the role's limit at call time.")
+ .version(ConfigConstants.VERSION_2_0_0)
+ .intConf()
+ .checkValue(
+ v -> v != null && v > 0 && v <= COS_TOKEN_EXPIRE_IN_SECS_MAX,
+ COS_TOKEN_EXPIRE_IN_SECS_ERROR_MSG)
+ .createWithDefault(3600);
+
public COSCredentialConfig(Map<String, String> properties) {
super(false);
loadFromMap(properties, k -> true);
}
+ @NotNull
public String region() {
return this.get(COS_REGION);
}
@@ -76,4 +131,22 @@ public class COSCredentialConfig extends Config {
public String secretAccessKey() {
return this.get(COS_SECRET_ACCESS_KEY);
}
+
+ @NotNull
+ public String cosRoleArn() {
+ return this.get(COS_ROLE_ARN);
+ }
+
+ public String externalID() {
+ return this.get(COS_EXTERNAL_ID);
+ }
+
+ @NotNull
+ public String appID() {
+ return this.get(COS_APP_ID);
+ }
+
+ public Integer tokenExpireInSecs() {
+ return this.get(COS_TOKEN_EXPIRE_IN_SECS);
+ }
}
diff --git
a/catalogs/catalog-common/src/main/java/org/apache/gravitino/storage/COSProperties.java
b/catalogs/catalog-common/src/main/java/org/apache/gravitino/storage/COSProperties.java
index 7326ebc335..d25dcd3a1f 100644
---
a/catalogs/catalog-common/src/main/java/org/apache/gravitino/storage/COSProperties.java
+++
b/catalogs/catalog-common/src/main/java/org/apache/gravitino/storage/COSProperties.java
@@ -30,5 +30,15 @@ public class COSProperties {
// The static secret access key (Tencent Cloud SecretKey) used to access COS
data.
public static final String GRAVITINO_COS_ACCESS_KEY_SECRET =
"cos-secret-access-key";
+ // The CAM role ARN that the server assumes when issuing temporary STS
credentials.
+ // Format: qcs::cam::uin/<owner-uin>:roleName/<role-name>
+ public static final String GRAVITINO_COS_ROLE_ARN = "cos-role-arn";
+ // Optional external ID associated with the CAM role; required only when the
role is configured
+ // with a third-party external ID for cross-account assume-role.
+ public static final String GRAVITINO_COS_EXTERNAL_ID = "cos-external-id";
+ // The Tencent Cloud APPID that owns the COS buckets, used to build resource
ARNs in the STS
+ // session policy. Required when STS credential vending is enabled.
+ public static final String GRAVITINO_COS_APP_ID = "cos-app-id";
+
private COSProperties() {}
}
diff --git
a/catalogs/catalog-common/src/test/java/org/apache/gravitino/credential/config/TestCOSCredentialConfig.java
b/catalogs/catalog-common/src/test/java/org/apache/gravitino/credential/config/TestCOSCredentialConfig.java
new file mode 100644
index 0000000000..2c01cc2edc
--- /dev/null
+++
b/catalogs/catalog-common/src/test/java/org/apache/gravitino/credential/config/TestCOSCredentialConfig.java
@@ -0,0 +1,179 @@
+/*
+ * 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.config;
+
+import com.google.common.collect.ImmutableMap;
+import java.util.HashMap;
+import java.util.Map;
+import org.apache.gravitino.credential.CredentialConstants;
+import org.apache.gravitino.storage.COSProperties;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+
+public class TestCOSCredentialConfig {
+
+ /**
+ * Baseline properties for a COS STS credential config; individual tests
override the field under
+ * test. Kept minimal (all required-for-STS fields present) so blank-value
tests exercise only the
+ * specific field they target.
+ */
+ private static Map<String, String> baseProps() {
+ Map<String, String> props = new HashMap<>();
+ props.put(COSProperties.GRAVITINO_COS_REGION, "ap-shanghai");
+ props.put(COSProperties.GRAVITINO_COS_ACCESS_KEY_ID, "ak");
+ props.put(COSProperties.GRAVITINO_COS_ACCESS_KEY_SECRET, "sk");
+ props.put(COSProperties.GRAVITINO_COS_ROLE_ARN,
"qcs::cam::uin/100:roleName/role");
+ props.put(COSProperties.GRAVITINO_COS_APP_ID, "1259000000");
+ return props;
+ }
+
+ @Test
+ void testTokenExpireDefaultsTo3600WhenAbsent() {
+ // Sanity check: when the caller does not set cos-token-expire-in-secs at
all, the default
+ // (3600s) is applied and the range check does not reject the default.
+ COSCredentialConfig config = new COSCredentialConfig(baseProps());
+ Assertions.assertEquals(3600, config.tokenExpireInSecs());
+ }
+
+ @Test
+ void testTokenExpireAcceptsBoundaryValues() {
+ // 1s and 43200s are the extreme values the range check is expected to
allow.
+ // 43200s (12h) is the documented maximum for Tencent Cloud CAM AssumeRole
DurationSeconds
+ // per tencentcloud-sdk-java-sts v3.1.1239.
+ for (int valid : new int[] {1, 3600, 43200}) {
+ Map<String, String> props =
+ ImmutableMap.<String, String>builder()
+ .putAll(baseProps())
+ .put(CredentialConstants.COS_TOKEN_EXPIRE_IN_SECS,
String.valueOf(valid))
+ .build();
+ COSCredentialConfig config = new COSCredentialConfig(props);
+ Assertions.assertEquals(valid, config.tokenExpireInSecs(), "value=" +
valid);
+ }
+ }
+
+ @Test
+ void testTokenExpireRejectsNonPositiveValues() {
+ // Zero and negative values are meaningless for an STS token TTL. Fail
fast at read time
+ // rather than surfacing a Tencent Cloud STS API error at runtime.
+ //
+ // Note: Config#loadFromMap only records raw strings; ConfigEntry
validators fire lazily on
+ // Config#get. That's why the assertion wraps tokenExpireInSecs() rather
than the constructor.
+ for (int invalid : new int[] {0, -1, -3600}) {
+ Map<String, String> props =
+ ImmutableMap.<String, String>builder()
+ .putAll(baseProps())
+ .put(CredentialConstants.COS_TOKEN_EXPIRE_IN_SECS,
String.valueOf(invalid))
+ .build();
+ COSCredentialConfig config = new COSCredentialConfig(props);
+ Assertions.assertThrows(
+ IllegalArgumentException.class, config::tokenExpireInSecs, "value="
+ invalid);
+ }
+ }
+
+ @Test
+ void testTokenExpireRejectsValuesAboveTencentCloudLimit() {
+ // 43201s and above are guaranteed to be rejected by Tencent Cloud STS at
call time; catching
+ // them here gives a clearer error message tied to the SDK version we
depend on.
+ for (int invalid : new int[] {43201, 86400, Integer.MAX_VALUE}) {
+ Map<String, String> props =
+ ImmutableMap.<String, String>builder()
+ .putAll(baseProps())
+ .put(CredentialConstants.COS_TOKEN_EXPIRE_IN_SECS,
String.valueOf(invalid))
+ .build();
+ COSCredentialConfig config = new COSCredentialConfig(props);
+ Assertions.assertThrows(
+ IllegalArgumentException.class, config::tokenExpireInSecs, "value="
+ invalid);
+ }
+ }
+
+ @Test
+ void testAccessorsReadBaselineProperties() {
+ // Sanity check that the getters return exactly what was fed in via
loadFromMap. Guards
+ // against future refactors accidentally aliasing property keys.
+ COSCredentialConfig config = new COSCredentialConfig(baseProps());
+ Assertions.assertEquals("ap-shanghai", config.region());
+ Assertions.assertEquals("ak", config.accessKeyID());
+ Assertions.assertEquals("sk", config.secretAccessKey());
+ Assertions.assertEquals("qcs::cam::uin/100:roleName/role",
config.cosRoleArn());
+ Assertions.assertEquals("1259000000", config.appID());
+ }
+
+ @Test
+ void testExternalIdIsOptional() {
+ // cos-external-id has no NotBlank check because most tenants do not use
cross-account
+ // AssumeRole. When omitted, externalID() must return null so
COSTokenGenerator can skip
+ // setting ExternalId on the AssumeRole request.
+ COSCredentialConfig config = new COSCredentialConfig(baseProps());
+ Assertions.assertNull(config.externalID());
+
+ Map<String, String> withExtId =
+ ImmutableMap.<String, String>builder()
+ .putAll(baseProps())
+ .put(COSProperties.GRAVITINO_COS_EXTERNAL_ID, "ext-42")
+ .build();
+ COSCredentialConfig withExt = new COSCredentialConfig(withExtId);
+ Assertions.assertEquals("ext-42", withExt.externalID());
+ }
+
+ @Test
+ void testRegionIsRequired() {
+ // A blank / missing cos-region weakens the STS session policy (region ARN
would degrade to
+ // a wildcard). ConfigEntry.checkValue should reject it.
+ for (String blank : new String[] {"", " "}) {
+ Map<String, String> props = new HashMap<>(baseProps());
+ props.put(COSProperties.GRAVITINO_COS_REGION, blank);
+ COSCredentialConfig config = new COSCredentialConfig(props);
+ Assertions.assertThrows(
+ IllegalArgumentException.class, config::region, "region=[" + blank +
"]");
+ }
+ // Missing altogether: reading a required entry with no default must throw
as well.
+ Map<String, String> missing = new HashMap<>(baseProps());
+ missing.remove(COSProperties.GRAVITINO_COS_REGION);
+ COSCredentialConfig config = new COSCredentialConfig(missing);
+ Assertions.assertThrows(RuntimeException.class, config::region);
+ }
+
+ @Test
+ void testRoleArnIsRequiredForStsPath() {
+ // cos-role-arn is what tells the server which CAM role to AssumeRole
into. Without it the
+ // STS provider would call AssumeRole with a null RoleArn and the API
would 4xx. Catching it
+ // in config validation gives a clearer error tied to the property name.
+ for (String blank : new String[] {"", " "}) {
+ Map<String, String> props = new HashMap<>(baseProps());
+ props.put(COSProperties.GRAVITINO_COS_ROLE_ARN, blank);
+ COSCredentialConfig config = new COSCredentialConfig(props);
+ Assertions.assertThrows(
+ IllegalArgumentException.class, config::cosRoleArn, "role-arn=[" +
blank + "]");
+ }
+ }
+
+ @Test
+ void testAppIdIsRequiredForStsPath() {
+ // cos-app-id is required to build the resource ARN "<bucket>-<APPID>"
that scopes the STS
+ // session policy. Without it the policy would allow the wrong bucket, so
we hard-fail early.
+ for (String blank : new String[] {"", " "}) {
+ Map<String, String> props = new HashMap<>(baseProps());
+ props.put(COSProperties.GRAVITINO_COS_APP_ID, blank);
+ COSCredentialConfig config = new COSCredentialConfig(props);
+ Assertions.assertThrows(
+ IllegalArgumentException.class, config::appID, "app-id=[" + blank +
"]");
+ }
+ }
+}
diff --git a/clients/filesystem-hadoop3-runtime/build.gradle.kts
b/clients/filesystem-hadoop3-runtime/build.gradle.kts
index 28aa61be43..fb252ac43d 100644
--- a/clients/filesystem-hadoop3-runtime/build.gradle.kts
+++ b/clients/filesystem-hadoop3-runtime/build.gradle.kts
@@ -43,6 +43,12 @@ tasks.withType<ShadowJar>(ShadowJar::class.java) {
configurations = listOf(project.configurations.runtimeClasspath.get())
archiveClassifier.set("")
+ // Strip shaded slf4j-api brought in by :clients:client-java-runtime —
Hadoop classpaths
+ // usually ship slf4j 1.7.x, and bundling 2.x here breaks that binding at
runtime.
+ exclude("org/slf4j/**")
+ exclude("META-INF/maven/org.slf4j/**")
+ exclude("META-INF/services/org.slf4j.spi.SLF4JServiceProvider")
+
// Relocate dependencies to avoid conflicts
relocate("com.google", "org.apache.gravitino.shaded.com.google") {
// Do not relocate com.google.cloud.hadoop classes — they come from the
external
diff --git
a/clients/filesystem-hadoop3/src/test/java/org/apache/gravitino/filesystem/hadoop/integration/test/GravitinoVirtualFileSystemCOSCredentialIT.java
b/clients/filesystem-hadoop3/src/test/java/org/apache/gravitino/filesystem/hadoop/integration/test/GravitinoVirtualFileSystemCOSCredentialIT.java
new file mode 100644
index 0000000000..f2a1b7a818
--- /dev/null
+++
b/clients/filesystem-hadoop3/src/test/java/org/apache/gravitino/filesystem/hadoop/integration/test/GravitinoVirtualFileSystemCOSCredentialIT.java
@@ -0,0 +1,284 @@
+/*
+ * 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.filesystem.hadoop.integration.test;
+
+import static
org.apache.gravitino.catalog.fileset.FilesetCatalogPropertiesMetadata.FILESYSTEM_PROVIDERS;
+
+import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.Maps;
+import java.io.IOException;
+import java.util.Collections;
+import java.util.Map;
+import org.apache.gravitino.Catalog;
+import org.apache.gravitino.NameIdentifier;
+import org.apache.gravitino.catalog.hadoop.fs.FileSystemUtils;
+import org.apache.gravitino.cos.fs.COSFileSystemProvider;
+import org.apache.gravitino.credential.COSTokenCredential;
+import org.apache.gravitino.credential.Credential;
+import org.apache.gravitino.credential.CredentialConstants;
+import org.apache.gravitino.file.Fileset;
+import org.apache.gravitino.integration.test.util.GravitinoITUtils;
+import org.apache.gravitino.storage.COSProperties;
+import org.apache.hadoop.conf.Configuration;
+import org.junit.jupiter.api.AfterAll;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Disabled;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.condition.EnabledIf;
+import org.junit.platform.commons.util.StringUtils;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Integration test for the GVFS path against a Tencent Cloud COS catalog
whose credentials are
+ * vended dynamically via the {@code cos-token} provider (STS AssumeRole).
Mirrors {@link
+ * GravitinoVirtualFileSystemOSSCredentialIT} so that the two clouds keep
parity in coverage.
+ *
+ * <p>The test is gated on a separate set of {@code *_FOR_CREDENTIAL} env vars
(and notably {@code
+ * COS_ROLE_ARN_FOR_CREDENTIAL}) so that the static-key COS IT and this STS IT
can coexist without
+ * accidentally running with stale env config. If {@code
COS_EXTERNAL_ID_FOR_CREDENTIAL} is also
+ * set, it is propagated to the catalog to exercise the optional {@code
cos-external-id} path.
+ */
+@EnabledIf(value = "cosIsConfigured", disabledReason = "Tencent Cloud COS STS
is not prepared")
+public class GravitinoVirtualFileSystemCOSCredentialIT extends
GravitinoVirtualFileSystemIT {
+ private static final Logger LOG =
+ LoggerFactory.getLogger(GravitinoVirtualFileSystemCOSCredentialIT.class);
+
+ public static final String BUCKET_NAME =
System.getenv("COS_BUCKET_NAME_FOR_CREDENTIAL");
+ public static final String COS_ACCESS_KEY =
System.getenv("COS_ACCESS_KEY_ID_FOR_CREDENTIAL");
+ public static final String COS_SECRET_KEY =
System.getenv("COS_SECRET_ACCESS_KEY_FOR_CREDENTIAL");
+ public static final String COS_REGION =
System.getenv("COS_REGION_FOR_CREDENTIAL");
+ public static final String COS_ENDPOINT =
System.getenv("COS_ENDPOINT_FOR_CREDENTIAL");
+ public static final String COS_ROLE_ARN =
System.getenv("COS_ROLE_ARN_FOR_CREDENTIAL");
+ // COSCredentialConfig#COS_APP_ID has a NotBlank check and COSTokenGenerator
uses it to build
+ // the STS resource ARN (e.g. "my-bucket-1259000000"). Missing it would fail
catalog creation
+ // before any GVFS traffic runs.
+ public static final String COS_APP_ID =
System.getenv("COS_APP_ID_FOR_CREDENTIAL");
+ // Optional external id for AssumeRole; when set the catalog is created with
it so that STS
+ // rejects calls whose ExternalId does not match the target role's trust
policy.
+ public static final String COS_EXTERNAL_ID =
System.getenv("COS_EXTERNAL_ID_FOR_CREDENTIAL");
+
+ @BeforeAll
+ public void startIntegrationTest() {
+ // Override parent's @BeforeAll - it's redirected to startUp() below so
that we can copy the
+ // tencent-bundle JARs to the Gravitino server before booting it.
+ }
+
+ @BeforeAll
+ public void startUp() throws Exception {
+ copyBundleJarsToHadoop("tencent-bundle");
+ super.startIntegrationTest();
+
+ // hadoop-cos defaults to 128 MB blocks; must match
GravitinoVirtualFileSystemCOSIT so that
+ // the parent's testGetDefaultBlockSizes() assertion passes.
+ defaultBlockSize = 128 * 1024 * 1024;
+ defaultReplication = 1;
+
+ metalakeName = GravitinoITUtils.genRandomName("gvfs_it_metalake");
+ catalogName = GravitinoITUtils.genRandomName("catalog");
+ schemaName = GravitinoITUtils.genRandomName("schema");
+
+ Assertions.assertFalse(client.metalakeExists(metalakeName));
+ metalake = client.createMetalake(metalakeName, "metalake comment",
Collections.emptyMap());
+ Assertions.assertTrue(client.metalakeExists(metalakeName));
+
+ Map<String, String> properties = Maps.newHashMap();
+ properties.put(FILESYSTEM_PROVIDERS, "cos");
+ properties.put(COSProperties.GRAVITINO_COS_ACCESS_KEY_ID, COS_ACCESS_KEY);
+ properties.put(COSProperties.GRAVITINO_COS_ACCESS_KEY_SECRET,
COS_SECRET_KEY);
+ properties.put(COSProperties.GRAVITINO_COS_REGION, COS_REGION);
+ if (StringUtils.isNotBlank(COS_ENDPOINT)) {
+ properties.put(COSProperties.GRAVITINO_COS_ENDPOINT, COS_ENDPOINT);
+ }
+ properties.put(COSProperties.GRAVITINO_COS_ROLE_ARN, COS_ROLE_ARN);
+ properties.put(COSProperties.GRAVITINO_COS_APP_ID, COS_APP_ID);
+ if (StringUtils.isNotBlank(COS_EXTERNAL_ID)) {
+ properties.put(COSProperties.GRAVITINO_COS_EXTERNAL_ID, COS_EXTERNAL_ID);
+ }
+ properties.put(
+ CredentialConstants.CREDENTIAL_PROVIDERS,
COSTokenCredential.COS_TOKEN_CREDENTIAL_TYPE);
+ // Explicit non-default TTL so downstream tests can assert expireTimeInMs
propagation without
+ // colliding with the 3600s built-in default.
+ properties.put(CredentialConstants.COS_TOKEN_EXPIRE_IN_SECS, "1800");
+
+ Catalog catalog =
+ metalake.createCatalog(
+ catalogName, Catalog.Type.FILESET, "hadoop", "catalog comment",
properties);
+ Assertions.assertTrue(metalake.catalogExists(catalogName));
+
+ catalog.asSchemas().createSchema(schemaName, "schema comment", properties);
+ Assertions.assertTrue(catalog.asSchemas().schemaExists(schemaName));
+
+ conf.set("fs.gvfs.impl",
"org.apache.gravitino.filesystem.hadoop.GravitinoVirtualFileSystem");
+ conf.set("fs.AbstractFileSystem.gvfs.impl",
"org.apache.gravitino.filesystem.hadoop.Gvfs");
+ conf.set("fs.gvfs.impl.disable.cache", "true");
+ conf.set("fs.gravitino.server.uri", serverUri);
+ conf.set("fs.gravitino.client.metalake", metalakeName);
+ // Enable credential vending on the GVFS client so that data-plane
operations go through the
+ // cos-token STS AssumeRole flow (via COSTokenGenerator) instead of
falling back to the static
+ // AK/SK configured on the catalog. Mirrors the S3/OSS/ABS/GCS credential
ITs.
+ conf.set("fs.gravitino.enableCredentialVending", "true");
+
+ // Pass the COS endpoint settings to the underlying CosFileSystem so that
GVFS can hand off
+ // file IO once the STS credentials are vended back to the client.
+ conf.set(COSProperties.GRAVITINO_COS_ACCESS_KEY_ID, COS_ACCESS_KEY);
+ conf.set(COSProperties.GRAVITINO_COS_ACCESS_KEY_SECRET, COS_SECRET_KEY);
+ conf.set(COSProperties.GRAVITINO_COS_REGION, COS_REGION);
+ if (StringUtils.isNotBlank(COS_ENDPOINT)) {
+ conf.set(COSProperties.GRAVITINO_COS_ENDPOINT, COS_ENDPOINT);
+ }
+ conf.set("fs.cosn.impl", "org.apache.hadoop.fs.CosFileSystem");
+ }
+
+ @AfterAll
+ public void tearDown() throws IOException {
+ Catalog catalog = metalake.loadCatalog(catalogName);
+ catalog.asSchemas().dropSchema(schemaName, true);
+ metalake.dropCatalog(catalogName, true);
+ client.dropMetalake(metalakeName, true);
+
+ if (client != null) {
+ client.close();
+ client = null;
+ }
+
+ try {
+ closer.close();
+ } catch (Exception e) {
+ LOG.error("Exception in closing CloseableGroup", e);
+ }
+ }
+
+ /**
+ * Strip the {@code gravitino.bypass} prefix and translate Gravitino
property keys into their
+ * hadoop-cos equivalents. Mirrors the OSS counterpart and the production
{@code
+ * GravitinoVirtualFileSystem#getConfigMap}.
+ */
+ protected Configuration
convertGvfsConfigToRealFileSystemConfig(Configuration gvfsConf) {
+ Configuration cosConf = new Configuration();
+ Map<String, String> map = Maps.newHashMap();
+
+ gvfsConf.forEach(entry -> map.put(entry.getKey(), entry.getValue()));
+
+ Map<String, String> hadoopConfMap =
+ FileSystemUtils.toHadoopConfigMap(
+ map, COSFileSystemProvider.GRAVITINO_KEY_TO_COS_HADOOP_KEY);
+
+ hadoopConfMap.forEach(cosConf::set);
+
+ return cosConf;
+ }
+
+ protected String genStorageLocation(String fileset) {
+ return String.format("cosn://%s/%s", BUCKET_NAME, fileset);
+ }
+
+ @Disabled(
+ "COS does not support HDFS-style append; CosFileSystem throws "
+ + "UnsupportedOperationException for append()")
+ public void testAppend() throws IOException {}
+
+ /**
+ * For the {@code cos-token} STS provider, catalog-scoped credential vending
must return an empty
+ * array (mirrors {@code oss-token} / {@code s3-token}).
+ *
+ * <p>Rationale: {@code COSTokenGenerator.generate(...)} bails out with
{@code null} whenever the
+ * context is not a {@code PathBasedCredentialContext}, and the
catalog-level endpoint always
+ * hands in a {@code CatalogCredentialContext}. Without this test we would
silently regress if
+ * someone later flipped that guard - the STS token would leak out at
catalog scope with a policy
+ * that is not path-restricted.
+ */
+ @Test
+ void testCatalogCredentialsReturnsEmpty() {
+ Catalog catalog = metalake.loadCatalog(catalogName);
+ Credential[] credentials = catalog.supportsCredentials().getCredentials();
+ Assertions.assertEquals(
+ 0,
+ credentials.length,
+ "cos-token provider must return no credential at catalog scope
(path-less context)");
+ }
+
+ /**
+ * {@code cos-token-expire-in-secs} configured on the catalog (1800 in
{@link #startUp()}) must be
+ * honoured end-to-end - i.e. the vended {@code expireTimeInMs} sits ~30
minutes in the future
+ * rather than falling back to the 3600s built-in default. Also asserts the
three cos-token info
+ * fields are present so downstream consumers can rely on the payload shape.
+ */
+ @Test
+ void testFilesetCredentialRespectsExpireInSecs() {
+ String filesetName = GravitinoITUtils.genRandomName("cos_cred_ttl");
+ NameIdentifier filesetIdent = NameIdentifier.of(schemaName, filesetName);
+ Catalog catalog = metalake.loadCatalog(catalogName);
+
+ Fileset fileset =
+ catalog
+ .asFilesetCatalog()
+ .createFileset(
+ filesetIdent,
+ "fileset for ttl check",
+ Fileset.Type.MANAGED,
+ genStorageLocation(filesetName),
+ ImmutableMap.of());
+
+ try {
+ long now = System.currentTimeMillis();
+ Credential[] credentials =
fileset.supportsCredentials().getCredentials();
+
+ Assertions.assertEquals(1, credentials.length, "expect exactly one
cos-token credential");
+ Assertions.assertInstanceOf(COSTokenCredential.class, credentials[0]);
+
+ Map<String, String> info = credentials[0].credentialInfo();
+ Assertions.assertTrue(
+
StringUtils.isNotBlank(info.get(COSTokenCredential.GRAVITINO_COS_SESSION_ACCESS_KEY_ID)),
+ "cos-access-key-id must be present");
+ Assertions.assertTrue(
+ StringUtils.isNotBlank(
+
info.get(COSTokenCredential.GRAVITINO_COS_SESSION_SECRET_ACCESS_KEY)),
+ "cos-secret-access-key must be present");
+ Assertions.assertTrue(
+
StringUtils.isNotBlank(info.get(COSTokenCredential.GRAVITINO_COS_SESSION_TOKEN)),
+ "cos-security-token must be present");
+
+ long remainingMs = credentials[0].expireTimeInMs() - now;
+ // We configured 1800s. Allow a generous +/- 120s window to absorb the
round trip to STS and
+ // any small clock skew between server and this JVM.
+ long lower = 1_680_000L;
+ long upper = 1_920_000L;
+ Assertions.assertTrue(
+ remainingMs >= lower && remainingMs <= upper,
+ () ->
+ String.format(
+ "expireTimeInMs remaining=%d ms is outside [%d, %d] -
cos-token-expire-in-secs=1800 did not propagate",
+ remainingMs, lower, upper));
+ } finally {
+ catalog.asFilesetCatalog().dropFileset(filesetIdent);
+ }
+ }
+
+ protected static boolean cosIsConfigured() {
+ return
StringUtils.isNotBlank(System.getenv("COS_ACCESS_KEY_ID_FOR_CREDENTIAL"))
+ &&
StringUtils.isNotBlank(System.getenv("COS_SECRET_ACCESS_KEY_FOR_CREDENTIAL"))
+ &&
StringUtils.isNotBlank(System.getenv("COS_BUCKET_NAME_FOR_CREDENTIAL"))
+ && StringUtils.isNotBlank(System.getenv("COS_REGION_FOR_CREDENTIAL"))
+ && StringUtils.isNotBlank(System.getenv("COS_ROLE_ARN_FOR_CREDENTIAL"))
+ && StringUtils.isNotBlank(System.getenv("COS_APP_ID_FOR_CREDENTIAL"));
+ }
+}
diff --git
a/common/src/test/java/org/apache/gravitino/credential/TestCredentialFactory.java
b/common/src/test/java/org/apache/gravitino/credential/TestCredentialFactory.java
index eaa7402c2a..4feb4b4e49 100644
---
a/common/src/test/java/org/apache/gravitino/credential/TestCredentialFactory.java
+++
b/common/src/test/java/org/apache/gravitino/credential/TestCredentialFactory.java
@@ -163,6 +163,54 @@ public class TestCredentialFactory {
Assertions.assertEquals(expireTime,
ossSecretKeyCredential1.expireTimeInMs());
}
+ @Test
+ void testCOSSecretKeyCredential() {
+ Map<String, String> cosSecretKeyCredentialInfo =
+ ImmutableMap.of(
+ COSSecretKeyCredential.GRAVITINO_COS_STATIC_ACCESS_KEY_ID,
+ "accessKeyId",
+ COSSecretKeyCredential.GRAVITINO_COS_STATIC_SECRET_ACCESS_KEY,
+ "secretAccessKey");
+ long expireTime = 0;
+ Credential cosSecretKeyCredential =
+ CredentialFactory.create(
+ COSSecretKeyCredential.COS_SECRET_KEY_CREDENTIAL_TYPE,
+ cosSecretKeyCredentialInfo,
+ expireTime);
+ Assertions.assertEquals(
+ COSSecretKeyCredential.COS_SECRET_KEY_CREDENTIAL_TYPE,
+ cosSecretKeyCredential.credentialType());
+ Assertions.assertInstanceOf(COSSecretKeyCredential.class,
cosSecretKeyCredential);
+ COSSecretKeyCredential typed = (COSSecretKeyCredential)
cosSecretKeyCredential;
+ Assertions.assertEquals("accessKeyId", typed.accessKeyId());
+ Assertions.assertEquals("secretAccessKey", typed.secretAccessKey());
+ Assertions.assertEquals(expireTime, typed.expireTimeInMs());
+ }
+
+ @Test
+ void testCOSTokenCredential() {
+ Map<String, String> cosTokenCredentialInfo =
+ ImmutableMap.of(
+ COSTokenCredential.GRAVITINO_COS_SESSION_ACCESS_KEY_ID,
+ "access-id",
+ COSTokenCredential.GRAVITINO_COS_SESSION_SECRET_ACCESS_KEY,
+ "secret-key",
+ COSTokenCredential.GRAVITINO_COS_SESSION_TOKEN,
+ "token");
+ long expireTime = 100;
+ Credential cosTokenCredential =
+ CredentialFactory.create(
+ COSTokenCredential.COS_TOKEN_CREDENTIAL_TYPE,
cosTokenCredentialInfo, expireTime);
+ Assertions.assertEquals(
+ COSTokenCredential.COS_TOKEN_CREDENTIAL_TYPE,
cosTokenCredential.credentialType());
+ Assertions.assertInstanceOf(COSTokenCredential.class, cosTokenCredential);
+ COSTokenCredential typed = (COSTokenCredential) cosTokenCredential;
+ Assertions.assertEquals("access-id", typed.accessKeyId());
+ Assertions.assertEquals("secret-key", typed.secretAccessKey());
+ Assertions.assertEquals("token", typed.securityToken());
+ Assertions.assertEquals(expireTime, typed.expireTimeInMs());
+ }
+
@Test
void testADLSTokenCredential() {
String storageAccountName = "storage-account-name";
diff --git a/docs/fileset-catalog-with-cos.md b/docs/fileset-catalog-with-cos.md
index dfe533d173..cff8e3e9cf 100644
--- a/docs/fileset-catalog-with-cos.md
+++ b/docs/fileset-catalog-with-cos.md
@@ -39,13 +39,17 @@ These properties are needed in addition to the shared
the GVFS clients, so they are listed together here — note that the Python
client spells them with
underscores while the catalog and the Java client use hyphens.
-| Catalog and Java client | Python client | Description
| Required |
-|-------------------------|-------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|----------|
-| `cos-region` | `cos_region` | Region of the COS
bucket, for example `ap-guangzhou` or `ap-shanghai`.
| Yes |
-| `cos-endpoint` | `cos_endpoint` | Endpoint *suffix* of the
COS service, mapped to `fs.cosn.bucket.endpoint_suffix`. It is a host suffix,
not a URL — `cos.ap-guangzhou.myqcloud.com`, not
`https://cos.ap-guangzhou.myqcloud.com`. When unset, hadoop-cos derives it from
`cos-region`. Set it only to reach a non-public endpoint such as a VPC
endpoint. | No |
-| `cos-access-key-id` | `cos_access_key_id` | Static access key id,
the Tencent Cloud `SecretId`.
| Yes |
-| `cos-secret-access-key` | `cos_secret_access_key` | Static secret access
key, the Tencent Cloud `SecretKey`.
| Yes |
-| `credential-providers` | (n/a) | The credential provider
types, separated by comma. Possible values are `cos-secret-key`. Setting it
enables credential vending, so clients no longer need the credentials above.
See [credential vending](./security/credential-vending.md) for the extra
properties each provider takes. | No |
+| Catalog and Java client | Python client | Description
| Required |
+|----------------------------|----------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|----------|
+| `cos-region` | `cos_region` | Region of the COS
bucket, for example `ap-guangzhou` or `ap-shanghai`.
| Yes |
+| `cos-endpoint` | `cos_endpoint` | Endpoint *suffix*
of the COS service, mapped to `fs.cosn.bucket.endpoint_suffix`. It is a host
suffix, not a URL — `cos.ap-guangzhou.myqcloud.com`, not
`https://cos.ap-guangzhou.myqcloud.com`. When unset, hadoop-cos derives it from
`cos-region`. Set it only to reach a non-public endpoint such as a VPC
endpoint.
| No |
+| `cos-access-key-id` | `cos_access_key_id` | Static access key
id, the Tencent Cloud `SecretId`.
| Yes |
+| `cos-secret-access-key` | `cos_secret_access_key` | Static secret
access key, the Tencent Cloud `SecretKey`.
| Yes |
+| `credential-providers` | (n/a) | The credential
provider types, separated by comma. Supported values are `cos-secret-key`
(static AK/SK vended by the server) and `cos-token` (short-lived STS token
issued via CAM `AssumeRole`). Setting it enables credential vending, so clients
no longer need the credentials above. See [credential
vending](./security/credential-vending.md) for the extra properties each
provider takes.| No |
+| `cos-role-arn` | `cos_role_arn` | The CAM role ARN
that the Gravitino server assumes when issuing STS temporary credentials, e.g.
`qcs::cam::uin/100012345678:roleName/GravitinoCOSAccess`. Required only when
`credential-providers` includes `cos-token`.
| No |
+| `cos-app-id` | `cos_app_id` | The numeric
Tencent Cloud AppId of the bucket owner (the trailing segment of the bucket
name, e.g. `1250000000`). Required only when `credential-providers` includes
`cos-token`; used to build the resource ARN in the STS session policy.
| No |
+| `cos-external-id` | `cos_external_id` | Optional
`ExternalId` propagated to the STS `AssumeRole` call to lock the role's trust
policy to Gravitino. Only meaningful when `credential-providers` includes
`cos-token`.
| No |
+| `cos-token-expire-in-secs` | `cos_token_expire_in_secs` | The COS STS token
expire time in seconds. Must not exceed the role's max session duration. Only
meaningful when `credential-providers` includes `cos-token`. Defaults to
`3600`.
| No |
:::note
`default-filesystem-provider` and `filesystem-providers` are deprecated. The
fileset catalog
@@ -451,17 +455,16 @@ For further Java client use cases, see
## Credential Vending
-With credential vending the catalog holds the Tencent Cloud COS credentials
and the Gravitino server hands
-out a credential per request, so clients never hold cloud keys of their own.
See
+With credential vending the catalog holds the Tencent Cloud COS credentials
and the Gravitino
+server hands out a credential per request, so clients never hold cloud keys of
their own. See
[Credential Vending](./security/credential-vending.md) for the general
mechanism.
-The supported provider is `cos-secret-key`, which vends the static
-`cos-access-key-id` / `cos-secret-access-key` configured on the catalog.
+The currently supported credential providers are listed below:
-:::note
-STS-token-based vending (`cos-token`), including role-arn and assume-role
configuration, will be
-added in a follow-up release. Until then only `cos-secret-key` is available.
-:::
+| Credential provider | Description
| Vended credential type |
+|---------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|------------------------|
+| `cos-secret-key` | The Gravitino server hands out the static
`cos-access-key-id` / `cos-secret-access-key` configured on the catalog. Useful
for centralising credentials on the server side.
| Static AK/SK |
+| `cos-token` | The Gravitino server calls Tencent Cloud CAM
`AssumeRole` and hands out a short-lived STS triple (`TmpSecretId` /
`TmpSecretKey` / `SessionToken`), scoped down to the fileset paths that the
client requested. Requires `cos-role-arn` and `cos-app-id` on the catalog and
CAM permissions (`sts:AssumeRole` / `cam:GetFederationToken`) on the account
whose AK/SK is configured on the catalog. | Short-lived STS token |
### Configure the catalog, schema, and fileset
@@ -482,6 +485,31 @@ curl -X POST -H "Accept:
application/vnd.gravitino.v1+json" \
}' http://localhost:8090/api/metalakes/metalake/catalogs
```
+To enable STS-based credential vending instead (recommended for production,
since the AK/SK on the catalog never leaves the server), switch
`credential-providers` to `cos-token` and add the CAM role wiring. The AK/SK
below is the *server-side* account used to call `sts:AssumeRole`; the vended
clients will only ever see the short-lived session token issued for the
requested fileset paths:
+
+```shell
+curl -X POST -H "Accept: application/vnd.gravitino.v1+json" \
+-H "Content-Type: application/json" -d '{
+ "name": "cos-catalog-with-sts-vending",
+ "type": "FILESET",
+ "comment": "This is a COS fileset catalog with STS credential vending",
+ "properties": {
+ "location": "cosn://my-bucket-1250000000/root",
+ "cos-region": "ap-guangzhou",
+ "cos-access-key-id": "server_access_key",
+ "cos-secret-access-key": "server_secret_key",
+ "credential-providers": "cos-token",
+ "cos-role-arn": "qcs::cam::uin/100012345678:roleName/GravitinoCOSAccess",
+ "cos-app-id": "1250000000",
+ "cos-token-expire-in-secs": "1800"
+ }
+}' http://localhost:8090/api/metalakes/metalake/catalogs
+```
+
+:::note
+When using `cos-token`, the CAM role referenced by `cos-role-arn` must (1)
trust the Gravitino server principal (or use `cos-external-id` for stricter
matching) and (2) have COS read/write permissions on the bucket paths you plan
to expose. Gravitino narrows the vended token further via a session policy that
only allows the fileset's own read/write locations, so the role can be as broad
as your organisation's policies allow — the effective permission is the
intersection.
+:::
+
Create the schema and fileset in the credential-vending catalog:
```shell
diff --git a/docs/security/credential-vending.md
b/docs/security/credential-vending.md
index ea8f11306b..c3283678e7 100755
--- a/docs/security/credential-vending.md
+++ b/docs/security/credential-vending.md
@@ -13,14 +13,14 @@ Gravitino credential vending is used to generate temporary
or static credentials
| Catalog type | Vends |
|--------------|---------------------------------|
-| Fileset | S3, OSS, GCS, ADLS |
+| Fileset | S3, OSS, GCS, ADLS, COS |
| Hive | S3, OSS, GCS, ADLS |
| Iceberg | S3, OSS, GCS, ADLS |
| Glue | S3 |
| JDBC | JDBC user and password |
| Paimon | S3, OSS, JDBC user and password |
-S3 is Amazon S3, OSS is Alibaba Cloud OSS, GCS is Google Cloud Storage, and
ADLS is Azure Data Lake Storage. The Gravitino Spark, Flink, and Trino
connectors consume vended credentials automatically for these catalogs.
+S3 is Amazon S3, OSS is Alibaba Cloud OSS, GCS is Google Cloud Storage, ADLS
is Azure Data Lake Storage, and COS is Tencent Cloud COS. The Gravitino Spark,
Flink, and Trino connectors consume vended credentials automatically for these
catalogs.
## Quick Start
@@ -113,6 +113,8 @@ Catalogs defined in `gravitino.conf` are not registered in
a metalake, so Gravit
| `adls-token` | ADLS | A user delegation SAS token
|
| `azure-account-key` | ADLS | The configured static storage account key
|
| `gcs-token` | GCS | A downscoped access token
|
+| `cos-token` | COS | A temporary STS token
|
+| `cos-secret-key` | COS | The configured static access key and secret
|
| `jdbc-user-password` | JDBC | The configured JDBC username and password
|
Each value has its own properties, listed in the sections below. To vend for
more than one storage type on a catalog, separate values with a comma. Custom
providers can be added by implementing `CredentialProvider`, described under
[Custom Credentials](#custom-credentials).
@@ -278,6 +280,85 @@ The key is long-lived, carries whatever permissions its
RAM user has, and is not
| `oss-access-key-id` | The static access key ID used to access OSS data.
| (none) | Yes |
| `oss-secret-access-key` | The static secret access key used to access OSS
data. | (none) | Yes |
+## COS
+
+### `cos-token`
+
+Gravitino calls Tencent Cloud STS
[AssumeRole](https://www.tencentcloud.com/document/product/598/33416) and
returns temporary credentials scoped to the table path. The role is a Cloud
Access Management (CAM) role on Tencent Cloud; the CAM abbreviation is used
throughout this section.
+
+Also set `cos-access-key-id` and `cos-secret-access-key`. Gravitino uses them
to call AssumeRole, not to reach data, and they are never sent to the engine.
+
+| Property | Description
|
Default value | Required |
+|----------------------------|-----------------------------------------------------------------------------------------------------------------------------|---------------|----------|
+| `cos-access-key-id` | The static access key ID (Tencent Cloud
`SecretId`) used by Gravitino to call STS `AssumeRole`.
| (none) | Yes |
+| `cos-secret-access-key` | The static secret access key (Tencent Cloud
`SecretKey`) used by Gravitino to call STS `AssumeRole`.
| (none) | Yes |
+| `cos-role-arn` | The ARN of the CAM role to assume, e.g.
`qcs::cam::uin/100012345678:roleName/GravitinoCOSAccess`.
| (none) | Yes |
+| `cos-region` | The region of the bucket, e.g. `ap-guangzhou`.
Used to build the STS endpoint and the resource ARN. |
(none) | Yes |
+| `cos-app-id` | The numeric Tencent Cloud AppId of the bucket
owner (the trailing segment of the bucket name, e.g. `1250000000`). |
(none) | Yes |
+| `cos-external-id` | Optional `ExternalId` propagated to STS
`AssumeRole` to lock the role's trust policy to Gravitino.
| (none) | No |
+| `cos-token-expire-in-secs` | The COS security token expire time in secs.
Must not exceed the role's max session duration.
| 3600 | No |
+
+#### Trust Policy on the CAM Role
+
+The role in `cos-role-arn` must allow the `cos-access-key-id` principal to
assume it. If `cos-external-id` is set, the trust policy must require the same
value.
+
+```json
+{
+ "version": "2.0",
+ "statement": [{
+ "effect": "allow",
+ "action": "name/sts:AssumeRole",
+ "principal": { "qcs": ["qcs::cam::uin/{account_uin}:uin/{account_uin}"] },
+ "condition": {
+ "string_equal": { "sts:external_id": "{external_id}" }
+ }
+ }]
+}
+```
+
+#### Permission Policy on the CAM Role
+
+The vended credentials inherit this policy, narrowed to the table path.
+
+```json
+{
+ "version": "2.0",
+ "statement": [
+ {
+ "effect": "allow",
+ "action": [
+ "cos:GetObject",
+ "cos:HeadObject",
+ "cos:PutObject",
+ "cos:DeleteObject",
+ "cos:InitiateMultipartUpload",
+ "cos:UploadPart",
+ "cos:ListParts",
+ "cos:CompleteMultipartUpload",
+ "cos:AbortMultipartUpload"
+ ],
+ "resource":
"qcs::cos:{region}:uid/{app_id}:{bucket_name}-{app_id}/{warehouse_path}/*"
+ },
+ {
+ "effect": "allow",
+ "action": ["cos:GetBucket", "cos:HeadBucket", "cos:GetBucketLocation"],
+ "resource": "qcs::cos:{region}:uid/{app_id}:{bucket_name}-{app_id}/*"
+ }
+ ]
+}
+```
+
+### `cos-secret-key`
+
+Returns the catalog's configured access key and secret to the client,
unchanged.
+
+The key is long-lived, carries whatever permissions its CAM user has, and is
not scoped to the table path. Any client that can load a table receives it, and
it stays valid after the query finishes. Prefer `cos-token`. Use
`cos-secret-key` to confirm the vending path works before configuring a role.
+
+| Property | Description
| Default value | Required |
+|-------------------------|-------------------------------------------------------|---------------|----------|
+| `cos-access-key-id` | The static access key ID used to access COS data.
| (none) | Yes |
+| `cos-secret-access-key` | The static secret access key used to access COS
data. | (none) | Yes |
+
## ADLS
### `adls-token`
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index cb83b3b861..6224b289d8 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -156,6 +156,7 @@ hudi = "0.15.0"
google-auth = "1.28.0"
aliyun-credentials = "0.3.12"
aliyun-sdk-oss = "3.10.2"
+tencentcloud-sdk-sts = "3.1.1239"
openlineage = "1.29.0"
jcstress = "0.8.15"
jmh-plugin = "0.7.3"
@@ -355,6 +356,7 @@ google-auth-credentials = { group = "com.google.auth", name
= "google-auth-libra
aliyun-credentials-sdk = { group='com.aliyun', name='credentials-java',
version.ref='aliyun-credentials' }
aliyun-sdk-oss = { module = "com.aliyun.oss:aliyun-sdk-oss", version.ref =
"aliyun-sdk-oss" }
+tencentcloud-sdk-sts = { group = "com.tencentcloudapi", name =
"tencentcloud-sdk-java-sts", version.ref = "tencentcloud-sdk-sts" }
flinkjdbc = {group='org.apache.flink',name='flink-connector-jdbc',
version.ref='flinkjdbc'}
flinkjdbc18 = {group='org.apache.flink',name='flink-connector-jdbc',
version.ref='flinkjdbc18'}
flinkjdbc119 = {group='org.apache.flink',name='flink-connector-jdbc',
version.ref='flinkjdbc119'}