Support dynamic credentials in S3HttpFixture (#117458)

Rephrase the authorization check in `S3HttpFixture` in terms of a
predicate provided by the caller so that there's no need for a separate
subclass that handles session tokens, and so that it can support
auto-generated credentials more naturally.

Also adapts `Ec2ImdsHttpFixture` to dynamically generate credentials
this way.

Also extracts the STS fixture in `S3HttpFixtureWithSTS` into a separate
service, similarly to #117324, and adapts this new fixture to
dynamically generate credentials too.

Relates ES-9984
This commit is contained in:
David Turner 2024-11-26 09:06:02 +00:00 committed by GitHub
parent ed33bea30c
commit b13e0d25c0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 553 additions and 155 deletions

View file

@ -46,6 +46,7 @@ dependencies {
yamlRestTestImplementation project(":test:framework")
yamlRestTestImplementation project(':test:fixtures:s3-fixture')
yamlRestTestImplementation project(':test:fixtures:ec2-imds-fixture')
yamlRestTestImplementation project(':test:fixtures:aws-sts-fixture')
yamlRestTestImplementation project(':test:fixtures:minio-fixture')
internalClusterTestImplementation project(':test:fixtures:minio-fixture')

View file

@ -35,7 +35,14 @@ public class RepositoryS3RestReloadCredentialsIT extends ESRestTestCase {
private static final String BUCKET = "RepositoryS3RestReloadCredentialsIT-bucket-" + HASHED_SEED;
private static final String BASE_PATH = "RepositoryS3RestReloadCredentialsIT-base-path-" + HASHED_SEED;
public static final S3HttpFixture s3Fixture = new S3HttpFixture(true, BUCKET, BASE_PATH, "ignored");
private static volatile String repositoryAccessKey;
public static final S3HttpFixture s3Fixture = new S3HttpFixture(
true,
BUCKET,
BASE_PATH,
S3HttpFixture.mutableAccessKey(() -> repositoryAccessKey)
);
private static final MutableSettingsProvider keystoreSettings = new MutableSettingsProvider();
@ -68,7 +75,7 @@ public class RepositoryS3RestReloadCredentialsIT extends ESRestTestCase {
// Set up initial credentials
final var accessKey1 = randomIdentifier();
s3Fixture.setAccessKey(accessKey1);
repositoryAccessKey = accessKey1;
keystoreSettings.put("s3.client.default.access_key", accessKey1);
keystoreSettings.put("s3.client.default.secret_key", randomIdentifier());
cluster.updateStoredSecureSettings();
@ -79,14 +86,14 @@ public class RepositoryS3RestReloadCredentialsIT extends ESRestTestCase {
// Rotate credentials in blob store
final var accessKey2 = randomValueOtherThan(accessKey1, ESTestCase::randomIdentifier);
s3Fixture.setAccessKey(accessKey2);
repositoryAccessKey = accessKey2;
// Ensure that initial credentials now invalid
final var accessDeniedException2 = expectThrows(ResponseException.class, () -> client().performRequest(verifyRequest));
assertThat(accessDeniedException2.getResponse().getStatusLine().getStatusCode(), equalTo(500));
assertThat(
accessDeniedException2.getMessage(),
allOf(containsString("Bad access key"), containsString("Status Code: 403"), containsString("Error Code: AccessDenied"))
allOf(containsString("Access denied"), containsString("Status Code: 403"), containsString("Error Code: AccessDenied"))
);
// Set up refreshed credentials

View file

@ -10,8 +10,8 @@
package org.elasticsearch.repositories.s3;
import fixture.aws.imds.Ec2ImdsHttpFixture;
import fixture.s3.DynamicS3Credentials;
import fixture.s3.S3HttpFixture;
import fixture.s3.S3HttpFixtureWithSessionToken;
import com.carrotsearch.randomizedtesting.annotations.Name;
import com.carrotsearch.randomizedtesting.annotations.ParametersFactory;
@ -34,27 +34,30 @@ public class RepositoryS3ClientYamlTestSuiteIT extends AbstractRepositoryS3Clien
private static final String HASHED_SEED = Integer.toString(Murmur3HashFunction.hash(System.getProperty("tests.seed")));
private static final String TEMPORARY_SESSION_TOKEN = "session_token-" + HASHED_SEED;
private static final String IMDS_ACCESS_KEY = "imds-access-key-" + HASHED_SEED;
private static final String IMDS_SESSION_TOKEN = "imds-session-token-" + HASHED_SEED;
private static final S3HttpFixture s3Fixture = new S3HttpFixture();
private static final S3HttpFixtureWithSessionToken s3HttpFixtureWithSessionToken = new S3HttpFixtureWithSessionToken(
private static final S3HttpFixture s3HttpFixtureWithSessionToken = new S3HttpFixture(
true,
"session_token_bucket",
"session_token_base_path_integration_tests",
System.getProperty("s3TemporaryAccessKey"),
TEMPORARY_SESSION_TOKEN
S3HttpFixture.fixedAccessKeyAndToken(System.getProperty("s3TemporaryAccessKey"), TEMPORARY_SESSION_TOKEN)
);
private static final S3HttpFixtureWithSessionToken s3HttpFixtureWithImdsSessionToken = new S3HttpFixtureWithSessionToken(
private static final DynamicS3Credentials dynamicS3Credentials = new DynamicS3Credentials();
private static final Ec2ImdsHttpFixture ec2ImdsHttpFixture = new Ec2ImdsHttpFixture(
dynamicS3Credentials::addValidCredentials,
Set.of()
);
private static final S3HttpFixture s3HttpFixtureWithImdsSessionToken = new S3HttpFixture(
true,
"ec2_bucket",
"ec2_base_path",
IMDS_ACCESS_KEY,
IMDS_SESSION_TOKEN
dynamicS3Credentials::isAuthorized
);
private static final Ec2ImdsHttpFixture ec2ImdsHttpFixture = new Ec2ImdsHttpFixture(IMDS_ACCESS_KEY, IMDS_SESSION_TOKEN, Set.of());
public static ElasticsearchCluster cluster = ElasticsearchCluster.local()
.module("repository-s3")
.keystore("s3.client.integration_test_permanent.access_key", System.getProperty("s3PermanentAccessKey"))

View file

@ -10,12 +10,12 @@
package org.elasticsearch.repositories.s3;
import fixture.aws.imds.Ec2ImdsHttpFixture;
import fixture.s3.S3HttpFixtureWithSessionToken;
import fixture.s3.DynamicS3Credentials;
import fixture.s3.S3HttpFixture;
import com.carrotsearch.randomizedtesting.annotations.Name;
import com.carrotsearch.randomizedtesting.annotations.ParametersFactory;
import org.elasticsearch.cluster.routing.Murmur3HashFunction;
import org.elasticsearch.test.cluster.ElasticsearchCluster;
import org.elasticsearch.test.rest.yaml.ClientYamlTestCandidate;
import org.junit.ClassRule;
@ -26,23 +26,20 @@ import java.util.Set;
public class RepositoryS3EcsClientYamlTestSuiteIT extends AbstractRepositoryS3ClientYamlTestSuiteIT {
private static final String HASHED_SEED = Integer.toString(Murmur3HashFunction.hash(System.getProperty("tests.seed")));
private static final String ECS_ACCESS_KEY = "ecs-access-key-" + HASHED_SEED;
private static final String ECS_SESSION_TOKEN = "ecs-session-token-" + HASHED_SEED;
private static final S3HttpFixtureWithSessionToken s3Fixture = new S3HttpFixtureWithSessionToken(
"ecs_bucket",
"ecs_base_path",
ECS_ACCESS_KEY,
ECS_SESSION_TOKEN
);
private static final DynamicS3Credentials dynamicS3Credentials = new DynamicS3Credentials();
private static final Ec2ImdsHttpFixture ec2ImdsHttpFixture = new Ec2ImdsHttpFixture(
ECS_ACCESS_KEY,
ECS_SESSION_TOKEN,
dynamicS3Credentials::addValidCredentials,
Set.of("/ecs_credentials_endpoint")
);
private static final S3HttpFixture s3Fixture = new S3HttpFixture(
true,
"ecs_bucket",
"ecs_base_path",
dynamicS3Credentials::isAuthorized
);
public static ElasticsearchCluster cluster = ElasticsearchCluster.local()
.module("repository-s3")
.setting("s3.client.integration_test_ecs.endpoint", s3Fixture::getAddress)

View file

@ -9,8 +9,9 @@
package org.elasticsearch.repositories.s3;
import fixture.aws.sts.AwsStsHttpFixture;
import fixture.s3.DynamicS3Credentials;
import fixture.s3.S3HttpFixture;
import fixture.s3.S3HttpFixtureWithSTS;
import com.carrotsearch.randomizedtesting.annotations.Name;
import com.carrotsearch.randomizedtesting.annotations.ParametersFactory;
@ -24,13 +25,27 @@ import org.junit.rules.TestRule;
public class RepositoryS3StsClientYamlTestSuiteIT extends AbstractRepositoryS3ClientYamlTestSuiteIT {
public static final S3HttpFixture s3Fixture = new S3HttpFixture();
private static final S3HttpFixtureWithSTS s3Sts = new S3HttpFixtureWithSTS();
private static final DynamicS3Credentials dynamicS3Credentials = new DynamicS3Credentials();
private static final S3HttpFixture s3HttpFixture = new S3HttpFixture(
true,
"sts_bucket",
"sts_base_path",
dynamicS3Credentials::isAuthorized
);
private static final AwsStsHttpFixture stsHttpFixture = new AwsStsHttpFixture(dynamicS3Credentials::addValidCredentials, """
Atza|IQEBLjAsAhRFiXuWpUXuRvQ9PZL3GMFcYevydwIUFAHZwXZXXXXXXXXJnrulxKDHwy87oGKPznh0D6bEQZTSCzyoCtL_8S07pLpr0zMbn6w1lfVZKNTBdDans\
FBmtGnIsIapjI6xKR02Yc_2bQ8LZbUXSGm6Ry6_BG7PrtLZtj_dfCTj92xNGed-CrKqjG7nPBjNIL016GGvuS5gSvPRUxWES3VYfm1wl7WTI7jn-Pcb6M-buCgHhFO\
zTQxod27L9CqnOLio7N3gZAGpsp6n1-AJBOCJckcyXe2c6uD0srOJeZlKUm2eTDVMf8IehDVI0r1QOnTV6KzzAI3OY87Vd_cVMQ""");
public static ElasticsearchCluster cluster = ElasticsearchCluster.local()
.module("repository-s3")
.setting("s3.client.integration_test_sts.endpoint", s3Sts::getAddress)
.systemProperty("com.amazonaws.sdk.stsMetadataServiceEndpointOverride", () -> s3Sts.getAddress() + "/assume-role-with-web-identity")
.setting("s3.client.integration_test_sts.endpoint", s3HttpFixture::getAddress)
.systemProperty(
"com.amazonaws.sdk.stsMetadataServiceEndpointOverride",
() -> stsHttpFixture.getAddress() + "/assume-role-with-web-identity"
)
.configFile("repository-s3/aws-web-identity-token-file", Resource.fromClasspath("aws-web-identity-token-file"))
.environment("AWS_WEB_IDENTITY_TOKEN_FILE", System.getProperty("awsWebIdentityTokenExternalLocation"))
// // The AWS STS SDK requires the role and session names to be set. We can verify that they are sent to S3S in the
@ -40,7 +55,7 @@ public class RepositoryS3StsClientYamlTestSuiteIT extends AbstractRepositoryS3Cl
.build();
@ClassRule
public static TestRule ruleChain = RuleChain.outerRule(s3Fixture).around(s3Sts).around(cluster);
public static TestRule ruleChain = RuleChain.outerRule(s3HttpFixture).around(stsHttpFixture).around(cluster);
@ParametersFactory
public static Iterable<Object[]> parameters() throws Exception {

View file

@ -86,6 +86,7 @@ List projects = [
'distribution:tools:ansi-console',
'server',
'test:framework',
'test:fixtures:aws-sts-fixture',
'test:fixtures:azure-fixture',
'test:fixtures:ec2-imds-fixture',
'test:fixtures:gcs-fixture',

View file

@ -0,0 +1,19 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the "Elastic License
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
* Public License v 1"; you may not use this file except in compliance with, at
* your election, the "Elastic License 2.0", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
apply plugin: 'elasticsearch.java'
description = 'Fixture for emulating the Security Token Service (STS) running in AWS'
dependencies {
api project(':server')
api("junit:junit:${versions.junit}") {
transitive = false
}
api project(':test:framework')
}

View file

@ -0,0 +1,64 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the "Elastic License
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
* Public License v 1"; you may not use this file except in compliance with, at
* your election, the "Elastic License 2.0", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
package fixture.aws.sts;
import com.sun.net.httpserver.HttpHandler;
import com.sun.net.httpserver.HttpServer;
import org.junit.rules.ExternalResource;
import java.net.InetAddress;
import java.net.InetSocketAddress;
import java.net.UnknownHostException;
import java.util.Objects;
import java.util.function.BiConsumer;
public class AwsStsHttpFixture extends ExternalResource {
private HttpServer server;
private final BiConsumer<String, String> newCredentialsConsumer;
private final String webIdentityToken;
public AwsStsHttpFixture(BiConsumer<String, String> newCredentialsConsumer, String webIdentityToken) {
this.newCredentialsConsumer = Objects.requireNonNull(newCredentialsConsumer);
this.webIdentityToken = Objects.requireNonNull(webIdentityToken);
}
protected HttpHandler createHandler() {
return new AwsStsHttpHandler(newCredentialsConsumer, webIdentityToken);
}
public String getAddress() {
return "http://" + server.getAddress().getHostString() + ":" + server.getAddress().getPort();
}
public void stop(int delay) {
server.stop(delay);
}
protected void before() throws Throwable {
server = HttpServer.create(resolveAddress(), 0);
server.createContext("/", Objects.requireNonNull(createHandler()));
server.start();
}
@Override
protected void after() {
stop(0);
}
private static InetSocketAddress resolveAddress() {
try {
return new InetSocketAddress(InetAddress.getByName("localhost"), 0);
} catch (UnknownHostException e) {
throw new RuntimeException(e);
}
}
}

View file

@ -6,12 +6,16 @@
* your election, the "Elastic License 2.0", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
package fixture.s3;
package fixture.aws.sts;
import com.sun.net.httpserver.HttpExchange;
import com.sun.net.httpserver.HttpHandler;
import org.elasticsearch.ExceptionsHelper;
import org.elasticsearch.core.SuppressForbidden;
import org.elasticsearch.rest.RestStatus;
import java.io.IOException;
import java.net.URLDecoder;
import java.nio.charset.StandardCharsets;
import java.time.ZonedDateTime;
@ -19,53 +23,39 @@ import java.time.format.DateTimeFormatter;
import java.util.Arrays;
import java.util.Locale;
import java.util.Map;
import java.util.Objects;
import java.util.function.BiConsumer;
import java.util.stream.Collectors;
public class S3HttpFixtureWithSTS extends S3HttpFixture {
import static org.elasticsearch.test.ESTestCase.randomIdentifier;
private static final String ROLE_ARN = "arn:aws:iam::123456789012:role/FederatedWebIdentityRole";
private static final String ROLE_NAME = "sts-fixture-test";
private final String sessionToken;
/**
* Minimal HTTP handler that emulates the AWS STS server
*/
@SuppressForbidden(reason = "this test uses a HttpServer to emulate the AWS STS endpoint")
public class AwsStsHttpHandler implements HttpHandler {
static final String ROLE_ARN = "arn:aws:iam::123456789012:role/FederatedWebIdentityRole";
static final String ROLE_NAME = "sts-fixture-test";
private final BiConsumer<String, String> newCredentialsConsumer;
private final String webIdentityToken;
public S3HttpFixtureWithSTS() {
this(true);
}
public S3HttpFixtureWithSTS(boolean enabled) {
this(
enabled,
"sts_bucket",
"sts_base_path",
"sts_access_key",
"sts_session_token",
"Atza|IQEBLjAsAhRFiXuWpUXuRvQ9PZL3GMFcYevydwIUFAHZwXZXXXXXXXXJnrulxKDHwy87oGKPznh0D6bEQZTSCzyoCtL_8S07pLpr0zMbn6w1lfVZKNTBdDansFBmtGnIsIapjI6xKR02Yc_2bQ8LZbUXSGm6Ry6_BG7PrtLZtj_dfCTj92xNGed-CrKqjG7nPBjNIL016GGvuS5gSvPRUxWES3VYfm1wl7WTI7jn-Pcb6M-buCgHhFOzTQxod27L9CqnOLio7N3gZAGpsp6n1-AJBOCJckcyXe2c6uD0srOJeZlKUm2eTDVMf8IehDVI0r1QOnTV6KzzAI3OY87Vd_cVMQ"
);
}
public S3HttpFixtureWithSTS(
boolean enabled,
String bucket,
String basePath,
String accessKey,
String sessionToken,
String webIdentityToken
) {
super(enabled, bucket, basePath, accessKey);
this.sessionToken = sessionToken;
this.webIdentityToken = webIdentityToken;
public AwsStsHttpHandler(BiConsumer<String, String> newCredentialsConsumer, String webIdentityToken) {
this.newCredentialsConsumer = Objects.requireNonNull(newCredentialsConsumer);
this.webIdentityToken = Objects.requireNonNull(webIdentityToken);
}
@Override
protected HttpHandler createHandler() {
final HttpHandler delegate = super.createHandler();
return exchange -> {
public void handle(final HttpExchange exchange) throws IOException {
// https://docs.aws.amazon.com/STS/latest/APIReference/API_AssumeRoleWithWebIdentity.html
// It's run as a separate service, but we emulate it under the `assume-role-with-web-identity` endpoint
// of the S3 serve for the simplicity sake
if ("POST".equals(exchange.getRequestMethod())
&& exchange.getRequestURI().getPath().startsWith("/assume-role-with-web-identity")) {
try (exchange) {
final var requestMethod = exchange.getRequestMethod();
final var path = exchange.getRequestURI().getPath();
if ("POST".equals(requestMethod) && "/assume-role-with-web-identity/".equals(path)) {
String body = new String(exchange.getRequestBody().readAllBytes(), StandardCharsets.UTF_8);
Map<String, String> params = Arrays.stream(body.split("&"))
.map(e -> e.split("="))
@ -82,6 +72,9 @@ public class S3HttpFixtureWithSTS extends S3HttpFixture {
exchange.close();
return;
}
final var accessKey = randomIdentifier();
final var sessionToken = randomIdentifier();
newCredentialsConsumer.accept(accessKey, sessionToken);
final byte[] response = String.format(
Locale.ROOT,
"""
@ -95,7 +88,7 @@ public class S3HttpFixtureWithSTS extends S3HttpFixture {
</AssumedRoleUser>
<Credentials>
<SessionToken>%s</SessionToken>
<SecretAccessKey>secret_access_key</SecretAccessKey>
<SecretAccessKey>%s</SecretAccessKey>
<Expiration>%s</Expiration>
<AccessKeyId>%s</AccessKeyId>
</Credentials>
@ -109,6 +102,7 @@ public class S3HttpFixtureWithSTS extends S3HttpFixture {
ROLE_ARN,
ROLE_NAME,
sessionToken,
randomIdentifier(),
ZonedDateTime.now().plusDays(1L).format(DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ssZ")),
accessKey
).getBytes(StandardCharsets.UTF_8);
@ -118,7 +112,8 @@ public class S3HttpFixtureWithSTS extends S3HttpFixture {
exchange.close();
return;
}
delegate.handle(exchange);
};
ExceptionsHelper.maybeDieOnAnotherThread(new AssertionError("not supported: " + requestMethod + " " + path));
}
}
}

View file

@ -0,0 +1,268 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the "Elastic License
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
* Public License v 1"; you may not use this file except in compliance with, at
* your election, the "Elastic License 2.0", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
package fixture.aws.sts;
import com.sun.net.httpserver.Headers;
import com.sun.net.httpserver.HttpContext;
import com.sun.net.httpserver.HttpExchange;
import com.sun.net.httpserver.HttpPrincipal;
import org.elasticsearch.common.bytes.BytesArray;
import org.elasticsearch.common.bytes.BytesReference;
import org.elasticsearch.common.io.stream.BytesStreamOutput;
import org.elasticsearch.rest.RestStatus;
import org.elasticsearch.test.ESTestCase;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.InetSocketAddress;
import java.net.URI;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.util.HashMap;
import java.util.Map;
import java.util.stream.Collectors;
import static org.hamcrest.Matchers.aMapWithSize;
import static org.hamcrest.Matchers.containsString;
public class AwsStsHttpHandlerTests extends ESTestCase {
public void testGenerateCredentials() {
final Map<String, String> generatedCredentials = new HashMap<>();
final var webIdentityToken = randomUnicodeOfLength(10);
final var handler = new AwsStsHttpHandler(generatedCredentials::put, webIdentityToken);
final var response = handleRequest(
handler,
Map.of(
"Action",
"AssumeRoleWithWebIdentity",
"RoleSessionName",
AwsStsHttpHandler.ROLE_NAME,
"RoleArn",
AwsStsHttpHandler.ROLE_ARN,
"WebIdentityToken",
webIdentityToken
)
);
assertEquals(RestStatus.OK, response.status());
assertThat(generatedCredentials, aMapWithSize(1));
final var accessKey = generatedCredentials.keySet().iterator().next();
final var sessionToken = generatedCredentials.values().iterator().next();
final var responseBody = response.body().utf8ToString();
assertThat(responseBody, containsString("<AccessKeyId>" + accessKey + "</AccessKeyId>"));
assertThat(responseBody, containsString("<SessionToken>" + sessionToken + "</SessionToken>"));
}
public void testInvalidAction() {
final var handler = new AwsStsHttpHandler((key, token) -> fail(), randomUnicodeOfLength(10));
final var response = handleRequest(handler, Map.of("Action", "Unsupported"));
assertEquals(RestStatus.BAD_REQUEST, response.status());
}
public void testInvalidRole() {
final var webIdentityToken = randomUnicodeOfLength(10);
final var handler = new AwsStsHttpHandler((key, token) -> fail(), webIdentityToken);
final var response = handleRequest(
handler,
Map.of(
"Action",
"AssumeRoleWithWebIdentity",
"RoleSessionName",
randomValueOtherThan(AwsStsHttpHandler.ROLE_NAME, ESTestCase::randomIdentifier),
"RoleArn",
AwsStsHttpHandler.ROLE_ARN,
"WebIdentityToken",
webIdentityToken
)
);
assertEquals(RestStatus.UNAUTHORIZED, response.status());
}
public void testInvalidToken() {
final var webIdentityToken = randomUnicodeOfLength(10);
final var handler = new AwsStsHttpHandler((key, token) -> fail(), webIdentityToken);
final var response = handleRequest(
handler,
Map.of(
"Action",
"AssumeRoleWithWebIdentity",
"RoleSessionName",
AwsStsHttpHandler.ROLE_NAME,
"RoleArn",
AwsStsHttpHandler.ROLE_ARN,
"WebIdentityToken",
randomValueOtherThan(webIdentityToken, () -> randomUnicodeOfLength(10))
)
);
assertEquals(RestStatus.UNAUTHORIZED, response.status());
}
public void testInvalidARN() {
final var webIdentityToken = randomUnicodeOfLength(10);
final var handler = new AwsStsHttpHandler((key, token) -> fail(), webIdentityToken);
final var response = handleRequest(
handler,
Map.of(
"Action",
"AssumeRoleWithWebIdentity",
"RoleSessionName",
AwsStsHttpHandler.ROLE_NAME,
"RoleArn",
randomValueOtherThan(AwsStsHttpHandler.ROLE_ARN, ESTestCase::randomIdentifier),
"WebIdentityToken",
webIdentityToken
)
);
assertEquals(RestStatus.UNAUTHORIZED, response.status());
}
private record TestHttpResponse(RestStatus status, BytesReference body) {}
private static TestHttpResponse handleRequest(AwsStsHttpHandler handler, Map<String, String> body) {
final var httpExchange = new TestHttpExchange(
"POST",
"/assume-role-with-web-identity/",
new BytesArray(
body.entrySet()
.stream()
.map(e -> e.getKey() + "=" + URLEncoder.encode(e.getValue(), StandardCharsets.UTF_8))
.collect(Collectors.joining("&"))
),
TestHttpExchange.EMPTY_HEADERS
);
try {
handler.handle(httpExchange);
} catch (IOException e) {
fail(e);
}
assertNotEquals(0, httpExchange.getResponseCode());
return new TestHttpResponse(RestStatus.fromCode(httpExchange.getResponseCode()), httpExchange.getResponseBodyContents());
}
private static class TestHttpExchange extends HttpExchange {
private static final Headers EMPTY_HEADERS = new Headers();
private final String method;
private final URI uri;
private final BytesReference requestBody;
private final Headers requestHeaders;
private final Headers responseHeaders = new Headers();
private final BytesStreamOutput responseBody = new BytesStreamOutput();
private int responseCode;
TestHttpExchange(String method, String uri, BytesReference requestBody, Headers requestHeaders) {
this.method = method;
this.uri = URI.create(uri);
this.requestBody = requestBody;
this.requestHeaders = requestHeaders;
}
@Override
public Headers getRequestHeaders() {
return requestHeaders;
}
@Override
public Headers getResponseHeaders() {
return responseHeaders;
}
@Override
public URI getRequestURI() {
return uri;
}
@Override
public String getRequestMethod() {
return method;
}
@Override
public HttpContext getHttpContext() {
return null;
}
@Override
public void close() {}
@Override
public InputStream getRequestBody() {
try {
return requestBody.streamInput();
} catch (IOException e) {
throw new AssertionError(e);
}
}
@Override
public OutputStream getResponseBody() {
return responseBody;
}
@Override
public void sendResponseHeaders(int rCode, long responseLength) {
this.responseCode = rCode;
}
@Override
public InetSocketAddress getRemoteAddress() {
return null;
}
@Override
public int getResponseCode() {
return responseCode;
}
public BytesReference getResponseBodyContents() {
return responseBody.bytes();
}
@Override
public InetSocketAddress getLocalAddress() {
return null;
}
@Override
public String getProtocol() {
return "HTTP/1.1";
}
@Override
public Object getAttribute(String name) {
return null;
}
@Override
public void setAttribute(String name, Object value) {
fail("setAttribute not implemented");
}
@Override
public void setStreams(InputStream i, OutputStream o) {
fail("setStreams not implemented");
}
@Override
public HttpPrincipal getPrincipal() {
fail("getPrincipal not implemented");
throw new UnsupportedOperationException("getPrincipal not implemented");
}
}
}

View file

@ -18,23 +18,22 @@ import java.net.InetSocketAddress;
import java.net.UnknownHostException;
import java.util.Objects;
import java.util.Set;
import java.util.function.BiConsumer;
public class Ec2ImdsHttpFixture extends ExternalResource {
private HttpServer server;
private final String accessKey;
private final String sessionToken;
private final BiConsumer<String, String> newCredentialsConsumer;
private final Set<String> alternativeCredentialsEndpoints;
public Ec2ImdsHttpFixture(String accessKey, String sessionToken, Set<String> alternativeCredentialsEndpoints) {
this.accessKey = accessKey;
this.sessionToken = sessionToken;
this.alternativeCredentialsEndpoints = alternativeCredentialsEndpoints;
public Ec2ImdsHttpFixture(BiConsumer<String, String> newCredentialsConsumer, Set<String> alternativeCredentialsEndpoints) {
this.newCredentialsConsumer = Objects.requireNonNull(newCredentialsConsumer);
this.alternativeCredentialsEndpoints = Objects.requireNonNull(alternativeCredentialsEndpoints);
}
protected HttpHandler createHandler() {
return new Ec2ImdsHttpHandler(accessKey, sessionToken, alternativeCredentialsEndpoints);
return new Ec2ImdsHttpHandler(newCredentialsConsumer, alternativeCredentialsEndpoints);
}
public String getAddress() {

View file

@ -25,6 +25,7 @@ import java.time.format.DateTimeFormatter;
import java.util.Collection;
import java.util.Objects;
import java.util.Set;
import java.util.function.BiConsumer;
import static org.elasticsearch.test.ESTestCase.randomIdentifier;
@ -36,13 +37,11 @@ public class Ec2ImdsHttpHandler implements HttpHandler {
private static final String IMDS_SECURITY_CREDENTIALS_PATH = "/latest/meta-data/iam/security-credentials/";
private final String accessKey;
private final String sessionToken;
private final BiConsumer<String, String> newCredentialsConsumer;
private final Set<String> validCredentialsEndpoints = ConcurrentCollections.newConcurrentSet();
public Ec2ImdsHttpHandler(String accessKey, String sessionToken, Collection<String> alternativeCredentialsEndpoints) {
this.accessKey = Objects.requireNonNull(accessKey);
this.sessionToken = Objects.requireNonNull(sessionToken);
public Ec2ImdsHttpHandler(BiConsumer<String, String> newCredentialsConsumer, Collection<String> alternativeCredentialsEndpoints) {
this.newCredentialsConsumer = Objects.requireNonNull(newCredentialsConsumer);
this.validCredentialsEndpoints.addAll(alternativeCredentialsEndpoints);
}
@ -70,6 +69,9 @@ public class Ec2ImdsHttpHandler implements HttpHandler {
exchange.getResponseBody().write(response);
return;
} else if (validCredentialsEndpoints.contains(path)) {
final String accessKey = randomIdentifier();
final String sessionToken = randomIdentifier();
newCredentialsConsumer.accept(accessKey, sessionToken);
final byte[] response = Strings.format(
"""
{

View file

@ -28,15 +28,18 @@ import java.io.InputStream;
import java.io.OutputStream;
import java.net.InetSocketAddress;
import java.net.URI;
import java.util.HashMap;
import java.util.Map;
import java.util.Set;
import static org.hamcrest.Matchers.aMapWithSize;
public class Ec2ImdsHttpHandlerTests extends ESTestCase {
public void testImdsV1() throws IOException {
final var accessKey = randomIdentifier();
final var sessionToken = randomIdentifier();
final Map<String, String> generatedCredentials = new HashMap<>();
final var handler = new Ec2ImdsHttpHandler(accessKey, sessionToken, Set.of());
final var handler = new Ec2ImdsHttpHandler(generatedCredentials::put, Set.of());
final var roleResponse = handleRequest(handler, "GET", "/latest/meta-data/iam/security-credentials/");
assertEquals(RestStatus.OK, roleResponse.status());
@ -46,6 +49,10 @@ public class Ec2ImdsHttpHandlerTests extends ESTestCase {
final var credentialsResponse = handleRequest(handler, "GET", "/latest/meta-data/iam/security-credentials/" + profileName);
assertEquals(RestStatus.OK, credentialsResponse.status());
assertThat(generatedCredentials, aMapWithSize(1));
final var accessKey = generatedCredentials.keySet().iterator().next();
final var sessionToken = generatedCredentials.values().iterator().next();
final var responseMap = XContentHelper.convertToMap(XContentType.JSON.xContent(), credentialsResponse.body().streamInput(), false);
assertEquals(Set.of("AccessKeyId", "Expiration", "RoleArn", "SecretAccessKey", "Token"), responseMap.keySet());
assertEquals(accessKey, responseMap.get("AccessKeyId"));
@ -55,7 +62,7 @@ public class Ec2ImdsHttpHandlerTests extends ESTestCase {
public void testImdsV2Disabled() {
assertEquals(
RestStatus.METHOD_NOT_ALLOWED,
handleRequest(new Ec2ImdsHttpHandler(randomIdentifier(), randomIdentifier(), Set.of()), "PUT", "/latest/api/token").status()
handleRequest(new Ec2ImdsHttpHandler((accessKey, sessionToken) -> fail(), Set.of()), "PUT", "/latest/api/token").status()
);
}

View file

@ -0,0 +1,39 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the "Elastic License
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
* Public License v 1"; you may not use this file except in compliance with, at
* your election, the "Elastic License 2.0", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
package fixture.s3;
import org.elasticsearch.common.util.concurrent.ConcurrentCollections;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
/**
* Allows dynamic creation of access-key/session-token credentials for accessing AWS services such as S3. Typically there's one service
* (e.g. IMDS or STS) which creates credentials dynamically and registers them here using {@link #addValidCredentials}, and then the
* {@link S3HttpFixture} uses {@link #isAuthorized} to validate the credentials it receives corresponds with some previously-generated
* credentials.
*/
public class DynamicS3Credentials {
private final Map<String, Set<String>> validCredentialsMap = ConcurrentCollections.newConcurrentMap();
public boolean isAuthorized(String authorizationHeader, String sessionTokenHeader) {
return authorizationHeader != null
&& sessionTokenHeader != null
&& validCredentialsMap.getOrDefault(sessionTokenHeader, Set.of()).stream().anyMatch(authorizationHeader::contains);
}
public void addValidCredentials(String accessKey, String sessionToken) {
validCredentialsMap.computeIfAbsent(
Objects.requireNonNull(sessionToken, "sessionToken"),
t -> ConcurrentCollections.newConcurrentSet()
).add(Objects.requireNonNull(accessKey, "accessKey"));
}
}

View file

@ -21,6 +21,8 @@ import java.net.InetAddress;
import java.net.InetSocketAddress;
import java.net.UnknownHostException;
import java.util.Objects;
import java.util.function.BiPredicate;
import java.util.function.Supplier;
public class S3HttpFixture extends ExternalResource {
@ -29,21 +31,21 @@ public class S3HttpFixture extends ExternalResource {
private final boolean enabled;
private final String bucket;
private final String basePath;
protected volatile String accessKey;
private final BiPredicate<String, String> authorizationPredicate;
public S3HttpFixture() {
this(true);
}
public S3HttpFixture(boolean enabled) {
this(enabled, "bucket", "base_path_integration_tests", "s3_test_access_key");
this(enabled, "bucket", "base_path_integration_tests", fixedAccessKey("s3_test_access_key"));
}
public S3HttpFixture(boolean enabled, String bucket, String basePath, String accessKey) {
public S3HttpFixture(boolean enabled, String bucket, String basePath, BiPredicate<String, String> authorizationPredicate) {
this.enabled = enabled;
this.bucket = bucket;
this.basePath = basePath;
this.accessKey = accessKey;
this.authorizationPredicate = authorizationPredicate;
}
protected HttpHandler createHandler() {
@ -51,9 +53,11 @@ public class S3HttpFixture extends ExternalResource {
@Override
public void handle(final HttpExchange exchange) throws IOException {
try {
final String authorization = exchange.getRequestHeaders().getFirst("Authorization");
if (authorization == null || authorization.contains(accessKey) == false) {
sendError(exchange, RestStatus.FORBIDDEN, "AccessDenied", "Bad access key");
if (authorizationPredicate.test(
exchange.getRequestHeaders().getFirst("Authorization"),
exchange.getRequestHeaders().getFirst("x-amz-security-token")
) == false) {
sendError(exchange, RestStatus.FORBIDDEN, "AccessDenied", "Access denied by " + authorizationPredicate);
return;
}
super.handle(exchange);
@ -76,7 +80,7 @@ public class S3HttpFixture extends ExternalResource {
protected void before() throws Throwable {
if (enabled) {
InetSocketAddress inetSocketAddress = resolveAddress("localhost", 0);
InetSocketAddress inetSocketAddress = resolveAddress();
this.server = HttpServer.create(inetSocketAddress, 0);
HttpHandler handler = createHandler();
this.server.createContext("/", Objects.requireNonNull(handler));
@ -91,15 +95,27 @@ public class S3HttpFixture extends ExternalResource {
}
}
private static InetSocketAddress resolveAddress(String address, int port) {
private static InetSocketAddress resolveAddress() {
try {
return new InetSocketAddress(InetAddress.getByName(address), port);
return new InetSocketAddress(InetAddress.getByName("localhost"), 0);
} catch (UnknownHostException e) {
throw new RuntimeException(e);
}
}
public void setAccessKey(String accessKey) {
this.accessKey = accessKey;
public static BiPredicate<String, String> fixedAccessKey(String accessKey) {
return mutableAccessKey(() -> accessKey);
}
public static BiPredicate<String, String> mutableAccessKey(Supplier<String> accessKeySupplier) {
return (authorizationHeader, sessionTokenHeader) -> authorizationHeader != null
&& authorizationHeader.contains(accessKeySupplier.get());
}
public static BiPredicate<String, String> fixedAccessKeyAndToken(String accessKey, String sessionToken) {
Objects.requireNonNull(sessionToken);
final var accessKeyPredicate = fixedAccessKey(accessKey);
return (authorizationHeader, sessionTokenHeader) -> accessKeyPredicate.test(authorizationHeader, sessionTokenHeader)
&& sessionToken.equals(sessionTokenHeader);
}
}

View file

@ -1,42 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the "Elastic License
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
* Public License v 1"; you may not use this file except in compliance with, at
* your election, the "Elastic License 2.0", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
package fixture.s3;
import com.sun.net.httpserver.HttpHandler;
import org.elasticsearch.rest.RestStatus;
import static fixture.s3.S3HttpHandler.sendError;
public class S3HttpFixtureWithSessionToken extends S3HttpFixture {
protected final String sessionToken;
public S3HttpFixtureWithSessionToken(String bucket, String basePath, String accessKey, String sessionToken) {
super(true, bucket, basePath, accessKey);
this.sessionToken = sessionToken;
}
@Override
protected HttpHandler createHandler() {
final HttpHandler delegate = super.createHandler();
return exchange -> {
final String securityToken = exchange.getRequestHeaders().getFirst("x-amz-security-token");
if (securityToken == null) {
sendError(exchange, RestStatus.FORBIDDEN, "AccessDenied", "No session token");
return;
}
if (securityToken.equals(sessionToken) == false) {
sendError(exchange, RestStatus.FORBIDDEN, "AccessDenied", "Bad session token");
return;
}
delegate.handle(exchange);
};
}
}

View file

@ -44,7 +44,14 @@ public class S3SearchableSnapshotsCredentialsReloadIT extends ESRestTestCase {
private static final String BUCKET = "S3SearchableSnapshotsCredentialsReloadIT-bucket";
private static final String BASE_PATH = "S3SearchableSnapshotsCredentialsReloadIT-base-path";
public static final S3HttpFixture s3Fixture = new S3HttpFixture(true, BUCKET, BASE_PATH, "ignored");
private static volatile String repositoryAccessKey;
public static final S3HttpFixture s3Fixture = new S3HttpFixture(
true,
BUCKET,
BASE_PATH,
S3HttpFixture.mutableAccessKey(() -> repositoryAccessKey)
);
private static final MutableSettingsProvider keystoreSettings = new MutableSettingsProvider();
@ -78,7 +85,7 @@ public class S3SearchableSnapshotsCredentialsReloadIT extends ESRestTestCase {
// Set up initial credentials
final String accessKey1 = randomIdentifier();
s3Fixture.setAccessKey(accessKey1);
repositoryAccessKey = accessKey1;
keystoreSettings.put("s3.client.default.access_key", accessKey1);
keystoreSettings.put("s3.client.default.secret_key", randomIdentifier());
cluster.updateStoredSecureSettings();
@ -92,7 +99,7 @@ public class S3SearchableSnapshotsCredentialsReloadIT extends ESRestTestCase {
// Rotate credentials in blob store
logger.info("--> rotate credentials");
final String accessKey2 = randomValueOtherThan(accessKey1, ESTestCase::randomIdentifier);
s3Fixture.setAccessKey(accessKey2);
repositoryAccessKey = accessKey2;
// Ensure searchable snapshot now does not work due to invalid credentials
logger.info("--> expect failure");
@ -118,7 +125,7 @@ public class S3SearchableSnapshotsCredentialsReloadIT extends ESRestTestCase {
final String accessKey2 = randomValueOtherThan(accessKey1, ESTestCase::randomIdentifier);
final String alternativeClient = randomValueOtherThan("default", ESTestCase::randomIdentifier);
s3Fixture.setAccessKey(accessKey1);
repositoryAccessKey = accessKey1;
keystoreSettings.put("s3.client.default.access_key", accessKey1);
keystoreSettings.put("s3.client.default.secret_key", randomIdentifier());
keystoreSettings.put("s3.client." + alternativeClient + ".access_key", accessKey2);
@ -133,7 +140,7 @@ public class S3SearchableSnapshotsCredentialsReloadIT extends ESRestTestCase {
// Rotate credentials in blob store
logger.info("--> rotate credentials");
s3Fixture.setAccessKey(accessKey2);
repositoryAccessKey = accessKey2;
// Ensure searchable snapshot now does not work due to invalid credentials
logger.info("--> expect failure");
@ -157,7 +164,7 @@ public class S3SearchableSnapshotsCredentialsReloadIT extends ESRestTestCase {
final String accessKey2 = randomValueOtherThan(accessKey1, ESTestCase::randomIdentifier);
testHarness.putRepository(b -> b.put("access_key", accessKey1).put("secret_key", randomIdentifier()));
s3Fixture.setAccessKey(accessKey1);
repositoryAccessKey = accessKey1;
testHarness.createFrozenSearchableSnapshotIndex();
@ -166,7 +173,7 @@ public class S3SearchableSnapshotsCredentialsReloadIT extends ESRestTestCase {
// Rotate credentials in blob store
logger.info("--> rotate credentials");
s3Fixture.setAccessKey(accessKey2);
repositoryAccessKey = accessKey2;
// Ensure searchable snapshot now does not work due to invalid credentials
logger.info("--> expect failure");
@ -269,7 +276,7 @@ public class S3SearchableSnapshotsCredentialsReloadIT extends ESRestTestCase {
assertThat(
expectThrows(ResponseException.class, () -> client().performRequest(searchRequest)).getMessage(),
allOf(
containsString("Bad access key"),
containsString("Access denied"),
containsString("Status Code: 403"),
containsString("Error Code: AccessDenied"),
containsString("failed to read data from cache")