Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions hadoop-hdds/docs/content/design/ozone-sts.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ title: AWS STS Design for Ozone S3
summary: STS Support in Ozone
date: 2025-10-30
jira: HDDS-13323
status: implementing
status: implemented
author: Madhan Neethiraj, Ren Koike, Fabian Morgan, Stephen O'Donnell, Istvan Fajth, Uma Maheswara Rao Gangumalla
---
<!--
Expand Down Expand Up @@ -247,8 +247,9 @@ originalAccessKeyId in the session token and perform the following checks:
- Ensure the sessionToken is not expired
- Ensure the STS credentials are not revoked by looking up the revocation cutoff for the token's originalAccessKeyId
and comparing it against the token's signed creationTime
- Ensure that the Kerberos identity associated with the originalAccessKeyId is not revoked
- Validate the HMAC-SHA256 signature in the sessionToken
- Decrypt the secretAccessKey from the sessionToken and validate the AWS signature
- Authorize the call with either RangerOzoneAuthorizer or OzoneNativeAuthorizer
- Authorize the call with RangerOzoneAuthorizer

Assuming all these checks pass, the S3 API call will be invoked.
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,10 @@ public final class S3STSUtils {

public static final String OZONE_STATIC_ACCOUNT_ID = "123456789012";

public static final String DURATION_VALIDATION_ERROR_MESSAGE =
"Invalid Value: DurationSeconds must be a number between " + MIN_DURATION_SECONDS + " and " +
MAX_DURATION_SECONDS + " seconds";

private S3STSUtils() {
}

Expand Down Expand Up @@ -101,18 +105,32 @@ public static void addAssumeRoleAuditParams(Map<String, String> auditParams, Str
* @return validated duration
* @throws OMException if duration is invalid
*/
public static int validateDuration(Integer durationSeconds) throws OMException {
public static int validateDuration(int durationSeconds) throws OMException {
if (durationSeconds < MIN_DURATION_SECONDS || durationSeconds > MAX_DURATION_SECONDS) {
throw new OMException(DURATION_VALIDATION_ERROR_MESSAGE, INVALID_REQUEST);
}

return durationSeconds;
}

/**
* Validates the duration in seconds from a raw request value.
* @param durationSeconds duration in seconds as a string
* @return validated duration
* @throws OMException if duration is invalid
*/
public static int validateDuration(String 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);
final int value;
try {
value = Integer.parseInt(durationSeconds);
} catch (NumberFormatException e) {
throw new OMException(DURATION_VALIDATION_ERROR_MESSAGE, INVALID_REQUEST);
}

return durationSeconds;
return validateDuration(value);
}

/**
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
/*
* 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.assertj.core.api.Assertions.assertThat;
import static org.junit.jupiter.api.Assertions.assertEquals;
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 S3STSUtils.
*/
public class TestS3STSUtils {

private static final String DURATION_VALIDATION_ERROR_MESSAGE =
"Invalid Value: DurationSeconds must be a number between 900 and 43200 seconds";

@Test
public void testValidateDurationStringNullUsesDefault() throws OMException {
assertEquals(S3STSUtils.DEFAULT_DURATION_SECONDS, S3STSUtils.validateDuration((String) null));
}

@Test
public void testValidateDurationStringBlankIsInvalid() {
final OMException empty = assertThrows(OMException.class, () -> S3STSUtils.validateDuration(""));
assertThat(empty.getMessage()).isEqualTo(DURATION_VALIDATION_ERROR_MESSAGE);

final OMException whitespace = assertThrows(OMException.class, () -> S3STSUtils.validateDuration(" "));
assertThat(whitespace.getMessage()).isEqualTo(DURATION_VALIDATION_ERROR_MESSAGE);
}

@Test
public void testValidateDurationStringValidValues() throws OMException {
assertEquals(900, S3STSUtils.validateDuration("900"));
assertEquals(3600, S3STSUtils.validateDuration("3600"));
assertEquals(43200, S3STSUtils.validateDuration("43200"));
}

@Test
public void testValidateDurationStringOutOfRange() {
final OMException tooShort = assertThrows(OMException.class, () -> S3STSUtils.validateDuration("899"));
assertThat(tooShort.getMessage()).isEqualTo(DURATION_VALIDATION_ERROR_MESSAGE);

final OMException tooLong = assertThrows(OMException.class, () -> S3STSUtils.validateDuration("43201"));
assertThat(tooLong.getMessage()).isEqualTo(DURATION_VALIDATION_ERROR_MESSAGE);
}

@Test
public void testValidateDurationStringOverflowsInt() {
final OMException ex = assertThrows(OMException.class, () -> S3STSUtils.validateDuration("4320010000"));
assertThat(ex.getMessage()).isEqualTo(DURATION_VALIDATION_ERROR_MESSAGE);
}

@Test
public void testValidateDurationStringNonNumeric() {
final OMException abc = assertThrows(OMException.class, () -> S3STSUtils.validateDuration("abc"));
assertThat(abc.getMessage()).isEqualTo(DURATION_VALIDATION_ERROR_MESSAGE);

final OMException decimal = assertThrows(OMException.class, () -> S3STSUtils.validateDuration("3.5"));
assertThat(decimal.getMessage()).isEqualTo(DURATION_VALIDATION_ERROR_MESSAGE);

final OMException invalidSign = assertThrows(OMException.class, () -> S3STSUtils.validateDuration("+-3"));
assertThat(invalidSign.getMessage()).isEqualTo(DURATION_VALIDATION_ERROR_MESSAGE);
}

@Test
public void testValidateDurationIntValidValues() throws OMException {
assertEquals(900, S3STSUtils.validateDuration(900));
assertEquals(3600, S3STSUtils.validateDuration(3600));
assertEquals(43200, S3STSUtils.validateDuration(43200));
}

@Test
public void testValidateDurationIntOutOfRange() {
final OMException tooShort = assertThrows(OMException.class, () -> S3STSUtils.validateDuration(899));
assertThat(tooShort.getMessage()).isEqualTo(DURATION_VALIDATION_ERROR_MESSAGE);

final OMException tooLong = assertThrows(OMException.class, () -> S3STSUtils.validateDuration(43201));
assertThat(tooLong.getMessage()).isEqualTo(DURATION_VALIDATION_ERROR_MESSAGE);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -220,13 +220,14 @@ Assume Role Should Fail

Assume Role Should Fail Using Curl
# This keyword is needed to test boundary cases that the aws client prevents you from issuing, such as too short duration for token
[Arguments] ${perm_access_key_id} ${perm_secret_key} ${policy_json}=${EMPTY} ${expected_error}=AccessDenied ${expected_http_code}=400 ${role_arn}=${ROLE_ARN_OBS} ${role_session_name}=${ROLE_SESSION_NAME} ${duration_seconds}=900 ${extra_curl_params}=${EMPTY}
[Arguments] ${perm_access_key_id} ${perm_secret_key} ${policy_json}=${EMPTY} ${expected_error}=AccessDenied ${expected_http_code}=400 ${role_arn}=${ROLE_ARN_OBS} ${role_session_name}=${ROLE_SESSION_NAME} ${duration_seconds}=900 ${extra_curl_params}=${EMPTY} ${expected_message}=${EMPTY}
${cmd} = Set Variable curl --silent --show-error --include --request POST --aws-sigv4 "aws:amz:us-east-1:sts" --user '${perm_access_key_id}:${perm_secret_key}' --header "Content-Type: application/x-www-form-urlencoded" --data-urlencode "Action=AssumeRole" --data-urlencode "Version=2011-06-15" --data-urlencode "RoleArn=${role_arn}" --data-urlencode "RoleSessionName=${role_session_name}" ${STS_ENDPOINT_URL}
${cmd} = Set Variable If '${duration_seconds}' != '${EMPTY}' ${cmd} --data-urlencode "DurationSeconds=${duration_seconds}" ${cmd}
${cmd} = Set Variable If '${policy_json}' != '${EMPTY}' ${cmd} --data-urlencode "Policy=${policy_json}" ${cmd}
${cmd} = Set Variable If '${extra_curl_params}' != '${EMPTY}' ${cmd} ${extra_curl_params} ${cmd}
${output} = Execute And Ignore Error ${cmd}
Should Contain ${output} ${expected_error}
Run Keyword If '${expected_message}' != '${EMPTY}' Should Contain ${output} ${expected_message}
@{http_codes} = Get Regexp Matches ${output} (?m)^HTTP/[0-9.]+ ([0-9]{3}) 1
${code_count} = Get Length ${http_codes}
Should Be True ${code_count} > 0 Expected to find an HTTP status code in curl output, but none was found.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -735,6 +735,14 @@ Assume Role Should Fail For Too Short Role Arn
Assume Role Should Fail For Too Short Role Session Name
Assume Role Should Fail Using Curl perm_access_key_id=${PERMANENT_ACCESS_KEY_ID} perm_secret_key=${PERMANENT_SECRET_KEY} expected_error=ValidationError expected_http_code=400 role_arn=${ICEBERG_ALL_ACCESS_ROLE_OBS_ARN} role_session_name=a

Assume Role Should Fail For Invalid Duration
# AWS CLI rejects out-of-range DurationSeconds before the request is sent, so use curl.
Assume Role Should Fail Using Curl perm_access_key_id=${PERMANENT_ACCESS_KEY_ID} perm_secret_key=${PERMANENT_SECRET_KEY} expected_error=ValidationError expected_http_code=400 role_arn=${ICEBERG_ALL_ACCESS_ROLE_OBS_ARN} duration_seconds=43201 expected_message=must be a number between 900 and 43200 seconds
# Check duration that overflows integer as well
Assume Role Should Fail Using Curl perm_access_key_id=${PERMANENT_ACCESS_KEY_ID} perm_secret_key=${PERMANENT_SECRET_KEY} expected_error=ValidationError expected_http_code=400 role_arn=${ICEBERG_ALL_ACCESS_ROLE_OBS_ARN} duration_seconds=43200100000 expected_message=must be a number between 900 and 43200 seconds
# Whitespace-only DurationSeconds must be rejected
Assume Role Should Fail Using Curl perm_access_key_id=${PERMANENT_ACCESS_KEY_ID} perm_secret_key=${PERMANENT_SECRET_KEY} expected_error=ValidationError expected_http_code=400 role_arn=${ICEBERG_ALL_ACCESS_ROLE_OBS_ARN} duration_seconds=${SPACE}${SPACE}${SPACE}${SPACE}${SPACE} expected_message=must be a number between 900 and 43200 seconds

Assume Role With ExternalId Should Fail As UnsupportedOperation
Assume Role Should Fail perm_access_key_id=${PERMANENT_ACCESS_KEY_ID} perm_secret_key=${PERMANENT_SECRET_KEY} expected_error=UnsupportedOperation expected_http_code=501 role_arn=${ICEBERG_ALL_ACCESS_ROLE_OBS_ARN} extra_cli_args=--external-id test-external-id

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -168,7 +168,7 @@ public void testInvalidDurationTooShort() {

assertThat(exception.getResult()).isEqualTo(OMException.ResultCodes.INVALID_REQUEST);
assertThat(exception.getMessage()).isEqualTo(
"Invalid Value: DurationSeconds must be between 900 and 43200 seconds");
"Invalid Value: DurationSeconds must be a number between 900 and 43200 seconds");
assertMarkForAuditCalled(request);
}

Expand All @@ -188,7 +188,7 @@ public void testInvalidDurationTooLong() {

assertThat(exception.getResult()).isEqualTo(OMException.ResultCodes.INVALID_REQUEST);
assertThat(exception.getMessage()).isEqualTo(
"Invalid Value: DurationSeconds must be between 900 and 43200 seconds");
"Invalid Value: DurationSeconds must be a number between 900 and 43200 seconds");
assertMarkForAuditCalled(request);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,8 @@ public final class S3STSConfigKeys {
// Action=AssumeRole&RoleArn=...&RoleSessionName=...&DurationSeconds=...
// where RoleArn max length is 2048 and max bytes per character in UTF-8 encoding is 12
// (2048 * 12 = 24576) + other parameters and overheads, so setting to 32 KB
// this limit can be adjusted via configuration if needed.
// 12 is the worst-case percent-encoded wire size for one code point in application/x-www-form-urlencoded data:
// a 4-byte UTF-8 sequence can become four %XX triplets
public static final int OZONE_S3G_STS_PAYLOAD_HASH_MAX_VALUE = 32768;

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -146,7 +146,7 @@ public Response get(
@QueryParam("Action") String action,
@QueryParam("RoleArn") String roleArn,
@QueryParam("RoleSessionName") String roleSessionName,
@QueryParam("DurationSeconds") Integer durationSeconds,
@QueryParam("DurationSeconds") String durationSeconds,
@QueryParam("Version") String version,
@QueryParam("Policy") String awsIamSessionPolicy) throws OS3Exception {

Expand All @@ -173,7 +173,7 @@ public Response post(Form form) throws OS3Exception {
final String action = formParams.getFirst("Action");
final String roleArn = formParams.getFirst("RoleArn");
final String roleSessionName = formParams.getFirst("RoleSessionName");
final Integer durationSeconds = parseIntegerOrNull(formParams.getFirst("DurationSeconds"));
final String durationSeconds = formParams.getFirst("DurationSeconds");
final String version = formParams.getFirst("Version");
final String awsIamSessionPolicy = formParams.getFirst("Policy");

Expand All @@ -182,7 +182,7 @@ public Response post(Form form) throws OS3Exception {
}

private Response handleSTSRequest(Set<String> paramNamesToValidate, String action, String roleArn,
String roleSessionName, Integer durationSeconds, String version, String awsIamSessionPolicy) throws OS3Exception {
String roleSessionName, String durationSeconds, String version, String awsIamSessionPolicy) throws OS3Exception {
final String requestId = requestIdentifier.getRequestId();
// NOTE: invalid, missing or unsupported actions are not added to the audit log
try {
Expand Down Expand Up @@ -219,13 +219,18 @@ private Response handleSTSRequest(Set<String> paramNamesToValidate, String actio
}

private Response handleAssumeRole(Set<String> paramNamesToValidate, String roleArn, String roleSessionName,
Integer durationSeconds, String awsIamSessionPolicy, String version, String requestId) throws OSTSException {
String durationSeconds, String awsIamSessionPolicy, String version, String requestId) throws OSTSException {
final String action = "AssumeRole";
final Map<String, String> auditParams = getAuditParameters();
int duration = S3STSUtils.DEFAULT_DURATION_SECONDS;
String durationValidationError = null;
try {
duration = S3STSUtils.validateDuration(durationSeconds);
} catch (OMException e) {
durationValidationError = e.getMessage();
}
S3STSUtils.addAssumeRoleAuditParams(
auditParams, roleArn, roleSessionName, awsIamSessionPolicy,
durationSeconds == null ? S3STSUtils.DEFAULT_DURATION_SECONDS : durationSeconds,
requestId);
auditParams, roleArn, roleSessionName, awsIamSessionPolicy, duration, requestId);

// Validate parameters
if (version == null || !version.equals(EXPECTED_VERSION)) {
Expand All @@ -247,11 +252,8 @@ private Response handleAssumeRole(Set<String> paramNamesToValidate, String roleA
}

final Set<String> validationErrors = new HashSet<>();
int duration = durationSeconds == null ? S3STSUtils.DEFAULT_DURATION_SECONDS : durationSeconds;
try {
duration = S3STSUtils.validateDuration(durationSeconds);
} catch (OMException e) {
validationErrors.add(e.getMessage());
if (durationValidationError != null) {
validationErrors.add(durationValidationError);
}

try {
Expand Down Expand Up @@ -429,18 +431,6 @@ private static boolean isAwsValidButNotImplementedAssumeRoleParameter(String par
|| Strings.CI.startsWith(paramName, TRANSITIVE_TAG_KEYS_MEMBER_PREFIX);
}

private static Integer parseIntegerOrNull(String value) throws OSTSException {
if (StringUtils.isBlank(value)) {
return null;
}
try {
return Integer.parseInt(value);
} catch (NumberFormatException e) {
throw new OSTSException(STS_VALIDATION_ERROR)
.withMessage("1 validation error detected: Invalid Value: DurationSeconds must be a number");
}
}

private static final class AssumeRoleParamValidationResult {
private static final AssumeRoleParamValidationResult EMPTY = new AssumeRoleParamValidationResult(
Collections.emptyList(), Collections.emptyList());
Expand Down
Loading