/* * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. * * Licensed under the Apache License, Version 2.0 (the "License"). * You may not use this file except in compliance with the License. * A copy of the License is located at * * http://aws.amazon.com/apache2.0 * * or in the "license" file accompanying this file. This file 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 software.amazon.awssdk.services.s3control; import static org.assertj.core.api.Assertions.assertThat; import static org.junit.Assert.assertEquals; import static software.amazon.awssdk.utils.FunctionalUtils.invokeSafely; import static software.amazon.awssdk.utils.StringUtils.isEmpty; import java.io.IOException; import java.nio.charset.StandardCharsets; import java.time.Duration; import java.util.Collections; import java.util.List; import java.util.stream.Stream; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.Arguments; import org.junit.jupiter.params.provider.MethodSource; import software.amazon.awssdk.auth.signer.AwsSignerExecutionAttribute; import software.amazon.awssdk.auth.signer.internal.SignerConstant; import software.amazon.awssdk.awscore.presigner.PresignedRequest; import software.amazon.awssdk.core.SdkPlugin; import software.amazon.awssdk.core.interceptor.Context; import software.amazon.awssdk.core.interceptor.ExecutionAttributes; import software.amazon.awssdk.core.interceptor.ExecutionInterceptor; import software.amazon.awssdk.core.retry.backoff.FixedDelayBackoffStrategy; import software.amazon.awssdk.core.sync.RequestBody; import software.amazon.awssdk.core.waiters.Waiter; import software.amazon.awssdk.core.waiters.WaiterAcceptor; import software.amazon.awssdk.http.HttpExecuteRequest; import software.amazon.awssdk.http.HttpExecuteResponse; import software.amazon.awssdk.http.SdkHttpRequest; import software.amazon.awssdk.http.apache.ApacheHttpClient; import software.amazon.awssdk.regions.Region; import software.amazon.awssdk.services.s3.S3Client; import software.amazon.awssdk.services.s3.S3Configuration; import software.amazon.awssdk.services.s3.internal.plugins.S3OverrideAuthSchemePropertiesPlugin; import software.amazon.awssdk.services.s3.model.BucketAlreadyOwnedByYouException; import software.amazon.awssdk.services.s3.model.NoSuchKeyException; import software.amazon.awssdk.services.s3.presigner.S3Presigner; import software.amazon.awssdk.services.s3.presigner.model.PresignedGetObjectRequest; import software.amazon.awssdk.services.s3control.model.BucketAlreadyExistsException; import software.amazon.awssdk.services.s3control.model.CreateMultiRegionAccessPointInput; import software.amazon.awssdk.services.s3control.model.GetMultiRegionAccessPointResponse; import software.amazon.awssdk.services.s3control.model.ListMultiRegionAccessPointsResponse; import software.amazon.awssdk.services.s3control.model.MultiRegionAccessPointStatus; import software.amazon.awssdk.services.sts.StsClient; import software.amazon.awssdk.utils.IoUtils; import software.amazon.awssdk.utils.Logger; import software.amazon.awssdk.utils.StringInputStream; public class S3MrapIntegrationTest extends S3ControlIntegrationTestBase { private static final Logger log = Logger.loggerFor(S3MrapIntegrationTest.class); private static final String SIGV4A_CHUNKED_PAYLOAD_SIGNING = "STREAMING-AWS4-ECDSA-P256-SHA256-PAYLOAD"; private static final String SIGV4_CHUNKED_PAYLOAD_SIGNING = "STREAMING-AWS4-HMAC-SHA256-PAYLOAD"; private static final String UNSIGNED_PAYLOAD = "UNSIGNED-PAYLOAD"; private static final Region REGION = Region.US_WEST_2; private static String bucket; private static String mrapName; private static final String KEY = "aws-java-sdk-small-test-object"; private static final String CONTENT = "A short string for a small test object"; private static final int RETRY_TIMES = 10; private static final int RETRY_DELAY_IN_SECONDS = 30; private static S3ControlClient s3control; private static CaptureRequestInterceptor captureInterceptor; private static String mrapAlias; private static StsClient stsClient; private static S3Client s3Client; private static S3Client s3ClientWithPayloadSigning; @BeforeAll public static void setupFixture() { captureInterceptor = new CaptureRequestInterceptor(); s3control = S3ControlClient.builder() .region(REGION) .credentialsProvider(CREDENTIALS_PROVIDER_CHAIN) .build(); s3Client = mrapEnabledS3Client(Collections.singletonList(captureInterceptor)); s3ClientWithPayloadSigning = mrapEnabledS3ClientWithPayloadSigning(captureInterceptor); stsClient = StsClient.builder() .credentialsProvider(CREDENTIALS_PROVIDER_CHAIN) .region(REGION) .build(); accountId = stsClient.getCallerIdentity().account(); bucket = "do-not-delete-s3mraptest-" + accountId; mrapName = "javaintegtest" + accountId; log.info(() -> "bucket " + bucket); createBucketIfNotExist(bucket); createMrapIfNotExist(accountId, mrapName); mrapAlias = getMrapAliasAndVerify(accountId, mrapName); } @ParameterizedTest(name = "{index}:key = {1}, {0}") @MethodSource("keys") public void when_callingMrapWithDifferentPaths_unsignedPayload_requestIsAccepted(String name, String key, String expected) { putGetDeleteObjectMrap(s3Client, UNSIGNED_PAYLOAD, key, expected); } @ParameterizedTest(name = "{index}:key = {1}, {0}") @MethodSource("keys") public void when_callingMrapWithDifferentPaths_signedPayload_requestIsAccepted(String name, String key, String expected) { putGetDeleteObjectMrap(s3ClientWithPayloadSigning, SIGV4A_CHUNKED_PAYLOAD_SIGNING, key, expected); } @ParameterizedTest(name = "{index}:key = {1}, {0}") @MethodSource("keys") public void when_callingS3WithDifferentPaths_unsignedPayload_requestIsAccepted(String name, String key, String expected) { putGetDeleteObjectStandard(s3Client, UNSIGNED_PAYLOAD, key, expected); } @ParameterizedTest(name = "{index}:key = {1}, {0}") @MethodSource("keys") public void when_callingS3WithDifferentPaths_signedPayload_requestIsAccepted(String name, String key, String expected) { putGetDeleteObjectStandard(s3ClientWithPayloadSigning, SIGV4_CHUNKED_PAYLOAD_SIGNING, key, expected); } @Test public void when_creatingPresignedMrapUrl_getRequestWorks() { S3Presigner presigner = s3Presigner(); PresignedGetObjectRequest presignedGetObjectRequest = presigner.presignGetObject(p -> p.getObjectRequest(r -> r.bucket(constructMrapArn(accountId, mrapAlias)).key(KEY)) .signatureDuration(Duration.ofMinutes(10))); deleteObjectIfExists(s3Client, constructMrapArn(accountId, mrapAlias), KEY); s3Client.putObject(r -> r.bucket(constructMrapArn(accountId, mrapAlias)).key(KEY), RequestBody.fromString(CONTENT)); String object = applyPresignedUrl(presignedGetObjectRequest, null); assertEquals(CONTENT, object); verifySigv4aRequest(captureInterceptor.request(), UNSIGNED_PAYLOAD); } public void putGetDeleteObjectMrap(S3Client testClient, String payloadSigningTag, String key, String expected) { deleteObjectIfExists(testClient, constructMrapArn(accountId, mrapAlias), key); testClient.putObject(r -> r.bucket(constructMrapArn(accountId, mrapAlias)).key(key), RequestBody.fromString(CONTENT)); verifySigv4aRequest(captureInterceptor.request(), payloadSigningTag); String object = testClient.getObjectAsBytes(r -> r.bucket(constructMrapArn(accountId, mrapAlias)).key(key)).asString(StandardCharsets.UTF_8); assertEquals(CONTENT, object); verifySigv4aRequest(captureInterceptor.request(), UNSIGNED_PAYLOAD); testClient.deleteObject(r -> r.bucket(constructMrapArn(accountId, mrapAlias)).key(key)); verifySigv4aRequest(captureInterceptor.request(), UNSIGNED_PAYLOAD); assertThat(captureInterceptor.normalizePath).isNotNull().isEqualTo(false); assertThat(captureInterceptor.request.encodedPath()).isEqualTo(expected); } public void putGetDeleteObjectStandard(S3Client testClient, String payloadSigningTag, String key, String expected) { deleteObjectIfExists(testClient, bucket, key); testClient.putObject(r -> r.bucket(bucket).key(key), RequestBody.fromString(CONTENT)); verifySigv4Request(captureInterceptor.request(), payloadSigningTag); String object = testClient.getObjectAsBytes(r -> r.bucket(bucket).key(key)).asString(StandardCharsets.UTF_8); assertEquals(CONTENT, object); verifySigv4Request(captureInterceptor.request(), UNSIGNED_PAYLOAD); testClient.deleteObject(r -> r.bucket(bucket).key(key)); verifySigv4Request(captureInterceptor.request(), UNSIGNED_PAYLOAD); assertThat(captureInterceptor.normalizePath).isNotNull().isEqualTo(false); assertThat(captureInterceptor.request.encodedPath()).isEqualTo(expected); } private void verifySigv4aRequest(SdkHttpRequest signedRequest, String payloadSigningTag) { assertThat(signedRequest.headers().get("Authorization").get(0)).contains("AWS4-ECDSA-P256-SHA256"); assertThat(signedRequest.headers().get("Host").get(0)).isEqualTo(constructMrapHostname(mrapAlias)); assertThat(signedRequest.headers().get("x-amz-content-sha256").get(0)).isEqualTo(payloadSigningTag); assertThat(signedRequest.headers().get("X-Amz-Date").get(0)).isNotEmpty(); assertThat(signedRequest.headers().get("X-Amz-Region-Set").get(0)).isEqualTo("*"); } private void verifySigv4Request(SdkHttpRequest signedRequest, String payloadSigningTag) { assertThat(signedRequest.headers().get("Authorization").get(0)).contains(SignerConstant.AWS4_SIGNING_ALGORITHM); assertThat(signedRequest.headers().get("Host").get(0)).isEqualTo(String.format("%s.s3.%s.amazonaws.com", bucket, REGION.id())); assertThat(signedRequest.headers().get("x-amz-content-sha256").get(0)).isEqualTo(payloadSigningTag); assertThat(signedRequest.headers().get("X-Amz-Date").get(0)).isNotEmpty(); } private static Stream keys() { return Stream.of( Arguments.of("Slash -> unchanged", "/", "//"), Arguments.of("Single segment with initial slash -> unchanged", "/foo", "//foo"), Arguments.of("Single segment no slash -> slash prepended", "foo", "/foo"), Arguments.of("Multiple segments -> unchanged", "/foo/bar", "//foo/bar"), Arguments.of("Multiple segments with trailing slash -> unchanged", "/foo/bar/", "//foo/bar/"), Arguments.of("Multiple segments, urlEncoded slash -> encodes percent", "/foo%2Fbar", "//foo%252Fbar"), Arguments.of("Single segment, dot -> should remove dot", "/.", "//."), Arguments.of("Multiple segments with dot -> should remove dot", "/foo/./bar", "//foo/./bar"), Arguments.of("Multiple segments with ending dot -> should remove dot and trailing slash", "/foo/bar/.", "//foo/bar/."), Arguments.of("Multiple segments with dots -> should remove dots and preceding segment", "/foo/bar/../baz", "//foo/bar/../baz"), Arguments.of("First segment has colon -> unchanged, url encoded first", "foo:/bar", "/foo%3A/bar"), Arguments.of("Multiple segments, urlEncoded slash -> encodes percent", "/foo%2F.%2Fbar", "//foo%252F.%252Fbar"), Arguments.of("No url encode, Multiple segments with dot -> unchanged", "/foo/./bar", "//foo/./bar"), Arguments.of("Multiple segments with dots -> unchanged", "/foo/bar/../baz", "//foo/bar/../baz"), Arguments.of("double slash", "//H", "///H"), Arguments.of("double slash in middle", "A//H", "/A//H") ); } private String constructMrapArn(String account, String mrapAlias) { return String.format("arn:aws:s3::%s:accesspoint:%s", account, mrapAlias); } private String constructMrapHostname(String mrapAlias) { return String.format("%s.accesspoint.s3-global.amazonaws.com", mrapAlias); } private S3Presigner s3Presigner() { return S3Presigner.builder() .region(REGION) .serviceConfiguration(S3Configuration.builder() .useArnRegionEnabled(true) .checksumValidationEnabled(false) .build()) .credentialsProvider(CREDENTIALS_PROVIDER_CHAIN) .build(); } private static void createMrapIfNotExist(String account, String mrapName) { software.amazon.awssdk.services.s3control.model.Region mrapRegion = software.amazon.awssdk.services.s3control.model.Region.builder().bucket(bucket).build(); boolean mrapNotExists = s3control.listMultiRegionAccessPoints(r -> r.accountId(account)) .accessPoints().stream() .noneMatch(a -> a.name().equals(S3MrapIntegrationTest.mrapName)); if (mrapNotExists) { CreateMultiRegionAccessPointInput details = CreateMultiRegionAccessPointInput.builder() .name(mrapName) .regions(mrapRegion) .build(); log.info(() -> "Creating MRAP: " + mrapName); s3control.createMultiRegionAccessPoint(r -> r.accountId(account).details(details)); waitForResourceCreation(mrapName); } } private static void waitForResourceCreation(String mrapName) throws IllegalStateException { Waiter waiter = Waiter.builder(ListMultiRegionAccessPointsResponse.class) .addAcceptor(WaiterAcceptor.successOnResponseAcceptor(r -> r.accessPoints().stream().findFirst().filter(mrap -> mrap.name().equals(mrapName) && mrap.status().equals(MultiRegionAccessPointStatus.READY)).isPresent() )) .addAcceptor(WaiterAcceptor.retryOnResponseAcceptor(i -> true)) .overrideConfiguration(b -> b.maxAttempts(RETRY_TIMES).backoffStrategy(FixedDelayBackoffStrategy.create(Duration.ofSeconds(RETRY_DELAY_IN_SECONDS)))) .build(); waiter.run(() -> s3control.listMultiRegionAccessPoints(r -> r.accountId(accountId))); } public static String getMrapAliasAndVerify(String account, String mrapName) { GetMultiRegionAccessPointResponse mrap = s3control.getMultiRegionAccessPoint(r -> r.accountId(account).name(mrapName)); assertThat(mrap.accessPoint()).isNotNull(); assertThat(mrap.accessPoint().name()).isEqualTo(mrapName); log.info(() -> "Alias: " + mrap.accessPoint().alias()); return mrap.accessPoint().alias(); } private String applyPresignedUrl(PresignedRequest presignedRequest, String content) { try { HttpExecuteRequest.Builder builder = HttpExecuteRequest.builder().request(presignedRequest.httpRequest()); if (!isEmpty(content)) { builder.contentStreamProvider(() -> new StringInputStream(content)); } HttpExecuteRequest request = builder.build(); HttpExecuteResponse response = ApacheHttpClient.create().prepareRequest(request).call(); return response.responseBody() .map(stream -> invokeSafely(() -> IoUtils.toUtf8String(stream))) .orElseThrow(() -> new IOException("No input stream")); } catch (IOException e) { log.error(() -> "Error occurred ", e); } return null; } private static S3Client mrapEnabledS3Client(List executionInterceptors) { return S3Client.builder() .region(REGION) .credentialsProvider(CREDENTIALS_PROVIDER_CHAIN) .serviceConfiguration(S3Configuration.builder() .useArnRegionEnabled(true) .build()) .overrideConfiguration(o -> o.executionInterceptors(executionInterceptors)) .build(); } private static S3Client mrapEnabledS3ClientWithPayloadSigning(ExecutionInterceptor executionInterceptor) { // We can't use here `S3OverrideAuthSchemePropertiesPlugin.enablePayloadSigningPlugin()` since // it enables payload signing for *all* operations. SdkPlugin plugin = S3OverrideAuthSchemePropertiesPlugin.builder() .payloadSigningEnabled(true) .addOperationConstraint("UploadPart") .addOperationConstraint("PutObject") .build(); return S3Client.builder() .region(REGION) .credentialsProvider(CREDENTIALS_PROVIDER_CHAIN) .serviceConfiguration(S3Configuration.builder() .useArnRegionEnabled(true) .build()) .overrideConfiguration(o -> o.addExecutionInterceptor(executionInterceptor)) .addPlugin(plugin) .build(); } private void deleteObjectIfExists(S3Client s31, String bucket1, String key) { System.out.println(bucket1); try { s31.deleteObject(r -> r.bucket(bucket1).key(key)); } catch (NoSuchKeyException e) { } } private static void createBucketIfNotExist(String bucket) { try { s3Client.createBucket(b -> b.bucket(bucket)); s3Client.waiter().waitUntilBucketExists(b -> b.bucket(bucket)); } catch (BucketAlreadyOwnedByYouException | BucketAlreadyExistsException e) { // ignore } } private static class CaptureRequestInterceptor implements ExecutionInterceptor { private SdkHttpRequest request; private Boolean normalizePath; public SdkHttpRequest request() { return request; } @Override public void beforeTransmission(Context.BeforeTransmission context, ExecutionAttributes executionAttributes) { this.request = context.httpRequest(); this.normalizePath = executionAttributes.getAttribute(AwsSignerExecutionAttribute.SIGNER_NORMALIZE_PATH); } } }