Skip to content
Open
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
Original file line number Diff line number Diff line change
Expand Up @@ -275,5 +275,7 @@ public enum ResultCodes {
KEY_UNDER_LEASE_SOFT_LIMIT_PERIOD,

TOO_MANY_SNAPSHOTS,

REVOKED_TOKEN,
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -569,6 +569,8 @@ enum Status {
KEY_UNDER_LEASE_SOFT_LIMIT_PERIOD = 97;

TOO_MANY_SNAPSHOTS = 98;

REVOKED_TOKEN = 99;
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,14 +18,17 @@
package org.apache.hadoop.ozone.security;

import static org.apache.hadoop.ozone.om.exceptions.OMException.ResultCodes.INVALID_TOKEN;
import static org.apache.hadoop.ozone.om.exceptions.OMException.ResultCodes.REVOKED_TOKEN;
import static org.apache.hadoop.ozone.protocol.proto.OzoneManagerProtocolProtos.OMTokenProto.Type.S3AUTHINFO;

import com.google.protobuf.ServiceException;
import java.time.Clock;
import java.time.ZoneOffset;
import org.apache.hadoop.hdds.annotation.InterfaceAudience;
import org.apache.hadoop.hdds.annotation.InterfaceStability;
import org.apache.hadoop.hdds.utils.db.Table;
import org.apache.hadoop.io.Text;
import org.apache.hadoop.ozone.om.OMMetadataManager;
import org.apache.hadoop.ozone.om.OzoneManager;
import org.apache.hadoop.ozone.om.exceptions.OMException;
import org.apache.hadoop.ozone.om.exceptions.OMLeaderNotReadyException;
Expand All @@ -34,6 +37,8 @@
import org.apache.hadoop.ozone.protocol.proto.OzoneManagerProtocolProtos.S3Authentication;
import org.apache.hadoop.ozone.protocolPB.OzoneManagerProtocolServerSideTranslatorPB;
import org.apache.hadoop.security.token.SecretManager;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

/**
* Utility class which holds methods required for parse/validation of
Expand All @@ -44,6 +49,7 @@
public final class S3SecurityUtil {

private static final Clock CLOCK = Clock.system(ZoneOffset.UTC);
private static final Logger LOG = LoggerFactory.getLogger(S3SecurityUtil.class);

private S3SecurityUtil() {
}
Expand All @@ -64,6 +70,13 @@ public static void validateS3Credential(OMRequest omRequest,
if (!token.isEmpty()) {
final STSTokenIdentifier stsTokenIdentifier = STSSecurityUtil.constructValidateAndDecryptSTSToken(
token, ozoneManager.getSecretKeyClient(), CLOCK);

// Ensure the token is not revoked
if (isRevokedStsTempAccessKeyId(stsTokenIdentifier, ozoneManager)) {
LOG.info("Session token has been revoked: {}, {}", stsTokenIdentifier.getTempAccessKeyId(), token);
throw new OMException("STS token has been revoked", REVOKED_TOKEN);
}

// HMAC signature and expiration were validated above. Now validate AWS signature.
validateSTSTokenAwsSignature(stsTokenIdentifier, omRequest);
OzoneManager.setStsTokenIdentifier(stsTokenIdentifier);
Expand Down Expand Up @@ -124,4 +137,32 @@ private static void validateSTSTokenAwsSignature(STSTokenIdentifier stsTokenIden
throw new OMException(
"STS token validation failed for token: " + omRequest.getS3Authentication().getSessionToken(), INVALID_TOKEN);
}

/**
* Returns true if the STS token's temporary access key ID is present in the revoked STS token table.
*/
private static boolean isRevokedStsTempAccessKeyId(STSTokenIdentifier stsTokenIdentifier, OzoneManager ozoneManager) {
try {
final OMMetadataManager metadataManager = ozoneManager.getMetadataManager();
if (metadataManager == null) {
return false;
}

final Table<String, String> revokedStsTokenTable = metadataManager.getS3RevokedStsTokenTable();
if (revokedStsTokenTable == null) {
return false;
}

final String tempAccessKeyId = stsTokenIdentifier.getTempAccessKeyId();
if (tempAccessKeyId == null || tempAccessKeyId.isEmpty()) {
return false;
}

return revokedStsTokenTable.getIfExist(tempAccessKeyId) != null;
} catch (Exception e) {
// Any DB or codec problem is treated as best-effort failure.
LOG.warn("Failed to check STS token revocation state: {}", e.getMessage());
return false;
}
}
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There are some common mocking and setup steps we are doing in all the tests in this file. Can those be moved to a different method with the annotations of @BeforeAll and @BeforeEach?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

updated

Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
/*
* 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.security;

import static org.apache.hadoop.ozone.om.exceptions.OMException.ResultCodes.REVOKED_TOKEN;
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.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.CALLS_REAL_METHODS;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.mockStatic;
import static org.mockito.Mockito.when;

import java.time.Clock;
import java.time.Instant;
import java.util.UUID;
import java.util.concurrent.ThreadLocalRandom;
import org.apache.hadoop.hdds.security.symmetric.SecretKeyClient;
import org.apache.hadoop.hdds.utils.db.InMemoryTestTable;
import org.apache.hadoop.hdds.utils.db.Table;
import org.apache.hadoop.ozone.om.OMMetadataManager;
import org.apache.hadoop.ozone.om.OzoneManager;
import org.apache.hadoop.ozone.om.exceptions.OMException;
import org.apache.hadoop.ozone.protocol.proto.OzoneManagerProtocolProtos.OMRequest;
import org.apache.hadoop.ozone.protocol.proto.OzoneManagerProtocolProtos.S3Authentication;
import org.apache.hadoop.ozone.protocol.proto.OzoneManagerProtocolProtos.Type;
import org.junit.jupiter.api.Test;
import org.mockito.MockedStatic;

/**
* Tests for STS revocation handling in {@link S3SecurityUtil}.
*/
public class TestS3SecurityUtil {
private static final byte[] ENCRYPTION_KEY = new byte[5];

{
ThreadLocalRandom.current().nextBytes(ENCRYPTION_KEY);
}

@Test
public void testValidateS3CredentialFailsWhenTokenRevoked() throws Exception {
// If the revoked STS token table contains an entry for the temporary access key id extracted from the session
// token, validateS3Credential should reject the request with REVOKED_TOKEN
validateS3CredentialHelper("session-token-a", true, true, REVOKED_TOKEN);
}

@Test
public void testValidateS3CredentialWhenMetadataUnavailable() throws Exception {
// If the metadata manager is not available, the revocation check should not cause the request to be rejected.
validateS3CredentialHelper("session-token-b", false, false, null);
}

@Test
public void testValidateS3CredentialSuccessWhenNotRevoked() throws Exception {
// Normal case: token is NOT revoked and request is accepted
validateS3CredentialHelper("session-token-c", true, false, null);
}

private void validateS3CredentialHelper(String sessionToken, boolean metadataAvailable, boolean isRevoked,
OMException.ResultCodes expectedResult) throws Exception {

try (OzoneManager ozoneManager = mock(OzoneManager.class)) {
when(ozoneManager.isSecurityEnabled()).thenReturn(true);
when(ozoneManager.getSecretKeyClient()).thenReturn(mock(SecretKeyClient.class));

final Table<String, String> revokedSTSTokenTable = new InMemoryTestTable<>();
if (metadataAvailable) {
final OMMetadataManager metadataManager = mock(OMMetadataManager.class);
when(ozoneManager.getMetadataManager()).thenReturn(metadataManager);
when(metadataManager.getS3RevokedStsTokenTable()).thenReturn(revokedSTSTokenTable);
} else {
when(ozoneManager.getMetadataManager()).thenReturn(null);
}

final String tempAccessKeyId = "temp-access-key-id";
if (isRevoked) {
revokedSTSTokenTable.put(tempAccessKeyId, sessionToken);
}

final STSTokenIdentifier stsTokenIdentifier = createSTSTokenIdentifier();

try (MockedStatic<STSSecurityUtil> stsSecurityUtilMock = mockStatic(STSSecurityUtil.class, CALLS_REAL_METHODS);
MockedStatic<AWSV4AuthValidator> awsV4AuthValidatorMock = mockStatic(
AWSV4AuthValidator.class, CALLS_REAL_METHODS)) {

stsSecurityUtilMock.when(
() -> STSSecurityUtil.constructValidateAndDecryptSTSToken(
eq(sessionToken), any(SecretKeyClient.class), any(Clock.class)))
.thenReturn(stsTokenIdentifier);

// Mock AWS V4 signature validation
awsV4AuthValidatorMock.when(() -> AWSV4AuthValidator.validateRequest(anyString(), anyString(), anyString()))
.thenReturn(true);

final OMRequest omRequest = createRequestWithSessionToken(sessionToken);

if (expectedResult != null) {
final OMException ex = assertThrows(OMException.class,
() -> S3SecurityUtil.validateS3Credential(omRequest, ozoneManager));
assertEquals(expectedResult, ex.getResult());
} else {
assertDoesNotThrow(() -> S3SecurityUtil.validateS3Credential(omRequest, ozoneManager));
}
}
}
}

private STSTokenIdentifier createSTSTokenIdentifier() {
return new STSTokenIdentifier(
"temp-access-key-id", "original-access-key-id", "arn:aws:iam::123456789012:role/test-role",
Instant.now().plusSeconds(3600), "secret-access-key", "session-policy",
ENCRYPTION_KEY);
}

private static OMRequest createRequestWithSessionToken(String sessionToken) {
final S3Authentication s3Authentication = S3Authentication.newBuilder()
.setAccessId("accessKeyId")
.setStringToSign("string-to-sign")
.setSignature("signature")
.setSessionToken(sessionToken)
.build();

return OMRequest.newBuilder()
.setClientId(UUID.randomUUID().toString())
.setCmdType(Type.CreateVolume)
.setS3Authentication(s3Authentication)
.build();
}
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could you please mention the reason for this change?

Copy link
Contributor Author

@fmorg-git fmorg-git Dec 9, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it is mainly for slight performance improvement. SecureRandom can sometimes block, and also since this is a unit test with dummy data, there is no need for strong cryptographic generation of encryption key, so I switched to ThreadLocalRandom. I used ThreadLocalRandom in this PR for TestS3SecurityUtil unit test and I remembered this other change from a previous PR, so I just changed it as well.

Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.UUID;
import java.util.concurrent.ThreadLocalRandom;
import org.apache.hadoop.ozone.protocol.proto.OzoneManagerProtocolProtos.OMTokenProto;
import org.junit.jupiter.api.Test;

Expand All @@ -39,7 +40,7 @@ public class TestSTSTokenIdentifier {
private static final byte[] ENCRYPTION_KEY = new byte[5];

{
new SecureRandom().nextBytes(ENCRYPTION_KEY);
ThreadLocalRandom.current().nextBytes(ENCRYPTION_KEY);
}

@Test
Expand Down