From ce3e85c2a52260d7fb3edb0bb6f7e757f6474175 Mon Sep 17 00:00:00 2001 From: Fabian Morgan Date: Tue, 20 Jan 2026 20:25:48 -0800 Subject: [PATCH 1/2] connect STS endpoint to backend processing --- .../apache/hadoop/ozone/OzoneConfigKeys.java | 4 + .../apache/hadoop/ozone/om/OzoneManager.java | 28 +++ .../ratis/utils/OzoneManagerRatisUtils.java | 8 + .../ratis/TestOzoneManagerRatisRequest.java | 58 ++++++ .../ozone/s3/endpoint/EndpointBase.java | 4 + .../s3/signature/AWSSignatureProcessor.java | 34 ++++ .../ozone/s3/signature/SignatureInfo.java | 25 ++- .../ozone/s3sts/S3AssumeRoleResponseXml.java | 179 ++++++++++++++++++ .../hadoop/ozone/s3sts/S3STSConfigKeys.java | 4 +- .../hadoop/ozone/s3sts/S3STSEndpoint.java | 179 ++++++++++-------- .../hadoop/ozone/s3sts/S3STSEndpointBase.java | 5 + .../{TestSTS.java => TestS3STSEndpoint.java} | 92 +++++++-- .../hadoop/ozone/s3sts/package-info.java | 21 ++ 13 files changed, 548 insertions(+), 93 deletions(-) create mode 100644 hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3sts/S3AssumeRoleResponseXml.java rename hadoop-ozone/s3gateway/src/test/java/org/apache/hadoop/ozone/s3sts/{TestSTS.java => TestS3STSEndpoint.java} (51%) create mode 100644 hadoop-ozone/s3gateway/src/test/java/org/apache/hadoop/ozone/s3sts/package-info.java diff --git a/hadoop-hdds/common/src/main/java/org/apache/hadoop/ozone/OzoneConfigKeys.java b/hadoop-hdds/common/src/main/java/org/apache/hadoop/ozone/OzoneConfigKeys.java index ceca7d0c8824..7aa3a6bdab64 100644 --- a/hadoop-hdds/common/src/main/java/org/apache/hadoop/ozone/OzoneConfigKeys.java +++ b/hadoop-hdds/common/src/main/java/org/apache/hadoop/ozone/OzoneConfigKeys.java @@ -693,6 +693,10 @@ public final class OzoneConfigKeys { public static final String OZONE_CLIENT_ELASTIC_BYTE_BUFFER_POOL_MAX_SIZE = "ozone.client.elastic.byte.buffer.pool.max.size"; public static final String OZONE_CLIENT_ELASTIC_BYTE_BUFFER_POOL_MAX_SIZE_DEFAULT = "16GB"; + + public static final String OZONE_S3G_STS_HTTP_ENABLED_KEY = + "ozone.s3g.sts.http.enabled"; + public static final boolean OZONE_S3G_STS_HTTP_ENABLED_DEFAULT = false; /** * There is no need to instantiate this class. diff --git a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/OzoneManager.java b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/OzoneManager.java index 7917130950e9..cda7c76b38b3 100644 --- a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/OzoneManager.java +++ b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/OzoneManager.java @@ -476,6 +476,7 @@ public final class OzoneManager extends ServiceRuntimeInfoImpl private final boolean isS3MultiTenancyEnabled; private final boolean isStrictS3; + private final boolean isS3STSEnabled; private ExitManager exitManager; private OzoneManagerPrepareState prepareState; @@ -685,6 +686,11 @@ private OzoneManager(OzoneConfiguration conf, StartupOption startupOption) this.isS3MultiTenancyEnabled = OMMultiTenantManager.checkAndEnableMultiTenancy(this, conf); + // Enable S3 STS if config key is set + this.isS3STSEnabled = conf.getBoolean( + OzoneConfigKeys.OZONE_S3G_STS_HTTP_ENABLED_KEY, + OzoneConfigKeys.OZONE_S3G_STS_HTTP_ENABLED_DEFAULT); + metrics = OMMetrics.create(); omSnapshotIntMetrics = OmSnapshotInternalMetrics.create(); perfMetrics = OMPerformanceMetrics.register(); @@ -1137,6 +1143,13 @@ public boolean isStrictS3() { return isStrictS3; } + /** + * Returns true if S3 STS is enabled; false otherwise. + */ + public boolean isS3STSEnabled() { + return isS3STSEnabled; + } + /** * Throws OMException FEATURE_NOT_ENABLED if S3 multi-tenancy is not enabled. */ @@ -1150,6 +1163,21 @@ public void checkS3MultiTenancyEnabled() throws OMException { FEATURE_NOT_ENABLED); } + /** + * Throws OMException FEATURE_NOT_ENABLED if S3 STS (AssumeRole) is not enabled. + */ + public void checkS3STSEnabled() throws OMException { + if (isS3STSEnabled()) { + if (getAccessAuthorizer().isNative()) { + throw new OMException("S3 STS is not enabled for Ozone Native Authorizer", FEATURE_NOT_ENABLED); + } + return; + } + + throw new OMException("S3 STS is not enabled. Please set " + OzoneConfigKeys.OZONE_S3G_STS_HTTP_ENABLED_KEY + + " to true and restart all OMs.", FEATURE_NOT_ENABLED); + } + /** * Return config value of {@link OzoneConfigKeys#OZONE_SECURITY_ENABLED_KEY}. */ diff --git a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/ratis/utils/OzoneManagerRatisUtils.java b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/ratis/utils/OzoneManagerRatisUtils.java index 4f1b2fc952da..bacd0b652584 100644 --- a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/ratis/utils/OzoneManagerRatisUtils.java +++ b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/ratis/utils/OzoneManagerRatisUtils.java @@ -30,6 +30,8 @@ import java.nio.file.InvalidPathException; import java.nio.file.Path; import java.nio.file.Paths; +import java.time.Clock; +import java.time.ZoneOffset; import org.apache.hadoop.hdds.conf.ConfigurationSource; import org.apache.hadoop.hdds.conf.OzoneConfiguration; import org.apache.hadoop.hdds.security.SecurityConfig; @@ -68,6 +70,7 @@ import org.apache.hadoop.ozone.om.request.key.acl.prefix.OMPrefixSetAclRequest; import org.apache.hadoop.ozone.om.request.s3.multipart.S3ExpiredMultipartUploadsAbortRequest; import org.apache.hadoop.ozone.om.request.s3.security.OMSetSecretRequest; +import org.apache.hadoop.ozone.om.request.s3.security.S3AssumeRoleRequest; import org.apache.hadoop.ozone.om.request.s3.security.S3DeleteRevokedSTSTokensRequest; import org.apache.hadoop.ozone.om.request.s3.security.S3GetSecretRequest; import org.apache.hadoop.ozone.om.request.s3.security.S3RevokeSTSTokenRequest; @@ -119,6 +122,8 @@ public final class OzoneManagerRatisUtils { private static final Logger LOG = LoggerFactory .getLogger(OzoneManagerRatisUtils.class); + private static final Clock CLOCK = Clock.system(ZoneOffset.UTC); + private OzoneManagerRatisUtils() { } @@ -198,6 +203,9 @@ public static OMClientRequest createClientRequest(OMRequest omRequest, return new OMSetSecretRequest(omRequest); case RevokeS3Secret: return new S3RevokeSecretRequest(omRequest); + case AssumeRole: + ozoneManager.checkS3STSEnabled(); + return new S3AssumeRoleRequest(omRequest, CLOCK); case RevokeSTSToken: return new S3RevokeSTSTokenRequest(omRequest); case DeleteRevokedSTSTokens: diff --git a/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/om/ratis/TestOzoneManagerRatisRequest.java b/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/om/ratis/TestOzoneManagerRatisRequest.java index fdc9e0f008de..6a4418045a16 100644 --- a/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/om/ratis/TestOzoneManagerRatisRequest.java +++ b/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/om/ratis/TestOzoneManagerRatisRequest.java @@ -18,8 +18,10 @@ package org.apache.hadoop.ozone.om.ratis; import static org.apache.hadoop.ozone.protocol.proto.OzoneManagerProtocolProtos.Status.INVALID_REQUEST; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.CALLS_REAL_METHODS; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; @@ -44,6 +46,8 @@ import org.apache.hadoop.ozone.om.request.OMRequestTestUtils; import org.apache.hadoop.ozone.protocol.proto.OzoneManagerProtocolProtos; import org.apache.hadoop.ozone.protocolPB.OzoneManagerProtocolServerSideTranslatorPB; +import org.apache.hadoop.ozone.security.acl.IAccessAuthorizer; +import org.apache.ratis.protocol.ClientId; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; @@ -136,4 +140,58 @@ public void testUnknownRequestHandling() assertEquals(expectedResponse, actualResponse); } + + @Test + public void testAssumeRoleRejectedWhenStsDisabled() { + ozoneManager = mock(OzoneManager.class, CALLS_REAL_METHODS); + when(ozoneManager.isS3STSEnabled()).thenReturn(false); + + final OzoneManagerProtocolProtos.OMRequest omRequest = + OzoneManagerProtocolProtos.OMRequest.newBuilder() + .setCmdType(OzoneManagerProtocolProtos.Type.AssumeRole) + .setClientId(ClientId.randomId().toString()) + .build(); + + final OMException omException = assertThrows(OMException.class, + () -> OzoneManagerRatisUtils.createClientRequest(omRequest, ozoneManager)); + assertEquals(OMException.ResultCodes.FEATURE_NOT_ENABLED, omException.getResult()); + } + + @Test + public void testAssumeRoleRejectedWhenStsEnabledButNativeAuthorizerUsed() { + ozoneManager = mock(OzoneManager.class, CALLS_REAL_METHODS); + when(ozoneManager.isS3STSEnabled()).thenReturn(true); + + final IAccessAuthorizer authorizer = mock(IAccessAuthorizer.class); + when(authorizer.isNative()).thenReturn(true); + when(ozoneManager.getAccessAuthorizer()).thenReturn(authorizer); + + final OzoneManagerProtocolProtos.OMRequest omRequest = + OzoneManagerProtocolProtos.OMRequest.newBuilder() + .setCmdType(OzoneManagerProtocolProtos.Type.AssumeRole) + .setClientId(ClientId.randomId().toString()) + .build(); + + final OMException omException = assertThrows(OMException.class, + () -> OzoneManagerRatisUtils.createClientRequest(omRequest, ozoneManager)); + assertEquals(OMException.ResultCodes.FEATURE_NOT_ENABLED, omException.getResult()); + } + + @Test + public void testAssumeRoleRejectedWhenStsEnabledAndNativeAuthorizerNotUsed() { + ozoneManager = mock(OzoneManager.class, CALLS_REAL_METHODS); + when(ozoneManager.isS3STSEnabled()).thenReturn(true); + + final IAccessAuthorizer authorizer = mock(IAccessAuthorizer.class); + when(authorizer.isNative()).thenReturn(false); + when(ozoneManager.getAccessAuthorizer()).thenReturn(authorizer); + + final OzoneManagerProtocolProtos.OMRequest omRequest = + OzoneManagerProtocolProtos.OMRequest.newBuilder() + .setCmdType(OzoneManagerProtocolProtos.Type.AssumeRole) + .setClientId(ClientId.randomId().toString()) + .build(); + + assertDoesNotThrow(() -> OzoneManagerRatisUtils.createClientRequest(omRequest, ozoneManager)); + } } diff --git a/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/endpoint/EndpointBase.java b/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/endpoint/EndpointBase.java index a7ef000c6727..1f8e0532db8c 100644 --- a/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/endpoint/EndpointBase.java +++ b/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/endpoint/EndpointBase.java @@ -139,6 +139,10 @@ public void initialization() { s3Auth = new S3Auth(signatureInfo.getStringToSign(), signatureInfo.getSignature(), signatureInfo.getAwsAccessId(), signatureInfo.getAwsAccessId()); + if (signatureInfo.getSessionToken() != null && + !signatureInfo.getSessionToken().isEmpty()) { + s3Auth.setSessionToken(signatureInfo.getSessionToken()); + } LOG.debug("S3 access id: {}", s3Auth.getAccessID()); ClientProtocol clientProtocol = getClient().getObjectStore().getClientProxy(); diff --git a/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/signature/AWSSignatureProcessor.java b/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/signature/AWSSignatureProcessor.java index 9abf2fc227db..c34eb51a75a5 100644 --- a/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/signature/AWSSignatureProcessor.java +++ b/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/signature/AWSSignatureProcessor.java @@ -103,6 +103,15 @@ public SignatureInfo parseSignature() throws OS3Exception, IOException, NoSuchAl if (signatureInfo == null) { signatureInfo = new SignatureInfo.Builder(Version.NONE).setService("s3").build(); } + + // Capture STS session token if present (header-based or query-based). + // - Header-based SigV4: x-amz-security-token + // - Query-based (for presigned URLs): X-Amz-Security-Token + final String sessionToken = extractSessionToken(headers); + if (sessionToken != null && !sessionToken.isEmpty()) { + signatureInfo.setSessionToken(sessionToken); + } + String payloadHash = getPayloadHash(headers, signatureInfo); signatureInfo.setPayloadHash(payloadHash); signatureInfo.setUnfilteredURI( @@ -110,6 +119,31 @@ public SignatureInfo parseSignature() throws OS3Exception, IOException, NoSuchAl return signatureInfo; } + private String extractSessionToken(LowerCaseKeyStringMap headers) { + // Header-based token + final String headerToken = headers.get("x-amz-security-token"); + if (headerToken != null && !headerToken.isEmpty()) { + return headerToken; + } + + // Query-based token - this would be used for presigned URLs + final MultivaluedMap queryParams = context.getUriInfo().getQueryParameters(); + if (queryParams == null) { + return null; + } + final String stsQueryParam = queryParams.getFirst("X-Amz-Security-Token"); + if (stsQueryParam != null && !stsQueryParam.isEmpty()) { + return stsQueryParam; + } + + // Check lowercase query parameter as well. + final String stsQueryParamLowercase = queryParams.getFirst("x-amz-security-token"); + if (stsQueryParamLowercase != null && !stsQueryParamLowercase.isEmpty()) { + return stsQueryParamLowercase; + } + return null; + } + private String getPayloadHash(Map headers, SignatureInfo signatureInfo) throws OS3Exception, NoSuchAlgorithmException, IOException { if (signatureInfo.getVersion() == Version.V2) { diff --git a/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/signature/SignatureInfo.java b/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/signature/SignatureInfo.java index ffe8a8dddd60..52c7b00ccd00 100644 --- a/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/signature/SignatureInfo.java +++ b/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/signature/SignatureInfo.java @@ -59,6 +59,13 @@ public class SignatureInfo { private String service = null; + /** + * Optional AWS session token (x-amz-security-token / X-Amz-Security-Token). + *

+ * This is required for STS temporary credentials when calling S3 APIs. + */ + private String sessionToken = null; + public SignatureInfo() { } private SignatureInfo(Builder b) { @@ -78,7 +85,8 @@ public void initialize(SignatureInfo signatureInfo) { .setUnfilteredURI(signatureInfo.getUnfilteredURI()) .setStringToSign(signatureInfo.getStringToSign()) .setPayloadHash(signatureInfo.getPayloadHash()) - .setService(signatureInfo.getService())); + .setService(signatureInfo.getService()) + .setSessionToken(signatureInfo.getSessionToken())); } private void initialize(Builder b) { @@ -95,6 +103,7 @@ private void initialize(Builder b) { this.stringToSign = b.stringToSign; this.payloadHash = b.payloadHash; this.service = b.service; + this.sessionToken = b.sessionToken; } public String getAwsAccessId() { @@ -165,6 +174,14 @@ public void setService(String service) { this.service = service; } + public String getSessionToken() { + return sessionToken; + } + + public void setSessionToken(String sessionToken) { + this.sessionToken = sessionToken; + } + /** * Signature version. */ @@ -189,6 +206,7 @@ public static class Builder { private String stringToSign = null; private String payloadHash = null; private String service = null; + private String sessionToken = null; public Builder(Version version) { this.version = version; @@ -254,6 +272,11 @@ public Builder setService(String service) { return this; } + public Builder setSessionToken(String sessionToken) { + this.sessionToken = sessionToken; + return this; + } + public SignatureInfo build() { return new SignatureInfo(this); } diff --git a/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3sts/S3AssumeRoleResponseXml.java b/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3sts/S3AssumeRoleResponseXml.java new file mode 100644 index 000000000000..bd4be9a7eafb --- /dev/null +++ b/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3sts/S3AssumeRoleResponseXml.java @@ -0,0 +1,179 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.hadoop.ozone.s3sts; + +import javax.xml.bind.annotation.XmlAccessType; +import javax.xml.bind.annotation.XmlAccessorType; +import javax.xml.bind.annotation.XmlElement; +import javax.xml.bind.annotation.XmlRootElement; + +/** + * JAXB model for AWS STS AssumeRoleResponse. + */ +@XmlAccessorType(XmlAccessType.FIELD) +@XmlRootElement(name = "AssumeRoleResponse", namespace = "https://sts.amazonaws.com/doc/2011-06-15/") +public class S3AssumeRoleResponseXml { + + @XmlElement(name = "AssumeRoleResult") + private AssumeRoleResult assumeRoleResult; + + @XmlElement(name = "ResponseMetadata") + private ResponseMetadata responseMetadata; + + public AssumeRoleResult getAssumeRoleResult() { + return assumeRoleResult; + } + + public void setAssumeRoleResult(AssumeRoleResult assumeRoleResult) { + this.assumeRoleResult = assumeRoleResult; + } + + public ResponseMetadata getResponseMetadata() { + return responseMetadata; + } + + public void setResponseMetadata(ResponseMetadata responseMetadata) { + this.responseMetadata = responseMetadata; + } + + /** + * AssumeRoleResult element. + */ + @XmlAccessorType(XmlAccessType.FIELD) + public static class AssumeRoleResult { + @XmlElement(name = "Credentials") + private Credentials credentials; + + @XmlElement(name = "AssumedRoleUser") + private AssumedRoleUser assumedRoleUser; + + public Credentials getCredentials() { + return credentials; + } + + public void setCredentials(Credentials credentials) { + this.credentials = credentials; + } + + public AssumedRoleUser getAssumedRoleUser() { + return assumedRoleUser; + } + + public void setAssumedRoleUser(AssumedRoleUser assumedRoleUser) { + this.assumedRoleUser = assumedRoleUser; + } + } + + /** + * Credentials element. + */ + @XmlAccessorType(XmlAccessType.FIELD) + public static class Credentials { + @XmlElement(name = "AccessKeyId") + + private String accessKeyId; + @XmlElement(name = "SecretAccessKey") + + private String secretAccessKey; + @XmlElement(name = "SessionToken") + + private String sessionToken; + @XmlElement(name = "Expiration") + + private String expiration; + + public String getAccessKeyId() { + return accessKeyId; + } + + public void setAccessKeyId(String accessKeyId) { + this.accessKeyId = accessKeyId; + } + + public String getSecretAccessKey() { + return secretAccessKey; + } + + public void setSecretAccessKey(String secretAccessKey) { + this.secretAccessKey = secretAccessKey; + } + + public String getSessionToken() { + return sessionToken; + } + + public void setSessionToken(String sessionToken) { + this.sessionToken = sessionToken; + } + + public String getExpiration() { + return expiration; + } + + public void setExpiration(String expiration) { + this.expiration = expiration; + } + } + + /** + * AssumedRoleId element. + */ + @XmlAccessorType(XmlAccessType.FIELD) + public static class AssumedRoleUser { + @XmlElement(name = "AssumedRoleId") + private String assumedRoleId; + + @XmlElement(name = "Arn") + private String arn; + + public String getAssumedRoleId() { + return assumedRoleId; + } + + public void setAssumedRoleId(String assumedRoleId) { + this.assumedRoleId = assumedRoleId; + } + + public String getArn() { + return arn; + } + + public void setArn(String arn) { + this.arn = arn; + } + } + + /** + * ResponseMetadata element. + */ + @XmlAccessorType(XmlAccessType.FIELD) + public static class ResponseMetadata { + @XmlElement(name = "RequestId") + private String requestId; + + public String getRequestId() { + return requestId; + } + + public void setRequestId(String requestId) { + this.requestId = requestId; + } + } +} + + diff --git a/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3sts/S3STSConfigKeys.java b/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3sts/S3STSConfigKeys.java index 1512d3fc3c4b..aca0cbd470bd 100644 --- a/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3sts/S3STSConfigKeys.java +++ b/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3sts/S3STSConfigKeys.java @@ -17,13 +17,15 @@ package org.apache.hadoop.ozone.s3sts; +import org.apache.hadoop.ozone.OzoneConfigKeys; + /** * This class contains constants for configuration keys used * in S3 STS endpoint. */ public final class S3STSConfigKeys { public static final String OZONE_S3G_STS_HTTP_ENABLED_KEY = - "ozone.s3g.sts.http.enabled"; + OzoneConfigKeys.OZONE_S3G_STS_HTTP_ENABLED_KEY; public static final String OZONE_S3G_STS_HTTP_BIND_HOST_KEY = "ozone.s3g.sts.http-bind-host"; public static final String OZONE_S3G_STS_HTTPS_BIND_HOST_KEY = diff --git a/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3sts/S3STSEndpoint.java b/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3sts/S3STSEndpoint.java index 124581c6f260..6603e614005a 100644 --- a/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3sts/S3STSEndpoint.java +++ b/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3sts/S3STSEndpoint.java @@ -18,10 +18,10 @@ package org.apache.hadoop.ozone.s3sts; import java.io.IOException; +import java.io.StringWriter; import java.time.Instant; +import java.time.ZoneOffset; import java.time.format.DateTimeFormatter; -import java.util.Base64; -import java.util.Random; import java.util.UUID; import javax.ws.rs.FormParam; import javax.ws.rs.GET; @@ -31,6 +31,10 @@ import javax.ws.rs.QueryParam; import javax.ws.rs.core.MediaType; import javax.ws.rs.core.Response; +import javax.xml.bind.JAXBContext; +import javax.xml.bind.JAXBException; +import javax.xml.bind.Marshaller; +import org.apache.hadoop.ozone.om.helpers.AssumeRoleResponseInfo; import org.apache.hadoop.ozone.s3.exception.OS3Exception; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -65,9 +69,11 @@ public class S3STSEndpoint extends S3STSEndpointBase { private static final String GET_ACCESS_KEY_INFO_ACTION = "GetAccessKeyInfo"; // Default token duration (in seconds) - AWS default is 3600 (1 hour) + // TODO - add these constants and also validations in a common place that both endpoint and backend can use private static final int DEFAULT_DURATION_SECONDS = 3600; private static final int MAX_DURATION_SECONDS = 43200; // 12 hours private static final int MIN_DURATION_SECONDS = 900; // 15 minutes + private static final int MAX_SESSION_POLICY_SIZE = 2048; /** * STS endpoint that handles GET requests with query parameters. @@ -87,9 +93,10 @@ public Response get( @QueryParam("RoleArn") String roleArn, @QueryParam("RoleSessionName") String roleSessionName, @QueryParam("DurationSeconds") Integer durationSeconds, - @QueryParam("Version") String version) throws OS3Exception { + @QueryParam("Version") String version, + @QueryParam("Policy") String awsIamSessionPolicy) throws OS3Exception { - return handleSTSRequest(action, roleArn, roleSessionName, durationSeconds, version); + return handleSTSRequest(action, roleArn, roleSessionName, durationSeconds, version, awsIamSessionPolicy); } /** @@ -110,13 +117,14 @@ public Response post( @FormParam("RoleArn") String roleArn, @FormParam("RoleSessionName") String roleSessionName, @FormParam("DurationSeconds") Integer durationSeconds, - @FormParam("Version") String version) throws OS3Exception { + @FormParam("Version") String version, + @FormParam("Policy") String awsIamSessionPolicy) throws OS3Exception { - return handleSTSRequest(action, roleArn, roleSessionName, durationSeconds, version); + return handleSTSRequest(action, roleArn, roleSessionName, durationSeconds, version, awsIamSessionPolicy); } private Response handleSTSRequest(String action, String roleArn, String roleSessionName, - Integer durationSeconds, String version) throws OS3Exception { + Integer durationSeconds, String version, String awsIamSessionPolicy) throws OS3Exception { try { if (action == null) { return Response.status(Response.Status.BAD_REQUEST) @@ -140,7 +148,7 @@ private Response handleSTSRequest(String action, String roleArn, String roleSess switch (action) { case ASSUME_ROLE_ACTION: - return handleAssumeRole(roleArn, roleSessionName, duration); + return handleAssumeRole(roleArn, roleSessionName, duration, awsIamSessionPolicy); // These operations are not supported yet case GET_SESSION_TOKEN_ACTION: case ASSUME_ROLE_WITH_SAML_ACTION: @@ -180,9 +188,9 @@ private int validateDuration(Integer durationSeconds) throws IllegalArgumentExce return durationSeconds; } - private Response handleAssumeRole(String roleArn, String roleSessionName, int duration) + private Response handleAssumeRole(String roleArn, String roleSessionName, int duration, String awsIamSessionPolicy) throws IOException, OS3Exception { - // Validate required parameters for AssumeRole. RoleArn is required to pass the + // Validate required parameters for AssumeRole. RoleArn is required if (roleArn == null || roleArn.isEmpty()) { return Response.status(Response.Status.BAD_REQUEST) .entity("Missing required parameter: " + ROLE_ARN_PARAM) @@ -203,11 +211,27 @@ private Response handleAssumeRole(String roleArn, String roleSessionName, int du .build(); } - // TODO: Integrate with Ozone Manager to get actual temporary credentials - // String dummyCredentials = getClient().getObjectStore().getS3StsToken(userNameFromRequest()); - // Generate AssumeRole response - String responseXml = generateAssumeRoleResponse(roleArn, roleSessionName, duration); + // Check Policy size if available + if (awsIamSessionPolicy != null && awsIamSessionPolicy.length() > MAX_SESSION_POLICY_SIZE) { + return Response.status(Response.Status.BAD_REQUEST) + .entity("Policy length exceeded maximum allowed length of " + MAX_SESSION_POLICY_SIZE) + .build(); + } + + final String assumedRoleUserArn; + try { + assumedRoleUserArn = toAssumedRoleUserArn(roleArn, roleSessionName); + } catch (IllegalArgumentException e) { + return Response.status(Response.Status.BAD_REQUEST) + .entity(e.getMessage()) + .build(); + } + final AssumeRoleResponseInfo responseInfo = getClient() + .getObjectStore() + .assumeRole(roleArn, roleSessionName, duration, awsIamSessionPolicy); + // Generate AssumeRole response + final String responseXml = generateAssumeRoleResponse(assumedRoleUserArn, responseInfo); return Response.ok(responseXml) .header("Content-Type", "text/xml") .build(); @@ -222,78 +246,75 @@ private boolean isValidRoleSessionName(String roleSessionName) { return roleSessionName.matches("[a-zA-Z0-9+=,.@\\-]+"); } - // TODO: replace mock implementation with actual logic to generate new credentials - private String generateAssumeRoleResponse(String roleArn, String roleSessionName, int duration) { - // Generate realistic-looking temporary credentials - String accessKeyId = "ASIA" + generateRandomAlphanumeric(16); // AWS temp keys start with ASIA - String secretAccessKey = generateRandomBase64(40); - String sessionToken = generateSessionToken(); - String expiration = getExpirationTime(duration); - - // Generate AssumedRoleId (format: AROLEID:RoleSessionName) - String roleId = "AROA" + generateRandomAlphanumeric(16); - String assumedRoleId = roleId + ":" + roleSessionName; - - String requestId = UUID.randomUUID().toString(); - - return String.format( - "%n" + - "%n" + - " %n" + - " %n" + - " %s%n" + - " %s%n" + - " %s%n" + - " %s%n" + - " %n" + - " %n" + - " %s%n" + - " %s%n" + - " %n" + - " %n" + - " %n" + - " %s%n" + - " %n" + - "", - accessKeyId, secretAccessKey, sessionToken, expiration, - assumedRoleId, roleArn, requestId); - } + private String generateAssumeRoleResponse(String assumedRoleUserArn, AssumeRoleResponseInfo responseInfo) + throws IOException { + final String accessKeyId = responseInfo.getAccessKeyId(); + final String secretAccessKey = responseInfo.getSecretAccessKey(); + final String sessionToken = responseInfo.getSessionToken(); + final String assumedRoleId = responseInfo.getAssumedRoleId(); + + final String expiration = DateTimeFormatter.ISO_INSTANT.format( + Instant.ofEpochSecond(responseInfo.getExpirationEpochSeconds()).atOffset(ZoneOffset.UTC).toInstant()); - // TODO: this method should be removed once actual credential response from OM is implemented and used in the endpoint - private String generateRandomAlphanumeric(int length) { - String chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; - StringBuilder sb = new StringBuilder(); - Random random = new Random(); - for (int i = 0; i < length; i++) { - sb.append(chars.charAt(random.nextInt(chars.length()))); + final String requestId = UUID.randomUUID().toString(); + + try { + final S3AssumeRoleResponseXml response = new S3AssumeRoleResponseXml(); + final S3AssumeRoleResponseXml.AssumeRoleResult result = new S3AssumeRoleResponseXml.AssumeRoleResult(); + final S3AssumeRoleResponseXml.Credentials credentials = new S3AssumeRoleResponseXml.Credentials(); + credentials.setAccessKeyId(accessKeyId); + credentials.setSecretAccessKey(secretAccessKey); + credentials.setSessionToken(sessionToken); + credentials.setExpiration(expiration); + result.setCredentials(credentials); + final S3AssumeRoleResponseXml.AssumedRoleUser user = new S3AssumeRoleResponseXml.AssumedRoleUser(); + user.setAssumedRoleId(assumedRoleId); + user.setArn(assumedRoleUserArn); + result.setAssumedRoleUser(user); + response.setAssumeRoleResult(result); + final S3AssumeRoleResponseXml.ResponseMetadata meta = new S3AssumeRoleResponseXml.ResponseMetadata(); + meta.setRequestId(requestId); + response.setResponseMetadata(meta); + + final JAXBContext jaxbContext = JAXBContext.newInstance(S3AssumeRoleResponseXml.class); + final Marshaller marshaller = jaxbContext.createMarshaller(); + marshaller.setProperty(Marshaller.JAXB_FORMATTED_OUTPUT, Boolean.TRUE); + final StringWriter stringWriter = new StringWriter(); + marshaller.marshal(response, stringWriter); + return stringWriter.toString(); + } catch (JAXBException e) { + throw new IOException("Failed to marshal AssumeRole response", e); } - return sb.toString(); } - // TODO: this method should be removed once actual credential response from OM is implemented and used in the endpoint - private String generateRandomBase64(int length) { - String chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; - StringBuilder sb = new StringBuilder(); - Random random = new Random(); - for (int i = 0; i < length; i++) { - sb.append(chars.charAt((random.nextInt(chars.length())))); + private String toAssumedRoleUserArn(String roleArn, String roleSessionName) { + // RoleArn format: arn:aws:iam:::role/ + // Assumed role user arn format: arn:aws:sts:::assumed-role// + // TODO - refactor and reuse AwsRoleArnValidator for validation in future PR + final String errMsg = "Invalid RoleArn: must be in the format arn:aws:iam:::role/"; + final String[] parts = roleArn.split(":", 6); + if (parts.length != 6 || !"arn".equals(parts[0]) || parts[1].isEmpty() || !"iam".equals(parts[2])) { + throw new IllegalArgumentException(errMsg); } - return sb.toString(); - } - // TODO: this method should be removed once actual credential response from OM is implemented and used in the endpoint - private String generateSessionToken() { - byte[] tokenBytes = new byte[128]; - Random random = new Random(); - for (int i = 0; i < tokenBytes.length; i++) { - tokenBytes[i] = (byte) random.nextInt(256); + final String partition = parts[1]; + final String accountId = parts[4]; + final String resource = parts[5]; // role/ + + if (accountId == null || accountId.isEmpty() || resource == null || !resource.startsWith("role/") || + resource.length() == "role/".length()) { + throw new IllegalArgumentException(errMsg); } - return Base64.getEncoder().encodeToString(tokenBytes); - } - // TODO: this method should be removed once actual credential response from OM is implemented and used in the endpoint - private String getExpirationTime(int durationSeconds) { - Instant expiration = Instant.now().plusSeconds(durationSeconds); - return DateTimeFormatter.ISO_INSTANT.format(expiration); + final String roleName = resource.substring("role/".length()); + final StringBuilder stringBuilder = new StringBuilder("arn:"); + stringBuilder.append(partition); + stringBuilder.append(":sts::"); + stringBuilder.append(accountId); + stringBuilder.append(":assumed-role/"); + stringBuilder.append(roleName); + stringBuilder.append('/'); + stringBuilder.append(roleSessionName); + return stringBuilder.toString(); } } diff --git a/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3sts/S3STSEndpointBase.java b/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3sts/S3STSEndpointBase.java index ef753410f941..0de5e6c13743 100644 --- a/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3sts/S3STSEndpointBase.java +++ b/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3sts/S3STSEndpointBase.java @@ -56,8 +56,13 @@ public void initialization() { S3Auth s3Auth = new S3Auth(signatureInfo.getStringToSign(), signatureInfo.getSignature(), signatureInfo.getAwsAccessId(), signatureInfo.getAwsAccessId()); + if (signatureInfo.getSessionToken() != null && + !signatureInfo.getSessionToken().isEmpty()) { + s3Auth.setSessionToken(signatureInfo.getSessionToken()); + } ClientProtocol clientProtocol = getClient().getObjectStore().getClientProxy(); clientProtocol.setThreadLocalS3Auth(s3Auth); + clientProtocol.setIsS3Request(true); } private AuditMessage.Builder auditMessageBaseBuilder(AuditAction op, diff --git a/hadoop-ozone/s3gateway/src/test/java/org/apache/hadoop/ozone/s3sts/TestSTS.java b/hadoop-ozone/s3gateway/src/test/java/org/apache/hadoop/ozone/s3sts/TestS3STSEndpoint.java similarity index 51% rename from hadoop-ozone/s3gateway/src/test/java/org/apache/hadoop/ozone/s3sts/TestSTS.java rename to hadoop-ozone/s3gateway/src/test/java/org/apache/hadoop/ozone/s3sts/TestS3STSEndpoint.java index 7696bd4d3edf..a78c2c394e55 100644 --- a/hadoop-ozone/s3gateway/src/test/java/org/apache/hadoop/ozone/s3sts/TestSTS.java +++ b/hadoop-ozone/s3gateway/src/test/java/org/apache/hadoop/ozone/s3sts/TestS3STSEndpoint.java @@ -21,25 +21,42 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.when; +import java.io.StringReader; +import java.time.Instant; import javax.ws.rs.container.ContainerRequestContext; import javax.ws.rs.core.Response; +import javax.xml.parsers.DocumentBuilder; +import javax.xml.parsers.DocumentBuilderFactory; +import org.apache.commons.lang3.RandomStringUtils; import org.apache.hadoop.hdds.conf.OzoneConfiguration; +import org.apache.hadoop.ozone.client.ObjectStore; import org.apache.hadoop.ozone.client.OzoneClient; import org.apache.hadoop.ozone.client.OzoneClientStub; +import org.apache.hadoop.ozone.om.helpers.AssumeRoleResponseInfo; import org.apache.hadoop.ozone.s3.OzoneConfigurationHolder; import org.apache.hadoop.ozone.s3.signature.SignatureInfo; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.mockito.Mock; +import org.w3c.dom.Document; +import org.w3c.dom.Element; +import org.xml.sax.InputSource; /** * Test for S3 STS endpoint. */ -public class TestSTS { +public class TestS3STSEndpoint { private S3STSEndpoint endpoint; private static final String ROLE_ARN = "arn:aws:iam::123456789012:role/test-role"; private static final String ROLE_SESSION_NAME = "test-session"; + private static final String ROLE_USER_ARN = "arn:aws:sts::123456789012:assumed-role/test-role/" + ROLE_SESSION_NAME; @Mock private ContainerRequestContext context; @@ -49,7 +66,19 @@ public void setup() throws Exception { OzoneConfiguration config = new OzoneConfiguration(); config.set(OZONE_S3_ADMINISTRATORS, "test-user"); OzoneConfigurationHolder.setConfiguration(config); - OzoneClient clientStub = new OzoneClientStub(); + OzoneClient clientStub = spy(new OzoneClientStub()); + + // Stub assumeRole to return deterministic credentials. + ObjectStore objectStore = mock(ObjectStore.class); + when(objectStore.assumeRole(anyString(), anyString(), anyInt(), any())) + .thenReturn(new AssumeRoleResponseInfo( + "ASIA1234567890123456", + "mySecretAccessKey", + "session-token", + Instant.now().plusSeconds(3600).getEpochSecond(), + "AROA1234567890123456:test-session")); + when(clientStub.getObjectStore()).thenReturn(objectStore); + endpoint = new S3STSEndpoint(); endpoint.setClient(clientStub); endpoint.setContext(context); @@ -64,24 +93,39 @@ public void setup() throws Exception { @Test public void testStsAssumeRole() throws Exception { Response response = endpoint.get( - "AssumeRole", ROLE_ARN, ROLE_SESSION_NAME, 3600, "2011-06-15"); + "AssumeRole", ROLE_ARN, ROLE_SESSION_NAME, 3600, "2011-06-15", null); assertEquals(200, response.getStatus()); String responseXml = (String) response.getEntity(); assertNotNull(responseXml); - assertTrue(responseXml.contains("AssumeRoleResponse")); - assertTrue(responseXml.contains("AccessKeyId")); - assertTrue(responseXml.contains("SecretAccessKey")); - assertTrue(responseXml.contains("SessionToken")); - assertTrue(responseXml.contains("AssumedRoleUser")); - assertTrue(responseXml.contains(ROLE_ARN)); + + // Parse response XML and verify values + final DocumentBuilderFactory documentBuilderFactory = DocumentBuilderFactory.newInstance(); + documentBuilderFactory.setNamespaceAware(true); + final DocumentBuilder documentBuilder = documentBuilderFactory.newDocumentBuilder(); + final Document doc = documentBuilder.parse(new InputSource(new StringReader(responseXml))); + + final Element root = doc.getDocumentElement(); + assertEquals("AssumeRoleResponse", root.getLocalName()); + + final String accessKeyId = doc.getElementsByTagName("AccessKeyId").item(0).getTextContent(); + assertEquals("ASIA1234567890123456", accessKeyId); + + final String secretAccessKey = doc.getElementsByTagName("SecretAccessKey").item(0).getTextContent(); + assertEquals("mySecretAccessKey", secretAccessKey); + + final String sessionToken = doc.getElementsByTagName("SessionToken").item(0).getTextContent(); + assertEquals("session-token", sessionToken); + + final String arn = doc.getElementsByTagName("Arn").item(0).getTextContent(); + assertEquals(ROLE_USER_ARN, arn); } @Test public void testStsInvalidDuration() throws Exception { Response response = endpoint.get( - "AssumeRole", ROLE_ARN, ROLE_SESSION_NAME, -1, "2011-06-15"); + "AssumeRole", ROLE_ARN, ROLE_SESSION_NAME, -1, "2011-06-15", null); assertEquals(400, response.getStatus()); String errorMessage = (String) response.getEntity(); @@ -91,7 +135,7 @@ public void testStsInvalidDuration() throws Exception { @Test public void testStsUnsupportedAction() throws Exception { Response response = endpoint.get( - "UnsupportedAction", ROLE_ARN, ROLE_SESSION_NAME, 3600, "2011-06-15"); + "UnsupportedAction", ROLE_ARN, ROLE_SESSION_NAME, 3600, "2011-06-15", null); assertEquals(400, response.getStatus()); String errorMessage = (String) response.getEntity(); @@ -101,10 +145,34 @@ public void testStsUnsupportedAction() throws Exception { @Test public void testStsInvalidVersion() throws Exception { Response response = endpoint.get( - "AssumeRole", ROLE_ARN, ROLE_SESSION_NAME, 3600, "2000-01-01"); + "AssumeRole", ROLE_ARN, ROLE_SESSION_NAME, 3600, "2000-01-01", null); assertEquals(400, response.getStatus()); String errorMessage = (String) response.getEntity(); assertTrue(errorMessage.contains("Invalid or missing Version parameter. Supported version is 2011-06-15.")); } + + @Test + public void testStsPolicyTooLarge() throws Exception { + final String tooLargePolicy = RandomStringUtils.insecure().nextAlphanumeric(2049); + + final Response response = endpoint.get( + "AssumeRole", ROLE_ARN, ROLE_SESSION_NAME, 3600, "2011-06-15", tooLargePolicy); + + assertEquals(400, response.getStatus()); + final String errorMessage = (String) response.getEntity(); + assertTrue(errorMessage.contains("Policy length exceeded maximum allowed length of 2048")); + } + + @Test + public void testStsInvalidRoleArn() throws Exception { + final String invalidRoleArn = "arn:awsNotValid::123456789012:role/test-role"; + final Response response = endpoint.get( + "AssumeRole", invalidRoleArn, ROLE_SESSION_NAME, 3600, "2011-06-15", null); + + assertEquals(400, response.getStatus()); + final String errorMessage = (String) response.getEntity(); + assertTrue( + errorMessage.contains("Invalid RoleArn: must be in the format arn:aws:iam:::role/")); + } } diff --git a/hadoop-ozone/s3gateway/src/test/java/org/apache/hadoop/ozone/s3sts/package-info.java b/hadoop-ozone/s3gateway/src/test/java/org/apache/hadoop/ozone/s3sts/package-info.java new file mode 100644 index 000000000000..27318a155206 --- /dev/null +++ b/hadoop-ozone/s3gateway/src/test/java/org/apache/hadoop/ozone/s3sts/package-info.java @@ -0,0 +1,21 @@ +/* + * 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. + */ + +/** + * Unit tests for the s3 sts endpoint. + */ +package org.apache.hadoop.ozone.s3sts; From 0e0f4844f448315046140486ae8e9e6999ad2d04 Mon Sep 17 00:00:00 2001 From: Fabian Morgan Date: Wed, 21 Jan 2026 22:48:51 -0800 Subject: [PATCH 2/2] refactor constants and validations to shared location --- .../om/helpers}/AwsRoleArnValidator.java | 13 +- .../hadoop/ozone/om/helpers/S3STSUtils.java | 153 ++++++++++++++++++ .../om/helpers}/TestAwsRoleArnValidator.java | 12 +- .../s3/security/S3AssumeRoleRequest.java | 44 ++--- .../s3/security/TestS3AssumeRoleRequest.java | 17 +- .../hadoop/ozone/s3sts/S3STSEndpoint.java | 118 ++------------ .../hadoop/ozone/s3sts/TestS3STSEndpoint.java | 2 +- 7 files changed, 206 insertions(+), 153 deletions(-) rename hadoop-ozone/{ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/s3/security => common/src/main/java/org/apache/hadoop/ozone/om/helpers}/AwsRoleArnValidator.java (91%) create mode 100644 hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/om/helpers/S3STSUtils.java rename hadoop-ozone/{ozone-manager/src/test/java/org/apache/hadoop/ozone/om/request/s3/security => common/src/test/java/org/apache/hadoop/ozone/om/helpers}/TestAwsRoleArnValidator.java (94%) diff --git a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/s3/security/AwsRoleArnValidator.java b/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/om/helpers/AwsRoleArnValidator.java similarity index 91% rename from hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/s3/security/AwsRoleArnValidator.java rename to hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/om/helpers/AwsRoleArnValidator.java index 1f5af2fcc598..2bfc5085320c 100644 --- a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/s3/security/AwsRoleArnValidator.java +++ b/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/om/helpers/AwsRoleArnValidator.java @@ -15,7 +15,7 @@ * limitations under the License. */ -package org.apache.hadoop.ozone.om.request.s3.security; +package org.apache.hadoop.ozone.om.helpers; import org.apache.commons.lang3.StringUtils; import org.apache.hadoop.ozone.om.exceptions.OMException; @@ -125,7 +125,7 @@ private static boolean isAllDigits(String s) { */ private static boolean hasCharNotAllowedInIamRoleArn(String s) { for (int i = 0; i < s.length(); i++) { - if (!isCharAllowedInIamRoleArn(s.charAt(i))) { + if (!isCharAllowedInIamRoleArn(s.codePointAt(i))) { return true; } } @@ -134,12 +134,11 @@ private static boolean hasCharNotAllowedInIamRoleArn(String s) { /** * Checks if the supplied char is allowed in IAM Role ARN. + * Pattern: [\u0009\u000A\u000D\u0020-\u007E\u0085\u00A0-\uD7FF\uE000-\uFFFD\u10000-\u10FFFF]+ */ - private static boolean isCharAllowedInIamRoleArn(char c) { - return (c >= 'A' && c <= 'Z') - || (c >= 'a' && c <= 'z') - || (c >= '0' && c <= '9') - || c == '+' || c == '=' || c == ',' || c == '.' || c == '@' || c == '_' || c == '-'; + private static boolean isCharAllowedInIamRoleArn(int c) { + return c == 0x09 || c == 0x0A || c == 0x0D || (c >= 0x20 && c <= 0x7E) || c == 0x85 || (c >= 0xA0 && c <= 0xD7FF) || + (c >= 0xE000 && c <= 0xFFFD) || (c >= 0x10000 && c <= 0x10FFFF); } } diff --git a/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/om/helpers/S3STSUtils.java b/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/om/helpers/S3STSUtils.java new file mode 100644 index 000000000000..7bc8985c112c --- /dev/null +++ b/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/om/helpers/S3STSUtils.java @@ -0,0 +1,153 @@ +/* + * 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.hadoop.ozone.om.helpers; + +import static org.apache.hadoop.ozone.om.exceptions.OMException.ResultCodes.INVALID_REQUEST; + +import net.jcip.annotations.Immutable; +import org.apache.commons.lang3.StringUtils; +import org.apache.hadoop.ozone.om.exceptions.OMException; + +/** + * Utility class containing constants and validation methods shared by STS endpoint and OzoneManager processing. + */ +@Immutable +public final class S3STSUtils { + // STS API constants + public static final int DEFAULT_DURATION_SECONDS = 3600; // 1 hour + public static final int MAX_DURATION_SECONDS = 43200; // 12 hours + public static final int MIN_DURATION_SECONDS = 900; // 15 minutes + + public static final int ASSUME_ROLE_SESSION_NAME_MIN_LENGTH = 2; + public static final int ASSUME_ROLE_SESSION_NAME_MAX_LENGTH = 64; + + // AWS limit for session policy is 2048 characters + public static final int MAX_SESSION_POLICY_LENGTH = 2048; + + private S3STSUtils() { + } + + /** + * Validates the duration in seconds. + * @param durationSeconds duration in seconds + * @return validated duration + * @throws OMException if duration is invalid + */ + public static int validateDuration(Integer durationSeconds) throws OMException { + if (durationSeconds == null) { + return DEFAULT_DURATION_SECONDS; + } + + if (durationSeconds < MIN_DURATION_SECONDS || durationSeconds > MAX_DURATION_SECONDS) { + throw new OMException( + "Invalid Value: DurationSeconds must be between " + MIN_DURATION_SECONDS + + " and " + MAX_DURATION_SECONDS + " seconds", INVALID_REQUEST); + } + + return durationSeconds; + } + + /** + * Validates the role session name. + * @param roleSessionName role session name + * @throws OMException if role session name is invalid + */ + public static void validateRoleSessionName(String roleSessionName) throws OMException { + if (StringUtils.isBlank(roleSessionName)) { + throw new OMException("Missing required parameter: RoleSessionName", INVALID_REQUEST); + } + + final int roleSessionNameLength = roleSessionName.length(); + if (roleSessionNameLength < ASSUME_ROLE_SESSION_NAME_MIN_LENGTH || + roleSessionNameLength > ASSUME_ROLE_SESSION_NAME_MAX_LENGTH) { + throw new OMException("Invalid RoleSessionName: must be " + ASSUME_ROLE_SESSION_NAME_MIN_LENGTH + "-" + + ASSUME_ROLE_SESSION_NAME_MAX_LENGTH + " characters long and " + + "contain only alphanumeric characters, +, =, ,, ., @, -", INVALID_REQUEST); + } + + // AWS allows: alphanumeric, +, =, ,, ., @, - + // Pattern: [\w+=,.@-]* + // Don't use regex for performance reasons + for (int i = 0; i < roleSessionNameLength; i++) { + final char c = roleSessionName.charAt(i); + if (!isRoleSessionNameChar(c)) { + throw new OMException("Invalid RoleSessionName: must be " + ASSUME_ROLE_SESSION_NAME_MIN_LENGTH + "-" + + ASSUME_ROLE_SESSION_NAME_MAX_LENGTH + " characters long and " + + "contain only alphanumeric characters, +, =, ,, ., @, -", INVALID_REQUEST); + } + } + } + + private static boolean isRoleSessionNameChar(char c) { + return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || + c == '_' || c == '+' || c == '=' || c == ',' || c == '.' || c == '@' || c == '-'; + } + + /** + * Validates the session policy length. + * @param awsIamSessionPolicy session policy + * @throws OMException if policy length is invalid + */ + public static void validateSessionPolicy(String awsIamSessionPolicy) throws OMException { + if (awsIamSessionPolicy != null && awsIamSessionPolicy.length() > MAX_SESSION_POLICY_LENGTH) { + throw new OMException( + "Policy length exceeded maximum allowed length of " + MAX_SESSION_POLICY_LENGTH, INVALID_REQUEST); + } + } + + /** + * Generates the assumed role user ARN. + * @param validRoleArn valid role ARN + * @param roleSessionName role session name + * @return assumed role user ARN + * @throws OMException if role ARN is invalid + */ + public static String toAssumedRoleUserArn(String validRoleArn, String roleSessionName) throws OMException { + // We already know the roleArn is valid, so perform the conversion for assumed role user arn format + // RoleArn format: arn:aws:iam:::role/ + // Assumed role user arn format: arn:aws:sts:::assumed-role// + final String[] parts = splitRoleArnWithoutRegex(validRoleArn); + + final String partition = parts[1]; + final String accountId = parts[4]; + final String resource = parts[5]; + final String roleName = resource.substring("role/".length()); + + final StringBuilder stringBuilder = new StringBuilder("arn:"); + stringBuilder.append(partition); + stringBuilder.append(":sts::"); + stringBuilder.append(accountId); + stringBuilder.append(":assumed-role/"); + stringBuilder.append(roleName); + stringBuilder.append('/'); + stringBuilder.append(roleSessionName); + return stringBuilder.toString(); + } + + private static String[] splitRoleArnWithoutRegex(String roleArn) { + final String[] parts = new String[6]; + int start = 0; + for (int i = 0; i < 5; i++) { + final int end = roleArn.indexOf(':', start); + parts[i] = roleArn.substring(start, end); + start = end + 1; + } + parts[5] = roleArn.substring(start); + return parts; + } +} diff --git a/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/om/request/s3/security/TestAwsRoleArnValidator.java b/hadoop-ozone/common/src/test/java/org/apache/hadoop/ozone/om/helpers/TestAwsRoleArnValidator.java similarity index 94% rename from hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/om/request/s3/security/TestAwsRoleArnValidator.java rename to hadoop-ozone/common/src/test/java/org/apache/hadoop/ozone/om/helpers/TestAwsRoleArnValidator.java index b5deffc1e0de..edea38d79d28 100644 --- a/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/om/request/s3/security/TestAwsRoleArnValidator.java +++ b/hadoop-ozone/common/src/test/java/org/apache/hadoop/ozone/om/helpers/TestAwsRoleArnValidator.java @@ -15,11 +15,12 @@ * limitations under the License. */ -package org.apache.hadoop.ozone.om.request.s3.security; +package org.apache.hadoop.ozone.om.helpers; import static org.assertj.core.api.Assertions.assertThat; import static org.junit.jupiter.api.Assertions.assertThrows; +import org.apache.commons.lang3.StringUtils; import org.apache.hadoop.ozone.om.exceptions.OMException; import org.junit.jupiter.api.Test; @@ -38,12 +39,12 @@ public void testValidateAndExtractRoleNameFromArnSuccessCases() throws OMExcepti assertThat(AwsRoleArnValidator.validateAndExtractRoleNameFromArn(ROLE_ARN_2)).isEqualTo("Role2"); // Path name right at 511-char max boundary - final String arnPrefixLen511 = S3SecurityTestUtils.repeat('p', 510) + "/"; // 510 chars + '/' = 511 + final String arnPrefixLen511 = StringUtils.repeat('p', 510) + "/"; // 510 chars + '/' = 511 final String arnMaxPath = "arn:aws:iam::123456789012:role/" + arnPrefixLen511 + "RoleB"; assertThat(AwsRoleArnValidator.validateAndExtractRoleNameFromArn(arnMaxPath)).isEqualTo("RoleB"); // Role name right at 64-char max boundary - final String roleName64 = S3SecurityTestUtils.repeat('A', 64); + final String roleName64 = StringUtils.repeat('A', 64); final String arn64 = "arn:aws:iam::123456789012:role/" + roleName64; assertThat(AwsRoleArnValidator.validateAndExtractRoleNameFromArn(arn64)).isEqualTo(roleName64); } @@ -105,7 +106,7 @@ public void testValidateAndExtractRoleNameFromArnFailureCases() { assertThat(e8.getMessage()).isEqualTo("Role ARN is required"); // Path name too long (> 511 characters) - final String arnPrefixLen512 = S3SecurityTestUtils.repeat('q', 511) + "/"; // 511 chars + '/' = 512 + final String arnPrefixLen512 = StringUtils.repeat('q', 511) + "/"; // 511 chars + '/' = 512 final String arnTooLongPath = "arn:aws:iam::123456789012:role/" + arnPrefixLen512 + "RoleA"; final OMException e9 = assertThrows( OMException.class, () -> AwsRoleArnValidator.validateAndExtractRoleNameFromArn(arnTooLongPath)); @@ -120,7 +121,7 @@ public void testValidateAndExtractRoleNameFromArnFailureCases() { assertThat(e10.getMessage()).isEqualTo("Invalid role ARN: missing role name"); // MyRole/ is considered a path // 65-char role name - final String roleName65 = S3SecurityTestUtils.repeat('B', 65); + final String roleName65 = StringUtils.repeat('B', 65); final String roleArn65 = "arn:aws:iam::123456789012:role/" + roleName65; final OMException e11 = assertThrows( OMException.class, () -> AwsRoleArnValidator.validateAndExtractRoleNameFromArn(roleArn65)); @@ -128,4 +129,3 @@ public void testValidateAndExtractRoleNameFromArnFailureCases() { assertThat(e11.getMessage()).isEqualTo("Invalid role name: " + roleName65); } } - diff --git a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/s3/security/S3AssumeRoleRequest.java b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/s3/security/S3AssumeRoleRequest.java index aecba45f32cd..9abfaa73a0fa 100644 --- a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/s3/security/S3AssumeRoleRequest.java +++ b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/s3/security/S3AssumeRoleRequest.java @@ -33,6 +33,8 @@ import org.apache.hadoop.ozone.om.OzoneManager; import org.apache.hadoop.ozone.om.exceptions.OMException; import org.apache.hadoop.ozone.om.execution.flowcontrol.ExecutionContext; +import org.apache.hadoop.ozone.om.helpers.AwsRoleArnValidator; +import org.apache.hadoop.ozone.om.helpers.S3STSUtils; import org.apache.hadoop.ozone.om.request.OMClientRequest; import org.apache.hadoop.ozone.om.request.util.OmResponseUtil; import org.apache.hadoop.ozone.om.response.OMClientResponse; @@ -62,14 +64,10 @@ public class S3AssumeRoleRequest extends OMClientRequest { SECURE_RANDOM = secureRandom; } - private static final int MIN_TOKEN_EXPIRATION_SECONDS = 900; // 15 minutes in seconds - private static final int MAX_TOKEN_EXPIRATION_SECONDS = 43200; // 12 hours in seconds private static final int STS_ACCESS_KEY_ID_LENGTH = 20; private static final int STS_SECRET_ACCESS_KEY_LENGTH = 40; private static final int STS_ROLE_ID_LENGTH = 16; private static final String ASSUME_ROLE_ID_PREFIX = "AROA"; - private static final int ASSUME_ROLE_SESSION_NAME_MIN_LENGTH = 2; - private static final int ASSUME_ROLE_SESSION_NAME_MAX_LENGTH = 64; private static final String CHARS_FOR_ACCESS_KEY_IDS = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ"; private static final int CHARS_FOR_ACCESS_KEY_IDS_LENGTH = CHARS_FOR_ACCESS_KEY_IDS.length(); private static final String CHARS_FOR_SECRET_ACCESS_KEYS = CHARS_FOR_ACCESS_KEY_IDS + @@ -91,19 +89,20 @@ public OMClientResponse validateAndUpdateCache(OzoneManager ozoneManager, Execut final int durationSeconds = assumeRoleRequest.getDurationSeconds(); // Validate duration - if (durationSeconds < MIN_TOKEN_EXPIRATION_SECONDS || durationSeconds > MAX_TOKEN_EXPIRATION_SECONDS) { - final OMException omException = new OMException( - "Duration must be between " + MIN_TOKEN_EXPIRATION_SECONDS + " and " + MAX_TOKEN_EXPIRATION_SECONDS, - OMException.ResultCodes.INVALID_REQUEST); + try { + S3STSUtils.validateDuration(durationSeconds); + } catch (OMException e) { return new S3AssumeRoleResponse( - createErrorOMResponse(OmResponseUtil.getOMResponseBuilder(omRequest), omException)); + createErrorOMResponse(OmResponseUtil.getOMResponseBuilder(omRequest), e)); } // Validate role session name final String roleSessionName = assumeRoleRequest.getRoleSessionName(); - final S3AssumeRoleResponse roleSessionNameErrorResponse = validateRoleSessionName(roleSessionName, omRequest); - if (roleSessionNameErrorResponse != null) { - return roleSessionNameErrorResponse; + try { + S3STSUtils.validateRoleSessionName(roleSessionName); + } catch (OMException e) { + return new S3AssumeRoleResponse( + createErrorOMResponse(OmResponseUtil.getOMResponseBuilder(omRequest), e)); } final String roleArn = assumeRoleRequest.getRoleArn(); @@ -155,27 +154,6 @@ public OMClientResponse validateAndUpdateCache(OzoneManager ozoneManager, Execut } } - /** - * Ensures RoleSessionName is valid. - */ - private S3AssumeRoleResponse validateRoleSessionName(String roleSessionName, OMRequest omRequest) { - if (StringUtils.isBlank(roleSessionName)) { - final OMException omException = new OMException( - "RoleSessionName is required", OMException.ResultCodes.INVALID_REQUEST); - return new S3AssumeRoleResponse( - createErrorOMResponse(OmResponseUtil.getOMResponseBuilder(omRequest), omException)); - } - if (roleSessionName.length() < ASSUME_ROLE_SESSION_NAME_MIN_LENGTH || - roleSessionName.length() > ASSUME_ROLE_SESSION_NAME_MAX_LENGTH) { - final OMException omException = new OMException( - "RoleSessionName length must be between " + ASSUME_ROLE_SESSION_NAME_MIN_LENGTH + " and " + - ASSUME_ROLE_SESSION_NAME_MAX_LENGTH, OMException.ResultCodes.INVALID_REQUEST); - return new S3AssumeRoleResponse( - createErrorOMResponse(OmResponseUtil.getOMResponseBuilder(omRequest), omException)); - } - return null; - } - /** * Generates session token using components from the AssumeRoleRequest. */ diff --git a/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/om/request/s3/security/TestS3AssumeRoleRequest.java b/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/om/request/s3/security/TestS3AssumeRoleRequest.java index 3ae3c3c7599e..8e975f9dde1f 100644 --- a/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/om/request/s3/security/TestS3AssumeRoleRequest.java +++ b/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/om/request/s3/security/TestS3AssumeRoleRequest.java @@ -142,7 +142,8 @@ public void testInvalidDurationTooShort() { final OMResponse omResponse = response.getOMResponse(); assertThat(omResponse.getStatus()).isEqualTo(Status.INVALID_REQUEST); - assertThat(omResponse.getMessage()).isEqualTo("Duration must be between 900 and 43200"); + assertThat(omResponse.getMessage()).isEqualTo( + "Invalid Value: DurationSeconds must be between 900 and 43200 seconds"); assertThat(omResponse.hasAssumeRoleResponse()).isFalse(); } @@ -161,7 +162,8 @@ public void testInvalidDurationTooLong() { final OMResponse omResponse = response.getOMResponse(); assertThat(omResponse.getStatus()).isEqualTo(Status.INVALID_REQUEST); - assertThat(omResponse.getMessage()).isEqualTo("Duration must be between 900 and 43200"); + assertThat(omResponse.getMessage()).isEqualTo( + "Invalid Value: DurationSeconds must be between 900 and 43200 seconds"); assertThat(omResponse.hasAssumeRoleResponse()).isFalse(); } @@ -326,7 +328,7 @@ public void testAssumeRoleWithEmptySessionName() { final OMClientResponse response = new S3AssumeRoleRequest(omRequest, CLOCK) .validateAndUpdateCache(ozoneManager, context); assertThat(response.getOMResponse().getStatus()).isEqualTo(Status.INVALID_REQUEST); - assertThat(response.getOMResponse().getMessage()).isEqualTo("RoleSessionName is required"); + assertThat(response.getOMResponse().getMessage()).isEqualTo("Missing required parameter: RoleSessionName"); } @Test @@ -343,7 +345,9 @@ public void testInvalidAssumeRoleSessionNameTooShort() { final OMResponse omResponse = response.getOMResponse(); assertThat(omResponse.getStatus()).isEqualTo(Status.INVALID_REQUEST); - assertThat(omResponse.getMessage()).isEqualTo("RoleSessionName length must be between 2 and 64"); + assertThat(omResponse.getMessage()).isEqualTo( + "Invalid RoleSessionName: must be 2-64 characters long and contain only alphanumeric " + + "characters, +, =, ,, ., @, -"); assertThat(omResponse.hasAssumeRoleResponse()).isFalse(); } @@ -362,7 +366,10 @@ public void testInvalidRoleSessionNameTooLong() { final OMResponse omResponse = response.getOMResponse(); assertThat(omResponse.getStatus()).isEqualTo(Status.INVALID_REQUEST); - assertThat(omResponse.getMessage()).isEqualTo("RoleSessionName length must be between 2 and 64"); + assertThat(omResponse.getMessage()).isEqualTo( + "Invalid RoleSessionName: must be 2-64 characters long and contain only alphanumeric " + + "characters, +, =, ,, ., @, -" + ); assertThat(omResponse.hasAssumeRoleResponse()).isFalse(); } diff --git a/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3sts/S3STSEndpoint.java b/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3sts/S3STSEndpoint.java index 6603e614005a..e8ddfb7fa291 100644 --- a/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3sts/S3STSEndpoint.java +++ b/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3sts/S3STSEndpoint.java @@ -34,7 +34,10 @@ import javax.xml.bind.JAXBContext; import javax.xml.bind.JAXBException; import javax.xml.bind.Marshaller; +import org.apache.hadoop.ozone.om.exceptions.OMException; import org.apache.hadoop.ozone.om.helpers.AssumeRoleResponseInfo; +import org.apache.hadoop.ozone.om.helpers.AwsRoleArnValidator; +import org.apache.hadoop.ozone.om.helpers.S3STSUtils; import org.apache.hadoop.ozone.s3.exception.OS3Exception; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -59,8 +62,6 @@ public class S3STSEndpoint extends S3STSEndpointBase { // STS API constants private static final String STS_ACTION_PARAM = "Action"; private static final String ASSUME_ROLE_ACTION = "AssumeRole"; - private static final String ROLE_ARN_PARAM = "RoleArn"; - private static final String ROLE_DURATION_SECONDS_PARAM = "DurationSeconds"; private static final String GET_SESSION_TOKEN_ACTION = "GetSessionToken"; private static final String ASSUME_ROLE_WITH_SAML_ACTION = "AssumeRoleWithSAML"; private static final String ASSUME_ROLE_WITH_WEB_IDENTITY_ACTION = "AssumeRoleWithWebIdentity"; @@ -68,13 +69,6 @@ public class S3STSEndpoint extends S3STSEndpointBase { private static final String DECODE_AUTHORIZATION_MESSAGE_ACTION = "DecodeAuthorizationMessage"; private static final String GET_ACCESS_KEY_INFO_ACTION = "GetAccessKeyInfo"; - // Default token duration (in seconds) - AWS default is 3600 (1 hour) - // TODO - add these constants and also validations in a common place that both endpoint and backend can use - private static final int DEFAULT_DURATION_SECONDS = 3600; - private static final int MAX_DURATION_SECONDS = 43200; // 12 hours - private static final int MIN_DURATION_SECONDS = 900; // 15 minutes - private static final int MAX_SESSION_POLICY_SIZE = 2048; - /** * STS endpoint that handles GET requests with query parameters. * AWS STS supports both GET and POST requests. @@ -131,15 +125,6 @@ private Response handleSTSRequest(String action, String roleArn, String roleSess .entity("Missing required parameter: " + STS_ACTION_PARAM) .build(); } - int duration; - try { - duration = validateDuration(durationSeconds); - } catch (IllegalArgumentException e) { - return Response.status(Response.Status.BAD_REQUEST) - .entity(e.getMessage()) - .build(); - } - if (version == null || !version.equals("2011-06-15")) { return Response.status(Response.Status.BAD_REQUEST) .entity("Invalid or missing Version parameter. Supported version is 2011-06-15.") @@ -148,7 +133,7 @@ private Response handleSTSRequest(String action, String roleArn, String roleSess switch (action) { case ASSUME_ROLE_ACTION: - return handleAssumeRole(roleArn, roleSessionName, duration, awsIamSessionPolicy); + return handleAssumeRole(roleArn, roleSessionName, durationSeconds, awsIamSessionPolicy); // These operations are not supported yet case GET_SESSION_TOKEN_ACTION: case ASSUME_ROLE_WITH_SAML_ACTION: @@ -174,54 +159,25 @@ private Response handleSTSRequest(String action, String roleArn, String roleSess } } - private int validateDuration(Integer durationSeconds) throws IllegalArgumentException, OS3Exception { - if (durationSeconds == null) { - return DEFAULT_DURATION_SECONDS; - } - - if (durationSeconds < MIN_DURATION_SECONDS || durationSeconds > MAX_DURATION_SECONDS) { - throw new IllegalArgumentException( - "Invalid Value: " + ROLE_DURATION_SECONDS_PARAM + " must be between " + MIN_DURATION_SECONDS + - " and " + MAX_DURATION_SECONDS + " seconds"); - } - - return durationSeconds; - } - - private Response handleAssumeRole(String roleArn, String roleSessionName, int duration, String awsIamSessionPolicy) - throws IOException, OS3Exception { - // Validate required parameters for AssumeRole. RoleArn is required - if (roleArn == null || roleArn.isEmpty()) { - return Response.status(Response.Status.BAD_REQUEST) - .entity("Missing required parameter: " + ROLE_ARN_PARAM) - .build(); - } - - if (roleSessionName == null || roleSessionName.isEmpty()) { - return Response.status(Response.Status.BAD_REQUEST) - .entity("Missing required parameter: RoleSessionName") - .build(); - } - - // Validate role session name format (AWS requirements) - if (!isValidRoleSessionName(roleSessionName)) { - return Response.status(Response.Status.BAD_REQUEST) - .entity("Invalid RoleSessionName: must be 2-64 characters long and " + - "contain only alphanumeric characters, +, =, ,, ., @, -") - .build(); - } - - // Check Policy size if available - if (awsIamSessionPolicy != null && awsIamSessionPolicy.length() > MAX_SESSION_POLICY_SIZE) { + private Response handleAssumeRole(String roleArn, String roleSessionName, Integer durationSeconds, + String awsIamSessionPolicy) throws IOException, OS3Exception { + // Validate parameters + int duration; + try { + duration = S3STSUtils.validateDuration(durationSeconds); + AwsRoleArnValidator.validateAndExtractRoleNameFromArn(roleArn); + S3STSUtils.validateRoleSessionName(roleSessionName); + S3STSUtils.validateSessionPolicy(awsIamSessionPolicy); + } catch (OMException e) { return Response.status(Response.Status.BAD_REQUEST) - .entity("Policy length exceeded maximum allowed length of " + MAX_SESSION_POLICY_SIZE) + .entity(e.getMessage()) .build(); } final String assumedRoleUserArn; try { - assumedRoleUserArn = toAssumedRoleUserArn(roleArn, roleSessionName); - } catch (IllegalArgumentException e) { + assumedRoleUserArn = S3STSUtils.toAssumedRoleUserArn(roleArn, roleSessionName); + } catch (OMException e) { return Response.status(Response.Status.BAD_REQUEST) .entity(e.getMessage()) .build(); @@ -237,15 +193,6 @@ private Response handleAssumeRole(String roleArn, String roleSessionName, int du .build(); } - private boolean isValidRoleSessionName(String roleSessionName) { - if (roleSessionName.length() < 2 || roleSessionName.length() > 64) { - return false; - } - - // AWS allows: alphanumeric, +, =, ,, ., @, - - return roleSessionName.matches("[a-zA-Z0-9+=,.@\\-]+"); - } - private String generateAssumeRoleResponse(String assumedRoleUserArn, AssumeRoleResponseInfo responseInfo) throws IOException { final String accessKeyId = responseInfo.getAccessKeyId(); @@ -286,35 +233,4 @@ private String generateAssumeRoleResponse(String assumedRoleUserArn, AssumeRoleR throw new IOException("Failed to marshal AssumeRole response", e); } } - - private String toAssumedRoleUserArn(String roleArn, String roleSessionName) { - // RoleArn format: arn:aws:iam:::role/ - // Assumed role user arn format: arn:aws:sts:::assumed-role// - // TODO - refactor and reuse AwsRoleArnValidator for validation in future PR - final String errMsg = "Invalid RoleArn: must be in the format arn:aws:iam:::role/"; - final String[] parts = roleArn.split(":", 6); - if (parts.length != 6 || !"arn".equals(parts[0]) || parts[1].isEmpty() || !"iam".equals(parts[2])) { - throw new IllegalArgumentException(errMsg); - } - - final String partition = parts[1]; - final String accountId = parts[4]; - final String resource = parts[5]; // role/ - - if (accountId == null || accountId.isEmpty() || resource == null || !resource.startsWith("role/") || - resource.length() == "role/".length()) { - throw new IllegalArgumentException(errMsg); - } - - final String roleName = resource.substring("role/".length()); - final StringBuilder stringBuilder = new StringBuilder("arn:"); - stringBuilder.append(partition); - stringBuilder.append(":sts::"); - stringBuilder.append(accountId); - stringBuilder.append(":assumed-role/"); - stringBuilder.append(roleName); - stringBuilder.append('/'); - stringBuilder.append(roleSessionName); - return stringBuilder.toString(); - } } diff --git a/hadoop-ozone/s3gateway/src/test/java/org/apache/hadoop/ozone/s3sts/TestS3STSEndpoint.java b/hadoop-ozone/s3gateway/src/test/java/org/apache/hadoop/ozone/s3sts/TestS3STSEndpoint.java index a78c2c394e55..da9e88a9bbda 100644 --- a/hadoop-ozone/s3gateway/src/test/java/org/apache/hadoop/ozone/s3sts/TestS3STSEndpoint.java +++ b/hadoop-ozone/s3gateway/src/test/java/org/apache/hadoop/ozone/s3sts/TestS3STSEndpoint.java @@ -173,6 +173,6 @@ public void testStsInvalidRoleArn() throws Exception { assertEquals(400, response.getStatus()); final String errorMessage = (String) response.getEntity(); assertTrue( - errorMessage.contains("Invalid RoleArn: must be in the format arn:aws:iam:::role/")); + errorMessage.contains("Invalid role ARN (does not start with arn:aws:iam::)")); } }