1 /* 2 * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 3 * 4 * Licensed under the Apache License, Version 2.0 (the "License"). 5 * You may not use this file except in compliance with the License. 6 * A copy of the License is located at 7 * 8 * http://aws.amazon.com/apache2.0 9 * 10 * or in the "license" file accompanying this file. This file is distributed 11 * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either 12 * express or implied. See the License for the specific language governing 13 * permissions and limitations under the License. 14 */ 15 package software.amazon.awssdk.services.s3control; 16 17 import static org.assertj.core.api.Assertions.assertThat; 18 import static org.junit.Assert.assertEquals; 19 import static software.amazon.awssdk.utils.FunctionalUtils.invokeSafely; 20 import static software.amazon.awssdk.utils.StringUtils.isEmpty; 21 22 import java.io.IOException; 23 import java.nio.charset.StandardCharsets; 24 import java.time.Duration; 25 import java.util.Collections; 26 import java.util.List; 27 import java.util.stream.Stream; 28 import org.junit.jupiter.api.BeforeAll; 29 import org.junit.jupiter.api.Test; 30 import org.junit.jupiter.params.ParameterizedTest; 31 import org.junit.jupiter.params.provider.Arguments; 32 import org.junit.jupiter.params.provider.MethodSource; 33 import software.amazon.awssdk.auth.signer.AwsSignerExecutionAttribute; 34 import software.amazon.awssdk.auth.signer.internal.SignerConstant; 35 import software.amazon.awssdk.awscore.presigner.PresignedRequest; 36 import software.amazon.awssdk.core.SdkPlugin; 37 import software.amazon.awssdk.core.interceptor.Context; 38 import software.amazon.awssdk.core.interceptor.ExecutionAttributes; 39 import software.amazon.awssdk.core.interceptor.ExecutionInterceptor; 40 import software.amazon.awssdk.core.retry.backoff.FixedDelayBackoffStrategy; 41 import software.amazon.awssdk.core.sync.RequestBody; 42 import software.amazon.awssdk.core.waiters.Waiter; 43 import software.amazon.awssdk.core.waiters.WaiterAcceptor; 44 import software.amazon.awssdk.http.HttpExecuteRequest; 45 import software.amazon.awssdk.http.HttpExecuteResponse; 46 import software.amazon.awssdk.http.SdkHttpRequest; 47 import software.amazon.awssdk.http.apache.ApacheHttpClient; 48 import software.amazon.awssdk.regions.Region; 49 import software.amazon.awssdk.services.s3.S3Client; 50 import software.amazon.awssdk.services.s3.S3Configuration; 51 import software.amazon.awssdk.services.s3.internal.plugins.S3OverrideAuthSchemePropertiesPlugin; 52 import software.amazon.awssdk.services.s3.model.BucketAlreadyOwnedByYouException; 53 import software.amazon.awssdk.services.s3.model.NoSuchKeyException; 54 import software.amazon.awssdk.services.s3.presigner.S3Presigner; 55 import software.amazon.awssdk.services.s3.presigner.model.PresignedGetObjectRequest; 56 import software.amazon.awssdk.services.s3control.model.BucketAlreadyExistsException; 57 import software.amazon.awssdk.services.s3control.model.CreateMultiRegionAccessPointInput; 58 import software.amazon.awssdk.services.s3control.model.GetMultiRegionAccessPointResponse; 59 import software.amazon.awssdk.services.s3control.model.ListMultiRegionAccessPointsResponse; 60 import software.amazon.awssdk.services.s3control.model.MultiRegionAccessPointStatus; 61 import software.amazon.awssdk.services.sts.StsClient; 62 import software.amazon.awssdk.utils.IoUtils; 63 import software.amazon.awssdk.utils.Logger; 64 import software.amazon.awssdk.utils.StringInputStream; 65 66 public class S3MrapIntegrationTest extends S3ControlIntegrationTestBase { 67 private static final Logger log = Logger.loggerFor(S3MrapIntegrationTest.class); 68 69 private static final String SIGV4A_CHUNKED_PAYLOAD_SIGNING = "STREAMING-AWS4-ECDSA-P256-SHA256-PAYLOAD"; 70 private static final String SIGV4_CHUNKED_PAYLOAD_SIGNING = "STREAMING-AWS4-HMAC-SHA256-PAYLOAD"; 71 private static final String UNSIGNED_PAYLOAD = "UNSIGNED-PAYLOAD"; 72 private static final Region REGION = Region.US_WEST_2; 73 private static String bucket; 74 private static String mrapName; 75 private static final String KEY = "aws-java-sdk-small-test-object"; 76 private static final String CONTENT = "A short string for a small test object"; 77 private static final int RETRY_TIMES = 10; 78 private static final int RETRY_DELAY_IN_SECONDS = 30; 79 80 private static S3ControlClient s3control; 81 private static CaptureRequestInterceptor captureInterceptor; 82 private static String mrapAlias; 83 private static StsClient stsClient; 84 private static S3Client s3Client; 85 private static S3Client s3ClientWithPayloadSigning; 86 87 @BeforeAll setupFixture()88 public static void setupFixture() { 89 captureInterceptor = new CaptureRequestInterceptor(); 90 91 s3control = S3ControlClient.builder() 92 .region(REGION) 93 .credentialsProvider(CREDENTIALS_PROVIDER_CHAIN) 94 .build(); 95 96 s3Client = mrapEnabledS3Client(Collections.singletonList(captureInterceptor)); 97 s3ClientWithPayloadSigning = mrapEnabledS3ClientWithPayloadSigning(captureInterceptor); 98 99 stsClient = StsClient.builder() 100 .credentialsProvider(CREDENTIALS_PROVIDER_CHAIN) 101 .region(REGION) 102 .build(); 103 accountId = stsClient.getCallerIdentity().account(); 104 bucket = "do-not-delete-s3mraptest-" + accountId; 105 mrapName = "javaintegtest" + accountId; 106 log.info(() -> "bucket " + bucket); 107 108 createBucketIfNotExist(bucket); 109 createMrapIfNotExist(accountId, mrapName); 110 mrapAlias = getMrapAliasAndVerify(accountId, mrapName); 111 } 112 113 @ParameterizedTest(name = "{index}:key = {1}, {0}") 114 @MethodSource("keys") when_callingMrapWithDifferentPaths_unsignedPayload_requestIsAccepted(String name, String key, String expected)115 public void when_callingMrapWithDifferentPaths_unsignedPayload_requestIsAccepted(String name, String key, String expected) { 116 putGetDeleteObjectMrap(s3Client, UNSIGNED_PAYLOAD, key, expected); 117 } 118 119 @ParameterizedTest(name = "{index}:key = {1}, {0}") 120 @MethodSource("keys") when_callingMrapWithDifferentPaths_signedPayload_requestIsAccepted(String name, String key, String expected)121 public void when_callingMrapWithDifferentPaths_signedPayload_requestIsAccepted(String name, String key, String expected) { 122 putGetDeleteObjectMrap(s3ClientWithPayloadSigning, SIGV4A_CHUNKED_PAYLOAD_SIGNING, key, expected); 123 } 124 125 @ParameterizedTest(name = "{index}:key = {1}, {0}") 126 @MethodSource("keys") when_callingS3WithDifferentPaths_unsignedPayload_requestIsAccepted(String name, String key, String expected)127 public void when_callingS3WithDifferentPaths_unsignedPayload_requestIsAccepted(String name, String key, String expected) { 128 putGetDeleteObjectStandard(s3Client, UNSIGNED_PAYLOAD, key, expected); 129 } 130 131 @ParameterizedTest(name = "{index}:key = {1}, {0}") 132 @MethodSource("keys") when_callingS3WithDifferentPaths_signedPayload_requestIsAccepted(String name, String key, String expected)133 public void when_callingS3WithDifferentPaths_signedPayload_requestIsAccepted(String name, String key, String expected) { 134 putGetDeleteObjectStandard(s3ClientWithPayloadSigning, SIGV4_CHUNKED_PAYLOAD_SIGNING, key, expected); 135 } 136 137 @Test when_creatingPresignedMrapUrl_getRequestWorks()138 public void when_creatingPresignedMrapUrl_getRequestWorks() { 139 S3Presigner presigner = s3Presigner(); 140 PresignedGetObjectRequest presignedGetObjectRequest = 141 presigner.presignGetObject(p -> p.getObjectRequest(r -> r.bucket(constructMrapArn(accountId, mrapAlias)).key(KEY)) 142 .signatureDuration(Duration.ofMinutes(10))); 143 144 deleteObjectIfExists(s3Client, constructMrapArn(accountId, mrapAlias), KEY); 145 s3Client.putObject(r -> r.bucket(constructMrapArn(accountId, mrapAlias)).key(KEY), RequestBody.fromString(CONTENT)); 146 147 String object = applyPresignedUrl(presignedGetObjectRequest, null); 148 assertEquals(CONTENT, object); 149 verifySigv4aRequest(captureInterceptor.request(), UNSIGNED_PAYLOAD); 150 } 151 putGetDeleteObjectMrap(S3Client testClient, String payloadSigningTag, String key, String expected)152 public void putGetDeleteObjectMrap(S3Client testClient, String payloadSigningTag, String key, String expected) { 153 deleteObjectIfExists(testClient, constructMrapArn(accountId, mrapAlias), key); 154 testClient.putObject(r -> r.bucket(constructMrapArn(accountId, mrapAlias)).key(key), RequestBody.fromString(CONTENT)); 155 verifySigv4aRequest(captureInterceptor.request(), payloadSigningTag); 156 157 String object = testClient.getObjectAsBytes(r -> r.bucket(constructMrapArn(accountId, mrapAlias)).key(key)).asString(StandardCharsets.UTF_8); 158 assertEquals(CONTENT, object); 159 verifySigv4aRequest(captureInterceptor.request(), UNSIGNED_PAYLOAD); 160 161 testClient.deleteObject(r -> r.bucket(constructMrapArn(accountId, mrapAlias)).key(key)); 162 verifySigv4aRequest(captureInterceptor.request(), UNSIGNED_PAYLOAD); 163 164 assertThat(captureInterceptor.normalizePath).isNotNull().isEqualTo(false); 165 assertThat(captureInterceptor.request.encodedPath()).isEqualTo(expected); 166 } 167 putGetDeleteObjectStandard(S3Client testClient, String payloadSigningTag, String key, String expected)168 public void putGetDeleteObjectStandard(S3Client testClient, String payloadSigningTag, String key, String expected) { 169 deleteObjectIfExists(testClient, bucket, key); 170 testClient.putObject(r -> r.bucket(bucket).key(key), RequestBody.fromString(CONTENT)); 171 verifySigv4Request(captureInterceptor.request(), payloadSigningTag); 172 173 String object = testClient.getObjectAsBytes(r -> r.bucket(bucket).key(key)).asString(StandardCharsets.UTF_8); 174 assertEquals(CONTENT, object); 175 verifySigv4Request(captureInterceptor.request(), UNSIGNED_PAYLOAD); 176 177 testClient.deleteObject(r -> r.bucket(bucket).key(key)); 178 verifySigv4Request(captureInterceptor.request(), UNSIGNED_PAYLOAD); 179 180 assertThat(captureInterceptor.normalizePath).isNotNull().isEqualTo(false); 181 assertThat(captureInterceptor.request.encodedPath()).isEqualTo(expected); 182 } 183 verifySigv4aRequest(SdkHttpRequest signedRequest, String payloadSigningTag)184 private void verifySigv4aRequest(SdkHttpRequest signedRequest, String payloadSigningTag) { 185 assertThat(signedRequest.headers().get("Authorization").get(0)).contains("AWS4-ECDSA-P256-SHA256"); 186 assertThat(signedRequest.headers().get("Host").get(0)).isEqualTo(constructMrapHostname(mrapAlias)); 187 assertThat(signedRequest.headers().get("x-amz-content-sha256").get(0)).isEqualTo(payloadSigningTag); 188 assertThat(signedRequest.headers().get("X-Amz-Date").get(0)).isNotEmpty(); 189 assertThat(signedRequest.headers().get("X-Amz-Region-Set").get(0)).isEqualTo("*"); 190 } 191 verifySigv4Request(SdkHttpRequest signedRequest, String payloadSigningTag)192 private void verifySigv4Request(SdkHttpRequest signedRequest, String payloadSigningTag) { 193 assertThat(signedRequest.headers().get("Authorization").get(0)).contains(SignerConstant.AWS4_SIGNING_ALGORITHM); 194 assertThat(signedRequest.headers().get("Host").get(0)).isEqualTo(String.format("%s.s3.%s.amazonaws.com", 195 bucket, REGION.id())); 196 assertThat(signedRequest.headers().get("x-amz-content-sha256").get(0)).isEqualTo(payloadSigningTag); 197 assertThat(signedRequest.headers().get("X-Amz-Date").get(0)).isNotEmpty(); 198 } 199 keys()200 private static Stream<Arguments> keys() { 201 return Stream.of( 202 Arguments.of("Slash -> unchanged", "/", "//"), 203 Arguments.of("Single segment with initial slash -> unchanged", "/foo", "//foo"), 204 Arguments.of("Single segment no slash -> slash prepended", "foo", "/foo"), 205 Arguments.of("Multiple segments -> unchanged", "/foo/bar", "//foo/bar"), 206 Arguments.of("Multiple segments with trailing slash -> unchanged", "/foo/bar/", "//foo/bar/"), 207 Arguments.of("Multiple segments, urlEncoded slash -> encodes percent", "/foo%2Fbar", "//foo%252Fbar"), 208 Arguments.of("Single segment, dot -> should remove dot", "/.", "//."), 209 Arguments.of("Multiple segments with dot -> should remove dot", "/foo/./bar", "//foo/./bar"), 210 Arguments.of("Multiple segments with ending dot -> should remove dot and trailing slash", "/foo/bar/.", "//foo/bar/."), 211 Arguments.of("Multiple segments with dots -> should remove dots and preceding segment", "/foo/bar/../baz", "//foo/bar/../baz"), 212 Arguments.of("First segment has colon -> unchanged, url encoded first", "foo:/bar", "/foo%3A/bar"), 213 Arguments.of("Multiple segments, urlEncoded slash -> encodes percent", "/foo%2F.%2Fbar", "//foo%252F.%252Fbar"), 214 Arguments.of("No url encode, Multiple segments with dot -> unchanged", "/foo/./bar", "//foo/./bar"), 215 Arguments.of("Multiple segments with dots -> unchanged", "/foo/bar/../baz", "//foo/bar/../baz"), 216 Arguments.of("double slash", "//H", "///H"), 217 Arguments.of("double slash in middle", "A//H", "/A//H") 218 ); 219 } 220 constructMrapArn(String account, String mrapAlias)221 private String constructMrapArn(String account, String mrapAlias) { 222 return String.format("arn:aws:s3::%s:accesspoint:%s", account, mrapAlias); 223 } 224 constructMrapHostname(String mrapAlias)225 private String constructMrapHostname(String mrapAlias) { 226 return String.format("%s.accesspoint.s3-global.amazonaws.com", mrapAlias); 227 } 228 s3Presigner()229 private S3Presigner s3Presigner() { 230 return S3Presigner.builder() 231 .region(REGION) 232 .serviceConfiguration(S3Configuration.builder() 233 .useArnRegionEnabled(true) 234 .checksumValidationEnabled(false) 235 .build()) 236 .credentialsProvider(CREDENTIALS_PROVIDER_CHAIN) 237 .build(); 238 } 239 createMrapIfNotExist(String account, String mrapName)240 private static void createMrapIfNotExist(String account, String mrapName) { 241 software.amazon.awssdk.services.s3control.model.Region mrapRegion = 242 software.amazon.awssdk.services.s3control.model.Region.builder().bucket(bucket).build(); 243 244 boolean mrapNotExists = s3control.listMultiRegionAccessPoints(r -> r.accountId(account)) 245 .accessPoints().stream() 246 .noneMatch(a -> a.name().equals(S3MrapIntegrationTest.mrapName)); 247 if (mrapNotExists) { 248 CreateMultiRegionAccessPointInput details = CreateMultiRegionAccessPointInput.builder() 249 .name(mrapName) 250 .regions(mrapRegion) 251 .build(); 252 log.info(() -> "Creating MRAP: " + mrapName); 253 s3control.createMultiRegionAccessPoint(r -> r.accountId(account).details(details)); 254 waitForResourceCreation(mrapName); 255 } 256 } 257 waitForResourceCreation(String mrapName)258 private static void waitForResourceCreation(String mrapName) throws IllegalStateException { 259 Waiter<ListMultiRegionAccessPointsResponse> waiter = 260 Waiter.builder(ListMultiRegionAccessPointsResponse.class) 261 .addAcceptor(WaiterAcceptor.successOnResponseAcceptor(r -> 262 r.accessPoints().stream().findFirst().filter(mrap -> mrap.name().equals(mrapName) && mrap.status().equals(MultiRegionAccessPointStatus.READY)).isPresent() 263 )) 264 .addAcceptor(WaiterAcceptor.retryOnResponseAcceptor(i -> true)) 265 .overrideConfiguration(b -> b.maxAttempts(RETRY_TIMES).backoffStrategy(FixedDelayBackoffStrategy.create(Duration.ofSeconds(RETRY_DELAY_IN_SECONDS)))) 266 .build(); 267 268 waiter.run(() -> s3control.listMultiRegionAccessPoints(r -> r.accountId(accountId))); 269 } 270 getMrapAliasAndVerify(String account, String mrapName)271 public static String getMrapAliasAndVerify(String account, String mrapName) { 272 GetMultiRegionAccessPointResponse mrap = s3control.getMultiRegionAccessPoint(r -> r.accountId(account).name(mrapName)); 273 assertThat(mrap.accessPoint()).isNotNull(); 274 assertThat(mrap.accessPoint().name()).isEqualTo(mrapName); 275 log.info(() -> "Alias: " + mrap.accessPoint().alias()); 276 return mrap.accessPoint().alias(); 277 } 278 applyPresignedUrl(PresignedRequest presignedRequest, String content)279 private String applyPresignedUrl(PresignedRequest presignedRequest, String content) { 280 try { 281 HttpExecuteRequest.Builder builder = HttpExecuteRequest.builder().request(presignedRequest.httpRequest()); 282 if (!isEmpty(content)) { 283 builder.contentStreamProvider(() -> new StringInputStream(content)); 284 } 285 HttpExecuteRequest request = builder.build(); 286 HttpExecuteResponse response = ApacheHttpClient.create().prepareRequest(request).call(); 287 return response.responseBody() 288 .map(stream -> invokeSafely(() -> IoUtils.toUtf8String(stream))) 289 .orElseThrow(() -> new IOException("No input stream")); 290 } catch (IOException e) { 291 log.error(() -> "Error occurred ", e); 292 } 293 return null; 294 } 295 mrapEnabledS3Client(List<ExecutionInterceptor> executionInterceptors)296 private static S3Client mrapEnabledS3Client(List<ExecutionInterceptor> executionInterceptors) { 297 return S3Client.builder() 298 .region(REGION) 299 .credentialsProvider(CREDENTIALS_PROVIDER_CHAIN) 300 .serviceConfiguration(S3Configuration.builder() 301 .useArnRegionEnabled(true) 302 .build()) 303 .overrideConfiguration(o -> o.executionInterceptors(executionInterceptors)) 304 .build(); 305 } 306 mrapEnabledS3ClientWithPayloadSigning(ExecutionInterceptor executionInterceptor)307 private static S3Client mrapEnabledS3ClientWithPayloadSigning(ExecutionInterceptor executionInterceptor) { 308 // We can't use here `S3OverrideAuthSchemePropertiesPlugin.enablePayloadSigningPlugin()` since 309 // it enables payload signing for *all* operations. 310 SdkPlugin plugin = S3OverrideAuthSchemePropertiesPlugin.builder() 311 .payloadSigningEnabled(true) 312 .addOperationConstraint("UploadPart") 313 .addOperationConstraint("PutObject") 314 .build(); 315 return S3Client.builder() 316 .region(REGION) 317 .credentialsProvider(CREDENTIALS_PROVIDER_CHAIN) 318 .serviceConfiguration(S3Configuration.builder() 319 .useArnRegionEnabled(true) 320 .build()) 321 .overrideConfiguration(o -> o.addExecutionInterceptor(executionInterceptor)) 322 .addPlugin(plugin) 323 .build(); 324 } 325 deleteObjectIfExists(S3Client s31, String bucket1, String key)326 private void deleteObjectIfExists(S3Client s31, String bucket1, String key) { 327 System.out.println(bucket1); 328 try { 329 s31.deleteObject(r -> r.bucket(bucket1).key(key)); 330 } catch (NoSuchKeyException e) { 331 } 332 } 333 createBucketIfNotExist(String bucket)334 private static void createBucketIfNotExist(String bucket) { 335 try { 336 s3Client.createBucket(b -> b.bucket(bucket)); 337 s3Client.waiter().waitUntilBucketExists(b -> b.bucket(bucket)); 338 } catch (BucketAlreadyOwnedByYouException | BucketAlreadyExistsException e) { 339 // ignore 340 } 341 } 342 343 private static class CaptureRequestInterceptor implements ExecutionInterceptor { 344 345 private SdkHttpRequest request; 346 private Boolean normalizePath; 347 request()348 public SdkHttpRequest request() { 349 return request; 350 } 351 352 @Override beforeTransmission(Context.BeforeTransmission context, ExecutionAttributes executionAttributes)353 public void beforeTransmission(Context.BeforeTransmission context, ExecutionAttributes executionAttributes) { 354 this.request = context.httpRequest(); 355 this.normalizePath = executionAttributes.getAttribute(AwsSignerExecutionAttribute.SIGNER_NORMALIZE_PATH); 356 } 357 } 358 } 359