This is an automated email from the ASF dual-hosted git repository.
sodonnell pushed a commit to branch HDDS-13323-sts
in repository https://gitbox.apache.org/repos/asf/ozone.git
The following commit(s) were added to refs/heads/HDDS-13323-sts by this push:
new 7eaebe6e428 HDDS-13888. [STS] Introduce S3AssumeRoleRequest and
S3AssumeRoleResponse (#9276)
7eaebe6e428 is described below
commit 7eaebe6e428e2caab816f2f57a50ddcdd1854922
Author: fmorg-git <[email protected]>
AuthorDate: Fri Nov 14 09:05:46 2025 -0800
HDDS-13888. [STS] Introduce S3AssumeRoleRequest and S3AssumeRoleResponse
(#9276)
---
.../request/s3/security/AwsRoleArnValidator.java | 145 ++++++++
.../request/s3/security/S3AssumeRoleRequest.java | 222 +++++++++++++
.../response/s3/security/S3AssumeRoleResponse.java | 42 +++
.../request/s3/security/S3SecurityTestUtils.java | 44 +++
.../s3/security/TestAwsRoleArnValidator.java | 131 ++++++++
.../s3/security/TestS3AssumeRoleRequest.java | 364 +++++++++++++++++++++
.../ozone/om/response/TestCleanupTableInfo.java | 2 +
.../s3/security/TestS3AssumeRoleResponse.java | 133 ++++++++
8 files changed, 1083 insertions(+)
diff --git
a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/s3/security/AwsRoleArnValidator.java
b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/s3/security/AwsRoleArnValidator.java
new file mode 100644
index 00000000000..1f5af2fcc59
--- /dev/null
+++
b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/s3/security/AwsRoleArnValidator.java
@@ -0,0 +1,145 @@
+/*
+ * 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.request.s3.security;
+
+import org.apache.commons.lang3.StringUtils;
+import org.apache.hadoop.ozone.om.exceptions.OMException;
+
+/**
+ * Validator for AWS IAM Role ARNs and extracts the role name from them.
+ */
+public final class AwsRoleArnValidator {
+
+ private static final int ASSUME_ROLE_NAME_MAX_LENGTH = 64;
+ private static final int ASSUME_ROLE_ARN_MIN_LENGTH = 20;
+ private static final int ASSUME_ROLE_ARN_MAX_LENGTH = 2048;
+
+ private AwsRoleArnValidator() {
+ }
+
+ /**
+ * Extract the role name from an AWS-style role ARN, falling back to the
+ * full ARN if parsing is not possible.
+ * Examples:
+ * <pre>{@code
+ * arn:aws:iam::123456789012:role/RoleA -> RoleA
+ * arn:aws:iam::123456789012:role/path/RoleB -> RoleB
+ * }</pre>
+ *
+ * @param roleArn the AWS role ARN to validate and extract from
+ * @return the extracted role name
+ * @throws OMException if the ARN is invalid
+ */
+ public static String validateAndExtractRoleNameFromArn(String roleArn)
throws OMException {
+ if (StringUtils.isBlank(roleArn)) {
+ throw new OMException("Role ARN is required",
OMException.ResultCodes.INVALID_REQUEST);
+ }
+
+ final int roleArnLength = roleArn.length();
+ if (roleArnLength < ASSUME_ROLE_ARN_MIN_LENGTH || roleArnLength >
ASSUME_ROLE_ARN_MAX_LENGTH) {
+ throw new OMException(
+ "Role ARN length must be between " + ASSUME_ROLE_ARN_MIN_LENGTH + "
and " +
+ ASSUME_ROLE_ARN_MAX_LENGTH, OMException.ResultCodes.INVALID_REQUEST);
+ }
+
+ // Expected format: arn:aws:iam::123456789012:role/[optional path
segments/]RoleName
+ if (!roleArn.startsWith("arn:aws:iam::")) {
+ throw new OMException(
+ "Invalid role ARN (does not start with arn:aws:iam::): " + roleArn,
OMException.ResultCodes.INVALID_REQUEST);
+ }
+
+ // Split ARN into parts: arn:aws:iam::accountId:role/path/name
+ final String[] parts = roleArn.split(":", 6);
+ if (parts.length < 6 || !parts[5].startsWith("role/")) {
+ throw new OMException(
+ "Invalid role ARN (unexpected field count): " + roleArn,
OMException.ResultCodes.INVALID_REQUEST);
+ }
+
+ // Validate account ID (12 digits)
+ final String accountId = parts[4];
+ if (accountId.length() != 12 || !isAllDigits(accountId)) {
+ throw new OMException("Invalid AWS account ID in ARN",
OMException.ResultCodes.INVALID_REQUEST);
+ }
+
+ // Extract role name (last segment after last slash)
+ final String rolePath = parts[5].substring(5); // Skip "role/"
+ if (rolePath.isEmpty() || rolePath.endsWith("/")) {
+ throw new OMException("Invalid role ARN: missing role name",
OMException.ResultCodes.INVALID_REQUEST);
+ }
+
+ final String[] pathSegments = rolePath.split("/");
+ final String roleName = pathSegments[pathSegments.length - 1];
+
+ // Validate role name
+ if (roleName.isEmpty() || roleName.length() > ASSUME_ROLE_NAME_MAX_LENGTH
||
+ hasCharNotAllowedInIamRoleArn(roleName)) {
+ throw new OMException("Invalid role name: " + roleName,
OMException.ResultCodes.INVALID_REQUEST);
+ }
+
+ // Validate path segments if present
+ if (pathSegments.length > 1) {
+ final String pathPrefix = rolePath.substring(0,
rolePath.lastIndexOf('/') + 1);
+ if (pathPrefix.length() > 511) {
+ throw new OMException(
+ "Role path length must be between 1 and 512 characters",
OMException.ResultCodes.INVALID_REQUEST);
+ }
+ for (String segment : pathSegments) {
+ if (segment.isEmpty() || hasCharNotAllowedInIamRoleArn(segment)) {
+ throw new OMException("Invalid role path segment: " + segment,
OMException.ResultCodes.INVALID_REQUEST);
+ }
+ }
+ }
+
+ return roleName;
+ }
+
+ /**
+ * Checks if all the characters in a String are numbers.
+ */
+ private static boolean isAllDigits(String s) {
+ for (int i = 0; i < s.length(); i++) {
+ if (!Character.isDigit(s.charAt(i))) {
+ return false;
+ }
+ }
+ return true;
+ }
+
+ /**
+ * Checks if supplied string contains a char that is not allowed in IAM Role
ARN.
+ */
+ private static boolean hasCharNotAllowedInIamRoleArn(String s) {
+ for (int i = 0; i < s.length(); i++) {
+ if (!isCharAllowedInIamRoleArn(s.charAt(i))) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ /**
+ * Checks if the supplied char is allowed in IAM Role ARN.
+ */
+ 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 == '-';
+ }
+}
+
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
new file mode 100644
index 00000000000..dc31644c806
--- /dev/null
+++
b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/s3/security/S3AssumeRoleRequest.java
@@ -0,0 +1,222 @@
+/*
+ * 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.request.s3.security;
+
+import com.google.common.annotations.VisibleForTesting;
+import java.io.IOException;
+import java.net.InetAddress;
+import java.security.SecureRandom;
+import java.time.Instant;
+import org.apache.commons.lang3.StringUtils;
+import org.apache.hadoop.ipc.ProtobufRpcEngine;
+import org.apache.hadoop.ozone.om.OzoneAclUtils;
+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.request.OMClientRequest;
+import org.apache.hadoop.ozone.om.request.util.OmResponseUtil;
+import org.apache.hadoop.ozone.om.response.OMClientResponse;
+import org.apache.hadoop.ozone.om.response.s3.security.S3AssumeRoleResponse;
+import
org.apache.hadoop.ozone.protocol.proto.OzoneManagerProtocolProtos.AssumeRoleRequest;
+import
org.apache.hadoop.ozone.protocol.proto.OzoneManagerProtocolProtos.AssumeRoleResponse;
+import
org.apache.hadoop.ozone.protocol.proto.OzoneManagerProtocolProtos.OMRequest;
+import org.apache.hadoop.security.UserGroupInformation;
+
+/**
+ * Handles S3AssumeRoleRequest request.
+ */
+public class S3AssumeRoleRequest extends OMClientRequest {
+
+ private static final SecureRandom SECURE_RANDOM = new 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 String STS_TOKEN_PREFIX = "ASIA";
+ 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 +
+ "abcdefghijklmnopqrstuvwxyz/+";
+ private static final int CHARS_FOR_SECRET_ACCESS_KEYS_LENGTH =
CHARS_FOR_SECRET_ACCESS_KEYS.length();
+
+ public S3AssumeRoleRequest(OMRequest omRequest) {
+ super(omRequest);
+ }
+
+ @Override
+ public OMClientResponse validateAndUpdateCache(OzoneManager ozoneManager,
ExecutionContext context) {
+ final OMRequest omRequest = getOmRequest();
+ final AssumeRoleRequest assumeRoleRequest =
omRequest.getAssumeRoleRequest();
+ 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);
+ return new S3AssumeRoleResponse(
+
createErrorOMResponse(OmResponseUtil.getOMResponseBuilder(omRequest),
omException));
+ }
+
+ // Validate role session name
+ final String roleSessionName = assumeRoleRequest.getRoleSessionName();
+ final S3AssumeRoleResponse roleSessionNameErrorResponse =
validateRoleSessionName(roleSessionName, omRequest);
+ if (roleSessionNameErrorResponse != null) {
+ return roleSessionNameErrorResponse;
+ }
+
+ final String roleArn = assumeRoleRequest.getRoleArn();
+ try {
+ // Validate role ARN and extract role
+ final String targetRoleName =
AwsRoleArnValidator.validateAndExtractRoleNameFromArn(roleArn);
+
+ if (!omRequest.hasS3Authentication()) {
+ final String msg = "S3AssumeRoleRequest does not have S3
authentication";
+ final OMException omException = new OMException(msg,
OMException.ResultCodes.INVALID_REQUEST);
+ return new S3AssumeRoleResponse(
+
createErrorOMResponse(OmResponseUtil.getOMResponseBuilder(omRequest),
omException));
+ }
+
+ // Generate temporary AWS credentials using cryptographically strong
SecureRandom
+ final String tempAccessKeyId = STS_TOKEN_PREFIX +
generateSecureRandomStringUsingChars(
+ CHARS_FOR_ACCESS_KEY_IDS, CHARS_FOR_ACCESS_KEY_IDS_LENGTH,
STS_ACCESS_KEY_ID_LENGTH);
+ final String secretAccessKey = generateSecureRandomStringUsingChars(
+ CHARS_FOR_SECRET_ACCESS_KEYS, CHARS_FOR_SECRET_ACCESS_KEYS_LENGTH,
STS_SECRET_ACCESS_KEY_LENGTH);
+ final String sessionToken = generateSessionToken(
+ targetRoleName, omRequest, ozoneManager, assumeRoleRequest,
secretAccessKey);
+
+ // Generate AssumedRoleId for response
+ final String roleId = ASSUME_ROLE_ID_PREFIX +
generateSecureRandomStringUsingChars(
+ CHARS_FOR_ACCESS_KEY_IDS, CHARS_FOR_ACCESS_KEY_IDS_LENGTH,
STS_ROLE_ID_LENGTH);
+ final String assumedRoleId = roleId + ":" + roleSessionName;
+
+ // Calculate expiration of session token
+ final long expirationEpochSeconds =
Instant.now().plusSeconds(durationSeconds).getEpochSecond();
+
+ final AssumeRoleResponse.Builder responseBuilder =
AssumeRoleResponse.newBuilder()
+ .setAccessKeyId(tempAccessKeyId)
+ .setSecretAccessKey(secretAccessKey)
+ .setSessionToken(sessionToken)
+ .setExpirationEpochSeconds(expirationEpochSeconds)
+ .setAssumedRoleId(assumedRoleId);
+
+ return new S3AssumeRoleResponse(
+ OmResponseUtil.getOMResponseBuilder(omRequest)
+ .setAssumeRoleResponse(responseBuilder.build())
+ .build());
+ } catch (OMException e) {
+ return new
S3AssumeRoleResponse(createErrorOMResponse(OmResponseUtil.getOMResponseBuilder(omRequest),
e));
+ } catch (IOException e) {
+ final OMException omException = new OMException(
+ "Failed to generate STS token for role: " + roleArn, e,
OMException.ResultCodes.INTERNAL_ERROR);
+ return new S3AssumeRoleResponse(
+
createErrorOMResponse(OmResponseUtil.getOMResponseBuilder(omRequest),
omException));
+ }
+ }
+
+ /**
+ * 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.
+ */
+ private String generateSessionToken(String targetRoleName, OMRequest
omRequest,
+ OzoneManager ozoneManager, AssumeRoleRequest assumeRoleRequest, String
secretAccessKey) throws IOException {
+
+ InetAddress remoteIp = ProtobufRpcEngine.Server.getRemoteIp();
+ if (remoteIp == null) {
+ remoteIp = ozoneManager.getOmRpcServerAddr().getAddress();
+ }
+
+ final String hostName = remoteIp != null ? remoteIp.getHostName() :
+ ozoneManager.getOmRpcServerAddr().getHostName();
+
+ // Determine the caller's access key ID - this will be referred to as the
original
+ // access key id. When STS tokens are used, the tokens will be authorized
as
+ // the kerberos principal associated to the original access key id, in
conjunction with the
+ // role permissions and optional AWS IAM session policy permissions.
+ final String originalAccessKeyId =
omRequest.getS3Authentication().getAccessId();
+
+ final String principal =
OzoneAclUtils.accessIdToUserPrincipal(originalAccessKeyId);
+ final UserGroupInformation ugi =
UserGroupInformation.createRemoteUser(principal);
+
+ final String roleArn = assumeRoleRequest.getRoleArn();
+ final String sessionPolicy = getSessionPolicy(
+ ozoneManager, originalAccessKeyId,
assumeRoleRequest.getAwsIamSessionPolicy(), hostName, remoteIp, ugi,
+ targetRoleName);
+
+ // TODO sts - generate a real STS token in a future PR that incorporates
the components above
+ final StringBuilder builder = new StringBuilder();
+ builder.append(originalAccessKeyId);
+ builder.append(':');
+ builder.append(roleArn);
+ builder.append(':');
+ builder.append(assumeRoleRequest.getDurationSeconds());
+ builder.append(':');
+ builder.append(secretAccessKey);
+ builder.append(':');
+ builder.append(sessionPolicy);
+ return builder.toString();
+ }
+
+ /**
+ * Calls utility to convert IAM Policy to Ozone nomenclature and uses this
output as input
+ * to IAccessAuthorizer.generateAssumeRoleSessionPolicy() which is currently
only implemented
+ * by RangerOzoneAuthorizer.
+ */
+ private String getSessionPolicy(OzoneManager ozoneManager, String
originalAccessKeyId, String awsIamPolicy,
+ String hostName, InetAddress remoteIp, UserGroupInformation ugi, String
targetRoleName) throws IOException {
+ // TODO sts - implement in a future PR
+ return null;
+ }
+
+ /**
+ * Generates a cryptographically strong String of the supplied stringLength
using supplied chars.
+ */
+ @VisibleForTesting
+ static String generateSecureRandomStringUsingChars(String chars, int
charsLength, int stringLength) {
+ final StringBuilder sb = new StringBuilder(stringLength);
+ for (int i = 0; i < stringLength; i++) {
+ sb.append(chars.charAt(SECURE_RANDOM.nextInt(charsLength)));
+ }
+ return sb.toString();
+ }
+}
diff --git
a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/response/s3/security/S3AssumeRoleResponse.java
b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/response/s3/security/S3AssumeRoleResponse.java
new file mode 100644
index 00000000000..18ed41a55f7
--- /dev/null
+++
b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/response/s3/security/S3AssumeRoleResponse.java
@@ -0,0 +1,42 @@
+/*
+ * 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.response.s3.security;
+
+import java.io.IOException;
+import org.apache.hadoop.hdds.utils.db.BatchOperation;
+import org.apache.hadoop.ozone.om.OMMetadataManager;
+import org.apache.hadoop.ozone.om.response.CleanupTableInfo;
+import org.apache.hadoop.ozone.om.response.OMClientResponse;
+import
org.apache.hadoop.ozone.protocol.proto.OzoneManagerProtocolProtos.OMResponse;
+
+/**
+ * Response for AssumeRole request.
+ * This is a stateless operation that doesn't modify any database tables.
+ */
+@CleanupTableInfo()
+public class S3AssumeRoleResponse extends OMClientResponse {
+
+ public S3AssumeRoleResponse(OMResponse omResponse) {
+ super(omResponse);
+ }
+
+ @Override
+ public void addToDBBatch(OMMetadataManager omMetadataManager, BatchOperation
batchOperation) throws IOException {
+ // No database changes for assume role - it's stateless
+ }
+}
diff --git
a/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/om/request/s3/security/S3SecurityTestUtils.java
b/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/om/request/s3/security/S3SecurityTestUtils.java
new file mode 100644
index 00000000000..9c91864a3fe
--- /dev/null
+++
b/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/om/request/s3/security/S3SecurityTestUtils.java
@@ -0,0 +1,44 @@
+/*
+ * 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.request.s3.security;
+
+/**
+ * Utility methods for S3 security tests.
+ */
+public final class S3SecurityTestUtils {
+
+ private S3SecurityTestUtils() {
+ // Utility class, no instantiation
+ }
+
+ /**
+ * Generates a string of length count containing the char c repeated.
+ *
+ * @param c the character to repeat
+ * @param count the number of times to repeat the character
+ * @return a string with the character repeated count times
+ */
+ public static String repeat(char c, int count) {
+ final StringBuilder sb = new StringBuilder(count);
+ for (int i = 0; i < count; i++) {
+ sb.append(c);
+ }
+ return sb.toString();
+ }
+}
+
diff --git
a/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/om/request/s3/security/TestAwsRoleArnValidator.java
b/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/om/request/s3/security/TestAwsRoleArnValidator.java
new file mode 100644
index 00000000000..b5deffc1e0d
--- /dev/null
+++
b/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/om/request/s3/security/TestAwsRoleArnValidator.java
@@ -0,0 +1,131 @@
+/*
+ * 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.request.s3.security;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+import org.apache.hadoop.ozone.om.exceptions.OMException;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Unit tests for AwsRoleArnValidator.
+ */
+public class TestAwsRoleArnValidator {
+
+ private static final String ROLE_ARN_1 =
"arn:aws:iam::123456789012:role/MyRole1";
+ private static final String ROLE_ARN_2 =
"arn:aws:iam::123456789012:role/path/anotherLevel/Role2";
+
+ @Test
+ public void testValidateAndExtractRoleNameFromArnSuccessCases() throws
OMException {
+
assertThat(AwsRoleArnValidator.validateAndExtractRoleNameFromArn(ROLE_ARN_1)).isEqualTo("MyRole1");
+
+
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 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 arn64 = "arn:aws:iam::123456789012:role/" + roleName64;
+
assertThat(AwsRoleArnValidator.validateAndExtractRoleNameFromArn(arn64)).isEqualTo(roleName64);
+ }
+
+ @Test
+ public void testValidateAndExtractRoleNameFromArnFailureCases() {
+ // Improper structure
+ final OMException e1 = assertThrows(
+ OMException.class, () ->
AwsRoleArnValidator.validateAndExtractRoleNameFromArn("roleNoSlashNorColons"));
+
assertThat(e1.getResult()).isEqualTo(OMException.ResultCodes.INVALID_REQUEST);
+ assertThat(e1.getMessage()).isEqualTo(
+ "Invalid role ARN (does not start with arn:aws:iam::):
roleNoSlashNorColons");
+
+ // Null
+ final OMException e2 = assertThrows(
+ OMException.class, () ->
AwsRoleArnValidator.validateAndExtractRoleNameFromArn(null));
+
assertThat(e2.getResult()).isEqualTo(OMException.ResultCodes.INVALID_REQUEST);
+ assertThat(e2.getMessage()).isEqualTo("Role ARN is required");
+
+ // String without role name
+ final OMException e3 = assertThrows(
+ OMException.class,
+ () ->
AwsRoleArnValidator.validateAndExtractRoleNameFromArn("arn:aws:iam::123456789012:role/"));
+
assertThat(e3.getResult()).isEqualTo(OMException.ResultCodes.INVALID_REQUEST);
+ assertThat(e3.getMessage()).isEqualTo("Invalid role ARN: missing role
name");
+
+ // No role resource and no role name
+ final OMException e4 = assertThrows(
+ OMException.class,
+ () ->
AwsRoleArnValidator.validateAndExtractRoleNameFromArn("arn:aws:iam::123456789012"));
+
assertThat(e4.getResult()).isEqualTo(OMException.ResultCodes.INVALID_REQUEST);
+ assertThat(e4.getMessage()).isEqualTo(
+ "Invalid role ARN (unexpected field count):
arn:aws:iam::123456789012");
+
+ // No role resource but contains role name
+ final OMException e5 = assertThrows(
+ OMException.class,
+ () ->
AwsRoleArnValidator.validateAndExtractRoleNameFromArn("arn:aws:iam::123456789012:WebRole"));
+
assertThat(e5.getResult()).isEqualTo(OMException.ResultCodes.INVALID_REQUEST);
+ assertThat(e5.getMessage()).isEqualTo(
+ "Invalid role ARN (unexpected field count):
arn:aws:iam::123456789012:WebRole");
+
+ // Empty string
+ final OMException e6 = assertThrows(
+ OMException.class, () ->
AwsRoleArnValidator.validateAndExtractRoleNameFromArn(""));
+
assertThat(e6.getResult()).isEqualTo(OMException.ResultCodes.INVALID_REQUEST);
+ assertThat(e6.getMessage()).isEqualTo("Role ARN is required");
+
+ // String with only slash
+ final OMException e7 = assertThrows(
+ OMException.class, () ->
AwsRoleArnValidator.validateAndExtractRoleNameFromArn("/"));
+
assertThat(e7.getResult()).isEqualTo(OMException.ResultCodes.INVALID_REQUEST);
+ assertThat(e7.getMessage()).isEqualTo("Role ARN length must be between 20
and 2048");
+
+ // String with only whitespace
+ final OMException e8 = assertThrows(
+ OMException.class, () ->
AwsRoleArnValidator.validateAndExtractRoleNameFromArn(" "));
+
assertThat(e8.getResult()).isEqualTo(OMException.ResultCodes.INVALID_REQUEST);
+ 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 arnTooLongPath = "arn:aws:iam::123456789012:role/" +
arnPrefixLen512 + "RoleA";
+ final OMException e9 = assertThrows(
+ OMException.class, () ->
AwsRoleArnValidator.validateAndExtractRoleNameFromArn(arnTooLongPath));
+
assertThat(e9.getResult()).isEqualTo(OMException.ResultCodes.INVALID_REQUEST);
+ assertThat(e9.getMessage()).isEqualTo("Role path length must be between 1
and 512 characters");
+
+ // Otherwise valid role ending in /
+ final OMException e10 = assertThrows(
+ OMException.class,
+ () ->
AwsRoleArnValidator.validateAndExtractRoleNameFromArn("arn:aws:iam::123456789012:role/MyRole/"));
+
assertThat(e10.getResult()).isEqualTo(OMException.ResultCodes.INVALID_REQUEST);
+ 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 roleArn65 = "arn:aws:iam::123456789012:role/" + roleName65;
+ final OMException e11 = assertThrows(
+ OMException.class, () ->
AwsRoleArnValidator.validateAndExtractRoleNameFromArn(roleArn65));
+
assertThat(e11.getResult()).isEqualTo(OMException.ResultCodes.INVALID_REQUEST);
+ assertThat(e11.getMessage()).isEqualTo("Invalid role name: " + roleName65);
+ }
+}
+
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
new file mode 100644
index 00000000000..9a826393a5f
--- /dev/null
+++
b/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/om/request/s3/security/TestS3AssumeRoleRequest.java
@@ -0,0 +1,364 @@
+/*
+ * 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.request.s3.security;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+import java.net.InetSocketAddress;
+import java.time.Instant;
+import java.util.regex.Pattern;
+import org.apache.hadoop.ozone.om.OzoneManager;
+import org.apache.hadoop.ozone.om.execution.flowcontrol.ExecutionContext;
+import org.apache.hadoop.ozone.om.response.OMClientResponse;
+import
org.apache.hadoop.ozone.protocol.proto.OzoneManagerProtocolProtos.AssumeRoleRequest;
+import
org.apache.hadoop.ozone.protocol.proto.OzoneManagerProtocolProtos.AssumeRoleResponse;
+import
org.apache.hadoop.ozone.protocol.proto.OzoneManagerProtocolProtos.OMRequest;
+import
org.apache.hadoop.ozone.protocol.proto.OzoneManagerProtocolProtos.OMResponse;
+import
org.apache.hadoop.ozone.protocol.proto.OzoneManagerProtocolProtos.S3Authentication;
+import
org.apache.hadoop.ozone.protocol.proto.OzoneManagerProtocolProtos.Status;
+import org.apache.hadoop.ozone.protocol.proto.OzoneManagerProtocolProtos.Type;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Unit tests for S3AssumeRoleRequest.
+ */
+public class TestS3AssumeRoleRequest {
+
+ private static final String ROLE_ARN_1 =
"arn:aws:iam::123456789012:role/MyRole1";
+ private static final String SESSION_NAME = "testSessionName";
+ private static final String ORIGINAL_ACCESS_KEY_ID = "origAccessKeyId";
+
+ private OzoneManager ozoneManager;
+ private ExecutionContext context;
+
+ @BeforeEach
+ public void setup() {
+ ozoneManager = mock(OzoneManager.class);
+ when(ozoneManager.getOmRpcServerAddr()).thenReturn(
+ new InetSocketAddress("localhost", 9876));
+ context = ExecutionContext.of(1L, null);
+ }
+
+ @Test
+ public void testInvalidDurationTooShort() {
+ final OMRequest omRequest = baseOmRequestBuilder()
+ .setAssumeRoleRequest(
+ AssumeRoleRequest.newBuilder()
+ .setRoleArn(ROLE_ARN_1)
+ .setRoleSessionName(SESSION_NAME)
+ .setDurationSeconds(899) // less than 900
+ ).build();
+
+ final OMClientResponse response = new S3AssumeRoleRequest(omRequest)
+ .validateAndUpdateCache(ozoneManager, context);
+ 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.hasAssumeRoleResponse()).isFalse();
+ }
+
+ @Test
+ public void testInvalidDurationTooLong() {
+ final OMRequest omRequest = baseOmRequestBuilder()
+ .setAssumeRoleRequest(
+ AssumeRoleRequest.newBuilder()
+ .setRoleArn(ROLE_ARN_1)
+ .setRoleSessionName(SESSION_NAME)
+ .setDurationSeconds(43201) // more than 43200
+ ).build();
+
+ final OMClientResponse response = new S3AssumeRoleRequest(omRequest)
+ .validateAndUpdateCache(ozoneManager, context);
+ 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.hasAssumeRoleResponse()).isFalse();
+ }
+
+ @Test
+ public void testValidDurationMaxBoundary() {
+ final OMRequest omRequest = baseOmRequestBuilder()
+ .setAssumeRoleRequest(
+ AssumeRoleRequest.newBuilder()
+ .setRoleArn(ROLE_ARN_1)
+ .setRoleSessionName(SESSION_NAME)
+ .setDurationSeconds(43200) // exactly max
+ ).build();
+
+ final OMClientResponse response = new S3AssumeRoleRequest(omRequest)
+ .validateAndUpdateCache(ozoneManager, context);
+ final OMResponse omResponse = response.getOMResponse();
+
+ assertThat(omResponse.getStatus()).isEqualTo(Status.OK);
+ assertThat(omResponse.hasAssumeRoleResponse()).isTrue();
+ }
+
+ @Test
+ public void testValidDurationMinBoundary() {
+ final OMRequest omRequest = baseOmRequestBuilder()
+ .setAssumeRoleRequest(
+ AssumeRoleRequest.newBuilder()
+ .setRoleArn(ROLE_ARN_1)
+ .setRoleSessionName(SESSION_NAME)
+ .setDurationSeconds(900) // exactly min
+ ).build();
+
+ final OMClientResponse response = new S3AssumeRoleRequest(omRequest)
+ .validateAndUpdateCache(ozoneManager, context);
+ final OMResponse omResponse = response.getOMResponse();
+
+ assertThat(omResponse.getStatus()).isEqualTo(Status.OK);
+ assertThat(omResponse.hasAssumeRoleResponse()).isTrue();
+ }
+
+ @Test
+ public void testMissingS3Authentication() {
+ final OMRequest omRequest = OMRequest.newBuilder() // note: not using
baseOMRequestBuilder that has S3 auth
+ .setCmdType(Type.AssumeRole)
+ .setClientId("client-1")
+ .setAssumeRoleRequest(
+ AssumeRoleRequest.newBuilder()
+ .setRoleArn(ROLE_ARN_1)
+ .setRoleSessionName(SESSION_NAME)
+ .setDurationSeconds(3600)
+ ).build();
+
+ final OMClientResponse response = new S3AssumeRoleRequest(omRequest)
+ .validateAndUpdateCache(ozoneManager, context);
+ final OMResponse omResponse = response.getOMResponse();
+
+ assertThat(omResponse.getStatus()).isEqualTo(Status.INVALID_REQUEST);
+ assertThat(omResponse.getMessage()).isEqualTo("S3AssumeRoleRequest does
not have S3 authentication");
+ assertThat(omResponse.hasAssumeRoleResponse()).isFalse();
+ }
+
+ @Test
+ public void testSuccessfulAssumeRoleGeneratesCredentials() {
+ final int durationSeconds = 3600;
+ final OMRequest omRequest = baseOmRequestBuilder()
+ .setAssumeRoleRequest(
+ AssumeRoleRequest.newBuilder()
+ .setRoleArn(ROLE_ARN_1)
+ .setRoleSessionName(SESSION_NAME)
+ .setDurationSeconds(durationSeconds)
+ ).build();
+
+ final long before = Instant.now().getEpochSecond();
+ final OMClientResponse clientResponse = new S3AssumeRoleRequest(omRequest)
+ .validateAndUpdateCache(ozoneManager, context);
+ final OMResponse omResponse = clientResponse.getOMResponse();
+
+ assertThat(omResponse.getStatus()).isEqualTo(Status.OK);
+ assertThat(omResponse.hasAssumeRoleResponse()).isTrue();
+ assertThat(omResponse.getCmdType()).isEqualTo(Type.AssumeRole);
+
+ final AssumeRoleResponse assumeRoleResponse =
omResponse.getAssumeRoleResponse();
+
+ // AccessKeyId: prefix ASIA + 20 chars
+ assertThat(assumeRoleResponse.getAccessKeyId()).startsWith("ASIA");
+ assertThat(assumeRoleResponse.getAccessKeyId().length()).isEqualTo(24);
// 20 chars + 4 chars from ASIA
+
+ // SecretAccessKey: 40 chars
+ assertThat(assumeRoleResponse.getSecretAccessKey().length()).isEqualTo(40);
+
+ // AssumedRoleId: prefix AROA + 16 chars, followed by ":" and sessionName
+ assertThat(assumeRoleResponse.getAssumedRoleId())
+ .startsWith("AROA")
+ .contains(":" + SESSION_NAME);
+ final int expectedAssumedRoleIdLength = 4 + 16 + 1 +
SESSION_NAME.length(); // 4 for AROA, 16 chars, 1 for ":"
+
assertThat(assumeRoleResponse.getAssumedRoleId().length()).isEqualTo(expectedAssumedRoleIdLength);
+
+ // Expiration around now + durationSeconds (allow small skew)
+ final long after = Instant.now().getEpochSecond();
+ final long expirationEpochSeconds =
assumeRoleResponse.getExpirationEpochSeconds();
+ assertThat(expirationEpochSeconds).isBetween(before + durationSeconds - 1,
after + durationSeconds + 1);
+ }
+
+ @Test
+ public void testGenerateSecureRandomStringUsingChars() {
+ final String chars = "ABC";
+ final int length = 32;
+ final String s = S3AssumeRoleRequest.generateSecureRandomStringUsingChars(
+ chars, chars.length(), length);
+ assertThat(s).hasSize(length).matches(Pattern.compile("^[ABC]{" + length +
"}$"));
+
+ // Test with length 0
+ final String empty =
S3AssumeRoleRequest.generateSecureRandomStringUsingChars(
+ "ABC", 3, 0);
+ assertThat(empty).isEmpty();
+
+ // Test with length 1
+ final String single =
S3AssumeRoleRequest.generateSecureRandomStringUsingChars(
+ "XYZ", 3, 1);
+ assertThat(single).hasSize(1).matches(Pattern.compile("^[XYZ]$"));
+ }
+
+ @Test
+ public void testAssumeRoleCredentialsAreUnique() {
+ // Test that multiple calls generate different credentials
+ final OMRequest omRequest = baseOmRequestBuilder()
+ .setAssumeRoleRequest(
+ AssumeRoleRequest.newBuilder()
+ .setRoleArn(ROLE_ARN_1)
+ .setRoleSessionName(SESSION_NAME)
+ .setDurationSeconds(3600)
+ ).build();
+
+ final OMClientResponse response1 = new S3AssumeRoleRequest(omRequest)
+ .validateAndUpdateCache(ozoneManager, context);
+ final OMClientResponse response2 = new S3AssumeRoleRequest(omRequest)
+ .validateAndUpdateCache(ozoneManager, context);
+
+ final AssumeRoleResponse assumeRoleResponse1 =
response1.getOMResponse().getAssumeRoleResponse();
+ final AssumeRoleResponse assumeRoleResponse2 =
response2.getOMResponse().getAssumeRoleResponse();
+
+ // Different access keys
+
assertThat(assumeRoleResponse1.getAccessKeyId()).isNotEqualTo(assumeRoleResponse2.getAccessKeyId());
+
+ // Different secret keys
+
assertThat(assumeRoleResponse1.getSecretAccessKey()).isNotEqualTo(assumeRoleResponse2.getSecretAccessKey());
+
+ // Different session tokens
+
assertThat(assumeRoleResponse1.getSessionToken()).isNotEqualTo(assumeRoleResponse2.getSessionToken());
+
+ // Different assumed role IDs
+
assertThat(assumeRoleResponse1.getAssumedRoleId()).isNotEqualTo(assumeRoleResponse2.getAssumedRoleId());
+ }
+
+ @Test
+ public void testAssumeRoleWithEmptySessionName() {
+ final OMRequest omRequest = baseOmRequestBuilder()
+ .setAssumeRoleRequest(
+ AssumeRoleRequest.newBuilder()
+ .setRoleArn(ROLE_ARN_1)
+ .setRoleSessionName("")
+ .setDurationSeconds(3600)
+ ).build();
+
+ final OMClientResponse response = new S3AssumeRoleRequest(omRequest)
+ .validateAndUpdateCache(ozoneManager, context);
+
assertThat(response.getOMResponse().getStatus()).isEqualTo(Status.INVALID_REQUEST);
+
assertThat(response.getOMResponse().getMessage()).isEqualTo("RoleSessionName is
required");
+ }
+
+ @Test
+ public void testInvalidAssumeRoleSessionNameTooShort() {
+ final OMRequest omRequest = baseOmRequestBuilder()
+ .setAssumeRoleRequest(
+ AssumeRoleRequest.newBuilder()
+ .setRoleArn(ROLE_ARN_1)
+ .setRoleSessionName("T") // Less than 2 characters
+ ).build();
+
+ final OMClientResponse response = new S3AssumeRoleRequest(omRequest)
+ .validateAndUpdateCache(ozoneManager, context);
+ 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.hasAssumeRoleResponse()).isFalse();
+ }
+
+ @Test
+ public void testInvalidRoleSessionNameTooLong() {
+ final String tooLongRoleSessionName = S3SecurityTestUtils.repeat('h', 70);
+ final OMRequest omRequest = baseOmRequestBuilder()
+ .setAssumeRoleRequest(
+ AssumeRoleRequest.newBuilder()
+ .setRoleArn(ROLE_ARN_1)
+ .setRoleSessionName(tooLongRoleSessionName)
+ ).build();
+
+ final OMClientResponse response = new S3AssumeRoleRequest(omRequest)
+ .validateAndUpdateCache(ozoneManager, context);
+ 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.hasAssumeRoleResponse()).isFalse();
+ }
+
+ @Test
+ public void testValidRoleSessionNameMaxLengthBoundary() {
+ final String roleSessionName = S3SecurityTestUtils.repeat('g', 64);
+ final OMRequest omRequest = baseOmRequestBuilder()
+ .setAssumeRoleRequest(
+ AssumeRoleRequest.newBuilder()
+ .setRoleArn(ROLE_ARN_1)
+ .setRoleSessionName(roleSessionName) // exactly max length
+ ).build();
+
+ final OMClientResponse response = new S3AssumeRoleRequest(omRequest)
+ .validateAndUpdateCache(ozoneManager, context);
+ final OMResponse omResponse = response.getOMResponse();
+
+ assertThat(omResponse.getStatus()).isEqualTo(Status.OK);
+ assertThat(omResponse.hasAssumeRoleResponse()).isTrue();
+ }
+
+ @Test
+ public void testValidRoleSessionNameMinLengthBoundary() {
+ final OMRequest omRequest = baseOmRequestBuilder()
+ .setAssumeRoleRequest(
+ AssumeRoleRequest.newBuilder()
+ .setRoleArn(ROLE_ARN_1)
+ .setRoleSessionName("TT") // exactly min length
+ ).build();
+
+ final OMClientResponse response = new S3AssumeRoleRequest(omRequest)
+ .validateAndUpdateCache(ozoneManager, context);
+ final OMResponse omResponse = response.getOMResponse();
+
+ assertThat(omResponse.getStatus()).isEqualTo(Status.OK);
+ assertThat(omResponse.hasAssumeRoleResponse()).isTrue();
+ }
+
+ @Test
+ public void testAssumeRoleWithSessionPolicyPresent() {
+ final String sessionPolicy =
"{\"Version\":\"2012-10-17\",\"Statement\":[]}";
+ final OMRequest omRequest = baseOmRequestBuilder()
+ .setAssumeRoleRequest(
+ AssumeRoleRequest.newBuilder()
+ .setRoleArn(ROLE_ARN_1)
+ .setRoleSessionName(SESSION_NAME)
+ .setDurationSeconds(3600)
+ .setAwsIamSessionPolicy(sessionPolicy)
+ ).build();
+
+ final OMClientResponse response = new S3AssumeRoleRequest(omRequest)
+ .validateAndUpdateCache(ozoneManager, context);
+ assertThat(response.getOMResponse().getStatus()).isEqualTo(Status.OK);
+ }
+
+ private static OMRequest.Builder baseOmRequestBuilder() {
+ return OMRequest.newBuilder()
+ .setCmdType(Type.AssumeRole)
+ .setClientId("client-1")
+ .setS3Authentication(
+ S3Authentication.newBuilder()
+ .setAccessId(ORIGINAL_ACCESS_KEY_ID)
+ );
+ }
+}
+
+
diff --git
a/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/om/response/TestCleanupTableInfo.java
b/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/om/response/TestCleanupTableInfo.java
index 3683110af1e..3ab4a8106cb 100644
---
a/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/om/response/TestCleanupTableInfo.java
+++
b/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/om/response/TestCleanupTableInfo.java
@@ -63,6 +63,7 @@
import org.apache.hadoop.ozone.om.request.key.OMKeyCreateRequest;
import org.apache.hadoop.ozone.om.response.file.OMFileCreateResponse;
import org.apache.hadoop.ozone.om.response.key.OMKeyCreateResponse;
+import org.apache.hadoop.ozone.om.response.s3.security.S3AssumeRoleResponse;
import org.apache.hadoop.ozone.om.response.util.OMEchoRPCWriteResponse;
import
org.apache.hadoop.ozone.protocol.proto.OzoneManagerProtocolProtos.CreateFileRequest;
import
org.apache.hadoop.ozone.protocol.proto.OzoneManagerProtocolProtos.CreateKeyRequest;
@@ -139,6 +140,7 @@ public void checkAnnotationAndTableName() {
// OMEchoRPCWriteResponse does not need CleanupTable.
subTypes.remove(OMEchoRPCWriteResponse.class);
subTypes.remove(DummyOMClientResponse.class);
+ subTypes.remove(S3AssumeRoleResponse.class);
subTypes.forEach(aClass -> {
if (Modifier.isAbstract(aClass.getModifiers())) {
assertFalse(aClass.isAnnotationPresent(CleanupTableInfo.class),
diff --git
a/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/om/response/s3/security/TestS3AssumeRoleResponse.java
b/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/om/response/s3/security/TestS3AssumeRoleResponse.java
new file mode 100644
index 00000000000..879c6d67eb6
--- /dev/null
+++
b/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/om/response/s3/security/TestS3AssumeRoleResponse.java
@@ -0,0 +1,133 @@
+/*
+ * 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.response.s3.security;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verifyNoInteractions;
+
+import org.apache.hadoop.hdds.utils.db.BatchOperation;
+import org.apache.hadoop.ozone.om.OMMetadataManager;
+import
org.apache.hadoop.ozone.protocol.proto.OzoneManagerProtocolProtos.AssumeRoleResponse;
+import
org.apache.hadoop.ozone.protocol.proto.OzoneManagerProtocolProtos.OMResponse;
+import
org.apache.hadoop.ozone.protocol.proto.OzoneManagerProtocolProtos.Status;
+import org.apache.hadoop.ozone.protocol.proto.OzoneManagerProtocolProtos.Type;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Unit tests for S3AssumeRoleResponse.
+ */
+public class TestS3AssumeRoleResponse {
+
+ @Test
+ public void testAddToDBBatchIsNoOpAndResponseIsAccessible() throws Exception
{
+ final AssumeRoleResponse assumeRoleResponse =
AssumeRoleResponse.newBuilder()
+ .setAccessKeyId("ASIA123")
+ .setSecretAccessKey("secret-xyz")
+ .setSessionToken("session-token")
+ .setExpirationEpochSeconds(12345L)
+ .setAssumedRoleId("AROA123:session")
+ .build();
+
+ final OMResponse omResponse = OMResponse.newBuilder()
+ .setCmdType(Type.AssumeRole)
+ .setStatus(Status.OK)
+ .setSuccess(true)
+ .setAssumeRoleResponse(assumeRoleResponse)
+ .build();
+
+ final S3AssumeRoleResponse response = new S3AssumeRoleResponse(omResponse);
+
+ // Should not throw and should not interact with DB tables
+ final OMMetadataManager omMetadataManager = mock(OMMetadataManager.class);
+ final BatchOperation batchOperation = mock(BatchOperation.class);
+ response.addToDBBatch(omMetadataManager, batchOperation);
+
+ // Ensure the wrapped response is present and unchanged
+ assertThat(response.getOMResponse().getStatus()).isEqualTo(Status.OK);
+ assertThat(response.getOMResponse().hasAssumeRoleResponse()).isTrue();
+
assertThat(response.getOMResponse().getAssumeRoleResponse()).isEqualTo(assumeRoleResponse);
+
+ // Verify that batch operations were never called
+ verifyNoInteractions(batchOperation);
+ verifyNoInteractions(omMetadataManager);
+ }
+
+ @Test
+ public void testResponseWithErrorStatus() {
+ final OMResponse errorResponse = OMResponse.newBuilder()
+ .setCmdType(Type.AssumeRole)
+ .setStatus(Status.INVALID_REQUEST)
+ .setSuccess(false)
+ .build();
+
+ final S3AssumeRoleResponse response = new
S3AssumeRoleResponse(errorResponse);
+
+
assertThat(response.getOMResponse().getStatus()).isEqualTo(Status.INVALID_REQUEST);
+ assertThat(response.getOMResponse().getSuccess()).isFalse();
+ assertThat(response.getOMResponse().hasAssumeRoleResponse()).isFalse();
+ }
+
+ @Test
+ public void testResponsePreservesAllAssumeRoleDetails() {
+ final String expectedAccessKeyId = "ASIA123";
+ final String expectedSecretAccessKey = "secretAccessKey";
+ final String expectedSessionToken = "sessionTokenData";
+ final long expectedExpiration = 1234567890L;
+ final String expectedAssumedRoleId = "AROA1234567890:mySession";
+
+ final AssumeRoleResponse assumeRoleResponse =
AssumeRoleResponse.newBuilder()
+ .setAccessKeyId(expectedAccessKeyId)
+ .setSecretAccessKey(expectedSecretAccessKey)
+ .setSessionToken(expectedSessionToken)
+ .setExpirationEpochSeconds(expectedExpiration)
+ .setAssumedRoleId(expectedAssumedRoleId)
+ .build();
+
+ final OMResponse omResponse = OMResponse.newBuilder()
+ .setCmdType(Type.AssumeRole)
+ .setStatus(Status.OK)
+ .setSuccess(true)
+ .setAssumeRoleResponse(assumeRoleResponse)
+ .build();
+
+ final S3AssumeRoleResponse response = new S3AssumeRoleResponse(omResponse);
+
+ final AssumeRoleResponse retrievedResponse =
response.getOMResponse().getAssumeRoleResponse();
+
assertThat(retrievedResponse.getAccessKeyId()).isEqualTo(expectedAccessKeyId);
+
assertThat(retrievedResponse.getSecretAccessKey()).isEqualTo(expectedSecretAccessKey);
+
assertThat(retrievedResponse.getSessionToken()).isEqualTo(expectedSessionToken);
+
assertThat(retrievedResponse.getExpirationEpochSeconds()).isEqualTo(expectedExpiration);
+
assertThat(retrievedResponse.getAssumedRoleId()).isEqualTo(expectedAssumedRoleId);
+ }
+
+ @Test
+ public void testResponseWithEmptyAssumeRoleResponse() {
+ final OMResponse omResponse = OMResponse.newBuilder()
+ .setCmdType(Type.AssumeRole)
+ .setStatus(Status.OK)
+ .setSuccess(true)
+ .build();
+
+ final S3AssumeRoleResponse response = new S3AssumeRoleResponse(omResponse);
+
+ assertThat(response.getOMResponse().hasAssumeRoleResponse()).isFalse();
+ }
+}
+
+
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]