This is an automated email from the ASF dual-hosted git repository.
cryptoe pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/druid.git
The following commit(s) were added to refs/heads/master by this push:
new 0849af5e5df minor: Add new intermediate S3 role (#20540)
0849af5e5df is described below
commit 0849af5e5df35e4bb602d28f84429c243989687f
Author: Adarsh Sanjeev <[email protected]>
AuthorDate: Fri Oct 23 23:29:54 2026 +0530
minor: Add new intermediate S3 role (#20540)
* Add new intermediate chained role
* Fix test
* Add test
* Builds
* Address comments
* Address comments
* Update doc
---
docs/development/extensions-core/s3.md | 19 +++
docs/ingestion/input-sources.md | 2 +-
docs/multi-stage-query/reference.md | 2 +-
extensions-core/s3-extensions/pom.xml | 5 +
.../apache/druid/data/input/s3/S3InputSource.java | 4 +-
.../druid/storage/s3/S3IntermediateRoleConfig.java | 59 ++++++++
.../druid/storage/s3/S3StorageDruidModule.java | 8 +-
.../storage/s3/ServerSideEncryptingAmazonS3.java | 70 ++++++++-
.../storage/s3/output/S3ExportStorageProvider.java | 8 +-
.../druid/data/input/s3/S3InputSourceTest.java | 10 ++
.../storage/s3/S3IntermediateRoleConfigTest.java | 104 ++++++++++++++
...rSideEncryptingAmazonS3MultipartUploadTest.java | 2 +
.../s3/ServerSideEncryptingAmazonS3Test.java | 157 +++++++++++++++++++++
.../storage/s3/TestAWSCredentialsProvider.java | 6 +-
14 files changed, 446 insertions(+), 10 deletions(-)
diff --git a/docs/development/extensions-core/s3.md
b/docs/development/extensions-core/s3.md
index 43b225aabff..6fc754c267c 100644
--- a/docs/development/extensions-core/s3.md
+++ b/docs/development/extensions-core/s3.md
@@ -81,6 +81,24 @@ For more information, refer to the [Amazon Developer
Guide](https://docs.aws.ama
Alternatively, you can bypass this chain by specifying an access key and
secret key using a [Properties
Object](../../ingestion/input-sources.md#s3-input-source) inside your ingestion
specification.
+Whichever credentials are resolved are then used to assume any `assumeRoleArn`
named by an ingestion spec or export destination. Setting
`druid.s3.intermediateAssumeRoleArn` inserts one role in between: the resolved
credentials assume that role first, and the resulting credentials assume
`assumeRoleArn`. Keys given in an ingestion spec's Properties Object skip the
intermediate role and assume `assumeRoleArn` directly.
+
+Set `druid.s3.intermediateAssumeRoleArn` on every process that builds an S3
input source or export destination:
+
+* Peons, which run ingestion and export tasks. Peons inherit the Middle
Manager's runtime properties, so set it in the Middle Manager's
`runtime.properties`, or set
`druid.indexer.fork.property.druid.s3.intermediateAssumeRoleArn` there to apply
it to Peons only.
+* Indexers, which run tasks in their own process.
+* The Overlord, which runs the sampler used by the web console's data loader.
+
+A process that doesn't have the property set keeps assuming `assumeRoleArn`
directly, so leaving it out of one of these causes the same ingestion to
succeed on some processes and fail on others.
+
+:::warning
+
+Scope an intermediate role to a single trust boundary. Druid assumes it with
no external ID, so every role that trusts it is reachable by every ingestion
spec and `EXPORT` statement that any process using the role can run. If several
clusters share one ARN, the bucket owner's trust policy can no longer tell
those clusters apart, and a user of any one of them can assume any role that
trusts the shared intermediate role.
+
+Note that `assumeRoleArn` and `assumeRoleExternalId` both come from whoever
writes the spec or `EXPORT` statement, so the external ID doesn't restrict
which roles a spec author can reach. Treat it as a value spec authors supply
rather than a secret the cluster holds, and have the bucket owner's trust
policy turn on something spec authors don't control, such as an intermediate
role used by only one cluster.
+
+:::
+
Use the property
[`druid.startup.logging.maskProperties`](../../configuration/index.md#startup-logging)
to mask credentials information in Druid logs. For example, `["password",
"secretKey", "awsSecretAccessKey"]`.
### S3 permissions settings
@@ -127,6 +145,7 @@ For example, to set the region to 'us-east-1' through
system properties:
|`druid.s3.accessKey`|S3 access key. See [S3 authentication
methods](#s3-authentication-methods) for more details|Can be omitted according
to authentication methods chosen.|
|`druid.s3.secretKey`|S3 secret key. See [S3 authentication
methods](#s3-authentication-methods) for more details|Can be omitted according
to authentication methods chosen.|
|`druid.s3.fileSessionCredentials`|Path to properties file containing
`sessionToken`, `accessKey` and `secretKey` value. One key/value pair per line
(format `key=value`). See [S3 authentication
methods](#s3-authentication-methods) for more details |Can be omitted according
to authentication methods chosen.|
+|`druid.s3.intermediateAssumeRoleArn`|AWS ARN of a role to assume before
assuming the `assumeRoleArn` given by an ingestion spec or export destination.
Lets a bucket owner's trust policy name one fixed ARN instead of a different
principal for every Druid cluster. See [S3 authentication
methods](#s3-authentication-methods) for more details.|None|
|`druid.s3.protocol`|Communication protocol type to use when sending requests
to AWS. `http` or `https` can be used. This configuration would be ignored if
`druid.s3.endpoint.url` is filled with a URL with a different protocol.|`https`|
|`druid.s3.disableChunkedEncoding`|Disables chunked encoding. See [AWS
document](https://docs.aws.amazon.com/AWSJavaSDK/latest/javadoc/com/amazonaws/services/s3/AmazonS3Builder.html#disableChunkedEncoding--)
for details.|false|
|`druid.s3.enablePathStyleAccess`|Enables path style access. See [AWS
document](https://docs.aws.amazon.com/AWSJavaSDK/latest/javadoc/com/amazonaws/services/s3/AmazonS3Builder.html#enablePathStyleAccess--)
for details.|false|
diff --git a/docs/ingestion/input-sources.md b/docs/ingestion/input-sources.md
index 05b019ef3f3..99646c5503c 100644
--- a/docs/ingestion/input-sources.md
+++ b/docs/ingestion/input-sources.md
@@ -212,7 +212,7 @@ Properties Object:
:::info
-If `accessKeyId` and `secretAccessKey` are not given, the default [S3
credentials provider
chain](../development/extensions-core/s3.md#s3-authentication-methods) is used.
+If `accessKeyId` and `secretAccessKey` are not given, the default [S3
credentials provider
chain](../development/extensions-core/s3.md#s3-authentication-methods) is used.
If the Druid process sets `druid.s3.intermediateAssumeRoleArn`, the resolved
credentials assume that role before assuming `assumeRoleArn`.
:::
diff --git a/docs/multi-stage-query/reference.md
b/docs/multi-stage-query/reference.md
index a774dc11fdf..739a4ea3599 100644
--- a/docs/multi-stage-query/reference.md
+++ b/docs/multi-stage-query/reference.md
@@ -152,7 +152,7 @@ Supported arguments for the function:
|---|---|---|---|
| `bucket` | Yes | S3 bucket destination for exported files. You must add the
bucket and prefix combination to the
`druid.export.storage.s3.allowedExportPaths` allow list. | n/a |
| `prefix` | Yes | Destination path in the bucket to create exported files.
The export query expects the destination path to be empty. If the location
includes other files, the query will fail. You must add the bucket and prefix
combination to the `druid.export.storage.s3.allowedExportPaths` allow list. |
n/a |
-| `assumeRoleArn` | No | ARN of the role to assume before exporting data. If
not provided, the default credentials configured for the Druid process are
used. | n/a |
+| `assumeRoleArn` | No | ARN of the role to assume before exporting data. If
not provided, the default credentials configured for the Druid process are
used. If provided and the Druid process sets
`druid.s3.intermediateAssumeRoleArn`, those credentials assume that role before
assuming `assumeRoleArn`. | n/a |
| `assumeRoleExternalId` | No | External ID to use when assuming the role
specified in assumeRoleArn. This provides an additional layer of security for
role assumption. Only used when assumeRoleArn is set. | n/a |
Configure the following runtime parameters to export to an S3 destination:
diff --git a/extensions-core/s3-extensions/pom.xml
b/extensions-core/s3-extensions/pom.xml
index 584d6c663dc..976ce809ea3 100644
--- a/extensions-core/s3-extensions/pom.xml
+++ b/extensions-core/s3-extensions/pom.xml
@@ -142,6 +142,11 @@
<artifactId>auth</artifactId>
<version>${aws.sdk.v2.version}</version>
</dependency>
+ <dependency>
+ <groupId>software.amazon.awssdk</groupId>
+ <artifactId>identity-spi</artifactId>
+ <version>${aws.sdk.v2.version}</version>
+ </dependency>
<dependency>
<groupId>software.amazon.awssdk</groupId>
<artifactId>sdk-core</artifactId>
diff --git
a/extensions-core/s3-extensions/src/main/java/org/apache/druid/data/input/s3/S3InputSource.java
b/extensions-core/s3-extensions/src/main/java/org/apache/druid/data/input/s3/S3InputSource.java
index 85cd832d98f..9164c9bad29 100644
---
a/extensions-core/s3-extensions/src/main/java/org/apache/druid/data/input/s3/S3InputSource.java
+++
b/extensions-core/s3-extensions/src/main/java/org/apache/druid/data/input/s3/S3InputSource.java
@@ -129,7 +129,9 @@ public class S3InputSource extends CloudObjectInputSource
awsEndpointConfig,
awsClientConfig,
s3InputSourceConfig,
- null
+ null,
+ s3ClientBuilder.getIntermediateRoleConfig(),
+ s3ClientBuilder.getIntermediateRoleEndpointConfig()
).build();
});
this.maxRetries = RetryUtils.DEFAULT_MAX_TRIES;
diff --git
a/extensions-core/s3-extensions/src/main/java/org/apache/druid/storage/s3/S3IntermediateRoleConfig.java
b/extensions-core/s3-extensions/src/main/java/org/apache/druid/storage/s3/S3IntermediateRoleConfig.java
new file mode 100644
index 00000000000..9124eef1d46
--- /dev/null
+++
b/extensions-core/s3-extensions/src/main/java/org/apache/druid/storage/s3/S3IntermediateRoleConfig.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.druid.storage.s3;
+
+import com.fasterxml.jackson.annotation.JsonProperty;
+import com.google.common.annotations.VisibleForTesting;
+
+import javax.annotation.Nullable;
+
+/**
+ * Role assumed before the {@code assumeRoleArn} named by an ingestion spec or
export destination
+ */
+public class S3IntermediateRoleConfig
+{
+ @Nullable
+ @JsonProperty
+ private String intermediateAssumeRoleArn;
+
+ public S3IntermediateRoleConfig()
+ {
+ }
+
+ @VisibleForTesting
+ S3IntermediateRoleConfig(@Nullable String intermediateAssumeRoleArn)
+ {
+ this.intermediateAssumeRoleArn = intermediateAssumeRoleArn;
+ }
+
+ @Nullable
+ public String getIntermediateAssumeRoleArn()
+ {
+ return intermediateAssumeRoleArn;
+ }
+
+ @Override
+ public String toString()
+ {
+ return "S3IntermediateRoleConfig{" +
+ "intermediateAssumeRoleArn='" + intermediateAssumeRoleArn + '\'' +
+ '}';
+ }
+}
diff --git
a/extensions-core/s3-extensions/src/main/java/org/apache/druid/storage/s3/S3StorageDruidModule.java
b/extensions-core/s3-extensions/src/main/java/org/apache/druid/storage/s3/S3StorageDruidModule.java
index a0c7f986655..8ada5b50165 100644
---
a/extensions-core/s3-extensions/src/main/java/org/apache/druid/storage/s3/S3StorageDruidModule.java
+++
b/extensions-core/s3-extensions/src/main/java/org/apache/druid/storage/s3/S3StorageDruidModule.java
@@ -116,6 +116,7 @@ public class S3StorageDruidModule implements DruidModule
JsonConfigProvider.bind(binder, "druid.storage", S3StorageConfig.class);
JsonConfigProvider.bind(binder, "druid.storage.sse.kms",
S3SSEKmsConfig.class);
JsonConfigProvider.bind(binder, "druid.storage.sse.custom",
S3SSECustomConfig.class);
+ JsonConfigProvider.bind(binder, "druid.s3",
S3IntermediateRoleConfig.class);
Binders.bindTaskLogs(binder, SCHEME, S3TaskLogs.class);
JsonConfigProvider.bind(binder, "druid.indexer.logs",
S3TaskLogsConfig.class);
@@ -130,7 +131,8 @@ public class S3StorageDruidModule implements DruidModule
AWSProxyConfig proxyConfig,
AWSEndpointConfig endpointConfig,
AWSClientConfig clientConfig,
- S3StorageConfig storageConfig
+ S3StorageConfig storageConfig,
+ S3IntermediateRoleConfig intermediateRoleConfig
)
{
if (clientConfig.isForceGlobalBucketAccessEnabled() != null) {
@@ -149,7 +151,9 @@ public class S3StorageDruidModule implements DruidModule
endpointConfig,
clientConfig,
null,
- null
+ null,
+ intermediateRoleConfig,
+ endpointConfig
);
}
diff --git
a/extensions-core/s3-extensions/src/main/java/org/apache/druid/storage/s3/ServerSideEncryptingAmazonS3.java
b/extensions-core/s3-extensions/src/main/java/org/apache/druid/storage/s3/ServerSideEncryptingAmazonS3.java
index 81884ea0cf4..133ab8f8d89 100644
---
a/extensions-core/s3-extensions/src/main/java/org/apache/druid/storage/s3/ServerSideEncryptingAmazonS3.java
+++
b/extensions-core/s3-extensions/src/main/java/org/apache/druid/storage/s3/ServerSideEncryptingAmazonS3.java
@@ -322,7 +322,9 @@ public class ServerSideEncryptingAmazonS3
@Nullable AWSEndpointConfig awsEndpointConfig,
@Nullable AWSClientConfig awsClientConfig,
@Nullable S3InputSourceConfig s3InputSourceConfig,
- @Nullable S3ExportStorageProvider s3ExportStorageProvider
+ @Nullable S3ExportStorageProvider s3ExportStorageProvider,
+ @Nullable S3IntermediateRoleConfig intermediateRoleConfig,
+ @Nullable AWSEndpointConfig intermediateRoleEndpointConfig
)
{
if (s3InputSourceConfig != null && s3ExportStorageProvider != null) {
@@ -411,6 +413,15 @@ public class ServerSideEncryptingAmazonS3
// Apply assume role if configured
if (!Strings.isNullOrEmpty(assumeRoleArn)) {
+ if (s3InputSourceConfig == null ||
!s3InputSourceConfig.isCredentialsConfigured()) {
+ credentialsProvider = maybeAssumeIntermediateRole(
+ credentialsProvider,
+ intermediateRoleConfig,
+ intermediateRoleEndpointConfig,
+ awsClientConfig
+ );
+ }
+
credentialsProvider = createAssumeRoleCredentialsProvider(
assumeRoleArn,
assumeRoleExternalId,
@@ -427,7 +438,9 @@ public class ServerSideEncryptingAmazonS3
return ServerSideEncryptingAmazonS3.builder()
.setS3ClientSupplier(clientBuilder::build)
.setS3AsyncClientSupplier(asyncClientBuilder::build)
- .setS3StorageConfig(s3StorageConfig);
+ .setS3StorageConfig(s3StorageConfig)
+
.setIntermediateRoleConfig(intermediateRoleConfig)
+
.setIntermediateRoleEndpointConfig(intermediateRoleEndpointConfig);
}
/**
@@ -461,6 +474,31 @@ public class ServerSideEncryptingAmazonS3
}
}
+ /**
+ * Assumes an intermediate role if one is configured.
+ */
+ private static AwsCredentialsProvider maybeAssumeIntermediateRole(
+ AwsCredentialsProvider baseCredentialsProvider,
+ @Nullable S3IntermediateRoleConfig intermediateRoleConfig,
+ @Nullable AWSEndpointConfig intermediateRoleEndpointConfig,
+ @Nullable AWSClientConfig awsClientConfig
+ )
+ {
+ final String intermediateAssumeRoleArn =
+ intermediateRoleConfig == null ? null :
intermediateRoleConfig.getIntermediateAssumeRoleArn();
+ if (Strings.isNullOrEmpty(intermediateAssumeRoleArn)) {
+ return baseCredentialsProvider;
+ }
+
+ return createAssumeRoleCredentialsProvider(
+ intermediateAssumeRoleArn,
+ null,
+ intermediateRoleEndpointConfig,
+ baseCredentialsProvider,
+ awsClientConfig
+ );
+ }
+
public static AwsCredentialsProvider createAssumeRoleCredentialsProvider(
String assumeRoleArn,
@Nullable String assumeRoleExternalId,
@@ -500,6 +538,10 @@ public class ServerSideEncryptingAmazonS3
@Nullable
private Supplier<S3AsyncClient> s3AsyncClientSupplier;
private S3StorageConfig s3StorageConfig = new S3StorageConfig(new
NoopServerSideEncryption(), null);
+ @Nullable
+ private S3IntermediateRoleConfig intermediateRoleConfig;
+ @Nullable
+ private AWSEndpointConfig intermediateRoleEndpointConfig;
public Builder setS3ClientSupplier(Supplier<S3Client> s3ClientSupplier)
{
@@ -524,6 +566,30 @@ public class ServerSideEncryptingAmazonS3
return this.s3StorageConfig;
}
+ public Builder setIntermediateRoleConfig(@Nullable
S3IntermediateRoleConfig intermediateRoleConfig)
+ {
+ this.intermediateRoleConfig = intermediateRoleConfig;
+ return this;
+ }
+
+ @Nullable
+ public S3IntermediateRoleConfig getIntermediateRoleConfig()
+ {
+ return this.intermediateRoleConfig;
+ }
+
+ public Builder setIntermediateRoleEndpointConfig(@Nullable
AWSEndpointConfig intermediateRoleEndpointConfig)
+ {
+ this.intermediateRoleEndpointConfig = intermediateRoleEndpointConfig;
+ return this;
+ }
+
+ @Nullable
+ public AWSEndpointConfig getIntermediateRoleEndpointConfig()
+ {
+ return this.intermediateRoleEndpointConfig;
+ }
+
/**
* Builds a new {@link ServerSideEncryptingAmazonS3} instance.
*
diff --git
a/extensions-core/s3-extensions/src/main/java/org/apache/druid/storage/s3/output/S3ExportStorageProvider.java
b/extensions-core/s3-extensions/src/main/java/org/apache/druid/storage/s3/output/S3ExportStorageProvider.java
index cdcd79b1675..566d06c869c 100644
---
a/extensions-core/s3-extensions/src/main/java/org/apache/druid/storage/s3/output/S3ExportStorageProvider.java
+++
b/extensions-core/s3-extensions/src/main/java/org/apache/druid/storage/s3/output/S3ExportStorageProvider.java
@@ -40,6 +40,7 @@ import org.apache.druid.error.DruidException;
import org.apache.druid.java.util.common.StringUtils;
import org.apache.druid.storage.ExportStorageProvider;
import org.apache.druid.storage.StorageConnector;
+import org.apache.druid.storage.s3.S3IntermediateRoleConfig;
import org.apache.druid.storage.s3.S3StorageConfig;
import org.apache.druid.storage.s3.S3StorageDruidModule;
import org.apache.druid.storage.s3.ServerSideEncryptingAmazonS3;
@@ -85,6 +86,9 @@ public class S3ExportStorageProvider implements
ExportStorageProvider
AWSClientConfig awsClientConfig;
@JacksonInject
AwsCredentialsProvider baseCredentialsProvider;
+ @JacksonInject
+ @Nullable
+ S3IntermediateRoleConfig intermediateRoleConfig;
@JacksonInject
S3UploadManager s3UploadManager;
@@ -113,7 +117,9 @@ public class S3ExportStorageProvider implements
ExportStorageProvider
awsEndpointConfig,
awsClientConfig,
null,
- this
+ this,
+ intermediateRoleConfig,
+ awsEndpointConfig
).build();
});
}
diff --git
a/extensions-core/s3-extensions/src/test/java/org/apache/druid/data/input/s3/S3InputSourceTest.java
b/extensions-core/s3-extensions/src/test/java/org/apache/druid/data/input/s3/S3InputSourceTest.java
index 0e041a7640d..421f7eb7fb3 100644
---
a/extensions-core/s3-extensions/src/test/java/org/apache/druid/data/input/s3/S3InputSourceTest.java
+++
b/extensions-core/s3-extensions/src/test/java/org/apache/druid/data/input/s3/S3InputSourceTest.java
@@ -348,6 +348,8 @@ public class S3InputSourceTest extends
InitializedNullHandlingTest
{
EasyMock.reset(SERVER_SIDE_ENCRYPTING_AMAZON_S3_BUILDER);
EasyMock.expect(SERVER_SIDE_ENCRYPTING_AMAZON_S3_BUILDER.getS3StorageConfig()).andStubReturn(S3_STORAGE_CONFIG);
+
EasyMock.expect(SERVER_SIDE_ENCRYPTING_AMAZON_S3_BUILDER.getIntermediateRoleConfig()).andStubReturn(null);
+
EasyMock.expect(SERVER_SIDE_ENCRYPTING_AMAZON_S3_BUILDER.getIntermediateRoleEndpointConfig()).andStubReturn(null);
EasyMock.replay(SERVER_SIDE_ENCRYPTING_AMAZON_S3_BUILDER);
final S3InputSource withPrefixes = new S3InputSource(
SERVICE,
@@ -375,6 +377,8 @@ public class S3InputSourceTest extends
InitializedNullHandlingTest
{
EasyMock.reset(SERVER_SIDE_ENCRYPTING_AMAZON_S3_BUILDER);
EasyMock.expect(SERVER_SIDE_ENCRYPTING_AMAZON_S3_BUILDER.getS3StorageConfig()).andStubReturn(S3_STORAGE_CONFIG);
+
EasyMock.expect(SERVER_SIDE_ENCRYPTING_AMAZON_S3_BUILDER.getIntermediateRoleConfig()).andStubReturn(null);
+
EasyMock.expect(SERVER_SIDE_ENCRYPTING_AMAZON_S3_BUILDER.getIntermediateRoleEndpointConfig()).andStubReturn(null);
EasyMock.replay(SERVER_SIDE_ENCRYPTING_AMAZON_S3_BUILDER);
final S3InputSource withSessionToken = new S3InputSource(
SERVICE,
@@ -407,6 +411,8 @@ public class S3InputSourceTest extends
InitializedNullHandlingTest
EasyMock.reset(SERVER_SIDE_ENCRYPTING_AMAZON_S3_BUILDER);
EasyMock.expect(SERVER_SIDE_ENCRYPTING_AMAZON_S3_BUILDER.getS3StorageConfig())
.andStubReturn(S3_STORAGE_CONFIG);
+
EasyMock.expect(SERVER_SIDE_ENCRYPTING_AMAZON_S3_BUILDER.getIntermediateRoleConfig()).andStubReturn(null);
+
EasyMock.expect(SERVER_SIDE_ENCRYPTING_AMAZON_S3_BUILDER.getIntermediateRoleEndpointConfig()).andStubReturn(null);
EasyMock.replay(SERVER_SIDE_ENCRYPTING_AMAZON_S3_BUILDER);
final AWSEndpointConfig schemelessEndpoint = MAPPER.readValue(
@@ -542,6 +548,8 @@ public class S3InputSourceTest extends
InitializedNullHandlingTest
EasyMock.reset(SERVER_SIDE_ENCRYPTING_AMAZON_S3_BUILDER);
EasyMock.expect(SERVER_SIDE_ENCRYPTING_AMAZON_S3_BUILDER.getS3StorageConfig()).andStubReturn(S3_STORAGE_CONFIG);
+
EasyMock.expect(SERVER_SIDE_ENCRYPTING_AMAZON_S3_BUILDER.getIntermediateRoleConfig()).andStubReturn(null);
+
EasyMock.expect(SERVER_SIDE_ENCRYPTING_AMAZON_S3_BUILDER.getIntermediateRoleEndpointConfig()).andStubReturn(null);
EasyMock.replay(SERVER_SIDE_ENCRYPTING_AMAZON_S3_BUILDER);
final S3InputSource withPrefixes = new S3InputSource(
SERVICE,
@@ -583,6 +591,8 @@ public class S3InputSourceTest extends
InitializedNullHandlingTest
EasyMock.replay(mockS3StorageConfig);
EasyMock.reset(SERVER_SIDE_ENCRYPTING_AMAZON_S3_BUILDER);
EasyMock.expect(SERVER_SIDE_ENCRYPTING_AMAZON_S3_BUILDER.getS3StorageConfig()).andStubReturn(mockS3StorageConfig);
+
EasyMock.expect(SERVER_SIDE_ENCRYPTING_AMAZON_S3_BUILDER.getIntermediateRoleConfig()).andStubReturn(null);
+
EasyMock.expect(SERVER_SIDE_ENCRYPTING_AMAZON_S3_BUILDER.getIntermediateRoleEndpointConfig()).andStubReturn(null);
EasyMock.replay(SERVER_SIDE_ENCRYPTING_AMAZON_S3_BUILDER);
final S3InputSource withPrefixes = new S3InputSource(
SERVICE,
diff --git
a/extensions-core/s3-extensions/src/test/java/org/apache/druid/storage/s3/S3IntermediateRoleConfigTest.java
b/extensions-core/s3-extensions/src/test/java/org/apache/druid/storage/s3/S3IntermediateRoleConfigTest.java
new file mode 100644
index 00000000000..b3d994ed2f6
--- /dev/null
+++
b/extensions-core/s3-extensions/src/test/java/org/apache/druid/storage/s3/S3IntermediateRoleConfigTest.java
@@ -0,0 +1,104 @@
+/*
+ * 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.druid.storage.s3;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.google.inject.Guice;
+import com.google.inject.Injector;
+import com.google.inject.Scopes;
+import com.google.inject.name.Names;
+import jakarta.validation.Validation;
+import jakarta.validation.Validator;
+import org.apache.druid.guice.JsonConfigProvider;
+import org.apache.druid.guice.JsonConfigurator;
+import org.apache.druid.guice.LazySingleton;
+import org.apache.druid.jackson.DefaultObjectMapper;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import java.util.Properties;
+import java.util.UUID;
+
+public class S3IntermediateRoleConfigTest
+{
+ private static final String PROPERTY_PREFIX = UUID.randomUUID().toString();
+ private static final String INTERMEDIATE_ROLE_ARN =
"arn:aws:iam::123456789012:role/intermediate";
+
+ private final Properties properties = new Properties();
+
+ @BeforeEach
+ public void setUp()
+ {
+ properties.clear();
+ }
+
+ @AfterEach
+ public void tearDown()
+ {
+ properties.clear();
+ }
+
+ private Injector createInjector()
+ {
+ return Guice.createInjector(
+ binder -> {
+
binder.bindConstant().annotatedWith(Names.named("serviceName")).to("druid/test/s3");
+
binder.bindConstant().annotatedWith(Names.named("servicePort")).to(0);
+
binder.bindConstant().annotatedWith(Names.named("tlsServicePort")).to(-1);
+
binder.bind(Validator.class).toInstance(Validation.buildDefaultValidatorFactory().getValidator());
+ binder.bindScope(LazySingleton.class, Scopes.SINGLETON);
+ // The mapper matters: see
testSiblingPropertiesUnderTheSamePrefixAreIgnored.
+ binder.bind(ObjectMapper.class).toInstance(new
DefaultObjectMapper());
+ binder.bind(JsonConfigurator.class).in(LazySingleton.class);
+ binder.bind(Properties.class).toInstance(properties);
+ JsonConfigProvider.bind(binder, PROPERTY_PREFIX,
S3IntermediateRoleConfig.class);
+ }
+ );
+ }
+
+ @Test
+ public void testIntermediateAssumeRoleArnIsUnsetByDefault()
+ {
+ final S3IntermediateRoleConfig config =
createInjector().getInstance(S3IntermediateRoleConfig.class);
+ Assertions.assertNull(config.getIntermediateAssumeRoleArn());
+ }
+
+ @Test
+ public void testIntermediateAssumeRoleArnIsBoundFromProperties()
+ {
+ properties.put(PROPERTY_PREFIX + ".intermediateAssumeRoleArn",
INTERMEDIATE_ROLE_ARN);
+
+ final S3IntermediateRoleConfig config =
createInjector().getInstance(S3IntermediateRoleConfig.class);
+ Assertions.assertEquals(INTERMEDIATE_ROLE_ARN,
config.getIntermediateAssumeRoleArn());
+ }
+
+ @Test
+ public void testSiblingPropertiesUnderTheSamePrefixAreIgnored()
+ {
+ properties.put(PROPERTY_PREFIX + ".intermediateAssumeRoleArn",
INTERMEDIATE_ROLE_ARN);
+ properties.put(PROPERTY_PREFIX + ".accessKey", "someAccessKey");
+ properties.put(PROPERTY_PREFIX + ".retryMode", "adaptive");
+
+ final S3IntermediateRoleConfig config =
createInjector().getInstance(S3IntermediateRoleConfig.class);
+ Assertions.assertEquals(INTERMEDIATE_ROLE_ARN,
config.getIntermediateAssumeRoleArn());
+ }
+}
diff --git
a/extensions-core/s3-extensions/src/test/java/org/apache/druid/storage/s3/ServerSideEncryptingAmazonS3MultipartUploadTest.java
b/extensions-core/s3-extensions/src/test/java/org/apache/druid/storage/s3/ServerSideEncryptingAmazonS3MultipartUploadTest.java
index ce3979db5a8..69d62919d10 100644
---
a/extensions-core/s3-extensions/src/test/java/org/apache/druid/storage/s3/ServerSideEncryptingAmazonS3MultipartUploadTest.java
+++
b/extensions-core/s3-extensions/src/test/java/org/apache/druid/storage/s3/ServerSideEncryptingAmazonS3MultipartUploadTest.java
@@ -111,6 +111,8 @@ public class ServerSideEncryptingAmazonS3MultipartUploadTest
endpointConfig,
clientConfig,
null,
+ null,
+ null,
null
).build();
diff --git
a/extensions-core/s3-extensions/src/test/java/org/apache/druid/storage/s3/ServerSideEncryptingAmazonS3Test.java
b/extensions-core/s3-extensions/src/test/java/org/apache/druid/storage/s3/ServerSideEncryptingAmazonS3Test.java
index 6f16924c524..b4430749cc5 100644
---
a/extensions-core/s3-extensions/src/test/java/org/apache/druid/storage/s3/ServerSideEncryptingAmazonS3Test.java
+++
b/extensions-core/s3-extensions/src/test/java/org/apache/druid/storage/s3/ServerSideEncryptingAmazonS3Test.java
@@ -20,6 +20,8 @@
package org.apache.druid.storage.s3;
import org.apache.druid.common.aws.AWSClientConfig;
+import org.apache.druid.common.aws.AWSEndpointConfig;
+import org.apache.druid.data.input.s3.S3InputSourceConfig;
import org.easymock.Capture;
import org.easymock.EasyMock;
import org.junit.jupiter.api.Assertions;
@@ -28,6 +30,7 @@ import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import software.amazon.awssdk.auth.credentials.AnonymousCredentialsProvider;
import software.amazon.awssdk.core.sync.RequestBody;
+import software.amazon.awssdk.regions.Region;
import software.amazon.awssdk.retries.api.RetryStrategy;
import software.amazon.awssdk.services.s3.S3AsyncClient;
import software.amazon.awssdk.services.s3.S3Client;
@@ -35,6 +38,12 @@ import software.amazon.awssdk.services.s3.model.Grant;
import software.amazon.awssdk.services.s3.model.Grantee;
import software.amazon.awssdk.services.s3.model.PutObjectRequest;
import software.amazon.awssdk.services.s3.model.PutObjectResponse;
+import software.amazon.awssdk.services.sts.StsClient;
+import
software.amazon.awssdk.services.sts.auth.StsAssumeRoleCredentialsProvider;
+import software.amazon.awssdk.services.sts.auth.StsCredentialsProvider;
+import software.amazon.awssdk.services.sts.model.AssumeRoleRequest;
+import software.amazon.awssdk.services.sts.model.AssumeRoleResponse;
+import software.amazon.awssdk.services.sts.model.Credentials;
import software.amazon.awssdk.transfer.s3.S3TransferManager;
import software.amazon.awssdk.transfer.s3.model.CompletedFileUpload;
import software.amazon.awssdk.transfer.s3.model.FileUpload;
@@ -43,6 +52,9 @@ import
software.amazon.awssdk.transfer.s3.model.UploadFileRequest;
import java.io.File;
import java.io.IOException;
import java.lang.reflect.Field;
+import java.lang.reflect.Method;
+import java.time.Duration;
+import java.time.Instant;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.CompletableFuture;
@@ -50,6 +62,27 @@ import java.util.concurrent.CompletableFuture;
public class ServerSideEncryptingAmazonS3Test
{
+ private static final String INTERMEDIATE_ROLE_ARN =
"arn:aws:iam::123456789012:role/intermediate";
+ private static final String TARGET_ROLE_ARN =
"arn:aws:iam::123456789012:role/target";
+
+ private static final AWSEndpointConfig REGION_CONFIG = new
AWSEndpointConfig()
+ {
+ @Override
+ public String getSigningRegion()
+ {
+ return "us-east-1";
+ }
+ };
+
+ private static final AWSEndpointConfig INTERMEDIATE_REGION_CONFIG = new
AWSEndpointConfig()
+ {
+ @Override
+ public String getSigningRegion()
+ {
+ return "eu-south-1";
+ }
+ };
+
@TempDir
public File temporaryFolder;
@@ -282,6 +315,8 @@ public class ServerSideEncryptingAmazonS3Test
null,
clientConfig,
null,
+ null,
+ null,
null
);
@@ -289,6 +324,128 @@ public class ServerSideEncryptingAmazonS3Test
Assertions.assertNotSame(issued.get(0), issued.get(1));
}
+ @Test
+ public void testSpecCredentialsAssumeTheRoleTheSpecNamesDirectly() throws
Exception
+ {
+ final ServerSideEncryptingAmazonS3 s3 =
ServerSideEncryptingAmazonS3.builder(
+ AnonymousCredentialsProvider.create(),
+ new S3StorageConfig(new NoopServerSideEncryption(), new
S3TransferConfig()),
+ null,
+ REGION_CONFIG,
+ new AWSClientConfig(),
+ new S3InputSourceConfig(() -> "specAccessKey", () -> "specSecretKey",
TARGET_ROLE_ARN, null, null),
+ null,
+ new S3IntermediateRoleConfig(INTERMEDIATE_ROLE_ARN),
+ null
+ ).build();
+
+ final StsAssumeRoleCredentialsProvider target = assumeRoleProvider(
+ s3.getS3Client().serviceClientConfiguration().credentialsProvider(),
+ "the S3 client should assume a role"
+ );
+ final Field stsClientField =
StsCredentialsProvider.class.getDeclaredField("stsClient");
+ stsClientField.setAccessible(true);
+ final StsClient targetStsClient = (StsClient) stsClientField.get(target);
+
+ try {
+ Assertions.assertEquals(TARGET_ROLE_ARN, roleArnRequestedBy(target));
+ Assertions.assertFalse(
+ targetStsClient.serviceClientConfiguration().credentialsProvider()
instanceof StsAssumeRoleCredentialsProvider,
+ "the spec's own credentials assume the spec's role without the
intermediate role"
+ );
+ Assertions.assertEquals(
+ "specAccessKey",
+
targetStsClient.serviceClientConfiguration().credentialsProvider().resolveIdentity().join().accessKeyId()
+ );
+ }
+ finally {
+ target.close();
+ }
+ }
+
+ @Test
+ public void testIntermediateRoleIsAssumedBeforeTheRoleTheSpecNames() throws
Exception
+ {
+ final ServerSideEncryptingAmazonS3 s3 =
ServerSideEncryptingAmazonS3.builder(
+ AnonymousCredentialsProvider.create(),
+ new S3StorageConfig(new NoopServerSideEncryption(), new
S3TransferConfig()),
+ null,
+ REGION_CONFIG,
+ new AWSClientConfig(),
+ new S3InputSourceConfig(null, null, TARGET_ROLE_ARN, null, null),
+ null,
+ new S3IntermediateRoleConfig(INTERMEDIATE_ROLE_ARN),
+ INTERMEDIATE_REGION_CONFIG
+ ).build();
+
+ final StsAssumeRoleCredentialsProvider target = assumeRoleProvider(
+ s3.getS3Client().serviceClientConfiguration().credentialsProvider(),
+ "the S3 client should assume a role"
+ );
+ // The STS client the target provider calls out with carries the
credentials of the previous link in the chain.
+ final Field stsClientField =
StsCredentialsProvider.class.getDeclaredField("stsClient");
+ stsClientField.setAccessible(true);
+ final StsClient targetStsClient = (StsClient) stsClientField.get(target);
+
+ final StsAssumeRoleCredentialsProvider intermediate = assumeRoleProvider(
+ targetStsClient.serviceClientConfiguration().credentialsProvider(),
+ "the STS client should itself assume the intermediate role"
+ );
+
+ try {
+ Assertions.assertEquals(
+ TARGET_ROLE_ARN,
+ roleArnRequestedBy(target),
+ "the S3 client assumes the role the spec named"
+ );
+ Assertions.assertEquals(
+ INTERMEDIATE_ROLE_ARN,
+ roleArnRequestedBy(intermediate),
+ "and it does so with credentials from assuming the intermediate role
first"
+ );
+ Assertions.assertEquals(Region.US_EAST_1,
targetStsClient.serviceClientConfiguration().region());
+ Assertions.assertEquals(
+ Region.EU_SOUTH_1,
+ ((StsClient)
stsClientField.get(intermediate)).serviceClientConfiguration().region()
+ );
+ }
+ finally {
+ target.close();
+ intermediate.close();
+ }
+ }
+
+ private static StsAssumeRoleCredentialsProvider assumeRoleProvider(Object
provider, String message)
+ {
+ return Assertions.assertInstanceOf(StsAssumeRoleCredentialsProvider.class,
provider, message);
+ }
+
+ private static String roleArnRequestedBy(StsAssumeRoleCredentialsProvider
provider) throws Exception
+ {
+ final Capture<AssumeRoleRequest> requested = EasyMock.newCapture();
+ final StsClient stsClient = EasyMock.createMock(StsClient.class);
+ final AssumeRoleResponse response = AssumeRoleResponse.builder()
+ .credentials(
+ Credentials.builder()
+ .accessKeyId("accessKey")
+ .secretAccessKey("secretKey")
+ .sessionToken("sessionToken")
+ .expiration(Instant.now().plus(Duration.ofHours(1)))
+ .build()
+ )
+ .build();
+
EasyMock.expect(stsClient.assumeRole(EasyMock.capture(requested))).andReturn(response);
+ EasyMock.replay(stsClient);
+
+ final Method getUpdatedCredentials =
+
StsAssumeRoleCredentialsProvider.class.getDeclaredMethod("getUpdatedCredentials",
StsClient.class);
+ getUpdatedCredentials.setAccessible(true);
+ getUpdatedCredentials.invoke(provider, stsClient);
+
+ EasyMock.verify(stsClient);
+ return requested.getValue().roleArn();
+ }
+
@Test
public void testBuilderWithAsyncClient() throws NoSuchFieldException,
IllegalAccessException
{
diff --git
a/extensions-core/s3-extensions/src/test/java/org/apache/druid/storage/s3/TestAWSCredentialsProvider.java
b/extensions-core/s3-extensions/src/test/java/org/apache/druid/storage/s3/TestAWSCredentialsProvider.java
index cc362f6ba8d..1ca307a7e9a 100644
---
a/extensions-core/s3-extensions/src/test/java/org/apache/druid/storage/s3/TestAWSCredentialsProvider.java
+++
b/extensions-core/s3-extensions/src/test/java/org/apache/druid/storage/s3/TestAWSCredentialsProvider.java
@@ -66,7 +66,8 @@ public class TestAWSCredentialsProvider
new AWSProxyConfig(),
new AWSEndpointConfig(),
new AWSClientConfig(),
- new S3StorageConfig(new NoopServerSideEncryption(), null)
+ new S3StorageConfig(new NoopServerSideEncryption(), null),
+ new S3IntermediateRoleConfig()
);
s3Module.getAmazonS3Client(
@@ -101,7 +102,8 @@ public class TestAWSCredentialsProvider
new AWSProxyConfig(),
new AWSEndpointConfig(),
new AWSClientConfig(),
- new S3StorageConfig(new NoopServerSideEncryption(), null)
+ new S3StorageConfig(new NoopServerSideEncryption(), null),
+ new S3IntermediateRoleConfig()
);
s3Module.getAmazonS3Client(
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]