From e96df8e20039b8d7e81dcfcc9c40b6cd9f184abc Mon Sep 17 00:00:00 2001 From: fmorg-git Date: Wed, 12 Nov 2025 11:38:06 -0800 Subject: [PATCH 01/54] HDDS-13887. [STS] Protobuf Plumbing for AssumeRole Requests (#9254) --- .../hadoop/ozone/client/ObjectStore.java | 20 ++ .../ozone/client/protocol/ClientProtocol.java | 17 ++ .../hadoop/ozone/client/rpc/RpcClient.java | 11 + .../java/org/apache/hadoop/ozone/OmUtils.java | 1 + .../om/helpers/AssumeRoleResponseInfo.java | 133 ++++++++ .../om/protocol/OzoneManagerProtocol.java | 22 ++ ...ManagerProtocolClientSideTranslatorPB.java | 24 ++ .../helpers/TestAssumeRoleResponseInfo.java | 286 ++++++++++++++++++ .../src/main/proto/OmClientProtocol.proto | 27 ++ .../apache/hadoop/ozone/audit/OMAction.java | 2 + .../ozone/client/ClientProtocolStub.java | 11 + 11 files changed, 554 insertions(+) create mode 100644 hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/om/helpers/AssumeRoleResponseInfo.java create mode 100644 hadoop-ozone/common/src/test/java/org/apache/hadoop/ozone/om/helpers/TestAssumeRoleResponseInfo.java diff --git a/hadoop-ozone/client/src/main/java/org/apache/hadoop/ozone/client/ObjectStore.java b/hadoop-ozone/client/src/main/java/org/apache/hadoop/ozone/client/ObjectStore.java index 456dc9162145..18e28f387c64 100644 --- a/hadoop-ozone/client/src/main/java/org/apache/hadoop/ozone/client/ObjectStore.java +++ b/hadoop-ozone/client/src/main/java/org/apache/hadoop/ozone/client/ObjectStore.java @@ -36,6 +36,7 @@ import org.apache.hadoop.ozone.OzoneFsServerDefaults; import org.apache.hadoop.ozone.client.protocol.ClientProtocol; import org.apache.hadoop.ozone.om.exceptions.OMException; +import org.apache.hadoop.ozone.om.helpers.AssumeRoleResponseInfo; import org.apache.hadoop.ozone.om.helpers.BucketLayout; import org.apache.hadoop.ozone.om.helpers.DeleteTenantState; import org.apache.hadoop.ozone.om.helpers.OmVolumeArgs; @@ -750,6 +751,25 @@ public Iterator listSnapshotDiffJobs( return new SnapshotDiffJobIterator(volumeName, bucketName, jobStatus, listAllStatus, prevSnapshotDiffJob); } + /** + * Process the AssumeRole operation. + * + * @param roleArn The ARN of the role to assume + * @param roleSessionName The session name (should be unique) for this operation + * @param durationSeconds The duration in seconds for the token validity + * @param awsIamSessionPolicy The AWS IAM JSON session policy + * @return AssumeRoleResponseInfo The AssumeRole response information containing temporary credentials + * @throws IOException if an error occurs during the AssumeRole operation + */ + public AssumeRoleResponseInfo assumeRole( + String roleArn, + String roleSessionName, + int durationSeconds, + String awsIamSessionPolicy + ) throws IOException { + return proxy.assumeRole(roleArn, roleSessionName, durationSeconds, awsIamSessionPolicy); + } + /** * An Iterator to iterate over {@link SnapshotDiffJobIterator} list. */ diff --git a/hadoop-ozone/client/src/main/java/org/apache/hadoop/ozone/client/protocol/ClientProtocol.java b/hadoop-ozone/client/src/main/java/org/apache/hadoop/ozone/client/protocol/ClientProtocol.java index e3a575896347..7560f2efc61a 100644 --- a/hadoop-ozone/client/src/main/java/org/apache/hadoop/ozone/client/protocol/ClientProtocol.java +++ b/hadoop-ozone/client/src/main/java/org/apache/hadoop/ozone/client/protocol/ClientProtocol.java @@ -46,6 +46,7 @@ import org.apache.hadoop.ozone.client.io.OzoneOutputStream; import org.apache.hadoop.ozone.om.OMConfigKeys; import org.apache.hadoop.ozone.om.exceptions.OMException; +import org.apache.hadoop.ozone.om.helpers.AssumeRoleResponseInfo; import org.apache.hadoop.ozone.om.helpers.DeleteTenantState; import org.apache.hadoop.ozone.om.helpers.ErrorInfo; import org.apache.hadoop.ozone.om.helpers.LeaseKeyInfo; @@ -1359,4 +1360,20 @@ void putObjectTagging(String volumeName, String bucketName, String keyName, void deleteObjectTagging(String volumeName, String bucketName, String keyName) throws IOException; + /** + * Process the AssumeRole operation. + * + * @param roleArn The ARN of the role to assume + * @param roleSessionName The session name (should be unique) for this operation + * @param durationSeconds The duration in seconds for the token validity + * @param awsIamSessionPolicy The AWS IAM JSON session policy + * @return AssumeRoleResponseInfo The AssumeRole response information containing temporary credentials + * @throws IOException if an error occurs during the AssumeRole operation + */ + AssumeRoleResponseInfo assumeRole( + String roleArn, + String roleSessionName, + int durationSeconds, + String awsIamSessionPolicy + ) throws IOException; } diff --git a/hadoop-ozone/client/src/main/java/org/apache/hadoop/ozone/client/rpc/RpcClient.java b/hadoop-ozone/client/src/main/java/org/apache/hadoop/ozone/client/rpc/RpcClient.java index d4ebf0be1b38..115aa0bd20c0 100644 --- a/hadoop-ozone/client/src/main/java/org/apache/hadoop/ozone/client/rpc/RpcClient.java +++ b/hadoop-ozone/client/src/main/java/org/apache/hadoop/ozone/client/rpc/RpcClient.java @@ -127,6 +127,7 @@ import org.apache.hadoop.ozone.client.protocol.ClientProtocol; import org.apache.hadoop.ozone.om.OmConfig; import org.apache.hadoop.ozone.om.exceptions.OMException; +import org.apache.hadoop.ozone.om.helpers.AssumeRoleResponseInfo; import org.apache.hadoop.ozone.om.helpers.BasicOmKeyInfo; import org.apache.hadoop.ozone.om.helpers.BucketEncryptionKeyInfo; import org.apache.hadoop.ozone.om.helpers.BucketLayout; @@ -2790,6 +2791,16 @@ public void deleteObjectTagging(String volumeName, String bucketName, ozoneManagerClient.deleteObjectTagging(keyArgs); } + @Override + public AssumeRoleResponseInfo assumeRole( + String roleArn, + String roleSessionName, + int durationSeconds, + String awsIamSessionPolicy + ) throws IOException { + return ozoneManagerClient.assumeRole(roleArn, roleSessionName, durationSeconds, awsIamSessionPolicy); + } + private static ExecutorService createThreadPoolExecutor( int corePoolSize, int maximumPoolSize, String threadNameFormat) { return new ThreadPoolExecutor(corePoolSize, maximumPoolSize, diff --git a/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/OmUtils.java b/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/OmUtils.java index 4c20b3808654..be1c422711ae 100644 --- a/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/OmUtils.java +++ b/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/OmUtils.java @@ -300,6 +300,7 @@ public static boolean isReadOnly( case CompleteMultiPartUpload: case AbortMultiPartUpload: case GetS3Secret: + case AssumeRole: case GetDelegationToken: case RenewDelegationToken: case CancelDelegationToken: diff --git a/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/om/helpers/AssumeRoleResponseInfo.java b/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/om/helpers/AssumeRoleResponseInfo.java new file mode 100644 index 000000000000..08bf14ef4a26 --- /dev/null +++ b/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/om/helpers/AssumeRoleResponseInfo.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.helpers; + +import java.util.Objects; +import net.jcip.annotations.Immutable; +import org.apache.hadoop.ozone.protocol.proto.OzoneManagerProtocolProtos.AssumeRoleResponse; + +/** + * Utility class to handle AssumeRoleResponse protobuf message. + */ +@Immutable +public class AssumeRoleResponseInfo { + + private final String accessKeyId; + private final String secretAccessKey; + private final String sessionToken; + private final long expirationEpochSeconds; + private final String assumedRoleId; + + public String getAccessKeyId() { + return accessKeyId; + } + + public String getSecretAccessKey() { + return secretAccessKey; + } + + public String getSessionToken() { + return sessionToken; + } + + public long getExpirationEpochSeconds() { + return expirationEpochSeconds; + } + + public String getAssumedRoleId() { + return assumedRoleId; + } + + public AssumeRoleResponseInfo( + String accessKeyId, + String secretAccessKey, + String sessionToken, + long expirationEpochSeconds, + String assumedRoleId + ) { + this.accessKeyId = accessKeyId; + this.secretAccessKey = secretAccessKey; + this.sessionToken = sessionToken; + this.expirationEpochSeconds = expirationEpochSeconds; + this.assumedRoleId = assumedRoleId; + } + + public static AssumeRoleResponseInfo fromProtobuf( + AssumeRoleResponse response + ) { + return new AssumeRoleResponseInfo( + response.getAccessKeyId(), + response.getSecretAccessKey(), + response.getSessionToken(), + response.getExpirationEpochSeconds(), + response.getAssumedRoleId() + ); + } + + public AssumeRoleResponse getProtobuf() { + return AssumeRoleResponse.newBuilder() + .setAccessKeyId(accessKeyId) + .setSecretAccessKey(secretAccessKey) + .setSessionToken(sessionToken) + .setExpirationEpochSeconds(expirationEpochSeconds) + .setAssumedRoleId(assumedRoleId) + .build(); + } + + @Override + public String toString() { + return "AssumeRoleResponseInfo{" + + "accessKeyId='" + accessKeyId + '\'' + + ", secretAccessKey='" + secretAccessKey + '\'' + + ", sessionToken='" + sessionToken + '\'' + + ", expirationEpochSeconds=" + expirationEpochSeconds + + ", assumedRoleId='" + assumedRoleId + '\'' + + '}'; + } + + @Override + public boolean equals( + Object o + ) { + if (this == o) { + return true; + } + + if (o == null || getClass() != o.getClass()) { + return false; + } + + final AssumeRoleResponseInfo that = (AssumeRoleResponseInfo) o; + return expirationEpochSeconds == that.expirationEpochSeconds && + Objects.equals(accessKeyId, that.accessKeyId) && + Objects.equals(secretAccessKey, that.secretAccessKey) && + Objects.equals(sessionToken, that.sessionToken) && + Objects.equals(assumedRoleId, that.assumedRoleId); + } + + @Override + public int hashCode() { + return Objects.hash( + accessKeyId, + secretAccessKey, + sessionToken, + expirationEpochSeconds, + assumedRoleId + ); + } +} diff --git a/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/om/protocol/OzoneManagerProtocol.java b/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/om/protocol/OzoneManagerProtocol.java index 3bcf190662af..b41510bb2bff 100644 --- a/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/om/protocol/OzoneManagerProtocol.java +++ b/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/om/protocol/OzoneManagerProtocol.java @@ -29,6 +29,7 @@ import org.apache.hadoop.ozone.om.IOmMetadataReader; import org.apache.hadoop.ozone.om.OMConfigKeys; import org.apache.hadoop.ozone.om.exceptions.OMException; +import org.apache.hadoop.ozone.om.helpers.AssumeRoleResponseInfo; import org.apache.hadoop.ozone.om.helpers.DBUpdates; import org.apache.hadoop.ozone.om.helpers.DeleteTenantState; import org.apache.hadoop.ozone.om.helpers.ErrorInfo; @@ -1175,4 +1176,25 @@ default void deleteObjectTagging(OmKeyArgs args) throws IOException { * @throws IOException */ void startQuotaRepair(List buckets) throws IOException; + + /** + * Process the AssumeRole operation. + * + * @param roleArn The ARN of the role to assume + * @param roleSessionName The session name (should be unique) for this operation + * @param durationSeconds The duration in seconds for the token validity + * @param awsIamSessionPolicy The AWS IAM JSON session policy + * @return AssumeRoleResponseInfo The AssumeRole response information containing temporary credentials + * @throws IOException if an error occurs during the AssumeRole operation + */ + default AssumeRoleResponseInfo assumeRole( + String roleArn, + String roleSessionName, + int durationSeconds, + String awsIamSessionPolicy + ) throws IOException { + throw new UnsupportedOperationException( + "OzoneManager does not require this to be implemented" + ); + } } diff --git a/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/om/protocolPB/OzoneManagerProtocolClientSideTranslatorPB.java b/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/om/protocolPB/OzoneManagerProtocolClientSideTranslatorPB.java index 671a93a486ec..222f4bc1f48f 100644 --- a/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/om/protocolPB/OzoneManagerProtocolClientSideTranslatorPB.java +++ b/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/om/protocolPB/OzoneManagerProtocolClientSideTranslatorPB.java @@ -53,6 +53,7 @@ import org.apache.hadoop.ozone.ClientVersion; import org.apache.hadoop.ozone.OzoneAcl; import org.apache.hadoop.ozone.om.exceptions.OMException; +import org.apache.hadoop.ozone.om.helpers.AssumeRoleResponseInfo; import org.apache.hadoop.ozone.om.helpers.BasicOmKeyInfo; import org.apache.hadoop.ozone.om.helpers.DBUpdates; import org.apache.hadoop.ozone.om.helpers.DeleteTenantState; @@ -2650,6 +2651,29 @@ public void deleteObjectTagging(OmKeyArgs args) throws IOException { handleError(omResponse); } + @Override + public AssumeRoleResponseInfo assumeRole( + String roleArn, + String roleSessionName, + int durationSeconds, + String awsIamSessionPolicy + ) throws IOException { + final OzoneManagerProtocolProtos.AssumeRoleRequest.Builder request = + OzoneManagerProtocolProtos.AssumeRoleRequest.newBuilder() + .setRoleArn(roleArn) + .setRoleSessionName(roleSessionName) + .setDurationSeconds(durationSeconds) + .setAwsIamSessionPolicy(awsIamSessionPolicy != null ? awsIamSessionPolicy : ""); + + final OMRequest omRequest = createOMRequest(Type.AssumeRole) + .setAssumeRoleRequest(request) + .build(); + + return AssumeRoleResponseInfo.fromProtobuf( + handleError(submitRequest(omRequest)).getAssumeRoleResponse() + ); + } + private SafeMode toProtoBuf(SafeModeAction action) { switch (action) { case ENTER: diff --git a/hadoop-ozone/common/src/test/java/org/apache/hadoop/ozone/om/helpers/TestAssumeRoleResponseInfo.java b/hadoop-ozone/common/src/test/java/org/apache/hadoop/ozone/om/helpers/TestAssumeRoleResponseInfo.java new file mode 100644 index 000000000000..db5a409864d6 --- /dev/null +++ b/hadoop-ozone/common/src/test/java/org/apache/hadoop/ozone/om/helpers/TestAssumeRoleResponseInfo.java @@ -0,0 +1,286 @@ +/* + * 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.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; + +import org.apache.hadoop.ozone.protocol.proto.OzoneManagerProtocolProtos.AssumeRoleResponse; +import org.junit.jupiter.api.Test; + +/** + * Test AssumeRoleResponseInfo. + */ +public class TestAssumeRoleResponseInfo { + + private static final String ACCESS_KEY_ID = "ASIA7O1AJD8VV4KCEAX5"; + private static final String SECRET_ACCESS_KEY = "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"; + private static final String SESSION_TOKEN = "jgIDCAMaI2lkYnJva2VyL2lkYnJva2VyY2xpZW50QEVYQU1QTEUuQ09" + + "NOLDJ8bClM2IjaWRicm9rZXIvaWRicm9rZXJjbGllbnRARVhBTVBMRS5DT02CASRjMGM5YTk2NS00YTU1LTRmMjQtYTUxMi0" + + "3MGQ3M2JiMzg0ZDSKARRBU0lBN08xQUpEOFdWNEtDRUFYNZIBKGFybjphd3M6aWFtOjoxMjM0NTY3ODkwMTI6cm9sZS9mbS1" + + "kd3JvbGWaAXAvdmNOSjNqRW5zc3UyWklKYWxJbGRXZSswV1VmYkRvSmwxdXV1eDBPVExQalVzZ0VqOVE5T0FZVUZTd2JtUGo" + + "zZHNhaXpjMytacEJiVXJDNWRSV1FOTE4xcWJsVkhSdEZiZFBPTXp4NU5YY1pXdz09ogHQAVt7InJvbGVOYW1lIjoiZm0tZHd" + + "yb2xlIiwiZ3JhbnRzIjpbeyJvYmplY3RzIjpbImtleTogL3Mzdi9idWNrZXQxLyoiXSwicGVybWlzc2lvbnMiOlsicmVhZCJ" + + "dfSx7Im9iamVjdHMiOlsidm9sdW1lOiAvczN2Il0sInBlcm1pc3Npb25zIjpbInJlYWQiXX0seyJvYmplY3RzIjpbImJ1Y2t" + + "ldDogL3Mzdi9idWNrZXQxIl0sInBlcm1pc3Npb25zIjpbInJlYWQiXX1dfV0gCil_LhVjpP4hfMez4L5wNZDeqEubSeBfEow" + + "VoRnSQ-wIU1RTVG9rZW4DU1RT"; + private static final long EXPIRATION_EPOCH_SECONDS = 1577836800L; + private static final String ASSUMED_ROLE_ID = "arn:aws:iam::123456789012:role/MyRole"; + + @Test + public void testConstructor() { + final AssumeRoleResponseInfo response = new AssumeRoleResponseInfo( + ACCESS_KEY_ID, + SECRET_ACCESS_KEY, + SESSION_TOKEN, + EXPIRATION_EPOCH_SECONDS, + ASSUMED_ROLE_ID + ); + + assertEquals(ACCESS_KEY_ID, response.getAccessKeyId()); + assertEquals(SECRET_ACCESS_KEY, response.getSecretAccessKey()); + assertEquals(SESSION_TOKEN, response.getSessionToken()); + assertEquals(EXPIRATION_EPOCH_SECONDS, response.getExpirationEpochSeconds()); + assertEquals(ASSUMED_ROLE_ID, response.getAssumedRoleId()); + } + + @Test + public void testProtobufConversion() { + final AssumeRoleResponseInfo response = new AssumeRoleResponseInfo( + ACCESS_KEY_ID, + SECRET_ACCESS_KEY, + SESSION_TOKEN, + EXPIRATION_EPOCH_SECONDS, + ASSUMED_ROLE_ID + ); + + final AssumeRoleResponse proto = response.getProtobuf(); + + assertNotNull(proto); + assertEquals(ACCESS_KEY_ID, proto.getAccessKeyId()); + assertEquals(SECRET_ACCESS_KEY, proto.getSecretAccessKey()); + assertEquals(SESSION_TOKEN, proto.getSessionToken()); + assertEquals(EXPIRATION_EPOCH_SECONDS, proto.getExpirationEpochSeconds()); + assertEquals(ASSUMED_ROLE_ID, proto.getAssumedRoleId()); + } + + @Test + public void testFromProtobuf() { + final AssumeRoleResponse proto = AssumeRoleResponse.newBuilder() + .setAccessKeyId(ACCESS_KEY_ID) + .setSecretAccessKey(SECRET_ACCESS_KEY) + .setSessionToken(SESSION_TOKEN) + .setExpirationEpochSeconds(EXPIRATION_EPOCH_SECONDS) + .setAssumedRoleId(ASSUMED_ROLE_ID) + .build(); + + final AssumeRoleResponseInfo response = AssumeRoleResponseInfo.fromProtobuf(proto); + + assertEquals(ACCESS_KEY_ID, response.getAccessKeyId()); + assertEquals(SECRET_ACCESS_KEY, response.getSecretAccessKey()); + assertEquals(SESSION_TOKEN, response.getSessionToken()); + assertEquals(EXPIRATION_EPOCH_SECONDS, response.getExpirationEpochSeconds()); + assertEquals(ASSUMED_ROLE_ID, response.getAssumedRoleId()); + } + + @Test + public void testProtobufRoundTrip() { + final AssumeRoleResponseInfo originalResponse = new AssumeRoleResponseInfo( + ACCESS_KEY_ID, + SECRET_ACCESS_KEY, + SESSION_TOKEN, + EXPIRATION_EPOCH_SECONDS, + ASSUMED_ROLE_ID + ); + + final AssumeRoleResponse proto = originalResponse.getProtobuf(); + final AssumeRoleResponseInfo recoveredResponse = AssumeRoleResponseInfo.fromProtobuf(proto); + + assertEquals(originalResponse, recoveredResponse); + } + + @Test + public void testEqualsAndHashCodeWithIdenticalObjects() { + final AssumeRoleResponseInfo response1 = new AssumeRoleResponseInfo( + ACCESS_KEY_ID, + SECRET_ACCESS_KEY, + SESSION_TOKEN, + EXPIRATION_EPOCH_SECONDS, + ASSUMED_ROLE_ID + ); + + final AssumeRoleResponseInfo response2 = new AssumeRoleResponseInfo( + ACCESS_KEY_ID, + SECRET_ACCESS_KEY, + SESSION_TOKEN, + EXPIRATION_EPOCH_SECONDS, + ASSUMED_ROLE_ID + ); + + assertEquals(response1, response2); + assertEquals(response1.hashCode(), response2.hashCode()); + } + + @Test + public void testNotEqualsAndHashCodeWithDifferentAccessKeyId() { + final AssumeRoleResponseInfo response1 = new AssumeRoleResponseInfo( + ACCESS_KEY_ID, + SECRET_ACCESS_KEY, + SESSION_TOKEN, + EXPIRATION_EPOCH_SECONDS, + ASSUMED_ROLE_ID + ); + + final AssumeRoleResponseInfo response2 = new AssumeRoleResponseInfo( + "DIFFERENT_KEY_ID", + SECRET_ACCESS_KEY, + SESSION_TOKEN, + EXPIRATION_EPOCH_SECONDS, + ASSUMED_ROLE_ID + ); + + assertNotEquals(response1, response2); + assertNotEquals(response1.hashCode(), response2.hashCode()); + } + + @Test + public void testNotEqualsAndHashCodeWithDifferentSecretAccessKey() { + final AssumeRoleResponseInfo response1 = new AssumeRoleResponseInfo( + ACCESS_KEY_ID, + SECRET_ACCESS_KEY, + SESSION_TOKEN, + EXPIRATION_EPOCH_SECONDS, + ASSUMED_ROLE_ID + ); + + final AssumeRoleResponseInfo response2 = new AssumeRoleResponseInfo( + ACCESS_KEY_ID, + "DIFFERENT_SECRET_KEY", + SESSION_TOKEN, + EXPIRATION_EPOCH_SECONDS, + ASSUMED_ROLE_ID + ); + + assertNotEquals(response1, response2); + assertNotEquals(response1.hashCode(), response2.hashCode()); + } + + @Test + public void testNotEqualsAndHashCodeWithDifferentSessionToken() { + final AssumeRoleResponseInfo response1 = new AssumeRoleResponseInfo( + ACCESS_KEY_ID, + SECRET_ACCESS_KEY, + SESSION_TOKEN, + EXPIRATION_EPOCH_SECONDS, + ASSUMED_ROLE_ID + ); + + final AssumeRoleResponseInfo response2 = new AssumeRoleResponseInfo( + ACCESS_KEY_ID, + SECRET_ACCESS_KEY, + "DIFFERENT_TOKEN", + EXPIRATION_EPOCH_SECONDS, + ASSUMED_ROLE_ID + ); + + assertNotEquals(response1, response2); + assertNotEquals(response1.hashCode(), response2.hashCode()); + } + + @Test + public void testNotEqualsAndHashCodeWithDifferentExpirationEpochSeconds() { + final AssumeRoleResponseInfo response1 = new AssumeRoleResponseInfo( + ACCESS_KEY_ID, + SECRET_ACCESS_KEY, + SESSION_TOKEN, + EXPIRATION_EPOCH_SECONDS, + ASSUMED_ROLE_ID + ); + + final AssumeRoleResponseInfo response2 = new AssumeRoleResponseInfo( + ACCESS_KEY_ID, + SECRET_ACCESS_KEY, + SESSION_TOKEN, + 9999999999L, + ASSUMED_ROLE_ID + ); + + assertNotEquals(response1, response2); + assertNotEquals(response1.hashCode(), response2.hashCode()); + } + + @Test + public void testNotEqualsAndHashCodeWithDifferentAssumedRoleId() { + final AssumeRoleResponseInfo response1 = new AssumeRoleResponseInfo( + ACCESS_KEY_ID, + SECRET_ACCESS_KEY, + SESSION_TOKEN, + EXPIRATION_EPOCH_SECONDS, + ASSUMED_ROLE_ID + ); + + final AssumeRoleResponseInfo response2 = new AssumeRoleResponseInfo( + ACCESS_KEY_ID, + SECRET_ACCESS_KEY, + SESSION_TOKEN, + EXPIRATION_EPOCH_SECONDS, + "DIFFERENT_ROLE_ID" + ); + + assertNotEquals(response1, response2); + assertNotEquals(response1.hashCode(), response2.hashCode()); + } + + @Test + public void testNotEqualsWithNull() { + final AssumeRoleResponseInfo response = new AssumeRoleResponseInfo( + ACCESS_KEY_ID, + SECRET_ACCESS_KEY, + SESSION_TOKEN, + EXPIRATION_EPOCH_SECONDS, + ASSUMED_ROLE_ID + ); + + assertNotEquals(null, response); + } + + @Test + public void testToString() { + final AssumeRoleResponseInfo response = new AssumeRoleResponseInfo( + ACCESS_KEY_ID, + SECRET_ACCESS_KEY, + SESSION_TOKEN, + EXPIRATION_EPOCH_SECONDS, + ASSUMED_ROLE_ID + ); + + final String toString = response.toString(); + final String expectedString = "AssumeRoleResponseInfo{" + + "accessKeyId='" + ACCESS_KEY_ID + '\'' + + ", secretAccessKey='" + SECRET_ACCESS_KEY + '\'' + + ", sessionToken='" + SESSION_TOKEN + '\'' + + ", expirationEpochSeconds=" + EXPIRATION_EPOCH_SECONDS + + ", assumedRoleId='" + ASSUMED_ROLE_ID + '\'' + + '}'; + + assertNotNull(toString); + assertEquals(expectedString, toString); + } +} + diff --git a/hadoop-ozone/interface-client/src/main/proto/OmClientProtocol.proto b/hadoop-ozone/interface-client/src/main/proto/OmClientProtocol.proto index 1e5675f612e6..8e455e703422 100644 --- a/hadoop-ozone/interface-client/src/main/proto/OmClientProtocol.proto +++ b/hadoop-ozone/interface-client/src/main/proto/OmClientProtocol.proto @@ -156,6 +156,7 @@ enum Type { PutObjectTagging = 140; GetObjectTagging = 141; DeleteObjectTagging = 142; + AssumeRole = 143; } enum SafeMode { @@ -304,6 +305,7 @@ message OMRequest { optional PutObjectTaggingRequest putObjectTaggingRequest = 141; optional DeleteObjectTaggingRequest deleteObjectTaggingRequest = 142; repeated SetSnapshotPropertyRequest SetSnapshotPropertyRequests = 143; + optional AssumeRoleRequest assumeRoleRequest = 144; } message OMResponse { @@ -437,6 +439,7 @@ message OMResponse { optional GetObjectTaggingResponse getObjectTaggingResponse = 140; optional PutObjectTaggingResponse putObjectTaggingResponse = 141; optional DeleteObjectTaggingResponse deleteObjectTaggingResponse = 142; + optional AssumeRoleResponse assumeRoleResponse = 143; } enum Status { @@ -1494,6 +1497,7 @@ message OMTokenProto { enum Type { DELEGATION_TOKEN = 1; S3AUTHINFO = 2; + S3_STS_TOKEN = 3; }; required Type type = 1; optional uint32 version = 2; @@ -1511,6 +1515,11 @@ message OMTokenProto { optional string strToSign = 14; optional string omServiceId = 15 [deprecated = true]; optional string secretKeyId = 16; + // STS-specific fields + optional string roleArn = 17; + optional string originalAccessKeyId = 18; + optional string secretAccessKey = 19; + optional string sessionPolicy = 20; } message SecretKeyProto { @@ -2263,6 +2272,9 @@ message S3Authentication { optional string stringToSign = 1; optional string signature = 2; optional string accessId = 3; + // If present, indicates this request uses STS temporary credentials + // and carries the base64-encoded session token for validation. + optional string sessionToken = 4; } message RecoverLeaseRequest { @@ -2354,6 +2366,21 @@ message DeleteObjectTaggingRequest { message DeleteObjectTaggingResponse { } +message AssumeRoleRequest { + required string roleArn = 1; + required string roleSessionName = 2; + optional int32 durationSeconds = 3 [default = 3600]; + optional string awsIamSessionPolicy = 4; +} + +message AssumeRoleResponse { + required string accessKeyId = 1; + required string secretAccessKey = 2; + required string sessionToken = 3; + required uint64 expirationEpochSeconds = 4; + required string assumedRoleId = 5; +} + /** The OM service that takes care of Ozone namespace. */ diff --git a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/audit/OMAction.java b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/audit/OMAction.java index f728a88b2b38..9be2bdea709f 100644 --- a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/audit/OMAction.java +++ b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/audit/OMAction.java @@ -83,6 +83,8 @@ public enum OMAction implements AuditAction { SET_S3_SECRET, REVOKE_S3_SECRET, + S3_ASSUME_ROLE, + CREATE_TENANT, DELETE_TENANT, LIST_TENANT, diff --git a/hadoop-ozone/s3gateway/src/test/java/org/apache/hadoop/ozone/client/ClientProtocolStub.java b/hadoop-ozone/s3gateway/src/test/java/org/apache/hadoop/ozone/client/ClientProtocolStub.java index 739babce1d06..ef0d32e23874 100644 --- a/hadoop-ozone/s3gateway/src/test/java/org/apache/hadoop/ozone/client/ClientProtocolStub.java +++ b/hadoop-ozone/s3gateway/src/test/java/org/apache/hadoop/ozone/client/ClientProtocolStub.java @@ -37,6 +37,7 @@ import org.apache.hadoop.ozone.client.io.OzoneInputStream; import org.apache.hadoop.ozone.client.io.OzoneOutputStream; import org.apache.hadoop.ozone.client.protocol.ClientProtocol; +import org.apache.hadoop.ozone.om.helpers.AssumeRoleResponseInfo; import org.apache.hadoop.ozone.om.helpers.DeleteTenantState; import org.apache.hadoop.ozone.om.helpers.ErrorInfo; import org.apache.hadoop.ozone.om.helpers.LeaseKeyInfo; @@ -803,4 +804,14 @@ public void deleteObjectTagging(String volumeName, String bucketName, String key getBucket(volumeName, bucketName).deleteObjectTagging(keyName); } + @Override + public AssumeRoleResponseInfo assumeRole( + String roleArn, + String roleSessionName, + int durationSeconds, + String awsIamSessionPolicy + ) throws IOException { + return null; + } + } From 281a5b3630ddc26343d67c48fada83851b07bdb2 Mon Sep 17 00:00:00 2001 From: fmorg-git Date: Thu, 13 Nov 2025 21:05:54 -0800 Subject: [PATCH 02/54] HDDS-13848. [STS] Artifacts for Ranger to authorize STS token (#9214) --- .../org/apache/hadoop/ozone/OzoneConsts.java | 1 + .../ozone/security/acl/AssumeRoleRequest.java | 127 ++++++++++++++++++ .../ozone/security/acl/IAccessAuthorizer.java | 29 +++- .../hadoop/ozone/security/acl/OzoneObj.java | 17 +++ .../ozone/security/acl/OzoneObjInfo.java | 19 +++ .../ozone/security/acl/RequestContext.java | 44 +++++- .../security/acl/TestAssumeRoleRequest.java | 71 ++++++++++ .../security/acl/TestRequestContext.java | 37 ++++- 8 files changed, 337 insertions(+), 8 deletions(-) create mode 100644 hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/security/acl/AssumeRoleRequest.java create mode 100644 hadoop-ozone/common/src/test/java/org/apache/hadoop/ozone/security/acl/TestAssumeRoleRequest.java diff --git a/hadoop-hdds/common/src/main/java/org/apache/hadoop/ozone/OzoneConsts.java b/hadoop-hdds/common/src/main/java/org/apache/hadoop/ozone/OzoneConsts.java index aecbdfae615d..99d78f786fa4 100644 --- a/hadoop-hdds/common/src/main/java/org/apache/hadoop/ozone/OzoneConsts.java +++ b/hadoop-hdds/common/src/main/java/org/apache/hadoop/ozone/OzoneConsts.java @@ -65,6 +65,7 @@ public final class OzoneConsts { public static final String OZONE_ACL_CREATE = "c"; public static final String OZONE_ACL_READ_ACL = "x"; public static final String OZONE_ACL_WRITE_ACL = "y"; + public static final String OZONE_ACL_ASSUME_ROLE = "m"; public static final String OZONE_DATE_FORMAT = "EEE, dd MMM yyyy HH:mm:ss zzz"; diff --git a/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/security/acl/AssumeRoleRequest.java b/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/security/acl/AssumeRoleRequest.java new file mode 100644 index 000000000000..1272d5422ec1 --- /dev/null +++ b/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/security/acl/AssumeRoleRequest.java @@ -0,0 +1,127 @@ +/* + * 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.acl; + +import java.net.InetAddress; +import java.util.Objects; +import java.util.Set; +import net.jcip.annotations.Immutable; +import org.apache.hadoop.security.UserGroupInformation; + +/** + * Represents an S3 AssumeRole request that needs to be authorized by an IAccessAuthorizer. + * The grants parameter can be null if the access must not be limited beyond the role. + * Note that if the grants parameter is the empty set, this means the access should + * be the intersection of the role and the empty set, meaning no access will be granted. + */ +@Immutable +public class AssumeRoleRequest { + private final String host; + private final InetAddress ip; + private final UserGroupInformation clientUgi; + private final String targetRoleName; + private final Set grants; + + public AssumeRoleRequest(String host, InetAddress ip, UserGroupInformation clientUgi, String targetRoleName, + Set grants) { + + this.host = host; + this.ip = ip; + this.clientUgi = clientUgi; + this.targetRoleName = targetRoleName; + this.grants = grants; + } + + public String getHost() { + return host; + } + + public InetAddress getIp() { + return ip; + } + + public UserGroupInformation getClientUgi() { + return clientUgi; + } + + public String getTargetRoleName() { + return targetRoleName; + } + + public Set getGrants() { + return grants; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } else if (o == null || getClass() != o.getClass()) { + return false; + } + + final AssumeRoleRequest that = (AssumeRoleRequest) o; + return Objects.equals(host, that.host) && Objects.equals(ip, that.ip) && + Objects.equals(clientUgi, that.clientUgi) && Objects.equals(targetRoleName, that.targetRoleName) && + Objects.equals(grants, that.grants); + } + + @Override + public int hashCode() { + return Objects.hash(host, ip, clientUgi, targetRoleName, grants); + } + + /** + * Encapsulates the IOzoneObj and associated permissions. + */ + @Immutable + public static class OzoneGrant { + private final Set objects; + private final Set permissions; + + public OzoneGrant(Set objects, Set permissions) { + this.objects = objects; + this.permissions = permissions; + } + + public Set getObjects() { + return objects; + } + + public Set getPermissions() { + return permissions; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } else if (o == null || getClass() != o.getClass()) { + return false; + } + + final OzoneGrant that = (OzoneGrant) o; + return Objects.equals(objects, that.objects) && Objects.equals(permissions, that.permissions); + } + + @Override + public int hashCode() { + return Objects.hash(objects, permissions); + } + } +} diff --git a/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/security/acl/IAccessAuthorizer.java b/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/security/acl/IAccessAuthorizer.java index f1218a9aa088..8a07bab606b0 100644 --- a/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/security/acl/IAccessAuthorizer.java +++ b/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/security/acl/IAccessAuthorizer.java @@ -17,6 +17,8 @@ package org.apache.hadoop.ozone.security.acl; +import static org.apache.hadoop.ozone.om.exceptions.OMException.ResultCodes.NOT_SUPPORTED_OPERATION; + import java.util.Arrays; import java.util.BitSet; import java.util.Collections; @@ -48,6 +50,25 @@ public interface IAccessAuthorizer { boolean checkAccess(IOzoneObj ozoneObject, RequestContext context) throws OMException; + /** + * Attempts to authorize an STS AssumeRole request. If authorized, returns a String + * representation of the authorized session policy. This return value must be supplied on the subsequent + * {@link IAccessAuthorizer#checkAccess(IOzoneObj, RequestContext)} call, using the + * {@link RequestContext.Builder#setSessionPolicy(String)} parameter, and the authorizer will + * use the Role permissions and the session policy permissions to determine if + * the attempted action should be allowed for the given STS token. + *

+ * The user making this call must have the {@link ACLType#ASSUME_ROLE} permission. + * + * @param assumeRoleRequest the AssumeRole request containing role and optional limited scope policy grants + * @return a String representing the permissions granted according to the authorizer. + * @throws OMException if the caller is not authorized, either for the role and/or policy or for the + * {@link ACLType#ASSUME_ROLE} permission + */ + default String generateAssumeRoleSessionPolicy(AssumeRoleRequest assumeRoleRequest) throws OMException { + throw new OMException("The generateAssumeRoleSessionPolicy call is not supported", NOT_SUPPORTED_OPERATION); + } + /** * @return true for Ozone-native authorizer */ @@ -67,7 +88,9 @@ enum ACLType { READ_ACL, WRITE_ACL, ALL, - NONE; + NONE, + ASSUME_ROLE; // ability to create STS tokens + private static int length = ACLType.values().length; static { if (length > 16) { @@ -121,6 +144,8 @@ public static ACLType getACLRight(String type) { return ACLType.ALL; case OzoneConsts.OZONE_ACL_NONE: return ACLType.NONE; + case OzoneConsts.OZONE_ACL_ASSUME_ROLE: + return ACLType.ASSUME_ROLE; default: throw new IllegalArgumentException("[" + type + "] ACL right is not " + "recognized"); @@ -161,6 +186,8 @@ public static String getAclString(ACLType acl) { return OzoneConsts.OZONE_ACL_ALL; case NONE: return OzoneConsts.OZONE_ACL_NONE; + case ASSUME_ROLE: + return OzoneConsts.OZONE_ACL_ASSUME_ROLE; default: throw new IllegalArgumentException("ACL right is not recognized"); } diff --git a/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/security/acl/OzoneObj.java b/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/security/acl/OzoneObj.java index ae3a8ad15929..cb5dcfce8f51 100644 --- a/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/security/acl/OzoneObj.java +++ b/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/security/acl/OzoneObj.java @@ -22,6 +22,7 @@ import com.google.common.base.Preconditions; import java.util.LinkedHashMap; import java.util.Map; +import java.util.Objects; import org.apache.hadoop.ozone.OzoneConsts; import org.apache.hadoop.ozone.protocol.proto.OzoneManagerProtocolProtos; import org.apache.hadoop.ozone.protocol.proto.OzoneManagerProtocolProtos.OzoneObj.ObjectType; @@ -146,4 +147,20 @@ public Map toAuditMap() { return auditMap; } + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } else if (o == null || getClass() != o.getClass()) { + return false; + } + + final OzoneObj that = (OzoneObj) o; + return resType == that.resType && storeType == that.storeType; + } + + @Override + public int hashCode() { + return Objects.hash(resType, storeType); + } } diff --git a/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/security/acl/OzoneObjInfo.java b/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/security/acl/OzoneObjInfo.java index 36b450bec7fa..aa0c05af99c4 100644 --- a/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/security/acl/OzoneObjInfo.java +++ b/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/security/acl/OzoneObjInfo.java @@ -19,6 +19,7 @@ import static org.apache.hadoop.ozone.OzoneConsts.OZONE_URI_DELIMITER; +import java.util.Objects; import org.apache.commons.lang3.StringUtils; import org.apache.hadoop.ozone.om.helpers.OmKeyArgs; import org.apache.hadoop.ozone.protocol.proto.OzoneManagerProtocolProtos; @@ -237,4 +238,22 @@ public OzoneObjInfo build() { name, ozonePrefixPath); } } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } else if (!super.equals(o)) { + return false; + } + + final OzoneObjInfo that = (OzoneObjInfo) o; + return Objects.equals(volumeName, that.volumeName) && Objects.equals(bucketName, that.bucketName) && + Objects.equals(name, that.name) && Objects.equals(ozonePrefixPath, that.ozonePrefixPath); + } + + @Override + public int hashCode() { + return Objects.hash(super.hashCode(), volumeName, bucketName, name, ozonePrefixPath); + } } diff --git a/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/security/acl/RequestContext.java b/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/security/acl/RequestContext.java index 08724eae5ff2..f2d25c2ad231 100644 --- a/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/security/acl/RequestContext.java +++ b/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/security/acl/RequestContext.java @@ -43,13 +43,21 @@ public class RequestContext { */ private final boolean recursiveAccessCheck; + /** + * Represents optional session policy JSON for Ranger to use when authorizing + * an STS token. This value would have come as a result of a previous + * {@link IAccessAuthorizer#generateAssumeRoleSessionPolicy(AssumeRoleRequest)} call. + * The sessionPolicy includes the roleName. + */ + private final String sessionPolicy; + @SuppressWarnings("parameternumber") public RequestContext(String host, InetAddress ip, UserGroupInformation clientUgi, String serviceId, ACLIdentityType aclType, ACLType aclRights, String ownerName) { this(host, ip, clientUgi, serviceId, aclType, aclRights, ownerName, - false); + false, null); } @SuppressWarnings("parameternumber") @@ -57,6 +65,14 @@ public RequestContext(String host, InetAddress ip, UserGroupInformation clientUgi, String serviceId, ACLIdentityType aclType, ACLType aclRights, String ownerName, boolean recursiveAccessCheck) { + this(host, ip, clientUgi, serviceId, aclType, aclRights, ownerName, + recursiveAccessCheck, null); + } + + @SuppressWarnings("parameternumber") + public RequestContext(String host, InetAddress ip, UserGroupInformation clientUgi, String serviceId, + ACLIdentityType aclType, ACLType aclRights, String ownerName, boolean recursiveAccessCheck, + String sessionPolicy) { this.host = host; this.ip = ip; this.clientUgi = clientUgi; @@ -65,6 +81,7 @@ public RequestContext(String host, InetAddress ip, this.aclRights = aclRights; this.ownerName = ownerName; this.recursiveAccessCheck = recursiveAccessCheck; + this.sessionPolicy = sessionPolicy; } /** @@ -85,6 +102,7 @@ public static class Builder { private String ownerName; private boolean recursiveAccessCheck; + private String sessionPolicy; public Builder setHost(String bHost) { this.host = bHost; @@ -130,9 +148,14 @@ public Builder setRecursiveAccessCheck(boolean recursiveAccessCheckFlag) { return this; } + public Builder setSessionPolicy(String sessionPolicy) { + this.sessionPolicy = sessionPolicy; + return this; + } + public RequestContext build() { return new RequestContext(host, ip, clientUgi, serviceId, aclType, - aclRights, ownerName, recursiveAccessCheck); + aclRights, ownerName, recursiveAccessCheck, sessionPolicy); } } @@ -144,21 +167,26 @@ public static RequestContext.Builder getBuilder( UserGroupInformation ugi, InetAddress remoteAddress, String hostName, ACLType aclType, String ownerName) { return getBuilder(ugi, remoteAddress, hostName, aclType, ownerName, - false); + false); } public static RequestContext.Builder getBuilder( UserGroupInformation ugi, InetAddress remoteAddress, String hostName, ACLType aclType, String ownerName, boolean recursiveAccessCheck) { - RequestContext.Builder contextBuilder = RequestContext.newBuilder() + return getBuilder(ugi, remoteAddress, hostName, aclType, ownerName, recursiveAccessCheck, null); + } + + public static RequestContext.Builder getBuilder(UserGroupInformation ugi, InetAddress remoteAddress, String hostName, + ACLType aclType, String ownerName, boolean recursiveAccessCheck, String sessionPolicy) { + return RequestContext.newBuilder() .setClientUgi(ugi) .setIp(remoteAddress) .setHost(hostName) .setAclType(ACLIdentityType.USER) .setAclRights(aclType) .setOwnerName(ownerName) - .setRecursiveAccessCheck(recursiveAccessCheck); - return contextBuilder; + .setRecursiveAccessCheck(recursiveAccessCheck) + .setSessionPolicy(sessionPolicy); } public static RequestContext.Builder getBuilder(UserGroupInformation ugi, @@ -206,4 +234,8 @@ public String getOwnerName() { public boolean isRecursiveAccessCheck() { return recursiveAccessCheck; } + + public String getSessionPolicy() { + return sessionPolicy; + } } diff --git a/hadoop-ozone/common/src/test/java/org/apache/hadoop/ozone/security/acl/TestAssumeRoleRequest.java b/hadoop-ozone/common/src/test/java/org/apache/hadoop/ozone/security/acl/TestAssumeRoleRequest.java new file mode 100644 index 000000000000..e9d9c519bd11 --- /dev/null +++ b/hadoop-ozone/common/src/test/java/org/apache/hadoop/ozone/security/acl/TestAssumeRoleRequest.java @@ -0,0 +1,71 @@ +/* + * 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.acl; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; + +import java.util.Collections; +import java.util.HashSet; +import java.util.Set; +import org.apache.hadoop.security.UserGroupInformation; +import org.junit.jupiter.api.Test; + +/** + * Tests for {@link AssumeRoleRequest}. + */ +public class TestAssumeRoleRequest { + + @Test + public void testConstructorAndGetters() { + final UserGroupInformation ugi = UserGroupInformation.createRemoteUser("om"); + final Set grants = new HashSet<>(); + grants.add( + new AssumeRoleRequest.OzoneGrant( + Collections.singleton( + OzoneObjInfo.Builder.newBuilder() + .setResType(OzoneObj.ResourceType.BUCKET) + .setStoreType(OzoneObj.StoreType.OZONE) + .setVolumeName("s3v") + .setBucketName("myBucket") + .build()), + Collections.singleton(IAccessAuthorizer.ACLType.READ))); + + final AssumeRoleRequest assumeRoleRequest1 = new AssumeRoleRequest( + "host", null, ugi, "roleA", grants); + final AssumeRoleRequest assumeRoleRequest2 = new AssumeRoleRequest( + "host", null, ugi, "roleA", grants); + + assertEquals("host", assumeRoleRequest1.getHost()); + assertNull(assumeRoleRequest1.getIp()); + assertSame(ugi, assumeRoleRequest1.getClientUgi()); + assertEquals("roleA", assumeRoleRequest1.getTargetRoleName()); + assertEquals(grants, assumeRoleRequest1.getGrants()); + + assertEquals(assumeRoleRequest1, assumeRoleRequest2); + assertEquals(assumeRoleRequest1.hashCode(), assumeRoleRequest2.hashCode()); + + final AssumeRoleRequest assumeRoleRequest3 = new AssumeRoleRequest( + "host", null, ugi, "roleB", null); + assertNotEquals(assumeRoleRequest1, assumeRoleRequest3); + } +} + + diff --git a/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/security/acl/TestRequestContext.java b/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/security/acl/TestRequestContext.java index 086704d8236d..cb05c2ef6260 100644 --- a/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/security/acl/TestRequestContext.java +++ b/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/security/acl/TestRequestContext.java @@ -17,7 +17,9 @@ package org.apache.hadoop.ozone.security.acl; +import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertTrue; import java.io.IOException; @@ -80,6 +82,40 @@ public void testRecursiveAccessFlag() throws IOException { "Wrongly sets recursive flag value"); } + @Test + public void testSessionPolicy() { + final RequestContext.Builder builder = new RequestContext.Builder(); + RequestContext context = builder.build(); + assertNull(context.getSessionPolicy(), "sessionPolicy should default to null"); + + final String policy = "{\"Statement\":[]}"; + context = new RequestContext.Builder() + .setSessionPolicy(policy) + .build(); + assertEquals(policy, context.getSessionPolicy(), "sessionPolicy should be set via builder"); + + context = new RequestContext( + "host", null, null, "serviceId", IAccessAuthorizer.ACLIdentityType.GROUP, + IAccessAuthorizer.ACLType.CREATE, "owner", true, policy); + assertTrue(context.isRecursiveAccessCheck(), "recursiveAccessCheck should be true"); + assertEquals(policy, context.getSessionPolicy(), "sessionPolicy should be set via constructor"); + + context = RequestContext.getBuilder( + UserGroupInformation.createRemoteUser("user1"), null, null, + IAccessAuthorizer.ACLType.CREATE, "volume1", true) + .setSessionPolicy(policy) + .build(); + assertEquals(policy, context.getSessionPolicy(), "sessionPolicy should be set via getBuilder + builder"); + + context = RequestContext.getBuilder( + UserGroupInformation.createRemoteUser("user1"), null, null, + IAccessAuthorizer.ACLType.CREATE, "volume1", true, policy) + .build(); + assertEquals( + policy, context.getSessionPolicy(), + "sessionPolicy should be set via getBuilder (all params) + builder"); + } + private RequestContext getUserRequestContext(String username, IAccessAuthorizer.ACLType type, boolean isOwner, String ownerName, boolean recursiveAccessCheck) throws IOException { @@ -96,4 +132,3 @@ private RequestContext getUserRequestContext(String username, type, ownerName).build(); } } - From 7eaebe6e428e2caab816f2f57a50ddcdd1854922 Mon Sep 17 00:00:00 2001 From: fmorg-git Date: Fri, 14 Nov 2025 09:05:46 -0800 Subject: [PATCH 03/54] HDDS-13888. [STS] Introduce S3AssumeRoleRequest and S3AssumeRoleResponse (#9276) --- .../s3/security/AwsRoleArnValidator.java | 145 +++++++ .../s3/security/S3AssumeRoleRequest.java | 222 +++++++++++ .../s3/security/S3AssumeRoleResponse.java | 42 ++ .../s3/security/S3SecurityTestUtils.java | 44 +++ .../s3/security/TestAwsRoleArnValidator.java | 131 +++++++ .../s3/security/TestS3AssumeRoleRequest.java | 364 ++++++++++++++++++ .../om/response/TestCleanupTableInfo.java | 2 + .../s3/security/TestS3AssumeRoleResponse.java | 133 +++++++ 8 files changed, 1083 insertions(+) create mode 100644 hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/s3/security/AwsRoleArnValidator.java create mode 100644 hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/s3/security/S3AssumeRoleRequest.java create mode 100644 hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/response/s3/security/S3AssumeRoleResponse.java create mode 100644 hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/om/request/s3/security/S3SecurityTestUtils.java create mode 100644 hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/om/request/s3/security/TestAwsRoleArnValidator.java create mode 100644 hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/om/request/s3/security/TestS3AssumeRoleRequest.java create mode 100644 hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/om/response/s3/security/TestS3AssumeRoleResponse.java 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 000000000000..1f5af2fcc598 --- /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: + *

{@code
+   * arn:aws:iam::123456789012:role/RoleA -> RoleA
+   * arn:aws:iam::123456789012:role/path/RoleB -> RoleB
+   * }
+ * + * @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 000000000000..dc31644c806c --- /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 000000000000..18ed41a55f73 --- /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 000000000000..9c91864a3fe1 --- /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 000000000000..b5deffc1e0de --- /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 000000000000..9a826393a5f9 --- /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 3683110af1e6..3ab4a8106cbe 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 000000000000..879c6d67eb60 --- /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(); + } +} + + From a8c868870a249c987cd5cb167d031f78a7547864 Mon Sep 17 00:00:00 2001 From: fmorg-git Date: Mon, 17 Nov 2025 09:24:31 -0800 Subject: [PATCH 04/54] HDDS-13909. [STS] Introduce STSTokenIdentifier class (#9277) --- .../hadoop/ozone/client/ObjectStore.java | 8 +- .../ozone/client/protocol/ClientProtocol.java | 8 +- .../hadoop/ozone/client/rpc/RpcClient.java | 8 +- .../om/helpers/AssumeRoleResponseInfo.java | 49 +-- .../om/protocol/OzoneManagerProtocol.java | 12 +- ...ManagerProtocolClientSideTranslatorPB.java | 11 +- .../helpers/TestAssumeRoleResponseInfo.java | 129 +----- .../ozone/security/STSTokenIdentifier.java | 225 ++++++++++ .../security/TestSTSTokenIdentifier.java | 392 ++++++++++++++++++ 9 files changed, 661 insertions(+), 181 deletions(-) create mode 100644 hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/security/STSTokenIdentifier.java create mode 100644 hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/security/TestSTSTokenIdentifier.java diff --git a/hadoop-ozone/client/src/main/java/org/apache/hadoop/ozone/client/ObjectStore.java b/hadoop-ozone/client/src/main/java/org/apache/hadoop/ozone/client/ObjectStore.java index 18e28f387c64..e783bafe227a 100644 --- a/hadoop-ozone/client/src/main/java/org/apache/hadoop/ozone/client/ObjectStore.java +++ b/hadoop-ozone/client/src/main/java/org/apache/hadoop/ozone/client/ObjectStore.java @@ -761,12 +761,8 @@ public Iterator listSnapshotDiffJobs( * @return AssumeRoleResponseInfo The AssumeRole response information containing temporary credentials * @throws IOException if an error occurs during the AssumeRole operation */ - public AssumeRoleResponseInfo assumeRole( - String roleArn, - String roleSessionName, - int durationSeconds, - String awsIamSessionPolicy - ) throws IOException { + public AssumeRoleResponseInfo assumeRole(String roleArn, String roleSessionName, int durationSeconds, + String awsIamSessionPolicy) throws IOException { return proxy.assumeRole(roleArn, roleSessionName, durationSeconds, awsIamSessionPolicy); } diff --git a/hadoop-ozone/client/src/main/java/org/apache/hadoop/ozone/client/protocol/ClientProtocol.java b/hadoop-ozone/client/src/main/java/org/apache/hadoop/ozone/client/protocol/ClientProtocol.java index 7560f2efc61a..d4cc1d1fb512 100644 --- a/hadoop-ozone/client/src/main/java/org/apache/hadoop/ozone/client/protocol/ClientProtocol.java +++ b/hadoop-ozone/client/src/main/java/org/apache/hadoop/ozone/client/protocol/ClientProtocol.java @@ -1370,10 +1370,6 @@ void deleteObjectTagging(String volumeName, String bucketName, String keyName) * @return AssumeRoleResponseInfo The AssumeRole response information containing temporary credentials * @throws IOException if an error occurs during the AssumeRole operation */ - AssumeRoleResponseInfo assumeRole( - String roleArn, - String roleSessionName, - int durationSeconds, - String awsIamSessionPolicy - ) throws IOException; + AssumeRoleResponseInfo assumeRole(String roleArn, String roleSessionName, int durationSeconds, + String awsIamSessionPolicy) throws IOException; } diff --git a/hadoop-ozone/client/src/main/java/org/apache/hadoop/ozone/client/rpc/RpcClient.java b/hadoop-ozone/client/src/main/java/org/apache/hadoop/ozone/client/rpc/RpcClient.java index 115aa0bd20c0..5c3b8eb4793f 100644 --- a/hadoop-ozone/client/src/main/java/org/apache/hadoop/ozone/client/rpc/RpcClient.java +++ b/hadoop-ozone/client/src/main/java/org/apache/hadoop/ozone/client/rpc/RpcClient.java @@ -2792,12 +2792,8 @@ public void deleteObjectTagging(String volumeName, String bucketName, } @Override - public AssumeRoleResponseInfo assumeRole( - String roleArn, - String roleSessionName, - int durationSeconds, - String awsIamSessionPolicy - ) throws IOException { + public AssumeRoleResponseInfo assumeRole(String roleArn, String roleSessionName, int durationSeconds, + String awsIamSessionPolicy) throws IOException { return ozoneManagerClient.assumeRole(roleArn, roleSessionName, durationSeconds, awsIamSessionPolicy); } diff --git a/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/om/helpers/AssumeRoleResponseInfo.java b/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/om/helpers/AssumeRoleResponseInfo.java index 08bf14ef4a26..5f21abb3cbd6 100644 --- a/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/om/helpers/AssumeRoleResponseInfo.java +++ b/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/om/helpers/AssumeRoleResponseInfo.java @@ -53,13 +53,8 @@ public String getAssumedRoleId() { return assumedRoleId; } - public AssumeRoleResponseInfo( - String accessKeyId, - String secretAccessKey, - String sessionToken, - long expirationEpochSeconds, - String assumedRoleId - ) { + public AssumeRoleResponseInfo(String accessKeyId, String secretAccessKey, String sessionToken, + long expirationEpochSeconds, String assumedRoleId) { this.accessKeyId = accessKeyId; this.secretAccessKey = secretAccessKey; this.sessionToken = sessionToken; @@ -67,16 +62,10 @@ public AssumeRoleResponseInfo( this.assumedRoleId = assumedRoleId; } - public static AssumeRoleResponseInfo fromProtobuf( - AssumeRoleResponse response - ) { + public static AssumeRoleResponseInfo fromProtobuf(AssumeRoleResponse response) { return new AssumeRoleResponseInfo( - response.getAccessKeyId(), - response.getSecretAccessKey(), - response.getSessionToken(), - response.getExpirationEpochSeconds(), - response.getAssumedRoleId() - ); + response.getAccessKeyId(), response.getSecretAccessKey(), response.getSessionToken(), + response.getExpirationEpochSeconds(), response.getAssumedRoleId()); } public AssumeRoleResponse getProtobuf() { @@ -91,19 +80,13 @@ public AssumeRoleResponse getProtobuf() { @Override public String toString() { - return "AssumeRoleResponseInfo{" + - "accessKeyId='" + accessKeyId + '\'' + - ", secretAccessKey='" + secretAccessKey + '\'' + - ", sessionToken='" + sessionToken + '\'' + - ", expirationEpochSeconds=" + expirationEpochSeconds + - ", assumedRoleId='" + assumedRoleId + '\'' + - '}'; + return "AssumeRoleResponseInfo{" + "accessKeyId='" + accessKeyId + "', secretAccessKey='" + secretAccessKey + + "', sessionToken='" + sessionToken + "', expirationEpochSeconds=" + expirationEpochSeconds + + ", assumedRoleId='" + assumedRoleId + "'}"; } @Override - public boolean equals( - Object o - ) { + public boolean equals(Object o) { if (this == o) { return true; } @@ -113,21 +96,13 @@ public boolean equals( } final AssumeRoleResponseInfo that = (AssumeRoleResponseInfo) o; - return expirationEpochSeconds == that.expirationEpochSeconds && - Objects.equals(accessKeyId, that.accessKeyId) && - Objects.equals(secretAccessKey, that.secretAccessKey) && - Objects.equals(sessionToken, that.sessionToken) && + return expirationEpochSeconds == that.expirationEpochSeconds && Objects.equals(accessKeyId, that.accessKeyId) && + Objects.equals(secretAccessKey, that.secretAccessKey) && Objects.equals(sessionToken, that.sessionToken) && Objects.equals(assumedRoleId, that.assumedRoleId); } @Override public int hashCode() { - return Objects.hash( - accessKeyId, - secretAccessKey, - sessionToken, - expirationEpochSeconds, - assumedRoleId - ); + return Objects.hash(accessKeyId, secretAccessKey, sessionToken, expirationEpochSeconds, assumedRoleId); } } diff --git a/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/om/protocol/OzoneManagerProtocol.java b/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/om/protocol/OzoneManagerProtocol.java index b41510bb2bff..4261f71c4e5f 100644 --- a/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/om/protocol/OzoneManagerProtocol.java +++ b/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/om/protocol/OzoneManagerProtocol.java @@ -1187,14 +1187,8 @@ default void deleteObjectTagging(OmKeyArgs args) throws IOException { * @return AssumeRoleResponseInfo The AssumeRole response information containing temporary credentials * @throws IOException if an error occurs during the AssumeRole operation */ - default AssumeRoleResponseInfo assumeRole( - String roleArn, - String roleSessionName, - int durationSeconds, - String awsIamSessionPolicy - ) throws IOException { - throw new UnsupportedOperationException( - "OzoneManager does not require this to be implemented" - ); + default AssumeRoleResponseInfo assumeRole(String roleArn, String roleSessionName, int durationSeconds, + String awsIamSessionPolicy) throws IOException { + throw new UnsupportedOperationException("OzoneManager does not require this to be implemented"); } } diff --git a/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/om/protocolPB/OzoneManagerProtocolClientSideTranslatorPB.java b/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/om/protocolPB/OzoneManagerProtocolClientSideTranslatorPB.java index 222f4bc1f48f..c3c173a8cae8 100644 --- a/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/om/protocolPB/OzoneManagerProtocolClientSideTranslatorPB.java +++ b/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/om/protocolPB/OzoneManagerProtocolClientSideTranslatorPB.java @@ -2652,12 +2652,8 @@ public void deleteObjectTagging(OmKeyArgs args) throws IOException { } @Override - public AssumeRoleResponseInfo assumeRole( - String roleArn, - String roleSessionName, - int durationSeconds, - String awsIamSessionPolicy - ) throws IOException { + public AssumeRoleResponseInfo assumeRole(String roleArn, String roleSessionName, int durationSeconds, + String awsIamSessionPolicy) throws IOException { final OzoneManagerProtocolProtos.AssumeRoleRequest.Builder request = OzoneManagerProtocolProtos.AssumeRoleRequest.newBuilder() .setRoleArn(roleArn) @@ -2670,8 +2666,7 @@ public AssumeRoleResponseInfo assumeRole( .build(); return AssumeRoleResponseInfo.fromProtobuf( - handleError(submitRequest(omRequest)).getAssumeRoleResponse() - ); + handleError(submitRequest(omRequest)).getAssumeRoleResponse()); } private SafeMode toProtoBuf(SafeModeAction action) { diff --git a/hadoop-ozone/common/src/test/java/org/apache/hadoop/ozone/om/helpers/TestAssumeRoleResponseInfo.java b/hadoop-ozone/common/src/test/java/org/apache/hadoop/ozone/om/helpers/TestAssumeRoleResponseInfo.java index db5a409864d6..38c74dc1f261 100644 --- a/hadoop-ozone/common/src/test/java/org/apache/hadoop/ozone/om/helpers/TestAssumeRoleResponseInfo.java +++ b/hadoop-ozone/common/src/test/java/org/apache/hadoop/ozone/om/helpers/TestAssumeRoleResponseInfo.java @@ -46,12 +46,7 @@ public class TestAssumeRoleResponseInfo { @Test public void testConstructor() { final AssumeRoleResponseInfo response = new AssumeRoleResponseInfo( - ACCESS_KEY_ID, - SECRET_ACCESS_KEY, - SESSION_TOKEN, - EXPIRATION_EPOCH_SECONDS, - ASSUMED_ROLE_ID - ); + ACCESS_KEY_ID, SECRET_ACCESS_KEY, SESSION_TOKEN, EXPIRATION_EPOCH_SECONDS, ASSUMED_ROLE_ID); assertEquals(ACCESS_KEY_ID, response.getAccessKeyId()); assertEquals(SECRET_ACCESS_KEY, response.getSecretAccessKey()); @@ -63,12 +58,7 @@ public void testConstructor() { @Test public void testProtobufConversion() { final AssumeRoleResponseInfo response = new AssumeRoleResponseInfo( - ACCESS_KEY_ID, - SECRET_ACCESS_KEY, - SESSION_TOKEN, - EXPIRATION_EPOCH_SECONDS, - ASSUMED_ROLE_ID - ); + ACCESS_KEY_ID, SECRET_ACCESS_KEY, SESSION_TOKEN, EXPIRATION_EPOCH_SECONDS, ASSUMED_ROLE_ID); final AssumeRoleResponse proto = response.getProtobuf(); @@ -102,12 +92,7 @@ public void testFromProtobuf() { @Test public void testProtobufRoundTrip() { final AssumeRoleResponseInfo originalResponse = new AssumeRoleResponseInfo( - ACCESS_KEY_ID, - SECRET_ACCESS_KEY, - SESSION_TOKEN, - EXPIRATION_EPOCH_SECONDS, - ASSUMED_ROLE_ID - ); + ACCESS_KEY_ID, SECRET_ACCESS_KEY, SESSION_TOKEN, EXPIRATION_EPOCH_SECONDS, ASSUMED_ROLE_ID); final AssumeRoleResponse proto = originalResponse.getProtobuf(); final AssumeRoleResponseInfo recoveredResponse = AssumeRoleResponseInfo.fromProtobuf(proto); @@ -118,20 +103,10 @@ public void testProtobufRoundTrip() { @Test public void testEqualsAndHashCodeWithIdenticalObjects() { final AssumeRoleResponseInfo response1 = new AssumeRoleResponseInfo( - ACCESS_KEY_ID, - SECRET_ACCESS_KEY, - SESSION_TOKEN, - EXPIRATION_EPOCH_SECONDS, - ASSUMED_ROLE_ID - ); + ACCESS_KEY_ID, SECRET_ACCESS_KEY, SESSION_TOKEN, EXPIRATION_EPOCH_SECONDS, ASSUMED_ROLE_ID); final AssumeRoleResponseInfo response2 = new AssumeRoleResponseInfo( - ACCESS_KEY_ID, - SECRET_ACCESS_KEY, - SESSION_TOKEN, - EXPIRATION_EPOCH_SECONDS, - ASSUMED_ROLE_ID - ); + ACCESS_KEY_ID, SECRET_ACCESS_KEY, SESSION_TOKEN, EXPIRATION_EPOCH_SECONDS, ASSUMED_ROLE_ID); assertEquals(response1, response2); assertEquals(response1.hashCode(), response2.hashCode()); @@ -140,20 +115,10 @@ public void testEqualsAndHashCodeWithIdenticalObjects() { @Test public void testNotEqualsAndHashCodeWithDifferentAccessKeyId() { final AssumeRoleResponseInfo response1 = new AssumeRoleResponseInfo( - ACCESS_KEY_ID, - SECRET_ACCESS_KEY, - SESSION_TOKEN, - EXPIRATION_EPOCH_SECONDS, - ASSUMED_ROLE_ID - ); + ACCESS_KEY_ID, SECRET_ACCESS_KEY, SESSION_TOKEN, EXPIRATION_EPOCH_SECONDS, ASSUMED_ROLE_ID); final AssumeRoleResponseInfo response2 = new AssumeRoleResponseInfo( - "DIFFERENT_KEY_ID", - SECRET_ACCESS_KEY, - SESSION_TOKEN, - EXPIRATION_EPOCH_SECONDS, - ASSUMED_ROLE_ID - ); + "DIFFERENT_KEY_ID", SECRET_ACCESS_KEY, SESSION_TOKEN, EXPIRATION_EPOCH_SECONDS, ASSUMED_ROLE_ID); assertNotEquals(response1, response2); assertNotEquals(response1.hashCode(), response2.hashCode()); @@ -162,20 +127,10 @@ public void testNotEqualsAndHashCodeWithDifferentAccessKeyId() { @Test public void testNotEqualsAndHashCodeWithDifferentSecretAccessKey() { final AssumeRoleResponseInfo response1 = new AssumeRoleResponseInfo( - ACCESS_KEY_ID, - SECRET_ACCESS_KEY, - SESSION_TOKEN, - EXPIRATION_EPOCH_SECONDS, - ASSUMED_ROLE_ID - ); + ACCESS_KEY_ID, SECRET_ACCESS_KEY, SESSION_TOKEN, EXPIRATION_EPOCH_SECONDS, ASSUMED_ROLE_ID); final AssumeRoleResponseInfo response2 = new AssumeRoleResponseInfo( - ACCESS_KEY_ID, - "DIFFERENT_SECRET_KEY", - SESSION_TOKEN, - EXPIRATION_EPOCH_SECONDS, - ASSUMED_ROLE_ID - ); + ACCESS_KEY_ID, "DIFFERENT_SECRET_KEY", SESSION_TOKEN, EXPIRATION_EPOCH_SECONDS, ASSUMED_ROLE_ID); assertNotEquals(response1, response2); assertNotEquals(response1.hashCode(), response2.hashCode()); @@ -184,20 +139,10 @@ public void testNotEqualsAndHashCodeWithDifferentSecretAccessKey() { @Test public void testNotEqualsAndHashCodeWithDifferentSessionToken() { final AssumeRoleResponseInfo response1 = new AssumeRoleResponseInfo( - ACCESS_KEY_ID, - SECRET_ACCESS_KEY, - SESSION_TOKEN, - EXPIRATION_EPOCH_SECONDS, - ASSUMED_ROLE_ID - ); + ACCESS_KEY_ID, SECRET_ACCESS_KEY, SESSION_TOKEN, EXPIRATION_EPOCH_SECONDS, ASSUMED_ROLE_ID); final AssumeRoleResponseInfo response2 = new AssumeRoleResponseInfo( - ACCESS_KEY_ID, - SECRET_ACCESS_KEY, - "DIFFERENT_TOKEN", - EXPIRATION_EPOCH_SECONDS, - ASSUMED_ROLE_ID - ); + ACCESS_KEY_ID, SECRET_ACCESS_KEY, "DIFFERENT_TOKEN", EXPIRATION_EPOCH_SECONDS, ASSUMED_ROLE_ID); assertNotEquals(response1, response2); assertNotEquals(response1.hashCode(), response2.hashCode()); @@ -206,20 +151,10 @@ public void testNotEqualsAndHashCodeWithDifferentSessionToken() { @Test public void testNotEqualsAndHashCodeWithDifferentExpirationEpochSeconds() { final AssumeRoleResponseInfo response1 = new AssumeRoleResponseInfo( - ACCESS_KEY_ID, - SECRET_ACCESS_KEY, - SESSION_TOKEN, - EXPIRATION_EPOCH_SECONDS, - ASSUMED_ROLE_ID - ); + ACCESS_KEY_ID, SECRET_ACCESS_KEY, SESSION_TOKEN, EXPIRATION_EPOCH_SECONDS, ASSUMED_ROLE_ID); final AssumeRoleResponseInfo response2 = new AssumeRoleResponseInfo( - ACCESS_KEY_ID, - SECRET_ACCESS_KEY, - SESSION_TOKEN, - 9999999999L, - ASSUMED_ROLE_ID - ); + ACCESS_KEY_ID, SECRET_ACCESS_KEY, SESSION_TOKEN, 9999999999L, ASSUMED_ROLE_ID); assertNotEquals(response1, response2); assertNotEquals(response1.hashCode(), response2.hashCode()); @@ -228,20 +163,10 @@ public void testNotEqualsAndHashCodeWithDifferentExpirationEpochSeconds() { @Test public void testNotEqualsAndHashCodeWithDifferentAssumedRoleId() { final AssumeRoleResponseInfo response1 = new AssumeRoleResponseInfo( - ACCESS_KEY_ID, - SECRET_ACCESS_KEY, - SESSION_TOKEN, - EXPIRATION_EPOCH_SECONDS, - ASSUMED_ROLE_ID - ); + ACCESS_KEY_ID, SECRET_ACCESS_KEY, SESSION_TOKEN, EXPIRATION_EPOCH_SECONDS, ASSUMED_ROLE_ID); final AssumeRoleResponseInfo response2 = new AssumeRoleResponseInfo( - ACCESS_KEY_ID, - SECRET_ACCESS_KEY, - SESSION_TOKEN, - EXPIRATION_EPOCH_SECONDS, - "DIFFERENT_ROLE_ID" - ); + ACCESS_KEY_ID, SECRET_ACCESS_KEY, SESSION_TOKEN, EXPIRATION_EPOCH_SECONDS, "DIFFERENT_ROLE_ID"); assertNotEquals(response1, response2); assertNotEquals(response1.hashCode(), response2.hashCode()); @@ -250,12 +175,7 @@ public void testNotEqualsAndHashCodeWithDifferentAssumedRoleId() { @Test public void testNotEqualsWithNull() { final AssumeRoleResponseInfo response = new AssumeRoleResponseInfo( - ACCESS_KEY_ID, - SECRET_ACCESS_KEY, - SESSION_TOKEN, - EXPIRATION_EPOCH_SECONDS, - ASSUMED_ROLE_ID - ); + ACCESS_KEY_ID, SECRET_ACCESS_KEY, SESSION_TOKEN, EXPIRATION_EPOCH_SECONDS, ASSUMED_ROLE_ID); assertNotEquals(null, response); } @@ -263,21 +183,12 @@ public void testNotEqualsWithNull() { @Test public void testToString() { final AssumeRoleResponseInfo response = new AssumeRoleResponseInfo( - ACCESS_KEY_ID, - SECRET_ACCESS_KEY, - SESSION_TOKEN, - EXPIRATION_EPOCH_SECONDS, - ASSUMED_ROLE_ID - ); + ACCESS_KEY_ID, SECRET_ACCESS_KEY, SESSION_TOKEN, EXPIRATION_EPOCH_SECONDS, ASSUMED_ROLE_ID); final String toString = response.toString(); - final String expectedString = "AssumeRoleResponseInfo{" + - "accessKeyId='" + ACCESS_KEY_ID + '\'' + - ", secretAccessKey='" + SECRET_ACCESS_KEY + '\'' + - ", sessionToken='" + SESSION_TOKEN + '\'' + - ", expirationEpochSeconds=" + EXPIRATION_EPOCH_SECONDS + - ", assumedRoleId='" + ASSUMED_ROLE_ID + '\'' + - '}'; + final String expectedString = "AssumeRoleResponseInfo{" + "accessKeyId='" + ACCESS_KEY_ID + + "', secretAccessKey='" + SECRET_ACCESS_KEY + "', sessionToken='" + SESSION_TOKEN + + "', expirationEpochSeconds=" + EXPIRATION_EPOCH_SECONDS + ", assumedRoleId='" + ASSUMED_ROLE_ID + "'}"; assertNotNull(toString); assertEquals(expectedString, toString); diff --git a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/security/STSTokenIdentifier.java b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/security/STSTokenIdentifier.java new file mode 100644 index 000000000000..071d7d21a9a8 --- /dev/null +++ b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/security/STSTokenIdentifier.java @@ -0,0 +1,225 @@ +/* + * 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 com.google.common.base.Preconditions; +import java.io.ByteArrayInputStream; +import java.io.DataInput; +import java.io.DataInputStream; +import java.io.DataOutput; +import java.io.IOException; +import java.time.Instant; +import java.util.Objects; +import java.util.UUID; +import org.apache.hadoop.hdds.annotation.InterfaceAudience; +import org.apache.hadoop.hdds.annotation.InterfaceStability; +import org.apache.hadoop.hdds.security.token.ShortLivedTokenIdentifier; +import org.apache.hadoop.io.Text; +import org.apache.hadoop.ozone.protocol.proto.OzoneManagerProtocolProtos.OMTokenProto; + +/** + * Token identifier for STS (Security Token Service) tokens. + */ +@InterfaceAudience.Private +@InterfaceStability.Unstable +public class STSTokenIdentifier extends ShortLivedTokenIdentifier { + public static final Text KIND_NAME = new Text("STSToken"); + + // STS-specific fields + private String roleArn; + private String originalAccessKeyId; + private String secretAccessKey; + private String sessionPolicy; + + // Service name for STS tokens + public static final String STS_SERVICE = "STS"; + + /** + * Create an empty STS token identifier. + */ + public STSTokenIdentifier() { + super(); + } + + /** + * Create a new STS token identifier with encryption support. + * + * @param tempAccessKeyId the temporary access key ID (owner) + * @param originalAccessKeyId the original long-lived access key ID that created this token + * @param roleArn the ARN of the assumed role + * @param expiry the token expiration time + * @param secretAccessKey the secret access key associated with the temporary access key ID + * @param sessionPolicy an optional opaque identifier that further limits the scope of + * the permissions granted by the role + */ + public STSTokenIdentifier(String tempAccessKeyId, String originalAccessKeyId, String roleArn, Instant expiry, + String secretAccessKey, String sessionPolicy) { + super(tempAccessKeyId, expiry); + this.originalAccessKeyId = originalAccessKeyId; + this.roleArn = roleArn; + this.secretAccessKey = secretAccessKey; + this.sessionPolicy = sessionPolicy; + } + + @Override + public Text getKind() { + return KIND_NAME; + } + + @Override + public String getService() { + return STS_SERVICE; + } + + @Override + public void readFromByteArray(byte[] bytes) throws IOException { + final DataInputStream in = new DataInputStream(new ByteArrayInputStream(bytes)); + readFields(in); + } + + @Override + public void write(DataOutput out) throws IOException { + out.write(toProtoBuf().toByteArray()); + } + + @Override + public void readFields(DataInput in) throws IOException { + final OMTokenProto token = OMTokenProto.parseFrom((DataInputStream) in); + fromProtoBuf(token); + } + + /** + * Convert this identifier to protobuf format. + */ + public OMTokenProto toProtoBuf() { + final OMTokenProto.Builder builder = OMTokenProto.newBuilder() + .setType(OMTokenProto.Type.S3_STS_TOKEN) + .setMaxDate(getExpiry().toEpochMilli()) + .setOwner(getOwnerId() != null ? getOwnerId() : "") + .setAccessKeyId(getOwnerId() != null ? getOwnerId() : "") + .setOriginalAccessKeyId(originalAccessKeyId != null ? originalAccessKeyId : "") + .setRoleArn(roleArn != null ? roleArn : "") + // TODO sts - encrypt secret access key in a future PR + .setSecretAccessKey(secretAccessKey != null ? secretAccessKey : "") + .setSessionPolicy(sessionPolicy != null ? sessionPolicy : ""); + + if (getSecretKeyId() != null) { + builder.setSecretKeyId(getSecretKeyId().toString()); + } + + return builder.build(); + } + + /** + * Initialize this identifier from protobuf. + */ + public void fromProtoBuf(OMTokenProto token) throws IOException { + Preconditions.checkArgument( + token.getType() == OMTokenProto.Type.S3_STS_TOKEN, + "Invalid token type for STSTokenIdentifier: " + token.getType()); + + setOwnerId(token.getOwner()); + setExpiry(Instant.ofEpochMilli(token.getMaxDate())); + + if (token.hasOriginalAccessKeyId()) { + this.originalAccessKeyId = token.getOriginalAccessKeyId(); + } + if (token.hasRoleArn()) { + this.roleArn = token.getRoleArn(); + } + if (token.hasSecretAccessKey()) { + // TODO sts - decrypt secret access key in a future PR + this.secretAccessKey = token.getSecretAccessKey(); + } + + if (token.hasSecretKeyId()) { + try { + setSecretKeyId(UUID.fromString(token.getSecretKeyId())); + } catch (IllegalArgumentException e) { + // Handle invalid UUID format gracefully + throw new IOException( + "Invalid secretKeyId format in STS token: " + token.getSecretKeyId(), e); + } + } + + if (token.hasSessionPolicy()) { + this.sessionPolicy = token.getSessionPolicy(); + } + } + + public String getRoleArn() { + return roleArn; + } + + public String getSecretAccessKey() { + return secretAccessKey; + } + + public String getOriginalAccessKeyId() { + return originalAccessKeyId; + } + + /** + * Get the temporary access key ID (same as owner). + */ + public String getTempAccessKeyId() { + return getOwnerId(); + } + + /** + * Optional session policy associated with this STS token, or null/empty if none. + */ + public String getSessionPolicy() { + return sessionPolicy; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + + if (o == null || getClass() != o.getClass()) { + return false; + } + + if (!super.equals(o)) { + return false; + } + + final STSTokenIdentifier that = (STSTokenIdentifier) o; + return Objects.equals(roleArn, that.roleArn) && Objects.equals(secretAccessKey, that.secretAccessKey) && + Objects.equals(originalAccessKeyId, that.originalAccessKeyId) && + Objects.equals(sessionPolicy, that.sessionPolicy); + } + + @Override + public int hashCode() { + return Objects.hash( + super.hashCode(), roleArn, secretAccessKey, originalAccessKeyId, sessionPolicy); + } + + @Override + public String toString() { + // Intentionally left off secretAccessKey + return "STSTokenIdentifier{" + "tempAccessKeyId='" + getOwnerId() + "'" + + ", originalAccessKeyId='" + originalAccessKeyId + "', roleArn='" + roleArn + "'" + + ", expiry='" + getExpiry() + "', secretKeyId='" + getSecretKeyId() + "'" + + ", sessionPolicy='" + sessionPolicy + "'}"; + } +} diff --git a/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/security/TestSTSTokenIdentifier.java b/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/security/TestSTSTokenIdentifier.java new file mode 100644 index 000000000000..37909703a7f4 --- /dev/null +++ b/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/security/TestSTSTokenIdentifier.java @@ -0,0 +1,392 @@ +/* + * 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.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.io.ByteArrayOutputStream; +import java.io.DataOutputStream; +import java.io.IOException; +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.UUID; +import org.apache.hadoop.ozone.protocol.proto.OzoneManagerProtocolProtos.OMTokenProto; +import org.junit.jupiter.api.Test; + +/** + * Unit tests for STSTokenIdentifier. + */ +public class TestSTSTokenIdentifier { + + @Test + public void testKindAndService() { + final STSTokenIdentifier stsTokenIdentifier = new STSTokenIdentifier( + "tempAccessKeyId", "originalAccessKeyId", "roleArn", + Instant.now().plusSeconds(3600), "secretAccessKey", "sessionPolicy"); + + assertEquals("STSToken", stsTokenIdentifier.getKind().toString()); + assertEquals("STS", stsTokenIdentifier.getService()); + } + + @Test + public void testProtoBufRoundTrip() throws IOException { + // STSTokenIdentifier persists expiry with millisecond precision (via toEpochMilli), + // so use a millisecond-precision Instant to avoid nanos-only differences across + // platforms/JDKs during round-trips. + final Instant expiry = Instant.now().plusSeconds(7200).truncatedTo(ChronoUnit.MILLIS); + final STSTokenIdentifier originalTokenIdentifier = new STSTokenIdentifier( + "tempAccess", "origAccess", "arn:aws:iam::123456789012:role/RoleY", + expiry, "secretKey", "sessionPolicy"); + final UUID secretKeyId = UUID.randomUUID(); + originalTokenIdentifier.setSecretKeyId(secretKeyId); + + final OMTokenProto proto = originalTokenIdentifier.toProtoBuf(); + assertThat(proto.getType()).isEqualTo(OMTokenProto.Type.S3_STS_TOKEN); + assertThat(proto.getOwner()).isEqualTo("tempAccess"); + assertThat(proto.getMaxDate()).isEqualTo(expiry.toEpochMilli()); + assertThat(proto.getOriginalAccessKeyId()).isEqualTo("origAccess"); + assertThat(proto.getRoleArn()).isEqualTo("arn:aws:iam::123456789012:role/RoleY"); + assertThat(proto.getSecretAccessKey()).isEqualTo("secretKey"); + assertThat(proto.getSessionPolicy()).isEqualTo("sessionPolicy"); + assertThat(proto.getSecretKeyId()).isEqualTo(secretKeyId.toString()); + + final STSTokenIdentifier parsedTokenIdentifier = new STSTokenIdentifier(); + parsedTokenIdentifier.fromProtoBuf(proto); + + assertThat(parsedTokenIdentifier.getOwnerId()).isEqualTo("tempAccess"); + assertThat(parsedTokenIdentifier.getExpiry()).isEqualTo(expiry); + assertThat(parsedTokenIdentifier.getOriginalAccessKeyId()).isEqualTo("origAccess"); + assertThat(parsedTokenIdentifier.getRoleArn()).isEqualTo("arn:aws:iam::123456789012:role/RoleY"); + assertThat(parsedTokenIdentifier.getSecretAccessKey()).isEqualTo("secretKey"); + assertThat(parsedTokenIdentifier.getSecretKeyId()).isEqualTo(secretKeyId); + assertThat(parsedTokenIdentifier.getSessionPolicy()).isEqualTo("sessionPolicy"); + assertThat(parsedTokenIdentifier).isEqualTo(originalTokenIdentifier); + assertThat(parsedTokenIdentifier.hashCode()).isEqualTo(originalTokenIdentifier.hashCode()); + } + + @Test + public void testFromProtoBufInvalidSecretKeyId() { + final OMTokenProto invalid = OMTokenProto.newBuilder() + .setType(OMTokenProto.Type.S3_STS_TOKEN) + .setOwner("tempAccessKeyId") + .setMaxDate(Instant.now().toEpochMilli()) + .setSecretKeyId("not-a-uuid") + .build(); + + final STSTokenIdentifier stsTokenIdentifier = new STSTokenIdentifier( + "tempAccessKeyId", "originalAccessKeyId", "roleArn", Instant.now(), + "secretAccessKey", "sessionPolicy"); + + final IOException ex = assertThrows(IOException.class, () -> stsTokenIdentifier.fromProtoBuf(invalid)); + assertThat(ex.getMessage()).isEqualTo("Invalid secretKeyId format in STS token: not-a-uuid"); + } + + @Test + public void testProtobufRoundTripWithNullSessionPolicy() throws IOException { + final Instant expiry = Instant.now().plusSeconds(7200); + final STSTokenIdentifier stsTokenIdentifier = new STSTokenIdentifier( + "tempAccess", "origAccess", "arn:aws:iam::123456789012:role/RoleX", + expiry, "secretKey", null); + + final OMTokenProto proto = stsTokenIdentifier.toProtoBuf(); + assertThat(proto.getSessionPolicy()).isEmpty(); + + final STSTokenIdentifier parsedTokenIdentifier = new STSTokenIdentifier(); + parsedTokenIdentifier.fromProtoBuf(proto); + + assertThat(parsedTokenIdentifier.getSessionPolicy()).isEmpty(); + } + + @Test + public void testProtobufRoundTripWithEmptySessionPolicy() throws IOException { + final Instant expiry = Instant.now().plusSeconds(4000); + final STSTokenIdentifier stsTokenIdentifier = new STSTokenIdentifier( + "tempAccess", "origAccess", "arn:aws:iam::123456789012:role/RoleZ", + expiry, "secretKey", ""); + + final OMTokenProto proto = stsTokenIdentifier.toProtoBuf(); + assertThat(proto.getSessionPolicy()).isEmpty(); + + final STSTokenIdentifier parsedTokenIdentifier = new STSTokenIdentifier(); + parsedTokenIdentifier.fromProtoBuf(proto); + + assertThat(parsedTokenIdentifier.getSessionPolicy()).isEmpty(); + } + + @Test + public void testFromProtoBufInvalidTokenType() { + final OMTokenProto invalidType = OMTokenProto.newBuilder() + .setType(OMTokenProto.Type.DELEGATION_TOKEN) // Wrong type + .setOwner("tempAccessKeyId") + .setMaxDate(Instant.now().toEpochMilli()) + .build(); + + final STSTokenIdentifier stsTokenIdentifier = new STSTokenIdentifier( + "tempAccessKeyId", "origAccessKeyId", "roleArn", Instant.now(), + "secretAccessKey", "sessionPolicy"); + + final IllegalArgumentException ex = assertThrows( + IllegalArgumentException.class, () -> stsTokenIdentifier.fromProtoBuf(invalidType)); + assertThat(ex.getMessage()).isEqualTo("Invalid token type for STSTokenIdentifier: DELEGATION_TOKEN"); + } + + @Test + public void testWriteToAndReadFromByteArray() throws Exception { + // Use millisecond-precision Instant so that the value survives the + // toEpochMilli() / Instant.ofEpochMilli() round-trip without losing precision + // compared to the original object, which is compared using equals(). + final Instant expiry = + Instant.now().plusSeconds(1000).truncatedTo(ChronoUnit.MILLIS); + final STSTokenIdentifier originalTokenIdentifier = new STSTokenIdentifier( + "tempAccessKeyId", "originalAccessKeyId", "roleArn", expiry, + "secretAccessKey", "sessionPolicy"); + originalTokenIdentifier.setSecretKeyId(UUID.randomUUID()); + + final ByteArrayOutputStream baos = new ByteArrayOutputStream(); + try (DataOutputStream out = new DataOutputStream(baos)) { + originalTokenIdentifier.write(out); + } + + final byte[] bytes = baos.toByteArray(); + final STSTokenIdentifier parsedTokenIdentifier = new STSTokenIdentifier(); + parsedTokenIdentifier.readFromByteArray(bytes); + + assertThat(parsedTokenIdentifier).isEqualTo(originalTokenIdentifier); + } + + @Test + public void testWriteToAndReadFromByteArrayWithDifferentSecretKeyIds() throws Exception { + final UUID uuid1 = UUID.randomUUID(); + UUID uuid2 = UUID.randomUUID(); + if (uuid2.equals(uuid1)) { + uuid2 = UUID.randomUUID(); + } + + final Instant expiry = Instant.now().plusSeconds(1500); + final STSTokenIdentifier originalTokenIdentifier = new STSTokenIdentifier( + "tempAccessKeyId", "originalAccessKeyId", "roleArn", expiry, + "secretAccessKey", "sessionPolicy"); + originalTokenIdentifier.setSecretKeyId(uuid1); + + final ByteArrayOutputStream baos1 = new ByteArrayOutputStream(); + try (DataOutputStream out = new DataOutputStream(baos1)) { + originalTokenIdentifier.write(out); + } + + final STSTokenIdentifier anotherTokenIdentifier = new STSTokenIdentifier( + "tempAccessKeyId", "originalAccessKeyId", "roleArn", expiry, + "secretAccessKey", "sessionPolicy"); + anotherTokenIdentifier.setSecretKeyId(uuid2); + + final ByteArrayOutputStream baos2 = new ByteArrayOutputStream(); + try (DataOutputStream out = new DataOutputStream(baos2)) { + anotherTokenIdentifier.write(out); + } + + // The byte arrays should be different due to different secret key IDs + assertThat(baos1.toByteArray()).isNotEqualTo(baos2.toByteArray()); + } + + @Test + public void testWriteToAndReadFromByteArrayWithSameSecretKeyIds() throws Exception { + final UUID uuid = UUID.randomUUID(); + final Instant expiry = Instant.now().plusSeconds(1700); + + final STSTokenIdentifier originalTokenIdentifier = new STSTokenIdentifier( + "tempAccessKeyId", "originalAccessKeyId", "roleArn", expiry, + "secretAccessKey", "sessionPolicy"); + originalTokenIdentifier.setSecretKeyId(uuid); + + final ByteArrayOutputStream baos1 = new ByteArrayOutputStream(); + try (DataOutputStream out = new DataOutputStream(baos1)) { + originalTokenIdentifier.write(out); + } + + final STSTokenIdentifier anotherTokenIdentifier = new STSTokenIdentifier( + "tempAccessKeyId", "originalAccessKeyId", "roleArn", expiry, + "secretAccessKey", "sessionPolicy"); + anotherTokenIdentifier.setSecretKeyId(uuid); + + final ByteArrayOutputStream baos2 = new ByteArrayOutputStream(); + try (DataOutputStream out = new DataOutputStream(baos2)) { + anotherTokenIdentifier.write(out); + } + + // The byte arrays should be the same since they have the same contents + assertThat(baos1.toByteArray()).isEqualTo(baos2.toByteArray()); + } + + @Test + public void testGettersReturnCorrectValues() { + final Instant expiry = Instant.now().plusSeconds(3600); + final String tempAccessKeyId = "ASIATEMP123456"; + final String originalAccessKeyId = "AKIAORIGINAL123"; + final String roleArn = "arn:aws:iam::123456789012:role/MyRole"; + final String secretAccessKey = "mySecretKey"; + final String sessionPolicy = "myPolicy"; + + final STSTokenIdentifier stsTokenIdentifier = new STSTokenIdentifier( + tempAccessKeyId, originalAccessKeyId, roleArn, expiry, secretAccessKey, sessionPolicy); + + assertThat(stsTokenIdentifier.getOwnerId()).isEqualTo(tempAccessKeyId); + assertThat(stsTokenIdentifier.getTempAccessKeyId()).isEqualTo(tempAccessKeyId); + assertThat(stsTokenIdentifier.getOriginalAccessKeyId()).isEqualTo(originalAccessKeyId); + assertThat(stsTokenIdentifier.getRoleArn()).isEqualTo(roleArn); + assertThat(stsTokenIdentifier.getExpiry()).isEqualTo(expiry); + assertThat(stsTokenIdentifier.getSecretAccessKey()).isEqualTo(secretAccessKey); + assertThat(stsTokenIdentifier.getSessionPolicy()).isEqualTo(sessionPolicy); + } + + @Test + public void testEqualsAndHashCode() { + final Instant expiry = Instant.now().plusSeconds(3600); + final UUID uuid = UUID.randomUUID(); + + final STSTokenIdentifier stsTokenIdentifier = new STSTokenIdentifier( + "tempAccessKeyId", "originalAccessKeyId", "roleArn", expiry, + "secretAccessKey", "sessionPolicy"); + stsTokenIdentifier.setSecretKeyId(uuid); + + final STSTokenIdentifier stsTokenIdentifier2 = new STSTokenIdentifier( + "tempAccessKeyId", "originalAccessKeyId", "roleArn", expiry, + "secretAccessKey", "sessionPolicy"); + stsTokenIdentifier2.setSecretKeyId(uuid); + + assertThat(stsTokenIdentifier).isEqualTo(stsTokenIdentifier2); + assertThat(stsTokenIdentifier.hashCode()).isEqualTo(stsTokenIdentifier2.hashCode()); + } + + @Test + public void testNotEqualsWhenTempAccessKeyIdDiffers() { + final Instant expiry = Instant.now().plusSeconds(3600); + + final STSTokenIdentifier stsTokenIdentifier = new STSTokenIdentifier( + "tempAccessKeyId1", "originalAccessKeyId", "roleArn", + expiry, "secretAccessKey", "sessionPolicy"); + + final STSTokenIdentifier stsTokenIdentifier2 = new STSTokenIdentifier( + "tempAccessKeyId2", "originalAccessKeyId", "roleArn", expiry, + "secretAccessKey", "sessionPolicy"); + + assertThat(stsTokenIdentifier).isNotEqualTo(stsTokenIdentifier2); + } + + @Test + public void testNotEqualsWhenOriginalAccessKeyIdDiffers() { + final Instant expiry = Instant.now().plusSeconds(3600); + + final STSTokenIdentifier stsTokenIdentifier = new STSTokenIdentifier( + "tempAccessKeyId", "originalAccessKeyId1", "roleArn", expiry, + "secretAccessKey", "sessionPolicy"); + + final STSTokenIdentifier stsTokenIdentifier2 = new STSTokenIdentifier( + "tempAccessKeyId", "originalAccessKeyId2", "roleArn", expiry, + "secretAccessKey", "sessionPolicy"); + + assertThat(stsTokenIdentifier).isNotEqualTo(stsTokenIdentifier2); + } + + @Test + public void testNotEqualsWhenRoleArnDiffers() { + final Instant expiry = Instant.now().plusSeconds(3600); + + final STSTokenIdentifier stsTokenIdentifier = new STSTokenIdentifier( + "tempAccessKeyId", "originalAccessKeyId", "roleArn1", expiry, + "secretAccessKey", "sessionPolicy"); + + final STSTokenIdentifier stsTokenIdentifier2 = new STSTokenIdentifier( + "tempAccessKeyId", "originalAccessKeyId", "roleArn2", expiry, + "secretAccessKey", "sessionPolicy"); + + assertThat(stsTokenIdentifier).isNotEqualTo(stsTokenIdentifier2); + } + + @Test + public void testNotEqualsWhenExpirationDiffers() { + final STSTokenIdentifier stsTokenIdentifier = new STSTokenIdentifier( + "tempAccessKeyId", "originalAccessKeyId", "roleArn", + Instant.now().plusSeconds(3600), "secretAccessKey", "sessionPolicy"); + + final STSTokenIdentifier stsTokenIdentifier2 = new STSTokenIdentifier( + "tempAccessKeyId", "originalAccessKeyId", "roleArn", + Instant.now().plusSeconds(7600), "secretAccessKey", "sessionPolicy"); + + assertThat(stsTokenIdentifier).isNotEqualTo(stsTokenIdentifier2); + } + + @Test + public void testNotEqualsWhenSecretAccessKeyDiffers() { + final Instant expiry = Instant.now().plusSeconds(3600); + + final STSTokenIdentifier stsTokenIdentifier = new STSTokenIdentifier( + "tempAccessKeyId", "originalAccessKeyId", "roleArn", expiry, + "secretAccessKey1", "sessionPolicy"); + + final STSTokenIdentifier stsTokenIdentifier2 = new STSTokenIdentifier( + "tempAccessKeyId", "originalAccessKeyId", "roleArn", expiry, + "secretAccessKey2", "sessionPolicy"); + + assertThat(stsTokenIdentifier).isNotEqualTo(stsTokenIdentifier2); + } + + @Test + public void testNotEqualsWhenSessionPolicyDiffers() { + final Instant expiry = Instant.now().plusSeconds(3600); + + final STSTokenIdentifier stsTokenIdentifier = new STSTokenIdentifier( + "tempAccessKeyId", "originalAccessKeyId", "roleArn", expiry, + "secretAccessKey", "sessionPolicy1"); + + final STSTokenIdentifier stsTokenIdentifier2 = new STSTokenIdentifier( + "tempAccessKeyId", "originalAccessKeyId", "roleArn", expiry, + "secretAccessKey", "sessionPolicy2"); + + assertThat(stsTokenIdentifier).isNotEqualTo(stsTokenIdentifier2); + } + + @Test + public void testToString() { + final Instant expiry = Instant.now().plusSeconds(3600); + final UUID uuid = UUID.randomUUID(); + + final STSTokenIdentifier stsTokenIdentifier = new STSTokenIdentifier( + "tempAccessKeyId", "originalAccessKeyId", "roleArn", expiry, + "secretAccessKey", "sessionPolicy"); + stsTokenIdentifier.setSecretKeyId(uuid); + + final String stsTokenIdentifierStr = stsTokenIdentifier.toString(); + final String expectedString = "STSTokenIdentifier{" + "tempAccessKeyId='tempAccessKeyId'" + + ", originalAccessKeyId='originalAccessKeyId'" + ", roleArn='roleArn'" + ", expiry='" + expiry + + "', secretKeyId='" + uuid + "', sessionPolicy='sessionPolicy'" + '}'; + + assertEquals(expectedString, stsTokenIdentifierStr); + } + + @Test + public void testNotEqualsWithNull() { + final STSTokenIdentifier stsTokenIdentifier = new STSTokenIdentifier( + "tempAccessKeyId", "originalAccessKeyId", "roleArn", Instant.now(), + "secretAccessKey", "sessionPolicy"); + + assertThat(stsTokenIdentifier).isNotEqualTo(null); + } +} + + From b79a8dd4305c85ffbbe87fef5e83de9ed62ef9e6 Mon Sep 17 00:00:00 2001 From: fmorg-git Date: Tue, 18 Nov 2025 03:50:49 -0800 Subject: [PATCH 05/54] HDDS-13724. [STS] Part 1 - Create utility to convert IAM policy to groupings of OzoneObj and Acls (#9239) --- .../acl/iam/IamSessionPolicyResolver.java | 336 ++++++++++++++++++ .../ozone/security/acl/iam/package-info.java | 21 ++ .../acl/iam/TestIamSessionPolicyResolver.java | 313 ++++++++++++++++ 3 files changed, 670 insertions(+) create mode 100644 hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/security/acl/iam/IamSessionPolicyResolver.java create mode 100644 hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/security/acl/iam/package-info.java create mode 100644 hadoop-ozone/common/src/test/java/org/apache/hadoop/ozone/security/acl/iam/TestIamSessionPolicyResolver.java diff --git a/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/security/acl/iam/IamSessionPolicyResolver.java b/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/security/acl/iam/IamSessionPolicyResolver.java new file mode 100644 index 000000000000..23fbf063c871 --- /dev/null +++ b/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/security/acl/iam/IamSessionPolicyResolver.java @@ -0,0 +1,336 @@ +/* + * 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.acl.iam; + +import static org.apache.hadoop.ozone.om.exceptions.OMException.ResultCodes.INVALID_REQUEST; +import static org.apache.hadoop.ozone.om.exceptions.OMException.ResultCodes.NOT_SUPPORTED_OPERATION; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.util.Collections; +import java.util.HashSet; +import java.util.LinkedHashSet; +import java.util.Objects; +import java.util.Set; +import org.apache.commons.lang3.StringUtils; +import org.apache.hadoop.ozone.om.exceptions.OMException; +import org.apache.hadoop.ozone.security.acl.AssumeRoleRequest; + +/** + * Resolves a limited subset of AWS IAM session policies into Ozone ACL grants, + * according to either the RangerOzoneAuthorizer or OzoneNativeAuthorizer constructs. + *

+ * Here are some differences between the RangerOzoneAuthorizer and OzoneNativeAuthorizer: + * - RangerOzoneAuthorizer doesn't currently use ResourceType.PREFIX, whereas OzoneNativeAuthorizer does. + * - OzoneNativeAuthorizer doesn't allow wildcards in bucket names (ex. ResourceArn `arn:aws:s3:::*`, + * `arn:aws:s3:::bucket*` or `*`), whereas RangerOzoneAuthorizer does. + * - For OzoneNativeAuthorizer, certain object wildcards are accepted. For example, ResourceArn + * `arn:aws:s3:::myBucket/*` and `arn:aws:s3:::myBucket/folder/logs/*` are accepted but not + * `arn:aws:s3:::myBucket/file*.txt`. + *

+ * The only supported ResourceArn has prefix arn:aws:s3::: - all others will throw + * OMException with NOT_SUPPORTED_OPERATION. + *

+ * The only supported Condition operator is StringEquals - all others will throw + * OMException with NOT_SUPPORTED_OPERATION. Furthermore, only one Condition is supported in a + * statement. The value StringEquals is case-sensitive per the + * + * AWS spec. + *

+ * The only supported Condition key name is s3:prefix - all others will throw + * OMException with NOT_SUPPORTED_OPERATION. s3:prefix is case-insensitive per the + * AWS spec. + *

+ * The only supported Effect is Allow - all others will throw OMException with NOT_SUPPORTED_OPERATION. This + * value is case-sensitive per the + * AWS spec. + *

+ * If a (currently) unsupported S3 action is requested, such as s3:GetAccelerateConfiguration, + * it will be silently ignored. + *

+ * Supported wildcard expansions in Actions are: s3:*, s3:Get*, s3:Put*, s3:List*, + * s3:Create*, and s3:Delete*. + */ +public final class IamSessionPolicyResolver { + + private static final ObjectMapper MAPPER = new ObjectMapper(); + + // JSON length is limited per AWS policy. See https://docs.aws.amazon.com/STS/latest/APIReference/API_AssumeRole.html + // under Policy section. + private static final int MAX_JSON_LENGTH = 2048; + + private IamSessionPolicyResolver() { + } + + /** + * Resolves an S3 IAM session policy in the form of a JSON String to a data structure comprising + * the IOzoneObjs and permissions that IAM policy grants (if any). + *

+ * Each entry represents a path (such as /s3v/bucket1 or /s3v/bucket1/*) and a set of + * permissions (such as READ, LIST, CREATE). + *

+ * The OzoneObj can be different depending on the AuthorizerType (see main Javadoc at top of file + * for examples). + *

+ * + * @param policyJson the IAM session policy + * @param volumeName the volume under which the resource(s) live. This may not be s3v in + * multi-tenant scenarios + * @param authorizerType whether the IOzoneObjs should be generated for use by the + * RangerOzoneAuthorizer or the OzoneNativeAuthorizer + * @return the data structure comprising the paths and permission pairings that + * the session policy grants (if any) + * @throws OMException if the policy JSON is invalid, malformed, or contains unsupported features + */ + public static Set resolve(String policyJson, String volumeName, + AuthorizerType authorizerType) throws OMException { + + validateInputParameters(policyJson, volumeName, authorizerType); + + final Set result = new LinkedHashSet<>(); + + // Parse JSON into set of statements + final Set statements = parseJsonAndRetrieveStatements(policyJson); + + for (JsonNode stmt : statements) { + validateEffectInJsonStatement(stmt); + + final Set actions = readStringOrArray(stmt.get("Action")); + final Set resources = readStringOrArray(stmt.get("Resource")); + + // Parse prefixes from conditions, if any + final Set prefixes = parsePrefixesFromConditions(stmt); + + // Map actions to S3Action enum if possible + final Set mappedS3Actions = mapPolicyActionsToS3Actions(actions); + if (mappedS3Actions.isEmpty()) { + // No actions recognized - no need to look at Resources for this Statement + continue; + } + + // Categorize resources according to bucket resource, object resource, etc + final Set resourceSpecs = validateAndCategorizeResources(authorizerType, resources); + + // For each action, map to Ozone objects (paths) and acls based on resource specs and prefixes + final Set stmtResults = createPathsAndPermissions( + volumeName, authorizerType, mappedS3Actions, resourceSpecs, prefixes); + + result.addAll(stmtResults); + } + + return result; + } + + /** + * Ensures required input parameters are supplied. + */ + private static void validateInputParameters(String policyJson, String volumeName, + AuthorizerType authorizerType) throws OMException { + if (StringUtils.isBlank(policyJson)) { + throw new OMException("The IAM session policy JSON is required", INVALID_REQUEST); + } + + if (StringUtils.isBlank(volumeName)) { + throw new OMException("The volume name is required", INVALID_REQUEST); + } + + Objects.requireNonNull(authorizerType, "The authorizer type is required"); + + if (policyJson.length() > MAX_JSON_LENGTH) { + throw new OMException("Invalid policy JSON - exceeds maximum length of " + + MAX_JSON_LENGTH + " characters", INVALID_REQUEST); + } + } + + /** + * Parses IAM session policy and retrieve the statement(s). + */ + private static Set parseJsonAndRetrieveStatements(String policyJson) throws OMException { + final JsonNode root; + try { + root = MAPPER.readTree(policyJson); + } catch (Exception e) { + throw new OMException("Invalid policy JSON (most likely JSON structure is incorrect)", e, INVALID_REQUEST); + } + + final JsonNode statementsNode = root.path("Statement"); + if (statementsNode.isMissingNode()) { + throw new OMException("Invalid policy JSON - missing Statement", INVALID_REQUEST); + } + + final Set statements = new HashSet<>(); + + if (statementsNode.isArray()) { + statementsNode.forEach(statements::add); + } else { + statements.add(statementsNode); + } + return statements; + } + + /** + * Parses Effect from IAM session policy and ensures it is valid and supported. + */ + private static void validateEffectInJsonStatement(JsonNode statement) throws OMException { + final JsonNode effectNode = statement.get("Effect"); + if (effectNode != null) { + if (effectNode.isTextual()) { + final String effect = effectNode.asText(); + if (!"Allow".equals(effect)) { + throw new OMException("Unsupported Effect - " + effect, NOT_SUPPORTED_OPERATION); + } + return; + } + + throw new OMException( + "Invalid Effect in JSON policy (must be a String) - " + effectNode, INVALID_REQUEST); + } + + throw new OMException("Effect is missing from JSON policy", INVALID_REQUEST); + } + + /** + * Reads a JsonNode and converts to a Set of String, if the node represents + * a textual value or an array of textual values. Otherwise, returns + * an empty List. + */ + private static Set readStringOrArray(JsonNode node) { + if (node == null || node.isMissingNode() || node.isNull()) { + return Collections.emptySet(); + } + if (node.isTextual()) { + return Collections.singleton(node.asText()); + } + if (node.isArray()) { + final Set set = new HashSet<>(); + node.forEach(n -> { + if (n.isTextual()) { + set.add(n.asText()); + } + }); + return set; + } + + return Collections.emptySet(); + } + + /** + * Parses and returns prefixes from Conditions (if any). Also validates + * that if there is a Condition, there is only one and that the Condition + * operator and key name are supported. + *

+ * Only the StringEquals operator and s3:prefix key name are supported. + */ + private static Set parsePrefixesFromConditions(JsonNode stmt) throws OMException { + Set prefixes = Collections.emptySet(); + final JsonNode cond = stmt.get("Condition"); + if (cond != null && !cond.isMissingNode() && !cond.isNull()) { + if (cond.size() != 1) { + throw new OMException("Only one Condition is supported", NOT_SUPPORTED_OPERATION); + } + + if (!cond.isObject()) { + throw new OMException( + "Invalid Condition (must have operator StringEquals " + "and key name s3:prefix) - " + + cond, INVALID_REQUEST); + } + + final String operator = cond.fieldNames().next(); + if (!"StringEquals".equals(operator)) { + throw new OMException("Unsupported Condition operator - " + operator, NOT_SUPPORTED_OPERATION); + } + + final JsonNode operatorValue = cond.get("StringEquals"); + if ("null".equals(operatorValue.asText())) { + throw new OMException("Missing Condition operator - StringEquals", INVALID_REQUEST); + } + + if (!operatorValue.isObject()) { + throw new OMException("Invalid Condition operator value structure - " + operatorValue, INVALID_REQUEST); + } + + final String keyName = operatorValue.fieldNames().hasNext() ? operatorValue.fieldNames().next() : null; + if (!"s3:prefix".equalsIgnoreCase(keyName)) { + throw new OMException("Unsupported Condition key name - " + keyName, NOT_SUPPORTED_OPERATION); + } + + prefixes = readStringOrArray(operatorValue.get(keyName)); + } + + return prefixes; + } + + /** + * Maps actions from JSON IAM policy to S3Action enum in order to determine what the + * permissions should be. + */ + private static Set mapPolicyActionsToS3Actions(Set actions) { + // TODO implement in future PR + return Collections.emptySet(); + } + + /** + * Iterates over resources in IAM policy and determines whether it is a bucket resource, + * an object resource, a prefix or a wildcard. The categorization can be different + * depending on whether the AuthorizerType is Ranger (for RangerOzoneAuthorizer) or + * native (for OzoneNativeAuthorizer). See main Javadoc at top of file for more + * examples of these differences. + *

+ * It also validates that the Resource Arn(s) are valid and supported. + */ + private static Set validateAndCategorizeResources(AuthorizerType authorizerType, + Set resources) throws OMException { + // TODO implement in future PR + return Collections.emptySet(); + } + + /** + * Iterates over all resources, finds applicable actions (if any) and constructs + * entries pairing sets of IOzoneObjs with the requisite permissions granted (if any). + */ + private static Set createPathsAndPermissions(String volumeName, + AuthorizerType authorizerType, Set mappedS3Actions, Set resourceSpecs, + Set prefixes) { + // TODO implement in future PR + return Collections.emptySet(); + } + + /** + * The authorizer type, whether for OzoneNativeAuthorizer or RangerOzoneAuthorizer. + * The IOzoneObjs generated differ in certain cases depending on the type. + * See main Javadoc at top of file for differences. + */ + public enum AuthorizerType { + NATIVE, + RANGER + } + + /** + * Utility to help categorize IAM policy resources, whether for bucket, key, wildcards, etc. + */ + private static final class ResourceSpec { + // TODO implement in future PR + } + + /** + * Represents S3 actions and requisite permissions required and at what level. + */ + private enum S3Action { + // TODO implement in future PR + } +} diff --git a/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/security/acl/iam/package-info.java b/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/security/acl/iam/package-info.java new file mode 100644 index 000000000000..dee8fe2ea0cd --- /dev/null +++ b/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/security/acl/iam/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. + */ + +/** + * Classes related to ozone REST interface. + */ +package org.apache.hadoop.ozone.security.acl.iam; diff --git a/hadoop-ozone/common/src/test/java/org/apache/hadoop/ozone/security/acl/iam/TestIamSessionPolicyResolver.java b/hadoop-ozone/common/src/test/java/org/apache/hadoop/ozone/security/acl/iam/TestIamSessionPolicyResolver.java new file mode 100644 index 000000000000..d37f441a51e2 --- /dev/null +++ b/hadoop-ozone/common/src/test/java/org/apache/hadoop/ozone/security/acl/iam/TestIamSessionPolicyResolver.java @@ -0,0 +1,313 @@ +/* + * 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.acl.iam; + +import static org.apache.hadoop.ozone.om.exceptions.OMException.ResultCodes.INVALID_REQUEST; +import static org.apache.hadoop.ozone.om.exceptions.OMException.ResultCodes.NOT_SUPPORTED_OPERATION; +import static org.apache.hadoop.ozone.security.acl.iam.IamSessionPolicyResolver.AuthorizerType.NATIVE; +import static org.apache.hadoop.ozone.security.acl.iam.IamSessionPolicyResolver.AuthorizerType.RANGER; +import static org.assertj.core.api.Assertions.assertThat; + +import org.apache.hadoop.ozone.om.exceptions.OMException; +import org.junit.jupiter.api.Test; + +/** + * Test class for {@link IamSessionPolicyResolver}. + * */ +public class TestIamSessionPolicyResolver { + + private static final String VOLUME = "s3v"; + + @Test + public void testUnsupportedConditionOperatorThrows() { + final String json = "{\n" + + " \"Statement\": [{\n" + + " \"Effect\": \"Allow\",\n" + + " \"Action\": \"s3:ListBucket\",\n" + + " \"Resource\": \"arn:aws:s3:::b\",\n" + + " \"Condition\": { \"StringLike\": { \"s3:prefix\": \"x/*\" } }\n" + + " }]\n" + + "}"; + + expectResolveThrowsForBothAuthorizers( + json, "Unsupported Condition operator - StringLike", NOT_SUPPORTED_OPERATION); + } + + @Test + public void testUnsupportedConditionAttributeThrows() { + final String json = "{\n" + + " \"Statement\": [{\n" + + " \"Effect\": \"Allow\",\n" + + " \"Action\": \"s3:ListBucket\",\n" + + " \"Resource\": \"arn:aws:s3:::b\",\n" + + " \"Condition\": { \"StringEquals\": { \"aws:SourceArn\": \"arn:aws:s3:::d\" } }\n" + + " }]\n" + + "}"; + + expectResolveThrowsForBothAuthorizers( + json, "Unsupported Condition key name - aws:SourceArn", NOT_SUPPORTED_OPERATION); + } + + @Test + public void testUnsupportedEffectThrows() { + final String json = "{\n" + + " \"Statement\": [{\n" + + " \"Effect\": \"Deny\",\n" + // unsupported effect + " \"Action\": \"s3:ListBucket\",\n" + + " \"Resource\": \"arn:aws:s3:::proj-*\"\n" + + " }]\n" + + "}"; + + expectResolveThrowsForBothAuthorizers( + json, "Unsupported Effect - Deny", NOT_SUPPORTED_OPERATION); + } + + @Test + public void testInvalidJsonWithoutStatementThrows() { + final String json = "{\n" + + " \"RandomAttribute\": [{\n" + + " \"Effect\": \"Allow\",\n" + + " \"Action\": \"s3:ListBucket\",\n" + + " \"Resource\": \"arn:aws:s3:::b\",\n" + + " \"Condition\": { \"StringEquals\": { \"s3:prefix\": \"x/*\" } }\n" + + " }]\n" + + "}"; + + expectResolveThrowsForBothAuthorizers( + json, "Invalid policy JSON - missing Statement", INVALID_REQUEST); + } + + @Test + public void testInvalidEffectThrows() { + final String json = "{\n" + + " \"Statement\": [{\n" + + " \"Effect\": [\"Allow\"],\n" + + " \"Action\": \"s3:ListBucket\",\n" + + " \"Resource\": \"arn:aws:s3:::bucket1\"\n" + + " }]\n" + + "}"; + + expectResolveThrowsForBothAuthorizers( + json, "Invalid Effect in JSON policy (must be a String) - [\"Allow\"]", + INVALID_REQUEST); + } + + @Test + public void testMissingEffectInStatementThrows() { + final String json = "{\n" + + " \"Statement\": [{\n" + + " \"Action\": \"s3:ListBucket\",\n" + + " \"Resource\": \"arn:aws:s3:::bucket1\"\n" + + " }]\n" + + "}"; + + expectResolveThrowsForBothAuthorizers( + json, "Effect is missing from JSON policy", INVALID_REQUEST); + } + + @Test + public void testInvalidNumberOfConditionsThrows() { + final String json = "{\n" + + " \"Statement\": [\n" + + " {\n" + + " \"Effect\": \"Allow\",\n" + + " \"Action\": \"s3:ListBucket\",\n" + + " \"Resource\": \"arn:aws:s3:::b\",\n" + + " \"Condition\": [\n" + + " {\n" + + " \"StringEquals\": {\n" + + " \"aws:SourceArn\": \"arn:aws:s3:::d\"\n" + + " }\n" + + " },\n" + + " {\n" + + " \"StringEquals\": {\n" + + " \"aws:SourceArn\": \"arn:aws:s3:::e\"\n" + + " }\n" + + " }\n" + + " ]\n" + + " }\n" + + " ]\n" + + "}"; + + expectResolveThrowsForBothAuthorizers( + json, "Only one Condition is supported", NOT_SUPPORTED_OPERATION); + } + + @Test + public void testInvalidConditionThrows() { + final String json = "{\n" + + " \"Statement\": [\n" + + " {\n" + + " \"Effect\": \"Allow\",\n" + + " \"Action\": \"s3:ListBucket\",\n" + + " \"Resource\": \"arn:aws:s3:::b\",\n" + + " \"Condition\": [\"RandomCondition\"]\n" + + " }\n" + + " ]\n" + + "}"; + + expectResolveThrowsForBothAuthorizers( + json, "Invalid Condition (must have operator StringEquals and key name " + + "s3:prefix) - [\"RandomCondition\"]", INVALID_REQUEST); + } + + @Test + public void testInvalidConditionAttributeMissingStringEqualsThrows() { + final String json = "{\n" + + " \"Statement\": [{\n" + + " \"Effect\": \"Allow\",\n" + + " \"Action\": \"s3:ListBucket\",\n" + + " \"Resource\": \"arn:aws:s3:::b\",\n" + + " \"Condition\": { \"StringEquals\": null }\n" + + " }]\n" + + "}"; + + expectResolveThrowsForBothAuthorizers( + json, "Missing Condition operator - StringEquals", INVALID_REQUEST); + } + + @Test + public void testInvalidConditionAttributeStructureThrows() { + final String json = "{\n" + + " \"Statement\": [{\n" + + " \"Effect\": \"Allow\",\n" + + " \"Action\": \"s3:ListBucket\",\n" + + " \"Resource\": \"arn:aws:s3:::b\",\n" + + " \"Condition\": { \"StringEquals\": [{ \"s3:prefix\": \"folder/\" }] }\n" + + " }]\n" + + "}"; + + expectResolveThrowsForBothAuthorizers( + json, "Invalid Condition operator value structure - [{\"s3:prefix\":\"folder/\"}]", + INVALID_REQUEST); + } + + @Test + public void testInvalidJsonThrows() { + final String invalidJson = "{[{{}]\"\""; + + expectResolveThrowsForBothAuthorizers( + invalidJson, "Invalid policy JSON (most likely JSON structure is incorrect)", + INVALID_REQUEST); + } + + @Test + public void testJsonExceedsMaxLengthThrows() { + final String json = createJsonStringLargerThan2048Characters(); + + expectResolveThrowsForBothAuthorizers( + json, "Invalid policy JSON - exceeds maximum length of 2048 characters", + INVALID_REQUEST); + } + + @Test + public void testJsonAtMaxLengthSucceeds() throws OMException { + // Create a JSON string that is exactly 2048 characters + final String json = create2048CharJsonString(); + assertThat(json.length()).isEqualTo(2048); + + // Must not throw an exception + IamSessionPolicyResolver.resolve(json, VOLUME, NATIVE); + IamSessionPolicyResolver.resolve(json, VOLUME, RANGER); + } + + @Test + public void testConditionKeyMustBeCaseInsensitive() throws OMException { + final String json = "{\n" + + " \"Statement\": [{\n" + + " \"Effect\": \"Allow\",\n" + + " \"Action\": \"s3:ListBucket\",\n" + + " \"Resource\": \"arn:aws:s3:::b\",\n" + + " \"Condition\": { \"StringEquals\": { \"S3:PRefiX\": \"x/*\" } }\n" + + " }]\n" + + "}"; + + // Must not throw exception + IamSessionPolicyResolver.resolve(json, VOLUME, NATIVE); + IamSessionPolicyResolver.resolve(json, VOLUME, RANGER); + } + + @Test + public void testEffectMustBeCaseSensitive() { + final String json = "{\n" + + " \"Statement\": [{\n" + + " \"Effect\": \"aLLOw\",\n" + + " \"Action\": \"s3:ListBucket\",\n" + + " \"Resource\": \"arn:aws:s3:::b\",\n" + + " \"Condition\": { \"StringEquals\": { \"s3:prefix\": \"x/*\" } }\n" + + " }]\n" + + "}"; + + expectResolveThrowsForBothAuthorizers( + json, "Unsupported Effect - aLLOw", NOT_SUPPORTED_OPERATION); + } + + private static void expectResolveThrows(String json, + IamSessionPolicyResolver.AuthorizerType authorizerType, String expectedMessage, + OMException.ResultCodes expectedCode) { + try { + IamSessionPolicyResolver.resolve(json, VOLUME, authorizerType); + throw new AssertionError("Expected exception not thrown"); + } catch (OMException ex) { + assertThat(ex.getMessage()).isEqualTo(expectedMessage); + assertThat(ex.getResult()).isEqualTo(expectedCode); + } + } + + private static void expectResolveThrowsForBothAuthorizers(String json, + String expectedMessage, OMException.ResultCodes expectedCode) { + expectResolveThrows(json, NATIVE, expectedMessage, expectedCode); + expectResolveThrows(json, RANGER, expectedMessage, expectedCode); + } + + private static String createJsonStringLargerThan2048Characters() { + final StringBuilder jsonBuilder = new StringBuilder(); + jsonBuilder.append("{\n"); + jsonBuilder.append(" \"Statement\": [{\n"); + jsonBuilder.append(" \"Effect\": \"Allow\",\n"); + jsonBuilder.append(" \"Action\": \"s3:ListBucket\",\n"); + jsonBuilder.append(" \"Resource\": \"arn:aws:s3:::"); + // Add enough characters to exceed 2048 + while (jsonBuilder.length() < 2048) { + jsonBuilder.append("very-long-bucket-name-that-exceeds-the-limit-"); + } + jsonBuilder.append("\"\n"); + jsonBuilder.append(" }]\n"); + jsonBuilder.append('}'); + + return jsonBuilder.toString(); + } + + private static String create2048CharJsonString() { + final StringBuilder jsonBuilder = new StringBuilder(); + jsonBuilder.append("{\n"); + jsonBuilder.append(" \"Statement\": [{\n"); + jsonBuilder.append(" \"Effect\": \"Allow\",\n"); + jsonBuilder.append(" \"Action\": \"s3:ListBucket\",\n"); + jsonBuilder.append(" \"Resource\": \"arn:aws:s3:::"); + // Add characters to reach exactly 2048 (accounting for closing brackets and newlines) + // Closing part: "\"\n }]\n}" = 8 chars + while (jsonBuilder.length() < 2048 - 8) { + jsonBuilder.append('a'); + } + jsonBuilder.append("\"\n }]\n}"); + + return jsonBuilder.toString(); + } +} + From 9d3c32fba57e80c36c767c8da3853ff58ba2c0a1 Mon Sep 17 00:00:00 2001 From: fmorg-git Date: Wed, 19 Nov 2025 12:39:30 -0800 Subject: [PATCH 06/54] HDDS-13950. [STS] Introduce STSTokenSecretManager to sign STS tokens (#9323) --- .../apache/hadoop/ozone/om/OzoneManager.java | 20 +++ .../s3/security/S3AssumeRoleRequest.java | 23 ++-- .../ozone/security/STSTokenIdentifier.java | 7 +- .../ozone/security/STSTokenSecretManager.java | 84 +++++++++++++ .../s3/security/TestS3AssumeRoleRequest.java | 28 ++++- .../security/TestSTSTokenIdentifier.java | 88 ++++++++----- .../security/TestSTSTokenSecretManager.java | 119 ++++++++++++++++++ 7 files changed, 323 insertions(+), 46 deletions(-) create mode 100644 hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/security/STSTokenSecretManager.java create mode 100644 hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/security/TestSTSTokenSecretManager.java 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 3cf263e50135..5fe753871a15 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 @@ -307,6 +307,7 @@ import org.apache.hadoop.ozone.security.OMCertificateClient; import org.apache.hadoop.ozone.security.OzoneDelegationTokenSecretManager; import org.apache.hadoop.ozone.security.OzoneTokenIdentifier; +import org.apache.hadoop.ozone.security.STSTokenSecretManager; import org.apache.hadoop.ozone.security.acl.IAccessAuthorizer; import org.apache.hadoop.ozone.security.acl.IAccessAuthorizer.ACLIdentityType; import org.apache.hadoop.ozone.security.acl.IAccessAuthorizer.ACLType; @@ -381,6 +382,7 @@ public final class OzoneManager extends ServiceRuntimeInfoImpl private final ReconfigurationHandler reconfigurationHandler; private OzoneDelegationTokenSecretManager delegationTokenMgr; + private STSTokenSecretManager stsTokenSecretManager; private OzoneBlockTokenSecretManager blockTokenMgr; private CertificateClient certClient; private SecretKeyClient secretKeyClient; @@ -965,6 +967,7 @@ private void instantiateServices(boolean withNewSnapshot) throws IOException { if (secConfig.isSecurityEnabled() || testSecureOmFlag) { try { delegationTokenMgr = createDelegationTokenSecretManager(configuration); + stsTokenSecretManager = createSTSTokenSecretManager(); } catch (IllegalArgumentException e) { if (metadataManager != null) { // to avoid the unit test leak report failure @@ -1240,6 +1243,10 @@ private OzoneBlockTokenSecretManager createBlockTokenSecretManager() { return new OzoneBlockTokenSecretManager(expiryTime, secretKeyClient); } + private STSTokenSecretManager createSTSTokenSecretManager() { + return new STSTokenSecretManager(secretKeyClient); + } + private void stopSecretManager() { if (secretKeyClient != null) { LOG.info("Stopping secret key client."); @@ -1254,6 +1261,12 @@ private void stopSecretManager() { LOG.error("Failed to stop delegation token manager", e); } } + + if (stsTokenSecretManager != null) { + // STS token secret manager doesn't need explicit stop method + // as it uses the shared secret key client + LOG.info("Stopping OM STS token secret manager."); + } } @Override @@ -1332,6 +1345,9 @@ public void setSecretKeyClient(SecretKeyClient secretKeyClient) { if (delegationTokenMgr != null) { delegationTokenMgr.setSecretKeyClient(secretKeyClient); } + if (stsTokenSecretManager != null) { + stsTokenSecretManager.setSecretKeyClient(secretKeyClient); + } } /** @@ -4502,6 +4518,10 @@ public OzoneDelegationTokenSecretManager getDelegationTokenMgr() { return delegationTokenMgr; } + public STSTokenSecretManager getSTSTokenSecretManager() { + return stsTokenSecretManager; + } + /** * Return the list of Ozone administrators in effect. */ 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 dc31644c806c..31f71204dc46 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 @@ -103,7 +103,7 @@ public OMClientResponse validateAndUpdateCache(OzoneManager ozoneManager, Execut 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); + targetRoleName, omRequest, ozoneManager, assumeRoleRequest, secretAccessKey, tempAccessKeyId); // Generate AssumedRoleId for response final String roleId = ASSUME_ROLE_ID_PREFIX + generateSecureRandomStringUsingChars( @@ -159,15 +159,15 @@ private S3AssumeRoleResponse validateRoleSessionName(String roleSessionName, OMR * Generates session token using components from the AssumeRoleRequest. */ private String generateSessionToken(String targetRoleName, OMRequest omRequest, - OzoneManager ozoneManager, AssumeRoleRequest assumeRoleRequest, String secretAccessKey) throws IOException { + OzoneManager ozoneManager, AssumeRoleRequest assumeRoleRequest, String secretAccessKey, + String tempAccessKeyId) throws IOException { InetAddress remoteIp = ProtobufRpcEngine.Server.getRemoteIp(); if (remoteIp == null) { remoteIp = ozoneManager.getOmRpcServerAddr().getAddress(); } - final String hostName = remoteIp != null ? remoteIp.getHostName() : - ozoneManager.getOmRpcServerAddr().getHostName(); + 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 @@ -183,18 +183,9 @@ private String generateSessionToken(String targetRoleName, OMRequest omRequest, 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(); + return ozoneManager.getSTSTokenSecretManager().createSTSTokenString( + tempAccessKeyId, originalAccessKeyId, roleArn, assumeRoleRequest.getDurationSeconds(), secretAccessKey, + sessionPolicy); } /** diff --git a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/security/STSTokenIdentifier.java b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/security/STSTokenIdentifier.java index 071d7d21a9a8..1f2e8d300ae2 100644 --- a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/security/STSTokenIdentifier.java +++ b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/security/STSTokenIdentifier.java @@ -46,6 +46,9 @@ public class STSTokenIdentifier extends ShortLivedTokenIdentifier { private String secretAccessKey; private String sessionPolicy; + // Encryption key derived from ManagedSecretKey for this token + private transient byte[] encryptionKey; + // Service name for STS tokens public static final String STS_SERVICE = "STS"; @@ -66,14 +69,16 @@ public STSTokenIdentifier() { * @param secretAccessKey the secret access key associated with the temporary access key ID * @param sessionPolicy an optional opaque identifier that further limits the scope of * the permissions granted by the role + * @param encryptionKey the key bytes for encrypting sensitive fields */ public STSTokenIdentifier(String tempAccessKeyId, String originalAccessKeyId, String roleArn, Instant expiry, - String secretAccessKey, String sessionPolicy) { + String secretAccessKey, String sessionPolicy, byte[] encryptionKey) { super(tempAccessKeyId, expiry); this.originalAccessKeyId = originalAccessKeyId; this.roleArn = roleArn; this.secretAccessKey = secretAccessKey; this.sessionPolicy = sessionPolicy; + this.encryptionKey = encryptionKey != null ? encryptionKey.clone() : null; } @Override diff --git a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/security/STSTokenSecretManager.java b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/security/STSTokenSecretManager.java new file mode 100644 index 000000000000..b418beea4c3f --- /dev/null +++ b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/security/STSTokenSecretManager.java @@ -0,0 +1,84 @@ +/* + * 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 java.io.IOException; +import java.time.Instant; +import org.apache.hadoop.hdds.annotation.InterfaceAudience; +import org.apache.hadoop.hdds.annotation.InterfaceStability; +import org.apache.hadoop.hdds.security.symmetric.ManagedSecretKey; +import org.apache.hadoop.hdds.security.symmetric.SecretKeySignerClient; +import org.apache.hadoop.hdds.security.token.ShortLivedTokenSecretManager; +import org.apache.hadoop.security.token.Token; + +/** + * Secret manager for STS (Security Token Service) tokens. + * This class extends ShortLivedTokenSecretManager to make use of functionality such as signing tokens, etc. + */ +@InterfaceAudience.Private +@InterfaceStability.Unstable +public class STSTokenSecretManager extends ShortLivedTokenSecretManager { + + private static final long TOKEN_MAX_LIFETIME = 43200 * 1000L; // 12 hours in milliseconds + + // Store reference to secret key client for encryption key access + private final SecretKeySignerClient secretKeyClient; + + /** + * Create a new STS token secret manager. + * + * @param secretKeyClient client for accessing secret keys from SCM + */ + public STSTokenSecretManager(SecretKeySignerClient secretKeyClient) { + super(TOKEN_MAX_LIFETIME, secretKeyClient); + this.secretKeyClient = secretKeyClient; + } + + /** + * Create an STS token and return it as an encoded string. + * + * @param tempAccessKeyId the temporary access key ID + * @param originalAccessKeyId the original long-lived access key ID + * @param roleArn the ARN of the assumed role + * @param durationSeconds how long the token should be valid for + * @param secretAccessKey the secret access key associated with the temporary access key ID + * @param sessionPolicy an optional opaque identifier that further limits the scope of + * the permissions granted by the role + * @return base64 encoded token string + */ + public String createSTSTokenString(String tempAccessKeyId, String originalAccessKeyId, String roleArn, + int durationSeconds, String secretAccessKey, String sessionPolicy) throws IOException { + final Instant expiration = Instant.now().plusSeconds(durationSeconds); + + // Get the current secret key for encryption + final ManagedSecretKey currentSecretKey = secretKeyClient.getCurrentSecretKey(); + final byte[] encryptionKey = currentSecretKey.getSecretKey().getEncoded(); + + // Note - the encryptionKey will NOT be encoded in the token. When generateToken() is called, it eventually calls + // the write() method in STSTokenIdentifier which calls toProtoBuf(), and the encryptionKey is not + // serialized there. + // TODO sts - use the encryptionKey in a future PR to encrypt/decrypt the secretAccessKey + final STSTokenIdentifier identifier = new STSTokenIdentifier( + tempAccessKeyId, originalAccessKeyId, roleArn, expiration, secretAccessKey, sessionPolicy, encryptionKey); + + final Token token = generateToken(identifier); + return token.encodeToUrlString(); + } +} + + 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 9a826393a5f9..0940bbc55454 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 @@ -18,12 +18,20 @@ package org.apache.hadoop.ozone.om.request.s3.security; import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; +import java.io.IOException; import java.net.InetSocketAddress; +import java.nio.charset.StandardCharsets; import java.time.Instant; +import java.util.UUID; import java.util.regex.Pattern; +import javax.crypto.SecretKey; +import javax.crypto.spec.SecretKeySpec; +import org.apache.hadoop.hdds.security.symmetric.ManagedSecretKey; +import org.apache.hadoop.hdds.security.symmetric.SecretKeySignerClient; import org.apache.hadoop.ozone.om.OzoneManager; import org.apache.hadoop.ozone.om.execution.flowcontrol.ExecutionContext; import org.apache.hadoop.ozone.om.response.OMClientResponse; @@ -34,6 +42,8 @@ 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.apache.hadoop.ozone.security.STSTokenSecretManager; +import org.apache.hadoop.security.token.TokenIdentifier; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -50,10 +60,26 @@ public class TestS3AssumeRoleRequest { private ExecutionContext context; @BeforeEach - public void setup() { + public void setup() throws IOException { ozoneManager = mock(OzoneManager.class); + + final SecretKeySignerClient secretKeyClient = mock(SecretKeySignerClient.class); + final ManagedSecretKey managedSecretKey = mock(ManagedSecretKey.class); + final SecretKey secretKey = new SecretKeySpec( + "testSecretKey".getBytes(StandardCharsets.UTF_8), "HmacSHA256"); + final UUID secretKeyId = UUID.randomUUID(); + + when(secretKeyClient.getCurrentSecretKey()).thenReturn(managedSecretKey); + when(managedSecretKey.getSecretKey()).thenReturn(secretKey); + when(managedSecretKey.getId()).thenReturn(secretKeyId); + when(managedSecretKey.sign(any(TokenIdentifier.class))).thenReturn( + "signature".getBytes(StandardCharsets.UTF_8)); + + final STSTokenSecretManager stsTokenSecretManager = new STSTokenSecretManager(secretKeyClient); + when(ozoneManager.getOmRpcServerAddr()).thenReturn( new InetSocketAddress("localhost", 9876)); + when(ozoneManager.getSTSTokenSecretManager()).thenReturn(stsTokenSecretManager); context = ExecutionContext.of(1L, null); } diff --git a/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/security/TestSTSTokenIdentifier.java b/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/security/TestSTSTokenIdentifier.java index 37909703a7f4..ada9c7561045 100644 --- a/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/security/TestSTSTokenIdentifier.java +++ b/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/security/TestSTSTokenIdentifier.java @@ -24,6 +24,7 @@ import java.io.ByteArrayOutputStream; import java.io.DataOutputStream; import java.io.IOException; +import java.security.SecureRandom; import java.time.Instant; import java.time.temporal.ChronoUnit; import java.util.UUID; @@ -35,11 +36,17 @@ */ public class TestSTSTokenIdentifier { + private static final byte[] ENCRYPTION_KEY = new byte[5]; + + { + new SecureRandom().nextBytes(ENCRYPTION_KEY); + } + @Test public void testKindAndService() { final STSTokenIdentifier stsTokenIdentifier = new STSTokenIdentifier( "tempAccessKeyId", "originalAccessKeyId", "roleArn", - Instant.now().plusSeconds(3600), "secretAccessKey", "sessionPolicy"); + Instant.now().plusSeconds(3600), "secretAccessKey", "sessionPolicy", ENCRYPTION_KEY); assertEquals("STSToken", stsTokenIdentifier.getKind().toString()); assertEquals("STS", stsTokenIdentifier.getService()); @@ -53,7 +60,7 @@ public void testProtoBufRoundTrip() throws IOException { final Instant expiry = Instant.now().plusSeconds(7200).truncatedTo(ChronoUnit.MILLIS); final STSTokenIdentifier originalTokenIdentifier = new STSTokenIdentifier( "tempAccess", "origAccess", "arn:aws:iam::123456789012:role/RoleY", - expiry, "secretKey", "sessionPolicy"); + expiry, "secretKey", "sessionPolicy", ENCRYPTION_KEY); final UUID secretKeyId = UUID.randomUUID(); originalTokenIdentifier.setSecretKeyId(secretKeyId); @@ -92,7 +99,7 @@ public void testFromProtoBufInvalidSecretKeyId() { final STSTokenIdentifier stsTokenIdentifier = new STSTokenIdentifier( "tempAccessKeyId", "originalAccessKeyId", "roleArn", Instant.now(), - "secretAccessKey", "sessionPolicy"); + "secretAccessKey", "sessionPolicy", ENCRYPTION_KEY); final IOException ex = assertThrows(IOException.class, () -> stsTokenIdentifier.fromProtoBuf(invalid)); assertThat(ex.getMessage()).isEqualTo("Invalid secretKeyId format in STS token: not-a-uuid"); @@ -103,7 +110,7 @@ public void testProtobufRoundTripWithNullSessionPolicy() throws IOException { final Instant expiry = Instant.now().plusSeconds(7200); final STSTokenIdentifier stsTokenIdentifier = new STSTokenIdentifier( "tempAccess", "origAccess", "arn:aws:iam::123456789012:role/RoleX", - expiry, "secretKey", null); + expiry, "secretKey", null, ENCRYPTION_KEY); final OMTokenProto proto = stsTokenIdentifier.toProtoBuf(); assertThat(proto.getSessionPolicy()).isEmpty(); @@ -119,7 +126,7 @@ public void testProtobufRoundTripWithEmptySessionPolicy() throws IOException { final Instant expiry = Instant.now().plusSeconds(4000); final STSTokenIdentifier stsTokenIdentifier = new STSTokenIdentifier( "tempAccess", "origAccess", "arn:aws:iam::123456789012:role/RoleZ", - expiry, "secretKey", ""); + expiry, "secretKey", "", ENCRYPTION_KEY); final OMTokenProto proto = stsTokenIdentifier.toProtoBuf(); assertThat(proto.getSessionPolicy()).isEmpty(); @@ -140,7 +147,7 @@ public void testFromProtoBufInvalidTokenType() { final STSTokenIdentifier stsTokenIdentifier = new STSTokenIdentifier( "tempAccessKeyId", "origAccessKeyId", "roleArn", Instant.now(), - "secretAccessKey", "sessionPolicy"); + "secretAccessKey", "sessionPolicy", ENCRYPTION_KEY); final IllegalArgumentException ex = assertThrows( IllegalArgumentException.class, () -> stsTokenIdentifier.fromProtoBuf(invalidType)); @@ -156,7 +163,7 @@ public void testWriteToAndReadFromByteArray() throws Exception { Instant.now().plusSeconds(1000).truncatedTo(ChronoUnit.MILLIS); final STSTokenIdentifier originalTokenIdentifier = new STSTokenIdentifier( "tempAccessKeyId", "originalAccessKeyId", "roleArn", expiry, - "secretAccessKey", "sessionPolicy"); + "secretAccessKey", "sessionPolicy", ENCRYPTION_KEY); originalTokenIdentifier.setSecretKeyId(UUID.randomUUID()); final ByteArrayOutputStream baos = new ByteArrayOutputStream(); @@ -182,7 +189,7 @@ public void testWriteToAndReadFromByteArrayWithDifferentSecretKeyIds() throws Ex final Instant expiry = Instant.now().plusSeconds(1500); final STSTokenIdentifier originalTokenIdentifier = new STSTokenIdentifier( "tempAccessKeyId", "originalAccessKeyId", "roleArn", expiry, - "secretAccessKey", "sessionPolicy"); + "secretAccessKey", "sessionPolicy", ENCRYPTION_KEY); originalTokenIdentifier.setSecretKeyId(uuid1); final ByteArrayOutputStream baos1 = new ByteArrayOutputStream(); @@ -192,7 +199,7 @@ public void testWriteToAndReadFromByteArrayWithDifferentSecretKeyIds() throws Ex final STSTokenIdentifier anotherTokenIdentifier = new STSTokenIdentifier( "tempAccessKeyId", "originalAccessKeyId", "roleArn", expiry, - "secretAccessKey", "sessionPolicy"); + "secretAccessKey", "sessionPolicy", ENCRYPTION_KEY); anotherTokenIdentifier.setSecretKeyId(uuid2); final ByteArrayOutputStream baos2 = new ByteArrayOutputStream(); @@ -211,7 +218,7 @@ public void testWriteToAndReadFromByteArrayWithSameSecretKeyIds() throws Excepti final STSTokenIdentifier originalTokenIdentifier = new STSTokenIdentifier( "tempAccessKeyId", "originalAccessKeyId", "roleArn", expiry, - "secretAccessKey", "sessionPolicy"); + "secretAccessKey", "sessionPolicy", ENCRYPTION_KEY); originalTokenIdentifier.setSecretKeyId(uuid); final ByteArrayOutputStream baos1 = new ByteArrayOutputStream(); @@ -221,7 +228,7 @@ public void testWriteToAndReadFromByteArrayWithSameSecretKeyIds() throws Excepti final STSTokenIdentifier anotherTokenIdentifier = new STSTokenIdentifier( "tempAccessKeyId", "originalAccessKeyId", "roleArn", expiry, - "secretAccessKey", "sessionPolicy"); + "secretAccessKey", "sessionPolicy", ENCRYPTION_KEY); anotherTokenIdentifier.setSecretKeyId(uuid); final ByteArrayOutputStream baos2 = new ByteArrayOutputStream(); @@ -243,7 +250,7 @@ public void testGettersReturnCorrectValues() { final String sessionPolicy = "myPolicy"; final STSTokenIdentifier stsTokenIdentifier = new STSTokenIdentifier( - tempAccessKeyId, originalAccessKeyId, roleArn, expiry, secretAccessKey, sessionPolicy); + tempAccessKeyId, originalAccessKeyId, roleArn, expiry, secretAccessKey, sessionPolicy, ENCRYPTION_KEY); assertThat(stsTokenIdentifier.getOwnerId()).isEqualTo(tempAccessKeyId); assertThat(stsTokenIdentifier.getTempAccessKeyId()).isEqualTo(tempAccessKeyId); @@ -261,12 +268,12 @@ public void testEqualsAndHashCode() { final STSTokenIdentifier stsTokenIdentifier = new STSTokenIdentifier( "tempAccessKeyId", "originalAccessKeyId", "roleArn", expiry, - "secretAccessKey", "sessionPolicy"); + "secretAccessKey", "sessionPolicy", ENCRYPTION_KEY); stsTokenIdentifier.setSecretKeyId(uuid); final STSTokenIdentifier stsTokenIdentifier2 = new STSTokenIdentifier( "tempAccessKeyId", "originalAccessKeyId", "roleArn", expiry, - "secretAccessKey", "sessionPolicy"); + "secretAccessKey", "sessionPolicy", ENCRYPTION_KEY); stsTokenIdentifier2.setSecretKeyId(uuid); assertThat(stsTokenIdentifier).isEqualTo(stsTokenIdentifier2); @@ -279,11 +286,11 @@ public void testNotEqualsWhenTempAccessKeyIdDiffers() { final STSTokenIdentifier stsTokenIdentifier = new STSTokenIdentifier( "tempAccessKeyId1", "originalAccessKeyId", "roleArn", - expiry, "secretAccessKey", "sessionPolicy"); + expiry, "secretAccessKey", "sessionPolicy", ENCRYPTION_KEY); final STSTokenIdentifier stsTokenIdentifier2 = new STSTokenIdentifier( "tempAccessKeyId2", "originalAccessKeyId", "roleArn", expiry, - "secretAccessKey", "sessionPolicy"); + "secretAccessKey", "sessionPolicy", ENCRYPTION_KEY); assertThat(stsTokenIdentifier).isNotEqualTo(stsTokenIdentifier2); } @@ -294,11 +301,11 @@ public void testNotEqualsWhenOriginalAccessKeyIdDiffers() { final STSTokenIdentifier stsTokenIdentifier = new STSTokenIdentifier( "tempAccessKeyId", "originalAccessKeyId1", "roleArn", expiry, - "secretAccessKey", "sessionPolicy"); + "secretAccessKey", "sessionPolicy", ENCRYPTION_KEY); final STSTokenIdentifier stsTokenIdentifier2 = new STSTokenIdentifier( "tempAccessKeyId", "originalAccessKeyId2", "roleArn", expiry, - "secretAccessKey", "sessionPolicy"); + "secretAccessKey", "sessionPolicy", ENCRYPTION_KEY); assertThat(stsTokenIdentifier).isNotEqualTo(stsTokenIdentifier2); } @@ -309,11 +316,11 @@ public void testNotEqualsWhenRoleArnDiffers() { final STSTokenIdentifier stsTokenIdentifier = new STSTokenIdentifier( "tempAccessKeyId", "originalAccessKeyId", "roleArn1", expiry, - "secretAccessKey", "sessionPolicy"); + "secretAccessKey", "sessionPolicy", ENCRYPTION_KEY); final STSTokenIdentifier stsTokenIdentifier2 = new STSTokenIdentifier( "tempAccessKeyId", "originalAccessKeyId", "roleArn2", expiry, - "secretAccessKey", "sessionPolicy"); + "secretAccessKey", "sessionPolicy", ENCRYPTION_KEY); assertThat(stsTokenIdentifier).isNotEqualTo(stsTokenIdentifier2); } @@ -322,11 +329,11 @@ public void testNotEqualsWhenRoleArnDiffers() { public void testNotEqualsWhenExpirationDiffers() { final STSTokenIdentifier stsTokenIdentifier = new STSTokenIdentifier( "tempAccessKeyId", "originalAccessKeyId", "roleArn", - Instant.now().plusSeconds(3600), "secretAccessKey", "sessionPolicy"); + Instant.now().plusSeconds(3600), "secretAccessKey", "sessionPolicy", ENCRYPTION_KEY); final STSTokenIdentifier stsTokenIdentifier2 = new STSTokenIdentifier( "tempAccessKeyId", "originalAccessKeyId", "roleArn", - Instant.now().plusSeconds(7600), "secretAccessKey", "sessionPolicy"); + Instant.now().plusSeconds(7600), "secretAccessKey", "sessionPolicy", ENCRYPTION_KEY); assertThat(stsTokenIdentifier).isNotEqualTo(stsTokenIdentifier2); } @@ -337,11 +344,11 @@ public void testNotEqualsWhenSecretAccessKeyDiffers() { final STSTokenIdentifier stsTokenIdentifier = new STSTokenIdentifier( "tempAccessKeyId", "originalAccessKeyId", "roleArn", expiry, - "secretAccessKey1", "sessionPolicy"); + "secretAccessKey1", "sessionPolicy", ENCRYPTION_KEY); final STSTokenIdentifier stsTokenIdentifier2 = new STSTokenIdentifier( "tempAccessKeyId", "originalAccessKeyId", "roleArn", expiry, - "secretAccessKey2", "sessionPolicy"); + "secretAccessKey2", "sessionPolicy", ENCRYPTION_KEY); assertThat(stsTokenIdentifier).isNotEqualTo(stsTokenIdentifier2); } @@ -352,11 +359,11 @@ public void testNotEqualsWhenSessionPolicyDiffers() { final STSTokenIdentifier stsTokenIdentifier = new STSTokenIdentifier( "tempAccessKeyId", "originalAccessKeyId", "roleArn", expiry, - "secretAccessKey", "sessionPolicy1"); + "secretAccessKey", "sessionPolicy1", ENCRYPTION_KEY); final STSTokenIdentifier stsTokenIdentifier2 = new STSTokenIdentifier( "tempAccessKeyId", "originalAccessKeyId", "roleArn", expiry, - "secretAccessKey", "sessionPolicy2"); + "secretAccessKey", "sessionPolicy2", ENCRYPTION_KEY); assertThat(stsTokenIdentifier).isNotEqualTo(stsTokenIdentifier2); } @@ -368,7 +375,7 @@ public void testToString() { final STSTokenIdentifier stsTokenIdentifier = new STSTokenIdentifier( "tempAccessKeyId", "originalAccessKeyId", "roleArn", expiry, - "secretAccessKey", "sessionPolicy"); + "secretAccessKey", "sessionPolicy", ENCRYPTION_KEY); stsTokenIdentifier.setSecretKeyId(uuid); final String stsTokenIdentifierStr = stsTokenIdentifier.toString(); @@ -383,10 +390,35 @@ public void testToString() { public void testNotEqualsWithNull() { final STSTokenIdentifier stsTokenIdentifier = new STSTokenIdentifier( "tempAccessKeyId", "originalAccessKeyId", "roleArn", Instant.now(), - "secretAccessKey", "sessionPolicy"); + "secretAccessKey", "sessionPolicy", ENCRYPTION_KEY); assertThat(stsTokenIdentifier).isNotEqualTo(null); } + + @Test + public void testEqualsWithDifferentEncryptionKeys() { + final Instant expiry = Instant.now().plusSeconds(3600).truncatedTo(ChronoUnit.MILLIS); + final UUID uuid = UUID.randomUUID(); + + // Create first identifier with the default key + final STSTokenIdentifier stsTokenIdentifier = new STSTokenIdentifier( + "tempAccessKeyId", "originalAccessKeyId", "roleArn", expiry, + "secretAccessKey", "sessionPolicy", ENCRYPTION_KEY); + stsTokenIdentifier.setSecretKeyId(uuid); + + // Create second identifier with a different encryption key but otherwise same parameters + byte[] differentKey = new byte[5]; + new SecureRandom().nextBytes(differentKey); + + final STSTokenIdentifier stsTokenIdentifier2 = new STSTokenIdentifier( + "tempAccessKeyId", "originalAccessKeyId", "roleArn", expiry, + "secretAccessKey", "sessionPolicy", differentKey); + stsTokenIdentifier2.setSecretKeyId(uuid); + + // They should still be equal because encryptionKey is transient/ignored for identity + assertThat(stsTokenIdentifier).isEqualTo(stsTokenIdentifier2); + assertThat(stsTokenIdentifier.hashCode()).isEqualTo(stsTokenIdentifier2.hashCode()); + } } diff --git a/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/security/TestSTSTokenSecretManager.java b/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/security/TestSTSTokenSecretManager.java new file mode 100644 index 000000000000..f35fc902460e --- /dev/null +++ b/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/security/TestSTSTokenSecretManager.java @@ -0,0 +1,119 @@ +/* + * 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.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.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.time.Instant; +import java.util.UUID; +import javax.crypto.SecretKey; +import javax.crypto.spec.SecretKeySpec; +import org.apache.hadoop.hdds.security.symmetric.ManagedSecretKey; +import org.apache.hadoop.hdds.security.symmetric.SecretKeySignerClient; +import org.apache.hadoop.io.Text; +import org.apache.hadoop.security.token.Token; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +/** + * Test for STSTokenSecretManager. + */ +public class TestSTSTokenSecretManager { + private STSTokenSecretManager secretManager; + private static final String TEMP_ACCESS_KEY = "temp-access-key"; + private static final String ORIGINAL_ACCESS_KEY = "original-access-key"; + private static final String ROLE_ARN = "arn:aws:iam::123456789012:role/test-role"; + private static final String SECRET_ACCESS_KEY = "test-secret-access-key"; + private static final String SESSION_POLICY = "test-session-policy"; + private static final int DURATION_SECONDS = 3600; + + private static SecretKey sharedSecretKey; + + @BeforeAll + public static void setUpClass() { + final byte[] keyBytes = "01234567890123456789012345678901".getBytes(StandardCharsets.US_ASCII); + sharedSecretKey = new SecretKeySpec(keyBytes, "HmacSHA256"); + } + + @BeforeEach + public void setUp() throws Exception { + final SecretKeySignerClient mockSecretKeyClient = mock(SecretKeySignerClient.class); + final ManagedSecretKey mockSecretKey = mock(ManagedSecretKey.class); + + final UUID keyId = UUID.fromString("00000000-0000-0000-0000-000000000000"); + when(mockSecretKey.getId()).thenReturn(keyId); + when(mockSecretKey.getSecretKey()).thenReturn(sharedSecretKey); + when(mockSecretKey.sign(any(STSTokenIdentifier.class))) + .thenReturn("mock-signature".getBytes(StandardCharsets.UTF_8)); + when(mockSecretKeyClient.getCurrentSecretKey()).thenReturn(mockSecretKey); + + secretManager = new STSTokenSecretManager(mockSecretKeyClient); + } + + @Test + public void testCreateSTSTokenStringContainsCorrectFields() throws IOException { + final Instant beforeCreation = Instant.now(); + + final String tokenString = secretManager.createSTSTokenString( + TEMP_ACCESS_KEY, ORIGINAL_ACCESS_KEY, ROLE_ARN, DURATION_SECONDS, SECRET_ACCESS_KEY, SESSION_POLICY); + + // Decode the token + final Token token = new Token<>(); + token.decodeFromUrlString(tokenString); + + // Verify the token identifier fields + final STSTokenIdentifier identifier = new STSTokenIdentifier(); + identifier.readFromByteArray(token.getIdentifier()); + final Instant afterCreation = Instant.now(); + final Instant expiration = identifier.getExpiry(); + + assertEquals(TEMP_ACCESS_KEY, identifier.getTempAccessKeyId()); + assertEquals(ORIGINAL_ACCESS_KEY, identifier.getOriginalAccessKeyId()); + assertEquals(ROLE_ARN, identifier.getRoleArn()); + assertEquals(SECRET_ACCESS_KEY, identifier.getSecretAccessKey()); + assertEquals(SESSION_POLICY, identifier.getSessionPolicy()); + assertNotNull(identifier.getSecretKeyId()); + assertEquals(new Text("STSToken"), identifier.getKind()); + assertEquals("STS", identifier.getService()); + // Verify expiration is approximately durationSeconds in the future + assertTrue(expiration.isAfter(beforeCreation.plusSeconds(DURATION_SECONDS - 1))); + assertTrue(expiration.isBefore(afterCreation.plusSeconds(DURATION_SECONDS + 1))); + } + + @Test + public void testCreateSTSTokenStringWithNullSessionPolicy() throws IOException { + final String tokenString = secretManager.createSTSTokenString( + TEMP_ACCESS_KEY, ORIGINAL_ACCESS_KEY, ROLE_ARN, DURATION_SECONDS, SECRET_ACCESS_KEY, null); + + // Decode the token + final Token token = new Token<>(); + token.decodeFromUrlString(tokenString); + + final STSTokenIdentifier identifier = new STSTokenIdentifier(); + identifier.readFromByteArray(token.getIdentifier()); + assertTrue(identifier.getSessionPolicy().isEmpty()); + } +} From b4ff104a446d2acee9f9e7b6e252962cab3b5843 Mon Sep 17 00:00:00 2001 From: fmorg-git Date: Mon, 24 Nov 2025 22:15:44 -0800 Subject: [PATCH 07/54] HDDS-13925. [STS] Part 2 - Create utility to convert IAM policy to groupings of OzoneObj and Acls (#9292) --- .../acl/iam/IamSessionPolicyResolver.java | 143 ++++++++++++++- .../acl/iam/TestIamSessionPolicyResolver.java | 165 +++++++++++++++++- 2 files changed, 297 insertions(+), 11 deletions(-) diff --git a/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/security/acl/iam/IamSessionPolicyResolver.java b/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/security/acl/iam/IamSessionPolicyResolver.java index 23fbf063c871..dbf3dc9e0766 100644 --- a/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/security/acl/iam/IamSessionPolicyResolver.java +++ b/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/security/acl/iam/IamSessionPolicyResolver.java @@ -17,19 +17,31 @@ package org.apache.hadoop.ozone.security.acl.iam; +import static java.util.Collections.singleton; import static org.apache.hadoop.ozone.om.exceptions.OMException.ResultCodes.INVALID_REQUEST; import static org.apache.hadoop.ozone.om.exceptions.OMException.ResultCodes.NOT_SUPPORTED_OPERATION; +import static org.apache.hadoop.ozone.security.acl.IAccessAuthorizer.ACLType.CREATE; +import static org.apache.hadoop.ozone.security.acl.IAccessAuthorizer.ACLType.DELETE; +import static org.apache.hadoop.ozone.security.acl.IAccessAuthorizer.ACLType.LIST; +import static org.apache.hadoop.ozone.security.acl.IAccessAuthorizer.ACLType.READ; +import static org.apache.hadoop.ozone.security.acl.IAccessAuthorizer.ACLType.READ_ACL; +import static org.apache.hadoop.ozone.security.acl.IAccessAuthorizer.ACLType.WRITE_ACL; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; +import com.google.common.annotations.VisibleForTesting; import java.util.Collections; +import java.util.EnumSet; import java.util.HashSet; +import java.util.LinkedHashMap; import java.util.LinkedHashSet; +import java.util.Map; import java.util.Objects; import java.util.Set; import org.apache.commons.lang3.StringUtils; import org.apache.hadoop.ozone.om.exceptions.OMException; import org.apache.hadoop.ozone.security.acl.AssumeRoleRequest; +import org.apache.hadoop.ozone.security.acl.IAccessAuthorizer.ACLType; /** * Resolves a limited subset of AWS IAM session policies into Ozone ACL grants, @@ -74,6 +86,12 @@ public final class IamSessionPolicyResolver { // under Policy section. private static final int MAX_JSON_LENGTH = 2048; + // Used to group actions into s3:Get*, s3:Put*, s3:List*, s3:Delete*, s3:Create* + private static final String[] S3_ACTION_PREFIXES = {"s3:Get", "s3:Put", "s3:List", "s3:Delete", "s3:Create"}; + + @VisibleForTesting + static final Map> S3_ACTION_MAP_CI = buildCaseInsensitiveS3ActionMap(); + private IamSessionPolicyResolver() { } @@ -275,13 +293,54 @@ private static Set parsePrefixesFromConditions(JsonNode stmt) throws OME return prefixes; } + /** + * Builds a case-insensitive S3Action map by lowercasing keys. This map is used for mapping policy actions to + * S3Action enum values. This map is built once and cached statically. + */ + @VisibleForTesting + static Map> buildCaseInsensitiveS3ActionMap() { + final Map> ciMap = new LinkedHashMap<>(); + for (S3Action sa : S3Action.values()) { + // Exact action mapping + ciMap.put(sa.name.toLowerCase(), singleton(sa)); + + // Group into s3:Get*, s3:Put*, s3:List*, s3:Delete*, s3:Create* based on action name prefix + for (String prefix : S3_ACTION_PREFIXES) { + if (sa.name.startsWith(prefix)) { + final String wildcardKey = (prefix + "*").toLowerCase(); + ciMap.computeIfAbsent(wildcardKey, k -> new LinkedHashSet<>()).add(sa); + break; + } + } + } + return Collections.unmodifiableMap(ciMap); + } + /** * Maps actions from JSON IAM policy to S3Action enum in order to determine what the * permissions should be. */ - private static Set mapPolicyActionsToS3Actions(Set actions) { - // TODO implement in future PR - return Collections.emptySet(); + @VisibleForTesting + static Set mapPolicyActionsToS3Actions(Set actions) { + if (actions == null || actions.isEmpty()) { + return Collections.emptySet(); + } + + // Map the actions from the IAM policy to S3Action + final Set mappedActions = new LinkedHashSet<>(); + for (String action : actions) { + if ("s3:*".equalsIgnoreCase(action)) { + return EnumSet.of(S3Action.ALL_S3); + } + + // Unsupported actions are silently ignored + final Set s3Actions = S3_ACTION_MAP_CI.get(action.toLowerCase()); + if (s3Actions != null) { + mappedActions.addAll(s3Actions); + } + } + + return mappedActions; } /** @@ -321,16 +380,84 @@ public enum AuthorizerType { } /** - * Utility to help categorize IAM policy resources, whether for bucket, key, wildcards, etc. + * The type of resource the S3 action applies to. */ - private static final class ResourceSpec { - // TODO implement in future PR + private enum ActionKind { + VOLUME, + BUCKET, + OBJECT, + ALL } /** - * Represents S3 actions and requisite permissions required and at what level. + * Utility to help categorize IAM policy resources, whether for bucket, key, wildcards, etc. */ - private enum S3Action { + private static final class ResourceSpec { // TODO implement in future PR } + + @VisibleForTesting + enum S3Action { + // Volume-scope + // Used for ListBuckets api + LIST_ALL_MY_BUCKETS("s3:ListAllMyBuckets", ActionKind.VOLUME, EnumSet.of(READ, LIST), + EnumSet.noneOf(ACLType.class), EnumSet.noneOf(ACLType.class)), + + // Bucket-scope + CREATE_BUCKET("s3:CreateBucket", ActionKind.BUCKET, EnumSet.of(READ), EnumSet.of(CREATE), + EnumSet.noneOf(ACLType.class)), + DELETE_BUCKET("s3:DeleteBucket", ActionKind.BUCKET, EnumSet.of(READ), EnumSet.of(DELETE), + EnumSet.noneOf(ACLType.class)), + GET_BUCKET_ACL("s3:GetBucketAcl", ActionKind.BUCKET, EnumSet.of(READ), EnumSet.of(READ, READ_ACL), + EnumSet.noneOf(ACLType.class)), + GET_BUCKET_LOCATION("s3:GetBucketLocation", ActionKind.BUCKET, EnumSet.of(READ), EnumSet.of(READ), + EnumSet.noneOf(ACLType.class)), + // Used for HeadBucket, ListObjects and ListObjectsV2 apis + LIST_BUCKET("s3:ListBucket", ActionKind.BUCKET, EnumSet.of(READ), EnumSet.of(READ, LIST), + EnumSet.noneOf(ACLType.class)), + // Used for ListMultipartUploads API + LIST_BUCKET_MULTIPART_UPLOADS("s3:ListBucketMultipartUploads", ActionKind.BUCKET, EnumSet.of(READ), + EnumSet.of(READ, LIST), EnumSet.noneOf(ACLType.class)), + PUT_BUCKET_ACL("s3:PutBucketAcl", ActionKind.BUCKET, EnumSet.of(READ), EnumSet.of(WRITE_ACL), + EnumSet.noneOf(ACLType.class)), + + // Object-scope + ABORT_MULTIPART_UPLOAD("s3:AbortMultipartUpload", ActionKind.OBJECT, EnumSet.of(READ), EnumSet.of(READ), + EnumSet.of(DELETE)), + // Used for DeleteObject (when versionId parameter is not supplied), + // DeleteObjects (when versionId parameter is not supplied) APIs + DELETE_OBJECT("s3:DeleteObject", ActionKind.OBJECT, EnumSet.of(READ), EnumSet.of(READ), EnumSet.of(DELETE)), + DELETE_OBJECT_TAGGING("s3:DeleteObjectTagging", ActionKind.OBJECT, EnumSet.of(READ), EnumSet.of(READ), + EnumSet.of(DELETE)), + // Used for HeadObject, CopyObject (for source bucket), GetObject (without versionId parameter) APIs + GET_OBJECT("s3:GetObject", ActionKind.OBJECT, EnumSet.of(READ), EnumSet.of(READ), EnumSet.of(READ)), + GET_OBJECT_TAGGING("s3:GetObjectTagging", ActionKind.OBJECT, EnumSet.of(READ), EnumSet.of(READ), EnumSet.of(READ)), + // Used for ListParts API + LIST_MULTIPART_UPLOAD_PARTS("s3:ListMultipartUploadParts", ActionKind.OBJECT, EnumSet.of(READ), EnumSet.of(READ), + EnumSet.of(READ)), + // Used for CreateMultipartUpload, UploadPart, CompleteMultipartUpload, + // CopyObject (for destination bucket), PutObject APIs + PUT_OBJECT("s3:PutObject", ActionKind.OBJECT, EnumSet.of(READ), EnumSet.of(READ), + EnumSet.of(CREATE, ACLType.WRITE)), + PUT_OBJECT_TAGGING("s3:PutObjectTagging", ActionKind.OBJECT, EnumSet.of(READ), EnumSet.of(READ), + EnumSet.of(ACLType.WRITE)), + + // Wildcard all + ALL_S3("s3:*", ActionKind.ALL, EnumSet.of(ACLType.ALL), EnumSet.of(ACLType.ALL), EnumSet.of(ACLType.ALL)); + + private final String name; + private final ActionKind kind; + private final Set volumePerms; + private final Set bucketPerms; + private final Set objectPerms; + + S3Action(String name, ActionKind kind, Set volumePerms, Set bucketPerms, + Set objectPerms) { + this.name = name; + this.kind = kind; + this.volumePerms = volumePerms; + this.bucketPerms = bucketPerms; + this.objectPerms = objectPerms; + } + } } diff --git a/hadoop-ozone/common/src/test/java/org/apache/hadoop/ozone/security/acl/iam/TestIamSessionPolicyResolver.java b/hadoop-ozone/common/src/test/java/org/apache/hadoop/ozone/security/acl/iam/TestIamSessionPolicyResolver.java index d37f441a51e2..5721901b19c6 100644 --- a/hadoop-ozone/common/src/test/java/org/apache/hadoop/ozone/security/acl/iam/TestIamSessionPolicyResolver.java +++ b/hadoop-ozone/common/src/test/java/org/apache/hadoop/ozone/security/acl/iam/TestIamSessionPolicyResolver.java @@ -21,9 +21,16 @@ import static org.apache.hadoop.ozone.om.exceptions.OMException.ResultCodes.NOT_SUPPORTED_OPERATION; import static org.apache.hadoop.ozone.security.acl.iam.IamSessionPolicyResolver.AuthorizerType.NATIVE; import static org.apache.hadoop.ozone.security.acl.iam.IamSessionPolicyResolver.AuthorizerType.RANGER; +import static org.apache.hadoop.ozone.security.acl.iam.IamSessionPolicyResolver.buildCaseInsensitiveS3ActionMap; +import static org.apache.hadoop.ozone.security.acl.iam.IamSessionPolicyResolver.mapPolicyActionsToS3Actions; import static org.assertj.core.api.Assertions.assertThat; +import java.util.Collections; +import java.util.LinkedHashSet; +import java.util.Map; +import java.util.Set; import org.apache.hadoop.ozone.om.exceptions.OMException; +import org.apache.hadoop.ozone.security.acl.iam.IamSessionPolicyResolver.S3Action; import org.junit.jupiter.api.Test; /** @@ -257,6 +264,158 @@ public void testEffectMustBeCaseSensitive() { json, "Unsupported Effect - aLLOw", NOT_SUPPORTED_OPERATION); } + @Test + public void testBuildCaseInsensitiveS3ActionMapMatchesConstant() { + assertThat(buildCaseInsensitiveS3ActionMap()).isEqualTo(IamSessionPolicyResolver.S3_ACTION_MAP_CI); + } + + @Test + public void testBuildCaseInsensitiveS3ActionMap() { + final Map> caseInsensitiveS3ActionMap = buildCaseInsensitiveS3ActionMap(); + + // Verify that individual S3 actions are present + assertThat(caseInsensitiveS3ActionMap).containsKeys( + "s3:listbucket", "s3:getobject", "s3:putobject", "s3:deleteobject", "s3:createbucket", "s3:listallmybuckets"); + + // Verify that wildcard actions are present + assertThat(caseInsensitiveS3ActionMap).containsKeys( + "s3:*", "s3:get*", "s3:put*", "s3:list*", "s3:delete*", "s3:create*"); + + // Verify s3:Get* contains Get actions + final Set getActions = caseInsensitiveS3ActionMap.get("s3:get*"); + assertThat(getActions).containsOnly( + S3Action.GET_OBJECT, S3Action.GET_BUCKET_ACL, S3Action.GET_BUCKET_LOCATION, S3Action.GET_OBJECT_TAGGING); + + // Verify s3:Put* contains Put actions + final Set putActions = caseInsensitiveS3ActionMap.get("s3:put*"); + assertThat(putActions).containsOnly( + S3Action.PUT_OBJECT, S3Action.PUT_OBJECT_TAGGING, S3Action.PUT_BUCKET_ACL); + + // Verify s3:List* contains List actions + final Set listActions = caseInsensitiveS3ActionMap.get("s3:list*"); + assertThat(listActions).containsOnly( + S3Action.LIST_BUCKET, S3Action.LIST_ALL_MY_BUCKETS, S3Action.LIST_BUCKET_MULTIPART_UPLOADS, + S3Action.LIST_MULTIPART_UPLOAD_PARTS); + + // Verify s3:Delete* contains Delete actions + final Set deleteActions = caseInsensitiveS3ActionMap.get("s3:delete*"); + assertThat(deleteActions).containsOnly( + S3Action.DELETE_OBJECT, S3Action.DELETE_BUCKET, S3Action.DELETE_OBJECT_TAGGING); + + // Verify s3:Create* contains Create actions + final Set createActions = caseInsensitiveS3ActionMap.get("s3:create*"); + assertThat(createActions).containsOnly(S3Action.CREATE_BUCKET); + } + + @Test + public void testBuildCaseInsensitiveS3ActionMapIndividualActionsContainSingleEntry() { + final Map> actionMap = buildCaseInsensitiveS3ActionMap(); + + // Individual actions should map to a set with exactly one entry + final Set listBucketAction = actionMap.get("s3:listbucket"); + assertThat(listBucketAction).hasSize(1); + + final Set getObjectAction = actionMap.get("s3:getobject"); + assertThat(getObjectAction).hasSize(1); + } + + @Test + public void testMapPolicyActionsToS3ActionsWithNullReturnsEmpty() { + final Set result = mapPolicyActionsToS3Actions(null); + assertThat(result).isEmpty(); + } + + @Test + public void testMapPolicyActionsToS3ActionsWithEmptyListReturnsEmpty() { + final Set result = mapPolicyActionsToS3Actions(Collections.emptySet()); + assertThat(result).isEmpty(); + } + + @Test + public void testMapPolicyActionsToS3ActionsWithSingleActionMapsCorrectly() { + final Set listBucket = mapPolicyActionsToS3Actions(Collections.singleton("s3:ListBucket")); + assertThat(listBucket).containsOnly(S3Action.LIST_BUCKET); + + // Ensure case-insensitive action works + final Set listBucketCi = mapPolicyActionsToS3Actions(Collections.singleton("S3:ListBuCKet")); + assertThat(listBucketCi).containsOnly(S3Action.LIST_BUCKET); + + final Set deleteObject = mapPolicyActionsToS3Actions(Collections.singleton("s3:DeleteObject")); + assertThat(deleteObject).containsOnly(S3Action.DELETE_OBJECT); + + // Ensure case-insensitive action works + final Set deleteObjectCi = mapPolicyActionsToS3Actions(Collections.singleton("S3:DeLETeObjeCT")); + assertThat(deleteObjectCi).containsOnly(S3Action.DELETE_OBJECT); + } + + @Test + public void testMapPolicyActionsToS3ActionsWithMultipleActionsMapAllCorrectly() { + final Set result = mapPolicyActionsToS3Actions(strSet("s3:ListBucket", "s3:GetObject", "s3:PutObject")); + assertThat(result).containsOnly(S3Action.LIST_BUCKET, S3Action.GET_OBJECT, S3Action.PUT_OBJECT); + } + + @Test + public void testMapPolicyActionsToS3ActionsWithWildcardExpansion() { + final Set result = mapPolicyActionsToS3Actions(Collections.singleton("s3:Get*")); + assertThat(result).containsOnly(S3Action.GET_OBJECT, S3Action.GET_BUCKET_ACL, S3Action.GET_BUCKET_LOCATION, + S3Action.GET_OBJECT_TAGGING); + + // Ensure it is case-insensitive + final Set resultCi = mapPolicyActionsToS3Actions(Collections.singleton("s3:gET*")); + assertThat(resultCi).containsOnly(S3Action.GET_OBJECT, S3Action.GET_BUCKET_ACL, S3Action.GET_BUCKET_LOCATION, + S3Action.GET_OBJECT_TAGGING); + } + + @Test + public void testMapPolicyActionsToS3ActionsWithS3StarReturnsAll() { + final Set result = mapPolicyActionsToS3Actions(Collections.singleton("s3:*")); + assertThat(result).containsOnly(S3Action.ALL_S3); + + final Set resultCi = mapPolicyActionsToS3Actions(Collections.singleton("S3:*")); + assertThat(resultCi).containsOnly(S3Action.ALL_S3); + } + + @Test + public void testMapPolicyActionsToS3ActionsIgnoresUnsupportedActions() { + final Set result = mapPolicyActionsToS3Actions(strSet("s3:GetAccelerateConfiguration", "s3:GetObject")); + // Unsupported action should be silently ignored + assertThat(result).containsOnly(S3Action.GET_OBJECT); + } + + @Test + public void testMapPolicyActionsToS3ActionsWithOnlyUnsupportedActionsReturnsEmpty() { + final Set result = mapPolicyActionsToS3Actions( + strSet("s3:GetAccelerateConfiguration", "s3:PutBucketVersioning")); + assertThat(result).isEmpty(); + } + + @Test + public void testMapPolicyActionsToS3ActionsDeduplicatesResults() { + final Set result = mapPolicyActionsToS3Actions(strSet("s3:Get*", "s3:GetObject", "s3:GetBucketAcl")); + assertThat(result).containsOnly(S3Action.GET_OBJECT, S3Action.GET_BUCKET_ACL, S3Action.GET_BUCKET_LOCATION, + S3Action.GET_OBJECT_TAGGING); + } + + @Test + public void testMapPolicyActionsToS3ActionsHandlesMultipleWildcards() { + final Set result = mapPolicyActionsToS3Actions(strSet("s3:Get*", "s3:Put*")); + assertThat(result).containsOnly(S3Action.GET_OBJECT, S3Action.GET_BUCKET_ACL, S3Action.GET_BUCKET_LOCATION, + S3Action.GET_OBJECT_TAGGING, S3Action.PUT_OBJECT, S3Action.PUT_OBJECT_TAGGING, S3Action.PUT_BUCKET_ACL); + } + + @Test + public void testMapPolicyActionsToS3ActionsWithS3StarIgnoresOtherActions() { + final Set result = mapPolicyActionsToS3Actions(strSet("s3:*", "s3:GetObject", "s3:PutObject")); + // When s3:* is present, it should return only the ALL_S3 action + assertThat(result).containsOnly(S3Action.ALL_S3); + } + + private static Set strSet(String... strs) { + final Set s = new LinkedHashSet<>(); + Collections.addAll(s, strs); + return s; + } + private static void expectResolveThrows(String json, IamSessionPolicyResolver.AuthorizerType authorizerType, String expectedMessage, OMException.ResultCodes expectedCode) { @@ -268,9 +427,9 @@ private static void expectResolveThrows(String json, assertThat(ex.getResult()).isEqualTo(expectedCode); } } - - private static void expectResolveThrowsForBothAuthorizers(String json, - String expectedMessage, OMException.ResultCodes expectedCode) { + + private static void expectResolveThrowsForBothAuthorizers(String json, String expectedMessage, + OMException.ResultCodes expectedCode) { expectResolveThrows(json, NATIVE, expectedMessage, expectedCode); expectResolveThrows(json, RANGER, expectedMessage, expectedCode); } From c63f444a46148cc05fbdb402b5b67e4cc3317172 Mon Sep 17 00:00:00 2001 From: fmorg-git Date: Tue, 2 Dec 2025 08:24:18 -0800 Subject: [PATCH 08/54] HDDS-13961. [STS] Encrypt secretAccessKey in session token (#9344) Co-authored-by: Fabian Morgan --- hadoop-ozone/ozone-manager/pom.xml | 4 + .../s3/security/S3AssumeRoleRequest.java | 14 +- .../ozone/security/STSTokenEncryption.java | 205 ++++++++++++++++++ .../ozone/security/STSTokenIdentifier.java | 79 ++++++- .../ozone/security/STSTokenSecretManager.java | 1 - .../security/TestSTSTokenEncryption.java | 200 +++++++++++++++++ .../security/TestSTSTokenIdentifier.java | 37 +++- .../security/TestSTSTokenSecretManager.java | 2 + 8 files changed, 524 insertions(+), 18 deletions(-) create mode 100644 hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/security/STSTokenEncryption.java create mode 100644 hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/security/TestSTSTokenEncryption.java diff --git a/hadoop-ozone/ozone-manager/pom.xml b/hadoop-ozone/ozone-manager/pom.xml index 923b1c02cbeb..d1e1be0798b4 100644 --- a/hadoop-ozone/ozone-manager/pom.xml +++ b/hadoop-ozone/ozone-manager/pom.xml @@ -209,6 +209,10 @@ org.aspectj aspectjrt + + org.bouncycastle + bcprov-jdk18on + org.eclipse.jetty jetty-webapp 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 31f71204dc46..9d092eaba015 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 @@ -42,7 +42,19 @@ */ public class S3AssumeRoleRequest extends OMClientRequest { - private static final SecureRandom SECURE_RANDOM = new SecureRandom(); + private static final SecureRandom SECURE_RANDOM; + + static { + SecureRandom secureRandom; + try { + // Prefer non-blocking native PRNG where available + secureRandom = SecureRandom.getInstance("NativePRNGNonBlocking"); + } catch (Exception e) { + // Fallback to default SecureRandom implementation + secureRandom = new SecureRandom(); + } + 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 diff --git a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/security/STSTokenEncryption.java b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/security/STSTokenEncryption.java new file mode 100644 index 000000000000..ef03da982b05 --- /dev/null +++ b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/security/STSTokenEncryption.java @@ -0,0 +1,205 @@ +/* + * 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 com.google.common.base.Preconditions; +import java.nio.charset.StandardCharsets; +import java.security.SecureRandom; +import java.util.Base64; +import javax.crypto.Cipher; +import javax.crypto.spec.GCMParameterSpec; +import javax.crypto.spec.SecretKeySpec; +import org.apache.hadoop.hdds.annotation.InterfaceAudience; +import org.apache.hadoop.hdds.annotation.InterfaceStability; +import org.bouncycastle.crypto.digests.SHA256Digest; +import org.bouncycastle.crypto.generators.HKDFBytesGenerator; +import org.bouncycastle.crypto.params.HKDFParameters; +import org.bouncycastle.jce.provider.BouncyCastleProvider; + +/** + * Utility class for encrypting and decrypting sensitive data in STS tokens. + * Uses HKDF to derive an AES encryption key from the SCM ManagedSecretKey, + * then uses AES-GCM for authenticated encryption. + */ +@InterfaceAudience.Private +@InterfaceStability.Unstable +public final class STSTokenEncryption { + + // HKDF parameters + private static final byte[] HKDF_INFO = "STS-TOKEN-ENCRYPTION".getBytes(StandardCharsets.UTF_8); + private static final int HKDF_SALT_LENGTH = 16; // 128 bits + private static final int AES_KEY_LENGTH = 32; // 256 bits + + // AES-GCM parameters + private static final int GCM_IV_LENGTH = 12; // 96 bits + private static final int GCM_AUTHENTICATION_TAG_LENGTH_IN_BITS = 128; + private static final String AES_ALGORITHM = "AES"; + private static final String AES_CIPHER_TRANSFORMATION = "AES/GCM/NoPadding"; + + private static final SecureRandom SECURE_RANDOM; + private static final BouncyCastleProvider BC_PROVIDER = new BouncyCastleProvider(); + + private STSTokenEncryption() { + } + + static { + SecureRandom secureRandom; + try { + // Prefer non-blocking native PRNG where available + secureRandom = SecureRandom.getInstance("NativePRNGNonBlocking"); + } catch (Exception e) { + // Fallback to default SecureRandom implementation + secureRandom = new SecureRandom(); + } + SECURE_RANDOM = secureRandom; + } + + /** + * Encrypt sensitive data using AES-GCM with a key derived from the secret key via HKDF, + * binding the provided AAD to the authentication tag. + * + * @param plaintext the sensitive data to encrypt + * @param secretKeyBytes the secret key bytes from ManagedSecretKey + * @param aad additional authenticated data to bind + * @return base64-encoded encrypted data with Salt and IV prepended + * @throws STSTokenEncryptionException if encryption fails + */ + public static String encrypt(String plaintext, byte[] secretKeyBytes, byte[] aad) throws STSTokenEncryptionException { + Preconditions.checkArgument( + secretKeyBytes != null && secretKeyBytes.length > 0, "The secretKeyBytes must not be null nor empty"); + Preconditions.checkArgument(aad != null && aad.length > 0, "The aad must not be null nor empty"); + // Don't encrypt null/empty strings + if (plaintext == null || plaintext.isEmpty()) { + return plaintext; + } + + byte[] aesKey; + byte[] iv; + byte[] salt; + try { + // Generate random salt + salt = new byte[HKDF_SALT_LENGTH]; + SECURE_RANDOM.nextBytes(salt); + + // Derive AES key using HKDF with random salt + aesKey = deriveKey(secretKeyBytes, salt); + + // Generate random IV + iv = new byte[GCM_IV_LENGTH]; + SECURE_RANDOM.nextBytes(iv); + + // Initialize AES-GCM cipher + final Cipher cipher = Cipher.getInstance(AES_CIPHER_TRANSFORMATION, BC_PROVIDER); + final GCMParameterSpec spec = new GCMParameterSpec(GCM_AUTHENTICATION_TAG_LENGTH_IN_BITS, iv); + cipher.init(Cipher.ENCRYPT_MODE, new SecretKeySpec(aesKey, AES_ALGORITHM), spec); + cipher.updateAAD(aad); + + // Encrypt the plaintext + final byte[] ciphertext = cipher.doFinal(plaintext.getBytes(StandardCharsets.UTF_8)); + + // Combine salt, IV and ciphertext + final byte[] result = org.bouncycastle.util.Arrays.concatenate(salt, iv, ciphertext); + + return Base64.getEncoder().encodeToString(result); + } catch (Exception e) { + throw new STSTokenEncryptionException("Failed to encrypt sensitive data", e); + } + } + + /** + * Decrypt sensitive data using AES-GCM with a key derived from the secret key via HKDF, + * verifying the provided AAD bound to the authentication tag. + * + * @param encryptedData base64-encoded encrypted data with Salt and IV prepended + * @param secretKeyBytes the secret key bytes from ManagedSecretKey + * @param aad additional authenticated data to verify + * @return decrypted plaintext + * @throws STSTokenEncryptionException if decryption fails + */ + public static String decrypt(String encryptedData, byte[] secretKeyBytes, byte[] aad) + throws STSTokenEncryptionException { + Preconditions.checkArgument( + secretKeyBytes != null && secretKeyBytes.length > 0, "The secretKeyBytes must not be null nor empty"); + Preconditions.checkArgument(aad != null && aad.length > 0, "The aad must not be null nor empty"); + // Don't decrypt null/empty strings + if (encryptedData == null || encryptedData.isEmpty()) { + return encryptedData; + } + + byte[] aesKey; + try { + // Decode base64 + final byte[] data = Base64.getDecoder().decode(encryptedData); + + if (data.length < HKDF_SALT_LENGTH + GCM_IV_LENGTH) { + throw new STSTokenEncryptionException("Invalid encrypted data"); + } + + // Extract salt, IV and ciphertext + final byte[] salt = new byte[HKDF_SALT_LENGTH]; + final byte[] iv = new byte[GCM_IV_LENGTH]; + final byte[] ciphertext = new byte[data.length - HKDF_SALT_LENGTH - GCM_IV_LENGTH]; + + System.arraycopy(data, 0, salt, 0, HKDF_SALT_LENGTH); + System.arraycopy(data, HKDF_SALT_LENGTH, iv, 0, GCM_IV_LENGTH); + System.arraycopy(data, HKDF_SALT_LENGTH + GCM_IV_LENGTH, ciphertext, 0, ciphertext.length); + + // Derive AES key using HKDF with extracted salt + aesKey = deriveKey(secretKeyBytes, salt); + + // Initialize AES-GCM cipher + final Cipher cipher = Cipher.getInstance(AES_CIPHER_TRANSFORMATION, BC_PROVIDER); + final GCMParameterSpec spec = new GCMParameterSpec(GCM_AUTHENTICATION_TAG_LENGTH_IN_BITS, iv); + cipher.init(Cipher.DECRYPT_MODE, new SecretKeySpec(aesKey, AES_ALGORITHM), spec); + cipher.updateAAD(aad); + + // Decrypt the ciphertext + final byte[] output = cipher.doFinal(ciphertext); + + return new String(output, StandardCharsets.UTF_8); + } catch (Exception e) { + throw new STSTokenEncryptionException("Failed to decrypt sensitive data", e); + } + } + + /** + * Derive AES key using HKDF-SHA256. + */ + private static byte[] deriveKey(byte[] secretKeyBytes, byte[] salt) { + final HKDFBytesGenerator hkdf = new HKDFBytesGenerator(new SHA256Digest()); + hkdf.init(new HKDFParameters(secretKeyBytes, salt, HKDF_INFO)); + + final byte[] aesKey = new byte[AES_KEY_LENGTH]; + hkdf.generateBytes(aesKey, 0, AES_KEY_LENGTH); + return aesKey; + } + + /** + * Exception thrown when encryption/decryption operations fail. + */ + public static class STSTokenEncryptionException extends Exception { + public STSTokenEncryptionException(String message) { + super(message); + } + + public STSTokenEncryptionException(String message, Throwable cause) { + super(message, cause); + } + } +} + diff --git a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/security/STSTokenIdentifier.java b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/security/STSTokenIdentifier.java index 1f2e8d300ae2..1ba4b7186f2c 100644 --- a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/security/STSTokenIdentifier.java +++ b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/security/STSTokenIdentifier.java @@ -23,6 +23,7 @@ import java.io.DataInputStream; import java.io.DataOutput; import java.io.IOException; +import java.nio.charset.StandardCharsets; import java.time.Instant; import java.util.Objects; import java.util.UUID; @@ -112,21 +113,24 @@ public void readFields(DataInput in) throws IOException { * Convert this identifier to protobuf format. */ public OMTokenProto toProtoBuf() { - final OMTokenProto.Builder builder = OMTokenProto.newBuilder() + Preconditions.checkArgument(this.encryptionKey != null, "The encryption key must not be null"); + + final OMTokenProto.Builder builder = OMTokenProto.newBuilder(); + // Note: secretKeyId must be set before attempting to decrypt secretAccessKey + if (getSecretKeyId() != null) { + builder.setSecretKeyId(getSecretKeyId().toString()); + } + + builder .setType(OMTokenProto.Type.S3_STS_TOKEN) .setMaxDate(getExpiry().toEpochMilli()) .setOwner(getOwnerId() != null ? getOwnerId() : "") .setAccessKeyId(getOwnerId() != null ? getOwnerId() : "") .setOriginalAccessKeyId(originalAccessKeyId != null ? originalAccessKeyId : "") .setRoleArn(roleArn != null ? roleArn : "") - // TODO sts - encrypt secret access key in a future PR - .setSecretAccessKey(secretAccessKey != null ? secretAccessKey : "") + .setSecretAccessKey(secretAccessKey != null ? encryptSensitiveField(secretAccessKey) : "") .setSessionPolicy(sessionPolicy != null ? sessionPolicy : ""); - if (getSecretKeyId() != null) { - builder.setSecretKeyId(getSecretKeyId().toString()); - } - return builder.build(); } @@ -137,6 +141,7 @@ public void fromProtoBuf(OMTokenProto token) throws IOException { Preconditions.checkArgument( token.getType() == OMTokenProto.Type.S3_STS_TOKEN, "Invalid token type for STSTokenIdentifier: " + token.getType()); + Preconditions.checkArgument(this.encryptionKey != null, "The encryption key must not be null"); setOwnerId(token.getOwner()); setExpiry(Instant.ofEpochMilli(token.getMaxDate())); @@ -147,11 +152,6 @@ public void fromProtoBuf(OMTokenProto token) throws IOException { if (token.hasRoleArn()) { this.roleArn = token.getRoleArn(); } - if (token.hasSecretAccessKey()) { - // TODO sts - decrypt secret access key in a future PR - this.secretAccessKey = token.getSecretAccessKey(); - } - if (token.hasSecretKeyId()) { try { setSecretKeyId(UUID.fromString(token.getSecretKeyId())); @@ -161,12 +161,63 @@ public void fromProtoBuf(OMTokenProto token) throws IOException { "Invalid secretKeyId format in STS token: " + token.getSecretKeyId(), e); } } + // Note: secretKeyId must be set before attempting to decrypt secretAccessKey + if (token.hasSecretAccessKey()) { + this.secretAccessKey = decryptSensitiveField(token.getSecretAccessKey()); + } if (token.hasSessionPolicy()) { this.sessionPolicy = token.getSessionPolicy(); } } + /** + * Encrypt a sensitive field using the configured encryption key. + */ + private String encryptSensitiveField(String value) { + if (encryptionKey == null) { + throw new IllegalStateException("Encryption key must be set before encrypting sensitive fields"); + } + + try { + final byte[] aad = computeAadBytes(); + return STSTokenEncryption.encrypt(value, encryptionKey, aad); + } catch (STSTokenEncryption.STSTokenEncryptionException e) { + throw new RuntimeException("Token encryption failed", e); + } + } + + /** + * Decrypt a sensitive field using the configured encryption key. + */ + private String decryptSensitiveField(String encryptedValue) { + if (encryptionKey == null) { + throw new IllegalStateException("Encryption key must be set before decrypting sensitive fields"); + } + + try { + final byte[] aad = computeAadBytes(); + return STSTokenEncryption.decrypt(encryptedValue, encryptionKey, aad); + } catch (STSTokenEncryption.STSTokenEncryptionException e) { + throw new RuntimeException("Token decryption failed", e); + } + } + + /** + * Compute additional authenticated data to bind token context to encryption. + * Includes token type, ownerId, expiry millis, and secretKeyId. + */ + private byte[] computeAadBytes() { + final StringBuilder stringBuilder = new StringBuilder("v1|S3_STS_TOKEN|"); + stringBuilder.append(getOwnerId()); + stringBuilder.append('|'); + stringBuilder.append(getExpiry().toEpochMilli()); + stringBuilder.append('|'); + stringBuilder.append(getSecretKeyId().toString()); + final String aad = stringBuilder.toString(); + return aad.getBytes(StandardCharsets.UTF_8); + } + public String getRoleArn() { return roleArn; } @@ -193,6 +244,10 @@ public String getSessionPolicy() { return sessionPolicy; } + public void setEncryptionKey(byte[] encryptionKey) { + this.encryptionKey = encryptionKey.clone(); + } + @Override public boolean equals(Object o) { if (this == o) { diff --git a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/security/STSTokenSecretManager.java b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/security/STSTokenSecretManager.java index b418beea4c3f..598a5a71675e 100644 --- a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/security/STSTokenSecretManager.java +++ b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/security/STSTokenSecretManager.java @@ -72,7 +72,6 @@ public String createSTSTokenString(String tempAccessKeyId, String originalAccess // Note - the encryptionKey will NOT be encoded in the token. When generateToken() is called, it eventually calls // the write() method in STSTokenIdentifier which calls toProtoBuf(), and the encryptionKey is not // serialized there. - // TODO sts - use the encryptionKey in a future PR to encrypt/decrypt the secretAccessKey final STSTokenIdentifier identifier = new STSTokenIdentifier( tempAccessKeyId, originalAccessKeyId, roleArn, expiration, secretAccessKey, sessionPolicy, encryptionKey); diff --git a/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/security/TestSTSTokenEncryption.java b/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/security/TestSTSTokenEncryption.java new file mode 100644 index 000000000000..1eb880f9dd03 --- /dev/null +++ b/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/security/TestSTSTokenEncryption.java @@ -0,0 +1,200 @@ +/* + * 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.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.nio.charset.StandardCharsets; +import java.time.Instant; +import java.util.Base64; +import java.util.UUID; +import javax.crypto.KeyGenerator; +import javax.crypto.SecretKey; +import javax.crypto.spec.SecretKeySpec; +import org.apache.hadoop.ozone.protocol.proto.OzoneManagerProtocolProtos; +import org.apache.hadoop.ozone.security.STSTokenEncryption.STSTokenEncryptionException; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +/** + * Test class for STS token encryption functionality. + */ +public class TestSTSTokenEncryption { + + // These must match the constants in STSTokenEncryption. + private static final int HKDF_SALT_LENGTH = 16; // 128 bits + + private static SecretKey sharedSecretKey; + + @BeforeAll + public static void setUpClass() { + final byte[] keyBytes = "01234567890123456789012345678901".getBytes(StandardCharsets.US_ASCII); + sharedSecretKey = new SecretKeySpec(keyBytes, "HmacSHA256"); + } + + @Test + public void testEncryptDecryptRoundTrip() throws Exception { + final byte[] keyBytes = sharedSecretKey.getEncoded(); + + final String originalSecret = "mySecretAccessKey123456"; + final byte[] aad = "test-aad".getBytes(StandardCharsets.UTF_8); + + // Encrypt the secret + final String encrypted = STSTokenEncryption.encrypt(originalSecret, keyBytes, aad); + assertNotNull(encrypted); + assertNotEquals(originalSecret, encrypted); + + // Decrypt the secret + final String decrypted = STSTokenEncryption.decrypt(encrypted, keyBytes, aad); + assertEquals(originalSecret, decrypted); + } + + @Test + public void testSTSTokenIdentifierEncryption() throws Exception { + final byte[] keyBytes = sharedSecretKey.getEncoded(); + + final String tempAccessKeyId = "ASIA123TEMPKEY"; + final String originalAccessKeyId = "AKIA123ORIGINAL"; + final String roleArn = "arn:aws:iam::123456789012:role/TestRole"; + final String secretAccessKey = "mySecretAccessKey123456"; + // Use millisecond precision to match serialization format + final Instant expiry = Instant.ofEpochMilli(Instant.now().plusSeconds(3600).toEpochMilli()); + final String sessionPolicy = "test-session-policy"; + + // Create token identifier with encryption + final STSTokenIdentifier tokenId = new STSTokenIdentifier( + tempAccessKeyId, originalAccessKeyId, roleArn, expiry, secretAccessKey, sessionPolicy, keyBytes); + tokenId.setSecretKeyId(UUID.randomUUID()); + + // Convert to protobuf + final OzoneManagerProtocolProtos.OMTokenProto omTokenProto = tokenId.toProtoBuf(); + assertNotEquals(secretAccessKey, omTokenProto.getSecretAccessKey()); // ensure secretAccessKey is encrypted + final byte[] protobufBytes = omTokenProto.toByteArray(); + + // Create new token identifier from protobuf with decryption key + final STSTokenIdentifier decodedTokenId = new STSTokenIdentifier(); + decodedTokenId.setEncryptionKey(keyBytes); + decodedTokenId.readFromByteArray(protobufBytes); + + // Verify all fields are correctly decrypted + assertEquals(tempAccessKeyId, decodedTokenId.getTempAccessKeyId()); + assertEquals(originalAccessKeyId, decodedTokenId.getOriginalAccessKeyId()); + assertEquals(roleArn, decodedTokenId.getRoleArn()); + assertEquals(secretAccessKey, decodedTokenId.getSecretAccessKey()); + assertEquals(expiry, decodedTokenId.getExpiry()); + } + + @Test + public void testDecryptionWithWrongKey() throws Exception { + // Generate two different keys + final KeyGenerator keyGen = KeyGenerator.getInstance("HmacSHA256"); + keyGen.init(256); + final SecretKey key1 = keyGen.generateKey(); + final SecretKey key2 = keyGen.generateKey(); + + final String originalSecret = "mySecretAccessKey123456"; + final byte[] aad = "key-aad".getBytes(StandardCharsets.UTF_8); + + // Encrypt with key1 + final String encrypted = STSTokenEncryption.encrypt(originalSecret, key1.getEncoded(), aad); + + // Try to decrypt with key2 - should fail + assertThrows( + STSTokenEncryptionException.class, () -> STSTokenEncryption.decrypt(encrypted, key2.getEncoded(), aad)); + } + + @Test + public void testDecryptionFailsWhenCiphertextIsCorrupted() throws Exception { + final byte[] keyBytes = sharedSecretKey.getEncoded(); + + final String originalSecret = "mySecretAccessKey123456"; + final byte[] aad = "ciphertext-aad".getBytes(StandardCharsets.UTF_8); + + // Encrypt the secret + final String encrypted = STSTokenEncryption.encrypt(originalSecret, keyBytes, aad); + final byte[] data = Base64.getDecoder().decode(encrypted); + + // Corrupt the last byte of the ciphertext segment + data[data.length - 1] ^= 0x01; + + final String tampered = Base64.getEncoder().encodeToString(data); + + // Decryption must fail with corrupted ciphertext + assertThrows( + STSTokenEncryptionException.class, + () -> STSTokenEncryption.decrypt(tampered, keyBytes, aad)); + } + + @Test + public void testDecryptionFailsWhenSaltIsCorrupted() throws Exception { + final byte[] keyBytes = sharedSecretKey.getEncoded(); + + final String originalSecret = "mySecretAccessKey123456"; + final byte[] aad = "salt-aad".getBytes(StandardCharsets.UTF_8); + + // Encrypt the secret + final String encrypted = STSTokenEncryption.encrypt(originalSecret, keyBytes, aad); + final byte[] data = Base64.getDecoder().decode(encrypted); + + // Corrupt the first byte of the salt segment + data[0] ^= 0x01; + + final String tampered = Base64.getEncoder().encodeToString(data); + + // Decryption must fail with corrupted salt (derives wrong AES key) + assertThrows(STSTokenEncryptionException.class, () -> STSTokenEncryption.decrypt(tampered, keyBytes, aad)); + } + + @Test + public void testDecryptionFailsWhenIvIsCorrupted() throws Exception { + final byte[] keyBytes = sharedSecretKey.getEncoded(); + + final String originalSecret = "mySecretAccessKey123456"; + final byte[] aad = "iv-aad".getBytes(StandardCharsets.UTF_8); + + // Encrypt the secret + final String encrypted = STSTokenEncryption.encrypt(originalSecret, keyBytes, aad); + final byte[] data = Base64.getDecoder().decode(encrypted); + + // Corrupt the first byte of the IV segment + data[HKDF_SALT_LENGTH] ^= 0x01; + + final String tampered = Base64.getEncoder().encodeToString(data); + + // Decryption must fail with corrupted IV + assertThrows(STSTokenEncryptionException.class, () -> STSTokenEncryption.decrypt(tampered, keyBytes, aad)); + } + + @Test + public void testDecryptionFailsWhenAadIsModified() throws Exception { + final byte[] keyBytes = sharedSecretKey.getEncoded(); + + final String originalSecret = "mySecretAccessKey123456"; + final byte[] aadOriginal = "aad-original".getBytes(StandardCharsets.UTF_8); + final byte[] aadModified = "aad-modified".getBytes(StandardCharsets.UTF_8); + + // Encrypt with original AAD + final String encrypted = STSTokenEncryption.encrypt(originalSecret, keyBytes, aadOriginal); + + // Decrypt with modified AAD - authentication must fail + assertThrows(STSTokenEncryptionException.class, () -> STSTokenEncryption.decrypt(encrypted, keyBytes, aadModified)); + } +} diff --git a/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/security/TestSTSTokenIdentifier.java b/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/security/TestSTSTokenIdentifier.java index ada9c7561045..549d473a49d2 100644 --- a/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/security/TestSTSTokenIdentifier.java +++ b/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/security/TestSTSTokenIdentifier.java @@ -70,11 +70,12 @@ public void testProtoBufRoundTrip() throws IOException { assertThat(proto.getMaxDate()).isEqualTo(expiry.toEpochMilli()); assertThat(proto.getOriginalAccessKeyId()).isEqualTo("origAccess"); assertThat(proto.getRoleArn()).isEqualTo("arn:aws:iam::123456789012:role/RoleY"); - assertThat(proto.getSecretAccessKey()).isEqualTo("secretKey"); + assertThat(proto.getSecretAccessKey()).isNotEqualTo("secretKey"); // must be encrypted assertThat(proto.getSessionPolicy()).isEqualTo("sessionPolicy"); assertThat(proto.getSecretKeyId()).isEqualTo(secretKeyId.toString()); final STSTokenIdentifier parsedTokenIdentifier = new STSTokenIdentifier(); + parsedTokenIdentifier.setEncryptionKey(ENCRYPTION_KEY); parsedTokenIdentifier.fromProtoBuf(proto); assertThat(parsedTokenIdentifier.getOwnerId()).isEqualTo("tempAccess"); @@ -111,11 +112,14 @@ public void testProtobufRoundTripWithNullSessionPolicy() throws IOException { final STSTokenIdentifier stsTokenIdentifier = new STSTokenIdentifier( "tempAccess", "origAccess", "arn:aws:iam::123456789012:role/RoleX", expiry, "secretKey", null, ENCRYPTION_KEY); + final UUID secretKeyId = UUID.randomUUID(); + stsTokenIdentifier.setSecretKeyId(secretKeyId); final OMTokenProto proto = stsTokenIdentifier.toProtoBuf(); assertThat(proto.getSessionPolicy()).isEmpty(); final STSTokenIdentifier parsedTokenIdentifier = new STSTokenIdentifier(); + parsedTokenIdentifier.setEncryptionKey(ENCRYPTION_KEY); parsedTokenIdentifier.fromProtoBuf(proto); assertThat(parsedTokenIdentifier.getSessionPolicy()).isEmpty(); @@ -127,11 +131,14 @@ public void testProtobufRoundTripWithEmptySessionPolicy() throws IOException { final STSTokenIdentifier stsTokenIdentifier = new STSTokenIdentifier( "tempAccess", "origAccess", "arn:aws:iam::123456789012:role/RoleZ", expiry, "secretKey", "", ENCRYPTION_KEY); + final UUID secretKeyId = UUID.randomUUID(); + stsTokenIdentifier.setSecretKeyId(secretKeyId); final OMTokenProto proto = stsTokenIdentifier.toProtoBuf(); assertThat(proto.getSessionPolicy()).isEmpty(); final STSTokenIdentifier parsedTokenIdentifier = new STSTokenIdentifier(); + parsedTokenIdentifier.setEncryptionKey(ENCRYPTION_KEY); parsedTokenIdentifier.fromProtoBuf(proto); assertThat(parsedTokenIdentifier.getSessionPolicy()).isEmpty(); @@ -173,6 +180,7 @@ public void testWriteToAndReadFromByteArray() throws Exception { final byte[] bytes = baos.toByteArray(); final STSTokenIdentifier parsedTokenIdentifier = new STSTokenIdentifier(); + parsedTokenIdentifier.setEncryptionKey(ENCRYPTION_KEY); parsedTokenIdentifier.readFromByteArray(bytes); assertThat(parsedTokenIdentifier).isEqualTo(originalTokenIdentifier); @@ -207,8 +215,19 @@ public void testWriteToAndReadFromByteArrayWithDifferentSecretKeyIds() throws Ex anotherTokenIdentifier.write(out); } - // The byte arrays should be different due to different secret key IDs + // The byte arrays will not be the same because the encrypted secretAccessKey cipher for each will differ. + // However, the STSTokenIdentifier derived from each byte array should also not be the same. assertThat(baos1.toByteArray()).isNotEqualTo(baos2.toByteArray()); + final byte[] byteArr1 = baos1.toByteArray(); + final byte[] byteArr2 = baos2.toByteArray(); + assertThat(byteArr1).isNotEqualTo(byteArr2); + final STSTokenIdentifier tokenFromByteArr1 = new STSTokenIdentifier(); + tokenFromByteArr1.setEncryptionKey(ENCRYPTION_KEY); + tokenFromByteArr1.readFromByteArray(byteArr1); + final STSTokenIdentifier tokenFromByteArr2 = new STSTokenIdentifier(); + tokenFromByteArr2.setEncryptionKey(ENCRYPTION_KEY); + tokenFromByteArr2.readFromByteArray(byteArr2); + assertThat(tokenFromByteArr1).isNotEqualTo(tokenFromByteArr2); } @Test @@ -236,8 +255,18 @@ public void testWriteToAndReadFromByteArrayWithSameSecretKeyIds() throws Excepti anotherTokenIdentifier.write(out); } - // The byte arrays should be the same since they have the same contents - assertThat(baos1.toByteArray()).isEqualTo(baos2.toByteArray()); + // The byte arrays should not be the same because the encrypted secretAccessKey cipher for each will differ. + // However, the STSTokenIdentifier derived from each byte array should be the same. + final byte[] byteArr1 = baos1.toByteArray(); + final byte[] byteArr2 = baos2.toByteArray(); + assertThat(byteArr1).isNotEqualTo(byteArr2); + final STSTokenIdentifier tokenFromByteArr1 = new STSTokenIdentifier(); + tokenFromByteArr1.setEncryptionKey(ENCRYPTION_KEY); + tokenFromByteArr1.readFromByteArray(byteArr1); + final STSTokenIdentifier tokenFromByteArr2 = new STSTokenIdentifier(); + tokenFromByteArr2.setEncryptionKey(ENCRYPTION_KEY); + tokenFromByteArr2.readFromByteArray(byteArr2); + assertThat(tokenFromByteArr1).isEqualTo(tokenFromByteArr2); } @Test diff --git a/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/security/TestSTSTokenSecretManager.java b/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/security/TestSTSTokenSecretManager.java index f35fc902460e..5eb7868c4a2b 100644 --- a/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/security/TestSTSTokenSecretManager.java +++ b/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/security/TestSTSTokenSecretManager.java @@ -86,6 +86,7 @@ public void testCreateSTSTokenStringContainsCorrectFields() throws IOException { // Verify the token identifier fields final STSTokenIdentifier identifier = new STSTokenIdentifier(); + identifier.setEncryptionKey(sharedSecretKey.getEncoded()); identifier.readFromByteArray(token.getIdentifier()); final Instant afterCreation = Instant.now(); final Instant expiration = identifier.getExpiry(); @@ -113,6 +114,7 @@ public void testCreateSTSTokenStringWithNullSessionPolicy() throws IOException { token.decodeFromUrlString(tokenString); final STSTokenIdentifier identifier = new STSTokenIdentifier(); + identifier.setEncryptionKey(sharedSecretKey.getEncoded()); identifier.readFromByteArray(token.getIdentifier()); assertTrue(identifier.getSessionPolicy().isEmpty()); } From 934f4c188425c5639d7c339a1f1bc51050a8ea39 Mon Sep 17 00:00:00 2001 From: fmorg-git Date: Thu, 4 Dec 2025 01:19:56 -0800 Subject: [PATCH 09/54] HDDS-13926. [STS] Part 3 - Create utility to convert IAM policy to groupings of OzoneObj and Acls (#9306) --- .../ozone/security/acl/AssumeRoleRequest.java | 5 + .../acl/iam/IamSessionPolicyResolver.java | 440 ++++++++++++- .../acl/iam/TestIamSessionPolicyResolver.java | 595 +++++++++++++++++- 3 files changed, 1019 insertions(+), 21 deletions(-) diff --git a/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/security/acl/AssumeRoleRequest.java b/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/security/acl/AssumeRoleRequest.java index 1272d5422ec1..03d093b5aef6 100644 --- a/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/security/acl/AssumeRoleRequest.java +++ b/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/security/acl/AssumeRoleRequest.java @@ -123,5 +123,10 @@ public boolean equals(Object o) { public int hashCode() { return Objects.hash(objects, permissions); } + + @Override + public String toString() { + return "OzoneGrant{" + "objects=" + objects + ", permissions=" + permissions + '}'; + } } } diff --git a/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/security/acl/iam/IamSessionPolicyResolver.java b/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/security/acl/iam/IamSessionPolicyResolver.java index dbf3dc9e0766..7e10d566b591 100644 --- a/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/security/acl/iam/IamSessionPolicyResolver.java +++ b/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/security/acl/iam/IamSessionPolicyResolver.java @@ -30,6 +30,7 @@ import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import com.google.common.annotations.VisibleForTesting; +import com.google.common.base.Preconditions; import java.util.Collections; import java.util.EnumSet; import java.util.HashSet; @@ -42,6 +43,9 @@ import org.apache.hadoop.ozone.om.exceptions.OMException; import org.apache.hadoop.ozone.security.acl.AssumeRoleRequest; import org.apache.hadoop.ozone.security.acl.IAccessAuthorizer.ACLType; +import org.apache.hadoop.ozone.security.acl.IOzoneObj; +import org.apache.hadoop.ozone.security.acl.OzoneObj; +import org.apache.hadoop.ozone.security.acl.OzoneObjInfo; /** * Resolves a limited subset of AWS IAM session policies into Ozone ACL grants, @@ -73,7 +77,7 @@ * AWS spec. *

* If a (currently) unsupported S3 action is requested, such as s3:GetAccelerateConfiguration, - * it will be silently ignored. + * it will be silently ignored. Similarly, if an invalid S3 action is requested, it will be silently ignored. *

* Supported wildcard expansions in Actions are: s3:*, s3:Get*, s3:Put*, s3:List*, * s3:Create*, and s3:Delete*. @@ -82,6 +86,8 @@ public final class IamSessionPolicyResolver { private static final ObjectMapper MAPPER = new ObjectMapper(); + private static final String AWS_S3_ARN_PREFIX = "arn:aws:s3:::"; + // JSON length is limited per AWS policy. See https://docs.aws.amazon.com/STS/latest/APIReference/API_AssumeRole.html // under Policy section. private static final int MAX_JSON_LENGTH = 2048; @@ -343,6 +349,17 @@ static Set mapPolicyActionsToS3Actions(Set actions) { return mappedActions; } + /** + * Validates that wildcard bucket patterns are not used with native authorizer. + */ + private static void validateNativeAuthorizerBucketPattern(AuthorizerType authorizerType, String bucket) + throws OMException { + if (authorizerType == AuthorizerType.NATIVE && bucket.contains("*")) { + throw new OMException( + "Wildcard bucket patterns are not supported for Ozone native authorizer", NOT_SUPPORTED_OPERATION); + } + } + /** * Iterates over resources in IAM policy and determines whether it is a bucket resource, * an object resource, a prefix or a wildcard. The categorization can be different @@ -352,21 +369,276 @@ static Set mapPolicyActionsToS3Actions(Set actions) { *

* It also validates that the Resource Arn(s) are valid and supported. */ - private static Set validateAndCategorizeResources(AuthorizerType authorizerType, + @VisibleForTesting + static Set validateAndCategorizeResources(AuthorizerType authorizerType, Set resources) throws OMException { - // TODO implement in future PR - return Collections.emptySet(); + final Set resourceSpecs = new HashSet<>(); + if (resources.isEmpty()) { + throw new OMException("No Resource(s) found in policy", INVALID_REQUEST); + } + for (String resource : resources) { + if ("*".equals(resource)) { + validateNativeAuthorizerBucketPattern(authorizerType, "*"); + resourceSpecs.add(ResourceSpec.any()); + continue; + } + + if (!resource.startsWith(AWS_S3_ARN_PREFIX)) { + throw new OMException("Unsupported Resource Arn - " + resource, NOT_SUPPORTED_OPERATION); + } + + final String suffix = resource.substring(AWS_S3_ARN_PREFIX.length()); + if (suffix.isEmpty()) { + throw new OMException("Invalid Resource Arn - " + resource, INVALID_REQUEST); + } + + ResourceSpec spec = parseResourceSpec(suffix); + + // This scenario can happen in the case of arn:aws:s3:::*/* or arn:aws:s3:::*/test.txt for + // examples + validateNativeAuthorizerBucketPattern(authorizerType, spec.bucket); + + if (authorizerType == AuthorizerType.NATIVE && spec.type == S3ResourceType.OBJECT_PREFIX_WILDCARD) { + final String specPrefixExceptLastChar = spec.prefix.substring(0, spec.prefix.length() - 1); + if (spec.prefix.endsWith("*") && !specPrefixExceptLastChar.contains("*")) { + spec = ResourceSpec.objectPrefix(spec.bucket, specPrefixExceptLastChar); + } else { + throw new OMException( + "Wildcard prefix patterns are not supported for Ozone native authorizer if wildcard is not at the end", + NOT_SUPPORTED_OPERATION); + } + } + resourceSpecs.add(spec); + } + return resourceSpecs; } /** * Iterates over all resources, finds applicable actions (if any) and constructs * entries pairing sets of IOzoneObjs with the requisite permissions granted (if any). */ - private static Set createPathsAndPermissions(String volumeName, - AuthorizerType authorizerType, Set mappedS3Actions, Set resourceSpecs, - Set prefixes) { - // TODO implement in future PR - return Collections.emptySet(); + @VisibleForTesting + static Set createPathsAndPermissions(String volumeName, AuthorizerType authorizerType, + Set mappedS3Actions, Set resourceSpecs, Set prefixes) { + // Create map to collect IOzoneObj to ACLType mappings + final Map> objToAclsMap = new LinkedHashMap<>(); + + // Process each resource spec with the given actions + for (ResourceSpec resourceSpec : resourceSpecs) { + processResourceSpecWithActions(volumeName, authorizerType, mappedS3Actions, resourceSpec, prefixes, objToAclsMap); + } + + // Group objects by their ACL sets to create proper entries + return groupObjectsByAcls(objToAclsMap); + } + + /** + * Groups objects by their ACL sets. + */ + private static Set groupObjectsByAcls(Map> objToAclsMap) { + final Map, Set> groupMap = new LinkedHashMap<>(); + + // Group objects by their ACL sets only (across resource types) + objToAclsMap.forEach((obj, acls) -> + groupMap.computeIfAbsent(acls, k -> new LinkedHashSet<>()).add(obj)); + + // Convert to result format, filtering out entries with empty ACLs + final Set result = new LinkedHashSet<>(); + groupMap.forEach((key, objs) -> { + if (!key.isEmpty()) { + result.add(new AssumeRoleRequest.OzoneGrant(objs, key)); + } + }); + + return result; + } + + /** + * Processes a single ResourceSpec with given actions and adds resulting + * IOzoneObj to ACLType mappings to the provided map. + */ + private static void processResourceSpecWithActions(String volumeName, AuthorizerType authorizerType, + Set mappedS3Actions, ResourceSpec resourceSpec, Set prefixes, + Map> objToAclsMap) { + + // Process based on ResourceSpec type + switch (resourceSpec.type) { + case ANY: + Preconditions.checkArgument( + authorizerType != AuthorizerType.NATIVE, + "ResourceSpec type ANY not supported for OzoneNativeAuthorizer"); + processResourceTypeAny(volumeName, mappedS3Actions, objToAclsMap); + break; + case BUCKET: + processBucketResource(volumeName, mappedS3Actions, resourceSpec, prefixes, authorizerType, objToAclsMap); + break; + case BUCKET_WILDCARD: + Preconditions.checkArgument( + authorizerType != AuthorizerType.NATIVE, + "ResourceSpec type BUCKET_WILDCARD not supported for OzoneNativeAuthorizer"); + processBucketResource(volumeName, mappedS3Actions, resourceSpec, prefixes, authorizerType, objToAclsMap); + break; + case OBJECT_EXACT: + processObjectExactResource(volumeName, mappedS3Actions, resourceSpec, objToAclsMap); + break; + case OBJECT_PREFIX: + Preconditions.checkArgument( + authorizerType != AuthorizerType.RANGER, + "ResourceSpec type OBJECT_PREFIX not supported for RangerOzoneAuthorizer"); + processObjectPrefixResource(volumeName, authorizerType, mappedS3Actions, resourceSpec, objToAclsMap); + break; + case OBJECT_PREFIX_WILDCARD: + Preconditions.checkArgument( + authorizerType != AuthorizerType.NATIVE, + "ResourceSpec type OBJECT_PREFIX_WILDCARD not supported for OzoneNativeAuthorizer"); + processObjectPrefixResource(volumeName, authorizerType, mappedS3Actions, resourceSpec, objToAclsMap); + break; + default: + throw new IllegalStateException("Unexpected resourceSpec type found: " + resourceSpec.type); + } + } + + /** + * Handles ResourceType.ANY (*). + * Example: "Resource": "*" + */ + private static void processResourceTypeAny(String volumeName, Set mappedS3Actions, + Map> objToAclsMap) { + for (S3Action action : mappedS3Actions) { + addAclsForObj(objToAclsMap, volumeObj(volumeName), action.volumePerms); + addAclsForObj(objToAclsMap, bucketObj(volumeName, "*"), action.bucketPerms); + addAclsForObj(objToAclsMap, keyObj(volumeName, "*", "*"), action.objectPerms); + } + } + + /** + * Handles BUCKET and BUCKET_WILDCARD resource types. + * Example: "Resource": "arn:aws:s3:::my-bucket" or "Resource": "arn:aws:s3:::my-bucket*" or + * "Resource": "arn:aws:s3:::*" + */ + private static void processBucketResource(String volumeName, Set mappedS3Actions, + ResourceSpec resourceSpec, Set prefixes, AuthorizerType authorizerType, + Map> objToAclsMap) { + for (S3Action action : mappedS3Actions) { + // The s3:ListAllMyBuckets action can use either "*" or + // "arn:aws:s3:::*" as its Resource. The former is already handled via the + // ResourceSpec.ANY path. The latter is parsed as a BUCKET_WILDCARD with a + // bucket name of "*". To align with AWS, make sure that in this + // specific case we also grant the volume-level permissions for volume-scoped + // actions (currently s3:ListAllMyBuckets). + if (action.kind == ActionKind.BUCKET || action == S3Action.ALL_S3 || + action.kind == ActionKind.VOLUME && "*".equals(resourceSpec.bucket)) { // this handles s3:ListAllMyBuckets + addAclsForObj(objToAclsMap, volumeObj(volumeName), action.volumePerms); + addAclsForObj(objToAclsMap, bucketObj(volumeName, resourceSpec.bucket), action.bucketPerms); + } + + if (action == S3Action.LIST_BUCKET) { + // If condition prefixes are present, these would constrain the object permissions if the action + // is s3:ListBucket + if (prefixes != null && !prefixes.isEmpty()) { + for (String prefix : prefixes) { + createObjectResourcesFromConditionPrefix( + volumeName, authorizerType, resourceSpec, prefix, objToAclsMap, action.objectPerms); + } + } else { + // No condition prefixes, but we need READ access to all objects, so use "*" as the prefix + createObjectResourcesFromConditionPrefix( + volumeName, authorizerType, resourceSpec, "*", objToAclsMap, action.objectPerms); + } + } + } + } + + /** + * Handles OBJECT_EXACT resource type. + * Example: "Resource": "arn:aws:s3:::my-bucket/file.txt" + */ + private static void processObjectExactResource(String volumeName, Set mappedS3Actions, + ResourceSpec resourceSpec, Map> objToAclsMap) { + for (S3Action action : mappedS3Actions) { + addAclsForObj(objToAclsMap, volumeObj(volumeName), action.volumePerms); + if (action.kind == ActionKind.OBJECT) { + addAclsForObj(objToAclsMap, bucketObj(volumeName, resourceSpec.bucket), action.bucketPerms); + addAclsForObj(objToAclsMap, keyObj(volumeName, resourceSpec.bucket, resourceSpec.key), action.objectPerms); + } else if (action == S3Action.ALL_S3) { + // For s3:*, ALL should only apply at the object level; grant READ at bucket level for navigation + addAclsForObj(objToAclsMap, bucketObj(volumeName, resourceSpec.bucket), EnumSet.of(READ)); + addAclsForObj(objToAclsMap, keyObj(volumeName, resourceSpec.bucket, resourceSpec.key), action.objectPerms); + } + } + } + + /** + * Handles OBJECT_PREFIX and OBJECT_PREFIX_WILDCARD resource types. + * Prefixes can be specified in the Resource itself as in the example below, or via an s3:prefix Condition. + * Example: "Resource": "arn:aws:s3:::my-bucket/path/folder" + */ + private static void processObjectPrefixResource(String volumeName, AuthorizerType authorizerType, + Set mappedS3Actions, ResourceSpec resourceSpec, Map> objToAclsMap) { + for (S3Action action : mappedS3Actions) { + // Object actions apply to prefix/key resources + addAclsForObj(objToAclsMap, volumeObj(volumeName), action.volumePerms); + if (action.kind == ActionKind.OBJECT) { + addAclsForObj(objToAclsMap, bucketObj(volumeName, resourceSpec.bucket), action.bucketPerms); + } else if (action == S3Action.ALL_S3) { + // For s3:*, ALL should only apply at the object/prefix level; grant READ at bucket level for navigation + addAclsForObj(objToAclsMap, bucketObj(volumeName, resourceSpec.bucket), EnumSet.of(READ)); + } + + // Handle the resource prefix itself (e.g., my-bucket/*) + createObjectResourcesFromResourcePrefix( + volumeName, authorizerType, resourceSpec, objToAclsMap, action.objectPerms); + } + } + + /** + * Creates object resources from resource prefix (e.g., my-bucket/*). + */ + private static void createObjectResourcesFromResourcePrefix(String volumeName, AuthorizerType authorizerType, + ResourceSpec resourceSpec, Map> objToAclsMap, Set acls) { + if (authorizerType == AuthorizerType.NATIVE) { + final IOzoneObj prefixObj = prefixObj(volumeName, resourceSpec.bucket, resourceSpec.prefix); + addAclsForObj(objToAclsMap, prefixObj, acls); + } else { + final IOzoneObj keyObj = keyObj(volumeName, resourceSpec.bucket, resourceSpec.prefix); + addAclsForObj(objToAclsMap, keyObj, acls); + } + } + + /** + * Creates object resources from condition prefixes (i.e. the s3:prefix conditions). + */ + private static void createObjectResourcesFromConditionPrefix(String volumeName, AuthorizerType authorizerType, + ResourceSpec resourceSpec, String conditionPrefix, Map> objToAclsMap, Set acls) { + if (authorizerType == AuthorizerType.NATIVE) { + // For native authorizer, use PREFIX resource type with normalized prefix. + // Map "x" in condition list prefix to "x". Map "x/*" in condition list prefix to "x/". + // Map "*" in condition list prefix to "". + final String normalizedPrefix; + if (conditionPrefix != null && conditionPrefix.endsWith("*")) { + normalizedPrefix = conditionPrefix.substring(0, conditionPrefix.length() - 1); + } else { + normalizedPrefix = conditionPrefix; + } + final IOzoneObj prefixObj = prefixObj(volumeName, resourceSpec.bucket, normalizedPrefix); + addAclsForObj(objToAclsMap, prefixObj, acls); + } else { + // For Ranger authorizer, use KEY resource type with original prefix + // Map "x" in condition list prefix to "x". Map "x/*" in condition list prefix to "x/*". + // Map "* in condition list prefix to "*". + final IOzoneObj keyObj = keyObj(volumeName, resourceSpec.bucket, conditionPrefix); + addAclsForObj(objToAclsMap, keyObj, acls); + } + } + + /** + * Helper method to add ACLs for an IOzoneObj, merging with existing ACLs if present. + */ + private static void addAclsForObj(Map> objToAclsMap, IOzoneObj obj, Set acls) { + if (acls != null && !acls.isEmpty()) { + final OzoneObj ozoneObj = (OzoneObj) obj; + objToAclsMap.computeIfAbsent(ozoneObj, k -> EnumSet.noneOf(ACLType.class)).addAll(acls); + } } /** @@ -389,11 +661,105 @@ private enum ActionKind { ALL } + /** + * The categorization possibilities of Resources in the IAM policy. + */ + @VisibleForTesting + enum S3ResourceType { + ANY, // Ranger authorizer solely uses this + BUCKET, + BUCKET_WILDCARD, // Ranger authorizer solely uses this + OBJECT_PREFIX, // Native authorizer solely uses this + OBJECT_PREFIX_WILDCARD, // Ranger authorizer solely uses this. We initially categorize all resources with + // wildcard (*) as OBJECT_PREFIX_WILDCARD, but if the wildcard is not at the end, and + // Native authorizer is being used, an error is thrown. If the wildcard is at the end, + // then the categorization will use OBJECT_PREFIX for native authorizer instead and remove + // the wildcard. + OBJECT_EXACT + } + /** * Utility to help categorize IAM policy resources, whether for bucket, key, wildcards, etc. */ - private static final class ResourceSpec { - // TODO implement in future PR + @VisibleForTesting + static final class ResourceSpec { + private final S3ResourceType type; + private final String bucket; + private final String prefix; // for OBJECT_PREFIX or OBJECT_PREFIX_WILDCARD only, otherwise null + private final String key; // for OBJECT_EXACT only, otherwise null + + @VisibleForTesting + ResourceSpec(S3ResourceType type, String bucket, String prefix, String key) { + this.type = type; + this.bucket = bucket; + this.prefix = prefix; + this.key = key; + } + + static ResourceSpec any() { + return new ResourceSpec(S3ResourceType.ANY, "*", null, null); + } + + static ResourceSpec bucket(String bucket) { + return new ResourceSpec( + bucket.contains("*") ? S3ResourceType.BUCKET_WILDCARD : S3ResourceType.BUCKET, bucket, null, null); + } + + static ResourceSpec objectExact(String bucket, String key) { + return new ResourceSpec(S3ResourceType.OBJECT_EXACT, bucket, null, key); + } + + static ResourceSpec objectPrefix(String bucket, String prefix) { + return new ResourceSpec( + prefix.contains("*") ? S3ResourceType.OBJECT_PREFIX_WILDCARD : S3ResourceType.OBJECT_PREFIX, bucket, + prefix, null); + } + + @Override + public boolean equals(Object o) { + if (o == null || getClass() != o.getClass()) { + return false; + } + final ResourceSpec that = (ResourceSpec) o; + return type == that.type && Objects.equals(bucket, that.bucket) && Objects.equals(prefix, that.prefix) && + Objects.equals(key, that.key); + } + + @Override + public int hashCode() { + return Objects.hash(type, bucket, prefix, key); + } + + @Override + public String toString() { + return "ResourceSpec{" + "type=" + type + ", bucket='" + bucket + '\'' + ", prefix='" + prefix + '\'' + + ", key='" + key + '\'' + '}'; + } + } + + /** + * Parses and categorizes the ResourceArn. + *

+ * Suffix parameter can be: + * -> bucket + * -> bucket/* (prefix in OzoneNativeAuthorizer or wildcard key in RangerOzoneAuthorizer) + * -> bucket/deep/path/* (prefix in OzoneNativeAuthorizer or wildcard key in RangerOzoneAuthorizer) + * -> bucket/key or bucket/prefix/key (exact key) + */ + private static ResourceSpec parseResourceSpec(String suffix) { + + final int slashIndex = suffix.indexOf('/'); + if (slashIndex < 0) { + return ResourceSpec.bucket(suffix); + } + + final String bucket = suffix.substring(0, slashIndex); + final String rest = suffix.substring(slashIndex + 1); + if (rest.contains("*")) { + return ResourceSpec.objectPrefix(bucket, rest); + } + + return ResourceSpec.objectExact(bucket, rest); } @VisibleForTesting @@ -413,8 +779,7 @@ enum S3Action { GET_BUCKET_LOCATION("s3:GetBucketLocation", ActionKind.BUCKET, EnumSet.of(READ), EnumSet.of(READ), EnumSet.noneOf(ACLType.class)), // Used for HeadBucket, ListObjects and ListObjectsV2 apis - LIST_BUCKET("s3:ListBucket", ActionKind.BUCKET, EnumSet.of(READ), EnumSet.of(READ, LIST), - EnumSet.noneOf(ACLType.class)), + LIST_BUCKET("s3:ListBucket", ActionKind.BUCKET, EnumSet.of(READ), EnumSet.of(READ, LIST), EnumSet.of(READ)), // Used for ListMultipartUploads API LIST_BUCKET_MULTIPART_UPLOADS("s3:ListBucketMultipartUploads", ActionKind.BUCKET, EnumSet.of(READ), EnumSet.of(READ, LIST), EnumSet.noneOf(ACLType.class)), @@ -443,7 +808,7 @@ enum S3Action { EnumSet.of(ACLType.WRITE)), // Wildcard all - ALL_S3("s3:*", ActionKind.ALL, EnumSet.of(ACLType.ALL), EnumSet.of(ACLType.ALL), EnumSet.of(ACLType.ALL)); + ALL_S3("s3:*", ActionKind.ALL, EnumSet.of(READ), EnumSet.of(ACLType.ALL), EnumSet.of(ACLType.ALL)); private final String name; private final ActionKind kind; @@ -460,4 +825,51 @@ enum S3Action { this.objectPerms = objectPerms; } } + + /** + * Creates an OzoneObjInfo.Builder based on supplied parameters. + */ + private static OzoneObjInfo.Builder obj(OzoneObj.ResourceType type, String volumeName, String bucketName) { + return OzoneObjInfo.Builder.newBuilder() + .setResType(type) + .setStoreType(OzoneObj.StoreType.OZONE) + .setVolumeName(volumeName) + .setBucketName(bucketName); + } + + /** + * Creates IOzoneObj with ResourceType BUCKET. + */ + private static IOzoneObj bucketObj(String volumeName, String bucketName) { + return obj(OzoneObj.ResourceType.BUCKET, volumeName, bucketName).build(); + } + + /** + * Creates IOzoneObj with ResourceType KEY. + */ + private static IOzoneObj keyObj(String volumeName, String bucketName, String keyName) { + return obj(OzoneObj.ResourceType.KEY, volumeName, bucketName) + .setKeyName(keyName) + .build(); + } + + /** + * Creates IOzoneObj with ResourceType PREFIX. + */ + private static IOzoneObj prefixObj(String volumeName, String bucketName, String prefixName) { + return obj(OzoneObj.ResourceType.PREFIX, volumeName, bucketName) + .setPrefixName(prefixName) + .build(); + } + + /** + * Creates IOzoneObj with ResourceType VOLUME. + */ + private static IOzoneObj volumeObj(String volumeName) { + return OzoneObjInfo.Builder.newBuilder() + .setResType(OzoneObj.ResourceType.VOLUME) + .setStoreType(OzoneObj.StoreType.OZONE) + .setVolumeName(volumeName) + .build(); + } } diff --git a/hadoop-ozone/common/src/test/java/org/apache/hadoop/ozone/security/acl/iam/TestIamSessionPolicyResolver.java b/hadoop-ozone/common/src/test/java/org/apache/hadoop/ozone/security/acl/iam/TestIamSessionPolicyResolver.java index 5721901b19c6..9fe5965874c5 100644 --- a/hadoop-ozone/common/src/test/java/org/apache/hadoop/ozone/security/acl/iam/TestIamSessionPolicyResolver.java +++ b/hadoop-ozone/common/src/test/java/org/apache/hadoop/ozone/security/acl/iam/TestIamSessionPolicyResolver.java @@ -19,17 +19,32 @@ import static org.apache.hadoop.ozone.om.exceptions.OMException.ResultCodes.INVALID_REQUEST; import static org.apache.hadoop.ozone.om.exceptions.OMException.ResultCodes.NOT_SUPPORTED_OPERATION; +import static org.apache.hadoop.ozone.security.acl.IAccessAuthorizer.ACLType; +import static org.apache.hadoop.ozone.security.acl.IAccessAuthorizer.ACLType.CREATE; +import static org.apache.hadoop.ozone.security.acl.IAccessAuthorizer.ACLType.LIST; +import static org.apache.hadoop.ozone.security.acl.IAccessAuthorizer.ACLType.READ; +import static org.apache.hadoop.ozone.security.acl.IAccessAuthorizer.ACLType.READ_ACL; +import static org.apache.hadoop.ozone.security.acl.IAccessAuthorizer.ACLType.WRITE_ACL; import static org.apache.hadoop.ozone.security.acl.iam.IamSessionPolicyResolver.AuthorizerType.NATIVE; import static org.apache.hadoop.ozone.security.acl.iam.IamSessionPolicyResolver.AuthorizerType.RANGER; +import static org.apache.hadoop.ozone.security.acl.iam.IamSessionPolicyResolver.S3ResourceType; import static org.apache.hadoop.ozone.security.acl.iam.IamSessionPolicyResolver.buildCaseInsensitiveS3ActionMap; +import static org.apache.hadoop.ozone.security.acl.iam.IamSessionPolicyResolver.createPathsAndPermissions; import static org.apache.hadoop.ozone.security.acl.iam.IamSessionPolicyResolver.mapPolicyActionsToS3Actions; +import static org.apache.hadoop.ozone.security.acl.iam.IamSessionPolicyResolver.validateAndCategorizeResources; import static org.assertj.core.api.Assertions.assertThat; import java.util.Collections; import java.util.LinkedHashSet; import java.util.Map; import java.util.Set; +import java.util.stream.Collectors; +import java.util.stream.Stream; import org.apache.hadoop.ozone.om.exceptions.OMException; +import org.apache.hadoop.ozone.security.acl.AssumeRoleRequest; +import org.apache.hadoop.ozone.security.acl.IOzoneObj; +import org.apache.hadoop.ozone.security.acl.OzoneObj; +import org.apache.hadoop.ozone.security.acl.OzoneObjInfo; import org.apache.hadoop.ozone.security.acl.iam.IamSessionPolicyResolver.S3Action; import org.junit.jupiter.api.Test; @@ -218,8 +233,7 @@ public void testJsonExceedsMaxLengthThrows() { final String json = createJsonStringLargerThan2048Characters(); expectResolveThrowsForBothAuthorizers( - json, "Invalid policy JSON - exceeds maximum length of 2048 characters", - INVALID_REQUEST); + json, "Invalid policy JSON - exceeds maximum length of 2048 characters", INVALID_REQUEST); } @Test @@ -410,15 +424,584 @@ public void testMapPolicyActionsToS3ActionsWithS3StarIgnoresOtherActions() { assertThat(result).containsOnly(S3Action.ALL_S3); } + @Test + public void testValidateAndCategorizeResourcesWithWildcard() throws OMException { + expectOMExceptionWithCode( + () -> validateAndCategorizeResources(NATIVE, Collections.singleton("*")), + "Wildcard bucket patterns are not supported for Ozone native authorizer", NOT_SUPPORTED_OPERATION); + + final Set resultRanger = validateAndCategorizeResources( + RANGER, Collections.singleton("*")); + assertThat(resultRanger).containsOnly( + new IamSessionPolicyResolver.ResourceSpec(S3ResourceType.ANY, "*", null, null)); + } + + @Test + public void testValidateAndCategorizeResourcesWithSingleBucket() throws OMException { + final IamSessionPolicyResolver.ResourceSpec expectedResourceSpec = new IamSessionPolicyResolver.ResourceSpec( + S3ResourceType.BUCKET, "my-bucket", null, null); + + final Set resultNative = validateAndCategorizeResources( + NATIVE, Collections.singleton("arn:aws:s3:::my-bucket")); + assertThat(resultNative).containsOnly(expectedResourceSpec); + + final Set resultRanger = validateAndCategorizeResources( + RANGER, Collections.singleton("arn:aws:s3:::my-bucket")); + assertThat(resultRanger).containsOnly(expectedResourceSpec); + } + + @Test + public void testValidateAndCategorizeResourcesWithBucketWildcard() throws OMException { + final IamSessionPolicyResolver.ResourceSpec expectedResourceSpec = new IamSessionPolicyResolver.ResourceSpec( + S3ResourceType.BUCKET_WILDCARD, "my-bucket*", null, null); + + expectOMExceptionWithCode( + () -> validateAndCategorizeResources(NATIVE, Collections.singleton("arn:aws:s3:::my-bucket*")), + "Wildcard bucket patterns are not supported for Ozone native authorizer", + NOT_SUPPORTED_OPERATION); + + final Set resultRanger = validateAndCategorizeResources( + RANGER, Collections.singleton("arn:aws:s3:::my-bucket*")); + assertThat(resultRanger).containsOnly(expectedResourceSpec); + } + + @Test + public void testValidateAndCategorizeResourcesWithBucketWildcardAndExactObjectKey() throws OMException { + final IamSessionPolicyResolver.ResourceSpec expectedResourceSpec = new IamSessionPolicyResolver.ResourceSpec( + S3ResourceType.OBJECT_EXACT, "*", null, "myKey.txt"); + + expectOMExceptionWithCode( + () -> validateAndCategorizeResources(NATIVE, Collections.singleton("arn:aws:s3:::*/myKey.txt")), + "Wildcard bucket patterns are not supported for Ozone native authorizer", + NOT_SUPPORTED_OPERATION); + + final Set resultRanger = validateAndCategorizeResources( + RANGER, Collections.singleton("arn:aws:s3:::*/myKey.txt")); + assertThat(resultRanger).containsOnly(expectedResourceSpec); + } + + @Test + public void testValidateAndCategorizeResourcesWithBucketWildcardAndObjectWildcard() throws OMException { + expectOMExceptionWithCode( + () -> validateAndCategorizeResources(NATIVE, Collections.singleton("arn:aws:s3:::*/*")), + "Wildcard bucket patterns are not supported for Ozone native authorizer", + NOT_SUPPORTED_OPERATION); + + final IamSessionPolicyResolver.ResourceSpec expectedResourceSpec = new IamSessionPolicyResolver.ResourceSpec( + S3ResourceType.OBJECT_PREFIX_WILDCARD, "*", "*", null); + final Set resultRanger = validateAndCategorizeResources( + RANGER, Collections.singleton("arn:aws:s3:::*/*")); + assertThat(resultRanger).containsOnly(expectedResourceSpec); + } + + @Test + public void testValidateAndCategorizeResourcesWithBucketAndExactObjectKey() throws OMException { + final IamSessionPolicyResolver.ResourceSpec expectedResourceSpec = new IamSessionPolicyResolver.ResourceSpec( + S3ResourceType.OBJECT_EXACT, "bucket1", null, "key.txt"); + + final Set resultNative = validateAndCategorizeResources( + NATIVE, Collections.singleton("arn:aws:s3:::bucket1/key.txt")); + assertThat(resultNative).containsOnly(expectedResourceSpec); + + final Set resultRanger = validateAndCategorizeResources( + RANGER, Collections.singleton("arn:aws:s3:::bucket1/key.txt")); + assertThat(resultRanger).containsOnly(expectedResourceSpec); + } + + @Test + public void testValidateAndCategorizeResourcesWithBucketAndExactObjectKeyWithPath() throws OMException { + final IamSessionPolicyResolver.ResourceSpec expectedResourceSpec = new IamSessionPolicyResolver.ResourceSpec( + S3ResourceType.OBJECT_EXACT, "bucket2", null, "path/folder/nested/key.txt"); + + final Set resultNative = validateAndCategorizeResources( + NATIVE, Collections.singleton("arn:aws:s3:::bucket2/path/folder/nested/key.txt")); + assertThat(resultNative).containsOnly(expectedResourceSpec); + + final Set resultRanger = validateAndCategorizeResources( + RANGER, Collections.singleton("arn:aws:s3:::bucket2/path/folder/nested/key.txt")); + assertThat(resultRanger).containsOnly(expectedResourceSpec); + } + + @Test + public void testValidateAndCategorizeResourcesWithBucketAndObjectPrefixAndEmpty() throws OMException { + final IamSessionPolicyResolver.ResourceSpec expectedNativeResourceSpec = new IamSessionPolicyResolver.ResourceSpec( + S3ResourceType.OBJECT_PREFIX, "bucket3", "", null); + final Set resultNative = validateAndCategorizeResources( + NATIVE, Collections.singleton("arn:aws:s3:::bucket3/*")); + assertThat(resultNative).containsOnly(expectedNativeResourceSpec); + + final IamSessionPolicyResolver.ResourceSpec expectedRangerResourceSpec = new IamSessionPolicyResolver.ResourceSpec( + S3ResourceType.OBJECT_PREFIX_WILDCARD, "bucket3", "*", null); + final Set resultRanger = validateAndCategorizeResources( + RANGER, Collections.singleton("arn:aws:s3:::bucket3/*")); + assertThat(resultRanger).containsOnly(expectedRangerResourceSpec); + } + + @Test + public void testValidateAndCategorizeResourcesWithBucketAndObjectPrefixAndEmptyWithPath() throws OMException { + final IamSessionPolicyResolver.ResourceSpec expectedNativeResourceSpec = new IamSessionPolicyResolver.ResourceSpec( + S3ResourceType.OBJECT_PREFIX, "bucket3", "path/b/", null); + final Set resultNative = validateAndCategorizeResources( + NATIVE, Collections.singleton("arn:aws:s3:::bucket3/path/b/*")); + assertThat(resultNative).containsOnly(expectedNativeResourceSpec); + + final IamSessionPolicyResolver.ResourceSpec expectedRangerResourceSpec = new IamSessionPolicyResolver.ResourceSpec( + S3ResourceType.OBJECT_PREFIX_WILDCARD, "bucket3", "path/b/*", null); + final Set resultRanger = validateAndCategorizeResources( + RANGER, Collections.singleton("arn:aws:s3:::bucket3/path/b/*")); + assertThat(resultRanger).containsOnly(expectedRangerResourceSpec); + } + + @Test + public void testValidateAndCategorizeResourcesWithBucketAndObjectPrefixAndNonEmpty() throws OMException { + final IamSessionPolicyResolver.ResourceSpec expectedNativeResourceSpec = new IamSessionPolicyResolver.ResourceSpec( + S3ResourceType.OBJECT_PREFIX, "bucket3", "test", null); + final Set resultNative = validateAndCategorizeResources( + NATIVE, Collections.singleton("arn:aws:s3:::bucket3/test*")); + assertThat(resultNative).containsOnly(expectedNativeResourceSpec); + + final IamSessionPolicyResolver.ResourceSpec expectedRangerResourceSpec = new IamSessionPolicyResolver.ResourceSpec( + S3ResourceType.OBJECT_PREFIX_WILDCARD, "bucket3", "test*", null); + final Set resultRanger = validateAndCategorizeResources( + RANGER, Collections.singleton("arn:aws:s3:::bucket3/test*")); + assertThat(resultRanger).containsOnly(expectedRangerResourceSpec); + } + + @Test + public void testValidateAndCategorizeResourcesWithBucketAndObjectPrefixAndNonEmptyWithPath() throws OMException { + final IamSessionPolicyResolver.ResourceSpec expectedNativeResourceSpec = new IamSessionPolicyResolver.ResourceSpec( + S3ResourceType.OBJECT_PREFIX, "bucket", "a/b/test", null); + final Set resultNative = validateAndCategorizeResources( + NATIVE, Collections.singleton("arn:aws:s3:::bucket/a/b/test*")); + assertThat(resultNative).containsOnly(expectedNativeResourceSpec); + + final IamSessionPolicyResolver.ResourceSpec expectedRangerResourceSpec = new IamSessionPolicyResolver.ResourceSpec( + S3ResourceType.OBJECT_PREFIX_WILDCARD, "bucket", "a/b/test*", null); + final Set resultRanger = validateAndCategorizeResources( + RANGER, Collections.singleton("arn:aws:s3:::bucket/a/b/test*")); + assertThat(resultRanger).containsOnly(expectedRangerResourceSpec); + } + + @Test + public void testValidateAndCategorizeResourcesWithBucketAndObjectPrefixWildcardNotAtEnd() throws OMException { + expectOMExceptionWithCode( + () -> validateAndCategorizeResources(NATIVE, Collections.singleton("arn:aws:s3:::bucket3/*.log")), + "Wildcard prefix patterns are not supported for Ozone native authorizer if wildcard is not " + + "at the end", NOT_SUPPORTED_OPERATION); + + final IamSessionPolicyResolver.ResourceSpec expectedRangerResourceSpec = new IamSessionPolicyResolver.ResourceSpec( + S3ResourceType.OBJECT_PREFIX_WILDCARD, "bucket3", "*.log", null); + final Set resultRanger = validateAndCategorizeResources( + RANGER, Collections.singleton("arn:aws:s3:::bucket3/*.log")); + assertThat(resultRanger).containsOnly(expectedRangerResourceSpec); + } + + @Test + public void testValidateAndCategorizeResourcesWithBucketAndObjectPrefixWildcardNotAtEndWithPath() throws OMException { + expectOMExceptionWithCode( + () -> validateAndCategorizeResources(NATIVE, Collections.singleton("arn:aws:s3:::bucket/a/q/*.ps")), + "Wildcard prefix patterns are not supported for Ozone native authorizer if wildcard is not " + + "at the end", NOT_SUPPORTED_OPERATION); + + final IamSessionPolicyResolver.ResourceSpec expectedRangerResourceSpec = new IamSessionPolicyResolver.ResourceSpec( + S3ResourceType.OBJECT_PREFIX_WILDCARD, "bucket", "a/q/*.ps", null); + final Set resultRanger = validateAndCategorizeResources( + RANGER, Collections.singleton("arn:aws:s3:::bucket/a/q/*.ps")); + assertThat(resultRanger).containsOnly(expectedRangerResourceSpec); + } + + @Test + public void testValidateAndCategorizeResourcesWithBucketAndObjectPrefixWildcardOneAtEndAndOneNotAtEnd() + throws OMException { + expectOMExceptionWithCode( + () -> validateAndCategorizeResources(NATIVE, Collections.singleton("arn:aws:s3:::bucket3/*key*")), + "Wildcard prefix patterns are not supported for Ozone native authorizer if wildcard is not " + + "at the end", NOT_SUPPORTED_OPERATION); + + final IamSessionPolicyResolver.ResourceSpec expectedRangerResourceSpec = new IamSessionPolicyResolver.ResourceSpec( + S3ResourceType.OBJECT_PREFIX_WILDCARD, "bucket3", "*key*", null); + final Set resultRanger = validateAndCategorizeResources( + RANGER, Collections.singleton("arn:aws:s3:::bucket3/*key*")); + assertThat(resultRanger).containsOnly(expectedRangerResourceSpec); + } + + @Test + public void testValidateAndCategorizeResourcesWithBucketAndObjectPrefixWildcardOneAtEndAndOneNotAtEndWithPath() + throws OMException { + expectOMExceptionWithCode( + () -> validateAndCategorizeResources(NATIVE, Collections.singleton("arn:aws:s3:::bucket3/a/b/t/*key*")), + "Wildcard prefix patterns are not supported for Ozone native authorizer if wildcard is not " + + "at the end", NOT_SUPPORTED_OPERATION); + + final IamSessionPolicyResolver.ResourceSpec expectedRangerResourceSpec = new IamSessionPolicyResolver.ResourceSpec( + S3ResourceType.OBJECT_PREFIX_WILDCARD, "bucket3", "a/b/t/*key*", null); + final Set resultRanger = validateAndCategorizeResources( + RANGER, Collections.singleton("arn:aws:s3:::bucket3/a/b/t/*key*")); + assertThat(resultRanger).containsOnly(expectedRangerResourceSpec); + } + + @Test + public void testValidateAndCategorizeResourcesWithMultipleResources() throws OMException { + final Set resultNative = validateAndCategorizeResources( + NATIVE, strSet("arn:aws:s3:::bucket1", "arn:aws:s3:::bucket2/*", "arn:aws:s3:::bucket3/key.txt")); + assertThat(resultNative).containsOnly( + new IamSessionPolicyResolver.ResourceSpec(S3ResourceType.BUCKET, "bucket1", null, null), + new IamSessionPolicyResolver.ResourceSpec(S3ResourceType.OBJECT_PREFIX, "bucket2", "", null), + new IamSessionPolicyResolver.ResourceSpec(S3ResourceType.OBJECT_EXACT, "bucket3", null, "key.txt")); + + final Set resultRanger = validateAndCategorizeResources( + RANGER, strSet("arn:aws:s3:::bucket1", "arn:aws:s3:::bucket2/*", "arn:aws:s3:::bucket3/key.txt")); + assertThat(resultRanger).containsOnly( + new IamSessionPolicyResolver.ResourceSpec(S3ResourceType.BUCKET, "bucket1", null, null), + new IamSessionPolicyResolver.ResourceSpec(S3ResourceType.OBJECT_PREFIX_WILDCARD, "bucket2", "*", null), + new IamSessionPolicyResolver.ResourceSpec(S3ResourceType.OBJECT_EXACT, "bucket3", null, "key.txt")); + } + + @Test + public void testValidateAndCategorizeResourcesWithInvalidArnThrows() { + final String invalidArn = "arn:aws:ec2:::bucket"; + expectOMExceptionWithCode( + () -> validateAndCategorizeResources(NATIVE, Collections.singleton(invalidArn)), + "Unsupported Resource Arn - " + invalidArn, NOT_SUPPORTED_OPERATION); + expectOMExceptionWithCode( + () -> validateAndCategorizeResources(RANGER, Collections.singleton(invalidArn)), + "Unsupported Resource Arn - " + invalidArn, NOT_SUPPORTED_OPERATION); + } + + @Test + public void testValidateAndCategorizeResourcesWithArnWithNoBucketThrows() { + expectOMExceptionWithCode( + () -> validateAndCategorizeResources(NATIVE, Collections.singleton("arn:aws:s3:::")), + "Invalid Resource Arn - arn:aws:s3:::", INVALID_REQUEST); + expectOMExceptionWithCode( + () -> validateAndCategorizeResources(RANGER, Collections.singleton("arn:aws:s3:::")), + "Invalid Resource Arn - arn:aws:s3:::", INVALID_REQUEST); + } + + @Test + public void testValidateAndCategorizeResourcesWithNoResourcesThrows() { + expectOMExceptionWithCode( + () -> validateAndCategorizeResources(NATIVE, Collections.emptySet()), + "No Resource(s) found in policy", INVALID_REQUEST); + expectOMExceptionWithCode( + () -> validateAndCategorizeResources(RANGER, Collections.emptySet()), + "No Resource(s) found in policy", INVALID_REQUEST); + } + + @Test + public void testCreatePathsAndPermissionsWithResourceAny() { + // This also tests that acls are deduplicated across different resource types + final Set actions = Stream.of(S3Action.LIST_ALL_MY_BUCKETS, S3Action.LIST_BUCKET, S3Action.GET_OBJECT) + .collect(Collectors.toSet()); // actions at volume, bucket and key levels + final Set resourceSpecs = Collections.singleton( + new IamSessionPolicyResolver.ResourceSpec(S3ResourceType.ANY, "*", null, null)); + + expectIllegalArgumentException( + () -> createPathsAndPermissions(VOLUME, NATIVE, actions, resourceSpecs, Collections.emptySet()), + "ResourceSpec type ANY not supported for OzoneNativeAuthorizer"); + + final Set resultRanger = createPathsAndPermissions( + VOLUME, RANGER, actions, resourceSpecs, Collections.emptySet()); + final Set readAndListObjects = objSet(volume(), bucket("*")); // volume, bucket level have READ, LIST + final Set readObject = objSet(key("*", "*")); // key level has READ + assertThat(resultRanger).containsExactlyInAnyOrder( + new AssumeRoleRequest.OzoneGrant(readAndListObjects, acls(READ, LIST)), + new AssumeRoleRequest.OzoneGrant(readObject, acls(READ))); + } + + @Test + public void testCreatePathsAndPermissionsWithBucketResourceThatIsListBucket() { + final Set actions = Collections.singleton(IamSessionPolicyResolver.S3Action.LIST_BUCKET); + final Set resourceSpecs = Collections.singleton( + new IamSessionPolicyResolver.ResourceSpec(S3ResourceType.BUCKET, "bucket1", null, null)); + final Set readAndListObject = objSet(bucket("bucket1")); + + final Set nativeReadObjects = objSet(volume(), prefix("bucket1", "")); + final Set resultNative = createPathsAndPermissions( + VOLUME, NATIVE, actions, resourceSpecs, Collections.emptySet()); + assertThat(resultNative).containsExactlyInAnyOrder( + new AssumeRoleRequest.OzoneGrant(readAndListObject, acls(READ, LIST)), + new AssumeRoleRequest.OzoneGrant(nativeReadObjects, acls(READ))); + + final Set rangerReadObjects = objSet(volume(), key("bucket1", "*")); + final Set resultRanger = createPathsAndPermissions( + VOLUME, RANGER, actions, resourceSpecs, Collections.emptySet()); + assertThat(resultRanger).containsExactlyInAnyOrder( + new AssumeRoleRequest.OzoneGrant(readAndListObject, acls(READ, LIST)), + new AssumeRoleRequest.OzoneGrant(rangerReadObjects, acls(READ))); + } + + @Test + public void testCreatePathsAndPermissionsWithBucketResourceThatIsNotListBucket() { + final Set actions = Collections.singleton(S3Action.CREATE_BUCKET); + final Set resourceSpecs = Collections.singleton( + new IamSessionPolicyResolver.ResourceSpec(S3ResourceType.BUCKET, "bucket1", null, null)); + final Set createObject = objSet(bucket("bucket1")); + final Set readObject = objSet(volume()); + + final Set resultNative = createPathsAndPermissions( + VOLUME, NATIVE, actions, resourceSpecs, Collections.emptySet()); + assertThat(resultNative).containsExactlyInAnyOrder( + new AssumeRoleRequest.OzoneGrant(createObject, acls(CREATE)), + new AssumeRoleRequest.OzoneGrant(readObject, acls(READ))); + + final Set resultRanger = createPathsAndPermissions( + VOLUME, RANGER, actions, resourceSpecs, Collections.emptySet()); + assertThat(resultRanger).containsExactlyInAnyOrder( + new AssumeRoleRequest.OzoneGrant(createObject, acls(CREATE)), + new AssumeRoleRequest.OzoneGrant(readObject, acls(READ))); + } + + @Test + public void testCreatePathsAndPermissionsWithBucketWildcardResource() { + final Set actions = Collections.singleton(IamSessionPolicyResolver.S3Action.PUT_BUCKET_ACL); + final Set resourceSpecs = Collections.singleton( + new IamSessionPolicyResolver.ResourceSpec(S3ResourceType.BUCKET_WILDCARD, "bucket1*", null, null)); + final Set writeAclObject = objSet(bucket("bucket1*")); + final Set readVolume = objSet(volume()); + + expectIllegalArgumentException( + () -> createPathsAndPermissions(VOLUME, NATIVE, actions, resourceSpecs, Collections.emptySet()), + "ResourceSpec type BUCKET_WILDCARD not supported for OzoneNativeAuthorizer"); + + final Set resultRanger = createPathsAndPermissions( + VOLUME, RANGER, actions, resourceSpecs, Collections.emptySet()); + assertThat(resultRanger).containsExactlyInAnyOrder( + new AssumeRoleRequest.OzoneGrant(writeAclObject, acls(WRITE_ACL)), + new AssumeRoleRequest.OzoneGrant(readVolume, acls(READ))); + } + + @Test + public void testCreatePathsAndPermissionsWithBucketsWildcardResourceAll() { + // For AWS IAM, s3:ListAllMyBuckets supports both "*" and "arn:aws:s3:::*" as + // Resource values. The "*" case is covered by testCreatePathsAndPermissionsWithResourceAny. + // This test ensures that "arn:aws:s3:::*" (parsed as BUCKET_WILDCARD with bucket="*") + // also grants the expected volume-level permissions for ListAllMyBuckets. + final Set actions = Stream.of(S3Action.LIST_ALL_MY_BUCKETS, S3Action.LIST_BUCKET) + .collect(Collectors.toSet()); + final Set resourceSpecs = Collections.singleton( + new IamSessionPolicyResolver.ResourceSpec(S3ResourceType.BUCKET_WILDCARD, "*", null, null)); + + expectIllegalArgumentException( + () -> createPathsAndPermissions(VOLUME, NATIVE, actions, resourceSpecs, Collections.emptySet()), + "ResourceSpec type BUCKET_WILDCARD not supported for OzoneNativeAuthorizer"); + + final Set resultRanger = createPathsAndPermissions( + VOLUME, RANGER, actions, resourceSpecs, Collections.emptySet()); + + // Both the volume and the wildcard bucket should end up with READ + LIST permissions. + // We also need READ access on the keys + final Set readAndListObjects = objSet(volume(), bucket("*")); + final Set readObjects = objSet(key("*", "*")); + assertThat(resultRanger).containsExactlyInAnyOrder( + new AssumeRoleRequest.OzoneGrant(readAndListObjects, acls(READ, LIST)), + new AssumeRoleRequest.OzoneGrant(readObjects, acls(READ))); + } + + @Test + public void testCreatePathsAndPermissionsWithObjectExactResource() { + final Set actions = Collections.singleton(S3Action.GET_OBJECT); + final Set resourceSpecs = Collections.singleton( + new IamSessionPolicyResolver.ResourceSpec(S3ResourceType.OBJECT_EXACT, "bucket1", null, "key.txt")); + final Set readObjects = objSet(key("bucket1", "key.txt"), bucket("bucket1"), volume()); + + final Set resultNative = createPathsAndPermissions( + VOLUME, NATIVE, actions, resourceSpecs, Collections.emptySet()); + assertThat(resultNative).containsExactly(new AssumeRoleRequest.OzoneGrant(readObjects, acls(READ))); + + final Set resultRanger = createPathsAndPermissions( + VOLUME, RANGER, actions, resourceSpecs, Collections.emptySet()); + assertThat(resultRanger).containsExactly(new AssumeRoleRequest.OzoneGrant(readObjects, acls(READ))); + } + + @Test + public void testCreatePathsAndPermissionsWithObjectPrefixResource() { + final Set actions = Collections.singleton(S3Action.GET_OBJECT); + + final Set resourceSpecs = Collections.singleton( + new IamSessionPolicyResolver.ResourceSpec(S3ResourceType.OBJECT_PREFIX, "bucket1", "prefix/", null)); + final Set nativeReadObjects = objSet(prefix("bucket1", "prefix/"), bucket("bucket1"), volume()); + final Set resultNative = createPathsAndPermissions( + VOLUME, NATIVE, actions, resourceSpecs, Collections.emptySet()); + assertThat(resultNative).containsExactly(new AssumeRoleRequest.OzoneGrant(nativeReadObjects, acls(READ))); + + expectIllegalArgumentException( + () -> createPathsAndPermissions(VOLUME, RANGER, actions, resourceSpecs, Collections.emptySet()), + "ResourceSpec type OBJECT_PREFIX not supported for RangerOzoneAuthorizer"); + } + + @Test + public void testCreatePathsAndPermissionsWithObjectPrefixWildcardResource() { + final Set actions = Collections.singleton(S3Action.GET_OBJECT); + final Set resourceSpecs = Collections.singleton( + new IamSessionPolicyResolver.ResourceSpec(S3ResourceType.OBJECT_PREFIX_WILDCARD, "bucket1", "prefix/*", null)); + + expectIllegalArgumentException( + () -> createPathsAndPermissions(VOLUME, NATIVE, actions, resourceSpecs, Collections.emptySet()), + "ResourceSpec type OBJECT_PREFIX_WILDCARD not supported for OzoneNativeAuthorizer"); + + final Set rangerReadObjects = objSet(key("bucket1", "prefix/*"), bucket("bucket1"), volume()); + final Set resultRanger = createPathsAndPermissions( + VOLUME, RANGER, actions, resourceSpecs, Collections.emptySet()); + assertThat(resultRanger).containsExactly(new AssumeRoleRequest.OzoneGrant(rangerReadObjects, acls(READ))); + } + + @Test + public void testCreatePathsAndPermissionsWithConditionPrefixesForObjectActionMustIgnoreConditionPrefixes() { + final Set actions = Collections.singleton(S3Action.GET_OBJECT); + final Set prefixes = strSet("folder1/", "folder2/"); + + final Set nativeResourceSpecs = Collections.singleton( + new IamSessionPolicyResolver.ResourceSpec(S3ResourceType.OBJECT_PREFIX, "bucket1", "", null)); + final Set nativeReadObjects = objSet(prefix("bucket1", ""), bucket("bucket1"), volume()); + final Set resultNative = createPathsAndPermissions( + VOLUME, NATIVE, actions, nativeResourceSpecs, prefixes); + assertThat(resultNative).containsExactly(new AssumeRoleRequest.OzoneGrant(nativeReadObjects, acls(READ))); + + final Set rangerResourceSpecs = Collections.singleton( + new IamSessionPolicyResolver.ResourceSpec(S3ResourceType.OBJECT_PREFIX_WILDCARD, "bucket1", "*", null)); + final Set rangerReadObjects = objSet(key("bucket1", "*"), bucket("bucket1"), volume()); + final Set resultRanger = createPathsAndPermissions( + VOLUME, RANGER, actions, rangerResourceSpecs, prefixes); + assertThat(resultRanger).containsExactly(new AssumeRoleRequest.OzoneGrant(rangerReadObjects, acls(READ))); + } + + @Test + public void testCreatePathsAndPermissionsWithConditionPrefixesForBucketActionWhenActionIsListBucket() { + final Set actions = Collections.singleton(S3Action.LIST_BUCKET); + final Set prefixes = strSet("folder1/", "folder2/"); + + final Set nativeResourceSpecs = Collections.singleton( + new IamSessionPolicyResolver.ResourceSpec(S3ResourceType.BUCKET, "bucket1", null, null)); + final Set nativeReadObjects = objSet( + prefix("bucket1", "folder1/"), prefix("bucket1", "folder2/"), volume()); + final Set nativeReadAndListObject = objSet(bucket("bucket1")); + final Set resultNative = createPathsAndPermissions( + VOLUME, NATIVE, actions, nativeResourceSpecs, prefixes); + assertThat(resultNative).containsExactlyInAnyOrder( + new AssumeRoleRequest.OzoneGrant(nativeReadObjects, acls(READ)), + new AssumeRoleRequest.OzoneGrant(nativeReadAndListObject, acls(READ, LIST))); + + final Set rangerResourceSpecs = Collections.singleton( + new IamSessionPolicyResolver.ResourceSpec(S3ResourceType.BUCKET, "bucket1", null, null)); + final Set rangerReadObjects = objSet( + key("bucket1", "folder1/"), key("bucket1", "folder2/"), volume()); + final Set rangerReadAndListObject = objSet(bucket("bucket1")); + final Set resultRanger = createPathsAndPermissions( + VOLUME, RANGER, actions, rangerResourceSpecs, prefixes); + assertThat(resultRanger).containsExactlyInAnyOrder( + new AssumeRoleRequest.OzoneGrant(rangerReadObjects, acls(READ)), + new AssumeRoleRequest.OzoneGrant(rangerReadAndListObject, acls(READ, LIST))); + } + + @Test + public void testCreatePathsAndPermissionsWithConditionPrefixesForBucketActionWhenActionIsNotListBucket() { + final Set actions = Collections.singleton(S3Action.GET_BUCKET_ACL); + final Set prefixes = strSet("folder1/", "folder2/"); + final Set readObject = objSet(volume()); + final Set readAndReadAclObject = objSet(bucket("bucket1")); + + final Set nativeResourceSpecs = Collections.singleton( + new IamSessionPolicyResolver.ResourceSpec(S3ResourceType.BUCKET, "bucket1", null, null)); + final Set resultNative = createPathsAndPermissions( + VOLUME, NATIVE, actions, nativeResourceSpecs, prefixes); + assertThat(resultNative).containsExactlyInAnyOrder( + new AssumeRoleRequest.OzoneGrant(readObject, acls(READ)), + new AssumeRoleRequest.OzoneGrant(readAndReadAclObject, acls(READ, READ_ACL))); + + final Set rangerResourceSpecs = Collections.singleton( + new IamSessionPolicyResolver.ResourceSpec(S3ResourceType.BUCKET, "bucket1", null, null)); + final Set resultRanger = createPathsAndPermissions( + VOLUME, RANGER, actions, rangerResourceSpecs, prefixes); + assertThat(resultRanger).containsExactlyInAnyOrder( + new AssumeRoleRequest.OzoneGrant(readObject, acls(READ)), + new AssumeRoleRequest.OzoneGrant(readAndReadAclObject, acls(READ, READ_ACL))); + } + + // TODO sts - add more createPathsAndPermissions tests in the next PR + + private static void expectIllegalArgumentException(Runnable runnable, String expectedMessage) { + try { + runnable.run(); + throw new AssertionError("Expected exception not thrown"); + } catch (IllegalArgumentException ex) { + assertThat(ex.getMessage()).isEqualTo(expectedMessage); + } + } + + private static void expectOMExceptionWithCode(RunnableThrowingOMException runnable, String expectedMessage, + OMException.ResultCodes expectedCode) { + try { + runnable.run(); + throw new AssertionError("Expected exception not thrown"); + } catch (OMException ex) { + assertThat(ex.getMessage()).isEqualTo(expectedMessage); + assertThat(ex.getResult()).isEqualTo(expectedCode); + } + } + + @FunctionalInterface + private interface RunnableThrowingOMException { + void run() throws OMException; + } + + private static IOzoneObj key(String bucket, String key) { + return OzoneObjInfo.Builder.newBuilder() + .setResType(OzoneObj.ResourceType.KEY) + .setStoreType(OzoneObj.StoreType.OZONE) + .setVolumeName(VOLUME) + .setBucketName(bucket) + .setKeyName(key) + .build(); + } + + private static IOzoneObj volume() { + return OzoneObjInfo.Builder.newBuilder() + .setResType(OzoneObj.ResourceType.VOLUME) + .setStoreType(OzoneObj.StoreType.OZONE) + .setVolumeName(VOLUME) + .build(); + } + + private static IOzoneObj bucket(String bucket) { + return OzoneObjInfo.Builder.newBuilder() + .setResType(OzoneObj.ResourceType.BUCKET) + .setStoreType(OzoneObj.StoreType.OZONE) + .setVolumeName(VOLUME) + .setBucketName(bucket) + .build(); + } + + private static IOzoneObj prefix(String bucket, String prefix) { + return OzoneObjInfo.Builder.newBuilder() + .setResType(OzoneObj.ResourceType.PREFIX) + .setStoreType(OzoneObj.StoreType.OZONE) + .setVolumeName(VOLUME) + .setBucketName(bucket) + .setPrefixName(prefix) + .build(); + } + + private static Set objSet(IOzoneObj... objs) { + final Set s = new LinkedHashSet<>(); + Collections.addAll(s, objs); + return s; + } + + private static Set acls(ACLType... types) { + final Set s = new LinkedHashSet<>(); + Collections.addAll(s, types); + return s; + } + private static Set strSet(String... strs) { final Set s = new LinkedHashSet<>(); Collections.addAll(s, strs); return s; } - private static void expectResolveThrows(String json, - IamSessionPolicyResolver.AuthorizerType authorizerType, String expectedMessage, - OMException.ResultCodes expectedCode) { + private static void expectResolveThrows(String json, IamSessionPolicyResolver.AuthorizerType authorizerType, + String expectedMessage, OMException.ResultCodes expectedCode) { try { IamSessionPolicyResolver.resolve(json, VOLUME, authorizerType); throw new AssertionError("Expected exception not thrown"); @@ -448,7 +1031,6 @@ private static String createJsonStringLargerThan2048Characters() { jsonBuilder.append("\"\n"); jsonBuilder.append(" }]\n"); jsonBuilder.append('}'); - return jsonBuilder.toString(); } @@ -465,7 +1047,6 @@ private static String create2048CharJsonString() { jsonBuilder.append('a'); } jsonBuilder.append("\"\n }]\n}"); - return jsonBuilder.toString(); } } From 36852379d5c4adf7b4022efbc3c5d153528acb7b Mon Sep 17 00:00:00 2001 From: fmorg-git Date: Fri, 5 Dec 2025 02:53:56 -0800 Subject: [PATCH 10/54] HDDS-13997. [STS] Plumbing for passing STS token through S3 api processing (#9372) --- .../hadoop/ozone/om/protocol/S3Auth.java | 10 + ...ManagerProtocolClientSideTranslatorPB.java | 12 +- .../hadoop/ozone/om/OmMetadataReader.java | 6 +- .../apache/hadoop/ozone/om/OzoneManager.java | 65 +++- .../ozone/om/request/OMClientRequest.java | 31 +- .../s3/security/S3AssumeRoleRequest.java | 8 +- ...ManagerProtocolServerSideTranslatorPB.java | 1 + .../hadoop/ozone/security/S3SecurityUtil.java | 35 ++ .../ozone/security/STSSecurityUtil.java | 153 +++++++++ .../ozone/security/STSTokenSecretManager.java | 26 +- .../ozone/om/TestOzoneManagerS3Auth.java | 123 +++++++ .../TestOMClientRequestWithUserInfo.java | 137 ++++++++ .../ozone/security/TestSTSSecurityUtil.java | 318 ++++++++++++++++++ .../security/TestSTSTokenSecretManager.java | 16 +- 14 files changed, 917 insertions(+), 24 deletions(-) create mode 100644 hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/security/STSSecurityUtil.java create mode 100644 hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/om/TestOzoneManagerS3Auth.java create mode 100644 hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/security/TestSTSSecurityUtil.java diff --git a/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/om/protocol/S3Auth.java b/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/om/protocol/S3Auth.java index 84acade8f9a5..fa023dfc8119 100644 --- a/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/om/protocol/S3Auth.java +++ b/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/om/protocol/S3Auth.java @@ -27,6 +27,8 @@ public class S3Auth { public static final String S3_AUTH_CHECK = "ozone.s3.auth.check"; // User principal to be used for KMS encryption and decryption private String userPrincipal; + // Optional STS session token when using temporary credentials + private String sessionToken; public S3Auth(final String stringToSign, final String signature, @@ -57,4 +59,12 @@ public String getUserPrincipal() { public void setUserPrincipal(String userPrincipal) { this.userPrincipal = userPrincipal; } + + public String getSessionToken() { + return sessionToken; + } + + public void setSessionToken(String sessionToken) { + this.sessionToken = sessionToken; + } } diff --git a/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/om/protocolPB/OzoneManagerProtocolClientSideTranslatorPB.java b/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/om/protocolPB/OzoneManagerProtocolClientSideTranslatorPB.java index c3c173a8cae8..36adbe7b37fa 100644 --- a/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/om/protocolPB/OzoneManagerProtocolClientSideTranslatorPB.java +++ b/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/om/protocolPB/OzoneManagerProtocolClientSideTranslatorPB.java @@ -314,15 +314,21 @@ private OMResponse submitRequest(OMRequest omRequest) OMRequest.Builder builder = OMRequest.newBuilder(omRequest); // Insert S3 Authentication information for each request. if (getThreadLocalS3Auth() != null) { - builder.setS3Authentication( + final S3Authentication.Builder s3AuthBuilder = S3Authentication.newBuilder() .setSignature( threadLocalS3Auth.get().getSignature()) .setStringToSign( threadLocalS3Auth.get().getStringTosSign()) .setAccessId( - threadLocalS3Auth.get().getAccessID()) - .build()); + threadLocalS3Auth.get().getAccessID()); + + // Include STS session token if present so OM can validate it + if (threadLocalS3Auth.get().getSessionToken() != null) { + s3AuthBuilder.setSessionToken(threadLocalS3Auth.get().getSessionToken()); + } + + builder.setS3Authentication(s3AuthBuilder.build()); } if (s3AuthCheck && getThreadLocalS3Auth() == null) { throw new IllegalArgumentException("S3 Auth expected to " + diff --git a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/OmMetadataReader.java b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/OmMetadataReader.java index c413c96956f7..2fac369e3a2b 100644 --- a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/OmMetadataReader.java +++ b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/OmMetadataReader.java @@ -488,8 +488,9 @@ void checkAcls(ResourceType resType, StoreType store, throws IOException { UserGroupInformation user; if (getS3Auth() != null) { + final String effectiveAccessId = OzoneManager.getS3AuthEffectiveAccessId(); String principal = - OzoneAclUtils.accessIdToUserPrincipal(getS3Auth().getAccessId()); + OzoneAclUtils.accessIdToUserPrincipal(effectiveAccessId); user = UserGroupInformation.createRemoteUser(principal); } else { user = ProtobufRpcEngine.Server.getRemoteUser(); @@ -523,8 +524,9 @@ void checkAcls(ResourceType resType, StoreType store, throws IOException { UserGroupInformation user; if (getS3Auth() != null) { + final String effectiveAccessId = OzoneManager.getS3AuthEffectiveAccessId(); String principal = - OzoneAclUtils.accessIdToUserPrincipal(getS3Auth().getAccessId()); + OzoneAclUtils.accessIdToUserPrincipal(effectiveAccessId); user = UserGroupInformation.createRemoteUser(principal); } else { user = ProtobufRpcEngine.Server.getRemoteUser(); 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 5fe753871a15..e6c5f916cc1e 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 @@ -307,6 +307,7 @@ import org.apache.hadoop.ozone.security.OMCertificateClient; import org.apache.hadoop.ozone.security.OzoneDelegationTokenSecretManager; import org.apache.hadoop.ozone.security.OzoneTokenIdentifier; +import org.apache.hadoop.ozone.security.STSTokenIdentifier; import org.apache.hadoop.ozone.security.STSTokenSecretManager; import org.apache.hadoop.ozone.security.acl.IAccessAuthorizer; import org.apache.hadoop.ozone.security.acl.IAccessAuthorizer.ACLIdentityType; @@ -378,6 +379,9 @@ public final class OzoneManager extends ServiceRuntimeInfoImpl private static final ThreadLocal S3_AUTH = new ThreadLocal<>(); + // STS token (if present) + private static final ThreadLocal STS_TOKEN = new ThreadLocal<>(); + private static boolean securityEnabled = false; private final ReconfigurationHandler reconfigurationHandler; @@ -858,6 +862,47 @@ public static S3Authentication getS3Auth() { return S3_AUTH.get(); } + /** + * Set the STS token identifier for the current RPC handler thread. + */ + public static void setStsTokenIdentifier(STSTokenIdentifier val) { + STS_TOKEN.set(val); + } + + /** + * Get the STS token identifier for the current RPC handler thread. + */ + public static STSTokenIdentifier getStsTokenIdentifier() { + return STS_TOKEN.get(); + } + + /** + * Returns the effective accessId for the current request. If STS temporary credentials are being used, + * the access key id will be the original access key id (i.e. the creator of the token). + */ + public static String getS3AuthEffectiveAccessId() throws OMException { + final S3Authentication s3Auth = getS3Auth(); + if (s3Auth == null) { + return null; + } + + // If session token is present, try to resolve originalAccessKeyId from token + if (s3Auth.hasSessionToken() && !s3Auth.getSessionToken().isEmpty()) { + final STSTokenIdentifier stsTokenIdentifier = getStsTokenIdentifier(); + if (stsTokenIdentifier == null) { + throw new OMException( + "OMClientRequest has session token but no token identifier in OzoneManager", INVALID_REQUEST); + } + final String originalAccessKeyId = stsTokenIdentifier.getOriginalAccessKeyId(); + if (originalAccessKeyId != null && !originalAccessKeyId.isEmpty()) { + return originalAccessKeyId; + } else { + throw new OMException("Invalid STS Token format - could not find originalAccessKeyId", INVALID_REQUEST); + } + } + return s3Auth.getAccessId(); + } + /** Returns the ThreadName prefix for the current OM. */ public String getThreadNamePrefix() { return threadPrefix; @@ -1269,6 +1314,15 @@ private void stopSecretManager() { } } + /** + * Get the secret key client for this OzoneManager. + * + * @return the secret key client + */ + public SecretKeyClient getSecretKeyClient() { + return secretKeyClient; + } + @Override public UUID refetchSecretKey() { secretKeyClient.refetchSecretKey(); @@ -3836,7 +3890,12 @@ S3VolumeContext getS3VolumeContext(boolean skipChecks) throws IOException { s3Volume); } } else { - String accessId = s3Auth.getAccessId(); + // If this S3 request is authenticated with an STS session token, map + // the request to the *original* long-lived access ID so that the + // temporary credentials inherit that user's ACLs. Otherwise, fall back + // to the accessId included directly in the S3Authentication. + final String accessId = getS3AuthEffectiveAccessId(); + // If S3 Multi-Tenancy is not enabled, all S3 requests will be redirected // to the default s3v for compatibility final Optional optionalTenantId = isS3MultiTenancyEnabled() ? @@ -4621,8 +4680,8 @@ public ResolvedBucket resolveBucketLink(Pair requested, if (aclEnabled) { UserGroupInformation ugi = getRemoteUser(); if (getS3Auth() != null) { - ugi = UserGroupInformation.createRemoteUser( - OzoneAclUtils.accessIdToUserPrincipal(getS3Auth().getAccessId())); + final String principal = OzoneAclUtils.accessIdToUserPrincipal(getS3AuthEffectiveAccessId()); + ugi = UserGroupInformation.createRemoteUser(principal); } InetAddress remoteIp = Server.getRemoteIp(); resolved = resolveBucketLink(requested, new HashSet<>(), diff --git a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/OMClientRequest.java b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/OMClientRequest.java index e7689a90b810..b527b20ea659 100644 --- a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/OMClientRequest.java +++ b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/OMClientRequest.java @@ -48,6 +48,7 @@ import org.apache.hadoop.ozone.om.lock.OMLockDetails; import org.apache.hadoop.ozone.om.protocolPB.grpc.GrpcClientConstants; import org.apache.hadoop.ozone.om.ratis.utils.OzoneManagerRatisUtils; +import org.apache.hadoop.ozone.om.request.s3.security.S3AssumeRoleRequest; import org.apache.hadoop.ozone.om.response.OMClientResponse; import org.apache.hadoop.ozone.protocol.proto.OzoneManagerProtocolProtos; import org.apache.hadoop.ozone.protocol.proto.OzoneManagerProtocolProtos.LayoutVersion; @@ -168,11 +169,33 @@ public OzoneManagerProtocolProtos.UserInfo getUserInfo() throws IOException { OzoneManagerProtocolProtos.UserInfo.Builder userInfo = OzoneManagerProtocolProtos.UserInfo.newBuilder(); - // If S3 Authentication is set, determine user based on access ID. + // If S3 Authentication is set, determine user based on STS token first, + // falling back to accessId if session token not present. if (omRequest.hasS3Authentication()) { - String principal = OzoneAclUtils.accessIdToUserPrincipal( - omRequest.getS3Authentication().getAccessId()); - userInfo.setUserName(principal); + final String accessKeyId = omRequest.getS3Authentication().getAccessId(); + if (accessKeyId.startsWith(S3AssumeRoleRequest.STS_TOKEN_PREFIX) && + !omRequest.getS3Authentication().hasSessionToken()) { + throw new IOException("Error with STS token", new AuthenticationException( + "Missing session token for accessKeyId: " + accessKeyId)); + } + if (omRequest.getS3Authentication().hasSessionToken()) { + try { + final String originalAccessKeyId = OzoneManager.getS3AuthEffectiveAccessId(); + if (originalAccessKeyId != null && !originalAccessKeyId.isEmpty()) { + final String principal = OzoneAclUtils.accessIdToUserPrincipal(originalAccessKeyId); + userInfo.setUserName(principal); + } else { + throw new AuthenticationException( + "Invalid STS Token - originalAccessKeyId was null or empty: " + originalAccessKeyId); + } + } catch (Exception e) { + throw new IOException("Error with STS Token", e); + } + } else { + String principal = OzoneAclUtils.accessIdToUserPrincipal( + omRequest.getS3Authentication().getAccessId()); + userInfo.setUserName(principal); + } } else if (user != null) { // Added not null checks, as in UT's these values might be null. userInfo.setUserName(user.getUserName()); 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 9d092eaba015..b02f78e5643f 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 @@ -21,7 +21,9 @@ import java.io.IOException; import java.net.InetAddress; import java.security.SecureRandom; +import java.time.Clock; import java.time.Instant; +import java.time.ZoneOffset; import org.apache.commons.lang3.StringUtils; import org.apache.hadoop.ipc.ProtobufRpcEngine; import org.apache.hadoop.ozone.om.OzoneAclUtils; @@ -58,7 +60,6 @@ public class S3AssumeRoleRequest extends OMClientRequest { 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; @@ -70,6 +71,9 @@ public class S3AssumeRoleRequest extends OMClientRequest { 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(); + private static final Clock CLOCK = Clock.system(ZoneOffset.UTC); + + public static final String STS_TOKEN_PREFIX = "ASIA"; public S3AssumeRoleRequest(OMRequest omRequest) { super(omRequest); @@ -197,7 +201,7 @@ private String generateSessionToken(String targetRoleName, OMRequest omRequest, return ozoneManager.getSTSTokenSecretManager().createSTSTokenString( tempAccessKeyId, originalAccessKeyId, roleArn, assumeRoleRequest.getDurationSeconds(), secretAccessKey, - sessionPolicy); + sessionPolicy, CLOCK); } /** diff --git a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/protocolPB/OzoneManagerProtocolServerSideTranslatorPB.java b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/protocolPB/OzoneManagerProtocolServerSideTranslatorPB.java index 251e81e83ed3..de384ef9c4d7 100644 --- a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/protocolPB/OzoneManagerProtocolServerSideTranslatorPB.java +++ b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/protocolPB/OzoneManagerProtocolServerSideTranslatorPB.java @@ -174,6 +174,7 @@ private OMResponse internalProcessRequest(OMRequest request) throws ServiceExcep return ozoneManager.getOmExecutionFlow().submit(request); } finally { OzoneManager.setS3Auth(null); + OzoneManager.setStsTokenIdentifier(null); } } diff --git a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/security/S3SecurityUtil.java b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/security/S3SecurityUtil.java index cbe0ce414fe4..e31f822b2fb7 100644 --- a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/security/S3SecurityUtil.java +++ b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/security/S3SecurityUtil.java @@ -21,6 +21,8 @@ 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.io.Text; @@ -41,6 +43,8 @@ @InterfaceStability.Evolving public final class S3SecurityUtil { + private static final Clock CLOCK = Clock.system(ZoneOffset.UTC); + private S3SecurityUtil() { } @@ -54,6 +58,19 @@ private S3SecurityUtil() { public static void validateS3Credential(OMRequest omRequest, OzoneManager ozoneManager) throws ServiceException, OMException { if (ozoneManager.isSecurityEnabled()) { + // If STS session token is present, decode, decrypt and validate it once and save in thread-local + if (omRequest.getS3Authentication().hasSessionToken()) { + final String token = omRequest.getS3Authentication().getSessionToken(); + if (!token.isEmpty()) { + final STSTokenIdentifier stsTokenIdentifier = STSSecurityUtil.constructValidateAndDecryptSTSToken( + token, ozoneManager.getSecretKeyClient(), CLOCK); + // HMAC signature and expiration were validated above. Now validate AWS signature. + validateSTSTokenAwsSignature(stsTokenIdentifier, omRequest); + OzoneManager.setStsTokenIdentifier(stsTokenIdentifier); + return; + } + } + OzoneTokenIdentifier s3Token = constructS3Token(omRequest); try { // authenticate user with signature verification through @@ -89,4 +106,22 @@ private static OzoneTokenIdentifier constructS3Token(OMRequest omRequest) { s3Token.setOwner(new Text(auth.getAccessId())); return s3Token; } + + /** + * Validates the AWS signature of an STSTokenIdentifier that has already been decrypted. + * @param stsTokenIdentifier the decrypted STS token + * @param omRequest the OMRequest containing STS token + * @throws OMException if the AWS signature validation fails + */ + private static void validateSTSTokenAwsSignature(STSTokenIdentifier stsTokenIdentifier, OMRequest omRequest) + throws OMException { + final String secretAccessKey = stsTokenIdentifier.getSecretAccessKey(); + final S3Authentication s3Authentication = omRequest.getS3Authentication(); + if (AWSV4AuthValidator.validateRequest( + s3Authentication.getStringToSign(), s3Authentication.getSignature(), secretAccessKey)) { + return; + } + throw new OMException( + "STS token validation failed for token: " + omRequest.getS3Authentication().getSessionToken(), INVALID_TOKEN); + } } diff --git a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/security/STSSecurityUtil.java b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/security/STSSecurityUtil.java new file mode 100644 index 000000000000..c3fb14d24b16 --- /dev/null +++ b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/security/STSSecurityUtil.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.security; + +import static org.apache.hadoop.ozone.om.exceptions.OMException.ResultCodes.INVALID_TOKEN; + +import com.google.protobuf.InvalidProtocolBufferException; +import java.io.IOException; +import java.time.Clock; +import java.util.UUID; +import org.apache.hadoop.hdds.annotation.InterfaceAudience; +import org.apache.hadoop.hdds.annotation.InterfaceStability; +import org.apache.hadoop.hdds.security.symmetric.ManagedSecretKey; +import org.apache.hadoop.hdds.security.symmetric.SecretKeyClient; +import org.apache.hadoop.ozone.om.exceptions.OMException; +import org.apache.hadoop.ozone.protocol.proto.OzoneManagerProtocolProtos.OMTokenProto; +import org.apache.hadoop.security.token.SecretManager; +import org.apache.hadoop.security.token.Token; + +/** + * Utility class with methods to validate and decrypt STS tokens. + */ +@InterfaceAudience.Private +@InterfaceStability.Evolving +public final class STSSecurityUtil { + private STSSecurityUtil() { + } + + /** + * Constructs, validates and decrypts STS session token. + * + * @param sessionToken the session token from the x-amz-security-token header + * @param secretKeyClient the Ozone Manager secretKeyClient + * @param clock the system clock + * @return the STSTokenIdentifier with decrypted secretAccessKey + * @throws OMException if the token is not valid or processing failed otherwise + */ + public static STSTokenIdentifier constructValidateAndDecryptSTSToken(String sessionToken, + SecretKeyClient secretKeyClient, Clock clock) throws OMException { + try { + final Token token = decodeTokenFromString(sessionToken); + return verifyAndDecryptToken(token, secretKeyClient, clock); + } catch (SecretManager.InvalidToken e) { + throw new OMException("Invalid STS token format: " + e.getMessage(), e, INVALID_TOKEN); + } + } + + /** + * Verifies an STS Token by performing multiple checks. + * + * @param token the token to verify + * @param clock the system clock + * @return the STSTokenIdentifier with decrypted secretAccessKey + * @throws SecretManager.InvalidToken if the token is invalid + */ + private static STSTokenIdentifier verifyAndDecryptToken(Token token, + SecretKeyClient secretKeyClient, Clock clock) throws SecretManager.InvalidToken { + if (!STSTokenIdentifier.KIND_NAME.equals(token.getKind())) { + throw new SecretManager.InvalidToken("Invalid STS token - kind is incorrect: " + token.getKind()); + } + + if (!STSTokenIdentifier.STS_SERVICE.equals(token.getService().toString())) { + throw new SecretManager.InvalidToken("Invalid STS token - service is incorrect: " + token.getService()); + } + + final byte[] tokenBytes = token.getIdentifier(); + final OMTokenProto proto; + try { + proto = OMTokenProto.parseFrom(tokenBytes); + } catch (InvalidProtocolBufferException e) { + throw new SecretManager.InvalidToken("Invalid STS token - could not parse protocol buffer: " + e.getMessage()); + } + final UUID secretKeyId; + try { + secretKeyId = UUID.fromString(proto.getSecretKeyId()); + } catch (IllegalArgumentException e) { + throw new SecretManager.InvalidToken("Invalid STS token - secretKeyId was not valid: " + proto.getSecretKeyId()); + } + + final STSTokenIdentifier tokenId = new STSTokenIdentifier(); + final ManagedSecretKey secretKey; + try { + secretKey = getValidatedSecretKey(secretKeyId, secretKeyClient); + tokenId.setEncryptionKey(secretKey.getSecretKey().getEncoded()); + tokenId.readFromByteArray(tokenBytes); + } catch (IOException e) { + throw new SecretManager.InvalidToken("Invalid STS token - could not readFromByteArray: " + e.getMessage()); + } + + // Check expiration + if (tokenId.isExpired(clock.instant())) { + throw new SecretManager.InvalidToken("Invalid STS token - token expired at " + tokenId.getExpiry()); + } + + // Verify token signature against the original identifier bytes + if (!secretKey.isValidSignature(tokenBytes, token.getPassword())) { + throw new SecretManager.InvalidToken("Invalid STS token - signature is not correct for token: " + tokenId); + } + + return tokenId; + } + + private static ManagedSecretKey getValidatedSecretKey(UUID secretKeyId, SecretKeyClient secretKeyClient) + throws SecretManager.InvalidToken { + if (secretKeyId == null) { + throw new SecretManager.InvalidToken("STS token missing secret key ID"); + } + + final ManagedSecretKey secretKey; + try { + secretKey = secretKeyClient.getSecretKey(secretKeyId); + } catch (Exception e) { + throw new SecretManager.InvalidToken("Failed to retrieve secret key: " + e.getMessage()); + } + + if (secretKey == null) { + throw new SecretManager.InvalidToken("Secret key not found for STS token secretKeyId: " + secretKeyId); + } + + if (secretKey.isExpired()) { + throw new SecretManager.InvalidToken("Token cannot be verified due to expired secret key " + secretKeyId); + } + + return secretKey; + } + + private static Token decodeTokenFromString(String encodedToken) + throws SecretManager.InvalidToken { + final Token token = new Token<>(); + try { + token.decodeFromUrlString(encodedToken); + return token; + } catch (IOException e) { + throw new SecretManager.InvalidToken("Failed to decode STS token string: " + e); + } + } +} + diff --git a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/security/STSTokenSecretManager.java b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/security/STSTokenSecretManager.java index 598a5a71675e..f72b1892de85 100644 --- a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/security/STSTokenSecretManager.java +++ b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/security/STSTokenSecretManager.java @@ -18,12 +18,14 @@ package org.apache.hadoop.ozone.security; import java.io.IOException; +import java.time.Clock; import java.time.Instant; import org.apache.hadoop.hdds.annotation.InterfaceAudience; import org.apache.hadoop.hdds.annotation.InterfaceStability; import org.apache.hadoop.hdds.security.symmetric.ManagedSecretKey; import org.apache.hadoop.hdds.security.symmetric.SecretKeySignerClient; import org.apache.hadoop.hdds.security.token.ShortLivedTokenSecretManager; +import org.apache.hadoop.io.Text; import org.apache.hadoop.security.token.Token; /** @@ -49,6 +51,25 @@ public STSTokenSecretManager(SecretKeySignerClient secretKeyClient) { this.secretKeyClient = secretKeyClient; } + /** + * Override token generation so that we first compute the identifier bytes, then sign exactly those bytes, and + * return a Token that contains those same identifier bytes. This avoids non-determinism from multiple serializations + * which would break unit tests. If we used the inherited generateToken() in ShortLivedTokenSecretManager, it + * would have made two serialization calls: + * 1) in the call to secretKey.sign() in the createPassword() method + * 2) in the call to tokenIdentifier.getBytes() for the Token constructor + * These two calls would produce different secretAccessKey encrypted values because of the random initialization + * vector and random salt and hence give non-deterministic return value, so here we are only serializing once. + */ + @Override + public Token generateToken(STSTokenIdentifier tokenIdentifier) { + final ManagedSecretKey secretKey = secretKeyClient.getCurrentSecretKey(); + tokenIdentifier.setSecretKeyId(secretKey.getId()); + final byte[] identifierBytes = tokenIdentifier.getBytes(); + final byte[] password = secretKey.sign(identifierBytes); + return new Token<>(identifierBytes, password, tokenIdentifier.getKind(), new Text(tokenIdentifier.getService())); + } + /** * Create an STS token and return it as an encoded string. * @@ -59,11 +80,12 @@ public STSTokenSecretManager(SecretKeySignerClient secretKeyClient) { * @param secretAccessKey the secret access key associated with the temporary access key ID * @param sessionPolicy an optional opaque identifier that further limits the scope of * the permissions granted by the role + * @param clock the system clock * @return base64 encoded token string */ public String createSTSTokenString(String tempAccessKeyId, String originalAccessKeyId, String roleArn, - int durationSeconds, String secretAccessKey, String sessionPolicy) throws IOException { - final Instant expiration = Instant.now().plusSeconds(durationSeconds); + int durationSeconds, String secretAccessKey, String sessionPolicy, Clock clock) throws IOException { + final Instant expiration = clock.instant().plusSeconds(durationSeconds); // Get the current secret key for encryption final ManagedSecretKey currentSecretKey = secretKeyClient.getCurrentSecretKey(); diff --git a/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/om/TestOzoneManagerS3Auth.java b/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/om/TestOzoneManagerS3Auth.java new file mode 100644 index 000000000000..8ce4ee08bebb --- /dev/null +++ b/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/om/TestOzoneManagerS3Auth.java @@ -0,0 +1,123 @@ +/* + * 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; + +import static org.apache.hadoop.ozone.om.exceptions.OMException.ResultCodes.INVALID_REQUEST; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import org.apache.hadoop.ozone.om.exceptions.OMException; +import org.apache.hadoop.ozone.protocol.proto.OzoneManagerProtocolProtos.S3Authentication; +import org.apache.hadoop.ozone.security.STSTokenIdentifier; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; + +/** + * Test S3 Authentication logic in OzoneManager. + */ +public class TestOzoneManagerS3Auth { + + @AfterEach + public void tearDown() { + OzoneManager.setS3Auth(null); + OzoneManager.setStsTokenIdentifier(null); + } + + @Test + public void testGetS3AuthEffectiveAccessIdNoS3Auth() throws Exception { + OzoneManager.setS3Auth(null); + assertNull(OzoneManager.getS3AuthEffectiveAccessId()); + } + + @Test + public void testGetS3AuthEffectiveAccessIdNormal() throws Exception { + final String accessId = "accessId"; + final S3Authentication s3Auth = S3Authentication.newBuilder() + .setAccessId(accessId) + .setSignature("signature") + .setStringToSign("stringToSign") + .build(); + OzoneManager.setS3Auth(s3Auth); + + assertEquals(accessId, OzoneManager.getS3AuthEffectiveAccessId()); + } + + @Test + public void testGetS3AuthEffectiveAccessIdWithSessionToken() throws Exception { + final String tempAccessId = "ASIA12345"; + final String originalAccessId = "AKIAORIG98765"; + final String sessionToken = "sessionToken"; + + final S3Authentication s3Auth = S3Authentication.newBuilder() + .setAccessId(tempAccessId) + .setSignature("signature") + .setStringToSign("stringToSign") + .setSessionToken(sessionToken) + .build(); + OzoneManager.setS3Auth(s3Auth); + + final STSTokenIdentifier stsToken = mock(STSTokenIdentifier.class); + when(stsToken.getOriginalAccessKeyId()).thenReturn(originalAccessId); + OzoneManager.setStsTokenIdentifier(stsToken); + + assertEquals(originalAccessId, OzoneManager.getS3AuthEffectiveAccessId()); + } + + @Test + public void testGetS3AuthEffectiveAccessIdWithEmptySessionToken() throws Exception { + final String accessId = "AKIAORIG98765"; + final String emptySessionToken = ""; + + final S3Authentication s3Auth = S3Authentication.newBuilder() + .setAccessId(accessId) + .setSignature("signature") + .setStringToSign("stringToSign") + .setSessionToken(emptySessionToken) + .build(); + OzoneManager.setS3Auth(s3Auth); + + // Empty session token should be treated as if no session token is present. + assertEquals(accessId, OzoneManager.getS3AuthEffectiveAccessId()); + } + + @Test + public void testGetS3AuthEffectiveAccessIdWithSessionTokenMissingOriginalAccessKey() { + final String tempAccessId = "ASIA12345"; + final String sessionToken = "sessionToken"; + + final S3Authentication s3Auth = S3Authentication.newBuilder() + .setAccessId(tempAccessId) + .setSignature("signature") + .setStringToSign("stringToSign") + .setSessionToken(sessionToken) + .build(); + OzoneManager.setS3Auth(s3Auth); + + final STSTokenIdentifier stsToken = mock(STSTokenIdentifier.class); + when(stsToken.getOriginalAccessKeyId()).thenReturn(null); // Missing original ID + OzoneManager.setStsTokenIdentifier(stsToken); + + final OMException ex = assertThrows(OMException.class, OzoneManager::getS3AuthEffectiveAccessId); + assertEquals(INVALID_REQUEST, ex.getResult()); + assertEquals("Invalid STS Token format - could not find originalAccessKeyId", ex.getMessage()); + } +} + diff --git a/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/om/request/TestOMClientRequestWithUserInfo.java b/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/om/request/TestOMClientRequestWithUserInfo.java index 9fda60374c1e..d891a3bc8b95 100644 --- a/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/om/request/TestOMClientRequestWithUserInfo.java +++ b/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/om/request/TestOMClientRequestWithUserInfo.java @@ -22,6 +22,7 @@ import static org.apache.hadoop.ozone.om.request.OMRequestTestUtils.newCreateBucketRequest; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.mockStatic; @@ -47,6 +48,8 @@ import org.apache.hadoop.ozone.protocol.proto.OzoneManagerProtocolProtos; import org.apache.hadoop.ozone.protocol.proto.OzoneManagerProtocolProtos.BucketInfo; import org.apache.hadoop.ozone.protocol.proto.OzoneManagerProtocolProtos.OMRequest; +import org.apache.hadoop.ozone.protocol.proto.OzoneManagerProtocolProtos.S3Authentication; +import org.apache.hadoop.ozone.security.STSTokenIdentifier; import org.apache.hadoop.security.UserGroupInformation; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -163,4 +166,138 @@ public void testUserInfoInCaseOfGrpcTransport() throws IOException { } } + @Test + public void testUserInfoWithSTSToken() throws IOException { + final String accessId = "ASIA12345"; + final String signature = "Signature"; + final String stringToSign = "StringToSign"; + final String sessionToken = "SessionToken"; + final String originalAccessKeyId = "AKIAORIGINAL"; + + final STSTokenIdentifier stsTokenIdentifier = mock(STSTokenIdentifier.class); + when(stsTokenIdentifier.getOriginalAccessKeyId()).thenReturn(originalAccessKeyId); + + final S3Authentication s3Authentication = S3Authentication.newBuilder() + .setAccessId(accessId) + .setSignature(signature) + .setStringToSign(stringToSign) + .setSessionToken(sessionToken) + .build(); + + OzoneManager.setS3Auth(s3Authentication); + OzoneManager.setStsTokenIdentifier(stsTokenIdentifier); + + try { + final OMRequest.Builder omRequestBuilder = OMRequest.newBuilder() + .setCmdType(OzoneManagerProtocolProtos.Type.CommitKey) + .setClientId(UUID.randomUUID().toString()) + .setS3Authentication(s3Authentication); + + final OMRequest omRequest = omRequestBuilder.build(); + final OMClientRequest omClientRequest = new OMKeyCommitRequest(omRequest, mock(BucketLayout.class)); + + final OzoneManagerProtocolProtos.UserInfo userInfo = omClientRequest.getUserInfo(); + assertEquals(originalAccessKeyId, userInfo.getUserName()); + } finally { + OzoneManager.setStsTokenIdentifier(null); + OzoneManager.setS3Auth(null); + } + } + + @Test + public void testUserInfoWithSTSAccessKeyMissingSessionToken() { + final String accessId = "ASIA12345"; + final String signature = "Signature"; + final String stringToSign = "StringToSign"; + + final OMRequest omRequest = OMRequest.newBuilder() + .setCmdType(OzoneManagerProtocolProtos.Type.CommitKey) + .setClientId(UUID.randomUUID().toString()) + .setS3Authentication(S3Authentication.newBuilder() + .setAccessId(accessId) + .setSignature(signature) + .setStringToSign(stringToSign) + .build()) + .build(); + + final OMClientRequest omClientRequest = new OMKeyCommitRequest(omRequest, mock(BucketLayout.class)); + final IOException ex = assertThrows(IOException.class, omClientRequest::getUserInfo); + + assertEquals("Error with STS token", ex.getMessage()); + assertEquals("Missing session token for accessKeyId: " + accessId, ex.getCause().getMessage()); + } + + @Test + public void testUserInfoWithSessionTokenButNoStsTokenIdentifier() { + final String accessId = "ASIA12345"; + final String signature = "Signature"; + final String stringToSign = "StringToSign"; + final String sessionToken = "SessionToken"; + + final S3Authentication s3Authentication = S3Authentication.newBuilder() + .setAccessId(accessId) + .setSignature(signature) + .setStringToSign(stringToSign) + .setSessionToken(sessionToken) + .build(); + + final OMRequest omRequest = OMRequest.newBuilder() + .setCmdType(OzoneManagerProtocolProtos.Type.CommitKey) + .setClientId(UUID.randomUUID().toString()) + .setS3Authentication(s3Authentication) + .build(); + + OzoneManager.setS3Auth(s3Authentication); + OzoneManager.setStsTokenIdentifier(null); + + try { + final OMClientRequest omClientRequest = new OMKeyCommitRequest(omRequest, mock(BucketLayout.class)); + final IOException ex = assertThrows(IOException.class, omClientRequest::getUserInfo); + + assertEquals("Error with STS Token", ex.getMessage()); + assertEquals( + "OMClientRequest has session token but no token identifier in OzoneManager", ex.getCause().getMessage()); + } finally { + OzoneManager.setS3Auth(null); + } + } + + @Test + public void testUserInfoWithSessionTokenButEmptyOriginalAccessKeyId() { + final String accessId = "ASIA12345"; + final String signature = "Signature"; + final String stringToSign = "StringToSign"; + final String sessionToken = "SessionToken"; + + final STSTokenIdentifier stsTokenIdentifier = mock(STSTokenIdentifier.class); + when(stsTokenIdentifier.getOriginalAccessKeyId()).thenReturn(""); + + final S3Authentication s3Authentication = S3Authentication.newBuilder() + .setAccessId(accessId) + .setSignature(signature) + .setStringToSign(stringToSign) + .setSessionToken(sessionToken) + .build(); + + OzoneManager.setS3Auth(s3Authentication); + OzoneManager.setStsTokenIdentifier(stsTokenIdentifier); + + try { + final OMRequest omRequest = OMRequest.newBuilder() + .setCmdType(OzoneManagerProtocolProtos.Type.CommitKey) + .setClientId(UUID.randomUUID().toString()) + .setS3Authentication(s3Authentication) + .build(); + + final OMClientRequest omClientRequest = new OMKeyCommitRequest(omRequest, mock(BucketLayout.class)); + final IOException ex = assertThrows(IOException.class, omClientRequest::getUserInfo); + + assertEquals("Error with STS Token", ex.getMessage()); + assertEquals("Invalid STS Token format - could not find originalAccessKeyId", ex.getCause().getMessage()); + } finally { + OzoneManager.setS3Auth(null); + OzoneManager.setStsTokenIdentifier(null); + } + } + } diff --git a/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/security/TestSTSSecurityUtil.java b/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/security/TestSTSSecurityUtil.java new file mode 100644 index 000000000000..96c832877059 --- /dev/null +++ b/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/security/TestSTSSecurityUtil.java @@ -0,0 +1,318 @@ +/* + * 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.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.UUID; +import org.apache.hadoop.hdds.security.exception.SCMSecurityException; +import org.apache.hadoop.hdds.security.symmetric.ManagedSecretKey; +import org.apache.hadoop.hdds.security.symmetric.SecretKeyClient; +import org.apache.hadoop.io.Text; +import org.apache.hadoop.ozone.om.exceptions.OMException; +import org.apache.hadoop.ozone.protocol.proto.OzoneManagerProtocolProtos.OMTokenProto; +import org.apache.hadoop.security.token.Token; +import org.apache.ozone.test.TestClock; +import org.junit.jupiter.api.Test; + +/** + * Unit tests for STSSecurityUtil. + */ +public class TestSTSSecurityUtil { + private static final String TEMP_ACCESS_KEY = "temp-access-key"; + private static final String ORIGINAL_ACCESS_KEY = "original-access-key"; + private static final String ROLE_ARN = "arn:aws:iam::123456789012:role/test-role"; + private static final String SECRET_ACCESS_KEY = "test-secret-access-key"; + private static final String SESSION_POLICY = "test-session-policy"; + private static final int DURATION_SECONDS = 3600; + + private final SecretKeyTestClient secretKeyClient = new SecretKeyTestClient(); + private final STSTokenSecretManager tokenSecretManager = new STSTokenSecretManager(secretKeyClient); + private final UUID secretKeyId = secretKeyClient.getCurrentSecretKey().getId(); + private final TestClock clock = new TestClock(Instant.ofEpochMilli(1764819000), ZoneOffset.UTC); + + @Test + public void testConstructValidateAndDecryptSTSTokenInvalidProtobuf() throws IOException { + // Create a token whose identifier bytes are not a valid OMTokenProto + final Token token = new Token<>( + new byte[] {0x01, 0x02, 0x03}, new byte[] {0x04}, STSTokenIdentifier.KIND_NAME, + new Text(STSTokenIdentifier.STS_SERVICE)); + + final String tokenString = token.encodeToUrlString(); + + assertThatThrownBy(() -> + STSSecurityUtil.constructValidateAndDecryptSTSToken(tokenString, secretKeyClient, clock)) + .isInstanceOf(OMException.class) + .hasMessage( + "Invalid STS token format: Invalid STS token - could not parse protocol buffer: Protocol message " + + "contained an invalid tag (zero)."); + } + + @Test + public void testConstructValidateAndDecryptSTSTokenSuccess() throws IOException { + // Create a valid token + final String tokenString = tokenSecretManager.createSTSTokenString( + TEMP_ACCESS_KEY, ORIGINAL_ACCESS_KEY, ROLE_ARN, DURATION_SECONDS, SECRET_ACCESS_KEY, SESSION_POLICY, clock); + + // Validate and decrypt the token + final STSTokenIdentifier result = STSSecurityUtil.constructValidateAndDecryptSTSToken( + tokenString, secretKeyClient, clock); + + // Verify the result + assertThat(result.getOwnerId()).isEqualTo(TEMP_ACCESS_KEY); + assertThat(result.getOriginalAccessKeyId()).isEqualTo(ORIGINAL_ACCESS_KEY); + assertThat(result.getRoleArn()).isEqualTo(ROLE_ARN); + assertThat(result.getSecretAccessKey()).isEqualTo(SECRET_ACCESS_KEY); + assertThat(result.getSessionPolicy()).isEqualTo(SESSION_POLICY); + assertThat(result.isExpired(clock.instant())).isFalse(); + final long expirationEpochMillis = result.getExpiry().toEpochMilli(); + assertThat(expirationEpochMillis).isEqualTo(clock.millis() + (DURATION_SECONDS * 1000)); + } + + @Test + public void testConstructValidateAndDecryptSTSTokenSuccessWithNullSessionPolicy() throws Exception { + // Create a valid token with null session policy + final String tokenString = tokenSecretManager.createSTSTokenString( + TEMP_ACCESS_KEY, ORIGINAL_ACCESS_KEY, ROLE_ARN, DURATION_SECONDS, SECRET_ACCESS_KEY, null, clock); + + // Validate and decrypt the token + final STSTokenIdentifier result = STSSecurityUtil.constructValidateAndDecryptSTSToken( + tokenString, secretKeyClient, clock); + + // Verify the result + assertThat(result.getSessionPolicy()).isEmpty(); + } + + @Test + public void testConstructValidateAndDecryptSTSTokenInvalidFormat() { + // Try to decrypt an invalid token string + assertThatThrownBy(() -> + STSSecurityUtil.constructValidateAndDecryptSTSToken("invalid-token-format", secretKeyClient, clock)) + .isInstanceOf(OMException.class) + .hasMessageContaining("Invalid STS token format: Failed to decode STS token string"); + } + + @Test + public void testConstructValidateAndDecryptSTSTokenInvalidKind() throws Exception { + // Create a valid identifier to use as base + final String validTokenString = tokenSecretManager.createSTSTokenString( + TEMP_ACCESS_KEY, ORIGINAL_ACCESS_KEY, ROLE_ARN, DURATION_SECONDS, SECRET_ACCESS_KEY, SESSION_POLICY, clock); + + final Token validToken = new Token<>(); + validToken.decodeFromUrlString(validTokenString); + + // Create token with wrong kind + final Token token = new Token<>( + validToken.getIdentifier(), validToken.getPassword(), new Text("WRONG_KIND"), + new Text(STSTokenIdentifier.STS_SERVICE)); + + final String invalidTokenString = token.encodeToUrlString(); + + // Try to validate the token with wrong kind + assertThatThrownBy(() -> + STSSecurityUtil.constructValidateAndDecryptSTSToken(invalidTokenString, secretKeyClient, clock)) + .isInstanceOf(OMException.class) + .hasMessage("Invalid STS token format: Invalid STS token - kind is incorrect: WRONG_KIND"); + } + + @Test + public void testConstructValidateAndDecryptSTSTokenInvalidService() throws Exception { + // Create a token with incorrect service + final String validTokenString = tokenSecretManager.createSTSTokenString( + TEMP_ACCESS_KEY, ORIGINAL_ACCESS_KEY, ROLE_ARN, DURATION_SECONDS, SECRET_ACCESS_KEY, SESSION_POLICY, clock); + + final Token validToken = new Token<>(); + validToken.decodeFromUrlString(validTokenString); + + final Token token = new Token<>( + validToken.getIdentifier(), validToken.getPassword(), validToken.getKind(), new Text("WRONG_SERVICE")); + + final String invalidTokenString = token.encodeToUrlString(); + + // Try to validate the token with wrong service + assertThatThrownBy(() -> + STSSecurityUtil.constructValidateAndDecryptSTSToken(invalidTokenString, secretKeyClient, clock)) + .isInstanceOf(OMException.class) + .hasMessage("Invalid STS token format: Invalid STS token - service is incorrect: WRONG_SERVICE"); + } + + @Test + public void testConstructValidateAndDecryptSTSTokenExpired() throws Exception { + // Create a token that expires immediately (durationSeconds of 0) + final String tokenString = tokenSecretManager.createSTSTokenString( + TEMP_ACCESS_KEY, ORIGINAL_ACCESS_KEY, ROLE_ARN, 0, SECRET_ACCESS_KEY, SESSION_POLICY, clock); + + // Fast-forward time to ensure token is expired + clock.fastForward(100); + + // Try to validate the expired token + assertThatThrownBy(() -> + STSSecurityUtil.constructValidateAndDecryptSTSToken(tokenString, secretKeyClient, clock)) + .isInstanceOf(OMException.class) + .hasMessageContaining("Invalid STS token format: Invalid STS token - token expired at"); + } + + @Test + public void testConstructValidateAndDecryptSTSTokenSecretKeyNotFound() throws Exception { + // Create a valid token string + final String validTokenString = tokenSecretManager.createSTSTokenString( + TEMP_ACCESS_KEY, ORIGINAL_ACCESS_KEY, ROLE_ARN, DURATION_SECONDS, SECRET_ACCESS_KEY, SESSION_POLICY, clock); + + // Create a mock secret key client that returns null for the key + final SecretKeyClient mockKeyClient = mock(SecretKeyClient.class); + when(mockKeyClient.getSecretKey(any())).thenReturn(null); + + // Try to validate the token when secret key is not found + assertThatThrownBy(() -> + STSSecurityUtil.constructValidateAndDecryptSTSToken(validTokenString, mockKeyClient, clock)) + .isInstanceOf(OMException.class) + .hasMessage( + "Invalid STS token format: Invalid STS token - could not readFromByteArray: Secret key not found for " + + "STS token secretKeyId: " + secretKeyId); + } + + @Test + public void testConstructValidateAndDecryptSTSTokenInvalidSecretKeyId() throws Exception { + // Create a valid identifier to use as base + final String validTokenString = tokenSecretManager.createSTSTokenString( + TEMP_ACCESS_KEY, ORIGINAL_ACCESS_KEY, ROLE_ARN, DURATION_SECONDS, SECRET_ACCESS_KEY, SESSION_POLICY, clock); + + final Token validToken = new Token<>(); + validToken.decodeFromUrlString(validTokenString); + + // Rewrite the identifier with an invalid secretKeyId in the protobuf + final byte[] identifierBytes = validToken.getIdentifier(); + final OMTokenProto proto = OMTokenProto.parseFrom(identifierBytes); + + final OMTokenProto invalidProto = proto.toBuilder().setSecretKeyId("not-a-uuid").build(); + + final Token brokenToken = new Token<>( + invalidProto.toByteArray(), validToken.getPassword(), validToken.getKind(), validToken.getService()); + + final String invalidTokenString = brokenToken.encodeToUrlString(); + + assertThatThrownBy(() -> + STSSecurityUtil.constructValidateAndDecryptSTSToken(invalidTokenString, secretKeyClient, clock)) + .isInstanceOf(OMException.class) + .hasMessage("Invalid STS token format: Invalid STS token - secretKeyId was not valid: not-a-uuid"); + } + + @Test + public void testConstructValidateAndDecryptSTSTokenExpiredSecretKey() throws Exception { + // Create a valid token string + final String validTokenString = tokenSecretManager.createSTSTokenString( + TEMP_ACCESS_KEY, ORIGINAL_ACCESS_KEY, ROLE_ARN, DURATION_SECONDS, SECRET_ACCESS_KEY, SESSION_POLICY, clock); + + // Create a mock secret key that is expired + final ManagedSecretKey expiredSecretKey = mock(ManagedSecretKey.class); + when(expiredSecretKey.isExpired()).thenReturn(true); + + final SecretKeyClient mockKeyClient = mock(SecretKeyClient.class); + when(mockKeyClient.getSecretKey(any())).thenReturn(expiredSecretKey); + + // Try to validate the token with expired secret key + assertThatThrownBy(() -> + STSSecurityUtil.constructValidateAndDecryptSTSToken(validTokenString, mockKeyClient, clock)) + .isInstanceOf(OMException.class) + .hasMessage( + "Invalid STS token format: Invalid STS token - could not readFromByteArray: Token cannot be " + + "verified due to expired secret key " + secretKeyId); + } + + @Test + public void testConstructValidateAndDecryptSTSTokenSecretKeyRetrievalException() throws Exception { + // Create a valid token string + final String validTokenString = tokenSecretManager.createSTSTokenString( + TEMP_ACCESS_KEY, ORIGINAL_ACCESS_KEY, ROLE_ARN, DURATION_SECONDS, SECRET_ACCESS_KEY, SESSION_POLICY, clock); + + // Create a mock secret key client that throws an exception + final SecretKeyClient mockKeyClient = mock(SecretKeyClient.class); + when(mockKeyClient.getSecretKey(any())).thenThrow(new SCMSecurityException("something went wrong")); + + // Try to validate the token when secret key retrieval fails + assertThatThrownBy(() -> + STSSecurityUtil.constructValidateAndDecryptSTSToken(validTokenString, mockKeyClient, clock)) + .isInstanceOf(OMException.class) + .hasMessage( + "Invalid STS token format: Invalid STS token - could not readFromByteArray: Failed to retrieve secret " + + "key: something went wrong"); + } + + @Test + public void testConstructValidateAndDecryptSTSTokenInvalidSignature() throws Exception { + // Create a valid token string + final String validTokenString = tokenSecretManager.createSTSTokenString( + TEMP_ACCESS_KEY, ORIGINAL_ACCESS_KEY, ROLE_ARN, DURATION_SECONDS, SECRET_ACCESS_KEY, SESSION_POLICY, clock); + + final Token validToken = new Token<>(); + validToken.decodeFromUrlString(validTokenString); + + // Create a token with invalid signature (wrong password) + final Token invalidToken = new Token<>( + validToken.getIdentifier(), "wrong-signature".getBytes(StandardCharsets.UTF_8), validToken.getKind(), + validToken.getService()); + + final String invalidTokenString = invalidToken.encodeToUrlString(); + + // Try to validate the token with invalid signature + assertThatThrownBy(() -> + STSSecurityUtil.constructValidateAndDecryptSTSToken(invalidTokenString, secretKeyClient, clock)) + .isInstanceOf(OMException.class) + .hasMessageContaining("Invalid STS token format: Invalid STS token - signature is not correct for token"); + } + + @Test + public void testConstructValidateAndDecryptSTSTokenEmptyString() { + // Try to decrypt an empty token string + assertThatThrownBy(() -> + STSSecurityUtil.constructValidateAndDecryptSTSToken("", secretKeyClient, clock)) + .isInstanceOf(OMException.class) + .hasMessage("Invalid STS token format: Failed to decode STS token string: java.io.EOFException"); + } + + @Test + public void testConstructValidateAndDecryptMultipleTokens() throws Exception { + // Create multiple tokens and validate them all + final String token1 = tokenSecretManager.createSTSTokenString( + "temp-key-1", "orig-key-1", "role-arn-1", DURATION_SECONDS, + "secret-key-1", "policy-1", clock); + + final String token2 = tokenSecretManager.createSTSTokenString( + "temp-key-2", "orig-key-2", "role-arn-2", DURATION_SECONDS, + "secret-key-2", "policy-2", clock); + + final STSTokenIdentifier result1 = STSSecurityUtil.constructValidateAndDecryptSTSToken( + token1, secretKeyClient, clock); + final STSTokenIdentifier result2 = STSSecurityUtil.constructValidateAndDecryptSTSToken( + token2, secretKeyClient, clock); + + assertThat(result1.getOwnerId()).isEqualTo("temp-key-1"); + assertThat(result1.getOriginalAccessKeyId()).isEqualTo("orig-key-1"); + assertThat(result2.getOwnerId()).isEqualTo("temp-key-2"); + assertThat(result2.getOriginalAccessKeyId()).isEqualTo("orig-key-2"); + } +} + diff --git a/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/security/TestSTSTokenSecretManager.java b/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/security/TestSTSTokenSecretManager.java index 5eb7868c4a2b..ad7d3df71fff 100644 --- a/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/security/TestSTSTokenSecretManager.java +++ b/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/security/TestSTSTokenSecretManager.java @@ -27,6 +27,7 @@ import java.io.IOException; import java.nio.charset.StandardCharsets; import java.time.Instant; +import java.time.ZoneOffset; import java.util.UUID; import javax.crypto.SecretKey; import javax.crypto.spec.SecretKeySpec; @@ -34,6 +35,7 @@ import org.apache.hadoop.hdds.security.symmetric.SecretKeySignerClient; import org.apache.hadoop.io.Text; import org.apache.hadoop.security.token.Token; +import org.apache.ozone.test.TestClock; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -43,6 +45,8 @@ */ public class TestSTSTokenSecretManager { private STSTokenSecretManager secretManager; + private TestClock clock; + private static final String TEMP_ACCESS_KEY = "temp-access-key"; private static final String ORIGINAL_ACCESS_KEY = "original-access-key"; private static final String ROLE_ARN = "arn:aws:iam::123456789012:role/test-role"; @@ -71,14 +75,13 @@ public void setUp() throws Exception { when(mockSecretKeyClient.getCurrentSecretKey()).thenReturn(mockSecretKey); secretManager = new STSTokenSecretManager(mockSecretKeyClient); + clock = new TestClock(Instant.ofEpochMilli(1764819000), ZoneOffset.UTC); } @Test public void testCreateSTSTokenStringContainsCorrectFields() throws IOException { - final Instant beforeCreation = Instant.now(); - final String tokenString = secretManager.createSTSTokenString( - TEMP_ACCESS_KEY, ORIGINAL_ACCESS_KEY, ROLE_ARN, DURATION_SECONDS, SECRET_ACCESS_KEY, SESSION_POLICY); + TEMP_ACCESS_KEY, ORIGINAL_ACCESS_KEY, ROLE_ARN, DURATION_SECONDS, SECRET_ACCESS_KEY, SESSION_POLICY, clock); // Decode the token final Token token = new Token<>(); @@ -88,7 +91,6 @@ public void testCreateSTSTokenStringContainsCorrectFields() throws IOException { final STSTokenIdentifier identifier = new STSTokenIdentifier(); identifier.setEncryptionKey(sharedSecretKey.getEncoded()); identifier.readFromByteArray(token.getIdentifier()); - final Instant afterCreation = Instant.now(); final Instant expiration = identifier.getExpiry(); assertEquals(TEMP_ACCESS_KEY, identifier.getTempAccessKeyId()); @@ -99,15 +101,13 @@ public void testCreateSTSTokenStringContainsCorrectFields() throws IOException { assertNotNull(identifier.getSecretKeyId()); assertEquals(new Text("STSToken"), identifier.getKind()); assertEquals("STS", identifier.getService()); - // Verify expiration is approximately durationSeconds in the future - assertTrue(expiration.isAfter(beforeCreation.plusSeconds(DURATION_SECONDS - 1))); - assertTrue(expiration.isBefore(afterCreation.plusSeconds(DURATION_SECONDS + 1))); + assertEquals(clock.millis() + (DURATION_SECONDS * 1000), expiration.toEpochMilli()); } @Test public void testCreateSTSTokenStringWithNullSessionPolicy() throws IOException { final String tokenString = secretManager.createSTSTokenString( - TEMP_ACCESS_KEY, ORIGINAL_ACCESS_KEY, ROLE_ARN, DURATION_SECONDS, SECRET_ACCESS_KEY, null); + TEMP_ACCESS_KEY, ORIGINAL_ACCESS_KEY, ROLE_ARN, DURATION_SECONDS, SECRET_ACCESS_KEY, null, clock); // Decode the token final Token token = new Token<>(); From 8f7ec1011a49622afb60dd6e225b1bde1837b7de Mon Sep 17 00:00:00 2001 From: fmorg-git Date: Fri, 5 Dec 2025 04:49:18 -0800 Subject: [PATCH 11/54] HDDS-14066. [STS] Database updates for revoked STS tokens (#9420) --- .../hadoop/ozone/om/OMMetadataManager.java | 8 +++++ .../ozone/om/OmMetadataManagerImpl.java | 10 +++++++ .../hadoop/ozone/om/codec/OMDBDefinition.java | 23 +++++++++----- .../ozone/om/TestOmMetadataManager.java | 30 ++++++++++++++++++- 4 files changed, 63 insertions(+), 8 deletions(-) diff --git a/hadoop-ozone/interface-storage/src/main/java/org/apache/hadoop/ozone/om/OMMetadataManager.java b/hadoop-ozone/interface-storage/src/main/java/org/apache/hadoop/ozone/om/OMMetadataManager.java index baac362da741..7afe2c6249a9 100644 --- a/hadoop-ozone/interface-storage/src/main/java/org/apache/hadoop/ozone/om/OMMetadataManager.java +++ b/hadoop-ozone/interface-storage/src/main/java/org/apache/hadoop/ozone/om/OMMetadataManager.java @@ -484,6 +484,14 @@ String getMultipartKeyFSO(String volume, String bucket, String key, String */ Table getMetaTable(); + /** + * Gets the S3RevokedStsTokenTable. + * + * @return Table. + */ + Table getS3RevokedStsTokenTable(); + + /** * Returns number of rows in a table. This should not be used for very * large tables. diff --git a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/OmMetadataManagerImpl.java b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/OmMetadataManagerImpl.java index e7826708b895..b28f8bcb9d6e 100644 --- a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/OmMetadataManagerImpl.java +++ b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/OmMetadataManagerImpl.java @@ -181,6 +181,8 @@ public class OmMetadataManagerImpl implements OMMetadataManager, private TypedTable snapshotRenamedTable; private TypedTable compactionLogTable; + private TypedTable s3RevokedStsTokenTable; + private OzoneManager ozoneManager; // Epoch is used to generate the objectIDs. The most significant 2 bits of @@ -486,6 +488,9 @@ protected void initializeOmTables(CacheType cacheType, // TODO: [SNAPSHOT] Initialize table lock for snapshotRenamedTable. compactionLogTable = initializer.get(OMDBDefinition.COMPACTION_LOG_TABLE_DEF); + + // temporaryAccessKeyId -> sessionToken + s3RevokedStsTokenTable = initializer.get(OMDBDefinition.S3_REVOKED_STS_TOKEN_TABLE_DEF); } /** @@ -1683,6 +1688,11 @@ public Table getCompactionLogTable() { return compactionLogTable; } + @Override + public Table getS3RevokedStsTokenTable() { + return s3RevokedStsTokenTable; + } + /** * Get Snapshot Chain Manager. * diff --git a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/codec/OMDBDefinition.java b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/codec/OMDBDefinition.java index 9894e8f5d6bf..8b4632ef45bf 100644 --- a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/codec/OMDBDefinition.java +++ b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/codec/OMDBDefinition.java @@ -49,13 +49,14 @@ * OM database definitions. *

  * {@code
- * User, Token and Secret Tables:
+ * User, Token, Secret and Revoked STS Token Tables:
  * |------------------------------------------------------------------------|
- * |        Column Family |                 Mapping                         |
+ * |          Column Family |                 Mapping                       |
  * |------------------------------------------------------------------------|
- * |            userTable |             /user :- UserVolumeInfo             |
- * |          dTokenTable |      OzoneTokenID :- renew_time                 |
- * |        s3SecretTable | s3g_access_key_id :- s3Secret                   |
+ * |              userTable |             /user :- UserVolumeInfo           |
+ * |            dTokenTable |      OzoneTokenID :- renew_time               |
+ * |          s3SecretTable | s3g_access_key_id :- s3Secret                 |
+ * | s3RevokedStsTokenTable | sts_access_key_id :- sessionToken             |
  * |------------------------------------------------------------------------|
  * }
  * 
@@ -139,7 +140,7 @@ public final class OMDBDefinition extends DBDefinition.WithMap { //--------------------------------------------------------------------------- - // User, Token and Secret Tables: + // User, Token, Secret and Revoked STS Token Tables: public static final String USER_TABLE = "userTable"; /** userTable: /user :- UserVolumeInfo. */ public static final DBColumnFamilyDefinition USER_TABLE_DEF @@ -161,6 +162,13 @@ public final class OMDBDefinition extends DBDefinition.WithMap { StringCodec.get(), S3SecretValue.getCodec()); + public static final String S3_REVOKED_STS_TOKEN_TABLE = "s3RevokedStsTokenTable"; + /** s3RevokedStsTokenTable: sts_access_key_id :- sessionToken.*/ + public static final DBColumnFamilyDefinition S3_REVOKED_STS_TOKEN_TABLE_DEF + = new DBColumnFamilyDefinition<>(S3_REVOKED_STS_TOKEN_TABLE, + StringCodec.get(), + StringCodec.get()); + //--------------------------------------------------------------------------- // Volume, Bucket, Prefix and Transaction Tables: public static final String VOLUME_TABLE = "volumeTable"; @@ -339,7 +347,8 @@ public final class OMDBDefinition extends DBDefinition.WithMap { TENANT_STATE_TABLE_DEF, TRANSACTION_INFO_TABLE_DEF, USER_TABLE_DEF, - VOLUME_TABLE_DEF); + VOLUME_TABLE_DEF, + S3_REVOKED_STS_TOKEN_TABLE_DEF); private static final OMDBDefinition INSTANCE = new OMDBDefinition(); diff --git a/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/om/TestOmMetadataManager.java b/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/om/TestOmMetadataManager.java index bebc58807888..6f37afd0674c 100644 --- a/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/om/TestOmMetadataManager.java +++ b/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/om/TestOmMetadataManager.java @@ -38,6 +38,7 @@ import static org.apache.hadoop.ozone.om.codec.OMDBDefinition.OPEN_KEY_TABLE; import static org.apache.hadoop.ozone.om.codec.OMDBDefinition.PREFIX_TABLE; import static org.apache.hadoop.ozone.om.codec.OMDBDefinition.PRINCIPAL_TO_ACCESS_IDS_TABLE; +import static org.apache.hadoop.ozone.om.codec.OMDBDefinition.S3_REVOKED_STS_TOKEN_TABLE; import static org.apache.hadoop.ozone.om.codec.OMDBDefinition.S3_SECRET_TABLE; import static org.apache.hadoop.ozone.om.codec.OMDBDefinition.SNAPSHOT_INFO_TABLE; import static org.apache.hadoop.ozone.om.codec.OMDBDefinition.SNAPSHOT_RENAMED_TABLE; @@ -52,6 +53,7 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -137,7 +139,8 @@ public class TestOmMetadataManager { TENANT_STATE_TABLE, SNAPSHOT_INFO_TABLE, SNAPSHOT_RENAMED_TABLE, - COMPACTION_LOG_TABLE + COMPACTION_LOG_TABLE, + S3_REVOKED_STS_TOKEN_TABLE }; private OMMetadataManager omMetadataManager; @@ -1289,4 +1292,29 @@ public void testGetMultipartUploadKeys() throws Exception { assertEquals(25, noPagination.size()); } + + @Test + public void testS3RevokedStsTokenTablePutAndGet() throws Exception { + // Ensure the table is initialized + assertNotNull(omMetadataManager.getS3RevokedStsTokenTable(), "s3RevokedStsTokenTable should be initialized"); + + final String tempAccessKeyId1 = "ASIA7VUS1EOBCW8RRJVR"; + final String sessionToken1 = "test-session-token-1"; + final String tempAccessKeyId2 = "ASIA904E65QIGL9ON305"; + final String sessionToken2 = "test-session-token-2"; + + omMetadataManager.getS3RevokedStsTokenTable() + .put(tempAccessKeyId1, sessionToken1); + omMetadataManager.getS3RevokedStsTokenTable() + .put(tempAccessKeyId2, sessionToken2); + + // Verify get and getIfExist return the stored value + assertEquals(sessionToken1, omMetadataManager.getS3RevokedStsTokenTable().get(tempAccessKeyId1)); + assertEquals(sessionToken1, omMetadataManager.getS3RevokedStsTokenTable().getIfExist(tempAccessKeyId1)); + assertEquals(sessionToken2, omMetadataManager.getS3RevokedStsTokenTable().get(tempAccessKeyId2)); + assertEquals(sessionToken2, omMetadataManager.getS3RevokedStsTokenTable().getIfExist(tempAccessKeyId2)); + + // Unknown key should return null for getIfExist + assertNull(omMetadataManager.getS3RevokedStsTokenTable().getIfExist("ASIA_UNKNOWN_ACCESS_KEY")); + } } From 3d1770489523381de629055f485cdf5af81fa2b9 Mon Sep 17 00:00:00 2001 From: fmorg-git Date: Mon, 8 Dec 2025 11:46:45 -0800 Subject: [PATCH 12/54] HDDS-14067. [STS] Plumbing and CLI utility to revoke STS token (#9435) --- .../org/apache/hadoop/ozone/OzoneConsts.java | 1 + .../ozone/shell/s3/RevokeSTSTokenHandler.java | 79 ++++ .../apache/hadoop/ozone/shell/s3/S3Shell.java | 3 +- .../hadoop/ozone/client/ObjectStore.java | 10 + .../ozone/client/protocol/ClientProtocol.java | 8 + .../hadoop/ozone/client/rpc/RpcClient.java | 5 + .../java/org/apache/hadoop/ozone/OmUtils.java | 1 + .../om/protocol/OzoneManagerProtocol.java | 10 + ...ManagerProtocolClientSideTranslatorPB.java | 15 + .../src/main/proto/OmClientProtocol.proto | 11 + .../apache/hadoop/ozone/audit/OMAction.java | 2 + .../ratis/utils/OzoneManagerRatisUtils.java | 3 + .../s3/security/S3RevokeSTSTokenRequest.java | 122 ++++++ .../s3/security/S3RevokeSTSTokenResponse.java | 57 +++ .../security/TestS3RevokeSTSTokenRequest.java | 353 ++++++++++++++++++ .../ozone/client/ClientProtocolStub.java | 3 + 16 files changed, 682 insertions(+), 1 deletion(-) create mode 100644 hadoop-ozone/cli-shell/src/main/java/org/apache/hadoop/ozone/shell/s3/RevokeSTSTokenHandler.java create mode 100644 hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/s3/security/S3RevokeSTSTokenRequest.java create mode 100644 hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/response/s3/security/S3RevokeSTSTokenResponse.java create mode 100644 hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/om/request/s3/security/TestS3RevokeSTSTokenRequest.java diff --git a/hadoop-hdds/common/src/main/java/org/apache/hadoop/ozone/OzoneConsts.java b/hadoop-hdds/common/src/main/java/org/apache/hadoop/ozone/OzoneConsts.java index 99d78f786fa4..c7dacac989cf 100644 --- a/hadoop-hdds/common/src/main/java/org/apache/hadoop/ozone/OzoneConsts.java +++ b/hadoop-hdds/common/src/main/java/org/apache/hadoop/ozone/OzoneConsts.java @@ -301,6 +301,7 @@ public final class OzoneConsts { public static final String S3_GETSECRET_USER = "S3GetSecretUser"; public static final String S3_SETSECRET_USER = "S3SetSecretUser"; public static final String S3_REVOKESECRET_USER = "S3RevokeSecretUser"; + public static final String S3_REVOKESTSTOKEN_USER = "S3RevokeSTSTokenUser"; public static final String RENAMED_KEYS_MAP = "renamedKeysMap"; public static final String UNRENAMED_KEYS_MAP = "unRenamedKeysMap"; public static final String MULTIPART_UPLOAD_PART_NUMBER = "partNumber"; diff --git a/hadoop-ozone/cli-shell/src/main/java/org/apache/hadoop/ozone/shell/s3/RevokeSTSTokenHandler.java b/hadoop-ozone/cli-shell/src/main/java/org/apache/hadoop/ozone/shell/s3/RevokeSTSTokenHandler.java new file mode 100644 index 000000000000..2f63d4f2a5ad --- /dev/null +++ b/hadoop-ozone/cli-shell/src/main/java/org/apache/hadoop/ozone/shell/s3/RevokeSTSTokenHandler.java @@ -0,0 +1,79 @@ +/* + * 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.shell.s3; + +import java.io.IOException; +import java.io.InputStreamReader; +import java.nio.charset.StandardCharsets; +import java.util.Scanner; +import org.apache.hadoop.ozone.client.OzoneClient; +import org.apache.hadoop.ozone.shell.OzoneAddress; +import picocli.CommandLine.Command; +import picocli.CommandLine.Option; + +/** + * Executes revocation of STS tokens. + * + *

This command marks the specified STS temporary access key id as revoked + * by adding it to the OM's revoked STS token table. Subsequent S3 requests + * using the same temporary access key id will be rejected once the revocation + * state has propagated.

+ */ +@Command(name = "revokeststoken", + description = "Revoke S3 STS token for the given access key id") +public class RevokeSTSTokenHandler extends S3Handler { + + @Option(names = "-k", + required = true, + description = "STS temporary access key id (for example, ASIA...)") + private String accessKeyId; + + @Option(names = "-t", + required = true, + description = "STS session token") + private String sessionToken; + + @Option(names = "-y", + description = "Continue without interactive user confirmation") + private boolean yes; + + @Override + protected boolean isApplicable() { + return securityEnabled(); + } + + @Override + protected void execute(OzoneClient client, OzoneAddress address) + throws IOException { + + if (!yes) { + out().print("Enter 'y' to confirm STS token revocation for accessKeyId '" + + accessKeyId + "': "); + out().flush(); + final Scanner scanner = new Scanner(new InputStreamReader(System.in, StandardCharsets.UTF_8)); + final String confirmation = scanner.next().trim().toLowerCase(); + if (!"y".equals(confirmation)) { + out().println("Revoke STS token operation cancelled."); + return; + } + } + + client.getObjectStore().revokeSTSToken(accessKeyId, sessionToken); + out().println("STS token revoked for accessKeyId '" + accessKeyId + "'."); + } +} diff --git a/hadoop-ozone/cli-shell/src/main/java/org/apache/hadoop/ozone/shell/s3/S3Shell.java b/hadoop-ozone/cli-shell/src/main/java/org/apache/hadoop/ozone/shell/s3/S3Shell.java index 8c35a0c2e15d..014ea4c83bd1 100644 --- a/hadoop-ozone/cli-shell/src/main/java/org/apache/hadoop/ozone/shell/s3/S3Shell.java +++ b/hadoop-ozone/cli-shell/src/main/java/org/apache/hadoop/ozone/shell/s3/S3Shell.java @@ -28,7 +28,8 @@ subcommands = { GetS3SecretHandler.class, SetS3SecretHandler.class, - RevokeS3SecretHandler.class + RevokeS3SecretHandler.class, + RevokeSTSTokenHandler.class }) public class S3Shell extends Shell { diff --git a/hadoop-ozone/client/src/main/java/org/apache/hadoop/ozone/client/ObjectStore.java b/hadoop-ozone/client/src/main/java/org/apache/hadoop/ozone/client/ObjectStore.java index e783bafe227a..226ebbfb0349 100644 --- a/hadoop-ozone/client/src/main/java/org/apache/hadoop/ozone/client/ObjectStore.java +++ b/hadoop-ozone/client/src/main/java/org/apache/hadoop/ozone/client/ObjectStore.java @@ -766,6 +766,16 @@ public AssumeRoleResponseInfo assumeRole(String roleArn, String roleSessionName, return proxy.assumeRole(roleArn, roleSessionName, durationSeconds, awsIamSessionPolicy); } + /** + * Revokes an STS token. + * @param accessKeyId The STS accessKeyId (starting with ASIA...) + * @param sessionToken The STS session token + * @throws IOException if an error occurs while revoking the STS token + */ + public void revokeSTSToken(String accessKeyId, String sessionToken) throws IOException { + proxy.revokeSTSToken(accessKeyId, sessionToken); + } + /** * An Iterator to iterate over {@link SnapshotDiffJobIterator} list. */ diff --git a/hadoop-ozone/client/src/main/java/org/apache/hadoop/ozone/client/protocol/ClientProtocol.java b/hadoop-ozone/client/src/main/java/org/apache/hadoop/ozone/client/protocol/ClientProtocol.java index d4cc1d1fb512..0067407aff33 100644 --- a/hadoop-ozone/client/src/main/java/org/apache/hadoop/ozone/client/protocol/ClientProtocol.java +++ b/hadoop-ozone/client/src/main/java/org/apache/hadoop/ozone/client/protocol/ClientProtocol.java @@ -1372,4 +1372,12 @@ void deleteObjectTagging(String volumeName, String bucketName, String keyName) */ AssumeRoleResponseInfo assumeRole(String roleArn, String roleSessionName, int durationSeconds, String awsIamSessionPolicy) throws IOException; + + /** + * Revokes an STS token. + * @param accessKeyId The STS accessKeyId (starting with ASIA...) + * @param sessionToken The STS session token + * @throws IOException if an error occurs while revoking the STS token + */ + void revokeSTSToken(String accessKeyId, String sessionToken) throws IOException; } diff --git a/hadoop-ozone/client/src/main/java/org/apache/hadoop/ozone/client/rpc/RpcClient.java b/hadoop-ozone/client/src/main/java/org/apache/hadoop/ozone/client/rpc/RpcClient.java index 5c3b8eb4793f..791a159f01be 100644 --- a/hadoop-ozone/client/src/main/java/org/apache/hadoop/ozone/client/rpc/RpcClient.java +++ b/hadoop-ozone/client/src/main/java/org/apache/hadoop/ozone/client/rpc/RpcClient.java @@ -2797,6 +2797,11 @@ public AssumeRoleResponseInfo assumeRole(String roleArn, String roleSessionName, return ozoneManagerClient.assumeRole(roleArn, roleSessionName, durationSeconds, awsIamSessionPolicy); } + @Override + public void revokeSTSToken(String accessKeyId, String sessionToken) throws IOException { + ozoneManagerClient.revokeSTSToken(accessKeyId, sessionToken); + } + private static ExecutorService createThreadPoolExecutor( int corePoolSize, int maximumPoolSize, String threadNameFormat) { return new ThreadPoolExecutor(corePoolSize, maximumPoolSize, diff --git a/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/OmUtils.java b/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/OmUtils.java index be1c422711ae..dd70a9056f96 100644 --- a/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/OmUtils.java +++ b/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/OmUtils.java @@ -321,6 +321,7 @@ public static boolean isReadOnly( case DeleteOpenKeys: case SetS3Secret: case RevokeS3Secret: + case RevokeSTSToken: case PurgeDirectories: case PurgePaths: case CreateTenant: diff --git a/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/om/protocol/OzoneManagerProtocol.java b/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/om/protocol/OzoneManagerProtocol.java index 4261f71c4e5f..f98196d7276e 100644 --- a/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/om/protocol/OzoneManagerProtocol.java +++ b/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/om/protocol/OzoneManagerProtocol.java @@ -1191,4 +1191,14 @@ default AssumeRoleResponseInfo assumeRole(String roleArn, String roleSessionName String awsIamSessionPolicy) throws IOException { throw new UnsupportedOperationException("OzoneManager does not require this to be implemented"); } + + /** + * Revokes an STS token. + * @param accessKeyId The STS accessKeyId (starting with ASIA...) + * @param sessionToken The STS session token + * @throws IOException if an error occurs while revoking the STS token + */ + default void revokeSTSToken(String accessKeyId, String sessionToken) throws IOException { + throw new UnsupportedOperationException("OzoneManager does not require this to be implemented"); + } } diff --git a/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/om/protocolPB/OzoneManagerProtocolClientSideTranslatorPB.java b/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/om/protocolPB/OzoneManagerProtocolClientSideTranslatorPB.java index 36adbe7b37fa..105d353a4637 100644 --- a/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/om/protocolPB/OzoneManagerProtocolClientSideTranslatorPB.java +++ b/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/om/protocolPB/OzoneManagerProtocolClientSideTranslatorPB.java @@ -2675,6 +2675,21 @@ public AssumeRoleResponseInfo assumeRole(String roleArn, String roleSessionName, handleError(submitRequest(omRequest)).getAssumeRoleResponse()); } + @Override + public void revokeSTSToken(String accessKeyId, String sessionToken) throws IOException { + final OzoneManagerProtocolProtos.RevokeSTSTokenRequest request = + OzoneManagerProtocolProtos.RevokeSTSTokenRequest.newBuilder() + .setAccessKeyId(accessKeyId) + .setSessionToken(sessionToken) + .build(); + + final OMRequest omRequest = createOMRequest(Type.RevokeSTSToken) + .setRevokeSTSTokenRequest(request) + .build(); + + handleError(submitRequest(omRequest)); + } + private SafeMode toProtoBuf(SafeModeAction action) { switch (action) { case ENTER: diff --git a/hadoop-ozone/interface-client/src/main/proto/OmClientProtocol.proto b/hadoop-ozone/interface-client/src/main/proto/OmClientProtocol.proto index 8e455e703422..6e36be5ca48d 100644 --- a/hadoop-ozone/interface-client/src/main/proto/OmClientProtocol.proto +++ b/hadoop-ozone/interface-client/src/main/proto/OmClientProtocol.proto @@ -157,6 +157,7 @@ enum Type { GetObjectTagging = 141; DeleteObjectTagging = 142; AssumeRole = 143; + RevokeSTSToken = 144; } enum SafeMode { @@ -306,6 +307,7 @@ message OMRequest { optional DeleteObjectTaggingRequest deleteObjectTaggingRequest = 142; repeated SetSnapshotPropertyRequest SetSnapshotPropertyRequests = 143; optional AssumeRoleRequest assumeRoleRequest = 144; + optional RevokeSTSTokenRequest revokeSTSTokenRequest = 145; } message OMResponse { @@ -440,6 +442,7 @@ message OMResponse { optional PutObjectTaggingResponse putObjectTaggingResponse = 141; optional DeleteObjectTaggingResponse deleteObjectTaggingResponse = 142; optional AssumeRoleResponse assumeRoleResponse = 143; + optional RevokeSTSTokenResponse revokeSTSTokenResponse = 144; } enum Status { @@ -2381,6 +2384,14 @@ message AssumeRoleResponse { required string assumedRoleId = 5; } +message RevokeSTSTokenRequest { + required string accessKeyId = 1; + required string sessionToken = 2; +} + +message RevokeSTSTokenResponse { +} + /** The OM service that takes care of Ozone namespace. */ diff --git a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/audit/OMAction.java b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/audit/OMAction.java index 9be2bdea709f..f07c4494619a 100644 --- a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/audit/OMAction.java +++ b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/audit/OMAction.java @@ -83,7 +83,9 @@ public enum OMAction implements AuditAction { SET_S3_SECRET, REVOKE_S3_SECRET, + // STS Actions S3_ASSUME_ROLE, + REVOKE_STS_TOKEN, CREATE_TENANT, DELETE_TENANT, 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 5548be7bd8ba..706e00f9537e 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 @@ -69,6 +69,7 @@ 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.S3GetSecretRequest; +import org.apache.hadoop.ozone.om.request.s3.security.S3RevokeSTSTokenRequest; import org.apache.hadoop.ozone.om.request.s3.security.S3RevokeSecretRequest; import org.apache.hadoop.ozone.om.request.s3.tenant.OMSetRangerServiceVersionRequest; import org.apache.hadoop.ozone.om.request.s3.tenant.OMTenantAssignAdminRequest; @@ -196,6 +197,8 @@ public static OMClientRequest createClientRequest(OMRequest omRequest, return new OMSetSecretRequest(omRequest); case RevokeS3Secret: return new S3RevokeSecretRequest(omRequest); + case RevokeSTSToken: + return new S3RevokeSTSTokenRequest(omRequest); case PurgeKeys: return new OMKeyPurgeRequest(omRequest); case PurgeDirectories: diff --git a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/s3/security/S3RevokeSTSTokenRequest.java b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/s3/security/S3RevokeSTSTokenRequest.java new file mode 100644 index 000000000000..ff7a3831d0d6 --- /dev/null +++ b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/s3/security/S3RevokeSTSTokenRequest.java @@ -0,0 +1,122 @@ +/* + * 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 java.io.IOException; +import java.time.Clock; +import java.time.ZoneOffset; +import java.util.HashMap; +import java.util.Map; +import org.apache.hadoop.ozone.OzoneConsts; +import org.apache.hadoop.ozone.audit.OMAction; +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.S3RevokeSTSTokenResponse; +import org.apache.hadoop.ozone.protocol.proto.OzoneManagerProtocolProtos; +import org.apache.hadoop.ozone.protocol.proto.OzoneManagerProtocolProtos.OMRequest; +import org.apache.hadoop.ozone.protocol.proto.OzoneManagerProtocolProtos.OMResponse; +import org.apache.hadoop.ozone.security.STSSecurityUtil; +import org.apache.hadoop.ozone.security.STSTokenIdentifier; +import org.apache.hadoop.security.UserGroupInformation; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Handles S3RevokeSTSTokenRequest request. + * + *

This request marks an STS temporary access key id as revoked by inserting + * it into the {@code s3RevokedStsTokenTable}. Subsequent S3 requests + * authenticated with the same STS access key id will be rejected when the + * revocation state has propagated.

+ */ +public class S3RevokeSTSTokenRequest extends OMClientRequest { + + private static final Logger LOG = LoggerFactory.getLogger(S3RevokeSTSTokenRequest.class); + private static final Clock CLOCK = Clock.system(ZoneOffset.UTC); + + private String originalAccessKeyId; + + public S3RevokeSTSTokenRequest(OMRequest omRequest) { + super(omRequest); + } + + @Override + public OMRequest preExecute(OzoneManager ozoneManager) throws IOException { + final OzoneManagerProtocolProtos.RevokeSTSTokenRequest revokeReq = + getOmRequest().getRevokeSTSTokenRequest(); + + // Get the original (long-lived) access key id from the session token + // and enforce the same permission model that is used for S3 secret + // operations (get/set/revoke). Only the owner of the original access + // key (or an S3 / tenant admin) is allowed to revoke its temporary + // STS credentials. + final String sessionToken = revokeReq.getSessionToken(); + final String tempAccessKeyId = revokeReq.getAccessKeyId(); + final STSTokenIdentifier stsTokenIdentifier = STSSecurityUtil.constructValidateAndDecryptSTSToken( + sessionToken, ozoneManager.getSecretKeyClient(), CLOCK); + originalAccessKeyId = stsTokenIdentifier.getOriginalAccessKeyId(); + + // Validate that the Access Key ID in the request matches the one in the token + // to prevent users from revoking arbitrary keys using a valid token. + if (!stsTokenIdentifier.getTempAccessKeyId().equals(tempAccessKeyId)) { + throw new OMException("Access Key ID in request does not match the session token", + OMException.ResultCodes.INVALID_REQUEST); + } + + final UserGroupInformation ugi = S3SecretRequestHelper.getOrCreateUgi(originalAccessKeyId); + S3SecretRequestHelper.checkAccessIdSecretOpPermission(ozoneManager, ugi, originalAccessKeyId); + + final OMRequest.Builder omRequest = OMRequest.newBuilder() + .setRevokeSTSTokenRequest(revokeReq) + .setCmdType(getOmRequest().getCmdType()) + .setClientId(getOmRequest().getClientId()) + .setUserInfo(getUserInfo()); + + if (getOmRequest().hasTraceID()) { + omRequest.setTraceID(getOmRequest().getTraceID()); + } + + return omRequest.build(); + } + + @Override + public OMClientResponse validateAndUpdateCache(OzoneManager ozoneManager, ExecutionContext context) { + final OMResponse.Builder omResponse = OmResponseUtil.getOMResponseBuilder(getOmRequest()); + + final OzoneManagerProtocolProtos.RevokeSTSTokenRequest revokeReq = getOmRequest().getRevokeSTSTokenRequest(); + final String accessKeyId = revokeReq.getAccessKeyId(); + final String sessionToken = revokeReq.getSessionToken(); + + // All actual DB mutations are done in the response's addToDBBatch(). + final OMClientResponse omClientResponse = new S3RevokeSTSTokenResponse( + accessKeyId, sessionToken, omResponse.build()); + + // Audit log + final Map auditMap = new HashMap<>(); + auditMap.put(OzoneConsts.S3_REVOKESTSTOKEN_USER, originalAccessKeyId); + markForAudit(ozoneManager.getAuditLogger(), buildAuditMessage( + OMAction.REVOKE_STS_TOKEN, auditMap, null, getOmRequest().getUserInfo())); + + LOG.info("Marked STS temporary access key '{}' as revoked.", accessKeyId); + return omClientResponse; + } +} diff --git a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/response/s3/security/S3RevokeSTSTokenResponse.java b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/response/s3/security/S3RevokeSTSTokenResponse.java new file mode 100644 index 000000000000..523311bbadb8 --- /dev/null +++ b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/response/s3/security/S3RevokeSTSTokenResponse.java @@ -0,0 +1,57 @@ +/* + * 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.apache.hadoop.ozone.om.codec.OMDBDefinition.S3_REVOKED_STS_TOKEN_TABLE; +import static org.apache.hadoop.ozone.protocol.proto.OzoneManagerProtocolProtos.Status.OK; + +import jakarta.annotation.Nonnull; +import java.io.IOException; +import org.apache.hadoop.hdds.utils.db.BatchOperation; +import org.apache.hadoop.hdds.utils.db.Table; +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 RevokeSTSToken request. + */ +@CleanupTableInfo(cleanupTables = {S3_REVOKED_STS_TOKEN_TABLE}) +public class S3RevokeSTSTokenResponse extends OMClientResponse { + + private final String accessKeyId; + private final String sessionToken; + + public S3RevokeSTSTokenResponse(String accessKeyId, String sessionToken, @Nonnull OMResponse omResponse) { + super(omResponse); + this.accessKeyId = accessKeyId; + this.sessionToken = sessionToken; + } + + @Override + public void addToDBBatch(OMMetadataManager omMetadataManager, BatchOperation batchOperation) throws IOException { + if (accessKeyId != null && getOMResponse().hasStatus() && getOMResponse().getStatus() == OK) { + final Table table = omMetadataManager.getS3RevokedStsTokenTable(); + if (table != null) { + // Store sessionToken as value + table.putWithBatch(batchOperation, accessKeyId, sessionToken); + } + } + } +} diff --git a/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/om/request/s3/security/TestS3RevokeSTSTokenRequest.java b/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/om/request/s3/security/TestS3RevokeSTSTokenRequest.java new file mode 100644 index 000000000000..9a68c047f008 --- /dev/null +++ b/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/om/request/s3/security/TestS3RevokeSTSTokenRequest.java @@ -0,0 +1,353 @@ +/* + * 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.apache.hadoop.security.authentication.util.KerberosName.DEFAULT_MECHANISM; +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.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.io.IOException; +import java.util.Optional; +import java.util.UUID; +import org.apache.hadoop.hdds.security.symmetric.SecretKeyClient; +import org.apache.hadoop.ipc.ExternalCall; +import org.apache.hadoop.ipc.Server; +import org.apache.hadoop.ozone.om.OMMultiTenantManager; +import org.apache.hadoop.ozone.om.OzoneManager; +import org.apache.hadoop.ozone.om.exceptions.OMException; +import org.apache.hadoop.ozone.om.request.OMClientRequest; +import org.apache.hadoop.ozone.protocol.proto.OzoneManagerProtocolProtos; +import org.apache.hadoop.ozone.protocol.proto.OzoneManagerProtocolProtos.OMRequest; +import org.apache.hadoop.ozone.protocol.proto.OzoneManagerProtocolProtos.Type; +import org.apache.hadoop.ozone.security.STSTokenSecretManager; +import org.apache.hadoop.ozone.security.SecretKeyTestClient; +import org.apache.hadoop.security.UserGroupInformation; +import org.apache.hadoop.security.authentication.util.KerberosName; +import org.apache.ozone.test.TestClock; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +/** + * Tests for {@link S3RevokeSTSTokenRequest}. + */ +public class TestS3RevokeSTSTokenRequest { + + private static final TestClock CLOCK = TestClock.newInstance(); + + private STSTokenSecretManager stsTokenSecretManager; + private SecretKeyClient secretKeyClient; + private OMMultiTenantManager omMultiTenantManager; + + @BeforeEach + public void setUp() throws Exception { + // Initialize KerberosName rules so that UGI short names derived from + // principals like "alice@EXAMPLE.COM" are computed correctly. + KerberosName.setRuleMechanism(DEFAULT_MECHANISM); + KerberosName.setRules( + "RULE:[2:$1@$0](.*@EXAMPLE.COM)s/@.*//\n" + "RULE:[1:$1@$0](.*@EXAMPLE.COM)s/@.*//\n" + "DEFAULT"); + + secretKeyClient = new SecretKeyTestClient(); + stsTokenSecretManager = new STSTokenSecretManager(secretKeyClient); + // Multi-tenant manager mock used for tests that exercise the S3 multi-tenancy permission branch. + omMultiTenantManager = mock(OMMultiTenantManager.class); + } + + @AfterEach + public void tearDown() { + Server.getCurCall().remove(); + } + + @Test + public void testPreExecuteFailsForNonOwnerOfOriginalAccessKey() throws Exception { + // Verify that preExecute enforces permissions based on the original access key id encoded in the STS token + // and rejects revocation attempts from non-owners. + final String tempAccessKeyId = "ASIA12345678"; + final String originalAccessKeyId = "original-access-key-id"; + final String sessionToken = createSessionToken(tempAccessKeyId, originalAccessKeyId); + + // An RPC call running another Kerberos identity should NOT be allowed to revoke the token whose original + // access key id is different. + final UserGroupInformation tempUgi = UserGroupInformation.createRemoteUser("another-kerberos-identity"); + Server.getCurCall().set(new StubCall(tempUgi)); + + OMException ex; + try (OzoneManager ozoneManager = mock(OzoneManager.class)) { + when(ozoneManager.isS3MultiTenancyEnabled()).thenReturn(false); + when(ozoneManager.isS3Admin(any(UserGroupInformation.class))) + .thenReturn(false); + when(ozoneManager.getSecretKeyClient()).thenReturn(secretKeyClient); + + final OzoneManagerProtocolProtos.RevokeSTSTokenRequest revokeRequest = + OzoneManagerProtocolProtos.RevokeSTSTokenRequest.newBuilder() + .setAccessKeyId(tempAccessKeyId) + .setSessionToken(sessionToken) + .build(); + + final OMRequest omRequest = OMRequest.newBuilder() + .setClientId(UUID.randomUUID().toString()) + .setCmdType(Type.RevokeSTSToken) + .setRevokeSTSTokenRequest(revokeRequest) + .build(); + + final OMClientRequest omClientRequest = new S3RevokeSTSTokenRequest(omRequest); + + ex = assertThrows(OMException.class, () -> omClientRequest.preExecute(ozoneManager)); + } + assertEquals(OMException.ResultCodes.USER_MISMATCH, ex.getResult()); + } + + @Test + public void testPreExecuteSucceedsForOriginalAccessKeyOwner() throws Exception { + // Verify that preExecute allows the owner of the original access key id (as encoded in the STS token) + // to revoke the temporary credentials. + final String tempAccessKeyId = "ASIA4567891230"; + final String originalAccessKeyId = "original-access-key-id"; + final String sessionToken = createSessionToken(tempAccessKeyId, originalAccessKeyId); + + // Simulate RPC call running as originalAccessKeyId + final UserGroupInformation originalUgi = UserGroupInformation.createRemoteUser(originalAccessKeyId); + Server.getCurCall().set(new StubCall(originalUgi)); + + final OzoneManager ozoneManager = mock(OzoneManager.class); + when(ozoneManager.isS3MultiTenancyEnabled()).thenReturn(false); + when(ozoneManager.isS3Admin(any(UserGroupInformation.class))) + .thenReturn(false); + when(ozoneManager.getSecretKeyClient()).thenReturn(secretKeyClient); + + final OzoneManagerProtocolProtos.RevokeSTSTokenRequest revokeRequest = + OzoneManagerProtocolProtos.RevokeSTSTokenRequest.newBuilder() + .setAccessKeyId(tempAccessKeyId) + .setSessionToken(sessionToken) + .build(); + + final OMRequest omRequest = OMRequest.newBuilder() + .setClientId(UUID.randomUUID().toString()) + .setCmdType(Type.RevokeSTSToken) + .setRevokeSTSTokenRequest(revokeRequest) + .build(); + + final OMClientRequest omClientRequest = new S3RevokeSTSTokenRequest(omRequest); + final OMRequest result = omClientRequest.preExecute(ozoneManager); + assertEquals(Type.RevokeSTSToken, result.getCmdType()); + } + + @Test + public void testPreExecuteSucceedsForTenantAccessIdOwner() throws Exception { + // When S3 multi-tenancy is enabled and the original access key id is assigned to a tenant, verify that + // the tenant access ID owner is allowed to revoke the temporary credentials. + final String tenantId = "finance"; + final String originalAccessKeyId = "alice@EXAMPLE.COM"; + final String tempAccessKeyId = "ASIA123456789"; + final String sessionToken = createSessionToken(tempAccessKeyId, originalAccessKeyId); + + // Caller short name "alice" should match the owner username returned from the multi-tenant manager. + final UserGroupInformation callerUgi = UserGroupInformation.createRemoteUser(originalAccessKeyId); + Server.getCurCall().set(new StubCall(callerUgi)); + + final OzoneManager ozoneManager = mock(OzoneManager.class); + when(ozoneManager.isS3MultiTenancyEnabled()).thenReturn(true); + when(ozoneManager.getMultiTenantManager()).thenReturn(omMultiTenantManager); + when(ozoneManager.getSecretKeyClient()).thenReturn(secretKeyClient); + + // Original access key id is assigned to a tenant and owned by "alice". + when(omMultiTenantManager.getTenantForAccessID(originalAccessKeyId)) + .thenReturn(Optional.of(tenantId)); + when(omMultiTenantManager.getUserNameGivenAccessId(originalAccessKeyId)) + .thenReturn("alice"); + // Not a tenant admin; ownership should be sufficient. + when(omMultiTenantManager.isTenantAdmin(callerUgi, tenantId, false)) + .thenReturn(false); + + final OzoneManagerProtocolProtos.RevokeSTSTokenRequest revokeRequest = + OzoneManagerProtocolProtos.RevokeSTSTokenRequest.newBuilder() + .setAccessKeyId(tempAccessKeyId) + .setSessionToken(sessionToken) + .build(); + + final OMRequest omRequest = OMRequest.newBuilder() + .setClientId(UUID.randomUUID().toString()) + .setCmdType(Type.RevokeSTSToken) + .setRevokeSTSTokenRequest(revokeRequest) + .build(); + + final OMClientRequest omClientRequest = new S3RevokeSTSTokenRequest(omRequest); + + final OMRequest result = omClientRequest.preExecute(ozoneManager); + assertEquals(Type.RevokeSTSToken, result.getCmdType()); + } + + @Test + public void testPreExecuteSucceedsForTenantAdmin() throws Exception { + // When S3 multi-tenancy is enabled and the original access key id is assigned to a tenant, verify that a + // tenant admin (who is not the owner) is allowed to revoke the temporary credentials. + final String tenantId = "finance"; + final String originalAccessKeyId = "alice@EXAMPLE.COM"; + final String tempAccessKeyId = "ASIA4567890123"; + final String sessionToken = createSessionToken(tempAccessKeyId, originalAccessKeyId); + + // Caller short name "bob" does not own the access ID but will be configured as tenant admin. + final UserGroupInformation callerUgi = UserGroupInformation.createRemoteUser("bob@EXAMPLE.COM"); + Server.getCurCall().set(new StubCall(callerUgi)); + + final OzoneManager ozoneManager = mock(OzoneManager.class); + when(ozoneManager.isS3MultiTenancyEnabled()).thenReturn(true); + when(ozoneManager.getMultiTenantManager()).thenReturn(omMultiTenantManager); + when(ozoneManager.getSecretKeyClient()).thenReturn(secretKeyClient); + + // Original access key id is assigned to a tenant and owned by "alice". + when(omMultiTenantManager.getTenantForAccessID(originalAccessKeyId)) + .thenReturn(Optional.of(tenantId)); + when(omMultiTenantManager.getUserNameGivenAccessId(originalAccessKeyId)) + .thenReturn("alice"); + // Caller is configured as tenant admin so the check should pass. + when(omMultiTenantManager.isTenantAdmin(callerUgi, tenantId, false)) + .thenReturn(true); + + final OzoneManagerProtocolProtos.RevokeSTSTokenRequest revokeRequest = + OzoneManagerProtocolProtos.RevokeSTSTokenRequest.newBuilder() + .setAccessKeyId(tempAccessKeyId) + .setSessionToken(sessionToken) + .build(); + + final OMRequest omRequest = OMRequest.newBuilder() + .setClientId(UUID.randomUUID().toString()) + .setCmdType(Type.RevokeSTSToken) + .setRevokeSTSTokenRequest(revokeRequest) + .build(); + + final OMClientRequest omClientRequest = new S3RevokeSTSTokenRequest(omRequest); + + final OMRequest result = omClientRequest.preExecute(ozoneManager); + assertEquals(Type.RevokeSTSToken, result.getCmdType()); + } + + @Test + public void testPreExecuteFailsForNonOwnerNonAdminInTenant() throws Exception { + // When S3 multi-tenancy is enabled and the original access key id is assigned to a tenant, verify that a + // non-owner, non-admin caller is rejected. + final String tenantId = "finance"; + final String originalAccessKeyId = "alice@EXAMPLE.COM"; + final String tempAccessKeyId = "ASIA123456789"; + final String sessionToken = createSessionToken(tempAccessKeyId, originalAccessKeyId); + + // Caller short name "carol" does not own the access ID and is not + // configured as tenant admin. + final UserGroupInformation callerUgi = UserGroupInformation.createRemoteUser("carol@EXAMPLE.COM"); + Server.getCurCall().set(new StubCall(callerUgi)); + + final OMException ex; + try (OzoneManager ozoneManager = mock(OzoneManager.class)) { + when(ozoneManager.isS3MultiTenancyEnabled()).thenReturn(true); + when(ozoneManager.getMultiTenantManager()).thenReturn(omMultiTenantManager); + when(ozoneManager.getSecretKeyClient()).thenReturn(secretKeyClient); + + // Original access key id is assigned to a tenant and owned by "alice". + when(omMultiTenantManager.getTenantForAccessID(originalAccessKeyId)) + .thenReturn(Optional.of(tenantId)); + when(omMultiTenantManager.getUserNameGivenAccessId(originalAccessKeyId)) + .thenReturn("alice"); + // Caller is not a tenant admin. + when(omMultiTenantManager.isTenantAdmin(callerUgi, tenantId, false)) + .thenReturn(false); + + final OzoneManagerProtocolProtos.RevokeSTSTokenRequest revokeRequest = + OzoneManagerProtocolProtos.RevokeSTSTokenRequest.newBuilder() + .setAccessKeyId(tempAccessKeyId) + .setSessionToken(sessionToken) + .build(); + + final OMRequest omRequest = OMRequest.newBuilder() + .setClientId(UUID.randomUUID().toString()) + .setCmdType(Type.RevokeSTSToken) + .setRevokeSTSTokenRequest(revokeRequest) + .build(); + + final OMClientRequest omClientRequest = new S3RevokeSTSTokenRequest(omRequest); + + ex = assertThrows(OMException.class, () -> omClientRequest.preExecute(ozoneManager)); + } + assertEquals(OMException.ResultCodes.USER_MISMATCH, ex.getResult()); + } + + @Test + public void testPreExecuteFailsForMismatchedAccessKeyId() throws Exception { + // Verify that if the request access key id does not match the one inside the session token, the request is + // rejected. This prevents a user with a valid session token from revoking arbitrary STS credentials. + final String tempAccessKeyId = "ASIA123456789"; + final String otherAccessKeyId = "ASI987654321"; + final String originalAccessKeyId = "original-access-key-id"; + final String sessionToken = createSessionToken(tempAccessKeyId, originalAccessKeyId); + + // Caller is the owner of the session token, so permissions should pass + final UserGroupInformation originalUgi = UserGroupInformation.createRemoteUser(originalAccessKeyId); + Server.getCurCall().set(new StubCall(originalUgi)); + + final OMException ex; + try (OzoneManager ozoneManager = mock(OzoneManager.class)) { + when(ozoneManager.isS3MultiTenancyEnabled()).thenReturn(false); + when(ozoneManager.isS3Admin(any(UserGroupInformation.class))) + .thenReturn(false); + when(ozoneManager.getSecretKeyClient()).thenReturn(secretKeyClient); + + // Request tries to revoke otherAccessKeyId using a token for tempAccessKeyId + final OzoneManagerProtocolProtos.RevokeSTSTokenRequest revokeRequest = + OzoneManagerProtocolProtos.RevokeSTSTokenRequest.newBuilder() + .setAccessKeyId(otherAccessKeyId) + .setSessionToken(sessionToken) + .build(); + + final OMRequest omRequest = OMRequest.newBuilder() + .setClientId(UUID.randomUUID().toString()) + .setCmdType(Type.RevokeSTSToken) + .setRevokeSTSTokenRequest(revokeRequest) + .build(); + + final OMClientRequest omClientRequest = new S3RevokeSTSTokenRequest(omRequest); + + ex = assertThrows(OMException.class, () -> omClientRequest.preExecute(ozoneManager)); + } + assertEquals(OMException.ResultCodes.INVALID_REQUEST, ex.getResult()); + } + + /** + * Stub used to inject a remote user into the ProtobufRpcEngine.Server.getRemoteUser() thread-local. + */ + private static final class StubCall extends ExternalCall { + private final UserGroupInformation ugi; + + StubCall(UserGroupInformation ugi) { + super(null); + this.ugi = ugi; + } + + @Override + public UserGroupInformation getRemoteUser() { + return ugi; + } + } + + private String createSessionToken(String tempAccessKeyId, String originalAccessKeyId) throws IOException { + return stsTokenSecretManager.createSTSTokenString( + tempAccessKeyId, originalAccessKeyId, "arn:aws:iam::123456789012:role/test-role", 3600, + "test-secret-access-key", "test-session-policy", CLOCK); + } +} diff --git a/hadoop-ozone/s3gateway/src/test/java/org/apache/hadoop/ozone/client/ClientProtocolStub.java b/hadoop-ozone/s3gateway/src/test/java/org/apache/hadoop/ozone/client/ClientProtocolStub.java index ef0d32e23874..b56c6ca3fe8a 100644 --- a/hadoop-ozone/s3gateway/src/test/java/org/apache/hadoop/ozone/client/ClientProtocolStub.java +++ b/hadoop-ozone/s3gateway/src/test/java/org/apache/hadoop/ozone/client/ClientProtocolStub.java @@ -814,4 +814,7 @@ public AssumeRoleResponseInfo assumeRole( return null; } + @Override + public void revokeSTSToken(String accessKeyId, String sessionToken) throws IOException { + } } From 2062a06942e14e1093b0b78b5de022e266b88d35 Mon Sep 17 00:00:00 2001 From: fmorg-git Date: Mon, 8 Dec 2025 23:51:41 -0800 Subject: [PATCH 13/54] HDDS-13942. [STS] Part 4 - Create utility to convert IAM policy to groupings of OzoneObj and Acls (#9315) --- .../acl/iam/IamSessionPolicyResolver.java | 72 +- .../acl/iam/TestIamSessionPolicyResolver.java | 1177 +++++++++++++++-- 2 files changed, 1144 insertions(+), 105 deletions(-) diff --git a/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/security/acl/iam/IamSessionPolicyResolver.java b/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/security/acl/iam/IamSessionPolicyResolver.java index 7e10d566b591..b90bb43c1935 100644 --- a/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/security/acl/iam/IamSessionPolicyResolver.java +++ b/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/security/acl/iam/IamSessionPolicyResolver.java @@ -126,7 +126,9 @@ public static Set resolve(String policyJson, Strin validateInputParameters(policyJson, volumeName, authorizerType); - final Set result = new LinkedHashSet<>(); + // Accumulate ACLs across ALL statements using a single map to allow + // cross-statement deduplication and ALL-permission collapsing. + final Map> objToAclsMap = new LinkedHashMap<>(); // Parse JSON into set of statements final Set statements = parseJsonAndRetrieveStatements(policyJson); @@ -151,13 +153,11 @@ public static Set resolve(String policyJson, Strin final Set resourceSpecs = validateAndCategorizeResources(authorizerType, resources); // For each action, map to Ozone objects (paths) and acls based on resource specs and prefixes - final Set stmtResults = createPathsAndPermissions( - volumeName, authorizerType, mappedS3Actions, resourceSpecs, prefixes); - - result.addAll(stmtResults); + createPathsAndPermissions(volumeName, authorizerType, mappedS3Actions, resourceSpecs, prefixes, objToAclsMap); } - return result; + // Group accumulated objects by their ACL sets to create final result + return groupObjectsByAcls(objToAclsMap); } /** @@ -418,24 +418,19 @@ static Set validateAndCategorizeResources(AuthorizerType authorize * entries pairing sets of IOzoneObjs with the requisite permissions granted (if any). */ @VisibleForTesting - static Set createPathsAndPermissions(String volumeName, AuthorizerType authorizerType, - Set mappedS3Actions, Set resourceSpecs, Set prefixes) { - // Create map to collect IOzoneObj to ACLType mappings - final Map> objToAclsMap = new LinkedHashMap<>(); - + static void createPathsAndPermissions(String volumeName, AuthorizerType authorizerType, Set mappedS3Actions, + Set resourceSpecs, Set prefixes, Map> objToAclsMap) { // Process each resource spec with the given actions for (ResourceSpec resourceSpec : resourceSpecs) { processResourceSpecWithActions(volumeName, authorizerType, mappedS3Actions, resourceSpec, prefixes, objToAclsMap); } - - // Group objects by their ACL sets to create proper entries - return groupObjectsByAcls(objToAclsMap); } /** * Groups objects by their ACL sets. */ - private static Set groupObjectsByAcls(Map> objToAclsMap) { + @VisibleForTesting + static Set groupObjectsByAcls(Map> objToAclsMap) { final Map, Set> groupMap = new LinkedHashMap<>(); // Group objects by their ACL sets only (across resource types) @@ -526,24 +521,34 @@ private static void processBucketResource(String volumeName, Set mappe // bucket name of "*". To align with AWS, make sure that in this // specific case we also grant the volume-level permissions for volume-scoped // actions (currently s3:ListAllMyBuckets). - if (action.kind == ActionKind.BUCKET || action == S3Action.ALL_S3 || - action.kind == ActionKind.VOLUME && "*".equals(resourceSpec.bucket)) { // this handles s3:ListAllMyBuckets + if (action.kind == ActionKind.BUCKET || + (action.kind == ActionKind.VOLUME && "*".equals(resourceSpec.bucket))) { // this handles s3:ListAllMyBuckets addAclsForObj(objToAclsMap, volumeObj(volumeName), action.volumePerms); addAclsForObj(objToAclsMap, bucketObj(volumeName, resourceSpec.bucket), action.bucketPerms); + } else if (action == S3Action.ALL_S3) { + // For s3:*, ALL should only apply at the bucket level; grant READ at volume for navigation + // However, resource "arn:aws:s3:::*" can apply to volume as well (as explained above) + // If the bucket is "*", include the volumePerms, otherwise just include READ for navigation. + if ("*".equals(resourceSpec.bucket)) { + addAclsForObj(objToAclsMap, volumeObj(volumeName), action.volumePerms); + } else { + addAclsForObj(objToAclsMap, volumeObj(volumeName), EnumSet.of(READ)); + } + addAclsForObj(objToAclsMap, bucketObj(volumeName, resourceSpec.bucket), action.bucketPerms); } - if (action == S3Action.LIST_BUCKET) { + if (action == S3Action.LIST_BUCKET || action == S3Action.ALL_S3) { // If condition prefixes are present, these would constrain the object permissions if the action - // is s3:ListBucket + // is s3:ListBucket or s3:* (which includes s3:ListBucket) if (prefixes != null && !prefixes.isEmpty()) { for (String prefix : prefixes) { createObjectResourcesFromConditionPrefix( - volumeName, authorizerType, resourceSpec, prefix, objToAclsMap, action.objectPerms); + volumeName, authorizerType, resourceSpec, prefix, objToAclsMap, EnumSet.of(READ)); } } else { // No condition prefixes, but we need READ access to all objects, so use "*" as the prefix createObjectResourcesFromConditionPrefix( - volumeName, authorizerType, resourceSpec, "*", objToAclsMap, action.objectPerms); + volumeName, authorizerType, resourceSpec, "*", objToAclsMap, EnumSet.of(READ)); } } } @@ -556,11 +561,12 @@ private static void processBucketResource(String volumeName, Set mappe private static void processObjectExactResource(String volumeName, Set mappedS3Actions, ResourceSpec resourceSpec, Map> objToAclsMap) { for (S3Action action : mappedS3Actions) { - addAclsForObj(objToAclsMap, volumeObj(volumeName), action.volumePerms); if (action.kind == ActionKind.OBJECT) { + addAclsForObj(objToAclsMap, volumeObj(volumeName), action.volumePerms); addAclsForObj(objToAclsMap, bucketObj(volumeName, resourceSpec.bucket), action.bucketPerms); addAclsForObj(objToAclsMap, keyObj(volumeName, resourceSpec.bucket, resourceSpec.key), action.objectPerms); } else if (action == S3Action.ALL_S3) { + addAclsForObj(objToAclsMap, volumeObj(volumeName), EnumSet.of(READ)); // For s3:*, ALL should only apply at the object level; grant READ at bucket level for navigation addAclsForObj(objToAclsMap, bucketObj(volumeName, resourceSpec.bucket), EnumSet.of(READ)); addAclsForObj(objToAclsMap, keyObj(volumeName, resourceSpec.bucket, resourceSpec.key), action.objectPerms); @@ -577,10 +583,11 @@ private static void processObjectPrefixResource(String volumeName, AuthorizerTyp Set mappedS3Actions, ResourceSpec resourceSpec, Map> objToAclsMap) { for (S3Action action : mappedS3Actions) { // Object actions apply to prefix/key resources - addAclsForObj(objToAclsMap, volumeObj(volumeName), action.volumePerms); if (action.kind == ActionKind.OBJECT) { + addAclsForObj(objToAclsMap, volumeObj(volumeName), action.volumePerms); addAclsForObj(objToAclsMap, bucketObj(volumeName, resourceSpec.bucket), action.bucketPerms); } else if (action == S3Action.ALL_S3) { + addAclsForObj(objToAclsMap, volumeObj(volumeName), EnumSet.of(READ)); // For s3:*, ALL should only apply at the object/prefix level; grant READ at bucket level for navigation addAclsForObj(objToAclsMap, bucketObj(volumeName, resourceSpec.bucket), EnumSet.of(READ)); } @@ -633,11 +640,26 @@ private static void createObjectResourcesFromConditionPrefix(String volumeName, /** * Helper method to add ACLs for an IOzoneObj, merging with existing ACLs if present. + * If ALL permission is present, no other permissions are added. */ private static void addAclsForObj(Map> objToAclsMap, IOzoneObj obj, Set acls) { if (acls != null && !acls.isEmpty()) { final OzoneObj ozoneObj = (OzoneObj) obj; - objToAclsMap.computeIfAbsent(ozoneObj, k -> EnumSet.noneOf(ACLType.class)).addAll(acls); + final Set existingAcls = objToAclsMap.computeIfAbsent(ozoneObj, k -> EnumSet.noneOf(ACLType.class)); + + // If ALL is already present, don't add other permissions + if (existingAcls.contains(ACLType.ALL)) { + return; + } + + // If we're about to add ALL, remove all other permissions first + if (acls.contains(ACLType.ALL)) { + existingAcls.clear(); + existingAcls.add(ACLType.ALL); + } else { + // Only add permissions if ALL is not already present + existingAcls.addAll(acls); + } } } @@ -808,7 +830,7 @@ enum S3Action { EnumSet.of(ACLType.WRITE)), // Wildcard all - ALL_S3("s3:*", ActionKind.ALL, EnumSet.of(READ), EnumSet.of(ACLType.ALL), EnumSet.of(ACLType.ALL)); + ALL_S3("s3:*", ActionKind.ALL, EnumSet.of(READ, LIST), EnumSet.of(ACLType.ALL), EnumSet.of(ACLType.ALL)); private final String name; private final ActionKind kind; diff --git a/hadoop-ozone/common/src/test/java/org/apache/hadoop/ozone/security/acl/iam/TestIamSessionPolicyResolver.java b/hadoop-ozone/common/src/test/java/org/apache/hadoop/ozone/security/acl/iam/TestIamSessionPolicyResolver.java index 9fe5965874c5..b885c5130ec4 100644 --- a/hadoop-ozone/common/src/test/java/org/apache/hadoop/ozone/security/acl/iam/TestIamSessionPolicyResolver.java +++ b/hadoop-ozone/common/src/test/java/org/apache/hadoop/ozone/security/acl/iam/TestIamSessionPolicyResolver.java @@ -17,31 +17,38 @@ package org.apache.hadoop.ozone.security.acl.iam; +import static java.util.Collections.emptySet; import static org.apache.hadoop.ozone.om.exceptions.OMException.ResultCodes.INVALID_REQUEST; import static org.apache.hadoop.ozone.om.exceptions.OMException.ResultCodes.NOT_SUPPORTED_OPERATION; +import static org.apache.hadoop.ozone.security.acl.AssumeRoleRequest.OzoneGrant; import static org.apache.hadoop.ozone.security.acl.IAccessAuthorizer.ACLType; +import static org.apache.hadoop.ozone.security.acl.IAccessAuthorizer.ACLType.ALL; import static org.apache.hadoop.ozone.security.acl.IAccessAuthorizer.ACLType.CREATE; +import static org.apache.hadoop.ozone.security.acl.IAccessAuthorizer.ACLType.DELETE; import static org.apache.hadoop.ozone.security.acl.IAccessAuthorizer.ACLType.LIST; import static org.apache.hadoop.ozone.security.acl.IAccessAuthorizer.ACLType.READ; import static org.apache.hadoop.ozone.security.acl.IAccessAuthorizer.ACLType.READ_ACL; +import static org.apache.hadoop.ozone.security.acl.IAccessAuthorizer.ACLType.WRITE; import static org.apache.hadoop.ozone.security.acl.IAccessAuthorizer.ACLType.WRITE_ACL; import static org.apache.hadoop.ozone.security.acl.iam.IamSessionPolicyResolver.AuthorizerType.NATIVE; import static org.apache.hadoop.ozone.security.acl.iam.IamSessionPolicyResolver.AuthorizerType.RANGER; import static org.apache.hadoop.ozone.security.acl.iam.IamSessionPolicyResolver.S3ResourceType; import static org.apache.hadoop.ozone.security.acl.iam.IamSessionPolicyResolver.buildCaseInsensitiveS3ActionMap; import static org.apache.hadoop.ozone.security.acl.iam.IamSessionPolicyResolver.createPathsAndPermissions; +import static org.apache.hadoop.ozone.security.acl.iam.IamSessionPolicyResolver.groupObjectsByAcls; import static org.apache.hadoop.ozone.security.acl.iam.IamSessionPolicyResolver.mapPolicyActionsToS3Actions; +import static org.apache.hadoop.ozone.security.acl.iam.IamSessionPolicyResolver.resolve; import static org.apache.hadoop.ozone.security.acl.iam.IamSessionPolicyResolver.validateAndCategorizeResources; import static org.assertj.core.api.Assertions.assertThat; import java.util.Collections; +import java.util.LinkedHashMap; import java.util.LinkedHashSet; import java.util.Map; import java.util.Set; import java.util.stream.Collectors; import java.util.stream.Stream; import org.apache.hadoop.ozone.om.exceptions.OMException; -import org.apache.hadoop.ozone.security.acl.AssumeRoleRequest; import org.apache.hadoop.ozone.security.acl.IOzoneObj; import org.apache.hadoop.ozone.security.acl.OzoneObj; import org.apache.hadoop.ozone.security.acl.OzoneObjInfo; @@ -243,8 +250,8 @@ public void testJsonAtMaxLengthSucceeds() throws OMException { assertThat(json.length()).isEqualTo(2048); // Must not throw an exception - IamSessionPolicyResolver.resolve(json, VOLUME, NATIVE); - IamSessionPolicyResolver.resolve(json, VOLUME, RANGER); + resolve(json, VOLUME, NATIVE); + resolve(json, VOLUME, RANGER); } @Test @@ -259,8 +266,8 @@ public void testConditionKeyMustBeCaseInsensitive() throws OMException { "}"; // Must not throw exception - IamSessionPolicyResolver.resolve(json, VOLUME, NATIVE); - IamSessionPolicyResolver.resolve(json, VOLUME, RANGER); + resolve(json, VOLUME, NATIVE); + resolve(json, VOLUME, RANGER); } @Test @@ -286,7 +293,6 @@ public void testBuildCaseInsensitiveS3ActionMapMatchesConstant() { @Test public void testBuildCaseInsensitiveS3ActionMap() { final Map> caseInsensitiveS3ActionMap = buildCaseInsensitiveS3ActionMap(); - // Verify that individual S3 actions are present assertThat(caseInsensitiveS3ActionMap).containsKeys( "s3:listbucket", "s3:getobject", "s3:putobject", "s3:deleteobject", "s3:createbucket", "s3:listallmybuckets"); @@ -341,7 +347,7 @@ public void testMapPolicyActionsToS3ActionsWithNullReturnsEmpty() { @Test public void testMapPolicyActionsToS3ActionsWithEmptyListReturnsEmpty() { - final Set result = mapPolicyActionsToS3Actions(Collections.emptySet()); + final Set result = mapPolicyActionsToS3Actions(emptySet()); assertThat(result).isEmpty(); } @@ -681,11 +687,9 @@ public void testValidateAndCategorizeResourcesWithArnWithNoBucketThrows() { @Test public void testValidateAndCategorizeResourcesWithNoResourcesThrows() { expectOMExceptionWithCode( - () -> validateAndCategorizeResources(NATIVE, Collections.emptySet()), - "No Resource(s) found in policy", INVALID_REQUEST); + () -> validateAndCategorizeResources(NATIVE, emptySet()), "No Resource(s) found in policy", INVALID_REQUEST); expectOMExceptionWithCode( - () -> validateAndCategorizeResources(RANGER, Collections.emptySet()), - "No Resource(s) found in policy", INVALID_REQUEST); + () -> validateAndCategorizeResources(RANGER, emptySet()), "No Resource(s) found in policy", INVALID_REQUEST); } @Test @@ -697,16 +701,17 @@ public void testCreatePathsAndPermissionsWithResourceAny() { new IamSessionPolicyResolver.ResourceSpec(S3ResourceType.ANY, "*", null, null)); expectIllegalArgumentException( - () -> createPathsAndPermissions(VOLUME, NATIVE, actions, resourceSpecs, Collections.emptySet()), + () -> createPathsAndPermissions(VOLUME, NATIVE, actions, resourceSpecs, emptySet(), new LinkedHashMap<>()), "ResourceSpec type ANY not supported for OzoneNativeAuthorizer"); - final Set resultRanger = createPathsAndPermissions( - VOLUME, RANGER, actions, resourceSpecs, Collections.emptySet()); + final Map> objToAclsMapRanger = new LinkedHashMap<>(); + createPathsAndPermissions(VOLUME, RANGER, actions, resourceSpecs, emptySet(), objToAclsMapRanger); + final Set resultRanger = groupObjectsByAcls(objToAclsMapRanger); final Set readAndListObjects = objSet(volume(), bucket("*")); // volume, bucket level have READ, LIST final Set readObject = objSet(key("*", "*")); // key level has READ assertThat(resultRanger).containsExactlyInAnyOrder( - new AssumeRoleRequest.OzoneGrant(readAndListObjects, acls(READ, LIST)), - new AssumeRoleRequest.OzoneGrant(readObject, acls(READ))); + new OzoneGrant(readAndListObjects, acls(READ, LIST)), + new OzoneGrant(readObject, acls(READ))); } @Test @@ -716,19 +721,19 @@ public void testCreatePathsAndPermissionsWithBucketResourceThatIsListBucket() { new IamSessionPolicyResolver.ResourceSpec(S3ResourceType.BUCKET, "bucket1", null, null)); final Set readAndListObject = objSet(bucket("bucket1")); + final Map> objToAclsMapNative = new LinkedHashMap<>(); final Set nativeReadObjects = objSet(volume(), prefix("bucket1", "")); - final Set resultNative = createPathsAndPermissions( - VOLUME, NATIVE, actions, resourceSpecs, Collections.emptySet()); + createPathsAndPermissions(VOLUME, NATIVE, actions, resourceSpecs, emptySet(), objToAclsMapNative); + final Set resultNative = groupObjectsByAcls(objToAclsMapNative); assertThat(resultNative).containsExactlyInAnyOrder( - new AssumeRoleRequest.OzoneGrant(readAndListObject, acls(READ, LIST)), - new AssumeRoleRequest.OzoneGrant(nativeReadObjects, acls(READ))); + new OzoneGrant(readAndListObject, acls(READ, LIST)), new OzoneGrant(nativeReadObjects, acls(READ))); + final Map> objToAclsMapRanger = new LinkedHashMap<>(); final Set rangerReadObjects = objSet(volume(), key("bucket1", "*")); - final Set resultRanger = createPathsAndPermissions( - VOLUME, RANGER, actions, resourceSpecs, Collections.emptySet()); + createPathsAndPermissions(VOLUME, RANGER, actions, resourceSpecs, emptySet(), objToAclsMapRanger); + final Set resultRanger = groupObjectsByAcls(objToAclsMapRanger); assertThat(resultRanger).containsExactlyInAnyOrder( - new AssumeRoleRequest.OzoneGrant(readAndListObject, acls(READ, LIST)), - new AssumeRoleRequest.OzoneGrant(rangerReadObjects, acls(READ))); + new OzoneGrant(readAndListObject, acls(READ, LIST)), new OzoneGrant(rangerReadObjects, acls(READ))); } @Test @@ -739,17 +744,17 @@ public void testCreatePathsAndPermissionsWithBucketResourceThatIsNotListBucket() final Set createObject = objSet(bucket("bucket1")); final Set readObject = objSet(volume()); - final Set resultNative = createPathsAndPermissions( - VOLUME, NATIVE, actions, resourceSpecs, Collections.emptySet()); + final Map> objToAclsMapNative = new LinkedHashMap<>(); + createPathsAndPermissions(VOLUME, NATIVE, actions, resourceSpecs, emptySet(), objToAclsMapNative); + final Set resultNative = groupObjectsByAcls(objToAclsMapNative); assertThat(resultNative).containsExactlyInAnyOrder( - new AssumeRoleRequest.OzoneGrant(createObject, acls(CREATE)), - new AssumeRoleRequest.OzoneGrant(readObject, acls(READ))); + new OzoneGrant(createObject, acls(CREATE)), new OzoneGrant(readObject, acls(READ))); - final Set resultRanger = createPathsAndPermissions( - VOLUME, RANGER, actions, resourceSpecs, Collections.emptySet()); + final Map> objToAclsMapRanger = new LinkedHashMap<>(); + createPathsAndPermissions(VOLUME, RANGER, actions, resourceSpecs, emptySet(), objToAclsMapRanger); + final Set resultRanger = groupObjectsByAcls(objToAclsMapRanger); assertThat(resultRanger).containsExactlyInAnyOrder( - new AssumeRoleRequest.OzoneGrant(createObject, acls(CREATE)), - new AssumeRoleRequest.OzoneGrant(readObject, acls(READ))); + new OzoneGrant(createObject, acls(CREATE)), new OzoneGrant(readObject, acls(READ))); } @Test @@ -761,14 +766,14 @@ public void testCreatePathsAndPermissionsWithBucketWildcardResource() { final Set readVolume = objSet(volume()); expectIllegalArgumentException( - () -> createPathsAndPermissions(VOLUME, NATIVE, actions, resourceSpecs, Collections.emptySet()), + () -> createPathsAndPermissions(VOLUME, NATIVE, actions, resourceSpecs, emptySet(), new LinkedHashMap<>()), "ResourceSpec type BUCKET_WILDCARD not supported for OzoneNativeAuthorizer"); - final Set resultRanger = createPathsAndPermissions( - VOLUME, RANGER, actions, resourceSpecs, Collections.emptySet()); + final Map> objToAclsMapRanger = new LinkedHashMap<>(); + createPathsAndPermissions(VOLUME, RANGER, actions, resourceSpecs, emptySet(), objToAclsMapRanger); + final Set resultRanger = groupObjectsByAcls(objToAclsMapRanger); assertThat(resultRanger).containsExactlyInAnyOrder( - new AssumeRoleRequest.OzoneGrant(writeAclObject, acls(WRITE_ACL)), - new AssumeRoleRequest.OzoneGrant(readVolume, acls(READ))); + new OzoneGrant(writeAclObject, acls(WRITE_ACL)), new OzoneGrant(readVolume, acls(READ))); } @Test @@ -783,19 +788,19 @@ public void testCreatePathsAndPermissionsWithBucketsWildcardResourceAll() { new IamSessionPolicyResolver.ResourceSpec(S3ResourceType.BUCKET_WILDCARD, "*", null, null)); expectIllegalArgumentException( - () -> createPathsAndPermissions(VOLUME, NATIVE, actions, resourceSpecs, Collections.emptySet()), + () -> createPathsAndPermissions(VOLUME, NATIVE, actions, resourceSpecs, emptySet(), new LinkedHashMap<>()), "ResourceSpec type BUCKET_WILDCARD not supported for OzoneNativeAuthorizer"); - final Set resultRanger = createPathsAndPermissions( - VOLUME, RANGER, actions, resourceSpecs, Collections.emptySet()); + final Map> objToAclsMapRanger = new LinkedHashMap<>(); + createPathsAndPermissions(VOLUME, RANGER, actions, resourceSpecs, emptySet(), objToAclsMapRanger); // Both the volume and the wildcard bucket should end up with READ + LIST permissions. // We also need READ access on the keys + final Set resultRanger = groupObjectsByAcls(objToAclsMapRanger); final Set readAndListObjects = objSet(volume(), bucket("*")); final Set readObjects = objSet(key("*", "*")); assertThat(resultRanger).containsExactlyInAnyOrder( - new AssumeRoleRequest.OzoneGrant(readAndListObjects, acls(READ, LIST)), - new AssumeRoleRequest.OzoneGrant(readObjects, acls(READ))); + new OzoneGrant(readAndListObjects, acls(READ, LIST)), new OzoneGrant(readObjects, acls(READ))); } @Test @@ -805,13 +810,15 @@ public void testCreatePathsAndPermissionsWithObjectExactResource() { new IamSessionPolicyResolver.ResourceSpec(S3ResourceType.OBJECT_EXACT, "bucket1", null, "key.txt")); final Set readObjects = objSet(key("bucket1", "key.txt"), bucket("bucket1"), volume()); - final Set resultNative = createPathsAndPermissions( - VOLUME, NATIVE, actions, resourceSpecs, Collections.emptySet()); - assertThat(resultNative).containsExactly(new AssumeRoleRequest.OzoneGrant(readObjects, acls(READ))); + final Map> objToAclsMapNative = new LinkedHashMap<>(); + createPathsAndPermissions(VOLUME, NATIVE, actions, resourceSpecs, emptySet(), objToAclsMapNative); + final Set resultNative = groupObjectsByAcls(objToAclsMapNative); + assertThat(resultNative).containsExactly(new OzoneGrant(readObjects, acls(READ))); - final Set resultRanger = createPathsAndPermissions( - VOLUME, RANGER, actions, resourceSpecs, Collections.emptySet()); - assertThat(resultRanger).containsExactly(new AssumeRoleRequest.OzoneGrant(readObjects, acls(READ))); + final Map> objToAclsMapRanger = new LinkedHashMap<>(); + createPathsAndPermissions(VOLUME, RANGER, actions, resourceSpecs, emptySet(), objToAclsMapRanger); + final Set resultRanger = groupObjectsByAcls(objToAclsMapRanger); + assertThat(resultRanger).containsExactly(new OzoneGrant(readObjects, acls(READ))); } @Test @@ -821,12 +828,13 @@ public void testCreatePathsAndPermissionsWithObjectPrefixResource() { final Set resourceSpecs = Collections.singleton( new IamSessionPolicyResolver.ResourceSpec(S3ResourceType.OBJECT_PREFIX, "bucket1", "prefix/", null)); final Set nativeReadObjects = objSet(prefix("bucket1", "prefix/"), bucket("bucket1"), volume()); - final Set resultNative = createPathsAndPermissions( - VOLUME, NATIVE, actions, resourceSpecs, Collections.emptySet()); - assertThat(resultNative).containsExactly(new AssumeRoleRequest.OzoneGrant(nativeReadObjects, acls(READ))); + final Map> objToAclsMapNative = new LinkedHashMap<>(); + createPathsAndPermissions(VOLUME, NATIVE, actions, resourceSpecs, emptySet(), objToAclsMapNative); + final Set resultNative = groupObjectsByAcls(objToAclsMapNative); + assertThat(resultNative).containsExactly(new OzoneGrant(nativeReadObjects, acls(READ))); expectIllegalArgumentException( - () -> createPathsAndPermissions(VOLUME, RANGER, actions, resourceSpecs, Collections.emptySet()), + () -> createPathsAndPermissions(VOLUME, RANGER, actions, resourceSpecs, emptySet(), new LinkedHashMap<>()), "ResourceSpec type OBJECT_PREFIX not supported for RangerOzoneAuthorizer"); } @@ -837,13 +845,14 @@ public void testCreatePathsAndPermissionsWithObjectPrefixWildcardResource() { new IamSessionPolicyResolver.ResourceSpec(S3ResourceType.OBJECT_PREFIX_WILDCARD, "bucket1", "prefix/*", null)); expectIllegalArgumentException( - () -> createPathsAndPermissions(VOLUME, NATIVE, actions, resourceSpecs, Collections.emptySet()), + () -> createPathsAndPermissions(VOLUME, NATIVE, actions, resourceSpecs, emptySet(), new LinkedHashMap<>()), "ResourceSpec type OBJECT_PREFIX_WILDCARD not supported for OzoneNativeAuthorizer"); final Set rangerReadObjects = objSet(key("bucket1", "prefix/*"), bucket("bucket1"), volume()); - final Set resultRanger = createPathsAndPermissions( - VOLUME, RANGER, actions, resourceSpecs, Collections.emptySet()); - assertThat(resultRanger).containsExactly(new AssumeRoleRequest.OzoneGrant(rangerReadObjects, acls(READ))); + final Map> objToAclsMapRanger = new LinkedHashMap<>(); + createPathsAndPermissions(VOLUME, RANGER, actions, resourceSpecs, emptySet(), objToAclsMapRanger); + final Set resultRanger = groupObjectsByAcls(objToAclsMapRanger); + assertThat(resultRanger).containsExactly(new OzoneGrant(rangerReadObjects, acls(READ))); } @Test @@ -853,17 +862,19 @@ public void testCreatePathsAndPermissionsWithConditionPrefixesForObjectActionMus final Set nativeResourceSpecs = Collections.singleton( new IamSessionPolicyResolver.ResourceSpec(S3ResourceType.OBJECT_PREFIX, "bucket1", "", null)); + final Map> objToAclsMapNative = new LinkedHashMap<>(); final Set nativeReadObjects = objSet(prefix("bucket1", ""), bucket("bucket1"), volume()); - final Set resultNative = createPathsAndPermissions( - VOLUME, NATIVE, actions, nativeResourceSpecs, prefixes); - assertThat(resultNative).containsExactly(new AssumeRoleRequest.OzoneGrant(nativeReadObjects, acls(READ))); + createPathsAndPermissions(VOLUME, NATIVE, actions, nativeResourceSpecs, prefixes, objToAclsMapNative); + final Set resultNative = groupObjectsByAcls(objToAclsMapNative); + assertThat(resultNative).containsExactly(new OzoneGrant(nativeReadObjects, acls(READ))); final Set rangerResourceSpecs = Collections.singleton( new IamSessionPolicyResolver.ResourceSpec(S3ResourceType.OBJECT_PREFIX_WILDCARD, "bucket1", "*", null)); + final Map> objToAclsMapRanger = new LinkedHashMap<>(); final Set rangerReadObjects = objSet(key("bucket1", "*"), bucket("bucket1"), volume()); - final Set resultRanger = createPathsAndPermissions( - VOLUME, RANGER, actions, rangerResourceSpecs, prefixes); - assertThat(resultRanger).containsExactly(new AssumeRoleRequest.OzoneGrant(rangerReadObjects, acls(READ))); + createPathsAndPermissions(VOLUME, RANGER, actions, rangerResourceSpecs, prefixes, objToAclsMapRanger); + final Set resultRanger = groupObjectsByAcls(objToAclsMapRanger); + assertThat(resultRanger).containsExactly(new OzoneGrant(rangerReadObjects, acls(READ))); } @Test @@ -876,22 +887,22 @@ public void testCreatePathsAndPermissionsWithConditionPrefixesForBucketActionWhe final Set nativeReadObjects = objSet( prefix("bucket1", "folder1/"), prefix("bucket1", "folder2/"), volume()); final Set nativeReadAndListObject = objSet(bucket("bucket1")); - final Set resultNative = createPathsAndPermissions( - VOLUME, NATIVE, actions, nativeResourceSpecs, prefixes); + final Map> objToAclsMapNative = new LinkedHashMap<>(); + createPathsAndPermissions(VOLUME, NATIVE, actions, nativeResourceSpecs, prefixes, objToAclsMapNative); + final Set resultNative = groupObjectsByAcls(objToAclsMapNative); assertThat(resultNative).containsExactlyInAnyOrder( - new AssumeRoleRequest.OzoneGrant(nativeReadObjects, acls(READ)), - new AssumeRoleRequest.OzoneGrant(nativeReadAndListObject, acls(READ, LIST))); + new OzoneGrant(nativeReadObjects, acls(READ)), new OzoneGrant(nativeReadAndListObject, acls(READ, LIST))); final Set rangerResourceSpecs = Collections.singleton( new IamSessionPolicyResolver.ResourceSpec(S3ResourceType.BUCKET, "bucket1", null, null)); final Set rangerReadObjects = objSet( key("bucket1", "folder1/"), key("bucket1", "folder2/"), volume()); final Set rangerReadAndListObject = objSet(bucket("bucket1")); - final Set resultRanger = createPathsAndPermissions( - VOLUME, RANGER, actions, rangerResourceSpecs, prefixes); + final Map> objToAclsMapRanger = new LinkedHashMap<>(); + createPathsAndPermissions(VOLUME, RANGER, actions, rangerResourceSpecs, prefixes, objToAclsMapRanger); + final Set resultRanger = groupObjectsByAcls(objToAclsMapRanger); assertThat(resultRanger).containsExactlyInAnyOrder( - new AssumeRoleRequest.OzoneGrant(rangerReadObjects, acls(READ)), - new AssumeRoleRequest.OzoneGrant(rangerReadAndListObject, acls(READ, LIST))); + new OzoneGrant(rangerReadObjects, acls(READ)), new OzoneGrant(rangerReadAndListObject, acls(READ, LIST))); } @Test @@ -903,22 +914,1014 @@ public void testCreatePathsAndPermissionsWithConditionPrefixesForBucketActionWhe final Set nativeResourceSpecs = Collections.singleton( new IamSessionPolicyResolver.ResourceSpec(S3ResourceType.BUCKET, "bucket1", null, null)); - final Set resultNative = createPathsAndPermissions( - VOLUME, NATIVE, actions, nativeResourceSpecs, prefixes); + final Map> objToAclsMapNative = new LinkedHashMap<>(); + createPathsAndPermissions(VOLUME, NATIVE, actions, nativeResourceSpecs, prefixes, objToAclsMapNative); + final Set resultNative = groupObjectsByAcls(objToAclsMapNative); assertThat(resultNative).containsExactlyInAnyOrder( - new AssumeRoleRequest.OzoneGrant(readObject, acls(READ)), - new AssumeRoleRequest.OzoneGrant(readAndReadAclObject, acls(READ, READ_ACL))); + new OzoneGrant(readObject, acls(READ)), new OzoneGrant(readAndReadAclObject, acls(READ, READ_ACL))); final Set rangerResourceSpecs = Collections.singleton( new IamSessionPolicyResolver.ResourceSpec(S3ResourceType.BUCKET, "bucket1", null, null)); - final Set resultRanger = createPathsAndPermissions( - VOLUME, RANGER, actions, rangerResourceSpecs, prefixes); + final Map> objToAclsMapRanger = new LinkedHashMap<>(); + createPathsAndPermissions(VOLUME, RANGER, actions, rangerResourceSpecs, prefixes, objToAclsMapRanger); + final Set resultRanger = groupObjectsByAcls(objToAclsMapRanger); + assertThat(resultRanger).containsExactlyInAnyOrder( + new OzoneGrant(readObject, acls(READ)), new OzoneGrant(readAndReadAclObject, acls(READ, READ_ACL))); + } + + @Test + public void testCreatePathsAndPermissionsWithNoMappedActions() { + final Set actions = emptySet(); + + final Set nativeResourceSpecs = Collections.singleton( + new IamSessionPolicyResolver.ResourceSpec(S3ResourceType.OBJECT_PREFIX, "bucket1", null, null)); + final Map> objToAclsMapNative = new LinkedHashMap<>(); + createPathsAndPermissions(VOLUME, NATIVE, actions, nativeResourceSpecs, emptySet(), objToAclsMapNative); + final Set resultNative = groupObjectsByAcls(objToAclsMapNative); + assertThat(resultNative).isEmpty(); + + final Set rangerResourceSpecs = Collections.singleton( + new IamSessionPolicyResolver.ResourceSpec(S3ResourceType.OBJECT_PREFIX_WILDCARD, "bucket1", null, null)); + final Map> objToAclsMapRanger = new LinkedHashMap<>(); + createPathsAndPermissions(VOLUME, RANGER, actions, rangerResourceSpecs, emptySet(), objToAclsMapRanger); + final Set resultRanger = groupObjectsByAcls(objToAclsMapRanger); + assertThat(resultRanger).isEmpty(); + } + + @Test + public void testCreatePathsAndPermissionsWithNoMappedResources() { + final Set actions = Collections.singleton(S3Action.GET_OBJECT); + final Set resourceSpecs = emptySet(); + + final Map> objToAclsMapNative = new LinkedHashMap<>(); + createPathsAndPermissions(VOLUME, NATIVE, actions, resourceSpecs, emptySet(), objToAclsMapNative); + final Set resultNative = groupObjectsByAcls(objToAclsMapNative); + assertThat(resultNative).isEmpty(); + + final Map> objToAclsMapRanger = new LinkedHashMap<>(); + createPathsAndPermissions(VOLUME, RANGER, actions, resourceSpecs, emptySet(), objToAclsMapRanger); + final Set resultRanger = groupObjectsByAcls(objToAclsMapRanger); + assertThat(resultRanger).isEmpty(); + } + + @Test + public void testCreatePathsAndPermissionsDeduplicatesAcrossSameResourceTypes() { + final Set actions = Stream.of( + S3Action.GET_OBJECT, S3Action.GET_OBJECT_TAGGING, S3Action.DELETE_OBJECT, S3Action.DELETE_OBJECT_TAGGING) + .collect(Collectors.toSet()); + final Set resourceSpecs = Collections.singleton( + new IamSessionPolicyResolver.ResourceSpec(S3ResourceType.OBJECT_EXACT, "bucket1", null, "key.txt")); + final Set readAndDeleteObject = objSet(key("bucket1", "key.txt")); + final Set readObjects = objSet(bucket("bucket1"), volume()); + + final Map> objToAclsMapNative = new LinkedHashMap<>(); + createPathsAndPermissions(VOLUME, NATIVE, actions, resourceSpecs, emptySet(), objToAclsMapNative); + final Set resultNative = groupObjectsByAcls(objToAclsMapNative); + assertThat(resultNative).containsExactlyInAnyOrder( + new OzoneGrant(readAndDeleteObject, acls(READ, DELETE)), new OzoneGrant(readObjects, acls(READ))); + + final Map> objToAclsMapRanger = new LinkedHashMap<>(); + createPathsAndPermissions(VOLUME, RANGER, actions, resourceSpecs, emptySet(), objToAclsMapRanger); + final Set resultRanger = groupObjectsByAcls(objToAclsMapRanger); assertThat(resultRanger).containsExactlyInAnyOrder( - new AssumeRoleRequest.OzoneGrant(readObject, acls(READ)), - new AssumeRoleRequest.OzoneGrant(readAndReadAclObject, acls(READ, READ_ACL))); + new OzoneGrant(readAndDeleteObject, acls(READ, DELETE)), new OzoneGrant(readObjects, acls(READ))); + } + + @Test + public void testCreatePathsAndPermissionsWithAllS3ActionsOverridesAnyOtherAction() { + final Set actions = Stream.of( + S3Action.ALL_S3, S3Action.GET_OBJECT, S3Action.DELETE_OBJECT, S3Action.LIST_BUCKET) + .collect(Collectors.toSet()); + final Set resourceSpecs = Stream.of( + new IamSessionPolicyResolver.ResourceSpec(S3ResourceType.OBJECT_EXACT, "bucket1", null, "key.txt"), + new IamSessionPolicyResolver.ResourceSpec(S3ResourceType.BUCKET, "bucket2", null, null)) + .collect(Collectors.toSet()); + final Set allObjects = objSet(key("bucket1", "key.txt"), bucket("bucket2")); + + final Set nativeReadObjects = objSet(volume(), bucket("bucket1"), prefix("bucket2", "")); + final Map> objToAclsMapNative = new LinkedHashMap<>(); + createPathsAndPermissions(VOLUME, NATIVE, actions, resourceSpecs, emptySet(), objToAclsMapNative); + final Set resultNative = groupObjectsByAcls(objToAclsMapNative); + assertThat(resultNative).containsExactlyInAnyOrder( + new OzoneGrant(allObjects, acls(ALL)), new OzoneGrant(nativeReadObjects, acls(READ))); + + final Set rangerReadObjects = objSet(volume(), bucket("bucket1"), key("bucket2", "*")); + final Map> objToAclsMapRanger = new LinkedHashMap<>(); + createPathsAndPermissions(VOLUME, RANGER, actions, resourceSpecs, emptySet(), objToAclsMapRanger); + final Set resultRanger = groupObjectsByAcls(objToAclsMapRanger); + assertThat(resultRanger).containsExactlyInAnyOrder( + new OzoneGrant(allObjects, acls(ALL)), new OzoneGrant(rangerReadObjects, acls(READ))); + } + + @Test + public void testDeduplicatesAcrossMultipleStatementsWhenSameStatementsArePresent() throws OMException { + final String json = "{\n" + + " \"Version\": \"2012-10-17\",\n" + + " \"Statement\": [\n" + + " {\n" + + " \"Effect\": \"Allow\",\n" + + " \"Action\": [\n" + + " \"s3:GetBucketAcl\",\n" + + " \"s3:PutBucketAcl\",\n" + + " \"s3:ListBucket\"\n" + + " ],\n" + + " \"Resource\": \"arn:aws:s3:::my-bucket\"\n" + + " },\n" + + " {\n" + + " \"Effect\": \"Allow\",\n" + + " \"Action\": [\n" + + " \"s3:GetBucketAcl\",\n" + + " \"s3:PutBucketAcl\",\n" + + " \"s3:ListBucket\"\n" + + " ],\n" + + " \"Resource\": \"arn:aws:s3:::my-bucket\"\n" + + " }\n" + + " ]\n" + + "}"; + + final Set resolvedFromNativeAuthorizer = resolve(json, VOLUME, NATIVE); + final Set resolvedFromRangerAuthorizer = resolve(json, VOLUME, RANGER); + + // Ensure what we got is what we expected + final Set expectedResolvedNative = new LinkedHashSet<>(); + // Expected for native: bucket READ, LIST, READ_ACL, WRITE_ACL; volume and prefix "" READ + final Set bucketSet = objSet(bucket("my-bucket")); + final Set bucketAcls = acls(READ, LIST, READ_ACL, WRITE_ACL); + expectedResolvedNative.add(new OzoneGrant(bucketSet, bucketAcls)); + expectedResolvedNative.add(new OzoneGrant(objSet(volume(), prefix("my-bucket", "")), acls(READ))); + assertThat(resolvedFromNativeAuthorizer).isEqualTo(expectedResolvedNative); + + final Set expectedResolvedRanger = new LinkedHashSet<>(); + // Expected for Ranger: bucket READ, LIST, READ_ACL, WRITE_ACL; volume and key "*" READ + expectedResolvedRanger.add(new OzoneGrant(bucketSet, bucketAcls)); + expectedResolvedRanger.add(new OzoneGrant(objSet(volume(), key("my-bucket", "*")), acls(READ))); + assertThat(resolvedFromRangerAuthorizer).isEqualTo(expectedResolvedRanger); + } + + @Test + public void testDeduplicatesAcrossMultipleStatementsForSameActionsButDifferentResource() throws OMException { + final String json = "{\n" + + " \"Version\": \"2012-10-17\",\n" + + " \"Statement\": [\n" + + " {\n" + + " \"Effect\": \"Allow\",\n" + + " \"Action\": [\n" + + " \"s3:GetBucketAcl\",\n" + + " \"s3:PutBucketAcl\",\n" + + " \"s3:ListBucket\"\n" + + " ],\n" + + " \"Resource\": \"arn:aws:s3:::my-bucket\"\n" + + " },\n" + + " {\n" + + " \"Effect\": \"Allow\",\n" + + " \"Action\": [\n" + + " \"s3:GetBucketAcl\",\n" + + " \"s3:PutBucketAcl\",\n" + + " \"s3:ListBucket\"\n" + + " ],\n" + + " \"Resource\": \"arn:aws:s3:::my-bucket2\"\n" + + " }\n" + + " ]\n" + + "}"; + + final Set resolvedFromNativeAuthorizer = resolve(json, VOLUME, NATIVE); + final Set resolvedFromRangerAuthorizer = resolve(json, VOLUME, RANGER); + + // Ensure what we got is what we expected + final Set expectedResolvedNative = new LinkedHashSet<>(); + // Expected for native: bucket READ, LIST, READ_ACL, WRITE_ACL; volume and prefix "" READ + final Set bucketSet = objSet(bucket("my-bucket"), bucket("my-bucket2")); + final Set bucketAcls = acls(READ, LIST, READ_ACL, WRITE_ACL); + expectedResolvedNative.add(new OzoneGrant(bucketSet, bucketAcls)); + expectedResolvedNative.add(new OzoneGrant( + objSet(volume(), prefix("my-bucket2", ""), prefix("my-bucket", "")), acls(READ))); + assertThat(resolvedFromNativeAuthorizer).isEqualTo(expectedResolvedNative); + + final Set expectedResolvedRanger = new LinkedHashSet<>(); + // Expected for Ranger: bucket READ, LIST, READ_ACL, WRITE_ACL; volume and key "*" READ + expectedResolvedRanger.add(new OzoneGrant(bucketSet, bucketAcls)); + expectedResolvedRanger.add(new OzoneGrant( + objSet(volume(), key("my-bucket2", "*"), key("my-bucket", "*")), acls(READ))); + assertThat(resolvedFromRangerAuthorizer).isEqualTo(expectedResolvedRanger); + } + + @Test + public void testDeduplicatesAcrossMultipleStatementsForDifferentActionsButSameResource() throws OMException { + final String json = "{\n" + + " \"Version\": \"2012-10-17\",\n" + + " \"Statement\": [\n" + + " {\n" + + " \"Effect\": \"Allow\",\n" + + " \"Action\": [\n" + + " \"s3:GetBucketAcl\",\n" + + " \"s3:PutBucketAcl\",\n" + + " \"s3:ListBucket\"\n" + + " ],\n" + + " \"Resource\": \"arn:aws:s3:::my-bucket\"\n" + + " },\n" + + " {\n" + + " \"Effect\": \"Allow\",\n" + + " \"Action\": [\n" + + " \"s3:GetBucketAcl\",\n" + + " \"s3:CreateBucket\"\n" + + " ],\n" + + " \"Resource\": \"arn:aws:s3:::my-bucket\"\n" + + " }\n" + + " ]\n" + + "}"; + + final Set resolvedFromNativeAuthorizer = resolve(json, VOLUME, NATIVE); + final Set resolvedFromRangerAuthorizer = resolve(json, VOLUME, RANGER); + + // Ensure what we got is what we expected + final Set expectedResolvedNative = new LinkedHashSet<>(); + // Expected for native: bucket READ, LIST, READ_ACL, WRITE_ACL, CREATE; volume, prefix "" READ + final Set bucketSet = objSet(bucket("my-bucket")); + final Set bucketAcls = acls(READ, LIST, READ_ACL, WRITE_ACL, CREATE); + expectedResolvedNative.add(new OzoneGrant(bucketSet, bucketAcls)); + expectedResolvedNative.add(new OzoneGrant(objSet(volume(), prefix("my-bucket", "")), acls(READ))); + assertThat(resolvedFromNativeAuthorizer).isEqualTo(expectedResolvedNative); + + final Set expectedResolvedRanger = new LinkedHashSet<>(); + // Expected for Ranger: bucket READ, LIST, READ_ACL, WRITE_ACL, CREATE; volume, key "*" READ + expectedResolvedRanger.add(new OzoneGrant(bucketSet, bucketAcls)); + expectedResolvedRanger.add(new OzoneGrant(objSet(volume(), key("my-bucket", "*")), acls(READ))); + assertThat(resolvedFromRangerAuthorizer).isEqualTo(expectedResolvedRanger); + } + + @Test + public void testDeduplicatesAcrossMultipleStatementsWhenAllActionPresent() throws OMException { + final String json = "{\n" + + " \"Version\": \"2012-10-17\",\n" + + " \"Statement\": [\n" + + " {\n" + + " \"Effect\": \"Allow\",\n" + + " \"Action\": [\n" + + " \"s3:GetBucketAcl\",\n" + + " \"s3:PutBucketAcl\",\n" + + " \"s3:ListBucket\"\n" + + " ],\n" + + " \"Resource\": \"arn:aws:s3:::my-bucket\"\n" + + " },\n" + + " {\n" + + " \"Effect\": \"Allow\",\n" + + " \"Action\": \"s3:*\",\n" + + " \"Resource\": \"arn:aws:s3:::my-bucket\"\n" + + " }\n" + + " ]\n" + + "}"; + + final Set resolvedFromNativeAuthorizer = resolve(json, VOLUME, NATIVE); + final Set resolvedFromRangerAuthorizer = resolve(json, VOLUME, RANGER); + + // Ensure what we got is what we expected + final Set expectedResolvedNative = new LinkedHashSet<>(); + // Expected for native: bucket ALL (instead of individual actions); volume and prefix "" READ + final Set bucketSet = objSet(bucket("my-bucket")); + final Set bucketAcls = acls(ALL); + expectedResolvedNative.add(new OzoneGrant(bucketSet, bucketAcls)); + expectedResolvedNative.add(new OzoneGrant(objSet(volume(), prefix("my-bucket", "")), acls(READ))); + assertThat(resolvedFromNativeAuthorizer).isEqualTo(expectedResolvedNative); + + final Set expectedResolvedRanger = new LinkedHashSet<>(); + // Expected for Ranger: bucket ALL (instead of individual actions); volume and key "*" READ + expectedResolvedRanger.add(new OzoneGrant(bucketSet, bucketAcls)); + expectedResolvedRanger.add(new OzoneGrant(objSet(volume(), key("my-bucket", "*")), acls(READ))); + assertThat(resolvedFromRangerAuthorizer).isEqualTo(expectedResolvedRanger); + } + + @Test + public void testAllowGetPutOnKey() throws OMException { + final String json = "{\n" + + " \"Version\": \"2012-10-17\",\n" + + " \"Statement\": [{\n" + + " \"Effect\": \"Allow\",\n" + + " \"Action\": [\"s3:GetObject\", \"s3:PutObject\"],\n" + + " \"Resource\": \"arn:aws:s3:::my-bucket/folder/file.txt\"\n" + + " }]\n" + + "}"; + + final Set resolvedFromNativeAuthorizer = resolve(json, VOLUME, NATIVE); + final Set resolvedFromRangerAuthorizer = resolve(json, VOLUME, RANGER); + + // Ensure what we got is what we expected + final Set expectedResolvedFromBothAuthorizers = new LinkedHashSet<>(); + // Expected: READ, CREATE, WRITE on key; bucket READ; volume READ + final Set keySet = objSet(key("my-bucket", "folder/file.txt")); + final Set keyAcls = acls(READ, CREATE, WRITE); + expectedResolvedFromBothAuthorizers.add(new OzoneGrant(objSet(volume(), bucket("my-bucket")), acls(READ))); + expectedResolvedFromBothAuthorizers.add(new OzoneGrant(keySet, keyAcls)); + + assertThat(resolvedFromNativeAuthorizer).isEqualTo(expectedResolvedFromBothAuthorizers); + assertThat(resolvedFromRangerAuthorizer).isEqualTo(expectedResolvedFromBothAuthorizers); + } + + @Test + public void testAllActionsForKey() throws OMException { + final String json = "{\n" + + " \"Statement\": [{\n" + + " \"Effect\": \"Allow\",\n" + + " \"Action\": \"s3:*\",\n" + + " \"Resource\": \"arn:aws:s3:::my-bucket/*\"\n" + + " }]\n" + + "}"; + + final Set resolvedFromNativeAuthorizer = resolve(json, VOLUME, NATIVE); + final Set resolvedFromRangerAuthorizer = resolve(json, VOLUME, RANGER); + + // Ensure what we got is what we expected + final Set expectedResolvedNative = new LinkedHashSet<>(); + // Expected for native: all key ACLs on prefix "" under bucket; bucket READ, volume READ + final Set keyPrefixSet = objSet(prefix("my-bucket", "")); + final Set allKeyAcls = acls(ALL); + expectedResolvedNative.add(new OzoneGrant(keyPrefixSet, allKeyAcls)); + expectedResolvedNative.add(new OzoneGrant(objSet(volume(), bucket("my-bucket")), acls(READ))); + assertThat(resolvedFromNativeAuthorizer).isEqualTo(expectedResolvedNative); + + // Expected for Ranger: all key acls for resource type KEY with key name "*" + final Set expectedResolvedRanger = new LinkedHashSet<>(); + final Set rangerKeySet = objSet(key("my-bucket", "*")); + expectedResolvedRanger.add(new OzoneGrant(rangerKeySet, allKeyAcls)); + expectedResolvedRanger.add(new OzoneGrant(objSet(volume(), bucket("my-bucket")), acls(READ))); + assertThat(resolvedFromRangerAuthorizer).isEqualTo(expectedResolvedRanger); + } + + @Test + public void testAllActionsForBucket() throws OMException { + final String json = "{\n" + + " \"Statement\": [{\n" + + " \"Effect\": \"Allow\",\n" + + " \"Action\": \"s3:*\",\n" + + " \"Resource\": \"arn:aws:s3:::my-bucket\"\n" + + " }]\n" + + "}"; + + final Set resolvedFromNativeAuthorizer = resolve(json, VOLUME, NATIVE); + final Set resolvedFromRangerAuthorizer = resolve(json, VOLUME, RANGER); + + // Ensure what we got is what we expected + final Set expectedResolvedNative = new LinkedHashSet<>(); + // Expected for native: all Bucket ACLs for bucket; volume, prefix "" READ + final Set bucketSet = objSet(bucket("my-bucket")); + final Set allBucketAcls = acls(ALL); + expectedResolvedNative.add(new OzoneGrant(objSet(volume(), prefix("my-bucket", "")), acls(READ))); + expectedResolvedNative.add(new OzoneGrant(bucketSet, allBucketAcls)); + assertThat(resolvedFromNativeAuthorizer).isEqualTo(expectedResolvedNative); + + // Expected for Ranger: all Bucket ACLs for bucket; volume, key "*" READ + final Set expectedResolvedRanger = new LinkedHashSet<>(); + expectedResolvedRanger.add(new OzoneGrant(objSet(volume(), key("my-bucket", "*")), acls(READ))); + expectedResolvedRanger.add(new OzoneGrant(bucketSet, allBucketAcls)); + assertThat(resolvedFromRangerAuthorizer).isEqualTo(expectedResolvedRanger); + } + + @Test + public void testMultipleResourcesInSeparateStatements() throws OMException { + final String json = "{\n" + + " \"Version\": \"2012-10-17\",\n" + + " \"Statement\": [\n" + + " {\n" + + " \"Effect\": \"Allow\",\n" + + " \"Action\": [\n" + + " \"s3:GetBucketAcl\",\n" + + " \"s3:PutBucketAcl\",\n" + + " \"s3:ListBucket\"\n" + + " ],\n" + + " \"Resource\": \"arn:aws:s3:::my-bucket\"\n" + + " },\n" + + " {\n" + + " \"Effect\": \"Allow\",\n" + + " \"Action\": \"s3:*\",\n" + + " \"Resource\": \"arn:aws:s3:::my-bucket/*\"\n" + + " }\n" + + " ]\n" + + "}"; + + final Set resolvedFromNativeAuthorizer = resolve(json, VOLUME, NATIVE); + final Set resolvedFromRangerAuthorizer = resolve(json, VOLUME, RANGER); + + // Ensure what we got is what we expected + final Set expectedResolvedNative = new LinkedHashSet<>(); + // Expected for native: bucket READ, LIST, READ_ACL, WRITE_ACL; volume READ + final Set bucketSet = objSet(bucket("my-bucket")); + final Set bucketAcls = acls(READ, LIST, READ_ACL, WRITE_ACL); + expectedResolvedNative.add(new OzoneGrant(bucketSet, bucketAcls)); + expectedResolvedNative.add(new OzoneGrant(objSet(volume()), acls(READ))); + // Expected for native: all key ACLs on prefix "" under bucket + final Set keyPrefixSet = objSet(prefix("my-bucket", "")); + final Set keyAllAcls = acls(ALL); + expectedResolvedNative.add(new OzoneGrant(keyPrefixSet, keyAllAcls)); + assertThat(resolvedFromNativeAuthorizer).isEqualTo(expectedResolvedNative); + + final Set expectedResolvedRanger = new LinkedHashSet<>(); + // Expected for Ranger: bucket READ, LIST, READ_ACL, WRITE_ACL; volume READ + expectedResolvedRanger.add(new OzoneGrant(bucketSet, bucketAcls)); + expectedResolvedRanger.add(new OzoneGrant(objSet(volume()), acls(READ))); + // Expected for Ranger: all key acls for resource type KEY with key name "*" + final Set rangerKeySet = objSet(key("my-bucket", "*")); + expectedResolvedRanger.add(new OzoneGrant(rangerKeySet, keyAllAcls)); + assertThat(resolvedFromRangerAuthorizer).isEqualTo(expectedResolvedRanger); + } + + @Test + public void testMultipleResourcesInOneStatement() throws OMException { + final String json = "{\n" + + " \"Version\": \"2012-10-17\",\n" + + " \"Statement\": [\n" + + " {\n" + + " \"Effect\": \"Allow\",\n" + + " \"Action\": [\n" + + " \"s3:*\"\n" + + " ],\n" + + " \"Resource\": [\n" + + " \"arn:aws:s3:::my-bucket\",\n" + + " \"arn:aws:s3:::my-bucket/*\"\n" + + " ]\n" + + " }\n" + + " ]\n" + + "}"; + + final Set resolvedFromNativeAuthorizer = resolve(json, VOLUME, NATIVE); + final Set resolvedFromRangerAuthorizer = resolve(json, VOLUME, RANGER); + + // Ensure what we got is what we expected + final Set expectedResolvedNative = new LinkedHashSet<>(); + // Expected for native: all for bucket and key acls; volume READ + final Set resourceSetNative = objSet(bucket("my-bucket"), prefix("my-bucket", "")); + expectedResolvedNative.add(new OzoneGrant(resourceSetNative, acls(ALL))); + expectedResolvedNative.add(new OzoneGrant(objSet(volume()), acls(READ))); + assertThat(resolvedFromNativeAuthorizer).isEqualTo(expectedResolvedNative); + + final Set expectedResolvedRanger = new LinkedHashSet<>(); + // Expected for Ranger: all for bucket and key acls; volume READ + final Set resourceSetRanger = objSet(bucket("my-bucket"), key("my-bucket", "*")); + expectedResolvedRanger.add(new OzoneGrant(resourceSetRanger, acls(ALL))); + expectedResolvedRanger.add(new OzoneGrant(objSet(volume()), acls(READ))); + assertThat(resolvedFromRangerAuthorizer).isEqualTo(expectedResolvedRanger); + } + + @Test + public void testMultipleResourcesWithDifferentBucketsAndDeepPathsInOneStatement() throws OMException { + final String json = "{\n" + + " \"Version\": \"2012-10-17\",\n" + + " \"Statement\": [\n" + + " {\n" + + " \"Effect\": \"Allow\",\n" + + " \"Action\": [\n" + + " \"s3:*\"\n" + + " ],\n" + + " \"Resource\": [\n" + + " \"arn:aws:s3:::my-bucket/team/folder1/security/*\",\n" + + " \"arn:aws:s3:::my-bucket2/team/folder2/misc/*\"\n" + + " ]\n" + + " }\n" + + " ]\n" + + "}"; + + final Set resolvedFromNativeAuthorizer = resolve(json, VOLUME, NATIVE); + final Set resolvedFromRangerAuthorizer = resolve(json, VOLUME, RANGER); + + // Ensure what we got is what we expected + final Set expectedResolvedNative = new LinkedHashSet<>(); + // Expected for native: all key ACLs on prefix "team/folder1/security/" under + // my-bucket and all key ACLs on prefix "team/folder2/misc/" under my-bucket2; bucket READ; volume READ + final Set keyPrefixSet = objSet( + prefix("my-bucket", "team/folder1/security/"), prefix("my-bucket2", "team/folder2/misc/")); + final Set keyAllAcls = acls(ALL); + expectedResolvedNative.add(new OzoneGrant(keyPrefixSet, keyAllAcls)); + expectedResolvedNative.add(new OzoneGrant(objSet(volume(), bucket("my-bucket"), bucket("my-bucket2")), acls(READ))); + assertThat(resolvedFromNativeAuthorizer).isEqualTo(expectedResolvedNative); + + final Set expectedResolvedRanger = new LinkedHashSet<>(); + // Expected for Ranger: all key acls for resource type KEY with key name + // "team/folder1/security/*" under my-bucket and "team/folder2/misc/*" under my-bucket2; bucket READ; volume READ + final Set rangerKeySet = objSet( + key("my-bucket", "team/folder1/security/*"), key("my-bucket2", "team/folder2/misc/*")); + expectedResolvedRanger.add(new OzoneGrant(rangerKeySet, keyAllAcls)); + expectedResolvedRanger.add(new OzoneGrant(objSet(volume(), bucket("my-bucket"), bucket("my-bucket2")), acls(READ))); + assertThat(resolvedFromRangerAuthorizer).isEqualTo(expectedResolvedRanger); + } + + @Test + public void testUnsupportedActionIgnoredWhenItIsTheOnlyAction() throws OMException { + final String json = "{\n" + + " \"Statement\": [{\n" + + " \"Effect\": \"Allow\",\n" + + " \"Action\": \"s3:ReplicateObject\",\n" + // unsupported action + " \"Resource\": \"*\"\n" + + " }]\n" + + "}"; + + final Set resolvedFromNativeAuthorizer = resolve(json, VOLUME, NATIVE); + final Set resolvedFromRangerAuthorizer = resolve(json, VOLUME, RANGER); + + // Ensure what we got is what we expected + assertThat(resolvedFromNativeAuthorizer).isEmpty(); + assertThat(resolvedFromRangerAuthorizer).isEmpty(); + } + + @Test + public void testUnsupportedResourceArnThrows() { + final String json = "{\n" + + " \"Statement\": [{\n" + + " \"Effect\": \"Allow\",\n" + + " \"Action\": \"s3:ListBucket\",\n" + + " \"Resource\": \"arn:aws:dynamodb:us-east-2:123456789012:table/example-table\"\n" + + " }]\n" + + "}"; + + expectResolveThrowsForBothAuthorizers( + json, "Unsupported Resource Arn - " + + "arn:aws:dynamodb:us-east-2:123456789012:table/example-table", NOT_SUPPORTED_OPERATION); + } + + @Test + public void testListBucketWithWildcard() throws OMException { + final String json = "{\n" + + " \"Statement\": [{\n" + + " \"Effect\": \"Allow\",\n" + + " \"Action\": \"s3:ListBucket\",\n" + + " \"Resource\": \"arn:aws:s3:::proj-*\"\n" + + " }]\n" + + "}"; + + // Wildcards on bucket are not supported for Native authorizer + expectBucketWildcardUnsupportedExceptionForNativeAuthorizer(json); + + final Set resolvedFromRangerAuthorizer = resolve(json, VOLUME, RANGER); + // Ensure what we got is what we expected + final Set expectedResolvedRanger = new LinkedHashSet<>(); + // Expected for Ranger: bucket READ and LIST on wildcard pattern; volume and key "*" READ + final Set bucketSet = objSet(bucket("proj-*")); + final Set bucketAcls = acls(READ, LIST); + expectedResolvedRanger.add(new OzoneGrant(bucketSet, bucketAcls)); + expectedResolvedRanger.add(new OzoneGrant(objSet(volume(), key("proj-*", "*")), acls(READ))); + assertThat(resolvedFromRangerAuthorizer).isEqualTo(expectedResolvedRanger); + } + + @Test + public void testListBucketOperationsWithNoPrefixes() throws OMException { + final String json = "{\n" + + " \"Statement\": [\n" + + " {\n" + + " \"Effect\": \"Allow\",\n" + + " \"Action\": [\n" + + " \"s3:ListBucket\",\n" + + " \"s3:ListBucketMultipartUploads\"\n" + + " ],\n" + + " \"Resource\": \"arn:aws:s3:::proj\"\n" + + " }\n" + + " ]\n" + + "}"; + + final Set resolvedFromNativeAuthorizer = resolve(json, VOLUME, NATIVE); + final Set resolvedFromRangerAuthorizer = resolve(json, VOLUME, RANGER); + + // Ensure what we got is what we expected + final Set expectedResolvedNative = new LinkedHashSet<>(); + // Expected for native: bucket READ and LIST; volume, prefix "" READ + final Set bucketSet = objSet(bucket("proj")); + final Set bucketAcls = acls(READ, LIST); + final Set nativeReadObjects = objSet(volume(), prefix("proj", "")); + expectedResolvedNative.add(new OzoneGrant(bucketSet, bucketAcls)); + expectedResolvedNative.add(new OzoneGrant(nativeReadObjects, acls(READ))); + assertThat(resolvedFromNativeAuthorizer).isEqualTo(expectedResolvedNative); + + // Expected for Ranger: bucket READ and LIST; volume, key "*" READ + final Set rangerReadObjects = objSet(volume(), key("proj", "*")); + final Set expectedResolvedRanger = new LinkedHashSet<>(); + expectedResolvedRanger.add(new OzoneGrant(bucketSet, bucketAcls)); + expectedResolvedRanger.add(new OzoneGrant(rangerReadObjects, acls(READ))); + assertThat(resolvedFromRangerAuthorizer).isEqualTo(expectedResolvedRanger); + } + + @Test + public void testIgnoresUnsupportedActionsWhenSupportedActionsAreIncluded() throws OMException { + final String json = "{\n" + + " \"Version\": \"2012-10-17\",\n" + + " \"Statement\": [\n" + + " {\n" + + " \"Sid\": \"AllowListingOfDataLakeFolder\",\n" + + " \"Effect\": \"Allow\",\n" + + " \"Action\": [\n" + + " \"s3:GetAccelerateConfiguration\",\n" + // unsupported action + " \"s3:GetBucketAcl\",\n" + + " \"s3:GetObject\",\n" + // object-level action not applied for bucket + " \"s3:GetObjectAcl\",\n" + // unsupported action + " \"s3:ListBucket\",\n" + + " \"s3:ListBucketMultipartUploads\"\n" + + " ],\n" + + " \"Resource\": \"arn:aws:s3:::bucket1\",\n" + + " \"Condition\": {\n" + + " \"StringEquals\": {\n" + + " \"s3:prefix\": [ \"team/folder\", \"team/folder/*\" ]\n" + + " }\n" + + " }\n" + + " }\n" + + " ]\n" + + "}"; + + final Set resolvedFromNativeAuthorizer = resolve(json, VOLUME, NATIVE); + final Set resolvedFromRangerAuthorizer = resolve(json, VOLUME, RANGER); + + // Ensure what we got is what we expected + final Set expectedResolvedNative = new LinkedHashSet<>(); + + // Expected for native: READ, LIST, READ_ACL bucket acls; volume and prefixes "team/folder", "team/folder/" READ + final Set bucketSet = objSet(bucket("bucket1")); + final Set bucketAcls = acls(READ, LIST, READ_ACL); + expectedResolvedNative.add(new OzoneGrant(bucketSet, bucketAcls)); + expectedResolvedNative.add(new OzoneGrant( + objSet(volume(), prefix("bucket1", "team/folder"), prefix("bucket1", "team/folder/")), acls(READ))); + assertThat(resolvedFromNativeAuthorizer).isEqualTo(expectedResolvedNative); + + final Set expectedResolvedRanger = new LinkedHashSet<>(); + // Expected for Ranger: READ, LIST, READ_ACL bucket acls; volume and keys "team/folder" and "team/folder/*" READ + expectedResolvedRanger.add(new OzoneGrant(bucketSet, bucketAcls)); + expectedResolvedRanger.add(new OzoneGrant( + objSet(volume(), key("bucket1", "team/folder"), key("bucket1", "team/folder/*")), acls(READ))); + assertThat(resolvedFromRangerAuthorizer).isEqualTo(expectedResolvedRanger); + } + + @Test + public void testMultiplePrefixesWithWildcards() throws OMException { + final String json = "{\n" + + " \"Statement\": [{\n" + + " \"Effect\": \"Allow\",\n" + + " \"Action\": \"s3:GetObject\",\n" + + " \"Resource\": \"arn:aws:s3:::logs/*\",\n" + + " \"Condition\": { \"StringEquals\": { \"s3:prefix\": [\"a/*\", \"b/*\"] } }\n" + + " }]\n" + + "}"; + + final Set resolvedFromNativeAuthorizer = resolve(json, VOLUME, NATIVE); + final Set resolvedFromRangerAuthorizer = resolve(json, VOLUME, RANGER); + + // Ensure what we got is what we expected + final Set expectedResolvedNative = new LinkedHashSet<>(); + // Expected for native: READ acl on prefix "" (condition prefixes are ignored); bucket READ; volume READ; + final Set readObjectsNative = objSet(prefix("logs", ""), bucket("logs"), volume()); + expectedResolvedNative.add(new OzoneGrant(readObjectsNative, acls(READ))); + assertThat(resolvedFromNativeAuthorizer).isEqualTo(expectedResolvedNative); + + final Set expectedResolvedRanger = new LinkedHashSet<>(); + // Expected for Ranger: READ acl on key "*" (condition prefixes are ignored) + final Set keySet = objSet(key("logs", "*"), bucket("logs"), volume()); + expectedResolvedRanger.add(new OzoneGrant(keySet, acls(READ))); + assertThat(resolvedFromRangerAuthorizer).isEqualTo(expectedResolvedRanger); + } + + @Test + public void testObjectResourceWithWildcardInMiddle() throws OMException { + final String json = "{\n" + + " \"Statement\": [{\n" + + " \"Effect\": \"Allow\",\n" + + " \"Action\": \"s3:GetObject\",\n" + + " \"Resource\": \"arn:aws:s3:::logs/file*.log\"\n" + + " }]\n" + + "}"; + + // Wildcards in middle of object resource are not supported for Native authorizer + expectResolveThrows( + json, NATIVE, "Wildcard prefix patterns are not supported for Ozone native " + + "authorizer if wildcard is not at the end", NOT_SUPPORTED_OPERATION); + + final Set resolvedFromRangerAuthorizer = resolve(json, VOLUME, RANGER); + // Ensure what we got is what we expected + final Set expectedResolvedRanger = new LinkedHashSet<>(); + // Expected for Ranger: READ acl on key "file*.log", bucket READ, volume READ + final Set readObjectsRanger = objSet(key("logs", "file*.log"), bucket("logs"), volume()); + expectedResolvedRanger.add(new OzoneGrant(readObjectsRanger, acls(READ))); + assertThat(resolvedFromRangerAuthorizer).isEqualTo(expectedResolvedRanger); + } + + @Test + public void testObjectResourceWithPrefixWildcard() throws OMException { + final String json = "{\n" + + " \"Statement\": [{\n" + + " \"Effect\": \"Allow\",\n" + + " \"Action\": \"s3:GetObject\",\n" + + " \"Resource\": \"arn:aws:s3:::myBucket/file*\"\n" + + " }]\n" + + "}"; + + final Set resolvedFromNativeAuthorizer = resolve(json, VOLUME, NATIVE); + final Set resolvedFromRangerAuthorizer = resolve(json, VOLUME, RANGER); + + // Ensure what we got is what we expected + final Set expectedResolvedNative = new LinkedHashSet<>(); + // Expected for native: READ acl on prefix "file" under bucket, bucket READ, volume READ + final Set readObjectsNative = objSet(prefix("myBucket", "file"), bucket("myBucket"), volume()); + expectedResolvedNative.add(new OzoneGrant(readObjectsNative, acls(READ))); + assertThat(resolvedFromNativeAuthorizer).isEqualTo(expectedResolvedNative); + + final Set expectedResolvedRanger = new LinkedHashSet<>(); + // Expected for Ranger: READ acl on key "file*", bucket READ, volume READ + final Set readObjectsRanger = objSet(key("myBucket", "file*"), bucket("myBucket"), volume()); + expectedResolvedRanger.add(new OzoneGrant(readObjectsRanger, acls(READ))); + assertThat(resolvedFromRangerAuthorizer).isEqualTo(expectedResolvedRanger); + } + + @Test + public void testBucketActionOnAllResources() throws OMException { + final String json = "{\n" + + " \"Statement\": [{\n" + + " \"Effect\": \"Allow\",\n" + + " \"Action\": [\n" + + " \"s3:ListAllMyBuckets\",\n" + + " \"s3:ListBucket\"\n" + + " ],\n" + + " \"Resource\": \"*\"\n" + + " }]\n" + + "}"; + + // Wildcards on bucket are not supported for Native authorizer + expectBucketWildcardUnsupportedExceptionForNativeAuthorizer(json); + + final Set resolvedFromRangerAuthorizer = resolve(json, VOLUME, RANGER); + // Ensure what we got is what we expected + final Set expectedResolvedRanger = new LinkedHashSet<>(); + // Expected for Ranger: READ and LIST on volume and bucket (wildcard), READ on key "*" + final Set resourceSet = objSet(volume(), bucket("*")); + expectedResolvedRanger.add(new OzoneGrant(resourceSet, acls(READ, LIST))); + expectedResolvedRanger.add(new OzoneGrant(objSet(key("*", "*")), acls(READ))); + assertThat(resolvedFromRangerAuthorizer).isEqualTo(expectedResolvedRanger); + } + + @Test + public void testObjectActionOnAllResources() throws OMException { + final String json = "{\n" + + " \"Statement\": [{\n" + + " \"Effect\": \"Allow\",\n" + + " \"Action\": \"s3:PutObject\",\n" + + " \"Resource\": \"*\"\n" + + " }]\n" + + "}"; + + // Wildcards on bucket are not supported for Native authorizer + expectBucketWildcardUnsupportedExceptionForNativeAuthorizer(json); + + final Set resolvedFromRangerAuthorizer = resolve(json, VOLUME, RANGER); + // Ensure what we got is what we expected + final Set expectedResolvedRanger = new LinkedHashSet<>(); + // Expected for Ranger: CREATE and WRITE key acls on wildcard pattern, bucket READ, volume READ + final Set keySet = objSet(key("*", "*")); + final Set keyAcls = acls(CREATE, WRITE); + expectedResolvedRanger.add(new OzoneGrant(keySet, keyAcls)); + expectedResolvedRanger.add(new OzoneGrant(objSet(volume(), bucket("*")), acls(READ))); + assertThat(resolvedFromRangerAuthorizer).isEqualTo(expectedResolvedRanger); + } + + @Test + public void testAllActionsOnAllResources() throws OMException { + final String json = "{\n" + + " \"Statement\": [{\n" + + " \"Effect\": \"Allow\",\n" + + " \"Action\": \"s3:*\",\n" + + " \"Resource\": \"*\"\n" + + " }]\n" + + "}"; + + // Wildcards on bucket are not supported for Native authorizer + expectBucketWildcardUnsupportedExceptionForNativeAuthorizer(json); + + final Set resolvedFromRangerAuthorizer = resolve(json, VOLUME, RANGER); + // Ensure what we got is what we expected + final Set expectedResolvedRanger = new LinkedHashSet<>(); + // Expected for Ranger: READ, LIST acl on volume, ALL acl bucket (wildcard) and key (wildcard) + final Set resourceSet = objSet(bucket("*"), key("*", "*")); + expectedResolvedRanger.add(new OzoneGrant(resourceSet, acls(ALL))); + expectedResolvedRanger.add(new OzoneGrant(objSet(volume()), acls(READ, LIST))); + assertThat(resolvedFromRangerAuthorizer).isEqualTo(expectedResolvedRanger); + } + + @Test + public void testAllActionsOnAllBucketResources() throws OMException { + final String json = "{\n" + + " \"Statement\": [{\n" + + " \"Effect\": \"Allow\",\n" + + " \"Action\": \"s3:*\",\n" + + " \"Resource\": \"arn:aws:s3:::*\"\n" + + " }]\n" + + "}"; + + // Wildcards on bucket are not supported for Native authorizer + expectBucketWildcardUnsupportedExceptionForNativeAuthorizer(json); + + final Set resolvedFromRangerAuthorizer = resolve(json, VOLUME, RANGER); + // Ensure what we got is what we expected + final Set expectedResolvedRanger = new LinkedHashSet<>(); + // Expected for Ranger: ALL bucket acls on wildcard pattern, volume READ, key "*" READ + final Set bucketSet = objSet(bucket("*")); + final Set bucketAcls = acls(ALL); + expectedResolvedRanger.add(new OzoneGrant(bucketSet, bucketAcls)); + expectedResolvedRanger.add(new OzoneGrant(objSet(key("*", "*")), acls(READ))); + expectedResolvedRanger.add(new OzoneGrant(objSet(volume()), acls(READ, LIST))); + assertThat(resolvedFromRangerAuthorizer).isEqualTo(expectedResolvedRanger); + } + + @Test + public void testAllActionsOnAllObjectResources() throws OMException { + final String json = "{\n" + + " \"Statement\": [{\n" + + " \"Effect\": \"Allow\",\n" + + " \"Action\": \"s3:*\",\n" + + " \"Resource\": \"arn:aws:s3:::*/*\"\n" + + " }]\n" + + "}"; + + // Wildcards on bucket are not supported for Native authorizer + expectBucketWildcardUnsupportedExceptionForNativeAuthorizer(json); + + final Set resolvedFromRangerAuthorizer = resolve(json, VOLUME, RANGER); + // Ensure what we got is what we expected + final Set expectedResolvedRanger = new LinkedHashSet<>(); + // Expected for Ranger: ALL key acls on wildcard pattern; bucket READ; volume READ + final Set keySet = objSet(key("*", "*")); + final Set keyAcls = acls(ALL); + expectedResolvedRanger.add(new OzoneGrant(keySet, keyAcls)); + expectedResolvedRanger.add(new OzoneGrant(objSet(volume(), bucket("*")), acls(READ))); + assertThat(resolvedFromRangerAuthorizer).isEqualTo(expectedResolvedRanger); + } + + @Test + public void testWildcardActionGroupGetStar() throws OMException { + final String json = "{\n" + + " \"Statement\": [{\n" + + " \"Effect\": \"Allow\",\n" + + " \"Action\": \"s3:Get*\",\n" + + " \"Resource\": [\n" + + " \"arn:aws:s3:::my-bucket\",\n" + + " \"arn:aws:s3:::my-bucket/*\"\n" + + " ]\n" + + " }]\n" + + "}"; + + final Set resolvedFromNativeAuthorizer = resolve(json, VOLUME, NATIVE); + final Set resolvedFromRangerAuthorizer = resolve(json, VOLUME, RANGER); + + // Ensure what we got is what we expected + final Set expectedResolvedNative = new LinkedHashSet<>(); + // Expected for native: bucket READ, READ_ACL acls + final Set bucketSet = objSet(bucket("my-bucket")); + final Set bucketAcls = acls(READ, READ_ACL); + expectedResolvedNative.add(new OzoneGrant(bucketSet, bucketAcls)); + // Expected for native: READ acl on prefix "" under bucket; volume READ + final Set readObjectsNative = objSet(prefix("my-bucket", ""), volume()); + expectedResolvedNative.add(new OzoneGrant(readObjectsNative, acls(READ))); + assertThat(resolvedFromNativeAuthorizer).isEqualTo(expectedResolvedNative); + + final Set expectedResolvedRanger = new LinkedHashSet<>(); + // Expected for Ranger: bucket READ, READ_ACL acls + expectedResolvedRanger.add(new OzoneGrant(bucketSet, bucketAcls)); + // Expected for Ranger: READ key acl for resource type KEY with key name "*"; volume READ + final Set readObjectsRanger = objSet(key("my-bucket", "*"), volume()); + expectedResolvedRanger.add(new OzoneGrant(readObjectsRanger, acls(READ))); + assertThat(resolvedFromRangerAuthorizer).isEqualTo(expectedResolvedRanger); + } + + @Test + public void testWildcardActionGroupListStar() throws OMException { + final String json = "{\n" + + " \"Statement\": [{\n" + + " \"Effect\": \"Allow\",\n" + + " \"Action\": \"s3:List*\",\n" + + " \"Resource\": [\n" + + " \"arn:aws:s3:::my-bucket\",\n" + + " \"arn:aws:s3:::my-bucket/*\"\n" + // ListMultipartUploadParts has READ effect on file/object resources + " ]\n" + + " }]\n" + + "}"; + + final Set resolvedFromNativeAuthorizer = resolve(json, VOLUME, NATIVE); + final Set resolvedFromRangerAuthorizer = resolve(json, VOLUME, RANGER); + + // Ensure what we got is what we expected + final Set expectedResolvedNative = new LinkedHashSet<>(); + // Expected for native: READ, LIST bucket acls + final Set bucketSet = objSet(bucket("my-bucket")); + final Set bucketAcls = acls(READ, LIST); + expectedResolvedNative.add(new OzoneGrant(bucketSet, bucketAcls)); + // Expected for native: READ acl on prefix "" under bucket; volume READ + final Set readObjectsNative = objSet(prefix("my-bucket", ""), volume()); + expectedResolvedNative.add(new OzoneGrant(readObjectsNative, acls(READ))); + assertThat(resolvedFromNativeAuthorizer).isEqualTo(expectedResolvedNative); + + final Set expectedResolvedRanger = new LinkedHashSet<>(); + // Expected for Ranger: READ, LIST bucket acls + expectedResolvedRanger.add(new OzoneGrant(bucketSet, bucketAcls)); + // Expected for Ranger: READ key acl for resource type KEY with key name "*"; volume READ + final Set readObjectsRanger = objSet(key("my-bucket", "*"), volume()); + expectedResolvedRanger.add(new OzoneGrant(readObjectsRanger, acls(READ))); + assertThat(resolvedFromRangerAuthorizer).isEqualTo(expectedResolvedRanger); + } + + @Test + public void testWildcardActionGroupPutStar() throws OMException { + final String json = "{\n" + + " \"Statement\": [{\n" + + " \"Effect\": \"Allow\",\n" + + " \"Action\": \"s3:Put*\",\n" + + " \"Resource\": [\n" + + " \"arn:aws:s3:::my-bucket\",\n" + + " \"arn:aws:s3:::my-bucket/*\"\n" + + " ]\n" + + " }]\n" + + "}"; + + final Set resolvedFromNativeAuthorizer = resolve(json, VOLUME, NATIVE); + final Set resolvedFromRangerAuthorizer = resolve(json, VOLUME, RANGER); + + // Ensure what we got is what we expected + final Set expectedResolvedNative = new LinkedHashSet<>(); + // Expected for native: bucket READ, WRITE_ACL acl + final Set bucketSet = objSet(bucket("my-bucket")); + final Set bucketAcl = acls(READ, WRITE_ACL); + expectedResolvedNative.add(new OzoneGrant(bucketSet, bucketAcl)); + // Expected for native: CREATE, WRITE acls on prefix "" under bucket + final Set keyPrefixSet = objSet(prefix("my-bucket", "")); + final Set keyAcls = acls(CREATE, WRITE); + expectedResolvedNative.add(new OzoneGrant(keyPrefixSet, keyAcls)); + // Expected for native: volume READ + expectedResolvedNative.add(new OzoneGrant(objSet(volume()), acls(READ))); + assertThat(resolvedFromNativeAuthorizer).isEqualTo(expectedResolvedNative); + + final Set expectedResolvedRanger = new LinkedHashSet<>(); + // Expected for Ranger: bucket READ, WRITE_ACL acl + expectedResolvedRanger.add(new OzoneGrant(bucketSet, bucketAcl)); + // Expected for Ranger: CREATE, WRITE key acls for resource type KEY with key name "*" + final Set rangerKeySet = objSet(key("my-bucket", "*")); + expectedResolvedRanger.add(new OzoneGrant(rangerKeySet, keyAcls)); + // Expected for Ranger: volume READ + expectedResolvedRanger.add(new OzoneGrant(objSet(volume()), acls(READ))); + assertThat(resolvedFromRangerAuthorizer).isEqualTo(expectedResolvedRanger); } - // TODO sts - add more createPathsAndPermissions tests in the next PR + @Test + public void testWildcardActionGroupDeleteStar() throws OMException { + final String json = "{\n" + + " \"Statement\": [{\n" + + " \"Effect\": \"Allow\",\n" + + " \"Action\": \"s3:Delete*\",\n" + + " \"Resource\": [\n" + + " \"arn:aws:s3:::my-bucket\",\n" + + " \"arn:aws:s3:::my-bucket/*\"\n" + + " ]\n" + + " }]\n" + + "}"; + + final Set resolvedFromNativeAuthorizer = resolve(json, VOLUME, NATIVE); + final Set resolvedFromRangerAuthorizer = resolve(json, VOLUME, RANGER); + + // Ensure what we got is what we expected + final Set expectedResolvedNative = new LinkedHashSet<>(); + // Expected for native: DELETE on prefix "" under bucket; bucket READ, DELETE; volume READ + final Set resourceSetNative = objSet(prefix("my-bucket", "")); + expectedResolvedNative.add(new OzoneGrant(resourceSetNative, acls(DELETE))); + expectedResolvedNative.add(new OzoneGrant(objSet(bucket("my-bucket")), acls(READ, DELETE))); + expectedResolvedNative.add(new OzoneGrant(objSet(volume()), acls(READ))); + assertThat(resolvedFromNativeAuthorizer).isEqualTo(expectedResolvedNative); + + final Set expectedResolvedRanger = new LinkedHashSet<>(); + // Expected for Ranger: DELETE on resource type KEY with key name "*"; bucket READ, DELETE; volume READ + final Set resourceSetRanger = objSet(key("my-bucket", "*")); + expectedResolvedRanger.add(new OzoneGrant(resourceSetRanger, acls(DELETE))); + expectedResolvedRanger.add(new OzoneGrant(objSet(bucket("my-bucket")), acls(READ, DELETE))); + expectedResolvedRanger.add(new OzoneGrant(objSet(volume()), acls(READ))); + assertThat(resolvedFromRangerAuthorizer).isEqualTo(expectedResolvedRanger); + } + + @Test + public void testMismatchedActionAndResourceReturnsEmpty() throws OMException { + final String json = "{\n" + + " \"Statement\": [{\n" + + " \"Effect\": \"Allow\",\n" + + " \"Action\": \"s3:GetObject\",\n" + // object-level action + " \"Resource\": \"arn:aws:s3:::my-bucket\"\n" + // bucket-level resource + " }]\n" + + "}"; + + final Set resolvedFromNativeAuthorizer = resolve(json, VOLUME, NATIVE); + final Set resolvedFromRangerAuthorizer = resolve(json, VOLUME, RANGER); + + // Ensure what we got is what we expected + assertThat(resolvedFromNativeAuthorizer).isEmpty(); + assertThat(resolvedFromRangerAuthorizer).isEmpty(); + } + + @Test + public void testInvalidResourceArnThrows() { + final String json = "{\n" + + " \"Statement\": [{\n" + + " \"Effect\": \"Allow\",\n" + + " \"Action\": \"s3:ListBucket\",\n" + + " \"Resource\": \"arn:aws:s3:::\"\n" + + " }]\n" + + "}"; + + expectResolveThrowsForBothAuthorizers( + json, "Invalid Resource Arn - arn:aws:s3:::", INVALID_REQUEST); + } private static void expectIllegalArgumentException(Runnable runnable, String expectedMessage) { try { @@ -1003,7 +2006,7 @@ private static Set strSet(String... strs) { private static void expectResolveThrows(String json, IamSessionPolicyResolver.AuthorizerType authorizerType, String expectedMessage, OMException.ResultCodes expectedCode) { try { - IamSessionPolicyResolver.resolve(json, VOLUME, authorizerType); + resolve(json, VOLUME, authorizerType); throw new AssertionError("Expected exception not thrown"); } catch (OMException ex) { assertThat(ex.getMessage()).isEqualTo(expectedMessage); @@ -1017,6 +2020,20 @@ private static void expectResolveThrowsForBothAuthorizers(String json, String ex expectResolveThrows(json, RANGER, expectedMessage, expectedCode); } + /** + * Ensure resources containing wildcards in buckets throw an Exception + * when the OzoneNativeAuthorizer is used. + */ + private static void expectBucketWildcardUnsupportedExceptionForNativeAuthorizer(String json) { + try { + resolve(json, VOLUME, NATIVE); + throw new AssertionError("Expected exception not thrown"); + } catch (OMException ex) { + assertThat(ex.getMessage()).isEqualTo("Wildcard bucket patterns are not supported for Ozone native authorizer"); + assertThat(ex.getResult()).isEqualTo(NOT_SUPPORTED_OPERATION); + } + } + private static String createJsonStringLargerThan2048Characters() { final StringBuilder jsonBuilder = new StringBuilder(); jsonBuilder.append("{\n"); From ae4bdb30974f0ad5aa36024bbad770fab0901d35 Mon Sep 17 00:00:00 2001 From: fmorg-git Date: Wed, 17 Dec 2025 06:00:36 -0800 Subject: [PATCH 14/54] HDDS-14091. [STS] Deny access if STS token is found in revoked table (#9445) --- .../ozone/om/exceptions/OMException.java | 2 + .../src/main/proto/OmClientProtocol.proto | 2 + .../ozone/om/OmMetadataManagerImpl.java | 4 +- .../hadoop/ozone/security/S3SecurityUtil.java | 45 +++++ .../ozone/security/STSSecurityUtil.java | 25 +++ .../ozone/om/TestOmMetadataManager.java | 30 ++- .../ozone/security/TestS3SecurityUtil.java | 186 ++++++++++++++++++ .../ozone/security/TestSTSSecurityUtil.java | 58 +++++- .../security/TestSTSTokenIdentifier.java | 3 +- 9 files changed, 343 insertions(+), 12 deletions(-) create mode 100644 hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/security/TestS3SecurityUtil.java diff --git a/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/om/exceptions/OMException.java b/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/om/exceptions/OMException.java index 596eb1276560..70acadefed8b 100644 --- a/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/om/exceptions/OMException.java +++ b/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/om/exceptions/OMException.java @@ -275,5 +275,7 @@ public enum ResultCodes { KEY_UNDER_LEASE_SOFT_LIMIT_PERIOD, TOO_MANY_SNAPSHOTS, + + REVOKED_TOKEN, } } diff --git a/hadoop-ozone/interface-client/src/main/proto/OmClientProtocol.proto b/hadoop-ozone/interface-client/src/main/proto/OmClientProtocol.proto index 6e36be5ca48d..b24aff586cea 100644 --- a/hadoop-ozone/interface-client/src/main/proto/OmClientProtocol.proto +++ b/hadoop-ozone/interface-client/src/main/proto/OmClientProtocol.proto @@ -572,6 +572,8 @@ enum Status { KEY_UNDER_LEASE_SOFT_LIMIT_PERIOD = 97; TOO_MANY_SNAPSHOTS = 98; + + REVOKED_TOKEN = 99; } /** diff --git a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/OmMetadataManagerImpl.java b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/OmMetadataManagerImpl.java index b28f8bcb9d6e..3439e04c0636 100644 --- a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/OmMetadataManagerImpl.java +++ b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/OmMetadataManagerImpl.java @@ -490,7 +490,9 @@ protected void initializeOmTables(CacheType cacheType, compactionLogTable = initializer.get(OMDBDefinition.COMPACTION_LOG_TABLE_DEF); // temporaryAccessKeyId -> sessionToken - s3RevokedStsTokenTable = initializer.get(OMDBDefinition.S3_REVOKED_STS_TOKEN_TABLE_DEF); + // FULL_CACHE keeps revocations in memory as there are not expected to be many revoked tokens + s3RevokedStsTokenTable = initializer.get( + OMDBDefinition.S3_REVOKED_STS_TOKEN_TABLE_DEF, CacheType.FULL_CACHE); } /** diff --git a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/security/S3SecurityUtil.java b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/security/S3SecurityUtil.java index e31f822b2fb7..aeb4a2e189a3 100644 --- a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/security/S3SecurityUtil.java +++ b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/security/S3SecurityUtil.java @@ -17,7 +17,9 @@ package org.apache.hadoop.ozone.security; +import static org.apache.hadoop.ozone.om.exceptions.OMException.ResultCodes.INTERNAL_ERROR; 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; @@ -25,7 +27,9 @@ 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; @@ -34,6 +38,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 @@ -44,6 +50,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() { } @@ -64,6 +71,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); @@ -124,4 +138,35 @@ 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) + throws OMException { + try { + final OMMetadataManager metadataManager = ozoneManager.getMetadataManager(); + if (metadataManager == null) { + final String msg = "Could not determine STS revocation: metadataManager is null"; + LOG.warn(msg); + throw new OMException(msg, INTERNAL_ERROR); + } + + final Table revokedStsTokenTable = metadataManager.getS3RevokedStsTokenTable(); + if (revokedStsTokenTable == null) { + final String msg = "Could not determine STS revocation: revokedStsTokenTable is null"; + LOG.warn(msg); + throw new OMException(msg, INTERNAL_ERROR); + } + + // When the STSTokenIdentifier is validated, it ensures the temp access key id is not null/empty + final String tempAccessKeyId = stsTokenIdentifier.getTempAccessKeyId(); + + return revokedStsTokenTable.getIfExist(tempAccessKeyId) != null; + } catch (Exception e) { + final String msg = "Could not determine STS revocation because of Exception: " + e.getMessage(); + LOG.warn(msg, e); + throw new OMException(msg, e, INTERNAL_ERROR); + } + } } diff --git a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/security/STSSecurityUtil.java b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/security/STSSecurityUtil.java index c3fb14d24b16..44d8b63b973f 100644 --- a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/security/STSSecurityUtil.java +++ b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/security/STSSecurityUtil.java @@ -19,10 +19,12 @@ import static org.apache.hadoop.ozone.om.exceptions.OMException.ResultCodes.INVALID_TOKEN; +import com.google.common.annotations.VisibleForTesting; import com.google.protobuf.InvalidProtocolBufferException; import java.io.IOException; import java.time.Clock; import java.util.UUID; +import org.apache.commons.lang3.StringUtils; import org.apache.hadoop.hdds.annotation.InterfaceAudience; import org.apache.hadoop.hdds.annotation.InterfaceStability; import org.apache.hadoop.hdds.security.symmetric.ManagedSecretKey; @@ -102,6 +104,9 @@ private static STSTokenIdentifier verifyAndDecryptToken(Token decodeTokenFromString(String encodedTok throw new SecretManager.InvalidToken("Failed to decode STS token string: " + e); } } + + @VisibleForTesting + static void ensureEssentialFieldsArePresentInToken(STSTokenIdentifier stsTokenIdentifier) + throws SecretManager.InvalidToken { + if (StringUtils.isEmpty(stsTokenIdentifier.getTempAccessKeyId())) { + throw new SecretManager.InvalidToken("Invalid STS token - tempAccessKeyId is null/empty"); + } + if (stsTokenIdentifier.getExpiry() == null) { + throw new SecretManager.InvalidToken("Invalid STS token - expiry is null"); + } + if (StringUtils.isEmpty(stsTokenIdentifier.getRoleArn())) { + throw new SecretManager.InvalidToken("Invalid STS token - roleArn is null/empty"); + } + if (StringUtils.isEmpty(stsTokenIdentifier.getOriginalAccessKeyId())) { + throw new SecretManager.InvalidToken("Invalid STS token - originalAccessKeyId is null/empty"); + } + if (StringUtils.isEmpty(stsTokenIdentifier.getSecretAccessKey())) { + throw new SecretManager.InvalidToken("Invalid STS token - secretAccessKey is null/empty"); + } + } } diff --git a/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/om/TestOmMetadataManager.java b/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/om/TestOmMetadataManager.java index 6f37afd0674c..3541d303b246 100644 --- a/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/om/TestOmMetadataManager.java +++ b/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/om/TestOmMetadataManager.java @@ -79,6 +79,7 @@ import org.apache.hadoop.hdds.protocol.StorageType; import org.apache.hadoop.hdds.protocol.proto.HddsProtos; import org.apache.hadoop.hdds.utils.TransactionInfo; +import org.apache.hadoop.hdds.utils.db.Table; import org.apache.hadoop.hdds.utils.db.cache.CacheKey; import org.apache.hadoop.hdds.utils.db.cache.CacheValue; import org.apache.hadoop.ozone.om.codec.OMDBDefinition; @@ -1303,18 +1304,29 @@ public void testS3RevokedStsTokenTablePutAndGet() throws Exception { final String tempAccessKeyId2 = "ASIA904E65QIGL9ON305"; final String sessionToken2 = "test-session-token-2"; - omMetadataManager.getS3RevokedStsTokenTable() - .put(tempAccessKeyId1, sessionToken1); - omMetadataManager.getS3RevokedStsTokenTable() - .put(tempAccessKeyId2, sessionToken2); + final Table table = omMetadataManager.getS3RevokedStsTokenTable(); + + // This table is configured as FULL_CACHE in OmMetadataManagerImpl. + // A put() writes to RocksDB but does not update the table cache, so get() and getIfExist() will return null unless + // the cache is updated with addCacheEntry(). getSkipCache() will read the DB instead of the cache. + table.put(tempAccessKeyId1, sessionToken1); + table.put(tempAccessKeyId2, sessionToken2); + + // Verify the values are persisted in RocksDB. + assertEquals(sessionToken1, table.getSkipCache(tempAccessKeyId1)); + assertEquals(sessionToken2, table.getSkipCache(tempAccessKeyId2)); + + // Update cache to make get/getIfExist reflect the write for FULL_CACHE tables. + table.addCacheEntry(tempAccessKeyId1, sessionToken1, 1L); + table.addCacheEntry(tempAccessKeyId2, sessionToken2, 1L); // Verify get and getIfExist return the stored value - assertEquals(sessionToken1, omMetadataManager.getS3RevokedStsTokenTable().get(tempAccessKeyId1)); - assertEquals(sessionToken1, omMetadataManager.getS3RevokedStsTokenTable().getIfExist(tempAccessKeyId1)); - assertEquals(sessionToken2, omMetadataManager.getS3RevokedStsTokenTable().get(tempAccessKeyId2)); - assertEquals(sessionToken2, omMetadataManager.getS3RevokedStsTokenTable().getIfExist(tempAccessKeyId2)); + assertEquals(sessionToken1, table.get(tempAccessKeyId1)); + assertEquals(sessionToken1, table.getIfExist(tempAccessKeyId1)); + assertEquals(sessionToken2, table.get(tempAccessKeyId2)); + assertEquals(sessionToken2, table.getIfExist(tempAccessKeyId2)); // Unknown key should return null for getIfExist - assertNull(omMetadataManager.getS3RevokedStsTokenTable().getIfExist("ASIA_UNKNOWN_ACCESS_KEY")); + assertNull(table.getIfExist("ASIA_UNKNOWN_ACCESS_KEY")); } } diff --git a/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/security/TestS3SecurityUtil.java b/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/security/TestS3SecurityUtil.java new file mode 100644 index 000000000000..c5cce385071b --- /dev/null +++ b/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/security/TestS3SecurityUtil.java @@ -0,0 +1,186 @@ +/* + * 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.INTERNAL_ERROR; +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.junit.jupiter.api.Assertions.assertTrue; +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.doThrow; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.when; + +import java.time.Clock; +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.apache.ozone.test.TestClock; +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]; + private static final TestClock CLOCK = TestClock.newInstance(); + + { + 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 + final OMMetadataManager metadataManager = mock(OMMetadataManager.class); + final Table revokedSTSTokenTable = new InMemoryTestTable<>(); + validateS3CredentialHelper( + "session-token-a", metadataManager, revokedSTSTokenTable, true, createSTSTokenIdentifier(), + REVOKED_TOKEN, "STS token has been revoked"); + } + + @Test + public void testValidateS3CredentialWhenMetadataUnavailable() throws Exception { + // If the metadata manager is not available, throws INTERNAL_ERROR + validateS3CredentialHelper( + "session-token-b", null, null, false, createSTSTokenIdentifier(), + INTERNAL_ERROR, "Could not determine STS revocation: metadataManager is null"); + } + + @Test + public void testValidateS3CredentialSuccessWhenNotRevoked() throws Exception { + // Normal case: token is NOT revoked and request is accepted + final OMMetadataManager metadataManager = mock(OMMetadataManager.class); + final Table revokedSTSTokenTable = new InMemoryTestTable<>(); + validateS3CredentialHelper( + "session-token-c", metadataManager, revokedSTSTokenTable, false, createSTSTokenIdentifier(), + null, null); + } + + @Test + public void testValidateS3CredentialWhenMetadataManagerAvailableButRevokedTableNull() throws Exception { + // If the revoked STS token table is not available, throws INTERNAL_ERROR + final OMMetadataManager metadataManager = mock(OMMetadataManager.class); + validateS3CredentialHelper( + "session-token-d", metadataManager, null, false, createSTSTokenIdentifier(), + INTERNAL_ERROR, "Could not determine STS revocation: revokedStsTokenTable is null"); + } + + @Test + public void testValidateS3CredentialWhenTableThrowsException() throws Exception { + // If the revoked STS token table lookup throws, throws INTERNAL_ERROR (wrapped) + final OMMetadataManager metadataManager = mock(OMMetadataManager.class); + final Table revokedSTSTokenTable = spy(new InMemoryTestTable<>()); + doThrow(new RuntimeException("lookup failed")).when(revokedSTSTokenTable).getIfExist(anyString()); + validateS3CredentialHelper( + "session-token-g", metadataManager, revokedSTSTokenTable, false, createSTSTokenIdentifier(), + INTERNAL_ERROR, "Could not determine STS revocation because of Exception: lookup failed"); + } + + private void validateS3CredentialHelper(String sessionToken, OMMetadataManager metadataManager, + Table revokedSTSTokenTable, boolean isRevoked, STSTokenIdentifier stsTokenIdentifier, + OMException.ResultCodes expectedResult, String expectedMessageContents) throws Exception { + + try (OzoneManager ozoneManager = mock(OzoneManager.class)) { + when(ozoneManager.isSecurityEnabled()).thenReturn(true); + when(ozoneManager.getSecretKeyClient()).thenReturn(mock(SecretKeyClient.class)); + + when(ozoneManager.getMetadataManager()).thenReturn(metadataManager); + if (metadataManager != null) { + when(metadataManager.getS3RevokedStsTokenTable()).thenReturn(revokedSTSTokenTable); + } + + final String tempAccessKeyId = "temp-access-key-id"; + if (isRevoked) { + if (revokedSTSTokenTable == null) { + throw new IllegalArgumentException("revokedSTSTokenTable must not be null when isRevoked=true"); + } + revokedSTSTokenTable.put(tempAccessKeyId, sessionToken); + } + + try (MockedStatic stsSecurityUtilMock = mockStatic(STSSecurityUtil.class, CALLS_REAL_METHODS); + MockedStatic 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 omException = assertThrows( + OMException.class, () -> S3SecurityUtil.validateS3Credential(omRequest, ozoneManager)); + assertEquals(expectedResult, omException.getResult()); + if (expectedMessageContents != null) { + assertTrue( + omException.getMessage().contains(expectedMessageContents), + "Expected exception message to contain: '" + expectedMessageContents + "' but was: '" + + omException.getMessage() + "'"); + } + } 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", + CLOCK.instant().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(); + } +} diff --git a/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/security/TestSTSSecurityUtil.java b/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/security/TestSTSSecurityUtil.java index 96c832877059..6cf19b182eee 100644 --- a/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/security/TestSTSSecurityUtil.java +++ b/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/security/TestSTSSecurityUtil.java @@ -28,12 +28,14 @@ import java.time.Instant; import java.time.ZoneOffset; import java.util.UUID; +import java.util.concurrent.ThreadLocalRandom; import org.apache.hadoop.hdds.security.exception.SCMSecurityException; import org.apache.hadoop.hdds.security.symmetric.ManagedSecretKey; import org.apache.hadoop.hdds.security.symmetric.SecretKeyClient; import org.apache.hadoop.io.Text; import org.apache.hadoop.ozone.om.exceptions.OMException; import org.apache.hadoop.ozone.protocol.proto.OzoneManagerProtocolProtos.OMTokenProto; +import org.apache.hadoop.security.token.SecretManager; import org.apache.hadoop.security.token.Token; import org.apache.ozone.test.TestClock; import org.junit.jupiter.api.Test; @@ -48,12 +50,17 @@ public class TestSTSSecurityUtil { private static final String SECRET_ACCESS_KEY = "test-secret-access-key"; private static final String SESSION_POLICY = "test-session-policy"; private static final int DURATION_SECONDS = 3600; + private static final byte[] ENCRYPTION_KEY = new byte[5]; private final SecretKeyTestClient secretKeyClient = new SecretKeyTestClient(); private final STSTokenSecretManager tokenSecretManager = new STSTokenSecretManager(secretKeyClient); private final UUID secretKeyId = secretKeyClient.getCurrentSecretKey().getId(); private final TestClock clock = new TestClock(Instant.ofEpochMilli(1764819000), ZoneOffset.UTC); + { + ThreadLocalRandom.current().nextBytes(ENCRYPTION_KEY); + } + @Test public void testConstructValidateAndDecryptSTSTokenInvalidProtobuf() throws IOException { // Create a token whose identifier bytes are not a valid OMTokenProto @@ -314,5 +321,54 @@ public void testConstructValidateAndDecryptMultipleTokens() throws Exception { assertThat(result2.getOwnerId()).isEqualTo("temp-key-2"); assertThat(result2.getOriginalAccessKeyId()).isEqualTo("orig-key-2"); } -} + @Test + public void testEnsureEssentialFieldsArePresentInTokenMissingExpiry() { + final STSTokenIdentifier tokenIdentifier = new STSTokenIdentifier( + TEMP_ACCESS_KEY, ORIGINAL_ACCESS_KEY, ROLE_ARN, null, SECRET_ACCESS_KEY, SESSION_POLICY, ENCRYPTION_KEY); + + assertThatThrownBy(() -> STSSecurityUtil.ensureEssentialFieldsArePresentInToken(tokenIdentifier)) + .isInstanceOf(SecretManager.InvalidToken.class) + .hasMessage("Invalid STS token - expiry is null"); + } + + @Test + public void testEnsureEssentialFieldsArePresentInTokenMissingTempAccessKeyId() { + final STSTokenIdentifier tokenIdentifier = new STSTokenIdentifier( + null, ORIGINAL_ACCESS_KEY, ROLE_ARN, clock.instant(), SECRET_ACCESS_KEY, SESSION_POLICY, ENCRYPTION_KEY); + + assertThatThrownBy(() -> STSSecurityUtil.ensureEssentialFieldsArePresentInToken(tokenIdentifier)) + .isInstanceOf(SecretManager.InvalidToken.class) + .hasMessage("Invalid STS token - tempAccessKeyId is null/empty"); + } + + @Test + public void testEnsureEssentialFieldsArePresentInTokenMissingRoleArn() { + final STSTokenIdentifier tokenIdentifier = new STSTokenIdentifier( + TEMP_ACCESS_KEY, ORIGINAL_ACCESS_KEY, null, clock.instant(), SECRET_ACCESS_KEY, SESSION_POLICY, ENCRYPTION_KEY); + + assertThatThrownBy(() -> STSSecurityUtil.ensureEssentialFieldsArePresentInToken(tokenIdentifier)) + .isInstanceOf(SecretManager.InvalidToken.class) + .hasMessage("Invalid STS token - roleArn is null/empty"); + } + + @Test + public void testEnsureEssentialFieldsArePresentInTokenMissingOriginalAccessKeyId() { + final STSTokenIdentifier tokenIdentifier = new STSTokenIdentifier( + TEMP_ACCESS_KEY, null, ROLE_ARN, clock.instant(), SECRET_ACCESS_KEY, SESSION_POLICY, ENCRYPTION_KEY); + + assertThatThrownBy(() -> STSSecurityUtil.ensureEssentialFieldsArePresentInToken(tokenIdentifier)) + .isInstanceOf(SecretManager.InvalidToken.class) + .hasMessage("Invalid STS token - originalAccessKeyId is null/empty"); + } + + @Test + public void testEnsureEssentialFieldsArePresentInTokenMissingSecretAccessKey() { + final STSTokenIdentifier tokenIdentifier = new STSTokenIdentifier( + TEMP_ACCESS_KEY, ORIGINAL_ACCESS_KEY, ROLE_ARN, clock.instant(), null, SESSION_POLICY, ENCRYPTION_KEY); + + assertThatThrownBy(() -> STSSecurityUtil.ensureEssentialFieldsArePresentInToken(tokenIdentifier)) + .isInstanceOf(SecretManager.InvalidToken.class) + .hasMessage("Invalid STS token - secretAccessKey is null/empty"); + } +} diff --git a/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/security/TestSTSTokenIdentifier.java b/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/security/TestSTSTokenIdentifier.java index 549d473a49d2..09a786faaea3 100644 --- a/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/security/TestSTSTokenIdentifier.java +++ b/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/security/TestSTSTokenIdentifier.java @@ -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; @@ -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 From 08186522ad5bb1974ee07790d943b683e1f02ec1 Mon Sep 17 00:00:00 2001 From: fmorg-git Date: Fri, 19 Dec 2025 08:45:38 -0800 Subject: [PATCH 15/54] HDDS-14011. [STS] Ranger interactions for STS tokens (assumeRole and S3 api calls) (#9484) --- .../ozone/security/acl/RequestContext.java | 13 ++ .../hadoop/ozone/om/OmMetadataReader.java | 30 ++- .../s3/security/S3AssumeRoleRequest.java | 46 ++++- .../hadoop/ozone/om/TestOMMetadataReader.java | 133 +++++++++++++ .../s3/security/TestS3AssumeRoleRequest.java | 181 ++++++++++++++++-- .../security/acl/TestRequestContext.java | 66 +++++++ .../ozone/security/acl/package-info.java | 21 ++ 7 files changed, 458 insertions(+), 32 deletions(-) create mode 100644 hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/security/acl/package-info.java diff --git a/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/security/acl/RequestContext.java b/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/security/acl/RequestContext.java index f2d25c2ad231..18dbe94a4a80 100644 --- a/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/security/acl/RequestContext.java +++ b/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/security/acl/RequestContext.java @@ -238,4 +238,17 @@ public boolean isRecursiveAccessCheck() { public String getSessionPolicy() { return sessionPolicy; } + + public Builder toBuilder() { + return newBuilder() + .setHost(host) + .setIp(ip) + .setClientUgi(clientUgi) + .setServiceId(serviceId) + .setAclType(aclType) + .setAclRights(aclRights) + .setOwnerName(ownerName) + .setRecursiveAccessCheck(recursiveAccessCheck) + .setSessionPolicy(sessionPolicy); + } } diff --git a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/OmMetadataReader.java b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/OmMetadataReader.java index 2fac369e3a2b..e25cd3d42719 100644 --- a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/OmMetadataReader.java +++ b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/OmMetadataReader.java @@ -54,6 +54,7 @@ import org.apache.hadoop.ozone.om.helpers.OzoneFileStatusLight; import org.apache.hadoop.ozone.om.helpers.S3VolumeContext; import org.apache.hadoop.ozone.om.protocolPB.grpc.GrpcClientConstants; +import org.apache.hadoop.ozone.security.STSTokenIdentifier; import org.apache.hadoop.ozone.security.acl.IAccessAuthorizer; import org.apache.hadoop.ozone.security.acl.IAccessAuthorizer.ACLIdentityType; import org.apache.hadoop.ozone.security.acl.IAccessAuthorizer.ACLType; @@ -589,8 +590,10 @@ public boolean checkAcls(ResourceType resType, StoreType storeType, public boolean checkAcls(OzoneObj obj, RequestContext context, boolean throwIfPermissionDenied) throws OMException { + final RequestContext normalizedRequestContext = maybeAttachSessionPolicyFromThreadLocal(context); + if (!captureLatencyNs(perfMetrics::setCheckAccessLatencyNs, - () -> accessAuthorizer.checkAccess(obj, context))) { + () -> accessAuthorizer.checkAccess(obj, normalizedRequestContext))) { if (throwIfPermissionDenied) { String volumeName = obj.getVolumeName() != null ? "Volume:" + obj.getVolumeName() + " " : ""; @@ -599,11 +602,12 @@ public boolean checkAcls(OzoneObj obj, RequestContext context, String keyName = obj.getKeyName() != null ? "Key:" + obj.getKeyName() : ""; log.warn("User {} doesn't have {} permission to access {} {}{}{}", - context.getClientUgi().getShortUserName(), context.getAclRights(), + normalizedRequestContext.getClientUgi().getShortUserName(), + normalizedRequestContext.getAclRights(), obj.getResourceType(), volumeName, bucketName, keyName); throw new OMException( - "User " + context.getClientUgi().getShortUserName() + - " doesn't have " + context.getAclRights() + + "User " + normalizedRequestContext.getClientUgi().getShortUserName() + + " doesn't have " + normalizedRequestContext.getAclRights() + " permission to access " + obj.getResourceType() + " " + volumeName + bucketName + keyName, ResultCodes.PERMISSION_DENIED); } @@ -613,6 +617,24 @@ public boolean checkAcls(OzoneObj obj, RequestContext context, } } + /** + * Attaches session policy to RequestContext if an STSTokenIdentifier is found in the Ozone Manager thread local + * (meaning this is an STS request), and the STSTokenIdentifier has a session policy. Otherwise, returns the + * RequestContext as it was before. + * @param context the original RequestContext + * @return RequestContext as before or with sessionPolicy embedded + */ + private RequestContext maybeAttachSessionPolicyFromThreadLocal(RequestContext context) { + final STSTokenIdentifier stsTokenIdentifier = OzoneManager.getStsTokenIdentifier(); + if (stsTokenIdentifier == null) { + return context; + } + + return context.toBuilder() + .setSessionPolicy(stsTokenIdentifier.getSessionPolicy()) + .build(); + } + static String getClientAddress() { String clientMachine = Server.getRemoteAddress(); if (clientMachine == null) { //not a RPC client 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 b02f78e5643f..aecba45f32cd 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 @@ -17,14 +17,17 @@ package org.apache.hadoop.ozone.om.request.s3.security; +import static org.apache.hadoop.ozone.security.acl.AssumeRoleRequest.OzoneGrant; + import com.google.common.annotations.VisibleForTesting; import java.io.IOException; import java.net.InetAddress; import java.security.SecureRandom; import java.time.Clock; -import java.time.Instant; -import java.time.ZoneOffset; +import java.util.Optional; +import java.util.Set; import org.apache.commons.lang3.StringUtils; +import org.apache.hadoop.hdds.scm.client.HddsClientUtils; import org.apache.hadoop.ipc.ProtobufRpcEngine; import org.apache.hadoop.ozone.om.OzoneAclUtils; import org.apache.hadoop.ozone.om.OzoneManager; @@ -37,6 +40,7 @@ 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.security.acl.iam.IamSessionPolicyResolver; import org.apache.hadoop.security.UserGroupInformation; /** @@ -71,12 +75,13 @@ public class S3AssumeRoleRequest extends OMClientRequest { 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(); - private static final Clock CLOCK = Clock.system(ZoneOffset.UTC); - public static final String STS_TOKEN_PREFIX = "ASIA"; - public S3AssumeRoleRequest(OMRequest omRequest) { + private final Clock clock; + + public S3AssumeRoleRequest(OMRequest omRequest, Clock clock) { super(omRequest); + this.clock = clock; } @Override @@ -127,7 +132,7 @@ public OMClientResponse validateAndUpdateCache(OzoneManager ozoneManager, Execut final String assumedRoleId = roleId + ":" + roleSessionName; // Calculate expiration of session token - final long expirationEpochSeconds = Instant.now().plusSeconds(durationSeconds).getEpochSecond(); + final long expirationEpochSeconds = clock.instant().plusSeconds(durationSeconds).getEpochSecond(); final AssumeRoleResponse.Builder responseBuilder = AssumeRoleResponse.newBuilder() .setAccessKeyId(tempAccessKeyId) @@ -201,7 +206,7 @@ private String generateSessionToken(String targetRoleName, OMRequest omRequest, return ozoneManager.getSTSTokenSecretManager().createSTSTokenString( tempAccessKeyId, originalAccessKeyId, roleArn, assumeRoleRequest.getDurationSeconds(), secretAccessKey, - sessionPolicy, CLOCK); + sessionPolicy, clock); } /** @@ -209,10 +214,31 @@ private String generateSessionToken(String targetRoleName, OMRequest omRequest, * to IAccessAuthorizer.generateAssumeRoleSessionPolicy() which is currently only implemented * by RangerOzoneAuthorizer. */ - private String getSessionPolicy(OzoneManager ozoneManager, String originalAccessKeyId, String awsIamPolicy, + @VisibleForTesting + 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; + + final String volumeName; + if (ozoneManager.isS3MultiTenancyEnabled()) { + final Optional tenantOpt = ozoneManager.getMultiTenantManager() + .getTenantForAccessID(originalAccessKeyId); + if (tenantOpt.isPresent()) { + volumeName = ozoneManager.getMultiTenantManager() + .getTenantVolumeName(tenantOpt.get()); + } else { + volumeName = HddsClientUtils.getDefaultS3VolumeName(ozoneManager.getConfiguration()); + } + } else { + volumeName = HddsClientUtils.getDefaultS3VolumeName(ozoneManager.getConfiguration()); + } + + final Set grants = StringUtils.isBlank(awsIamPolicy) ? + null : + IamSessionPolicyResolver.resolve(awsIamPolicy, volumeName, IamSessionPolicyResolver.AuthorizerType.RANGER); + + return ozoneManager.getAccessAuthorizer().generateAssumeRoleSessionPolicy( + new org.apache.hadoop.ozone.security.acl.AssumeRoleRequest( + hostName, remoteIp, ugi, targetRoleName, grants)); } /** diff --git a/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/om/TestOMMetadataReader.java b/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/om/TestOMMetadataReader.java index 00a94a538c3a..a6dc8e78ca64 100644 --- a/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/om/TestOMMetadataReader.java +++ b/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/om/TestOMMetadataReader.java @@ -18,20 +18,41 @@ package org.apache.hadoop.ozone.om; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import io.grpc.Context; +import java.net.InetAddress; import org.apache.hadoop.ipc.Server; +import org.apache.hadoop.ozone.audit.AuditLogger; +import org.apache.hadoop.ozone.om.exceptions.OMException; +import org.apache.hadoop.ozone.security.STSTokenIdentifier; +import org.apache.hadoop.ozone.security.acl.IAccessAuthorizer; +import org.apache.hadoop.ozone.security.acl.OzoneObj; +import org.apache.hadoop.ozone.security.acl.OzoneObjInfo; +import org.apache.hadoop.ozone.security.acl.RequestContext; +import org.apache.hadoop.security.UserGroupInformation; +import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; import org.mockito.MockedStatic; +import org.slf4j.Logger; /** * Test ozone metadata reader. */ public class TestOMMetadataReader { + @AfterEach + public void clearStsThreadLocal() { + OzoneManager.setStsTokenIdentifier(null); + } + @Test public void testGetClientAddress() { try ( @@ -69,4 +90,116 @@ public void testGetClientAddress() { } } + @Test + public void testCheckAclsAttachesSessionPolicyFromThreadLocal() throws Exception { + final String sessionPolicy = "session-policy-from-thread-local"; + setupStsTokenIdentifier(sessionPolicy); + + final IAccessAuthorizer accessAuthorizer = createMockIAccessAuthorizerReturningTrue(); + final OmMetadataReader omMetadataReader = createMetadataReader(accessAuthorizer); + + final RequestContext contextWithoutSessionPolicy = createTestRequestContext(null); + final OzoneObj obj = createTestOzoneObj(); + + assertTrue(omMetadataReader.checkAcls(obj, contextWithoutSessionPolicy, true)); + + verifySessionPolicyPassedToAuthorizer(accessAuthorizer, obj, sessionPolicy); + } + + @Test + public void testNoSessionPolicyWhenThreadLocalIsNull() throws Exception { + // No STS token identifier in thread local + OzoneManager.setStsTokenIdentifier(null); + + final IAccessAuthorizer accessAuthorizer = createMockIAccessAuthorizerReturningTrue(); + final OmMetadataReader omMetadataReader = createMetadataReader(accessAuthorizer); + + final RequestContext contextWithoutSessionPolicy = createTestRequestContext(null); + final OzoneObj obj = createTestOzoneObj(); + + assertTrue(omMetadataReader.checkAcls(obj, contextWithoutSessionPolicy, true)); + + verifySessionPolicyPassedToAuthorizer(accessAuthorizer, obj, null); + } + + private OmMetadataReader createMetadataReader(IAccessAuthorizer accessAuthorizer) { + final OzoneManager ozoneManager = mock(OzoneManager.class); + when(ozoneManager.getBucketManager()).thenReturn(mock(BucketManager.class)); + when(ozoneManager.getVolumeManager()).thenReturn(mock(VolumeManager.class)); + when(ozoneManager.getAclsEnabled()).thenReturn(true); + when(ozoneManager.getPerfMetrics()).thenReturn(mock(OMPerformanceMetrics.class)); + + return new OmMetadataReader( + mock(KeyManager.class), mock(PrefixManager.class), ozoneManager, mock(Logger.class), mock(AuditLogger.class), + mock(OmMetadataReaderMetrics.class), accessAuthorizer); + } + + /** + * Creates and sets a mock STSTokenIdentifier with the given session policy in the thread-local. + * @param sessionPolicy the session policy to return, or null + */ + private void setupStsTokenIdentifier(String sessionPolicy) { + final STSTokenIdentifier stsTokenIdentifier = mock(STSTokenIdentifier.class); + when(stsTokenIdentifier.getSessionPolicy()).thenReturn(sessionPolicy); + OzoneManager.setStsTokenIdentifier(stsTokenIdentifier); + } + + /** + * Creates a mock IAccessAuthorizer that returns the specified result for checkAccess. + * @return the mocked IAccessAuthorizer + */ + private IAccessAuthorizer createMockIAccessAuthorizerReturningTrue() throws OMException { + final IAccessAuthorizer accessAuthorizer = mock(IAccessAuthorizer.class); + when(accessAuthorizer.checkAccess(any(OzoneObj.class), any(RequestContext.class))) + .thenReturn(true); + return accessAuthorizer; + } + + /** + * Creates a test RequestContext with the given session policy. + * @param sessionPolicy the session policy to set, or null + * @return the constructed RequestContext + */ + private RequestContext createTestRequestContext(String sessionPolicy) { + RequestContext.Builder builder = RequestContext.newBuilder() + .setClientUgi(UserGroupInformation.createRemoteUser("testUser")) + .setIp(InetAddress.getLoopbackAddress()) + .setHost("localhost") + .setAclType(IAccessAuthorizer.ACLIdentityType.USER) + .setAclRights(IAccessAuthorizer.ACLType.READ) + .setOwnerName("owner"); + + if (sessionPolicy != null) { + builder.setSessionPolicy(sessionPolicy); + } + + return builder.build(); + } + + /** + * Creates a test OzoneObj representing a key. + * @return the constructed OzoneObj + */ + private OzoneObj createTestOzoneObj() { + return OzoneObjInfo.Builder.newBuilder() + .setResType(OzoneObj.ResourceType.KEY) + .setStoreType(OzoneObj.StoreType.OZONE) + .setVolumeName("vol") + .setBucketName("bucket") + .setKeyName("key") + .build(); + } + + /** + * Verifies that the accessAuthorizer received a call to checkAccess with the expected session policy. + * @param accessAuthorizer the mock authorizer to verify + * @param expectedObj the expected OzoneObj + * @param expectedSessionPolicy the expected session policy (may be null) + */ + private void verifySessionPolicyPassedToAuthorizer(IAccessAuthorizer accessAuthorizer, OzoneObj expectedObj, + String expectedSessionPolicy) throws OMException { + final ArgumentCaptor captor = ArgumentCaptor.forClass(RequestContext.class); + verify(accessAuthorizer).checkAccess(eq(expectedObj), captor.capture()); + assertEquals(expectedSessionPolicy, captor.getValue().getSessionPolicy()); + } } 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 0940bbc55454..3ae3c3c7599e 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 @@ -17,21 +17,32 @@ package org.apache.hadoop.ozone.om.request.s3.security; +import static java.util.Collections.emptySet; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import java.io.IOException; +import java.net.InetAddress; import java.net.InetSocketAddress; import java.nio.charset.StandardCharsets; import java.time.Instant; +import java.time.ZoneOffset; +import java.util.Collections; +import java.util.Optional; +import java.util.Set; import java.util.UUID; import java.util.regex.Pattern; import javax.crypto.SecretKey; import javax.crypto.spec.SecretKeySpec; +import org.apache.hadoop.hdds.conf.OzoneConfiguration; import org.apache.hadoop.hdds.security.symmetric.ManagedSecretKey; import org.apache.hadoop.hdds.security.symmetric.SecretKeySignerClient; +import org.apache.hadoop.ozone.om.OMMultiTenantManager; import org.apache.hadoop.ozone.om.OzoneManager; import org.apache.hadoop.ozone.om.execution.flowcontrol.ExecutionContext; import org.apache.hadoop.ozone.om.response.OMClientResponse; @@ -43,9 +54,16 @@ import org.apache.hadoop.ozone.protocol.proto.OzoneManagerProtocolProtos.Status; import org.apache.hadoop.ozone.protocol.proto.OzoneManagerProtocolProtos.Type; import org.apache.hadoop.ozone.security.STSTokenSecretManager; +import org.apache.hadoop.ozone.security.acl.AssumeRoleRequest.OzoneGrant; +import org.apache.hadoop.ozone.security.acl.IAccessAuthorizer; +import org.apache.hadoop.ozone.security.acl.iam.IamSessionPolicyResolver; +import org.apache.hadoop.security.UserGroupInformation; import org.apache.hadoop.security.token.TokenIdentifier; +import org.apache.ozone.test.TestClock; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.MockedStatic; /** * Unit tests for S3AssumeRoleRequest. @@ -55,14 +73,33 @@ 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 static final String TARGET_ROLE_NAME = "targetRole"; + private static final String SESSION_POLICY_VALUE = "session-policy"; + private static final String AWS_IAM_POLICY = "{\n" + + " \"Statement\": [{\n" + + " \"Effect\": \"Allow\",\n" + + " \"Action\": \"s3:*\",\n" + + " \"Resource\": \"arn:aws:s3:::*/*\"\n" + + " }]\n" + + "}"; + + private static final TestClock CLOCK = new TestClock(Instant.ofEpochMilli(1764819000), ZoneOffset.UTC); + private static final String OM_HOST = "om-host"; + private static final InetAddress LOOPBACK_IP = InetAddress.getLoopbackAddress(); + private static final Set EMPTY_GRANTS = Collections.singleton(new OzoneGrant(emptySet(), emptySet())); private OzoneManager ozoneManager; private ExecutionContext context; + private IAccessAuthorizer accessAuthorizer; @BeforeEach public void setup() throws IOException { ozoneManager = mock(OzoneManager.class); + final OzoneConfiguration configuration = new OzoneConfiguration(); + when(ozoneManager.getConfiguration()).thenReturn(configuration); + when(ozoneManager.isS3MultiTenancyEnabled()).thenReturn(false); + final SecretKeySignerClient secretKeyClient = mock(SecretKeySignerClient.class); final ManagedSecretKey managedSecretKey = mock(ManagedSecretKey.class); final SecretKey secretKey = new SecretKeySpec( @@ -80,6 +117,13 @@ public void setup() throws IOException { when(ozoneManager.getOmRpcServerAddr()).thenReturn( new InetSocketAddress("localhost", 9876)); when(ozoneManager.getSTSTokenSecretManager()).thenReturn(stsTokenSecretManager); + + accessAuthorizer = mock(IAccessAuthorizer.class); + when(ozoneManager.getAccessAuthorizer()).thenReturn(accessAuthorizer); + when(accessAuthorizer.generateAssumeRoleSessionPolicy(any( + org.apache.hadoop.ozone.security.acl.AssumeRoleRequest.class))) + .thenReturn(SESSION_POLICY_VALUE); + context = ExecutionContext.of(1L, null); } @@ -93,7 +137,7 @@ public void testInvalidDurationTooShort() { .setDurationSeconds(899) // less than 900 ).build(); - final OMClientResponse response = new S3AssumeRoleRequest(omRequest) + final OMClientResponse response = new S3AssumeRoleRequest(omRequest, CLOCK) .validateAndUpdateCache(ozoneManager, context); final OMResponse omResponse = response.getOMResponse(); @@ -112,7 +156,7 @@ public void testInvalidDurationTooLong() { .setDurationSeconds(43201) // more than 43200 ).build(); - final OMClientResponse response = new S3AssumeRoleRequest(omRequest) + final OMClientResponse response = new S3AssumeRoleRequest(omRequest, CLOCK) .validateAndUpdateCache(ozoneManager, context); final OMResponse omResponse = response.getOMResponse(); @@ -131,7 +175,7 @@ public void testValidDurationMaxBoundary() { .setDurationSeconds(43200) // exactly max ).build(); - final OMClientResponse response = new S3AssumeRoleRequest(omRequest) + final OMClientResponse response = new S3AssumeRoleRequest(omRequest, CLOCK) .validateAndUpdateCache(ozoneManager, context); final OMResponse omResponse = response.getOMResponse(); @@ -149,7 +193,7 @@ public void testValidDurationMinBoundary() { .setDurationSeconds(900) // exactly min ).build(); - final OMClientResponse response = new S3AssumeRoleRequest(omRequest) + final OMClientResponse response = new S3AssumeRoleRequest(omRequest, CLOCK) .validateAndUpdateCache(ozoneManager, context); final OMResponse omResponse = response.getOMResponse(); @@ -169,7 +213,7 @@ public void testMissingS3Authentication() { .setDurationSeconds(3600) ).build(); - final OMClientResponse response = new S3AssumeRoleRequest(omRequest) + final OMClientResponse response = new S3AssumeRoleRequest(omRequest, CLOCK) .validateAndUpdateCache(ozoneManager, context); final OMResponse omResponse = response.getOMResponse(); @@ -189,8 +233,7 @@ public void testSuccessfulAssumeRoleGeneratesCredentials() { .setDurationSeconds(durationSeconds) ).build(); - final long before = Instant.now().getEpochSecond(); - final OMClientResponse clientResponse = new S3AssumeRoleRequest(omRequest) + final OMClientResponse clientResponse = new S3AssumeRoleRequest(omRequest, CLOCK) .validateAndUpdateCache(ozoneManager, context); final OMResponse omResponse = clientResponse.getOMResponse(); @@ -214,10 +257,9 @@ public void testSuccessfulAssumeRoleGeneratesCredentials() { 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(); + // Verify expiration added durationSeconds final long expirationEpochSeconds = assumeRoleResponse.getExpirationEpochSeconds(); - assertThat(expirationEpochSeconds).isBetween(before + durationSeconds - 1, after + durationSeconds + 1); + assertThat(expirationEpochSeconds).isEqualTo(CLOCK.instant().getEpochSecond() + durationSeconds); } @Test @@ -250,9 +292,9 @@ public void testAssumeRoleCredentialsAreUnique() { .setDurationSeconds(3600) ).build(); - final OMClientResponse response1 = new S3AssumeRoleRequest(omRequest) + final OMClientResponse response1 = new S3AssumeRoleRequest(omRequest, CLOCK) .validateAndUpdateCache(ozoneManager, context); - final OMClientResponse response2 = new S3AssumeRoleRequest(omRequest) + final OMClientResponse response2 = new S3AssumeRoleRequest(omRequest, CLOCK) .validateAndUpdateCache(ozoneManager, context); final AssumeRoleResponse assumeRoleResponse1 = response1.getOMResponse().getAssumeRoleResponse(); @@ -281,7 +323,7 @@ public void testAssumeRoleWithEmptySessionName() { .setDurationSeconds(3600) ).build(); - final OMClientResponse response = new S3AssumeRoleRequest(omRequest) + 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"); @@ -296,7 +338,7 @@ public void testInvalidAssumeRoleSessionNameTooShort() { .setRoleSessionName("T") // Less than 2 characters ).build(); - final OMClientResponse response = new S3AssumeRoleRequest(omRequest) + final OMClientResponse response = new S3AssumeRoleRequest(omRequest, CLOCK) .validateAndUpdateCache(ozoneManager, context); final OMResponse omResponse = response.getOMResponse(); @@ -315,7 +357,7 @@ public void testInvalidRoleSessionNameTooLong() { .setRoleSessionName(tooLongRoleSessionName) ).build(); - final OMClientResponse response = new S3AssumeRoleRequest(omRequest) + final OMClientResponse response = new S3AssumeRoleRequest(omRequest, CLOCK) .validateAndUpdateCache(ozoneManager, context); final OMResponse omResponse = response.getOMResponse(); @@ -334,7 +376,7 @@ public void testValidRoleSessionNameMaxLengthBoundary() { .setRoleSessionName(roleSessionName) // exactly max length ).build(); - final OMClientResponse response = new S3AssumeRoleRequest(omRequest) + final OMClientResponse response = new S3AssumeRoleRequest(omRequest, CLOCK) .validateAndUpdateCache(ozoneManager, context); final OMResponse omResponse = response.getOMResponse(); @@ -351,7 +393,7 @@ public void testValidRoleSessionNameMinLengthBoundary() { .setRoleSessionName("TT") // exactly min length ).build(); - final OMClientResponse response = new S3AssumeRoleRequest(omRequest) + final OMClientResponse response = new S3AssumeRoleRequest(omRequest, CLOCK) .validateAndUpdateCache(ozoneManager, context); final OMResponse omResponse = response.getOMResponse(); @@ -371,11 +413,114 @@ public void testAssumeRoleWithSessionPolicyPresent() { .setAwsIamSessionPolicy(sessionPolicy) ).build(); - final OMClientResponse response = new S3AssumeRoleRequest(omRequest) + final OMClientResponse response = new S3AssumeRoleRequest(omRequest, CLOCK) .validateAndUpdateCache(ozoneManager, context); assertThat(response.getOMResponse().getStatus()).isEqualTo(Status.OK); } + @Test + public void testGetSessionPolicyUsesDefaultVolumeWhenMultiTenantDisabled() throws Exception { + when(ozoneManager.isS3MultiTenancyEnabled()).thenReturn(false); + + // Ensure s3v default volume was captured in the method invocation + final org.apache.hadoop.ozone.security.acl.AssumeRoleRequest capturedAssumeRoleRequest = + captureAssumeRoleRequest("s3v", "userNameA"); + + assertThat(capturedAssumeRoleRequest.getHost()).isEqualTo(OM_HOST); + assertThat(capturedAssumeRoleRequest.getIp()).isEqualTo(LOOPBACK_IP); + assertThat(capturedAssumeRoleRequest.getTargetRoleName()).isEqualTo(TARGET_ROLE_NAME); + assertThat(capturedAssumeRoleRequest.getGrants()).isEqualTo(EMPTY_GRANTS); + } + + @Test + public void testGetSessionPolicyResolvesIamPolicyWithTenantVolume() throws Exception { + when(ozoneManager.isS3MultiTenancyEnabled()).thenReturn(true); + + final OMMultiTenantManager multiTenantManager = mock(OMMultiTenantManager.class); + when(ozoneManager.getMultiTenantManager()).thenReturn(multiTenantManager); + when(multiTenantManager.getTenantForAccessID(ORIGINAL_ACCESS_KEY_ID)).thenReturn(Optional.of("tenant-a")); + when(multiTenantManager.getTenantVolumeName("tenant-a")).thenReturn("tenant-a-volume"); + + // Ensure "tenant-a-volume" was captured in the method invocation + final org.apache.hadoop.ozone.security.acl.AssumeRoleRequest capturedAssumeRoleRequest = + captureAssumeRoleRequest("tenant-a-volume", "userNameA"); + + assertThat(capturedAssumeRoleRequest.getHost()).isEqualTo(OM_HOST); + assertThat(capturedAssumeRoleRequest.getIp()).isEqualTo(LOOPBACK_IP); + assertThat(capturedAssumeRoleRequest.getTargetRoleName()).isEqualTo(TARGET_ROLE_NAME); + assertThat(capturedAssumeRoleRequest.getGrants()).isEqualTo(EMPTY_GRANTS); + } + + @Test + public void testGetSessionPolicyFallsBackToDefaultVolumeWhenTenantMissing() throws Exception { + when(ozoneManager.isS3MultiTenancyEnabled()).thenReturn(true); + + final OMMultiTenantManager multiTenantManager = mock(OMMultiTenantManager.class); + when(ozoneManager.getMultiTenantManager()).thenReturn(multiTenantManager); + when(multiTenantManager.getTenantForAccessID(ORIGINAL_ACCESS_KEY_ID)).thenReturn(Optional.empty()); + + // Ensure s3v default volume was captured in the method invocation since tenant was missing + final org.apache.hadoop.ozone.security.acl.AssumeRoleRequest capturedAssumeRoleRequest = + captureAssumeRoleRequest("s3v", "userNameB"); + + verify(multiTenantManager, never()).getTenantVolumeName(any()); + assertThat(capturedAssumeRoleRequest.getHost()).isEqualTo(OM_HOST); + assertThat(capturedAssumeRoleRequest.getIp()).isEqualTo(LOOPBACK_IP); + assertThat(capturedAssumeRoleRequest.getTargetRoleName()).isEqualTo(TARGET_ROLE_NAME); + assertThat(capturedAssumeRoleRequest.getGrants()).isEqualTo(EMPTY_GRANTS); + } + + @Test + public void testGetSessionPolicyWithBlankAwsPolicyCapturesNullGrants() throws Exception { + when(ozoneManager.isS3MultiTenancyEnabled()).thenReturn(false); + + final String awsIamPolicy = null; + try (MockedStatic resolverMock = mockStatic(IamSessionPolicyResolver.class)) { + final String result = new S3AssumeRoleRequest(baseOmRequestBuilder().build(), CLOCK) + .getSessionPolicy( + ozoneManager, ORIGINAL_ACCESS_KEY_ID, awsIamPolicy, OM_HOST, LOOPBACK_IP, + UserGroupInformation.createRemoteUser("userNameC"), TARGET_ROLE_NAME); + + assertThat(result).isEqualTo(SESSION_POLICY_VALUE); + + // Ensure IamSessionPolicyResolver was never invoked since awsIamPolicy is null + resolverMock.verifyNoInteractions(); + } + + final ArgumentCaptor captor = + ArgumentCaptor.forClass(org.apache.hadoop.ozone.security.acl.AssumeRoleRequest.class); + verify(accessAuthorizer).generateAssumeRoleSessionPolicy(captor.capture()); + + final org.apache.hadoop.ozone.security.acl.AssumeRoleRequest capturedAssumeRoleRequest = captor.getValue(); + assertThat(capturedAssumeRoleRequest.getHost()).isEqualTo(OM_HOST); + assertThat(capturedAssumeRoleRequest.getIp()).isEqualTo(LOOPBACK_IP); + assertThat(capturedAssumeRoleRequest.getTargetRoleName()).isEqualTo(TARGET_ROLE_NAME); + assertThat(capturedAssumeRoleRequest.getGrants()).isNull(); + } + + private org.apache.hadoop.ozone.security.acl.AssumeRoleRequest captureAssumeRoleRequest(String volumeName, + String userName) throws Exception { + try (MockedStatic resolverMock = mockStatic(IamSessionPolicyResolver.class)) { + resolverMock.when(() -> IamSessionPolicyResolver.resolve( + AWS_IAM_POLICY, volumeName, IamSessionPolicyResolver.AuthorizerType.RANGER)) + .thenReturn(EMPTY_GRANTS); + + final String result = new S3AssumeRoleRequest(baseOmRequestBuilder().build(), CLOCK) + .getSessionPolicy( + ozoneManager, ORIGINAL_ACCESS_KEY_ID, AWS_IAM_POLICY, OM_HOST, LOOPBACK_IP, + UserGroupInformation.createRemoteUser(userName), TARGET_ROLE_NAME); + + assertThat(result).isEqualTo(SESSION_POLICY_VALUE); + resolverMock.verify(() -> IamSessionPolicyResolver.resolve( + AWS_IAM_POLICY, volumeName, IamSessionPolicyResolver.AuthorizerType.RANGER)); + } + + final ArgumentCaptor captor = + ArgumentCaptor.forClass(org.apache.hadoop.ozone.security.acl.AssumeRoleRequest.class); + verify(accessAuthorizer).generateAssumeRoleSessionPolicy(captor.capture()); + return captor.getValue(); + } + private static OMRequest.Builder baseOmRequestBuilder() { return OMRequest.newBuilder() .setCmdType(Type.AssumeRole) diff --git a/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/security/acl/TestRequestContext.java b/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/security/acl/TestRequestContext.java index cb05c2ef6260..937c69f5874f 100644 --- a/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/security/acl/TestRequestContext.java +++ b/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/security/acl/TestRequestContext.java @@ -131,4 +131,70 @@ private RequestContext getUserRequestContext(String username, UserGroupInformation.createRemoteUser(username), null, null, type, ownerName).build(); } + + @Test + public void testToBuilderWithNoModifications() { + // Create a RequestContext with all fields set + final UserGroupInformation ugi = UserGroupInformation.createRemoteUser("testUser"); + final String host = "testHost"; + final String serviceId = "testServiceId"; + final String ownerName = "testOwner"; + final String sessionPolicy = "{\"Statement\":[{\"Effect\":\"Allow\"}]}"; + + final RequestContext original = new RequestContext( + host, null, ugi, serviceId, IAccessAuthorizer.ACLIdentityType.USER, IAccessAuthorizer.ACLType.READ, ownerName, + true, sessionPolicy); + + // Use toBuilder to create a new builder + final RequestContext.Builder builder = original.toBuilder(); + final RequestContext requestCtxFromToBuilder = builder.build(); + + // Verify all fields are preserved + assertEquals(original.getHost(), requestCtxFromToBuilder.getHost(), "Host should be preserved"); + assertNull(original.getIp(), "IP should be preserved"); + assertEquals(original.getClientUgi(), requestCtxFromToBuilder.getClientUgi(), "ClientUgi should be preserved"); + assertEquals(original.getServiceId(), requestCtxFromToBuilder.getServiceId(), "ServiceId should be preserved"); + assertEquals(original.getAclType(), requestCtxFromToBuilder.getAclType(), "AclType should be preserved"); + assertEquals(original.getAclRights(), requestCtxFromToBuilder.getAclRights(), "AclRights should be preserved"); + assertEquals(original.getOwnerName(), requestCtxFromToBuilder.getOwnerName(), "OwnerName should be preserved"); + assertTrue(original.isRecursiveAccessCheck(), "RecursiveAccessCheck should be preserved"); + assertEquals(original.getSessionPolicy(), requestCtxFromToBuilder.getSessionPolicy(), + "SessionPolicy should be preserved"); + } + + @Test + public void testToBuilderWithModifications() { + // Create an original RequestContext + final UserGroupInformation originalUgi = UserGroupInformation.createRemoteUser("user1"); + final RequestContext original = new RequestContext( + "host1", null, originalUgi, "service1", IAccessAuthorizer.ACLIdentityType.USER, IAccessAuthorizer.ACLType.READ, + "owner1", false, null); + + // Use toBuilder and modify some fields + final UserGroupInformation newUgi = UserGroupInformation.createRemoteUser("user2"); + final RequestContext modified = original.toBuilder() + .setHost("host2") + .setClientUgi(newUgi) + .setAclRights(IAccessAuthorizer.ACLType.WRITE) + .setOwnerName("owner2") + .setRecursiveAccessCheck(true) + .setSessionPolicy("{\"Statement\":[]}") + .build(); + + // Verify original is unchanged + assertEquals("host1", original.getHost(), "Original should be unchanged"); + assertEquals(originalUgi, original.getClientUgi(), "Original UGI should be unchanged"); + assertEquals(IAccessAuthorizer.ACLType.READ, original.getAclRights(), "Original ACL rights should be unchanged"); + assertEquals("owner1", original.getOwnerName(), "Original owner name should be unchanged"); + assertFalse(original.isRecursiveAccessCheck(), "Original recursive flag should be unchanged"); + assertNull(original.getSessionPolicy(), "Original session policy should be unchanged"); + + // Verify modified has new values + assertEquals("host2", modified.getHost(), "Modified host should be updated"); + assertEquals(newUgi, modified.getClientUgi(), "Modified UGI should be updated"); + assertEquals(IAccessAuthorizer.ACLType.WRITE, modified.getAclRights(), "Modified ACL rights should be updated"); + assertEquals("owner2", modified.getOwnerName(), "Modified owner should be updated"); + assertTrue(modified.isRecursiveAccessCheck(), "Modified recursive flag should be updated"); + assertEquals("{\"Statement\":[]}", modified.getSessionPolicy(), "Modified session policy should be updated"); + } } diff --git a/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/security/acl/package-info.java b/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/security/acl/package-info.java new file mode 100644 index 000000000000..7feb73bd2e67 --- /dev/null +++ b/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/security/acl/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. + */ + +/** + * Ozone security acl tests. + */ +package org.apache.hadoop.ozone.security.acl; From a47b0b4788077209927e04fc935c3afe48b4ce5e Mon Sep 17 00:00:00 2001 From: fmorg-git Date: Wed, 31 Dec 2025 02:26:25 -0800 Subject: [PATCH 16/54] HDDS-14067. [STS] Plumbing and CLI utility to revoke STS token (#9507) --- .../ozone/shell/s3/RevokeSTSTokenHandler.java | 20 +++----- .../hadoop/ozone/client/ObjectStore.java | 7 ++- .../ozone/client/protocol/ClientProtocol.java | 5 +- .../hadoop/ozone/client/rpc/RpcClient.java | 4 +- .../om/protocol/OzoneManagerProtocol.java | 5 +- ...ManagerProtocolClientSideTranslatorPB.java | 3 +- .../src/main/proto/OmClientProtocol.proto | 3 +- .../hadoop/ozone/om/OMMetadataManager.java | 2 +- .../ozone/om/OmMetadataManagerImpl.java | 8 ++-- .../hadoop/ozone/om/codec/OMDBDefinition.java | 8 ++-- .../s3/security/S3RevokeSTSTokenRequest.java | 34 +++++--------- .../s3/security/S3RevokeSTSTokenResponse.java | 16 ++++--- .../hadoop/ozone/security/S3SecurityUtil.java | 13 ++--- .../ozone/om/TestOmMetadataManager.java | 37 ++++++++------- .../security/TestS3RevokeSTSTokenRequest.java | 47 +------------------ .../om/request/s3/security/package-info.java | 21 +++++++++ .../ozone/security/TestS3SecurityUtil.java | 14 +++--- .../ozone/client/ClientProtocolStub.java | 2 +- 18 files changed, 102 insertions(+), 147 deletions(-) create mode 100644 hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/om/request/s3/security/package-info.java diff --git a/hadoop-ozone/cli-shell/src/main/java/org/apache/hadoop/ozone/shell/s3/RevokeSTSTokenHandler.java b/hadoop-ozone/cli-shell/src/main/java/org/apache/hadoop/ozone/shell/s3/RevokeSTSTokenHandler.java index 2f63d4f2a5ad..274304217f86 100644 --- a/hadoop-ozone/cli-shell/src/main/java/org/apache/hadoop/ozone/shell/s3/RevokeSTSTokenHandler.java +++ b/hadoop-ozone/cli-shell/src/main/java/org/apache/hadoop/ozone/shell/s3/RevokeSTSTokenHandler.java @@ -29,20 +29,14 @@ /** * Executes revocation of STS tokens. * - *

This command marks the specified STS temporary access key id as revoked - * by adding it to the OM's revoked STS token table. Subsequent S3 requests - * using the same temporary access key id will be rejected once the revocation + *

This command marks the specified STS token as revoked by adding it to the OM's revoked STS token table. + * Subsequent S3 requests using the same session token will be rejected once the revocation * state has propagated.

*/ @Command(name = "revokeststoken", - description = "Revoke S3 STS token for the given access key id") + description = "Revoke S3 STS token for the given session token") public class RevokeSTSTokenHandler extends S3Handler { - @Option(names = "-k", - required = true, - description = "STS temporary access key id (for example, ASIA...)") - private String accessKeyId; - @Option(names = "-t", required = true, description = "STS session token") @@ -62,8 +56,8 @@ protected void execute(OzoneClient client, OzoneAddress address) throws IOException { if (!yes) { - out().print("Enter 'y' to confirm STS token revocation for accessKeyId '" + - accessKeyId + "': "); + out().print("Enter 'y' to confirm STS token revocation for sessionToken '" + + sessionToken + "': "); out().flush(); final Scanner scanner = new Scanner(new InputStreamReader(System.in, StandardCharsets.UTF_8)); final String confirmation = scanner.next().trim().toLowerCase(); @@ -73,7 +67,7 @@ protected void execute(OzoneClient client, OzoneAddress address) } } - client.getObjectStore().revokeSTSToken(accessKeyId, sessionToken); - out().println("STS token revoked for accessKeyId '" + accessKeyId + "'."); + client.getObjectStore().revokeSTSToken(sessionToken); + out().println("STS token revoked for sessionToken '" + sessionToken + "'."); } } diff --git a/hadoop-ozone/client/src/main/java/org/apache/hadoop/ozone/client/ObjectStore.java b/hadoop-ozone/client/src/main/java/org/apache/hadoop/ozone/client/ObjectStore.java index 226ebbfb0349..4e6e1b0ae9de 100644 --- a/hadoop-ozone/client/src/main/java/org/apache/hadoop/ozone/client/ObjectStore.java +++ b/hadoop-ozone/client/src/main/java/org/apache/hadoop/ozone/client/ObjectStore.java @@ -768,12 +768,11 @@ public AssumeRoleResponseInfo assumeRole(String roleArn, String roleSessionName, /** * Revokes an STS token. - * @param accessKeyId The STS accessKeyId (starting with ASIA...) - * @param sessionToken The STS session token + * @param sessionToken The STS sessionToken * @throws IOException if an error occurs while revoking the STS token */ - public void revokeSTSToken(String accessKeyId, String sessionToken) throws IOException { - proxy.revokeSTSToken(accessKeyId, sessionToken); + public void revokeSTSToken(String sessionToken) throws IOException { + proxy.revokeSTSToken(sessionToken); } /** diff --git a/hadoop-ozone/client/src/main/java/org/apache/hadoop/ozone/client/protocol/ClientProtocol.java b/hadoop-ozone/client/src/main/java/org/apache/hadoop/ozone/client/protocol/ClientProtocol.java index 0067407aff33..88c282856164 100644 --- a/hadoop-ozone/client/src/main/java/org/apache/hadoop/ozone/client/protocol/ClientProtocol.java +++ b/hadoop-ozone/client/src/main/java/org/apache/hadoop/ozone/client/protocol/ClientProtocol.java @@ -1375,9 +1375,8 @@ AssumeRoleResponseInfo assumeRole(String roleArn, String roleSessionName, int du /** * Revokes an STS token. - * @param accessKeyId The STS accessKeyId (starting with ASIA...) - * @param sessionToken The STS session token + * @param sessionToken The STS sessionToken * @throws IOException if an error occurs while revoking the STS token */ - void revokeSTSToken(String accessKeyId, String sessionToken) throws IOException; + void revokeSTSToken(String sessionToken) throws IOException; } diff --git a/hadoop-ozone/client/src/main/java/org/apache/hadoop/ozone/client/rpc/RpcClient.java b/hadoop-ozone/client/src/main/java/org/apache/hadoop/ozone/client/rpc/RpcClient.java index 791a159f01be..67e2ac203bfd 100644 --- a/hadoop-ozone/client/src/main/java/org/apache/hadoop/ozone/client/rpc/RpcClient.java +++ b/hadoop-ozone/client/src/main/java/org/apache/hadoop/ozone/client/rpc/RpcClient.java @@ -2798,8 +2798,8 @@ public AssumeRoleResponseInfo assumeRole(String roleArn, String roleSessionName, } @Override - public void revokeSTSToken(String accessKeyId, String sessionToken) throws IOException { - ozoneManagerClient.revokeSTSToken(accessKeyId, sessionToken); + public void revokeSTSToken(String sessionToken) throws IOException { + ozoneManagerClient.revokeSTSToken(sessionToken); } private static ExecutorService createThreadPoolExecutor( diff --git a/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/om/protocol/OzoneManagerProtocol.java b/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/om/protocol/OzoneManagerProtocol.java index f98196d7276e..2661bc82366e 100644 --- a/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/om/protocol/OzoneManagerProtocol.java +++ b/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/om/protocol/OzoneManagerProtocol.java @@ -1194,11 +1194,10 @@ default AssumeRoleResponseInfo assumeRole(String roleArn, String roleSessionName /** * Revokes an STS token. - * @param accessKeyId The STS accessKeyId (starting with ASIA...) - * @param sessionToken The STS session token + * @param sessionToken The STS sessionToken * @throws IOException if an error occurs while revoking the STS token */ - default void revokeSTSToken(String accessKeyId, String sessionToken) throws IOException { + default void revokeSTSToken(String sessionToken) throws IOException { throw new UnsupportedOperationException("OzoneManager does not require this to be implemented"); } } diff --git a/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/om/protocolPB/OzoneManagerProtocolClientSideTranslatorPB.java b/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/om/protocolPB/OzoneManagerProtocolClientSideTranslatorPB.java index 105d353a4637..8bbc3320dc1a 100644 --- a/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/om/protocolPB/OzoneManagerProtocolClientSideTranslatorPB.java +++ b/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/om/protocolPB/OzoneManagerProtocolClientSideTranslatorPB.java @@ -2676,10 +2676,9 @@ public AssumeRoleResponseInfo assumeRole(String roleArn, String roleSessionName, } @Override - public void revokeSTSToken(String accessKeyId, String sessionToken) throws IOException { + public void revokeSTSToken(String sessionToken) throws IOException { final OzoneManagerProtocolProtos.RevokeSTSTokenRequest request = OzoneManagerProtocolProtos.RevokeSTSTokenRequest.newBuilder() - .setAccessKeyId(accessKeyId) .setSessionToken(sessionToken) .build(); diff --git a/hadoop-ozone/interface-client/src/main/proto/OmClientProtocol.proto b/hadoop-ozone/interface-client/src/main/proto/OmClientProtocol.proto index b24aff586cea..08eb79fbd9ea 100644 --- a/hadoop-ozone/interface-client/src/main/proto/OmClientProtocol.proto +++ b/hadoop-ozone/interface-client/src/main/proto/OmClientProtocol.proto @@ -2387,8 +2387,7 @@ message AssumeRoleResponse { } message RevokeSTSTokenRequest { - required string accessKeyId = 1; - required string sessionToken = 2; + required string sessionToken = 1; } message RevokeSTSTokenResponse { diff --git a/hadoop-ozone/interface-storage/src/main/java/org/apache/hadoop/ozone/om/OMMetadataManager.java b/hadoop-ozone/interface-storage/src/main/java/org/apache/hadoop/ozone/om/OMMetadataManager.java index 7afe2c6249a9..82e04b3ff10e 100644 --- a/hadoop-ozone/interface-storage/src/main/java/org/apache/hadoop/ozone/om/OMMetadataManager.java +++ b/hadoop-ozone/interface-storage/src/main/java/org/apache/hadoop/ozone/om/OMMetadataManager.java @@ -489,7 +489,7 @@ String getMultipartKeyFSO(String volume, String bucket, String key, String * * @return Table. */ - Table getS3RevokedStsTokenTable(); + Table getS3RevokedStsTokenTable(); /** diff --git a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/OmMetadataManagerImpl.java b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/OmMetadataManagerImpl.java index 3439e04c0636..6b051611ee5c 100644 --- a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/OmMetadataManagerImpl.java +++ b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/OmMetadataManagerImpl.java @@ -181,7 +181,7 @@ public class OmMetadataManagerImpl implements OMMetadataManager, private TypedTable snapshotRenamedTable; private TypedTable compactionLogTable; - private TypedTable s3RevokedStsTokenTable; + private TypedTable s3RevokedStsTokenTable; private OzoneManager ozoneManager; @@ -489,8 +489,8 @@ protected void initializeOmTables(CacheType cacheType, compactionLogTable = initializer.get(OMDBDefinition.COMPACTION_LOG_TABLE_DEF); - // temporaryAccessKeyId -> sessionToken - // FULL_CACHE keeps revocations in memory as there are not expected to be many revoked tokens + // sessionToken -> insertionTimeMillis + // FULL_CACHE keeps revocations in memory as there are not expected to be many s3RevokedStsTokenTable = initializer.get( OMDBDefinition.S3_REVOKED_STS_TOKEN_TABLE_DEF, CacheType.FULL_CACHE); } @@ -1691,7 +1691,7 @@ public Table getCompactionLogTable() { } @Override - public Table getS3RevokedStsTokenTable() { + public Table getS3RevokedStsTokenTable() { return s3RevokedStsTokenTable; } diff --git a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/codec/OMDBDefinition.java b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/codec/OMDBDefinition.java index 8b4632ef45bf..c8a6ac239810 100644 --- a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/codec/OMDBDefinition.java +++ b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/codec/OMDBDefinition.java @@ -56,7 +56,7 @@ * | userTable | /user :- UserVolumeInfo | * | dTokenTable | OzoneTokenID :- renew_time | * | s3SecretTable | s3g_access_key_id :- s3Secret | - * | s3RevokedStsTokenTable | sts_access_key_id :- sessionToken | + * | s3RevokedStsTokenTable | sts_session_token :- insertionTimeMillis | * |------------------------------------------------------------------------| * } * @@ -163,11 +163,11 @@ public final class OMDBDefinition extends DBDefinition.WithMap { S3SecretValue.getCodec()); public static final String S3_REVOKED_STS_TOKEN_TABLE = "s3RevokedStsTokenTable"; - /** s3RevokedStsTokenTable: sts_access_key_id :- sessionToken.*/ - public static final DBColumnFamilyDefinition S3_REVOKED_STS_TOKEN_TABLE_DEF + /** s3RevokedStsTokenTable: sts_session_token :- insertionTimeMillis.*/ + public static final DBColumnFamilyDefinition S3_REVOKED_STS_TOKEN_TABLE_DEF = new DBColumnFamilyDefinition<>(S3_REVOKED_STS_TOKEN_TABLE, StringCodec.get(), - StringCodec.get()); + LongCodec.get()); //--------------------------------------------------------------------------- // Volume, Bucket, Prefix and Transaction Tables: diff --git a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/s3/security/S3RevokeSTSTokenRequest.java b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/s3/security/S3RevokeSTSTokenRequest.java index ff7a3831d0d6..369fc8bc14ad 100644 --- a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/s3/security/S3RevokeSTSTokenRequest.java +++ b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/s3/security/S3RevokeSTSTokenRequest.java @@ -25,7 +25,6 @@ import org.apache.hadoop.ozone.OzoneConsts; import org.apache.hadoop.ozone.audit.OMAction; 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; @@ -43,9 +42,9 @@ /** * Handles S3RevokeSTSTokenRequest request. * - *

This request marks an STS temporary access key id as revoked by inserting + *

This request marks an STS session token as revoked by inserting * it into the {@code s3RevokedStsTokenTable}. Subsequent S3 requests - * authenticated with the same STS access key id will be rejected when the + * authenticated with the same STS session token will be rejected when the * revocation state has propagated.

*/ public class S3RevokeSTSTokenRequest extends OMClientRequest { @@ -53,8 +52,6 @@ public class S3RevokeSTSTokenRequest extends OMClientRequest { private static final Logger LOG = LoggerFactory.getLogger(S3RevokeSTSTokenRequest.class); private static final Clock CLOCK = Clock.system(ZoneOffset.UTC); - private String originalAccessKeyId; - public S3RevokeSTSTokenRequest(OMRequest omRequest) { super(omRequest); } @@ -67,21 +64,14 @@ public OMRequest preExecute(OzoneManager ozoneManager) throws IOException { // Get the original (long-lived) access key id from the session token // and enforce the same permission model that is used for S3 secret // operations (get/set/revoke). Only the owner of the original access - // key (or an S3 / tenant admin) is allowed to revoke its temporary - // STS credentials. + // key (i.e. the creator of the STS token) or an S3 / tenant admin is allowed + // to revoke its temporary STS credentials. final String sessionToken = revokeReq.getSessionToken(); - final String tempAccessKeyId = revokeReq.getAccessKeyId(); final STSTokenIdentifier stsTokenIdentifier = STSSecurityUtil.constructValidateAndDecryptSTSToken( sessionToken, ozoneManager.getSecretKeyClient(), CLOCK); - originalAccessKeyId = stsTokenIdentifier.getOriginalAccessKeyId(); - - // Validate that the Access Key ID in the request matches the one in the token - // to prevent users from revoking arbitrary keys using a valid token. - if (!stsTokenIdentifier.getTempAccessKeyId().equals(tempAccessKeyId)) { - throw new OMException("Access Key ID in request does not match the session token", - OMException.ResultCodes.INVALID_REQUEST); - } + final String originalAccessKeyId = stsTokenIdentifier.getOriginalAccessKeyId(); + final OzoneManagerProtocolProtos.UserInfo userInfo = getUserInfo(); final UserGroupInformation ugi = S3SecretRequestHelper.getOrCreateUgi(originalAccessKeyId); S3SecretRequestHelper.checkAccessIdSecretOpPermission(ozoneManager, ugi, originalAccessKeyId); @@ -89,7 +79,7 @@ public OMRequest preExecute(OzoneManager ozoneManager) throws IOException { .setRevokeSTSTokenRequest(revokeReq) .setCmdType(getOmRequest().getCmdType()) .setClientId(getOmRequest().getClientId()) - .setUserInfo(getUserInfo()); + .setUserInfo(userInfo); if (getOmRequest().hasTraceID()) { omRequest.setTraceID(getOmRequest().getTraceID()); @@ -103,20 +93,20 @@ public OMClientResponse validateAndUpdateCache(OzoneManager ozoneManager, Execut final OMResponse.Builder omResponse = OmResponseUtil.getOMResponseBuilder(getOmRequest()); final OzoneManagerProtocolProtos.RevokeSTSTokenRequest revokeReq = getOmRequest().getRevokeSTSTokenRequest(); - final String accessKeyId = revokeReq.getAccessKeyId(); final String sessionToken = revokeReq.getSessionToken(); // All actual DB mutations are done in the response's addToDBBatch(). final OMClientResponse omClientResponse = new S3RevokeSTSTokenResponse( - accessKeyId, sessionToken, omResponse.build()); + sessionToken, omResponse.build()); // Audit log final Map auditMap = new HashMap<>(); - auditMap.put(OzoneConsts.S3_REVOKESTSTOKEN_USER, originalAccessKeyId); + final OzoneManagerProtocolProtos.UserInfo userInfo = getOmRequest().getUserInfo(); + auditMap.put(OzoneConsts.S3_REVOKESTSTOKEN_USER, userInfo.getUserName()); markForAudit(ozoneManager.getAuditLogger(), buildAuditMessage( - OMAction.REVOKE_STS_TOKEN, auditMap, null, getOmRequest().getUserInfo())); + OMAction.REVOKE_STS_TOKEN, auditMap, null, userInfo)); - LOG.info("Marked STS temporary access key '{}' as revoked.", accessKeyId); + LOG.info("Marked STS session token '{}' as revoked.", sessionToken); return omClientResponse; } } diff --git a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/response/s3/security/S3RevokeSTSTokenResponse.java b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/response/s3/security/S3RevokeSTSTokenResponse.java index 523311bbadb8..5b1a8cf3b019 100644 --- a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/response/s3/security/S3RevokeSTSTokenResponse.java +++ b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/response/s3/security/S3RevokeSTSTokenResponse.java @@ -22,6 +22,8 @@ import jakarta.annotation.Nonnull; import java.io.IOException; +import java.time.Clock; +import java.time.ZoneOffset; import org.apache.hadoop.hdds.utils.db.BatchOperation; import org.apache.hadoop.hdds.utils.db.Table; import org.apache.hadoop.ozone.om.OMMetadataManager; @@ -35,22 +37,22 @@ @CleanupTableInfo(cleanupTables = {S3_REVOKED_STS_TOKEN_TABLE}) public class S3RevokeSTSTokenResponse extends OMClientResponse { - private final String accessKeyId; + private static final Clock CLOCK = Clock.system(ZoneOffset.UTC); + private final String sessionToken; - public S3RevokeSTSTokenResponse(String accessKeyId, String sessionToken, @Nonnull OMResponse omResponse) { + public S3RevokeSTSTokenResponse(String sessionToken, @Nonnull OMResponse omResponse) { super(omResponse); - this.accessKeyId = accessKeyId; this.sessionToken = sessionToken; } @Override public void addToDBBatch(OMMetadataManager omMetadataManager, BatchOperation batchOperation) throws IOException { - if (accessKeyId != null && getOMResponse().hasStatus() && getOMResponse().getStatus() == OK) { - final Table table = omMetadataManager.getS3RevokedStsTokenTable(); + if (sessionToken != null && getOMResponse().hasStatus() && getOMResponse().getStatus() == OK) { + final Table table = omMetadataManager.getS3RevokedStsTokenTable(); if (table != null) { - // Store sessionToken as value - table.putWithBatch(batchOperation, accessKeyId, sessionToken); + // Store insertionTimeMillis as value + table.putWithBatch(batchOperation, sessionToken, CLOCK.millis()); } } } diff --git a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/security/S3SecurityUtil.java b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/security/S3SecurityUtil.java index aeb4a2e189a3..792b64e8697c 100644 --- a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/security/S3SecurityUtil.java +++ b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/security/S3SecurityUtil.java @@ -73,7 +73,7 @@ public static void validateS3Credential(OMRequest omRequest, token, ozoneManager.getSecretKeyClient(), CLOCK); // Ensure the token is not revoked - if (isRevokedStsTempAccessKeyId(stsTokenIdentifier, ozoneManager)) { + if (isRevokedStsToken(token, ozoneManager)) { LOG.info("Session token has been revoked: {}, {}", stsTokenIdentifier.getTempAccessKeyId(), token); throw new OMException("STS token has been revoked", REVOKED_TOKEN); } @@ -140,9 +140,9 @@ private static void validateSTSTokenAwsSignature(STSTokenIdentifier stsTokenIden } /** - * Returns true if the STS token's temporary access key ID is present in the revoked STS token table. + * Returns true if the STS session token is present in the revoked STS token table. */ - private static boolean isRevokedStsTempAccessKeyId(STSTokenIdentifier stsTokenIdentifier, OzoneManager ozoneManager) + private static boolean isRevokedStsToken(String sessionToken, OzoneManager ozoneManager) throws OMException { try { final OMMetadataManager metadataManager = ozoneManager.getMetadataManager(); @@ -152,17 +152,14 @@ private static boolean isRevokedStsTempAccessKeyId(STSTokenIdentifier stsTokenId throw new OMException(msg, INTERNAL_ERROR); } - final Table revokedStsTokenTable = metadataManager.getS3RevokedStsTokenTable(); + final Table revokedStsTokenTable = metadataManager.getS3RevokedStsTokenTable(); if (revokedStsTokenTable == null) { final String msg = "Could not determine STS revocation: revokedStsTokenTable is null"; LOG.warn(msg); throw new OMException(msg, INTERNAL_ERROR); } - // When the STSTokenIdentifier is validated, it ensures the temp access key id is not null/empty - final String tempAccessKeyId = stsTokenIdentifier.getTempAccessKeyId(); - - return revokedStsTokenTable.getIfExist(tempAccessKeyId) != null; + return revokedStsTokenTable.getIfExist(sessionToken) != null; } catch (Exception e) { final String msg = "Could not determine STS revocation because of Exception: " + e.getMessage(); LOG.warn(msg, e); diff --git a/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/om/TestOmMetadataManager.java b/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/om/TestOmMetadataManager.java index 3541d303b246..bd5f2b56dd75 100644 --- a/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/om/TestOmMetadataManager.java +++ b/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/om/TestOmMetadataManager.java @@ -79,7 +79,7 @@ import org.apache.hadoop.hdds.protocol.StorageType; import org.apache.hadoop.hdds.protocol.proto.HddsProtos; import org.apache.hadoop.hdds.utils.TransactionInfo; -import org.apache.hadoop.hdds.utils.db.Table; +import org.apache.hadoop.hdds.utils.db.TypedTable; import org.apache.hadoop.hdds.utils.db.cache.CacheKey; import org.apache.hadoop.hdds.utils.db.cache.CacheValue; import org.apache.hadoop.ozone.om.codec.OMDBDefinition; @@ -105,6 +105,7 @@ import org.apache.hadoop.ozone.protocol.proto.OzoneManagerProtocolProtos.PartKeyInfo; import org.apache.hadoop.ozone.snapshot.ListSnapshotResponse; import org.apache.hadoop.util.Time; +import org.apache.ozone.test.TestClock; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; @@ -1299,34 +1300,36 @@ public void testS3RevokedStsTokenTablePutAndGet() throws Exception { // Ensure the table is initialized assertNotNull(omMetadataManager.getS3RevokedStsTokenTable(), "s3RevokedStsTokenTable should be initialized"); - final String tempAccessKeyId1 = "ASIA7VUS1EOBCW8RRJVR"; + final TestClock clock = TestClock.newInstance(); final String sessionToken1 = "test-session-token-1"; - final String tempAccessKeyId2 = "ASIA904E65QIGL9ON305"; + final long insertionTime1 = clock.millis(); final String sessionToken2 = "test-session-token-2"; - - final Table table = omMetadataManager.getS3RevokedStsTokenTable(); + final long insertionTime2 = insertionTime1 + 1234L; // This table is configured as FULL_CACHE in OmMetadataManagerImpl. // A put() writes to RocksDB but does not update the table cache, so get() and getIfExist() will return null unless // the cache is updated with addCacheEntry(). getSkipCache() will read the DB instead of the cache. - table.put(tempAccessKeyId1, sessionToken1); - table.put(tempAccessKeyId2, sessionToken2); + final TypedTable revokedTable = + (TypedTable) omMetadataManager.getS3RevokedStsTokenTable(); + + revokedTable.put(sessionToken1, insertionTime1); + revokedTable.put(sessionToken2, insertionTime2); // Verify the values are persisted in RocksDB. - assertEquals(sessionToken1, table.getSkipCache(tempAccessKeyId1)); - assertEquals(sessionToken2, table.getSkipCache(tempAccessKeyId2)); + assertEquals(insertionTime1, revokedTable.getSkipCache(sessionToken1)); + assertEquals(insertionTime2, revokedTable.getSkipCache(sessionToken2)); // Update cache to make get/getIfExist reflect the write for FULL_CACHE tables. - table.addCacheEntry(tempAccessKeyId1, sessionToken1, 1L); - table.addCacheEntry(tempAccessKeyId2, sessionToken2, 1L); + revokedTable.addCacheEntry(sessionToken1, insertionTime1, 1L); + revokedTable.addCacheEntry(sessionToken2, insertionTime2, 1L); // Verify get and getIfExist return the stored value - assertEquals(sessionToken1, table.get(tempAccessKeyId1)); - assertEquals(sessionToken1, table.getIfExist(tempAccessKeyId1)); - assertEquals(sessionToken2, table.get(tempAccessKeyId2)); - assertEquals(sessionToken2, table.getIfExist(tempAccessKeyId2)); + assertEquals(insertionTime1, revokedTable.get(sessionToken1)); + assertEquals(insertionTime1, revokedTable.getIfExist(sessionToken1)); + assertEquals(insertionTime2, revokedTable.get(sessionToken2)); + assertEquals(insertionTime2, revokedTable.getIfExist(sessionToken2)); - // Unknown key should return null for getIfExist - assertNull(table.getIfExist("ASIA_UNKNOWN_ACCESS_KEY")); + // Invalid sessionToken should return null for getIfExist + assertNull(revokedTable.getIfExist("INVALID_SESSION_TOKEN")); } } diff --git a/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/om/request/s3/security/TestS3RevokeSTSTokenRequest.java b/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/om/request/s3/security/TestS3RevokeSTSTokenRequest.java index 9a68c047f008..d4460ad83e6e 100644 --- a/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/om/request/s3/security/TestS3RevokeSTSTokenRequest.java +++ b/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/om/request/s3/security/TestS3RevokeSTSTokenRequest.java @@ -64,7 +64,7 @@ public void setUp() throws Exception { KerberosName.setRuleMechanism(DEFAULT_MECHANISM); KerberosName.setRules( "RULE:[2:$1@$0](.*@EXAMPLE.COM)s/@.*//\n" + "RULE:[1:$1@$0](.*@EXAMPLE.COM)s/@.*//\n" + "DEFAULT"); - + secretKeyClient = new SecretKeyTestClient(); stsTokenSecretManager = new STSTokenSecretManager(secretKeyClient); // Multi-tenant manager mock used for tests that exercise the S3 multi-tenancy permission branch. @@ -98,7 +98,6 @@ public void testPreExecuteFailsForNonOwnerOfOriginalAccessKey() throws Exception final OzoneManagerProtocolProtos.RevokeSTSTokenRequest revokeRequest = OzoneManagerProtocolProtos.RevokeSTSTokenRequest.newBuilder() - .setAccessKeyId(tempAccessKeyId) .setSessionToken(sessionToken) .build(); @@ -135,7 +134,6 @@ public void testPreExecuteSucceedsForOriginalAccessKeyOwner() throws Exception { final OzoneManagerProtocolProtos.RevokeSTSTokenRequest revokeRequest = OzoneManagerProtocolProtos.RevokeSTSTokenRequest.newBuilder() - .setAccessKeyId(tempAccessKeyId) .setSessionToken(sessionToken) .build(); @@ -179,7 +177,6 @@ public void testPreExecuteSucceedsForTenantAccessIdOwner() throws Exception { final OzoneManagerProtocolProtos.RevokeSTSTokenRequest revokeRequest = OzoneManagerProtocolProtos.RevokeSTSTokenRequest.newBuilder() - .setAccessKeyId(tempAccessKeyId) .setSessionToken(sessionToken) .build(); @@ -224,7 +221,6 @@ public void testPreExecuteSucceedsForTenantAdmin() throws Exception { final OzoneManagerProtocolProtos.RevokeSTSTokenRequest revokeRequest = OzoneManagerProtocolProtos.RevokeSTSTokenRequest.newBuilder() - .setAccessKeyId(tempAccessKeyId) .setSessionToken(sessionToken) .build(); @@ -271,7 +267,6 @@ public void testPreExecuteFailsForNonOwnerNonAdminInTenant() throws Exception { final OzoneManagerProtocolProtos.RevokeSTSTokenRequest revokeRequest = OzoneManagerProtocolProtos.RevokeSTSTokenRequest.newBuilder() - .setAccessKeyId(tempAccessKeyId) .setSessionToken(sessionToken) .build(); @@ -288,46 +283,6 @@ public void testPreExecuteFailsForNonOwnerNonAdminInTenant() throws Exception { assertEquals(OMException.ResultCodes.USER_MISMATCH, ex.getResult()); } - @Test - public void testPreExecuteFailsForMismatchedAccessKeyId() throws Exception { - // Verify that if the request access key id does not match the one inside the session token, the request is - // rejected. This prevents a user with a valid session token from revoking arbitrary STS credentials. - final String tempAccessKeyId = "ASIA123456789"; - final String otherAccessKeyId = "ASI987654321"; - final String originalAccessKeyId = "original-access-key-id"; - final String sessionToken = createSessionToken(tempAccessKeyId, originalAccessKeyId); - - // Caller is the owner of the session token, so permissions should pass - final UserGroupInformation originalUgi = UserGroupInformation.createRemoteUser(originalAccessKeyId); - Server.getCurCall().set(new StubCall(originalUgi)); - - final OMException ex; - try (OzoneManager ozoneManager = mock(OzoneManager.class)) { - when(ozoneManager.isS3MultiTenancyEnabled()).thenReturn(false); - when(ozoneManager.isS3Admin(any(UserGroupInformation.class))) - .thenReturn(false); - when(ozoneManager.getSecretKeyClient()).thenReturn(secretKeyClient); - - // Request tries to revoke otherAccessKeyId using a token for tempAccessKeyId - final OzoneManagerProtocolProtos.RevokeSTSTokenRequest revokeRequest = - OzoneManagerProtocolProtos.RevokeSTSTokenRequest.newBuilder() - .setAccessKeyId(otherAccessKeyId) - .setSessionToken(sessionToken) - .build(); - - final OMRequest omRequest = OMRequest.newBuilder() - .setClientId(UUID.randomUUID().toString()) - .setCmdType(Type.RevokeSTSToken) - .setRevokeSTSTokenRequest(revokeRequest) - .build(); - - final OMClientRequest omClientRequest = new S3RevokeSTSTokenRequest(omRequest); - - ex = assertThrows(OMException.class, () -> omClientRequest.preExecute(ozoneManager)); - } - assertEquals(OMException.ResultCodes.INVALID_REQUEST, ex.getResult()); - } - /** * Stub used to inject a remote user into the ProtobufRpcEngine.Server.getRemoteUser() thread-local. */ diff --git a/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/om/request/s3/security/package-info.java b/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/om/request/s3/security/package-info.java new file mode 100644 index 000000000000..7727afbfc68c --- /dev/null +++ b/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/om/request/s3/security/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. + */ + +/** + * Package contains test classes for S3 Security requests. + */ +package org.apache.hadoop.ozone.om.request.s3.security; diff --git a/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/security/TestS3SecurityUtil.java b/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/security/TestS3SecurityUtil.java index c5cce385071b..20b3f3fb28b7 100644 --- a/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/security/TestS3SecurityUtil.java +++ b/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/security/TestS3SecurityUtil.java @@ -65,7 +65,7 @@ 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 final OMMetadataManager metadataManager = mock(OMMetadataManager.class); - final Table revokedSTSTokenTable = new InMemoryTestTable<>(); + final Table revokedSTSTokenTable = new InMemoryTestTable<>(); validateS3CredentialHelper( "session-token-a", metadataManager, revokedSTSTokenTable, true, createSTSTokenIdentifier(), REVOKED_TOKEN, "STS token has been revoked"); @@ -83,7 +83,7 @@ public void testValidateS3CredentialWhenMetadataUnavailable() throws Exception { public void testValidateS3CredentialSuccessWhenNotRevoked() throws Exception { // Normal case: token is NOT revoked and request is accepted final OMMetadataManager metadataManager = mock(OMMetadataManager.class); - final Table revokedSTSTokenTable = new InMemoryTestTable<>(); + final Table revokedSTSTokenTable = new InMemoryTestTable<>(); validateS3CredentialHelper( "session-token-c", metadataManager, revokedSTSTokenTable, false, createSTSTokenIdentifier(), null, null); @@ -102,7 +102,7 @@ public void testValidateS3CredentialWhenMetadataManagerAvailableButRevokedTableN public void testValidateS3CredentialWhenTableThrowsException() throws Exception { // If the revoked STS token table lookup throws, throws INTERNAL_ERROR (wrapped) final OMMetadataManager metadataManager = mock(OMMetadataManager.class); - final Table revokedSTSTokenTable = spy(new InMemoryTestTable<>()); + final Table revokedSTSTokenTable = spy(new InMemoryTestTable<>()); doThrow(new RuntimeException("lookup failed")).when(revokedSTSTokenTable).getIfExist(anyString()); validateS3CredentialHelper( "session-token-g", metadataManager, revokedSTSTokenTable, false, createSTSTokenIdentifier(), @@ -110,7 +110,7 @@ public void testValidateS3CredentialWhenTableThrowsException() throws Exception } private void validateS3CredentialHelper(String sessionToken, OMMetadataManager metadataManager, - Table revokedSTSTokenTable, boolean isRevoked, STSTokenIdentifier stsTokenIdentifier, + Table revokedSTSTokenTable, boolean isRevoked, STSTokenIdentifier stsTokenIdentifier, OMException.ResultCodes expectedResult, String expectedMessageContents) throws Exception { try (OzoneManager ozoneManager = mock(OzoneManager.class)) { @@ -124,10 +124,8 @@ private void validateS3CredentialHelper(String sessionToken, OMMetadataManager m final String tempAccessKeyId = "temp-access-key-id"; if (isRevoked) { - if (revokedSTSTokenTable == null) { - throw new IllegalArgumentException("revokedSTSTokenTable must not be null when isRevoked=true"); - } - revokedSTSTokenTable.put(tempAccessKeyId, sessionToken); + final long insertionTimeMillis = CLOCK.millis(); + revokedSTSTokenTable.put(sessionToken, insertionTimeMillis); } try (MockedStatic stsSecurityUtilMock = mockStatic(STSSecurityUtil.class, CALLS_REAL_METHODS); diff --git a/hadoop-ozone/s3gateway/src/test/java/org/apache/hadoop/ozone/client/ClientProtocolStub.java b/hadoop-ozone/s3gateway/src/test/java/org/apache/hadoop/ozone/client/ClientProtocolStub.java index b56c6ca3fe8a..477b6876af85 100644 --- a/hadoop-ozone/s3gateway/src/test/java/org/apache/hadoop/ozone/client/ClientProtocolStub.java +++ b/hadoop-ozone/s3gateway/src/test/java/org/apache/hadoop/ozone/client/ClientProtocolStub.java @@ -815,6 +815,6 @@ public AssumeRoleResponseInfo assumeRole( } @Override - public void revokeSTSToken(String accessKeyId, String sessionToken) throws IOException { + public void revokeSTSToken(String sessionToken) throws IOException { } } From 0b2db9c80c9de2fe0a7924d081e6102712760c99 Mon Sep 17 00:00:00 2001 From: fmorg-git Date: Thu, 8 Jan 2026 07:12:39 -0800 Subject: [PATCH 17/54] HDDS-14373. [STS] Revoked STS token logic tweaks (#9604) --- .../s3/security/S3RevokeSTSTokenRequest.java | 6 +++ .../security/TestS3RevokeSTSTokenRequest.java | 44 +++++++++++++++++++ .../ozone/s3/endpoint/EndpointBase.java | 3 +- .../ozone/s3/endpoint/TestEndpointBase.java | 18 ++++++++ 4 files changed, 70 insertions(+), 1 deletion(-) diff --git a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/s3/security/S3RevokeSTSTokenRequest.java b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/s3/security/S3RevokeSTSTokenRequest.java index 369fc8bc14ad..94c2f8d50831 100644 --- a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/s3/security/S3RevokeSTSTokenRequest.java +++ b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/s3/security/S3RevokeSTSTokenRequest.java @@ -22,6 +22,8 @@ import java.time.ZoneOffset; import java.util.HashMap; import java.util.Map; +import org.apache.hadoop.hdds.utils.db.cache.CacheKey; +import org.apache.hadoop.hdds.utils.db.cache.CacheValue; import org.apache.hadoop.ozone.OzoneConsts; import org.apache.hadoop.ozone.audit.OMAction; import org.apache.hadoop.ozone.om.OzoneManager; @@ -106,6 +108,10 @@ public OMClientResponse validateAndUpdateCache(OzoneManager ozoneManager, Execut markForAudit(ozoneManager.getAuditLogger(), buildAuditMessage( OMAction.REVOKE_STS_TOKEN, auditMap, null, userInfo)); + // Update the cache immediately so subsequent validation checks see the revocation + ozoneManager.getMetadataManager().getS3RevokedStsTokenTable().addCacheEntry( + new CacheKey<>(sessionToken), CacheValue.get(context.getIndex(), CLOCK.millis())); + LOG.info("Marked STS session token '{}' as revoked.", sessionToken); return omClientResponse; } diff --git a/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/om/request/s3/security/TestS3RevokeSTSTokenRequest.java b/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/om/request/s3/security/TestS3RevokeSTSTokenRequest.java index d4460ad83e6e..5a2eadd40fdc 100644 --- a/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/om/request/s3/security/TestS3RevokeSTSTokenRequest.java +++ b/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/om/request/s3/security/TestS3RevokeSTSTokenRequest.java @@ -21,19 +21,28 @@ 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.Mockito.eq; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import java.io.IOException; import java.util.Optional; import java.util.UUID; import org.apache.hadoop.hdds.security.symmetric.SecretKeyClient; +import org.apache.hadoop.hdds.utils.db.Table; +import org.apache.hadoop.hdds.utils.db.cache.CacheKey; +import org.apache.hadoop.hdds.utils.db.cache.CacheValue; import org.apache.hadoop.ipc.ExternalCall; import org.apache.hadoop.ipc.Server; +import org.apache.hadoop.ozone.audit.AuditLogger; +import org.apache.hadoop.ozone.om.OMMetadataManager; import org.apache.hadoop.ozone.om.OMMultiTenantManager; 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.response.OMClientResponse; import org.apache.hadoop.ozone.protocol.proto.OzoneManagerProtocolProtos; import org.apache.hadoop.ozone.protocol.proto.OzoneManagerProtocolProtos.OMRequest; import org.apache.hadoop.ozone.protocol.proto.OzoneManagerProtocolProtos.Type; @@ -283,6 +292,41 @@ public void testPreExecuteFailsForNonOwnerNonAdminInTenant() throws Exception { assertEquals(OMException.ResultCodes.USER_MISMATCH, ex.getResult()); } + @Test + public void testValidateAndUpdateCacheUpdatesCacheImmediately() throws Exception { + final String tempAccessKeyId = "ASIA4567891230"; + final String originalAccessKeyId = "original-access-key-id"; + final String sessionToken = createSessionToken(tempAccessKeyId, originalAccessKeyId); + + final OzoneManager ozoneManager = mock(OzoneManager.class); + final OMMetadataManager omMetadataManager = mock(OMMetadataManager.class); + @SuppressWarnings("unchecked") + final Table s3RevokedStsTokenTable = mock(Table.class); + final ExecutionContext context = mock(ExecutionContext.class); + final AuditLogger auditLogger = mock(AuditLogger.class); + + when(ozoneManager.getMetadataManager()).thenReturn(omMetadataManager); + when(omMetadataManager.getS3RevokedStsTokenTable()).thenReturn(s3RevokedStsTokenTable); + when(ozoneManager.getAuditLogger()).thenReturn(auditLogger); + + final OzoneManagerProtocolProtos.RevokeSTSTokenRequest revokeRequest = + OzoneManagerProtocolProtos.RevokeSTSTokenRequest.newBuilder() + .setSessionToken(sessionToken) + .build(); + + final OMRequest omRequest = OMRequest.newBuilder() + .setClientId(UUID.randomUUID().toString()) + .setCmdType(Type.RevokeSTSToken) + .setRevokeSTSTokenRequest(revokeRequest) + .build(); + + final S3RevokeSTSTokenRequest s3RevokeSTSTokenRequest = new S3RevokeSTSTokenRequest(omRequest); + final OMClientResponse omClientResponse = s3RevokeSTSTokenRequest.validateAndUpdateCache(ozoneManager, context); + + assertEquals(OzoneManagerProtocolProtos.Status.OK, omClientResponse.getOMResponse().getStatus()); + verify(s3RevokedStsTokenTable).addCacheEntry(eq(new CacheKey<>(sessionToken)), any(CacheValue.class)); + } + /** * Stub used to inject a remote user into the ProtobufRpcEngine.Server.getRemoteUser() thread-local. */ 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 d15cf5c427fe..a7ef000c6727 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 @@ -537,7 +537,8 @@ protected void auditReadFailure(AuditAction action, Exception ex) { protected boolean isAccessDenied(OMException ex) { ResultCodes result = ex.getResult(); return result == ResultCodes.PERMISSION_DENIED - || result == ResultCodes.INVALID_TOKEN; + || result == ResultCodes.INVALID_TOKEN + || result == ResultCodes.REVOKED_TOKEN; } } diff --git a/hadoop-ozone/s3gateway/src/test/java/org/apache/hadoop/ozone/s3/endpoint/TestEndpointBase.java b/hadoop-ozone/s3gateway/src/test/java/org/apache/hadoop/ozone/s3/endpoint/TestEndpointBase.java index ae47655e4314..25426f044955 100644 --- a/hadoop-ozone/s3gateway/src/test/java/org/apache/hadoop/ozone/s3/endpoint/TestEndpointBase.java +++ b/hadoop-ozone/s3gateway/src/test/java/org/apache/hadoop/ozone/s3/endpoint/TestEndpointBase.java @@ -17,10 +17,13 @@ package org.apache.hadoop.ozone.s3.endpoint; +import static org.apache.hadoop.ozone.om.exceptions.OMException.ResultCodes; import static org.apache.hadoop.ozone.s3.util.S3Consts.CUSTOM_METADATA_HEADER_PREFIX; import static org.assertj.core.api.Assertions.assertThat; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; import java.nio.charset.StandardCharsets; import java.util.Locale; @@ -28,6 +31,7 @@ import javax.ws.rs.core.MultivaluedHashMap; import javax.ws.rs.core.MultivaluedMap; import org.apache.hadoop.ozone.OzoneConsts; +import org.apache.hadoop.ozone.om.exceptions.OMException; import org.apache.hadoop.ozone.s3.exception.OS3Exception; import org.junit.jupiter.api.Test; @@ -114,4 +118,18 @@ public void init() { } assertEquals(value, customMetadata.get(key)); } + @Test + public void testAccessDeniedResultCodes() { + final EndpointBase endpointBase = new EndpointBase() { + @Override + public void init() { } + }; + + assertTrue(endpointBase.isAccessDenied(new OMException(ResultCodes.PERMISSION_DENIED))); + assertTrue(endpointBase.isAccessDenied(new OMException(ResultCodes.INVALID_TOKEN))); + assertTrue(endpointBase.isAccessDenied(new OMException(ResultCodes.REVOKED_TOKEN))); + assertFalse(endpointBase.isAccessDenied(new OMException(ResultCodes.INTERNAL_ERROR))); + assertFalse(endpointBase.isAccessDenied(new OMException(ResultCodes.BUCKET_NOT_FOUND))); + } + } From c63f0096315903435666101c4d416d6d94f4286e Mon Sep 17 00:00:00 2001 From: fmorg-git Date: Thu, 8 Jan 2026 22:43:24 -0800 Subject: [PATCH 18/54] HDDS-14094. [STS] Background service to remove revoked tokens that are past expiration from DB (#9468) --- .../src/main/resources/ozone-default.xml | 21 + .../java/org/apache/hadoop/ozone/OmUtils.java | 3 +- .../apache/hadoop/ozone/om/OMConfigKeys.java | 10 + .../src/main/proto/OmClientProtocol.proto | 24 +- .../apache/hadoop/ozone/om/OzoneManager.java | 17 + .../ratis/utils/OzoneManagerRatisUtils.java | 3 + .../S3DeleteRevokedSTSTokensRequest.java | 72 +++ .../S3DeleteRevokedSTSTokensResponse.java | 66 +++ .../RevokedSTSTokenCleanupService.java | 267 +++++++++++ .../TestRevokedSTSTokenCleanupService.java | 448 ++++++++++++++++++ .../hadoop/ozone/om/service/package-info.java | 21 + 11 files changed, 946 insertions(+), 6 deletions(-) create mode 100644 hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/s3/security/S3DeleteRevokedSTSTokensRequest.java create mode 100644 hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/response/s3/security/S3DeleteRevokedSTSTokensResponse.java create mode 100644 hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/service/RevokedSTSTokenCleanupService.java create mode 100644 hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/om/service/TestRevokedSTSTokenCleanupService.java create mode 100644 hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/om/service/package-info.java diff --git a/hadoop-hdds/common/src/main/resources/ozone-default.xml b/hadoop-hdds/common/src/main/resources/ozone-default.xml index 1e8df1c67470..f3ea84abb4ab 100644 --- a/hadoop-hdds/common/src/main/resources/ozone-default.xml +++ b/hadoop-hdds/common/src/main/resources/ozone-default.xml @@ -4858,4 +4858,25 @@ 5m Interval for cleaning up orphan snapshot local data versions corresponding to snapshots + + + ozone.om.sts.token.cleanup.service.interval + 3h + OZONE, OM, PERFORMANCE, SECURITY + + A background job that periodically checks revoked STS token entries and + deletes ones that have existed for 12 hours. This entry controls the interval of this + cleanup check. Unit could be defined with postfix (ns,ms,s,m,h,d). + + + + ozone.om.sts.token.cleanup.service.timeout + 15m + OZONE, OM, PERFORMANCE, SECURITY + + A timeout value for the revoked STS token cleanup service. If this is set + greater than 0, the service will stop waiting for the deletion + completion after this time. Unit could be defined with postfix (ns,ms,s,m,h,d). + + diff --git a/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/OmUtils.java b/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/OmUtils.java index dd70a9056f96..9707f7b0cdbc 100644 --- a/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/OmUtils.java +++ b/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/OmUtils.java @@ -344,6 +344,7 @@ public static boolean isReadOnly( case QuotaRepair: case PutObjectTagging: case DeleteObjectTagging: + case DeleteRevokedSTSTokens: case UnknownCommand: return false; case EchoRPC: @@ -364,7 +365,7 @@ public static byte[] getSHADigest() throws IOException { "This could possibly indicate a faulty JRE"); } } - + /** * Get a collection of all active omNodeIds (excluding decommissioned nodes) * for the given omServiceId. diff --git a/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/om/OMConfigKeys.java b/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/om/OMConfigKeys.java index 469900aa8ea7..a3d9a2063cfb 100644 --- a/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/om/OMConfigKeys.java +++ b/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/om/OMConfigKeys.java @@ -682,6 +682,16 @@ public final class OMConfigKeys { "ozone.om.snapshot.local.data.manager.service.interval"; public static final String OZONE_OM_SNAPSHOT_LOCAL_DATA_MANAGER_SERVICE_INTERVAL_DEFAULT = "5m"; + public static final String OZONE_OM_STS_TOKEN_CLEANUP_SERVICE_INTERVAL + = "ozone.om.sts.token.cleanup.service.interval"; + public static final String OZONE_OM_STS_TOKEN_CLEANUP_SERVICE_INTERVAL_DEFAULT + = "3h"; + + public static final String OZONE_OM_STS_TOKEN_CLEANUP_SERVICE_TIMEOUT + = "ozone.om.sts.token.cleanup.service.timeout"; + public static final String OZONE_OM_STS_TOKEN_CLEANUP_SERVICE_TIMEOUT_DEFAULT + = "15m"; + /** * Never constructed. */ diff --git a/hadoop-ozone/interface-client/src/main/proto/OmClientProtocol.proto b/hadoop-ozone/interface-client/src/main/proto/OmClientProtocol.proto index 08eb79fbd9ea..00892f79a719 100644 --- a/hadoop-ozone/interface-client/src/main/proto/OmClientProtocol.proto +++ b/hadoop-ozone/interface-client/src/main/proto/OmClientProtocol.proto @@ -158,6 +158,7 @@ enum Type { DeleteObjectTagging = 142; AssumeRole = 143; RevokeSTSToken = 144; + DeleteRevokedSTSTokens = 145; } enum SafeMode { @@ -308,6 +309,7 @@ message OMRequest { repeated SetSnapshotPropertyRequest SetSnapshotPropertyRequests = 143; optional AssumeRoleRequest assumeRoleRequest = 144; optional RevokeSTSTokenRequest revokeSTSTokenRequest = 145; + optional DeleteRevokedSTSTokensRequest deleteRevokedSTSTokensRequest = 146; } message OMResponse { @@ -438,11 +440,12 @@ message OMResponse { optional GetQuotaRepairStatusResponse GetQuotaRepairStatusResponse = 136; optional StartQuotaRepairResponse StartQuotaRepairResponse = 137; - optional GetObjectTaggingResponse getObjectTaggingResponse = 140; - optional PutObjectTaggingResponse putObjectTaggingResponse = 141; - optional DeleteObjectTaggingResponse deleteObjectTaggingResponse = 142; - optional AssumeRoleResponse assumeRoleResponse = 143; - optional RevokeSTSTokenResponse revokeSTSTokenResponse = 144; + optional GetObjectTaggingResponse getObjectTaggingResponse = 140; + optional PutObjectTaggingResponse putObjectTaggingResponse = 141; + optional DeleteObjectTaggingResponse deleteObjectTaggingResponse = 142; + optional AssumeRoleResponse assumeRoleResponse = 143; + optional RevokeSTSTokenResponse revokeSTSTokenResponse = 144; + optional DeleteRevokedSTSTokensResponse deleteRevokedSTSTokensResponse = 145; } enum Status { @@ -2393,6 +2396,17 @@ message RevokeSTSTokenRequest { message RevokeSTSTokenResponse { } +/** + This will contain a list of revoked STS session tokens whose entries should be removed from + the s3RevokedStsTokenTable. +*/ +message DeleteRevokedSTSTokensRequest { + repeated string sessionToken = 1; +} + +message DeleteRevokedSTSTokensResponse { +} + /** The OM service that takes care of Ozone namespace. */ 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 e6c5f916cc1e..7917130950e9 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 @@ -287,6 +287,7 @@ import org.apache.hadoop.ozone.om.service.DirectoryDeletingService; import org.apache.hadoop.ozone.om.service.OMRangerBGSyncService; import org.apache.hadoop.ozone.om.service.QuotaRepairTask; +import org.apache.hadoop.ozone.om.service.RevokedSTSTokenCleanupService; import org.apache.hadoop.ozone.om.snapshot.OmSnapshotUtils; import org.apache.hadoop.ozone.om.upgrade.OMLayoutFeature; import org.apache.hadoop.ozone.om.upgrade.OMLayoutVersionManager; @@ -438,6 +439,7 @@ public final class OzoneManager extends ServiceRuntimeInfoImpl private final boolean isSpnegoEnabled; private final SecurityConfig secConfig; private S3SecretManager s3SecretManager; + private RevokedSTSTokenCleanupService revokedSTSTokenCleanupService; private final boolean isOmGrpcServerEnabled; private volatile boolean isOmRpcServerRunning = false; private volatile boolean isOmGrpcServerRunning = false; @@ -1946,6 +1948,18 @@ public void start() throws IOException { keyManager.start(configuration); + final long stsTokenCleanupInterval = configuration.getTimeDuration( + OMConfigKeys.OZONE_OM_STS_TOKEN_CLEANUP_SERVICE_INTERVAL, + OMConfigKeys.OZONE_OM_STS_TOKEN_CLEANUP_SERVICE_INTERVAL_DEFAULT, + TimeUnit.MILLISECONDS); + final long stsTokenCleanupTimeout = configuration.getTimeDuration( + OMConfigKeys.OZONE_OM_STS_TOKEN_CLEANUP_SERVICE_TIMEOUT, + OMConfigKeys.OZONE_OM_STS_TOKEN_CLEANUP_SERVICE_TIMEOUT_DEFAULT, + TimeUnit.MILLISECONDS); + revokedSTSTokenCleanupService = new RevokedSTSTokenCleanupService( + stsTokenCleanupInterval, TimeUnit.MILLISECONDS, stsTokenCleanupTimeout, this); + revokedSTSTokenCleanupService.start(); + try { httpServer = new OzoneManagerHttpServer(configuration, this); httpServer.start(); @@ -2524,6 +2538,9 @@ public boolean stop() { if (edekCacheLoader != null) { edekCacheLoader.shutdown(); } + if (revokedSTSTokenCleanupService != null) { + revokedSTSTokenCleanupService.shutdown(); + } return true; } catch (Exception e) { LOG.error("OzoneManager stop failed.", e); 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 706e00f9537e..4f1b2fc952da 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 @@ -68,6 +68,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.S3DeleteRevokedSTSTokensRequest; import org.apache.hadoop.ozone.om.request.s3.security.S3GetSecretRequest; import org.apache.hadoop.ozone.om.request.s3.security.S3RevokeSTSTokenRequest; import org.apache.hadoop.ozone.om.request.s3.security.S3RevokeSecretRequest; @@ -199,6 +200,8 @@ public static OMClientRequest createClientRequest(OMRequest omRequest, return new S3RevokeSecretRequest(omRequest); case RevokeSTSToken: return new S3RevokeSTSTokenRequest(omRequest); + case DeleteRevokedSTSTokens: + return new S3DeleteRevokedSTSTokensRequest(omRequest); case PurgeKeys: return new OMKeyPurgeRequest(omRequest); case PurgeDirectories: diff --git a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/s3/security/S3DeleteRevokedSTSTokensRequest.java b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/s3/security/S3DeleteRevokedSTSTokensRequest.java new file mode 100644 index 000000000000..ee2a8656445c --- /dev/null +++ b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/s3/security/S3DeleteRevokedSTSTokensRequest.java @@ -0,0 +1,72 @@ +/* + * 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 java.io.IOException; +import java.util.List; +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.S3DeleteRevokedSTSTokensResponse; +import org.apache.hadoop.ozone.om.service.RevokedSTSTokenCleanupService; +import org.apache.hadoop.ozone.protocol.proto.OzoneManagerProtocolProtos.DeleteRevokedSTSTokensRequest; +import org.apache.hadoop.ozone.protocol.proto.OzoneManagerProtocolProtos.OMRequest; +import org.apache.hadoop.ozone.protocol.proto.OzoneManagerProtocolProtos.OMResponse; +import org.apache.hadoop.security.UserGroupInformation; +import org.apache.hadoop.security.authentication.client.AuthenticationException; + +/** + * Handles DeleteRevokedSTSTokens requests submitted by {@link RevokedSTSTokenCleanupService}. + */ +public class S3DeleteRevokedSTSTokensRequest extends OMClientRequest { + + public S3DeleteRevokedSTSTokensRequest(OMRequest omRequest) { + super(omRequest); + } + + @Override + public OMRequest preExecute(OzoneManager ozoneManager) throws IOException { + final UserGroupInformation ugi; + try { + ugi = createUGI(); + } catch (AuthenticationException e) { + throw new OMException(e, OMException.ResultCodes.PERMISSION_DENIED); + } + if (!ozoneManager.isAdmin(ugi) && !ozoneManager.isS3Admin(ugi)) { + throw new OMException("Only admins can delete revoked STS tokens", OMException.ResultCodes.PERMISSION_DENIED); + } + + return getOmRequest().toBuilder() + .setUserInfo(getUserInfo()) + .build(); + } + + @Override + public OMClientResponse validateAndUpdateCache(OzoneManager ozoneManager, ExecutionContext context) { + final DeleteRevokedSTSTokensRequest request = getOmRequest().getDeleteRevokedSTSTokensRequest(); + final OMResponse.Builder omResponse = OmResponseUtil.getOMResponseBuilder(getOmRequest()); + + final List sessionTokens = request.getSessionTokenList(); + return new S3DeleteRevokedSTSTokensResponse(sessionTokens, omResponse.build()); + } +} + + diff --git a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/response/s3/security/S3DeleteRevokedSTSTokensResponse.java b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/response/s3/security/S3DeleteRevokedSTSTokensResponse.java new file mode 100644 index 000000000000..cb44e7f466d9 --- /dev/null +++ b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/response/s3/security/S3DeleteRevokedSTSTokensResponse.java @@ -0,0 +1,66 @@ +/* + * 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.apache.hadoop.ozone.om.codec.OMDBDefinition.S3_REVOKED_STS_TOKEN_TABLE; +import static org.apache.hadoop.ozone.protocol.proto.OzoneManagerProtocolProtos.Status.OK; + +import jakarta.annotation.Nonnull; +import java.io.IOException; +import java.util.List; +import org.apache.hadoop.hdds.utils.db.BatchOperation; +import org.apache.hadoop.hdds.utils.db.Table; +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 DeleteRevokedSTSTokens request. + */ +@CleanupTableInfo(cleanupTables = {S3_REVOKED_STS_TOKEN_TABLE}) +public class S3DeleteRevokedSTSTokensResponse extends OMClientResponse { + + private final List sessionTokens; + + public S3DeleteRevokedSTSTokensResponse(List sessionTokens, @Nonnull OMResponse omResponse) { + super(omResponse); + this.sessionTokens = sessionTokens; + } + + @Override + public void addToDBBatch(OMMetadataManager omMetadataManager, BatchOperation batchOperation) throws IOException { + if (sessionTokens == null || sessionTokens.isEmpty()) { + return; + } + if (!getOMResponse().hasStatus() || getOMResponse().getStatus() != OK) { + return; + } + + final Table table = omMetadataManager.getS3RevokedStsTokenTable(); + if (table == null) { + return; + } + + for (String sessionToken : sessionTokens) { + table.deleteWithBatch(batchOperation, sessionToken); + } + } +} + + diff --git a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/service/RevokedSTSTokenCleanupService.java b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/service/RevokedSTSTokenCleanupService.java new file mode 100644 index 000000000000..3d9668d6469c --- /dev/null +++ b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/service/RevokedSTSTokenCleanupService.java @@ -0,0 +1,267 @@ +/* + * 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.service; + +import com.google.common.annotations.VisibleForTesting; +import com.google.protobuf.ServiceException; +import java.io.IOException; +import java.time.Clock; +import java.time.ZoneOffset; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicLong; +import org.apache.hadoop.hdds.conf.StorageUnit; +import org.apache.hadoop.hdds.utils.BackgroundService; +import org.apache.hadoop.hdds.utils.BackgroundTask; +import org.apache.hadoop.hdds.utils.BackgroundTaskQueue; +import org.apache.hadoop.hdds.utils.BackgroundTaskResult; +import org.apache.hadoop.hdds.utils.db.Table; +import org.apache.hadoop.ozone.ClientVersion; +import org.apache.hadoop.ozone.om.OMConfigKeys; +import org.apache.hadoop.ozone.om.OMMetadataManager; +import org.apache.hadoop.ozone.om.OzoneManager; +import org.apache.hadoop.ozone.om.ratis.utils.OzoneManagerRatisUtils; +import org.apache.hadoop.ozone.protocol.proto.OzoneManagerProtocolProtos.DeleteRevokedSTSTokensRequest; +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.Type; +import org.apache.hadoop.util.Time; +import org.apache.ratis.protocol.ClientId; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Background service that periodically scans the revoked STS token table and submits OM requests to + * remove entries have been present past the cleanup threshold. + */ +public class RevokedSTSTokenCleanupService extends BackgroundService { + private static final Logger LOG = LoggerFactory.getLogger(RevokedSTSTokenCleanupService.class); + + // Use a single thread + private static final int REVOKED_STS_TOKEN_CLEANER_CORE_POOL_SIZE = 1; + private static final Clock CLOCK = Clock.system(ZoneOffset.UTC); + private static final long CLEANUP_THRESHOLD = 12 * 60 * 60 * 1000L; // 12 hours in milliseconds + + private final OzoneManager ozoneManager; + private final OMMetadataManager metadataManager; + private final AtomicBoolean suspended; + private final AtomicLong runCount; + private final AtomicLong submittedDeletedEntryCount; + private final AtomicLong callIdCount; + // Dummy client ID to use for response, since this is triggered by a + // service, not the client. + private final ClientId clientId = ClientId.randomId(); + private final int ratisByteLimit; + + /** + * Creates a Revoked STS Token cleanup service. + * + * @param interval the interval between successive runs + * @param unit the time unit for {@code interval} + * @param serviceTimeout timeout for a single run + * @param ozoneManager the OzoneManager instance + */ + public RevokedSTSTokenCleanupService(long interval, TimeUnit unit, long serviceTimeout, OzoneManager ozoneManager) { + super( + "RevokedSTSTokenCleanupService", interval, unit, REVOKED_STS_TOKEN_CLEANER_CORE_POOL_SIZE, + serviceTimeout, ozoneManager.getThreadNamePrefix()); + this.ozoneManager = ozoneManager; + this.metadataManager = ozoneManager.getMetadataManager(); + this.suspended = new AtomicBoolean(false); + this.runCount = new AtomicLong(0); + this.submittedDeletedEntryCount = new AtomicLong(0); + this.callIdCount = new AtomicLong(0); + int limit = (int) ozoneManager.getConfiguration().getStorageSize( + OMConfigKeys.OZONE_OM_RATIS_LOG_APPENDER_QUEUE_BYTE_LIMIT, + OMConfigKeys.OZONE_OM_RATIS_LOG_APPENDER_QUEUE_BYTE_LIMIT_DEFAULT, StorageUnit.BYTES); + // Always go to 90% of max limit for request as other header(s) will be added + this.ratisByteLimit = (int) (limit * 0.9); + } + + /** + * Returns the number of times this Background service has run. + * @return Long, run count. + */ + @VisibleForTesting + public long getRunCount() { + return runCount.get(); + } + + /** + * Returns the number of entries this Background service has submitted for deletion. + * @return Long, submitted for deletion entry count. + */ + @VisibleForTesting + public long getSubmittedDeletedEntryCount() { + return submittedDeletedEntryCount.get(); + } + + @Override + public BackgroundTaskQueue getTasks() { + final BackgroundTaskQueue queue = new BackgroundTaskQueue(); + queue.add(new RevokedSTSTokenCleanupTask()); + return queue; + } + + private boolean shouldRun() { + return !suspended.get() && ozoneManager.isLeaderReady(); + } + + private class RevokedSTSTokenCleanupTask implements BackgroundTask { + + @Override + public BackgroundTaskResult call() throws Exception { + if (!shouldRun()) { + return BackgroundTaskResult.EmptyTaskResult.newResult(); + } + + final long startTime = Time.monotonicNow(); + runCount.incrementAndGet(); + final Table revokedStsTokenTable = metadataManager.getS3RevokedStsTokenTable(); + + long deletedInRun = 0; + final List batch = new ArrayList<>(); + + try (Table.KeyValueIterator iterator = revokedStsTokenTable.iterator()) { + iterator.seekToFirst(); + while (iterator.hasNext()) { + final Table.KeyValue entry = iterator.next(); + final String sessionToken = entry.getKey(); + final Long initialCreationTimeMillis = entry.getValue(); + + if (shouldCleanup(initialCreationTimeMillis)) { + // Calculate the size this token would add to the protobuf message. + // Make a copy of the batch to do the size check + final List batchCopyWithCandidate = new ArrayList<>(batch); + batchCopyWithCandidate.add(sessionToken); + int batchWithCandidateSize = getBatchSerializedSize(batchCopyWithCandidate); + + // If adding this token would exceed the limit, submit the current batch + if (batchWithCandidateSize > ratisByteLimit) { + if (!batch.isEmpty()) { + if (submitCleanupRequest(batch)) { + deletedInRun += batch.size(); + } else { + LOG.warn("Failed to submit batch of {} revoked tokens.", batch.size()); + } + batch.clear(); + + // Re-calculate the size of the candidate token alone in an empty batch + // to check if it exceeds the limit by itself. + final List singleCandidateBatch = new ArrayList<>(); + singleCandidateBatch.add(sessionToken); + batchWithCandidateSize = getBatchSerializedSize(singleCandidateBatch); + } + + // Check if the single token exceeds the limit (either strictly single or after flush) + if (batchWithCandidateSize > ratisByteLimit) { + LOG.error( + "Single revoked STS Token size ({}) would exceed the ratisByteLimit ({}). SessionToken " + + "initialCreationTimeMillis: {}", batchWithCandidateSize, ratisByteLimit, initialCreationTimeMillis); + continue; + } + } + batch.add(sessionToken); + } + } + } catch (IOException e) { + LOG.error("Failure while scanning s3RevokedStsTokenTable. It will be retried in the next interval", e); + if (deletedInRun == 0) { + return BackgroundTaskResult.EmptyTaskResult.newResult(); + } + } + + // Submit any remaining tokens + if (!batch.isEmpty()) { + if (submitCleanupRequest(batch)) { + deletedInRun += batch.size(); + } else { + LOG.warn("Failed to submit final batch of {} revoked tokens.", batch.size()); + } + } + + // Update stats + if (deletedInRun > 0) { + submittedDeletedEntryCount.addAndGet(deletedInRun); + LOG.info("Found and removed {} revoked STS token entries.", deletedInRun); + } + + final long elapsed = Time.monotonicNow() - startTime; + LOG.info("RevokedSTSTokenCleanupService run completed. deletedEntriesInRun={}, totalDeletedEntries={}, " + + "callIdCount={}, elapsedTimeMs={}", deletedInRun, submittedDeletedEntryCount.get(), callIdCount.get(), + elapsed); + + final long resultCount = deletedInRun; + return () -> (int) resultCount; + } + + /** + * Returns true if the given STS session token has been in the table past the cleanup threshold. + */ + private boolean shouldCleanup(long initialCreationTimeMillis) { + final long now = CLOCK.millis(); + + if (now - initialCreationTimeMillis > CLEANUP_THRESHOLD) { + if (LOG.isDebugEnabled()) { + LOG.debug( + "Revoked STS token entry created at {} is older than 12 hours, will clean up. Current time: {}", + initialCreationTimeMillis, now); + } + return true; + } + return false; + } + + /** + * Builds and submits an OMRequest to delete the provided revoked STS token(s). + */ + private boolean submitCleanupRequest(List sessionTokens) { + final DeleteRevokedSTSTokensRequest request = DeleteRevokedSTSTokensRequest.newBuilder() + .addAllSessionToken(sessionTokens) + .build(); + + final OMRequest omRequest = OMRequest.newBuilder() + .setCmdType(Type.DeleteRevokedSTSTokens) + .setDeleteRevokedSTSTokensRequest(request) + .setClientId(clientId.toString()) + .setVersion(ClientVersion.CURRENT_VERSION) + .build(); + + try { + final OMResponse omResponse = OzoneManagerRatisUtils.submitRequest( + ozoneManager, omRequest, clientId, callIdCount.incrementAndGet()); + return omResponse != null && omResponse.getSuccess(); + } catch (ServiceException e) { + LOG.error("Revoked STS token cleanup request failed. Will retry at next run.", e); + return false; + } + } + + private int getBatchSerializedSize(List sessionTokenBatch) { + final DeleteRevokedSTSTokensRequest request = DeleteRevokedSTSTokensRequest.newBuilder() + .addAllSessionToken(sessionTokenBatch) + .build(); + + return request.getSerializedSize(); + } + } +} + + diff --git a/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/om/service/TestRevokedSTSTokenCleanupService.java b/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/om/service/TestRevokedSTSTokenCleanupService.java new file mode 100644 index 000000000000..1cf459d03245 --- /dev/null +++ b/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/om/service/TestRevokedSTSTokenCleanupService.java @@ -0,0 +1,448 @@ +/* + * 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.service; + +import static org.apache.hadoop.ozone.protocol.proto.OzoneManagerProtocolProtos.Status.OK; +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.when; + +import com.google.protobuf.ServiceException; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.Consumer; +import org.apache.hadoop.hdds.conf.OzoneConfiguration; +import org.apache.hadoop.hdds.conf.StorageUnit; +import org.apache.hadoop.hdds.utils.db.StringInMemoryTestTable; +import org.apache.hadoop.ozone.om.OMConfigKeys; +import org.apache.hadoop.ozone.om.OMMetadataManager; +import org.apache.hadoop.ozone.om.OzoneManager; +import org.apache.hadoop.ozone.om.ratis.utils.OzoneManagerRatisUtils; +import org.apache.hadoop.ozone.protocol.proto.OzoneManagerProtocolProtos.DeleteRevokedSTSTokensRequest; +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.Status; +import org.apache.hadoop.ozone.protocol.proto.OzoneManagerProtocolProtos.Type; +import org.apache.ozone.test.TestClock; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.MockedStatic; + +/** + * Unit tests for {@link RevokedSTSTokenCleanupService}. + */ +public class TestRevokedSTSTokenCleanupService { + private OzoneManager ozoneManager; + private StringInMemoryTestTable revokedStsTokenTable; + private TestClock testClock; + private OzoneConfiguration ozoneConfiguration; + + @BeforeEach + public void setUp() { + testClock = TestClock.newInstance(); + ozoneManager = mock(OzoneManager.class); + ozoneConfiguration = new OzoneConfiguration(); + final OMMetadataManager omMetadataManager = mock(OMMetadataManager.class); + revokedStsTokenTable = new StringInMemoryTestTable<>(); + + when(ozoneManager.isLeaderReady()).thenReturn(true); + when(ozoneManager.getMetadataManager()).thenReturn(omMetadataManager); + when(ozoneManager.getConfiguration()).thenReturn(ozoneConfiguration); + when(ozoneManager.getThreadNamePrefix()).thenReturn("om-"); + when(omMetadataManager.getS3RevokedStsTokenTable()).thenReturn(revokedStsTokenTable); + } + + @Test + public void submitsCleanupRequestForOnlyExpiredTokens() throws Exception { + // If there are two revoked entries, one expired and one not expired, only the expired session token should be + // submitted for cleanup. + final long nowMillis = testClock.millis(); + final long expiredCreationTimeMillis = nowMillis - TimeUnit.HOURS.toMillis(13); // older than 12h threshold + final long validCreationTimeMillis = nowMillis - TimeUnit.HOURS.toMillis(1); + revokedStsTokenTable.put("session-token-a", expiredCreationTimeMillis); + revokedStsTokenTable.put("session-token-b", validCreationTimeMillis); + + final AtomicReference capturedRequest = new AtomicReference<>(); + + try (MockedStatic ozoneManagerRatisUtilsMock = mockStatic(OzoneManagerRatisUtils.class)) { + mockRatisSubmitAndCapture(ozoneManagerRatisUtilsMock, capturedRequest); + + // Run the cleanup service + final RevokedSTSTokenCleanupService revokedSTSTokenCleanupService = createAndRunCleanupService(); + + assertThat(revokedSTSTokenCleanupService.getRunCount()).isEqualTo(1); + assertThat(revokedSTSTokenCleanupService.getSubmittedDeletedEntryCount()).isEqualTo(1); + + final OMRequest omRequest = capturedRequest.get(); + assertThat(omRequest).isNotNull(); + assertThat(omRequest.getCmdType()).isEqualTo(Type.DeleteRevokedSTSTokens); + + final DeleteRevokedSTSTokensRequest deleteRevokedSTSTokensRequest = + omRequest.getDeleteRevokedSTSTokensRequest(); + assertThat(deleteRevokedSTSTokensRequest.getSessionTokenList()).containsExactly("session-token-a"); + } + } + + @Test + public void doesNotSubmitRequestWhenThereAreNoExpiredTokens() throws Exception { + // If only non-expired entries exist in the revoked sts token table, no cleanup request should be submitted and + // no metrics should be updated. + final long nowMillis = testClock.millis(); + revokedStsTokenTable.put("session-token-c", nowMillis - TimeUnit.HOURS.toMillis(1)); + revokedStsTokenTable.put("session-token-d", nowMillis - TimeUnit.HOURS.toMillis(2)); + + final AtomicReference capturedRequest = new AtomicReference<>(); + + try (MockedStatic ozoneManagerRatisUtilsMock = mockStatic(OzoneManagerRatisUtils.class)) { + mockRatisSubmitAndCapture(ozoneManagerRatisUtilsMock, capturedRequest); + + // Run the cleanup service + final RevokedSTSTokenCleanupService revokedSTSTokenCleanupService = createAndRunCleanupService(); + + assertThat(revokedSTSTokenCleanupService.getRunCount()).isEqualTo(1); + assertThat(revokedSTSTokenCleanupService.getSubmittedDeletedEntryCount()).isZero(); + assertThat(capturedRequest.get()).isNull(); + } + } + + @Test + public void handlesNoEntriesInRevokedSTSTokenTable() throws Exception { + // If the table is empty (which most of the time it will be), no cleanup requests should be submitted and no metrics + // should be updated. + final AtomicReference capturedRequest = new AtomicReference<>(); + + try (MockedStatic ozoneManagerRatisUtilsMock = mockStatic(OzoneManagerRatisUtils.class)) { + mockRatisSubmitAndCapture(ozoneManagerRatisUtilsMock, capturedRequest); + + // Run the cleanup service + final RevokedSTSTokenCleanupService revokedSTSTokenCleanupService = createAndRunCleanupService(); + + assertThat(revokedSTSTokenCleanupService.getRunCount()).isEqualTo(1); + assertThat(revokedSTSTokenCleanupService.getSubmittedDeletedEntryCount()).isZero(); + assertThat(capturedRequest.get()).isNull(); + } + } + + @Test + public void doesNotUpdateMetricsOnRatisSubmissionServiceExceptionFailure() throws Exception { + // If there are expired tokens in the table but the OM request submission to clean up the entries fails with a + // service exception, the metrics should not be updated + final long nowMillis = testClock.millis(); + revokedStsTokenTable.put("session-token-e", nowMillis - TimeUnit.HOURS.toMillis(13)); + revokedStsTokenTable.put("session-token-f", nowMillis - TimeUnit.HOURS.toMillis(14)); + + final AtomicInteger submitAttempts = new AtomicInteger(0); + + try (MockedStatic ozoneManagerRatisUtilsMock = mockStatic(OzoneManagerRatisUtils.class)) { + // Simulate Ratis submission failure + mockRatisSubmitToFail(ozoneManagerRatisUtilsMock, submitAttempts); + + // Run the cleanup service + final RevokedSTSTokenCleanupService revokedSTSTokenCleanupService = createAndRunCleanupService(); + + assertThat(revokedSTSTokenCleanupService.getRunCount()).isEqualTo(1); + assertThat(submitAttempts.get()).isEqualTo(1); + assertThat(revokedSTSTokenCleanupService.getSubmittedDeletedEntryCount()).isZero(); + } + } + + @Test + public void doesNotUpdateMetricsOnNonSuccessfulResponse() throws Exception { + // If there is an expired token in the table but the OM request submission to clean up the entries gets a + // non-successful response, the metrics should not be updated + final long nowMillis = testClock.millis(); + revokedStsTokenTable.put("session-token-f", nowMillis - TimeUnit.HOURS.toMillis(20)); + + try (MockedStatic ozoneManagerRatisUtilsMock = mockStatic(OzoneManagerRatisUtils.class)) { + // Return a non-successful response + mockRatisSubmitWithInternalErrorResponse(ozoneManagerRatisUtilsMock); + + // Run the cleanup service + final RevokedSTSTokenCleanupService revokedSTSTokenCleanupService = createAndRunCleanupService(); + + assertThat(revokedSTSTokenCleanupService.getRunCount()).isEqualTo(1); + assertThat(revokedSTSTokenCleanupService.getSubmittedDeletedEntryCount()).isZero(); + } + } + + @Test + public void handlesAllExpiredTokens() throws Exception { + // If all the tokens in the table are expired on a particular run, ensure the metrics are updated appropriately + final long nowMillis = testClock.millis(); + revokedStsTokenTable.put("session-token-g", nowMillis - TimeUnit.HOURS.toMillis(13)); + revokedStsTokenTable.put("session-token-h", nowMillis - TimeUnit.HOURS.toMillis(14)); + revokedStsTokenTable.put("session-token-i", nowMillis - TimeUnit.HOURS.toMillis(15)); + + final AtomicReference capturedRequest = new AtomicReference<>(); + + try (MockedStatic ozoneManagerRatisUtilsMock = mockStatic(OzoneManagerRatisUtils.class)) { + mockRatisSubmitAndCapture(ozoneManagerRatisUtilsMock, capturedRequest); + + // Run the cleanup service + final RevokedSTSTokenCleanupService revokedSTSTokenCleanupService = createAndRunCleanupService(); + + assertThat(revokedSTSTokenCleanupService.getRunCount()).isEqualTo(1); + assertThat(revokedSTSTokenCleanupService.getSubmittedDeletedEntryCount()).isEqualTo(3); + + final OMRequest omRequest = capturedRequest.get(); + assertThat(omRequest).isNotNull(); + assertThat(omRequest.getCmdType()).isEqualTo(Type.DeleteRevokedSTSTokens); + + final DeleteRevokedSTSTokensRequest deleteRevokedSTSTokensRequest = + omRequest.getDeleteRevokedSTSTokensRequest(); + assertThat(deleteRevokedSTSTokensRequest.getSessionTokenList()) + .containsExactlyInAnyOrder("session-token-g", "session-token-h", "session-token-i"); + } + } + + @Test + public void submitsMultipleRequestsWhenBatchSizeIsExceeded() throws Exception { + // If the tokens exceed the configured batch size, multiple requests should be submitted + final long nowMillis = testClock.millis(); + + // Create 10 expired tokens + for (int i = 0; i < 10; i++) { + revokedStsTokenTable.put("session-token-" + i, nowMillis - TimeUnit.HOURS.toMillis(13)); + } + + // Set a very small ratisByteLimit (100 bytes) to force batching. A single token request will be small, but 10 + // will exceed this. The effective limit will be 90 bytes (90% of 100). + ozoneConfiguration.setStorageSize( + OMConfigKeys.OZONE_OM_RATIS_LOG_APPENDER_QUEUE_BYTE_LIMIT, 100, StorageUnit.BYTES); + + final List capturedRequests = new ArrayList<>(); + + try (MockedStatic ozoneManagerRatisUtilsMock = mockStatic(OzoneManagerRatisUtils.class)) { + mockRatisSubmitAndCaptureRequests(ozoneManagerRatisUtilsMock, capturedRequests); + + // Run the cleanup service + final RevokedSTSTokenCleanupService revokedSTSTokenCleanupService = createAndRunCleanupService(); + + assertThat(revokedSTSTokenCleanupService.getRunCount()).isEqualTo(1); + // There should be multiple requests + assertThat(capturedRequests.size()).isEqualTo(2); + + // Verify all tokens were included across the requests + final int totalTokens = capturedRequests.stream() + .mapToInt(r -> r.getDeleteRevokedSTSTokensRequest().getSessionTokenList().size()) + .sum(); + assertThat(totalTokens).isEqualTo(10); + assertThat(revokedSTSTokenCleanupService.getSubmittedDeletedEntryCount()).isEqualTo(10); + } + } + + @Test + public void testSingleOversizedExpiredTokenAndItIsTheOnlyExpiredToken() throws Exception { + // One sessionToken is larger than the ratisByteLimit, and it is the only expired token + final long nowMillis = testClock.millis(); + // Serialized size for largeToken is 102 > 90 (the effective ratisByteLimit) . + final String largeToken = new String(new char[100]).replace('\0', 'a'); + revokedStsTokenTable.put(largeToken, nowMillis - TimeUnit.HOURS.toMillis(13)); + + ozoneConfiguration.setStorageSize( + OMConfigKeys.OZONE_OM_RATIS_LOG_APPENDER_QUEUE_BYTE_LIMIT, 100, StorageUnit.BYTES); + + final List capturedRequests = new ArrayList<>(); + + try (MockedStatic ozoneManagerRatisUtilsMock = mockStatic(OzoneManagerRatisUtils.class)) { + mockRatisSubmitAndCaptureRequests(ozoneManagerRatisUtilsMock, capturedRequests); + + final RevokedSTSTokenCleanupService revokedSTSTokenCleanupService = createAndRunCleanupService(); + + assertThat(revokedSTSTokenCleanupService.getRunCount()).isEqualTo(1); + // Single token exceeding ratisByteLimit is skipped + assertThat(capturedRequests).isEmpty(); + assertThat(revokedSTSTokenCleanupService.getSubmittedDeletedEntryCount()).isZero(); + } + } + + @Test + public void testSingleOversizedExpiredTokenAndThereAreMultipleExpiredTokens() throws Exception { + // One sessionToken is larger than the ratisByteLimit, and it is not the only expired token + final long nowMillis = testClock.millis(); + final String smallToken = "session-token-j"; + final String largeToken = "session-token-k-" + new String(new char[90]).replace('\0', 'a'); // > 90 bytes + + revokedStsTokenTable.put(smallToken, nowMillis - TimeUnit.HOURS.toMillis(13)); + revokedStsTokenTable.put(largeToken, nowMillis - TimeUnit.HOURS.toMillis(13)); + + ozoneConfiguration.setStorageSize( + OMConfigKeys.OZONE_OM_RATIS_LOG_APPENDER_QUEUE_BYTE_LIMIT, 100, StorageUnit.BYTES); + + final List capturedRequests = new ArrayList<>(); + + try (MockedStatic ozoneManagerRatisUtilsMock = mockStatic(OzoneManagerRatisUtils.class)) { + mockRatisSubmitAndCaptureRequests(ozoneManagerRatisUtilsMock, capturedRequests); + + final RevokedSTSTokenCleanupService revokedSTSTokenCleanupService = createAndRunCleanupService(); + + assertThat(revokedSTSTokenCleanupService.getRunCount()).isEqualTo(1); + assertThat(capturedRequests).hasSize(1); + assertThat(revokedSTSTokenCleanupService.getSubmittedDeletedEntryCount()).isEqualTo(1); + } + } + + @Test + public void testExpiredAndNonExpiredTokensWithSmallRatisByteLimit() throws Exception { + // Expired and non-expired entries with ratisByteLimit of 100 + final long nowMillis = testClock.millis(); + + revokedStsTokenTable.put("session-token-l", nowMillis - TimeUnit.HOURS.toMillis(13)); + revokedStsTokenTable.put("session-token-m", nowMillis - TimeUnit.HOURS.toMillis(1)); // Should be skipped + revokedStsTokenTable.put("session-token-n", nowMillis - TimeUnit.HOURS.toMillis(13)); + + ozoneConfiguration.setStorageSize( + OMConfigKeys.OZONE_OM_RATIS_LOG_APPENDER_QUEUE_BYTE_LIMIT, 100, StorageUnit.BYTES); + + final List capturedRequests = new ArrayList<>(); + + try (MockedStatic ozoneManagerRatisUtilsMock = mockStatic(OzoneManagerRatisUtils.class)) { + mockRatisSubmitAndCaptureRequests(ozoneManagerRatisUtilsMock, capturedRequests); + + final RevokedSTSTokenCleanupService revokedSTSTokenCleanupService = createAndRunCleanupService(); + + assertThat(revokedSTSTokenCleanupService.getRunCount()).isEqualTo(1); + // session-token-l and session-token-n fit in one batch. session-token-m is ignored because it is not expired. + assertThat(capturedRequests).hasSize(1); + assertThat(capturedRequests.get(0).getDeleteRevokedSTSTokensRequest().getSessionTokenList()) + .containsExactly("session-token-l", "session-token-n"); + assertThat(revokedSTSTokenCleanupService.getSubmittedDeletedEntryCount()).isEqualTo(2); + } + } + + @Test + public void testExpiredTokenMatchesRatisByteLimitExactly() throws Exception { + // Force small batch of 100 bytes and test when the batch size is exactly ratisByteLimit + final long nowMillis = testClock.millis(); + final String tokenMatchingRatisByteLimitWhenSerialized = new String(new char[88]).replace('\0', 'a'); + + revokedStsTokenTable.put(tokenMatchingRatisByteLimitWhenSerialized, nowMillis - TimeUnit.HOURS.toMillis(13)); + + ozoneConfiguration.setStorageSize( + OMConfigKeys.OZONE_OM_RATIS_LOG_APPENDER_QUEUE_BYTE_LIMIT, 100, StorageUnit.BYTES); + + final List capturedRequests = new ArrayList<>(); + + try (MockedStatic ozoneManagerRatisUtilsMock = mockStatic(OzoneManagerRatisUtils.class)) { + mockRatisSubmitAndCaptureRequests(ozoneManagerRatisUtilsMock, capturedRequests); + + final RevokedSTSTokenCleanupService revokedSTSTokenCleanupService = createAndRunCleanupService(); + + assertThat(revokedSTSTokenCleanupService.getRunCount()).isEqualTo(1); + assertThat(capturedRequests).hasSize(1); + assertThat(revokedSTSTokenCleanupService.getSubmittedDeletedEntryCount()).isEqualTo(1); + } + } + + @Test + public void testCallIdCountIncreasesAcrossBatches() throws Exception { + // Force small batch of 40 bytes (which should trigger multiple calls to OzoneManagerRatisUtils.submitRequest) + // and ensure the callIdCount increases across each batch + // session-token-1 and session-token-2 are in first batch, and session-token-3 is in second batch. + final long nowMillis = testClock.millis(); + + revokedStsTokenTable.put("session-token-1", nowMillis - TimeUnit.HOURS.toMillis(13)); + revokedStsTokenTable.put("session-token-2", nowMillis - TimeUnit.HOURS.toMillis(13)); + revokedStsTokenTable.put("session-token-3", nowMillis - TimeUnit.HOURS.toMillis(13)); + + ozoneConfiguration.setStorageSize(OMConfigKeys.OZONE_OM_RATIS_LOG_APPENDER_QUEUE_BYTE_LIMIT, 40, StorageUnit.BYTES); + + final List capturedCallIdCounts = new ArrayList<>(); + + try (MockedStatic ozoneManagerRatisUtilsMock = mockStatic(OzoneManagerRatisUtils.class)) { + // Capture the callIdCount (4th argument) + ozoneManagerRatisUtilsMock.when( + () -> OzoneManagerRatisUtils.submitRequest(any(), any(), any(), anyLong())) + .thenAnswer(invocation -> { + capturedCallIdCounts.add(invocation.getArgument(3)); + return buildOkResponse(invocation.getArgument(1)); + }); + + final RevokedSTSTokenCleanupService revokedSTSTokenCleanupService = createAndRunCleanupService(); + + assertThat(revokedSTSTokenCleanupService.getRunCount()).isEqualTo(1); + assertThat(revokedSTSTokenCleanupService.getSubmittedDeletedEntryCount()).isEqualTo(3); + assertThat(capturedCallIdCounts).hasSize(2); + assertThat(capturedCallIdCounts.get(1)).isGreaterThan(capturedCallIdCounts.get(0)); + } + } + + private RevokedSTSTokenCleanupService createAndRunCleanupService() throws Exception { + final RevokedSTSTokenCleanupService revokedSTSTokenCleanupService = + new RevokedSTSTokenCleanupService(1, TimeUnit.HOURS, 1_000, ozoneManager); + revokedSTSTokenCleanupService.runPeriodicalTaskNow(); + return revokedSTSTokenCleanupService; + } + + private void mockRatisSubmitAndCapture(MockedStatic ozoneManagerRatisUtilsMock, + AtomicReference capturedRequest) { + mockRatisSubmit(ozoneManagerRatisUtilsMock, capturedRequest::set); + } + + private void mockRatisSubmitAndCaptureRequests(MockedStatic ozoneManagerRatisUtilsMock, + List capturedRequests) { + mockRatisSubmit(ozoneManagerRatisUtilsMock, capturedRequests::add); + } + + private void mockRatisSubmitToFail(MockedStatic ozoneManagerRatisUtilsMock, + AtomicInteger submitAttempts) { + ozoneManagerRatisUtilsMock.when( + () -> OzoneManagerRatisUtils.submitRequest(any(), any(), any(), anyLong())) + .thenAnswer(invocation -> { + submitAttempts.incrementAndGet(); + throw new ServiceException("Simulated Ratis failure"); + }); + } + + private void mockRatisSubmitWithInternalErrorResponse(MockedStatic omRatisUtilsMock) { + omRatisUtilsMock.when( + () -> OzoneManagerRatisUtils.submitRequest(any(), any(), any(), anyLong())) + .thenReturn(OMResponse.newBuilder() + .setCmdType(Type.DeleteRevokedSTSTokens) + .setStatus(Status.INTERNAL_ERROR) + .setSuccess(false) + .build()); + } + + private static OMResponse buildOkResponse(OMRequest omRequest) { + return OMResponse.newBuilder() + .setCmdType(omRequest.getCmdType()) + .setStatus(OK) + .setSuccess(true) + .build(); + } + + private void mockRatisSubmit(MockedStatic ozoneManagerRatisUtilsMock, + Consumer requestConsumer) { + ozoneManagerRatisUtilsMock.when( + () -> OzoneManagerRatisUtils.submitRequest(any(), any(), any(), anyLong())) + .thenAnswer(invocation -> { + final OMRequest omRequest = invocation.getArgument(1); + requestConsumer.accept(omRequest); + return buildOkResponse(omRequest); + }); + } +} + + diff --git a/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/om/service/package-info.java b/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/om/service/package-info.java new file mode 100644 index 000000000000..71dfeed880d0 --- /dev/null +++ b/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/om/service/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. + */ + +/** + * Tests for OM services. + */ +package org.apache.hadoop.ozone.om.service; From 2cf4e918b8e9a6812641e09a6f9a9a3d27b27243 Mon Sep 17 00:00:00 2001 From: fmorg-git Date: Sun, 18 Jan 2026 23:32:26 -0800 Subject: [PATCH 19/54] HDDS-14364. [STS] Revoked permanent credential must render all associated STS tokens useless (#9602) --- .../hadoop/ozone/security/S3SecurityUtil.java | 26 +++ .../ozone/security/TestS3SecurityUtil.java | 154 ++++++++++++++---- 2 files changed, 148 insertions(+), 32 deletions(-) diff --git a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/security/S3SecurityUtil.java b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/security/S3SecurityUtil.java index 792b64e8697c..17b74bb74174 100644 --- a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/security/S3SecurityUtil.java +++ b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/security/S3SecurityUtil.java @@ -23,6 +23,7 @@ import static org.apache.hadoop.ozone.protocol.proto.OzoneManagerProtocolProtos.OMTokenProto.Type.S3AUTHINFO; import com.google.protobuf.ServiceException; +import java.io.IOException; import java.time.Clock; import java.time.ZoneOffset; import org.apache.hadoop.hdds.annotation.InterfaceAudience; @@ -78,6 +79,13 @@ public static void validateS3Credential(OMRequest omRequest, throw new OMException("STS token has been revoked", REVOKED_TOKEN); } + // Ensure the principal that created the STS token (originalAccessKeyId) has not been revoked + if (isOriginalAccessKeyIdRevoked(stsTokenIdentifier, ozoneManager)) { + LOG.info("OriginalAccessKeyId for session token has been revoked: {}, {}", + stsTokenIdentifier.getOriginalAccessKeyId(), stsTokenIdentifier.getTempAccessKeyId()); + throw new OMException("STS token no longer valid: OriginalAccessKeyId principal revoked", REVOKED_TOKEN); + } + // HMAC signature and expiration were validated above. Now validate AWS signature. validateSTSTokenAwsSignature(stsTokenIdentifier, omRequest); OzoneManager.setStsTokenIdentifier(stsTokenIdentifier); @@ -166,4 +174,22 @@ private static boolean isRevokedStsToken(String sessionToken, OzoneManager ozone throw new OMException(msg, e, INTERNAL_ERROR); } } + + /** + * Returns true if the originalAccessKeyId of the STS token has been revoked. + */ + private static boolean isOriginalAccessKeyIdRevoked(STSTokenIdentifier stsTokenIdentifier, OzoneManager ozoneManager) + throws OMException { + // We already know originalAccessKeyId is not null from STSSecurityUtil.ensureEssentialFieldsArePresentInToken() + // method called from STSSecurityUtil.constructValidateAndDecryptSTSToken() method above + final String originalAccessKeyId = stsTokenIdentifier.getOriginalAccessKeyId(); + try { + // If the secret for the original principal is missing, it means it was revoked + return !ozoneManager.getS3SecretManager().hasS3Secret(originalAccessKeyId); + } catch (IOException e) { + final String msg = "Could not determine if original principal is revoked: " + e.getMessage(); + LOG.warn(msg, e); + throw new OMException(msg, e, INTERNAL_ERROR); + } + } } diff --git a/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/security/TestS3SecurityUtil.java b/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/security/TestS3SecurityUtil.java index 20b3f3fb28b7..d642c6e5ace9 100644 --- a/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/security/TestS3SecurityUtil.java +++ b/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/security/TestS3SecurityUtil.java @@ -33,6 +33,7 @@ import static org.mockito.Mockito.spy; import static org.mockito.Mockito.when; +import java.io.IOException; import java.time.Clock; import java.util.UUID; import java.util.concurrent.ThreadLocalRandom; @@ -41,6 +42,7 @@ 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.S3SecretManager; 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; @@ -62,72 +64,107 @@ public class TestS3SecurityUtil { @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 - final OMMetadataManager metadataManager = mock(OMMetadataManager.class); - final Table revokedSTSTokenTable = new InMemoryTestTable<>(); + // If the revoked STS token table contains an entry for the session token, the request should be rejected with + // REVOKED_TOKEN validateS3CredentialHelper( - "session-token-a", metadataManager, revokedSTSTokenTable, true, createSTSTokenIdentifier(), - REVOKED_TOKEN, "STS token has been revoked"); + new TestConfig() + .setTokenRevoked(true) + .setExpectedResult(REVOKED_TOKEN) + .setExpectedMessage("STS token has been revoked")); } @Test public void testValidateS3CredentialWhenMetadataUnavailable() throws Exception { // If the metadata manager is not available, throws INTERNAL_ERROR validateS3CredentialHelper( - "session-token-b", null, null, false, createSTSTokenIdentifier(), - INTERNAL_ERROR, "Could not determine STS revocation: metadataManager is null"); + new TestConfig() + .setMetadataManager(null) + .setExpectedResult(INTERNAL_ERROR) + .setExpectedMessage("Could not determine STS revocation: metadataManager is null")); } @Test public void testValidateS3CredentialSuccessWhenNotRevoked() throws Exception { // Normal case: token is NOT revoked and request is accepted - final OMMetadataManager metadataManager = mock(OMMetadataManager.class); - final Table revokedSTSTokenTable = new InMemoryTestTable<>(); - validateS3CredentialHelper( - "session-token-c", metadataManager, revokedSTSTokenTable, false, createSTSTokenIdentifier(), - null, null); + validateS3CredentialHelper(new TestConfig()); } @Test public void testValidateS3CredentialWhenMetadataManagerAvailableButRevokedTableNull() throws Exception { // If the revoked STS token table is not available, throws INTERNAL_ERROR - final OMMetadataManager metadataManager = mock(OMMetadataManager.class); validateS3CredentialHelper( - "session-token-d", metadataManager, null, false, createSTSTokenIdentifier(), - INTERNAL_ERROR, "Could not determine STS revocation: revokedStsTokenTable is null"); + new TestConfig() + .setRevokedSTSTokenTable(null) + .setExpectedResult(INTERNAL_ERROR) + .setExpectedMessage("Could not determine STS revocation: revokedStsTokenTable is null")); } @Test public void testValidateS3CredentialWhenTableThrowsException() throws Exception { // If the revoked STS token table lookup throws, throws INTERNAL_ERROR (wrapped) - final OMMetadataManager metadataManager = mock(OMMetadataManager.class); final Table revokedSTSTokenTable = spy(new InMemoryTestTable<>()); doThrow(new RuntimeException("lookup failed")).when(revokedSTSTokenTable).getIfExist(anyString()); + validateS3CredentialHelper( - "session-token-g", metadataManager, revokedSTSTokenTable, false, createSTSTokenIdentifier(), - INTERNAL_ERROR, "Could not determine STS revocation because of Exception: lookup failed"); + new TestConfig() + .setRevokedSTSTokenTable(revokedSTSTokenTable) + .setExpectedResult(INTERNAL_ERROR) + .setExpectedMessage("Could not determine STS revocation because of Exception: lookup failed")); } - private void validateS3CredentialHelper(String sessionToken, OMMetadataManager metadataManager, - Table revokedSTSTokenTable, boolean isRevoked, STSTokenIdentifier stsTokenIdentifier, - OMException.ResultCodes expectedResult, String expectedMessageContents) throws Exception { + @Test + public void testValidateS3CredentialFailsWhenOriginalAccessKeyIdPrincipalRevoked() throws Exception { + // If the originalAccessKeyId principal is revoked, throws REVOKED_TOKEN + validateS3CredentialHelper( + new TestConfig() + .setOriginalAccessKeyIdRevoked(true) + .setExpectedResult(REVOKED_TOKEN) + .setExpectedMessage("STS token no longer valid: OriginalAccessKeyId principal revoked")); + } + + @Test + public void testValidateS3CredentialFailsWhenOriginalAccessKeyIdCheckThrows() throws Exception { + // If checking originalAccessKeyId principal revocation fails, throws INTERNAL_ERROR + validateS3CredentialHelper( + new TestConfig() + .setShouldOriginalAccessKeyIdCheckThrowError(true) + .setExpectedResult(INTERNAL_ERROR) + .setExpectedMessage("Could not determine if original principal is revoked")); + } + private void validateS3CredentialHelper(TestConfig config) throws Exception { try (OzoneManager ozoneManager = mock(OzoneManager.class)) { when(ozoneManager.isSecurityEnabled()).thenReturn(true); when(ozoneManager.getSecretKeyClient()).thenReturn(mock(SecretKeyClient.class)); + final OMMetadataManager metadataManager = config.metadataManager; when(ozoneManager.getMetadataManager()).thenReturn(metadataManager); if (metadataManager != null) { - when(metadataManager.getS3RevokedStsTokenTable()).thenReturn(revokedSTSTokenTable); + when(metadataManager.getS3RevokedStsTokenTable()).thenReturn(config.revokedSTSTokenTable); + } + + // Mock S3SecretManager to handle originalAccessKeyId checks + final S3SecretManager s3SecretManager = mock(S3SecretManager.class); + when(ozoneManager.getS3SecretManager()).thenReturn(s3SecretManager); + if (config.shouldOriginalAccessKeyIdCheckThrowError) { + when(s3SecretManager.hasS3Secret(anyString())).thenThrow( + new IOException("An error occurred while checking if s3Secret exists")); + } else if (config.isOriginalAccessKeyIdRevoked) { + // Returning false means secret does NOT exist -> principal is revoked + when(s3SecretManager.hasS3Secret(anyString())).thenReturn(false); + } else { + // Returning true means secret exists -> principal is valid + when(s3SecretManager.hasS3Secret(anyString())).thenReturn(true); } - final String tempAccessKeyId = "temp-access-key-id"; - if (isRevoked) { + final String sessionToken = "session-token"; + if (config.isTokenRevoked && config.revokedSTSTokenTable != null) { final long insertionTimeMillis = CLOCK.millis(); - revokedSTSTokenTable.put(sessionToken, insertionTimeMillis); + config.revokedSTSTokenTable.put(sessionToken, insertionTimeMillis); } + final STSTokenIdentifier stsTokenIdentifier = createSTSTokenIdentifier(); + try (MockedStatic stsSecurityUtilMock = mockStatic(STSSecurityUtil.class, CALLS_REAL_METHODS); MockedStatic awsV4AuthValidatorMock = mockStatic( AWSV4AuthValidator.class, CALLS_REAL_METHODS)) { @@ -143,15 +180,15 @@ private void validateS3CredentialHelper(String sessionToken, OMMetadataManager m final OMRequest omRequest = createRequestWithSessionToken(sessionToken); - if (expectedResult != null) { + if (config.expectedResult != null) { final OMException omException = assertThrows( OMException.class, () -> S3SecurityUtil.validateS3Credential(omRequest, ozoneManager)); - assertEquals(expectedResult, omException.getResult()); - if (expectedMessageContents != null) { + assertEquals(config.expectedResult, omException.getResult()); + if (config.expectedMessage != null) { assertTrue( - omException.getMessage().contains(expectedMessageContents), - "Expected exception message to contain: '" + expectedMessageContents + "' but was: '" + - omException.getMessage() + "'"); + omException.getMessage().contains(config.expectedMessage), + "Expected exception message to contain: '" + config.expectedMessage + "' but was: '" + + omException.getMessage() + "'"); } } else { assertDoesNotThrow(() -> S3SecurityUtil.validateS3Credential(omRequest, ozoneManager)); @@ -167,6 +204,7 @@ private STSTokenIdentifier createSTSTokenIdentifier() { ENCRYPTION_KEY); } + @SuppressWarnings("SameParameterValue") private static OMRequest createRequestWithSessionToken(String sessionToken) { final S3Authentication s3Authentication = S3Authentication.newBuilder() .setAccessId("accessKeyId") @@ -181,4 +219,56 @@ private static OMRequest createRequestWithSessionToken(String sessionToken) { .setS3Authentication(s3Authentication) .build(); } + + /** + * Helper class to create various scenarios for testing. + */ + private static class TestConfig { + private OMMetadataManager metadataManager = mock(OMMetadataManager.class); + private Table revokedSTSTokenTable = new InMemoryTestTable<>(); + private boolean isTokenRevoked = false; + private boolean isOriginalAccessKeyIdRevoked = false; + private boolean shouldOriginalAccessKeyIdCheckThrowError = false; + private OMException.ResultCodes expectedResult = null; + private String expectedMessage = null; + + @SuppressWarnings("SameParameterValue") + TestConfig setMetadataManager(OMMetadataManager metadataManager) { + this.metadataManager = metadataManager; + return this; + } + + TestConfig setRevokedSTSTokenTable(Table table) { + this.revokedSTSTokenTable = table; + return this; + } + + @SuppressWarnings("SameParameterValue") + TestConfig setTokenRevoked(boolean isRevoked) { + this.isTokenRevoked = isRevoked; + return this; + } + + @SuppressWarnings("SameParameterValue") + TestConfig setOriginalAccessKeyIdRevoked(boolean isRevoked) { + this.isOriginalAccessKeyIdRevoked = isRevoked; + return this; + } + + @SuppressWarnings("SameParameterValue") + TestConfig setShouldOriginalAccessKeyIdCheckThrowError(boolean isError) { + this.shouldOriginalAccessKeyIdCheckThrowError = isError; + return this; + } + + TestConfig setExpectedResult(OMException.ResultCodes result) { + this.expectedResult = result; + return this; + } + + TestConfig setExpectedMessage(String message) { + this.expectedMessage = message; + return this; + } + } } From bf1453d2ec48525556258e2781713a6691eda357 Mon Sep 17 00:00:00 2001 From: len548 <63490262+len548@users.noreply.github.com> Date: Mon, 19 Jan 2026 16:02:41 +0100 Subject: [PATCH 20/54] HDDS-13345. STS port and endpoint skeleton (#9343) --- .../src/main/resources/ozone-default.xml | 48 +++ .../main/compose/ozonesecure/docker-config | 1 + .../ozone/TestOzoneConfigurationFields.java | 2 + .../org/apache/hadoop/ozone/s3/Gateway.java | 4 + .../hadoop/ozone/s3/S3STSHttpServer.java | 105 ++++++ .../ozone/s3/exception/S3ErrorTable.java | 4 + .../s3/signature/AWSSignatureProcessor.java | 75 ++++- .../AuthorizationV4HeaderParser.java | 1 + .../signature/AuthorizationV4QueryParser.java | 2 + .../ozone/s3/signature/SignatureInfo.java | 38 ++- .../s3/signature/SignatureProcessor.java | 4 +- .../s3/signature/StringToSignProducer.java | 50 +-- .../hadoop/ozone/s3sts/Application.java | 30 ++ .../hadoop/ozone/s3sts/S3STSConfigKeys.java | 51 +++ .../hadoop/ozone/s3sts/S3STSEnabled.java | 35 ++ .../S3STSEnabledEndpointRequestFilter.java | 53 ++++ .../hadoop/ozone/s3sts/S3STSEndpoint.java | 299 ++++++++++++++++++ .../hadoop/ozone/s3sts/S3STSEndpointBase.java | 109 +++++++ .../hadoop/ozone/s3sts/package-info.java | 21 ++ .../resources/webapps/s3g-sts/WEB-INF/web.xml | 33 ++ .../ozone/s3/TestAuthorizationFilter.java | 26 ++ .../signature/TestStringToSignProducer.java | 1 + .../apache/hadoop/ozone/s3sts/TestSTS.java | 110 +++++++ 23 files changed, 1056 insertions(+), 46 deletions(-) create mode 100644 hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/S3STSHttpServer.java create mode 100644 hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3sts/Application.java create mode 100644 hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3sts/S3STSConfigKeys.java create mode 100644 hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3sts/S3STSEnabled.java create mode 100644 hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3sts/S3STSEnabledEndpointRequestFilter.java create mode 100644 hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3sts/S3STSEndpoint.java create mode 100644 hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3sts/S3STSEndpointBase.java create mode 100644 hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3sts/package-info.java create mode 100644 hadoop-ozone/s3gateway/src/main/resources/webapps/s3g-sts/WEB-INF/web.xml create mode 100644 hadoop-ozone/s3gateway/src/test/java/org/apache/hadoop/ozone/s3sts/TestSTS.java diff --git a/hadoop-hdds/common/src/main/resources/ozone-default.xml b/hadoop-hdds/common/src/main/resources/ozone-default.xml index f3ea84abb4ab..630af4d397b0 100644 --- a/hadoop-hdds/common/src/main/resources/ozone-default.xml +++ b/hadoop-hdds/common/src/main/resources/ozone-default.xml @@ -2022,6 +2022,54 @@ will be used for http authentication. + + + ozone.s3g.sts.http.enabled + false + OZONE, S3GATEWAY + + The boolean which enables the Ozone S3Gateway STS endpoint. + + + + ozone.s3g.sts.http-bind-host + 0.0.0.0 + OZONE, S3GATEWAY + + The bind host for the S3 Gateway STS HTTP server. + If this optional address is set, it overrides only the hostname portion of + ozone.s3g.sts.http-address. + If not set, the value of ozone.s3g.http-bind-host is used. + + + + ozone.s3g.sts.http-address + 0.0.0.0:9880 + OZONE, S3GATEWAY + + The HTTP address for the S3 Gateway STS endpoint. + + + + ozone.s3g.sts.https-bind-host + 0.0.0.0 + OZONE, S3GATEWAY + + The bind host for the S3 Gateway STS HTTPS server. + If this optional address is set, it overrides only the hostname portion of + ozone.s3g.sts.http-address. + If not set, the value of ozone.s3g.https-bind-host is used. + + + + ozone.s3g.sts.https-address + 0.0.0.0:9881 + OZONE, S3GATEWAY + + The HTTPS address for the S3 Gateway STS endpoint. + + + ozone.s3g.metrics.percentiles.intervals.seconds 60 diff --git a/hadoop-ozone/dist/src/main/compose/ozonesecure/docker-config b/hadoop-ozone/dist/src/main/compose/ozonesecure/docker-config index 5daf6c11fc9b..3d5ebba92237 100644 --- a/hadoop-ozone/dist/src/main/compose/ozonesecure/docker-config +++ b/hadoop-ozone/dist/src/main/compose/ozonesecure/docker-config @@ -93,6 +93,7 @@ OZONE-SITE.XML_hdds.datanode.kerberos.keytab.file=/etc/security/keytabs/dn.keyta OZONE-SITE.XML_ozone.security.http.kerberos.enabled=true OZONE-SITE.XML_ozone.s3g.secret.http.enabled=true +OZONE-SITE.XML_ozone.s3g.sts.http.enabled=true OZONE-SITE.XML_ozone.http.filter.initializers=org.apache.hadoop.security.AuthenticationFilterInitializer OZONE-SITE.XML_ozone.om.http.auth.type=kerberos diff --git a/hadoop-ozone/integration-test/src/test/java/org/apache/hadoop/ozone/TestOzoneConfigurationFields.java b/hadoop-ozone/integration-test/src/test/java/org/apache/hadoop/ozone/TestOzoneConfigurationFields.java index 98ccd8fac8be..edb1484fd0f9 100644 --- a/hadoop-ozone/integration-test/src/test/java/org/apache/hadoop/ozone/TestOzoneConfigurationFields.java +++ b/hadoop-ozone/integration-test/src/test/java/org/apache/hadoop/ozone/TestOzoneConfigurationFields.java @@ -30,6 +30,7 @@ import org.apache.hadoop.ozone.recon.ReconServerConfigKeys; import org.apache.hadoop.ozone.s3.S3GatewayConfigKeys; import org.apache.hadoop.ozone.s3secret.S3SecretConfigKeys; +import org.apache.hadoop.ozone.s3sts.S3STSConfigKeys; /** * Tests if configuration constants documented in ozone-defaults.xml. @@ -45,6 +46,7 @@ public void initializeMemberVariables() { ReconConfigKeys.class, ReconServerConfigKeys.class, S3GatewayConfigKeys.class, S3SecretConfigKeys.class, + S3STSConfigKeys.class, SCMHTTPServerConfig.class, SCMHTTPServerConfig.ConfigStrings.class, ScmConfig.ConfigStrings.class diff --git a/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/Gateway.java b/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/Gateway.java index 9126d8ed4a2b..1f28dbf39bda 100644 --- a/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/Gateway.java +++ b/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/Gateway.java @@ -60,6 +60,7 @@ public class Gateway extends GenericCli implements Callable { private S3GatewayHttpServer httpServer; /** Servlets and static content on separate port. */ private BaseHttpServer contentServer; + private BaseHttpServer stsServer; private S3GatewayMetrics metrics; private final JvmPauseMonitor jvmPauseMonitor = newJvmPauseMonitor("S3G"); @@ -80,6 +81,7 @@ public Void call() throws Exception { setHttpBaseDir(OzoneConfigurationHolder.configuration()); httpServer = new S3GatewayHttpServer(OzoneConfigurationHolder.configuration(), "s3gateway"); contentServer = new S3GatewayWebAdminServer(OzoneConfigurationHolder.configuration(), "s3g-web"); + stsServer = new S3STSHttpServer(OzoneConfigurationHolder.configuration(), "s3g-sts"); metrics = S3GatewayMetrics.create(OzoneConfigurationHolder.configuration()); start(); @@ -104,12 +106,14 @@ public void start() throws IOException { jvmPauseMonitor.start(); httpServer.start(); contentServer.start(); + stsServer.start(); } public void stop() throws Exception { LOG.info("Stopping Ozone S3 gateway"); httpServer.stop(); contentServer.stop(); + stsServer.stop(); jvmPauseMonitor.stop(); S3GatewayMetrics.unRegister(); } diff --git a/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/S3STSHttpServer.java b/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/S3STSHttpServer.java new file mode 100644 index 000000000000..f100e408942c --- /dev/null +++ b/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/S3STSHttpServer.java @@ -0,0 +1,105 @@ +/* + * 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.s3; + +import static org.apache.hadoop.ozone.s3.S3GatewayConfigKeys.OZONE_S3G_HTTP_AUTH_CONFIG_PREFIX; +import static org.apache.hadoop.ozone.s3.S3GatewayConfigKeys.OZONE_S3G_HTTP_AUTH_TYPE; +import static org.apache.hadoop.ozone.s3.S3GatewayConfigKeys.OZONE_S3G_HTTP_BIND_HOST_DEFAULT; +import static org.apache.hadoop.ozone.s3.S3GatewayConfigKeys.OZONE_S3G_KEYTAB_FILE; +import static org.apache.hadoop.ozone.s3.S3GatewayConfigKeys.OZONE_S3G_WEB_AUTHENTICATION_KERBEROS_PRINCIPAL; +import static org.apache.hadoop.ozone.s3sts.S3STSConfigKeys.OZONE_S3G_STS_HTTPS_ADDRESS_KEY; +import static org.apache.hadoop.ozone.s3sts.S3STSConfigKeys.OZONE_S3G_STS_HTTPS_BIND_HOST_KEY; +import static org.apache.hadoop.ozone.s3sts.S3STSConfigKeys.OZONE_S3G_STS_HTTPS_BIND_PORT_DEFAULT; +import static org.apache.hadoop.ozone.s3sts.S3STSConfigKeys.OZONE_S3G_STS_HTTP_ADDRESS_KEY; +import static org.apache.hadoop.ozone.s3sts.S3STSConfigKeys.OZONE_S3G_STS_HTTP_BIND_HOST_KEY; +import static org.apache.hadoop.ozone.s3sts.S3STSConfigKeys.OZONE_S3G_STS_HTTP_BIND_PORT_DEFAULT; +import static org.apache.hadoop.ozone.s3sts.S3STSConfigKeys.OZONE_S3G_STS_HTTP_ENABLED_KEY; + +import java.io.IOException; +import org.apache.hadoop.hdds.conf.MutableConfigurationSource; +import org.apache.hadoop.hdds.server.http.BaseHttpServer; + +/** + * HTTP server for the S3 Gateway STS endpoint. + */ +public class S3STSHttpServer extends BaseHttpServer { + + S3STSHttpServer(MutableConfigurationSource conf, String name) throws IOException { + super(conf, name); + } + + @Override + protected String getHttpAddressKey() { + return OZONE_S3G_STS_HTTP_ADDRESS_KEY; + } + + @Override + protected String getHttpBindHostKey() { + return OZONE_S3G_STS_HTTP_BIND_HOST_KEY; + } + + @Override + protected String getHttpsAddressKey() { + return OZONE_S3G_STS_HTTPS_ADDRESS_KEY; + } + + @Override + protected String getHttpsBindHostKey() { + return OZONE_S3G_STS_HTTPS_BIND_HOST_KEY; + } + + @Override + protected String getBindHostDefault() { + return OZONE_S3G_HTTP_BIND_HOST_DEFAULT; + } + + @Override + protected int getHttpBindPortDefault() { + return OZONE_S3G_STS_HTTP_BIND_PORT_DEFAULT; + } + + @Override + protected int getHttpsBindPortDefault() { + return OZONE_S3G_STS_HTTPS_BIND_PORT_DEFAULT; + } + + @Override + protected String getKeytabFile() { + return OZONE_S3G_KEYTAB_FILE; + } + + @Override + protected String getSpnegoPrincipal() { + return OZONE_S3G_WEB_AUTHENTICATION_KERBEROS_PRINCIPAL; + } + + @Override + protected String getEnabledKey() { + return OZONE_S3G_STS_HTTP_ENABLED_KEY; + } + + @Override + protected String getHttpAuthType() { + return OZONE_S3G_HTTP_AUTH_TYPE; + } + + @Override + protected String getHttpAuthConfigPrefix() { + return OZONE_S3G_HTTP_AUTH_CONFIG_PREFIX; + } +} diff --git a/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/exception/S3ErrorTable.java b/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/exception/S3ErrorTable.java index 060ed83d1bcc..3c9ecdb768ab 100644 --- a/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/exception/S3ErrorTable.java +++ b/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/exception/S3ErrorTable.java @@ -160,6 +160,10 @@ public final class S3ErrorTable { "Access Denied", "User doesn't have permission to access this resource due to a " + "bucket ownership mismatch.", HTTP_FORBIDDEN); + public static final OS3Exception PAYLOAD_TOO_LARGE = new OS3Exception( + "PayloadTooLarge", "Your request body size was too large.", HTTP_BAD_REQUEST + ); + private static Function generateInternalError = e -> new OS3Exception("InternalError", e.getMessage(), HTTP_INTERNAL_ERROR); 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 7d8f2fe04e98..9abf2fc227db 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 @@ -18,8 +18,19 @@ package org.apache.hadoop.ozone.s3.signature; import static org.apache.hadoop.ozone.s3.exception.S3ErrorTable.MALFORMED_HEADER; +import static org.apache.hadoop.ozone.s3.exception.S3ErrorTable.PAYLOAD_TOO_LARGE; +import static org.apache.hadoop.ozone.s3.exception.S3ErrorTable.S3_AUTHINFO_CREATION_ERROR; +import static org.apache.hadoop.ozone.s3.util.S3Consts.UNSIGNED_PAYLOAD; +import static org.apache.hadoop.ozone.s3.util.S3Consts.X_AMZ_CONTENT_SHA256; +import static org.apache.hadoop.ozone.s3sts.S3STSConfigKeys.OZONE_S3G_STS_PAYLOAD_HASH_MAX_VALUE; import com.google.common.annotations.VisibleForTesting; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; import java.util.ArrayList; import java.util.Collection; import java.util.HashMap; @@ -39,6 +50,7 @@ import org.apache.hadoop.ozone.s3.exception.S3ErrorTable; import org.apache.hadoop.ozone.s3.signature.SignatureInfo.Version; import org.apache.hadoop.ozone.s3.util.AuditUtils; +import org.apache.kerby.util.Hex; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -60,7 +72,7 @@ public class AWSSignatureProcessor implements SignatureProcessor { private ContainerRequestContext context; @Override - public SignatureInfo parseSignature() throws OS3Exception { + public SignatureInfo parseSignature() throws OS3Exception, IOException, NoSuchAlgorithmException { LowerCaseKeyStringMap headers = LowerCaseKeyStringMap.fromHeaderMap(context.getHeaders()); @@ -89,13 +101,57 @@ public SignatureInfo parseSignature() throws OS3Exception { } } if (signatureInfo == null) { - signatureInfo = new SignatureInfo.Builder(Version.NONE).build(); + signatureInfo = new SignatureInfo.Builder(Version.NONE).setService("s3").build(); } + String payloadHash = getPayloadHash(headers, signatureInfo); + signatureInfo.setPayloadHash(payloadHash); signatureInfo.setUnfilteredURI( context.getUriInfo().getRequestUri().getPath()); return signatureInfo; } + private String getPayloadHash(Map headers, SignatureInfo signatureInfo) + throws OS3Exception, NoSuchAlgorithmException, IOException { + if (signatureInfo.getVersion() == Version.V2) { + throw S3_AUTHINFO_CREATION_ERROR; + } + if (signatureInfo.getService().equals("s3")) { + if (!signatureInfo.isSignPayload()) { + // According to AWS Signature V4 documentation using Query Parameters + // https://docs.aws.amazon.com/AmazonS3/latest/API/sigv4-query-string-auth.html + return UNSIGNED_PAYLOAD; + } + String contentSignatureHeaderValue = headers.get(X_AMZ_CONTENT_SHA256); + // According to AWS Signature V4 documentation using Authorization Header + // https://docs.aws.amazon.com/AmazonS3/latest/API/sig-v4-header-based-auth.html + // The x-amz-content-sha256 header is required + // for all AWS Signature Version 4 requests using Authorization header. + if (contentSignatureHeaderValue == null) { + LOG.error("The request must include " + X_AMZ_CONTENT_SHA256 + + " header for signed payload"); + throw S3_AUTHINFO_CREATION_ERROR; + } + // Simply return the header value of x-amz-content-sha256 as the payload hash + // These are the possible cases: + // 1. Actual payload checksum for single chunk upload + // 2. Unsigned payloads for multiple chunks upload + // - UNSIGNED-PAYLOAD + // - STREAMING-UNSIGNED-PAYLOAD-TRAILER + // 3. Signed payloads for multiple chunks upload + // - STREAMING-AWS4-HMAC-SHA256-PAYLOAD + // - STREAMING-AWS4-HMAC-SHA256-PAYLOAD-TRAILER + // - STREAMING-AWS4-ECDSA-P256-SHA256-PAYLOAD + // - STREAMING-AWS4-ECDSA-P256-SHA256-PAYLOAD-TRAILER + return contentSignatureHeaderValue; + } + // For STS payload hash is calculated over the body + InputStream in = context.getEntityStream(); + byte[] body = readAllBytes(in); + String payloadHash = Hex.encode(MessageDigest.getInstance("SHA-256").digest(body)); + context.setEntityStream(new ByteArrayInputStream(body)); + return payloadHash; + } + private AuditMessage buildAuthFailureMessage(MalformedResourceException e) { AuditMessage message = new AuditMessage.Builder() .forOperation(AuthOperation.fromContext(context)) @@ -107,6 +163,21 @@ private AuditMessage buildAuthFailureMessage(MalformedResourceException e) { return message; } + private byte[] readAllBytes(InputStream in) throws OS3Exception, IOException { + ByteArrayOutputStream buffer = new ByteArrayOutputStream(); + byte[] chunk = new byte[8192]; + int totalRead = 0; + int n; + while ((n = in.read(chunk)) != -1) { + if (totalRead + n > OZONE_S3G_STS_PAYLOAD_HASH_MAX_VALUE) { + throw PAYLOAD_TOO_LARGE; + } + buffer.write(chunk, 0, n); + totalRead += n; + } + return buffer.toByteArray(); + } + @VisibleForTesting public void setContext(ContainerRequestContext context) { this.context = context; diff --git a/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/signature/AuthorizationV4HeaderParser.java b/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/signature/AuthorizationV4HeaderParser.java index b327280d070c..ed4ef4b13dd8 100644 --- a/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/signature/AuthorizationV4HeaderParser.java +++ b/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/signature/AuthorizationV4HeaderParser.java @@ -90,6 +90,7 @@ public SignatureInfo parseSignature() throws MalformedResourceException { .setCredentialScope(credentialObj.createScope()) .setAlgorithm(algorithm) .setSignPayload(true) + .setService(credentialObj.getAwsService()) .build(); } diff --git a/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/signature/AuthorizationV4QueryParser.java b/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/signature/AuthorizationV4QueryParser.java index a70311271817..04d0d8cc5258 100644 --- a/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/signature/AuthorizationV4QueryParser.java +++ b/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/signature/AuthorizationV4QueryParser.java @@ -91,7 +91,9 @@ public SignatureInfo parseSignature() throws MalformedResourceException { .setSignedHeaders(queryParameters.get("X-Amz-SignedHeaders")) .setCredentialScope(credential.createScope()) .setAlgorithm(queryParameters.get("X-Amz-Algorithm")) + .setService(credential.getAwsService()) .setSignPayload(false) + .setPayloadHash("UNSIGNED-PAYLOAD") .build(); } 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 730481bce214..ffe8a8dddd60 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 @@ -55,6 +55,10 @@ public class SignatureInfo { private String stringToSign = null; + private String payloadHash = null; + + private String service = null; + public SignatureInfo() { } private SignatureInfo(Builder b) { @@ -72,7 +76,9 @@ public void initialize(SignatureInfo signatureInfo) { .setAlgorithm(signatureInfo.getAlgorithm()) .setSignPayload(signatureInfo.isSignPayload()) .setUnfilteredURI(signatureInfo.getUnfilteredURI()) - .setStringToSign(signatureInfo.getStringToSign())); + .setStringToSign(signatureInfo.getStringToSign()) + .setPayloadHash(signatureInfo.getPayloadHash()) + .setService(signatureInfo.getService())); } private void initialize(Builder b) { @@ -87,6 +93,8 @@ private void initialize(Builder b) { this.signPayload = b.signPayload; this.unfilteredURI = b.unfilteredURI; this.stringToSign = b.stringToSign; + this.payloadHash = b.payloadHash; + this.service = b.service; } public String getAwsAccessId() { @@ -141,6 +149,22 @@ public void setStrToSign(String strToSign) { this.stringToSign = strToSign; } + public String getPayloadHash() { + return this.payloadHash; + } + + public void setPayloadHash(String payloadHash) { + this.payloadHash = payloadHash; + } + + public String getService() { + return service; + } + + public void setService(String service) { + this.service = service; + } + /** * Signature version. */ @@ -163,6 +187,8 @@ public static class Builder { private boolean signPayload = true; private String unfilteredURI = null; private String stringToSign = null; + private String payloadHash = null; + private String service = null; public Builder(Version version) { this.version = version; @@ -218,6 +244,16 @@ public Builder setStringToSign(String stringToSign) { return this; } + public Builder setPayloadHash(String payloadHash) { + this.payloadHash = payloadHash; + return this; + } + + public Builder setService(String service) { + this.service = service; + return this; + } + public SignatureInfo build() { return new SignatureInfo(this); } diff --git a/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/signature/SignatureProcessor.java b/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/signature/SignatureProcessor.java index 32213e41dafb..36788b4e4ea4 100644 --- a/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/signature/SignatureProcessor.java +++ b/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/signature/SignatureProcessor.java @@ -17,6 +17,8 @@ package org.apache.hadoop.ozone.s3.signature; +import java.io.IOException; +import java.security.NoSuchAlgorithmException; import java.time.format.DateTimeFormatter; import org.apache.hadoop.ozone.s3.exception.OS3Exception; @@ -39,5 +41,5 @@ public interface SignatureProcessor { /** * API to return string to sign. */ - SignatureInfo parseSignature() throws OS3Exception; + SignatureInfo parseSignature() throws OS3Exception, IOException, NoSuchAlgorithmException; } diff --git a/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/signature/StringToSignProducer.java b/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/signature/StringToSignProducer.java index e2f8d64a4d18..d9500c196326 100644 --- a/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/signature/StringToSignProducer.java +++ b/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/signature/StringToSignProducer.java @@ -19,7 +19,6 @@ import static java.time.temporal.ChronoUnit.SECONDS; import static org.apache.hadoop.ozone.s3.exception.S3ErrorTable.S3_AUTHINFO_CREATION_ERROR; -import static org.apache.hadoop.ozone.s3.util.S3Consts.UNSIGNED_PAYLOAD; import static org.apache.hadoop.ozone.s3.util.S3Consts.X_AMZ_CONTENT_SHA256; import com.google.common.annotations.VisibleForTesting; @@ -118,7 +117,6 @@ public static String createSignatureBase( } strToSign.append(signatureInfo.getDateTime()).append(NEWLINE); strToSign.append(credentialScope).append(NEWLINE); - String canonicalRequest = buildCanonicalRequest( scheme, method, @@ -126,7 +124,7 @@ public static String createSignatureBase( signatureInfo.getSignedHeaders(), headers, queryParams, - !signatureInfo.isSignPayload()); + signatureInfo.getPayloadHash()); strToSign.append(hash(canonicalRequest)); if (LOG.isDebugEnabled()) { LOG.debug("canonicalRequest:[{}]", canonicalRequest); @@ -160,7 +158,7 @@ public static String buildCanonicalRequest( String signedHeaders, Map headers, Map queryParams, - boolean unsignedPayload + String payloadHash ) throws OS3Exception { Iterable parts = split("/", uri); @@ -179,6 +177,9 @@ public static String buildCanonicalRequest( canonicalHeaders.append(':'); if (headers.containsKey(header)) { String headerValue = headers.get(header); + if (header.equals("content-type")) { + headerValue = headerValue.toLowerCase(); + } canonicalHeaders.append(headerValue); canonicalHeaders.append(NEWLINE); @@ -197,10 +198,7 @@ public static String buildCanonicalRequest( } } - validateCanonicalHeaders(canonicalHeaders.toString(), headers, - unsignedPayload); - - String payloadHash = getPayloadHash(headers, unsignedPayload); + validateCanonicalHeaders(canonicalHeaders.toString(), headers); return method + NEWLINE + canonicalUri + NEWLINE @@ -210,37 +208,6 @@ public static String buildCanonicalRequest( + payloadHash; } - private static String getPayloadHash(Map headers, boolean isUsingQueryParameter) - throws OS3Exception { - if (isUsingQueryParameter) { - // According to AWS Signature V4 documentation using Query Parameters - // https://docs.aws.amazon.com/AmazonS3/latest/API/sigv4-query-string-auth.html - return UNSIGNED_PAYLOAD; - } - String contentSignatureHeaderValue = headers.get(X_AMZ_CONTENT_SHA256); - // According to AWS Signature V4 documentation using Authorization Header - // https://docs.aws.amazon.com/AmazonS3/latest/API/sig-v4-header-based-auth.html - // The x-amz-content-sha256 header is required - // for all AWS Signature Version 4 requests using Authorization header. - if (contentSignatureHeaderValue == null) { - LOG.error("The request must include " + X_AMZ_CONTENT_SHA256 - + " header for signed payload"); - throw S3_AUTHINFO_CREATION_ERROR; - } - // Simply return the header value of x-amz-content-sha256 as the payload hash - // These are the possible cases: - // 1. Actual payload checksum for single chunk upload - // 2. Unsigned payloads for multiple chunks upload - // - UNSIGNED-PAYLOAD - // - STREAMING-UNSIGNED-PAYLOAD-TRAILER - // 3. Signed payloads for multiple chunks upload - // - STREAMING-AWS4-HMAC-SHA256-PAYLOAD - // - STREAMING-AWS4-HMAC-SHA256-PAYLOAD-TRAILER - // - STREAMING-AWS4-ECDSA-P256-SHA256-PAYLOAD - // - STREAMING-AWS4-ECDSA-P256-SHA256-PAYLOAD-TRAILER - return contentSignatureHeaderValue; - } - /** * String join that also works with empty strings. * @@ -357,9 +324,8 @@ static void validateSignedHeader( */ private static void validateCanonicalHeaders( String canonicalHeaders, - Map headers, - Boolean unsignedPaylod - ) throws OS3Exception { + Map headers) + throws OS3Exception { if (!canonicalHeaders.contains(HOST + ":")) { LOG.error("The SignedHeaders list must include HTTP Host header"); throw S3_AUTHINFO_CREATION_ERROR; diff --git a/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3sts/Application.java b/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3sts/Application.java new file mode 100644 index 000000000000..65081d5d47fe --- /dev/null +++ b/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3sts/Application.java @@ -0,0 +1,30 @@ +/* + * 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 org.glassfish.jersey.server.ResourceConfig; + +/** + * JAX-RS application for the STS endpoint. + */ +public class Application extends ResourceConfig { + public Application() { + packages("org.apache.hadoop.ozone.s3sts"); + register(org.apache.hadoop.ozone.s3.AuthorizationFilter.class); + } +} 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 new file mode 100644 index 000000000000..1512d3fc3c4b --- /dev/null +++ b/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3sts/S3STSConfigKeys.java @@ -0,0 +1,51 @@ +/* + * 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; + +/** + * 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"; + 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 = + "ozone.s3g.sts.https-bind-host"; + public static final String OZONE_S3G_STS_HTTP_ADDRESS_KEY = + "ozone.s3g.sts.http-address"; + public static final String OZONE_S3G_STS_HTTPS_ADDRESS_KEY = + "ozone.s3g.sts.https-address"; + public static final int OZONE_S3G_STS_HTTP_BIND_PORT_DEFAULT = 9880; + public static final int OZONE_S3G_STS_HTTPS_BIND_PORT_DEFAULT = 9881; + // Max payload default size for STS AssumeRole API calls (32 KB) + // as STS AssumeRole has these parameters required in payload: + // 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. + public static final int OZONE_S3G_STS_PAYLOAD_HASH_MAX_VALUE = 32768; + + /** + * Never constructed. + */ + private S3STSConfigKeys() { + + } +} diff --git a/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3sts/S3STSEnabled.java b/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3sts/S3STSEnabled.java new file mode 100644 index 000000000000..84712a5fd1f9 --- /dev/null +++ b/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3sts/S3STSEnabled.java @@ -0,0 +1,35 @@ +/* + * 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 java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; +import javax.ws.rs.NameBinding; + +/** + * Annotation to disable S3 STS Endpoint. + */ +@NameBinding +@Retention(RetentionPolicy.RUNTIME) +@Target({ElementType.TYPE, ElementType.METHOD}) +public @interface S3STSEnabled { +} + + diff --git a/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3sts/S3STSEnabledEndpointRequestFilter.java b/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3sts/S3STSEnabledEndpointRequestFilter.java new file mode 100644 index 000000000000..50157ea75b0f --- /dev/null +++ b/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3sts/S3STSEnabledEndpointRequestFilter.java @@ -0,0 +1,53 @@ +/* + * 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 static org.apache.hadoop.ozone.s3sts.S3STSConfigKeys.OZONE_S3G_STS_HTTP_ENABLED_KEY; + +import java.io.IOException; +import javax.inject.Inject; +import javax.ws.rs.container.ContainerRequestContext; +import javax.ws.rs.container.ContainerRequestFilter; +import javax.ws.rs.core.MediaType; +import javax.ws.rs.core.Response; +import javax.ws.rs.ext.Provider; +import org.apache.hadoop.hdds.conf.OzoneConfiguration; + +/** + * Filter that disables all endpoints annotated with {@link S3STSEnabled}. + * Condition is based on the value of the configuration key + * ozone.s3g.s3sts.http.enabled. + */ +@S3STSEnabled +@Provider +public class S3STSEnabledEndpointRequestFilter implements ContainerRequestFilter { + @Inject + private OzoneConfiguration ozoneConfiguration; + + @Override + public void filter(ContainerRequestContext requestContext) throws IOException { + boolean isSTSEnabled = ozoneConfiguration.getBoolean( + OZONE_S3G_STS_HTTP_ENABLED_KEY, false); + if (!isSTSEnabled) { + requestContext.abortWith(Response.status(Response.Status.NOT_IMPLEMENTED) + .entity("STS endpoint is disabled.") + .type(MediaType.APPLICATION_XML_TYPE) + .build()); + } + } +} 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 new file mode 100644 index 000000000000..124581c6f260 --- /dev/null +++ b/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3sts/S3STSEndpoint.java @@ -0,0 +1,299 @@ +/* + * 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 java.io.IOException; +import java.time.Instant; +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; +import javax.ws.rs.POST; +import javax.ws.rs.Path; +import javax.ws.rs.Produces; +import javax.ws.rs.QueryParam; +import javax.ws.rs.core.MediaType; +import javax.ws.rs.core.Response; +import org.apache.hadoop.ozone.s3.exception.OS3Exception; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * AWS STS (Security Token Service) compatible endpoint for Ozone S3 Gateway. + *

+ * This endpoint provides temporary security credentials compatible with + * AWS STS API, exposed on the port 9880 or 9881. + *

+ * Currently supports only AssumeRole operation. Other STS operations will + * return appropriate error responses. + * + * @see AWS STS API Reference + */ +@Path("/") +@S3STSEnabled +public class S3STSEndpoint extends S3STSEndpointBase { + + private static final Logger LOG = LoggerFactory.getLogger(S3STSEndpoint.class); + + // 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"; + private static final String GET_CALLER_IDENTITY_ACTION = "GetCallerIdentity"; + 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) + 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 + + /** + * STS endpoint that handles GET requests with query parameters. + * AWS STS supports both GET and POST requests. + * + * @param action The STS action to perform (AssumeRole, GetSessionToken, etc.) + * @param roleArn The ARN of the role to assume (for AssumeRole) + * @param roleSessionName Session name for the role (for AssumeRole) + * @param durationSeconds Duration of the token validity in seconds + * @param version AWS STS API version (should be "2011-06-15") + * @return Response containing STS response XML or error + */ + @GET + @Produces(MediaType.APPLICATION_XML) + public Response get( + @QueryParam("Action") String action, + @QueryParam("RoleArn") String roleArn, + @QueryParam("RoleSessionName") String roleSessionName, + @QueryParam("DurationSeconds") Integer durationSeconds, + @QueryParam("Version") String version) throws OS3Exception { + + return handleSTSRequest(action, roleArn, roleSessionName, durationSeconds, version); + } + + /** + * STS endpoint that handles POST requests with form data. + * AWS STS typically uses POST requests with form-encoded parameters. + * + * @param action The STS action to perform + * @param roleArn The ARN of the role to assume + * @param roleSessionName Session name for the role + * @param durationSeconds Duration of the token validity + * @param version AWS STS API version + * @return Response containing STS response XML or error + */ + @POST + @Produces(MediaType.APPLICATION_XML) + public Response post( + @FormParam("Action") String action, + @FormParam("RoleArn") String roleArn, + @FormParam("RoleSessionName") String roleSessionName, + @FormParam("DurationSeconds") Integer durationSeconds, + @FormParam("Version") String version) throws OS3Exception { + + return handleSTSRequest(action, roleArn, roleSessionName, durationSeconds, version); + } + + private Response handleSTSRequest(String action, String roleArn, String roleSessionName, + Integer durationSeconds, String version) throws OS3Exception { + try { + if (action == null) { + return Response.status(Response.Status.BAD_REQUEST) + .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.") + .build(); + } + + switch (action) { + case ASSUME_ROLE_ACTION: + return handleAssumeRole(roleArn, roleSessionName, duration); + // These operations are not supported yet + case GET_SESSION_TOKEN_ACTION: + case ASSUME_ROLE_WITH_SAML_ACTION: + case ASSUME_ROLE_WITH_WEB_IDENTITY_ACTION: + case GET_CALLER_IDENTITY_ACTION: + case DECODE_AUTHORIZATION_MESSAGE_ACTION: + case GET_ACCESS_KEY_INFO_ACTION: + return Response.status(Response.Status.NOT_IMPLEMENTED) + .entity("Operation " + action + " is not supported yet.") + .build(); + default: + return Response.status(Response.Status.BAD_REQUEST) + .entity("Unsupported Action: " + action) + .build(); + } + } catch (OS3Exception s3e) { + // Handle known S3 exceptions + LOG.error("S3 Error during STS request: {}", s3e.toXml()); + throw s3e; + } catch (Exception ex) { + LOG.error("Unexpected error during STS request", ex); + return Response.serverError().build(); + } + } + + 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) + throws IOException, OS3Exception { + // Validate required parameters for AssumeRole. RoleArn is required to pass the + 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(); + } + + // 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); + + return Response.ok(responseXml) + .header("Content-Type", "text/xml") + .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+=,.@\\-]+"); + } + + // 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); + } + + // 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()))); + } + 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())))); + } + 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); + } + 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); + } +} 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 new file mode 100644 index 000000000000..ef753410f941 --- /dev/null +++ b/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3sts/S3STSEndpointBase.java @@ -0,0 +1,109 @@ +/* + * 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 com.google.common.annotations.VisibleForTesting; +import java.util.Map; +import javax.annotation.PostConstruct; +import javax.inject.Inject; +import javax.ws.rs.container.ContainerRequestContext; +import javax.ws.rs.core.Context; +import org.apache.hadoop.ozone.audit.AuditAction; +import org.apache.hadoop.ozone.audit.AuditEventStatus; +import org.apache.hadoop.ozone.audit.AuditLogger; +import org.apache.hadoop.ozone.audit.AuditLoggerType; +import org.apache.hadoop.ozone.audit.AuditMessage; +import org.apache.hadoop.ozone.audit.Auditor; +import org.apache.hadoop.ozone.client.OzoneClient; +import org.apache.hadoop.ozone.client.protocol.ClientProtocol; +import org.apache.hadoop.ozone.om.protocol.S3Auth; +import org.apache.hadoop.ozone.s3.signature.SignatureInfo; +import org.apache.hadoop.ozone.s3.util.AuditUtils; + +/** + * Base class for STS endpoints. + */ +public class S3STSEndpointBase implements Auditor { + + @Context + private ContainerRequestContext context; + + @Inject + private OzoneClient client; + @Inject + private SignatureInfo signatureInfo; + + protected static final AuditLogger AUDIT = + new AuditLogger(AuditLoggerType.S3GLOGGER); + + @PostConstruct + public void initialization() { + S3Auth s3Auth = new S3Auth(signatureInfo.getStringToSign(), + signatureInfo.getSignature(), + signatureInfo.getAwsAccessId(), signatureInfo.getAwsAccessId()); + ClientProtocol clientProtocol = getClient().getObjectStore().getClientProxy(); + clientProtocol.setThreadLocalS3Auth(s3Auth); + } + + private AuditMessage.Builder auditMessageBaseBuilder(AuditAction op, + Map auditMap) { + AuditMessage.Builder builder = new AuditMessage.Builder() + .forOperation(op) + .withParams(auditMap); + if (context != null) { + builder.atIp(AuditUtils.getClientIpAddress(context)); + } + return builder; + } + + @Override + public AuditMessage buildAuditMessageForSuccess(AuditAction op, + Map auditMap) { + AuditMessage.Builder builder = auditMessageBaseBuilder(op, auditMap) + .withResult(AuditEventStatus.SUCCESS); + return builder.build(); + } + + @Override + public AuditMessage buildAuditMessageForFailure(AuditAction op, + Map auditMap, Throwable throwable) { + AuditMessage.Builder builder = auditMessageBaseBuilder(op, auditMap) + .withResult(AuditEventStatus.FAILURE) + .withException(throwable); + return builder.build(); + } + + public OzoneClient getClient() { + return client; + } + + @VisibleForTesting + public void setClient(OzoneClient ozoneClient) { + this.client = ozoneClient; + } + + @VisibleForTesting + public void setContext(ContainerRequestContext context) { + this.context = context; + } + + @VisibleForTesting + public void setSignatureInfo(SignatureInfo signatureInfo) { + this.signatureInfo = signatureInfo; + } +} diff --git a/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3sts/package-info.java b/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3sts/package-info.java new file mode 100644 index 000000000000..76f778001826 --- /dev/null +++ b/hadoop-ozone/s3gateway/src/main/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. + */ + +/** + * This package contains the AWS STS (Security Token Service) compatible API for S3 Gateway. + */ +package org.apache.hadoop.ozone.s3sts; diff --git a/hadoop-ozone/s3gateway/src/main/resources/webapps/s3g-sts/WEB-INF/web.xml b/hadoop-ozone/s3gateway/src/main/resources/webapps/s3g-sts/WEB-INF/web.xml new file mode 100644 index 000000000000..d6dcf626dcce --- /dev/null +++ b/hadoop-ozone/s3gateway/src/main/resources/webapps/s3g-sts/WEB-INF/web.xml @@ -0,0 +1,33 @@ + + + + sts-jaxrs + org.glassfish.jersey.servlet.ServletContainer + + javax.ws.rs.Application + org.apache.hadoop.ozone.s3sts.Application + + 1 + + + sts-jaxrs + /sts/* + + + org.jboss.weld.environment.servlet.Listener + + diff --git a/hadoop-ozone/s3gateway/src/test/java/org/apache/hadoop/ozone/s3/TestAuthorizationFilter.java b/hadoop-ozone/s3gateway/src/test/java/org/apache/hadoop/ozone/s3/TestAuthorizationFilter.java index 6df57448cadc..5171138710e0 100644 --- a/hadoop-ozone/s3gateway/src/test/java/org/apache/hadoop/ozone/s3/TestAuthorizationFilter.java +++ b/hadoop-ozone/s3gateway/src/test/java/org/apache/hadoop/ozone/s3/TestAuthorizationFilter.java @@ -20,6 +20,7 @@ import static java.net.HttpURLConnection.HTTP_BAD_REQUEST; import static java.net.HttpURLConnection.HTTP_FORBIDDEN; import static org.apache.hadoop.ozone.s3.exception.S3ErrorTable.MALFORMED_HEADER; +import static org.apache.hadoop.ozone.s3.exception.S3ErrorTable.PAYLOAD_TOO_LARGE; import static org.apache.hadoop.ozone.s3.exception.S3ErrorTable.S3_AUTHINFO_CREATION_ERROR; import static org.apache.hadoop.ozone.s3.signature.AWSSignatureProcessor.DATE_FORMATTER; import static org.apache.hadoop.ozone.s3.signature.SignatureParser.AUTHORIZATION_HEADER; @@ -28,6 +29,7 @@ import static org.apache.hadoop.ozone.s3.signature.SignatureProcessor.HOST_HEADER; import static org.apache.hadoop.ozone.s3.signature.StringToSignProducer.X_AMAZ_DATE; import static org.apache.hadoop.ozone.s3.util.S3Consts.X_AMZ_CONTENT_SHA256; +import static org.apache.hadoop.ozone.s3sts.S3STSConfigKeys.OZONE_S3G_STS_PAYLOAD_HASH_MAX_VALUE; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.fail; @@ -35,6 +37,8 @@ import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; +import java.io.ByteArrayInputStream; +import java.io.InputStream; import java.net.URI; import java.net.URISyntaxException; import java.nio.charset.StandardCharsets; @@ -117,6 +121,24 @@ public class TestAuthorizationFilter { "application/octet-stream", "/", S3_AUTHINFO_CREATION_ERROR.getErrorMessage() + ), + // Too huge payload for signature V4 of STS request + arguments( + "POST", + "AWS4-HMAC-SHA256 Credential=testuser1/" + CURDATE + + "/us-east-1/sts/aws4_request, " + + "SignedHeaders=content-type;host;" + + "x-amz-date, " + + "Signature" + + "=56ec73ba1974f8feda8365c3caef89c5d4a688d5f9baccf47" + + "65f46a14cd745ad", + "Content-SHA", + "s3g:9880", + "Content-SHA", + DATETIME, + "application/x-www-form-urlencoded; charset=utf-8", + "/sts", + PAYLOAD_TOO_LARGE.getErrorMessage() ) ); } @@ -133,6 +155,10 @@ void testAuthFilterFailures( ContainerRequestContext context = setupContext(method, authHeader, contentMd5, host, amzContentSha256, date, contentType, path); + byte[] payloadBytes = new byte[OZONE_S3G_STS_PAYLOAD_HASH_MAX_VALUE + 1]; + InputStream payLoadStream = new ByteArrayInputStream(payloadBytes); + when(context.getEntityStream()).thenReturn(payLoadStream); + AWSSignatureProcessor awsSignatureProcessor = new AWSSignatureProcessor(); awsSignatureProcessor.setContext(context); diff --git a/hadoop-ozone/s3gateway/src/test/java/org/apache/hadoop/ozone/s3/signature/TestStringToSignProducer.java b/hadoop-ozone/s3gateway/src/test/java/org/apache/hadoop/ozone/s3/signature/TestStringToSignProducer.java index cbce030ef69f..1d9b89eb23cd 100644 --- a/hadoop-ozone/s3gateway/src/test/java/org/apache/hadoop/ozone/s3/signature/TestStringToSignProducer.java +++ b/hadoop-ozone/s3gateway/src/test/java/org/apache/hadoop/ozone/s3/signature/TestStringToSignProducer.java @@ -94,6 +94,7 @@ public void validateDateRange(Credential credentialObj) { //NOOP } }.parseSignature(); + signatureInfo.setPayloadHash("Content-SHA"); signatureInfo.setUnfilteredURI("/buckets"); headers.fixContentType(); 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/TestSTS.java new file mode 100644 index 000000000000..7696bd4d3edf --- /dev/null +++ b/hadoop-ozone/s3gateway/src/test/java/org/apache/hadoop/ozone/s3sts/TestSTS.java @@ -0,0 +1,110 @@ +/* + * 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 static org.apache.hadoop.ozone.OzoneConfigKeys.OZONE_S3_ADMINISTRATORS; +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 javax.ws.rs.container.ContainerRequestContext; +import javax.ws.rs.core.Response; +import org.apache.hadoop.hdds.conf.OzoneConfiguration; +import org.apache.hadoop.ozone.client.OzoneClient; +import org.apache.hadoop.ozone.client.OzoneClientStub; +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; + +/** + * Test for S3 STS endpoint. + */ +public class TestSTS { + 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"; + + @Mock + private ContainerRequestContext context; + + @BeforeEach + public void setup() throws Exception { + OzoneConfiguration config = new OzoneConfiguration(); + config.set(OZONE_S3_ADMINISTRATORS, "test-user"); + OzoneConfigurationHolder.setConfiguration(config); + OzoneClient clientStub = new OzoneClientStub(); + endpoint = new S3STSEndpoint(); + endpoint.setClient(clientStub); + endpoint.setContext(context); + SignatureInfo signatureInfo = new SignatureInfo.Builder(SignatureInfo.Version.V4) + .setAwsAccessId("test-user") + .setSignature("some-signature") + .setStringToSign("dummy-string") + .build(); + endpoint.setSignatureInfo(signatureInfo); + } + + @Test + public void testStsAssumeRole() throws Exception { + Response response = endpoint.get( + "AssumeRole", ROLE_ARN, ROLE_SESSION_NAME, 3600, "2011-06-15"); + + 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)); + } + + @Test + public void testStsInvalidDuration() throws Exception { + Response response = endpoint.get( + "AssumeRole", ROLE_ARN, ROLE_SESSION_NAME, -1, "2011-06-15"); + + assertEquals(400, response.getStatus()); + String errorMessage = (String) response.getEntity(); + assertTrue(errorMessage.contains("Invalid Value: DurationSeconds")); + } + + @Test + public void testStsUnsupportedAction() throws Exception { + Response response = endpoint.get( + "UnsupportedAction", ROLE_ARN, ROLE_SESSION_NAME, 3600, "2011-06-15"); + + assertEquals(400, response.getStatus()); + String errorMessage = (String) response.getEntity(); + assertTrue(errorMessage.contains("Unsupported Action")); + } + + @Test + public void testStsInvalidVersion() throws Exception { + Response response = endpoint.get( + "AssumeRole", ROLE_ARN, ROLE_SESSION_NAME, 3600, "2000-01-01"); + + assertEquals(400, response.getStatus()); + String errorMessage = (String) response.getEntity(); + assertTrue(errorMessage.contains("Invalid or missing Version parameter. Supported version is 2011-06-15.")); + } +} From 56da3888204cbd9ce8e701cfc0f330a92d378a45 Mon Sep 17 00:00:00 2001 From: fmorg-git Date: Tue, 27 Jan 2026 13:55:15 -0800 Subject: [PATCH 21/54] HDDS-14150. [STS] Connect STS Endpoint to Backend Processing (#9673) --- .../apache/hadoop/ozone/OzoneConfigKeys.java | 4 + .../apache/hadoop/ozone/om/OzoneManager.java | 28 +++ .../ratis/utils/OzoneManagerRatisUtils.java | 8 + .../ratis/TestOzoneManagerRatisRequest.java | 58 ++++++ .../hadoop/ozone/om/ratis/package-info.java | 21 ++ .../ozone/s3/endpoint/EndpointBase.java | 4 + .../s3/signature/AWSSignatureProcessor.java | 39 ++++ .../ozone/s3/signature/SignatureInfo.java | 25 ++- .../ozone/s3sts/S3AssumeRoleResponseXml.java | 179 +++++++++++++++++ .../hadoop/ozone/s3sts/S3STSConfigKeys.java | 4 +- .../hadoop/ozone/s3sts/S3STSEndpoint.java | 180 ++++++++++-------- .../hadoop/ozone/s3sts/S3STSEndpointBase.java | 5 + .../{TestSTS.java => TestS3STSEndpoint.java} | 92 +++++++-- .../hadoop/ozone/s3sts/package-info.java | 21 ++ 14 files changed, 575 insertions(+), 93 deletions(-) create mode 100644 hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/om/ratis/package-info.java 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..4d3bdef38e33 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 testAssumeRoleAllowedWhenStsEnabledAndNativeAuthorizerNotUsed() { + 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/ozone-manager/src/test/java/org/apache/hadoop/ozone/om/ratis/package-info.java b/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/om/ratis/package-info.java new file mode 100644 index 000000000000..3dea7af810ca --- /dev/null +++ b/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/om/ratis/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. + */ + +/** + * Package contains test classes for OM Ratis server implementation. + */ +package org.apache.hadoop.ozone.om.ratis; 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..92c2f102c905 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 @@ -25,6 +25,7 @@ import static org.apache.hadoop.ozone.s3sts.S3STSConfigKeys.OZONE_S3G_STS_PAYLOAD_HASH_MAX_VALUE; import com.google.common.annotations.VisibleForTesting; +import com.google.common.base.Strings; import java.io.ByteArrayInputStream; import java.io.ByteArrayOutputStream; import java.io.IOException; @@ -68,6 +69,8 @@ public class AWSSignatureProcessor implements SignatureProcessor { private static final AuditLogger AUDIT = new AuditLogger(AuditLoggerType.S3GLOGGER); + private static final String X_AMZ_SECURITY_TOKEN = "x-amz-security-token"; + @Context private ContainerRequestContext context; @@ -103,6 +106,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 +122,33 @@ 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; + } + for (Map.Entry> entry : queryParams.entrySet()) { + final String key = entry.getKey(); + if (Strings.isNullOrEmpty(key)) { + continue; + } + if (key.compareToIgnoreCase(X_AMZ_SECURITY_TOKEN) == 0) { + final List values = entry.getValue(); + if (values != null && !values.isEmpty()) { + return values.get(0); + } + } + } + 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..e33e9f805524 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 @@ -17,11 +17,12 @@ package org.apache.hadoop.ozone.s3sts; +import com.google.common.base.Strings; 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 +32,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 +70,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 +94,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 +118,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 +149,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 +189,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 +212,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 +247,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 (Strings.isNullOrEmpty(accountId) || Strings.isNullOrEmpty(resource) || !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 bb2b540bd28a6f95de2ede4e8f8c2c208112755c Mon Sep 17 00:00:00 2001 From: fmorg-git Date: Wed, 28 Jan 2026 06:17:27 -0800 Subject: [PATCH 22/54] HDDS-14514. [STS] Revamp error handling in endpoint to conform to AWS XML (#9674) --- .../ozone/s3/exception/OSTSException.java | 160 ++++++++ .../s3/exception/OSTSExceptionMapper.java | 49 +++ .../hadoop/ozone/s3sts/Application.java | 2 + .../hadoop/ozone/s3sts/S3STSEndpoint.java | 151 ++++---- .../hadoop/ozone/s3sts/package-info.java | 11 + .../s3/exception/TestOSTSExceptions.java | 102 ++++++ .../hadoop/ozone/s3sts/TestS3STSEndpoint.java | 346 ++++++++++++++++-- 7 files changed, 725 insertions(+), 96 deletions(-) create mode 100644 hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/exception/OSTSException.java create mode 100644 hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/exception/OSTSExceptionMapper.java create mode 100644 hadoop-ozone/s3gateway/src/test/java/org/apache/hadoop/ozone/s3/exception/TestOSTSExceptions.java diff --git a/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/exception/OSTSException.java b/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/exception/OSTSException.java new file mode 100644 index 000000000000..57c870a7eb0b --- /dev/null +++ b/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/exception/OSTSException.java @@ -0,0 +1,160 @@ +/* + * 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.s3.exception; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializationFeature; +import com.fasterxml.jackson.dataformat.xml.XmlMapper; +import com.fasterxml.jackson.module.jaxb.JaxbAnnotationModule; +import javax.xml.bind.annotation.XmlAccessType; +import javax.xml.bind.annotation.XmlAccessorType; +import javax.xml.bind.annotation.XmlAttribute; +import javax.xml.bind.annotation.XmlElement; +import javax.xml.bind.annotation.XmlRootElement; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * This class represents exceptions raised from Ozone STS service. + */ +public class OSTSException extends OS3Exception { + private static final Logger LOG = LoggerFactory.getLogger(OSTSException.class); + private static final ObjectMapper MAPPER; + private static final String AWS_FAULT_NS = "http://webservices.amazon.com/AWSFault/2005-15-09"; + private static final String STS_NS = "https://sts.amazonaws.com/doc/2011-06-15/"; + private static final String INVALID_ACTION = "InvalidAction"; + + static { + MAPPER = new XmlMapper(); + MAPPER.registerModule(new JaxbAnnotationModule()); + MAPPER.enable(SerializationFeature.INDENT_OUTPUT); + } + + private String type = "Sender"; + + public OSTSException(String codeVal, String messageVal, int httpCode) { + super(codeVal, messageVal, httpCode); + } + + public OSTSException(String codeVal, String messageVal, int httpCode, String typeVal) { + this(codeVal, messageVal, httpCode); + this.type = typeVal; + } + + public String getType() { + return type; + } + + public void setType(String type) { + this.type = type; + } + + @Override + public String toXml() { + try { + final ErrorResponse response = new ErrorResponse(this); + final String val = MAPPER.writeValueAsString(response); + LOG.debug("toXml val is {}", val); + return val; // STS error responses don't have prolog + } catch (Exception ex) { + LOG.error("Exception occurred", ex); + // Fallback + final String namespace = INVALID_ACTION.equals(getCode()) ? AWS_FAULT_NS : STS_NS; + + // STS error responses don't have prolog + final StringBuilder builder = new StringBuilder(); + builder.append("\n") + .append(" \n") + .append(" ").append(getType()).append("\n") + .append(" ").append(getCode()).append("\n") + .append(" ").append(getErrorMessage()).append("\n") + .append(" \n") + .append(" ").append(getRequestId()).append("\n") + .append(""); + return builder.toString(); + } + } + + @XmlAccessorType(XmlAccessType.FIELD) + @XmlRootElement(name = "ErrorResponse") + private static class ErrorResponse { + + @XmlAttribute + private String xmlns; + + @XmlElement(name = "Error") + private ErrorDetails error; + + @XmlElement(name = "RequestId") + private String requestId; + + ErrorResponse() { + } + + ErrorResponse(OSTSException ex) { + this.xmlns = INVALID_ACTION.equals(ex.getCode()) ? AWS_FAULT_NS : STS_NS; + this.error = new ErrorDetails(ex.getType(), ex.getCode(), ex.getErrorMessage()); + this.requestId = ex.getRequestId(); + } + + public String getXmlns() { + return xmlns; + } + + public ErrorDetails getError() { + return error; + } + + public String getRequestId() { + return requestId; + } + } + + @XmlAccessorType(XmlAccessType.FIELD) + private static class ErrorDetails { + @XmlElement(name = "Type") + private String type; + + @XmlElement(name = "Code") + private String code; + + @XmlElement(name = "Message") + private String message; + + ErrorDetails() { + } + + ErrorDetails(String type, String code, String message) { + this.type = type; + this.code = code; + this.message = message; + } + + public String getType() { + return type; + } + + public String getCode() { + return code; + } + + public String getMessage() { + return message; + } + } +} diff --git a/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/exception/OSTSExceptionMapper.java b/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/exception/OSTSExceptionMapper.java new file mode 100644 index 000000000000..bb564e0e061b --- /dev/null +++ b/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/exception/OSTSExceptionMapper.java @@ -0,0 +1,49 @@ +/* + * 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.s3.exception; + +import javax.inject.Inject; +import javax.ws.rs.core.Response; +import javax.ws.rs.ext.ExceptionMapper; +import javax.ws.rs.ext.Provider; +import org.apache.hadoop.ozone.s3.RequestIdentifier; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Class that represents various errors returned by the Ozone STS service. + */ +@Provider +public class OSTSExceptionMapper implements ExceptionMapper { + + private static final Logger LOG = LoggerFactory.getLogger(OSTSExceptionMapper.class); + + @Inject + private RequestIdentifier requestIdentifier; + + @Override + public Response toResponse(OSTSException exception) { + if (LOG.isDebugEnabled()) { + LOG.debug("Returning exception. ex: {}", exception.toString()); + } + exception.setRequestId(requestIdentifier.getRequestId()); + return Response.status(exception.getHttpCode()) + .entity(exception.toXml()).build(); + } +} + diff --git a/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3sts/Application.java b/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3sts/Application.java index 65081d5d47fe..1605532db1c5 100644 --- a/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3sts/Application.java +++ b/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3sts/Application.java @@ -17,6 +17,7 @@ package org.apache.hadoop.ozone.s3sts; +import org.apache.hadoop.ozone.s3.exception.OSTSExceptionMapper; import org.glassfish.jersey.server.ResourceConfig; /** @@ -26,5 +27,6 @@ public class Application extends ResourceConfig { public Application() { packages("org.apache.hadoop.ozone.s3sts"); register(org.apache.hadoop.ozone.s3.AuthorizationFilter.class); + register(OSTSExceptionMapper.class); } } 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 e33e9f805524..9d6b1b8d77f7 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 @@ -17,6 +17,11 @@ package org.apache.hadoop.ozone.s3sts; +import static javax.ws.rs.core.Response.Status.BAD_REQUEST; +import static javax.ws.rs.core.Response.Status.FORBIDDEN; +import static javax.ws.rs.core.Response.Status.INTERNAL_SERVER_ERROR; +import static javax.ws.rs.core.Response.Status.NOT_IMPLEMENTED; + import com.google.common.base.Strings; import java.io.IOException; import java.io.StringWriter; @@ -35,8 +40,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.s3.exception.OS3Exception; +import org.apache.hadoop.ozone.s3.exception.OSTSException; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -58,9 +65,7 @@ public class S3STSEndpoint extends S3STSEndpointBase { private static final Logger LOG = LoggerFactory.getLogger(S3STSEndpoint.class); // 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"; @@ -69,6 +74,8 @@ public class S3STSEndpoint extends S3STSEndpointBase { private static final String DECODE_AUTHORIZATION_MESSAGE_ACTION = "DecodeAuthorizationMessage"; private static final String GET_ACCESS_KEY_INFO_ACTION = "GetAccessKeyInfo"; + private static final String EXPECTED_VERSION = "2011-06-15"; + // 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; @@ -126,30 +133,19 @@ public Response post( private Response handleSTSRequest(String action, String roleArn, String roleSessionName, Integer durationSeconds, String version, String awsIamSessionPolicy) throws OS3Exception { + final String requestId = UUID.randomUUID().toString(); try { if (action == null) { - return Response.status(Response.Status.BAD_REQUEST) - .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.") + // Amazon STS has a different structure for the XML error response when the action is missing + return Response.status(BAD_REQUEST) + .entity("") + .type(MediaType.APPLICATION_XML) .build(); } switch (action) { case ASSUME_ROLE_ACTION: - return handleAssumeRole(roleArn, roleSessionName, duration, awsIamSessionPolicy); + return handleAssumeRole(roleArn, roleSessionName, durationSeconds, awsIamSessionPolicy, version, requestId); // These operations are not supported yet case GET_SESSION_TOKEN_ACTION: case ASSUME_ROLE_WITH_SAML_ACTION: @@ -157,25 +153,24 @@ private Response handleSTSRequest(String action, String roleArn, String roleSess case GET_CALLER_IDENTITY_ACTION: case DECODE_AUTHORIZATION_MESSAGE_ACTION: case GET_ACCESS_KEY_INFO_ACTION: - return Response.status(Response.Status.NOT_IMPLEMENTED) - .entity("Operation " + action + " is not supported yet.") - .build(); + throw new OSTSException( + "InvalidAction", "Operation " + action + " is not supported yet.", NOT_IMPLEMENTED.getStatusCode()); default: - return Response.status(Response.Status.BAD_REQUEST) - .entity("Unsupported Action: " + action) - .build(); + throw new OSTSException( + "InvalidAction", "Could not find operation " + action + " for version " + + (version == null ? "NO_VERSION_SPECIFIED. Expected version is: " + EXPECTED_VERSION : version), + BAD_REQUEST.getStatusCode()); } - } catch (OS3Exception s3e) { - // Handle known S3 exceptions - LOG.error("S3 Error during STS request: {}", s3e.toXml()); - throw s3e; + } catch (OSTSException e) { + throw e; } catch (Exception ex) { LOG.error("Unexpected error during STS request", ex); - return Response.serverError().build(); + throw new OSTSException( + "InternalFailure", "An internal error has occurred.", INTERNAL_SERVER_ERROR.getStatusCode(), "Receiver"); } } - private int validateDuration(Integer durationSeconds) throws IllegalArgumentException, OS3Exception { + private int validateDuration(Integer durationSeconds) throws IllegalArgumentException { if (durationSeconds == null) { return DEFAULT_DURATION_SECONDS; } @@ -189,53 +184,87 @@ private int validateDuration(Integer durationSeconds) throws IllegalArgumentExce return durationSeconds; } - private Response handleAssumeRole(String roleArn, String roleSessionName, int duration, String awsIamSessionPolicy) - throws IOException, OS3Exception { - // Validate required parameters for AssumeRole. RoleArn is required + private Response handleAssumeRole(String roleArn, String roleSessionName, Integer durationSeconds, + String awsIamSessionPolicy, String version, String requestId) throws OSTSException { + // Validate parameters + final String action = "AssumeRole"; + int duration; + try { + duration = validateDuration(durationSeconds); + } catch (IllegalArgumentException e) { + throw new OSTSException("ValidationError", e.getMessage(), BAD_REQUEST.getStatusCode()); + } + + if (version == null || !version.equals(EXPECTED_VERSION)) { + throw new OSTSException( + "InvalidAction", "Could not find operation " + action + " for version " + + (version == null ? "NO_VERSION_SPECIFIED. Expected version is: " + EXPECTED_VERSION : version), + BAD_REQUEST.getStatusCode()); + } + if (roleArn == null || roleArn.isEmpty()) { - return Response.status(Response.Status.BAD_REQUEST) - .entity("Missing required parameter: " + ROLE_ARN_PARAM) - .build(); + throw new OSTSException( + "ValidationError", "Value null at 'roleArn' failed to satisfy constraint: Member must not be null", + BAD_REQUEST.getStatusCode()); } if (roleSessionName == null || roleSessionName.isEmpty()) { - return Response.status(Response.Status.BAD_REQUEST) - .entity("Missing required parameter: RoleSessionName") - .build(); + throw new OSTSException( + "ValidationError", "Value null at 'roleSessionName' failed to satisfy constraint: Member must not be null", + BAD_REQUEST.getStatusCode()); } // 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(); + throw new OSTSException( + "ValidationError", "Invalid RoleSessionName: must be 2-64 characters long and " + + "contain only alphanumeric characters, +, =, ,, ., @, -", + BAD_REQUEST.getStatusCode()); } // 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(); + throw new OSTSException( + "ValidationError", "Value '" + awsIamSessionPolicy + "' at 'policy' failed to satisfy constraint: Member " + + "must have length less than or equal to 2048", BAD_REQUEST.getStatusCode()); } final String assumedRoleUserArn; try { assumedRoleUserArn = toAssumedRoleUserArn(roleArn, roleSessionName); } catch (IllegalArgumentException e) { - return Response.status(Response.Status.BAD_REQUEST) - .entity(e.getMessage()) - .build(); + throw new OSTSException("ValidationError", e.getMessage(), BAD_REQUEST.getStatusCode()); } - 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(); + try { + final AssumeRoleResponseInfo responseInfo = getClient() + .getObjectStore() + .assumeRole(roleArn, roleSessionName, duration, awsIamSessionPolicy); + // Generate AssumeRole response + final String responseXml = generateAssumeRoleResponse(assumedRoleUserArn, responseInfo, requestId); + return Response.ok(responseXml) + .header("Content-Type", "text/xml") + .build(); + } catch (IOException e) { + LOG.error("Error during AssumeRole processing", e); + if (e instanceof OMException) { + final OMException omException = (OMException) e; + if (omException.getResult() == OMException.ResultCodes.ACCESS_DENIED || + omException.getResult() == OMException.ResultCodes.PERMISSION_DENIED || + omException.getResult() == OMException.ResultCodes.TOKEN_EXPIRED) { + throw new OSTSException( + "AccessDenied", "User is not authorized to perform: sts:AssumeRole on resource: " + roleArn, + FORBIDDEN.getStatusCode()); + } + if (omException.getResult() == OMException.ResultCodes.INVALID_TOKEN) { + throw new OSTSException( + "InvalidClientTokenId", "The security token included in the request is invalid.", + FORBIDDEN.getStatusCode()); + } + } + throw new OSTSException("InternalFailure", "An internal error has occurred.", + INTERNAL_SERVER_ERROR.getStatusCode(), "Receiver"); + } } private boolean isValidRoleSessionName(String roleSessionName) { @@ -247,8 +276,8 @@ private boolean isValidRoleSessionName(String roleSessionName) { return roleSessionName.matches("[a-zA-Z0-9+=,.@\\-]+"); } - private String generateAssumeRoleResponse(String assumedRoleUserArn, AssumeRoleResponseInfo responseInfo) - throws IOException { + private String generateAssumeRoleResponse(String assumedRoleUserArn, AssumeRoleResponseInfo responseInfo, + String requestId) throws IOException { final String accessKeyId = responseInfo.getAccessKeyId(); final String secretAccessKey = responseInfo.getSecretAccessKey(); final String sessionToken = responseInfo.getSessionToken(); @@ -257,8 +286,6 @@ private String generateAssumeRoleResponse(String assumedRoleUserArn, AssumeRoleR final String expiration = DateTimeFormatter.ISO_INSTANT.format( Instant.ofEpochSecond(responseInfo.getExpirationEpochSeconds()).atOffset(ZoneOffset.UTC).toInstant()); - final String requestId = UUID.randomUUID().toString(); - try { final S3AssumeRoleResponseXml response = new S3AssumeRoleResponseXml(); final S3AssumeRoleResponseXml.AssumeRoleResult result = new S3AssumeRoleResponseXml.AssumeRoleResult(); diff --git a/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3sts/package-info.java b/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3sts/package-info.java index 76f778001826..3383580a5eb0 100644 --- a/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3sts/package-info.java +++ b/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3sts/package-info.java @@ -18,4 +18,15 @@ /** * This package contains the AWS STS (Security Token Service) compatible API for S3 Gateway. */ +@XmlSchema( + namespace = "https://sts.amazonaws.com/doc/2011-06-15/", + elementFormDefault = XmlNsForm.QUALIFIED, + xmlns = { + @XmlNs(prefix = "", namespaceURI = "https://sts.amazonaws.com/doc/2011-06-15/") + } +) package org.apache.hadoop.ozone.s3sts; + +import javax.xml.bind.annotation.XmlNs; +import javax.xml.bind.annotation.XmlNsForm; +import javax.xml.bind.annotation.XmlSchema; diff --git a/hadoop-ozone/s3gateway/src/test/java/org/apache/hadoop/ozone/s3/exception/TestOSTSExceptions.java b/hadoop-ozone/s3gateway/src/test/java/org/apache/hadoop/ozone/s3/exception/TestOSTSExceptions.java new file mode 100644 index 000000000000..cd77979ec204 --- /dev/null +++ b/hadoop-ozone/s3gateway/src/test/java/org/apache/hadoop/ozone/s3/exception/TestOSTSExceptions.java @@ -0,0 +1,102 @@ +/* + * 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.s3.exception; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; + +import java.io.StringReader; +import javax.xml.parsers.DocumentBuilder; +import javax.xml.parsers.DocumentBuilderFactory; +import org.apache.hadoop.ozone.web.utils.OzoneUtils; +import org.junit.jupiter.api.Test; +import org.w3c.dom.Document; +import org.w3c.dom.Element; +import org.xml.sax.InputSource; + +/** + * This class tests OSTSException class. It is named TestOSTSExceptions instead of + * TestOSTSException to avoid findbugs rule about classes ending in *Exception must + * extend Exception or Throwable. + */ +public class TestOSTSExceptions { + + @Test + public void testOSTSException() throws Exception { + final OSTSException ex = new OSTSException("ValidationError", "1 validation error detected", 400); + final String requestId = OzoneUtils.getRequestID(); + ex.setRequestId(requestId); + final String val = ex.toXml(); + + final Document doc = parseXml(val); + final Element root = doc.getDocumentElement(); + assertEquals("ErrorResponse", root.getLocalName()); + // Ensure the response uses the default namespace (no prefix like "ns2:") + assertEquals("ErrorResponse", root.getNodeName()); + assertEquals("https://sts.amazonaws.com/doc/2011-06-15/", root.getNamespaceURI()); + + assertEquals("Sender", doc.getElementsByTagName("Type").item(0).getTextContent()); + assertEquals("ValidationError", doc.getElementsByTagName("Code").item(0).getTextContent()); + assertEquals("1 validation error detected", doc.getElementsByTagName("Message").item(0).getTextContent()); + assertEquals(requestId, doc.getElementsByTagName("RequestId").item(0).getTextContent()); + } + + @Test + public void testOSTSExceptionInvalidAction() throws Exception { + final OSTSException ex = new OSTSException("InvalidAction", "Could not find operation", 400); + final String requestId = OzoneUtils.getRequestID(); + ex.setRequestId(requestId); + final String val = ex.toXml(); + + final Document doc = parseXml(val); + final Element root = doc.getDocumentElement(); + assertEquals("ErrorResponse", root.getLocalName()); + assertEquals("http://webservices.amazon.com/AWSFault/2005-15-09", root.getNamespaceURI()); + + assertEquals("Sender", doc.getElementsByTagName("Type").item(0).getTextContent()); + assertEquals("InvalidAction", doc.getElementsByTagName("Code").item(0).getTextContent()); + assertEquals("Could not find operation", doc.getElementsByTagName("Message").item(0).getTextContent()); + assertEquals(requestId, doc.getElementsByTagName("RequestId").item(0).getTextContent()); + } + + @Test + public void testOSTSExceptionWithCustomType() throws Exception { + final OSTSException ex = new OSTSException("InternalFailure", "An internal error has occurred.", 500, "Receiver"); + final String requestId = OzoneUtils.getRequestID(); + ex.setRequestId(requestId); + final String val = ex.toXml(); + + final Document doc = parseXml(val); + final Element root = doc.getDocumentElement(); + assertEquals("ErrorResponse", root.getLocalName()); + assertEquals("https://sts.amazonaws.com/doc/2011-06-15/", root.getNamespaceURI()); + + assertEquals("Receiver", doc.getElementsByTagName("Type").item(0).getTextContent()); + assertEquals("InternalFailure", doc.getElementsByTagName("Code").item(0).getTextContent()); + assertEquals("An internal error has occurred.", doc.getElementsByTagName("Message").item(0).getTextContent()); + assertEquals(requestId, doc.getElementsByTagName("RequestId").item(0).getTextContent()); + } + + private static Document parseXml(String xml) throws Exception { + assertNotNull(xml); + final DocumentBuilderFactory documentBuilderFactory = DocumentBuilderFactory.newInstance(); + documentBuilderFactory.setNamespaceAware(true); + final DocumentBuilder documentBuilder = documentBuilderFactory.newDocumentBuilder(); + return documentBuilder.parse(new InputSource(new StringReader(xml))); + } +} 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..aefb525448b4 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 @@ -20,14 +20,17 @@ import static org.apache.hadoop.ozone.OzoneConfigKeys.OZONE_S3_ADMINISTRATORS; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; 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.verify; import static org.mockito.Mockito.when; +import java.io.IOException; import java.io.StringReader; import java.time.Instant; import javax.ws.rs.container.ContainerRequestContext; @@ -39,11 +42,14 @@ 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.exceptions.OMException; import org.apache.hadoop.ozone.om.helpers.AssumeRoleResponseInfo; import org.apache.hadoop.ozone.s3.OzoneConfigurationHolder; +import org.apache.hadoop.ozone.s3.exception.OSTSException; import org.apache.hadoop.ozone.s3.signature.SignatureInfo; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; import org.mockito.Mock; import org.w3c.dom.Document; import org.w3c.dom.Element; @@ -54,9 +60,12 @@ */ public class TestS3STSEndpoint { private S3STSEndpoint endpoint; + private ObjectStore objectStore; 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; + private static final String STS_NS = "https://sts.amazonaws.com/doc/2011-06-15/"; + private static final String AWS_FAULT_NS = "http://webservices.amazon.com/AWSFault/2005-15-09"; @Mock private ContainerRequestContext context; @@ -69,7 +78,7 @@ public void setup() throws Exception { OzoneClient clientStub = spy(new OzoneClientStub()); // Stub assumeRole to return deterministic credentials. - ObjectStore objectStore = mock(ObjectStore.class); + objectStore = mock(ObjectStore.class); when(objectStore.assumeRole(anyString(), anyString(), anyInt(), any())) .thenReturn(new AssumeRoleResponseInfo( "ASIA1234567890123456", @@ -91,7 +100,7 @@ public void setup() throws Exception { } @Test - public void testStsAssumeRole() throws Exception { + public void testStsAssumeRoleValidForGetMethod() throws Exception { Response response = endpoint.get( "AssumeRole", ROLE_ARN, ROLE_SESSION_NAME, 3600, "2011-06-15", null); @@ -101,13 +110,18 @@ public void testStsAssumeRole() throws Exception { assertNotNull(responseXml); // 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 Document doc = parseXml(responseXml); final Element root = doc.getDocumentElement(); assertEquals("AssumeRoleResponse", root.getLocalName()); + assertEquals(STS_NS, root.getNamespaceURI()); + // Ensure the response uses the default namespace (no prefix like "ns2:") + assertEquals("AssumeRoleResponse", root.getNodeName()); + + // Verify some key elements are present in the STS namespace + assertNotNull(doc.getElementsByTagNameNS(STS_NS, "AssumeRoleResult").item(0)); + assertNotNull(doc.getElementsByTagNameNS(STS_NS, "Credentials").item(0)); + assertNotNull(doc.getElementsByTagNameNS(STS_NS, "AccessKeyId").item(0)); final String accessKeyId = doc.getElementsByTagName("AccessKeyId").item(0).getTextContent(); assertEquals("ASIA1234567890123456", accessKeyId); @@ -123,56 +137,320 @@ public void testStsAssumeRole() throws Exception { } @Test - public void testStsInvalidDuration() throws Exception { - Response response = endpoint.get( - "AssumeRole", ROLE_ARN, ROLE_SESSION_NAME, -1, "2011-06-15", null); + public void testStsAssumeRoleValidForPostMethod() throws Exception { + final Response response = endpoint.post("AssumeRole", ROLE_ARN, ROLE_SESSION_NAME, 3600, "2011-06-15", null); - assertEquals(400, response.getStatus()); - String errorMessage = (String) response.getEntity(); - assertTrue(errorMessage.contains("Invalid Value: DurationSeconds")); + assertEquals(200, response.getStatus()); + final String responseXml = (String) response.getEntity(); + assertNotNull(responseXml); + + final Document doc = parseXml(responseXml); + final Element root = doc.getDocumentElement(); + assertEquals("AssumeRoleResponse", root.getLocalName()); + assertEquals(STS_NS, root.getNamespaceURI()); + // Ensure the response uses the default namespace (no prefix like "ns2:") + assertEquals("AssumeRoleResponse", root.getNodeName()); + + // Verify some key elements are present in the STS namespace + assertNotNull(doc.getElementsByTagNameNS(STS_NS, "AssumeRoleResult").item(0)); + assertNotNull(doc.getElementsByTagNameNS(STS_NS, "Credentials").item(0)); + assertNotNull(doc.getElementsByTagNameNS(STS_NS, "AccessKeyId").item(0)); + + 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 testStsUnsupportedAction() throws Exception { - Response response = endpoint.get( - "UnsupportedAction", ROLE_ARN, ROLE_SESSION_NAME, 3600, "2011-06-15", null); + public void testStsNullAction() throws Exception { + final Response response = endpoint.get(null, ROLE_ARN, ROLE_SESSION_NAME, 3600, "2011-06-15", null); assertEquals(400, response.getStatus()); - String errorMessage = (String) response.getEntity(); - assertTrue(errorMessage.contains("Unsupported Action")); + final String errorMessage = (String) response.getEntity(); + assertEquals("", errorMessage); + + final Document doc = parseXml(errorMessage); + final Element root = doc.getDocumentElement(); + assertEquals("UnknownOperationException", root.getLocalName()); } @Test - public void testStsInvalidVersion() throws Exception { - Response response = endpoint.get( - "AssumeRole", ROLE_ARN, ROLE_SESSION_NAME, 3600, "2000-01-01", null); + public void testStsUnsupportedActionWithVersionSupplied() throws Exception { + final OSTSException ex = assertThrows(OSTSException.class, () -> + endpoint.get("UnsupportedAction", ROLE_ARN, ROLE_SESSION_NAME, 3600, "2011-06-15", null)); - assertEquals(400, response.getStatus()); - String errorMessage = (String) response.getEntity(); - assertTrue(errorMessage.contains("Invalid or missing Version parameter. Supported version is 2011-06-15.")); + assertEquals(400, ex.getHttpCode()); + + final String requestId = "test-request-id"; + ex.setRequestId(requestId); + assertStsErrorXml(ex.toXml(), AWS_FAULT_NS, "Sender", "InvalidAction", + "Could not find operation UnsupportedAction for version 2011-06-15"); + } + + @Test + public void testStsUnsupportedActionWithVersionNotSupplied() throws Exception { + final OSTSException ex = assertThrows(OSTSException.class, () -> + endpoint.get("UnsupportedAction", ROLE_ARN, ROLE_SESSION_NAME, 3600, null, null)); + + assertEquals(400, ex.getHttpCode()); + + final String requestId = "test-request-id"; + ex.setRequestId(requestId); + assertStsErrorXml(ex.toXml(), AWS_FAULT_NS, "Sender", "InvalidAction", + "Could not find operation UnsupportedAction for version NO_VERSION_SPECIFIED"); + } + + @Test + public void testStsAssumeRoleWithInvalidVersion() throws Exception { + final OSTSException ex = assertThrows(OSTSException.class, () -> + endpoint.get("AssumeRole", ROLE_ARN, ROLE_SESSION_NAME, 3600, "2000-01-01", null)); + + assertEquals(400, ex.getHttpCode()); + + final String requestId = "test-request-id"; + ex.setRequestId(requestId); + assertStsErrorXml(ex.toXml(), AWS_FAULT_NS, "Sender", "InvalidAction", + "Could not find operation AssumeRole for version 2000-01-01"); + } + + @Test + public void testStsInvalidDuration() throws Exception { + final OSTSException ex = assertThrows(OSTSException.class, () -> + endpoint.get("AssumeRole", ROLE_ARN, ROLE_SESSION_NAME, -1, "2011-06-15", null)); + + assertEquals(400, ex.getHttpCode()); + + final String requestId = "test-request-id"; + ex.setRequestId(requestId); + assertStsErrorXml(ex.toXml(), STS_NS, "Sender", "ValidationError", "Invalid Value: DurationSeconds"); + } + + @Test + public void testStsNullDurationUsesDefault3600() throws Exception { + final Response response = endpoint.get( + "AssumeRole", ROLE_ARN, ROLE_SESSION_NAME, null, "2011-06-15", null); + assertEquals(200, response.getStatus()); + + final ArgumentCaptor durationCaptor = ArgumentCaptor.forClass(Integer.class); + verify(objectStore).assumeRole(anyString(), anyString(), durationCaptor.capture(), any()); + assertEquals(3600, durationCaptor.getValue()); } @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); + final OSTSException ex = assertThrows(OSTSException.class, () -> + 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")); + assertEquals(400, ex.getHttpCode()); + + final String requestId = "test-request-id"; + ex.setRequestId(requestId); + assertStsErrorXml(ex.toXml(), STS_NS, "Sender", "ValidationError", + "Value '" + tooLargePolicy + "' at 'policy' failed to satisfy constraint: Member " + + "must have length less than or equal to 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); + final OSTSException ex = assertThrows(OSTSException.class, () -> + 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/")); + assertEquals(400, ex.getHttpCode()); + + final String requestId = "test-request-id"; + ex.setRequestId(requestId); + assertStsErrorXml(ex.toXml(), STS_NS, "Sender", "ValidationError", + "Invalid RoleArn: must be in the format arn:aws:iam:::role/"); + } + + @Test + public void testStsMissingRoleArn() throws Exception { + final OSTSException ex = assertThrows(OSTSException.class, () -> + endpoint.get("AssumeRole", null, ROLE_SESSION_NAME, 3600, "2011-06-15", null)); + + assertEquals(400, ex.getHttpCode()); + + final String requestId = "test-request-id"; + ex.setRequestId(requestId); + assertStsErrorXml(ex.toXml(), STS_NS, "Sender", "ValidationError", "Value null at 'roleArn'"); + } + + @Test + public void testStsInvalidRoleArnMissingRoleName() throws Exception { + final String invalidRoleArn = "arn:aws:iam::123456789012:role/"; + final OSTSException ex = assertThrows(OSTSException.class, () -> + endpoint.get("AssumeRole", invalidRoleArn, ROLE_SESSION_NAME, 3600, "2011-06-15", null)); + + assertEquals(400, ex.getHttpCode()); + assertEquals("ValidationError", ex.getCode()); + + final String requestId = "test-request-id"; + ex.setRequestId(requestId); + assertStsErrorXml(ex.toXml(), STS_NS, "Sender", "ValidationError", "Invalid RoleArn: must be in the format"); + } + + @Test + public void testStsInvalidRoleArnMissingAccountId() throws Exception { + final String invalidRoleArn = "arn:aws:iam:::role/test-role"; + final OSTSException ex = assertThrows(OSTSException.class, () -> + endpoint.get("AssumeRole", invalidRoleArn, ROLE_SESSION_NAME, 3600, "2011-06-15", null)); + + assertEquals(400, ex.getHttpCode()); + assertEquals("ValidationError", ex.getCode()); + + final String requestId = "test-request-id"; + ex.setRequestId(requestId); + assertStsErrorXml(ex.toXml(), STS_NS, "Sender", "ValidationError", "Invalid RoleArn: must be in the format" + ); + } + + @Test + public void testStsWhenActionNotImplemented() throws Exception { + final OSTSException ex = assertThrows(OSTSException.class, () -> + endpoint.get("GetSessionToken", ROLE_ARN, ROLE_SESSION_NAME, 3600, "2011-06-15", null)); + + assertEquals(501, ex.getHttpCode()); + + final String requestId = "test-request-id"; + ex.setRequestId(requestId); + assertStsErrorXml(ex.toXml(), AWS_FAULT_NS, "Sender", "InvalidAction", + "Operation GetSessionToken is not supported yet."); + } + + @Test + public void testStsMissingRoleSessionName() throws Exception { + final OSTSException ex = assertThrows(OSTSException.class, () -> + endpoint.get("AssumeRole", ROLE_ARN, null, 3600, "2011-06-15", null)); + + assertEquals(400, ex.getHttpCode()); + + final String requestId = "test-request-id"; + ex.setRequestId(requestId); + assertStsErrorXml(ex.toXml(), STS_NS, "Sender", "ValidationError", "Value null at 'roleSessionName'"); + } + + @Test + public void testStsInvalidRoleSessionNameWithInvalidCharacter() throws Exception { + final String invalidSession = "test/session"; + final OSTSException ex = assertThrows(OSTSException.class, () -> + endpoint.get("AssumeRole", ROLE_ARN, invalidSession, 3600, "2011-06-15", null)); + + assertEquals(400, ex.getHttpCode()); + + final String requestId = "test-request-id"; + ex.setRequestId(requestId); + assertStsErrorXml(ex.toXml(), STS_NS, "Sender", "ValidationError", "Invalid RoleSessionName"); + } + + @Test + public void testStsInvalidRoleSessionNameTooShort() throws Exception { + final String invalidSession = "a"; + final OSTSException ex = assertThrows(OSTSException.class, () -> + endpoint.get("AssumeRole", ROLE_ARN, invalidSession, 3600, "2011-06-15", null)); + + assertEquals(400, ex.getHttpCode()); + + final String requestId = "test-request-id"; + ex.setRequestId(requestId); + assertStsErrorXml(ex.toXml(), STS_NS, "Sender", "ValidationError", "Invalid RoleSessionName"); + } + + @Test + public void testStsInvalidRoleArnResourceType() throws Exception { + // Resource type must be role, not user + final String invalidRoleArn = "arn:aws:iam::123456789012:user/test-user"; + final OSTSException ex = assertThrows(OSTSException.class, () -> + endpoint.get("AssumeRole", invalidRoleArn, ROLE_SESSION_NAME, 3600, "2011-06-15", null)); + + assertEquals(400, ex.getHttpCode()); + + final String requestId = "test-request-id"; + ex.setRequestId(requestId); + assertStsErrorXml(ex.toXml(), STS_NS, "Sender", "ValidationError", "Invalid RoleArn: must be in the format"); + } + + @Test + public void testStsInternalFailureWhenBackendThrows() throws Exception { + when(objectStore.assumeRole(anyString(), anyString(), anyInt(), any())) + .thenThrow(new RuntimeException("some unexpected error")); + + final OSTSException ex = assertThrows(OSTSException.class, () -> + endpoint.get("AssumeRole", ROLE_ARN, ROLE_SESSION_NAME, 3600, "2011-06-15", null)); + + assertEquals(500, ex.getHttpCode()); + + final String requestId = "test-request-id"; + ex.setRequestId(requestId); + assertStsErrorXml(ex.toXml(), STS_NS, "Receiver", "InternalFailure", "An internal error has occurred."); + } + + @Test + public void testStsAccessDenied() throws Exception { + when(objectStore.assumeRole(anyString(), anyString(), anyInt(), any())) + .thenThrow(new OMException("Permission denied", OMException.ResultCodes.ACCESS_DENIED)); + + final OSTSException ex = assertThrows(OSTSException.class, () -> + endpoint.get("AssumeRole", ROLE_ARN, ROLE_SESSION_NAME, 3600, "2011-06-15", null)); + + assertEquals(403, ex.getHttpCode()); + + final String requestId = "test-request-id"; + ex.setRequestId(requestId); + assertStsErrorXml(ex.toXml(), STS_NS, "Sender", "AccessDenied", + "User is not authorized to perform: sts:AssumeRole on resource: " + ROLE_ARN); + } + + @Test + public void testStsIOExceptionWrappedAsInternalFailure() throws Exception { + when(objectStore.assumeRole(anyString(), anyString(), anyInt(), any())) + .thenThrow(new IOException("An IO error occurred")); + + final OSTSException ex = assertThrows(OSTSException.class, () -> + endpoint.get("AssumeRole", ROLE_ARN, ROLE_SESSION_NAME, 3600, "2011-06-15", null)); + + assertEquals(500, ex.getHttpCode()); + + final String requestId = "test-request-id"; + ex.setRequestId(requestId); + assertStsErrorXml(ex.toXml(), STS_NS, "Receiver", "InternalFailure", "An internal error has occurred."); + } + + private static Document parseXml(String xml) throws Exception { + final DocumentBuilderFactory documentBuilderFactory = DocumentBuilderFactory.newInstance(); + documentBuilderFactory.setNamespaceAware(true); + final DocumentBuilder documentBuilder = documentBuilderFactory.newDocumentBuilder(); + return documentBuilder.parse(new InputSource(new StringReader(xml))); + } + + private static void assertStsErrorXml(String xml, String expectedNamespace, String expectedType, String expectedCode, + String expectedMessageContains) throws Exception { + final Document doc = parseXml(xml); + final Element root = doc.getDocumentElement(); + assertEquals("ErrorResponse", root.getLocalName()); + assertEquals(expectedNamespace, root.getNamespaceURI()); + + final String type = doc.getElementsByTagName("Type").item(0).getTextContent(); + assertEquals(expectedType, type); + + final String code = doc.getElementsByTagName("Code").item(0).getTextContent(); + assertEquals(expectedCode, code); + + final String message = doc.getElementsByTagName("Message").item(0).getTextContent(); + assertNotNull(message); + assertTrue(message.contains(expectedMessageContains), "Expected message to contain: " + expectedMessageContains); + + final String requestId = doc.getElementsByTagName("RequestId").item(0).getTextContent(); + assertEquals("test-request-id", requestId); } } From 0f3b26b1b29b49cf1210b7896aa1895168884703 Mon Sep 17 00:00:00 2001 From: fmorg-git Date: Fri, 30 Jan 2026 07:52:39 -0800 Subject: [PATCH 23/54] HDDS-14420. [STS] Add audit logging to endpoint and OzoneManager for STS (#9687) --- .../hadoop/ozone/client/ObjectStore.java | 5 +- .../ozone/client/protocol/ClientProtocol.java | 3 +- .../hadoop/ozone/client/rpc/RpcClient.java | 4 +- .../hadoop/ozone/om/helpers/S3STSUtils.java | 44 +++++++ .../om/protocol/OzoneManagerProtocol.java | 3 +- ...ManagerProtocolClientSideTranslatorPB.java | 5 +- .../src/main/proto/OmClientProtocol.proto | 1 + .../ozone/om/helpers/OMAuditLogger.java | 2 + .../s3/security/S3AssumeRoleRequest.java | 81 +++++++----- .../s3/security/TestS3AssumeRoleRequest.java | 105 ++++++++++----- .../apache/hadoop/ozone/audit/S3GAction.java | 5 +- .../ozone/s3/S3STSHeadersResponseFilter.java | 49 +++++++ .../hadoop/ozone/s3sts/Application.java | 3 + .../hadoop/ozone/s3sts/S3STSEndpoint.java | 123 ++++++++++++------ .../hadoop/ozone/s3sts/S3STSEndpointBase.java | 26 +++- .../ozone/client/ClientProtocolStub.java | 8 +- .../hadoop/ozone/s3sts/TestS3STSEndpoint.java | 73 +++++++++-- 17 files changed, 415 insertions(+), 125 deletions(-) create mode 100644 hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/om/helpers/S3STSUtils.java create mode 100644 hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/S3STSHeadersResponseFilter.java diff --git a/hadoop-ozone/client/src/main/java/org/apache/hadoop/ozone/client/ObjectStore.java b/hadoop-ozone/client/src/main/java/org/apache/hadoop/ozone/client/ObjectStore.java index 4e6e1b0ae9de..f423979bde2d 100644 --- a/hadoop-ozone/client/src/main/java/org/apache/hadoop/ozone/client/ObjectStore.java +++ b/hadoop-ozone/client/src/main/java/org/apache/hadoop/ozone/client/ObjectStore.java @@ -758,12 +758,13 @@ public Iterator listSnapshotDiffJobs( * @param roleSessionName The session name (should be unique) for this operation * @param durationSeconds The duration in seconds for the token validity * @param awsIamSessionPolicy The AWS IAM JSON session policy + * @param requestId The requestId from the STS endpoint * @return AssumeRoleResponseInfo The AssumeRole response information containing temporary credentials * @throws IOException if an error occurs during the AssumeRole operation */ public AssumeRoleResponseInfo assumeRole(String roleArn, String roleSessionName, int durationSeconds, - String awsIamSessionPolicy) throws IOException { - return proxy.assumeRole(roleArn, roleSessionName, durationSeconds, awsIamSessionPolicy); + String awsIamSessionPolicy, String requestId) throws IOException { + return proxy.assumeRole(roleArn, roleSessionName, durationSeconds, awsIamSessionPolicy, requestId); } /** diff --git a/hadoop-ozone/client/src/main/java/org/apache/hadoop/ozone/client/protocol/ClientProtocol.java b/hadoop-ozone/client/src/main/java/org/apache/hadoop/ozone/client/protocol/ClientProtocol.java index 88c282856164..96e8b654474e 100644 --- a/hadoop-ozone/client/src/main/java/org/apache/hadoop/ozone/client/protocol/ClientProtocol.java +++ b/hadoop-ozone/client/src/main/java/org/apache/hadoop/ozone/client/protocol/ClientProtocol.java @@ -1367,11 +1367,12 @@ void deleteObjectTagging(String volumeName, String bucketName, String keyName) * @param roleSessionName The session name (should be unique) for this operation * @param durationSeconds The duration in seconds for the token validity * @param awsIamSessionPolicy The AWS IAM JSON session policy + * @param requestId The requestId from the STS endpoint * @return AssumeRoleResponseInfo The AssumeRole response information containing temporary credentials * @throws IOException if an error occurs during the AssumeRole operation */ AssumeRoleResponseInfo assumeRole(String roleArn, String roleSessionName, int durationSeconds, - String awsIamSessionPolicy) throws IOException; + String awsIamSessionPolicy, String requestId) throws IOException; /** * Revokes an STS token. diff --git a/hadoop-ozone/client/src/main/java/org/apache/hadoop/ozone/client/rpc/RpcClient.java b/hadoop-ozone/client/src/main/java/org/apache/hadoop/ozone/client/rpc/RpcClient.java index 67e2ac203bfd..ef01ebd87e54 100644 --- a/hadoop-ozone/client/src/main/java/org/apache/hadoop/ozone/client/rpc/RpcClient.java +++ b/hadoop-ozone/client/src/main/java/org/apache/hadoop/ozone/client/rpc/RpcClient.java @@ -2793,8 +2793,8 @@ public void deleteObjectTagging(String volumeName, String bucketName, @Override public AssumeRoleResponseInfo assumeRole(String roleArn, String roleSessionName, int durationSeconds, - String awsIamSessionPolicy) throws IOException { - return ozoneManagerClient.assumeRole(roleArn, roleSessionName, durationSeconds, awsIamSessionPolicy); + String awsIamSessionPolicy, String requestId) throws IOException { + return ozoneManagerClient.assumeRole(roleArn, roleSessionName, durationSeconds, awsIamSessionPolicy, requestId); } @Override 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..8d261e6c68e7 --- /dev/null +++ b/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/om/helpers/S3STSUtils.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.helpers; + +import com.google.common.base.Strings; +import java.util.Map; + +/** + * Utility class containing constants and validation methods shared by STS endpoint and OzoneManager processing. + */ +public final class S3STSUtils { + + private S3STSUtils() { + } + + /** + * Adds standard AssumeRole audit params. + */ + public static void addAssumeRoleAuditParams(Map auditParams, String roleArn, String roleSessionName, + String awsIamSessionPolicy, int duration, String requestId) { + + auditParams.put("action", "AssumeRole"); + auditParams.put("roleArn", roleArn); + auditParams.put("roleSessionName", roleSessionName); + auditParams.put("duration", String.valueOf(duration)); + auditParams.put("isPolicyIncluded", Strings.isNullOrEmpty(awsIamSessionPolicy) ? "N" : "Y"); + auditParams.put("requestId", requestId); + } +} diff --git a/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/om/protocol/OzoneManagerProtocol.java b/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/om/protocol/OzoneManagerProtocol.java index 2661bc82366e..5e4dafb925bc 100644 --- a/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/om/protocol/OzoneManagerProtocol.java +++ b/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/om/protocol/OzoneManagerProtocol.java @@ -1184,11 +1184,12 @@ default void deleteObjectTagging(OmKeyArgs args) throws IOException { * @param roleSessionName The session name (should be unique) for this operation * @param durationSeconds The duration in seconds for the token validity * @param awsIamSessionPolicy The AWS IAM JSON session policy + * @param requestId The requestId from the STS endpoint * @return AssumeRoleResponseInfo The AssumeRole response information containing temporary credentials * @throws IOException if an error occurs during the AssumeRole operation */ default AssumeRoleResponseInfo assumeRole(String roleArn, String roleSessionName, int durationSeconds, - String awsIamSessionPolicy) throws IOException { + String awsIamSessionPolicy, String requestId) throws IOException { throw new UnsupportedOperationException("OzoneManager does not require this to be implemented"); } diff --git a/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/om/protocolPB/OzoneManagerProtocolClientSideTranslatorPB.java b/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/om/protocolPB/OzoneManagerProtocolClientSideTranslatorPB.java index 8bbc3320dc1a..8b778ae95d07 100644 --- a/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/om/protocolPB/OzoneManagerProtocolClientSideTranslatorPB.java +++ b/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/om/protocolPB/OzoneManagerProtocolClientSideTranslatorPB.java @@ -2659,13 +2659,14 @@ public void deleteObjectTagging(OmKeyArgs args) throws IOException { @Override public AssumeRoleResponseInfo assumeRole(String roleArn, String roleSessionName, int durationSeconds, - String awsIamSessionPolicy) throws IOException { + String awsIamSessionPolicy, String requestId) throws IOException { final OzoneManagerProtocolProtos.AssumeRoleRequest.Builder request = OzoneManagerProtocolProtos.AssumeRoleRequest.newBuilder() .setRoleArn(roleArn) .setRoleSessionName(roleSessionName) .setDurationSeconds(durationSeconds) - .setAwsIamSessionPolicy(awsIamSessionPolicy != null ? awsIamSessionPolicy : ""); + .setAwsIamSessionPolicy(awsIamSessionPolicy != null ? awsIamSessionPolicy : "") + .setRequestId(requestId); final OMRequest omRequest = createOMRequest(Type.AssumeRole) .setAssumeRoleRequest(request) diff --git a/hadoop-ozone/interface-client/src/main/proto/OmClientProtocol.proto b/hadoop-ozone/interface-client/src/main/proto/OmClientProtocol.proto index 00892f79a719..707f8ac567d0 100644 --- a/hadoop-ozone/interface-client/src/main/proto/OmClientProtocol.proto +++ b/hadoop-ozone/interface-client/src/main/proto/OmClientProtocol.proto @@ -2379,6 +2379,7 @@ message AssumeRoleRequest { required string roleSessionName = 2; optional int32 durationSeconds = 3 [default = 3600]; optional string awsIamSessionPolicy = 4; + required string requestId = 5; } message AssumeRoleResponse { diff --git a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/helpers/OMAuditLogger.java b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/helpers/OMAuditLogger.java index e6185f3d65a0..2c17d2335475 100644 --- a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/helpers/OMAuditLogger.java +++ b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/helpers/OMAuditLogger.java @@ -95,6 +95,8 @@ private static void init() { CMD_AUDIT_ACTION_MAP.put(Type.GetObjectTagging, OMAction.GET_OBJECT_TAGGING); CMD_AUDIT_ACTION_MAP.put(Type.PutObjectTagging, OMAction.PUT_OBJECT_TAGGING); CMD_AUDIT_ACTION_MAP.put(Type.DeleteObjectTagging, OMAction.DELETE_OBJECT_TAGGING); + CMD_AUDIT_ACTION_MAP.put(Type.AssumeRole, OMAction.S3_ASSUME_ROLE); + CMD_AUDIT_ACTION_MAP.put(Type.RevokeSTSToken, OMAction.REVOKE_STS_TOKEN); } private static OMAction getAction(OzoneManagerProtocolProtos.OMRequest request) { 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..1b00454b70cb 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 @@ -24,19 +24,25 @@ import java.net.InetAddress; import java.security.SecureRandom; import java.time.Clock; +import java.util.HashMap; +import java.util.Map; import java.util.Optional; import java.util.Set; import org.apache.commons.lang3.StringUtils; import org.apache.hadoop.hdds.scm.client.HddsClientUtils; import org.apache.hadoop.ipc.ProtobufRpcEngine; +import org.apache.hadoop.ozone.audit.AuditLogger; +import org.apache.hadoop.ozone.audit.OMAction; 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.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; import org.apache.hadoop.ozone.om.response.s3.security.S3AssumeRoleResponse; +import org.apache.hadoop.ozone.protocol.proto.OzoneManagerProtocolProtos; 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; @@ -89,33 +95,39 @@ public OMClientResponse validateAndUpdateCache(OzoneManager ozoneManager, Execut 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(); + final String awsIamSessionPolicy = assumeRoleRequest.getAwsIamSessionPolicy(); + final String requestId = assumeRoleRequest.getRequestId(); + + final Map auditMap = new HashMap<>(); + // In HA environments, only the tempAccessKeyId on the leader is used by S3G, so it could be helpful to + // have the leader information + auditMap.put("omRole", ozoneManager.isLeaderReady() ? "LEADER" : "FOLLOWER"); + final AuditLogger auditLogger = ozoneManager.getAuditLogger(); + final OzoneManagerProtocolProtos.UserInfo userInfo = omRequest.getUserInfo(); + S3STSUtils.addAssumeRoleAuditParams( + auditMap, roleArn, roleSessionName, awsIamSessionPolicy, durationSeconds, requestId); + + Exception exception = null; + OMClientResponse omClientResponse; try { + // Validate duration + if (durationSeconds < MIN_TOKEN_EXPIRATION_SECONDS || durationSeconds > MAX_TOKEN_EXPIRATION_SECONDS) { + throw new OMException( + "Duration must be between " + MIN_TOKEN_EXPIRATION_SECONDS + " and " + MAX_TOKEN_EXPIRATION_SECONDS, + OMException.ResultCodes.INVALID_REQUEST); + } + + // Validate role session name + validateRoleSessionName(roleSessionName); + // 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)); + throw new OMException( + "S3AssumeRoleRequest does not have S3 authentication", OMException.ResultCodes.INVALID_REQUEST); } // Generate temporary AWS credentials using cryptographically strong SecureRandom @@ -134,6 +146,9 @@ public OMClientResponse validateAndUpdateCache(OzoneManager ozoneManager, Execut // Calculate expiration of session token final long expirationEpochSeconds = clock.instant().plusSeconds(durationSeconds).getEpochSecond(); + // Add tempAccessKeyId to the log so it can be determined which permanent user created the tempAccessKeyId + auditMap.put("tempAccessKeyId", tempAccessKeyId); + final AssumeRoleResponse.Builder responseBuilder = AssumeRoleResponse.newBuilder() .setAccessKeyId(tempAccessKeyId) .setSecretAccessKey(secretAccessKey) @@ -141,39 +156,41 @@ public OMClientResponse validateAndUpdateCache(OzoneManager ozoneManager, Execut .setExpirationEpochSeconds(expirationEpochSeconds) .setAssumedRoleId(assumedRoleId); - return new S3AssumeRoleResponse( + omClientResponse = new S3AssumeRoleResponse( OmResponseUtil.getOMResponseBuilder(omRequest) .setAssumeRoleResponse(responseBuilder.build()) .build()); } catch (OMException e) { - return new S3AssumeRoleResponse(createErrorOMResponse(OmResponseUtil.getOMResponseBuilder(omRequest), e)); + exception = e; + omClientResponse = 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( + exception = omException; + omClientResponse = new S3AssumeRoleResponse( createErrorOMResponse(OmResponseUtil.getOMResponseBuilder(omRequest), omException)); } + + // Audit log + markForAudit(auditLogger, buildAuditMessage(OMAction.S3_ASSUME_ROLE, auditMap, exception, userInfo)); + + return omClientResponse; } /** * Ensures RoleSessionName is valid. */ - private S3AssumeRoleResponse validateRoleSessionName(String roleSessionName, OMRequest omRequest) { + private void validateRoleSessionName(String roleSessionName) throws OMException { if (StringUtils.isBlank(roleSessionName)) { - final OMException omException = new OMException( - "RoleSessionName is required", OMException.ResultCodes.INVALID_REQUEST); - return new S3AssumeRoleResponse( - createErrorOMResponse(OmResponseUtil.getOMResponseBuilder(omRequest), omException)); + throw new OMException("RoleSessionName is required", OMException.ResultCodes.INVALID_REQUEST); } if (roleSessionName.length() < ASSUME_ROLE_SESSION_NAME_MIN_LENGTH || roleSessionName.length() > ASSUME_ROLE_SESSION_NAME_MAX_LENGTH) { - final OMException omException = new OMException( + throw 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; } /** 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..bda871386fed 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 @@ -23,6 +23,7 @@ import static org.mockito.Mockito.mock; import static org.mockito.Mockito.mockStatic; import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -42,9 +43,12 @@ import org.apache.hadoop.hdds.conf.OzoneConfiguration; import org.apache.hadoop.hdds.security.symmetric.ManagedSecretKey; import org.apache.hadoop.hdds.security.symmetric.SecretKeySignerClient; +import org.apache.hadoop.ozone.audit.AuditLogger; +import org.apache.hadoop.ozone.audit.AuditMessage; import org.apache.hadoop.ozone.om.OMMultiTenantManager; import org.apache.hadoop.ozone.om.OzoneManager; import org.apache.hadoop.ozone.om.execution.flowcontrol.ExecutionContext; +import org.apache.hadoop.ozone.om.helpers.OMAuditLogger; 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; @@ -87,14 +91,21 @@ public class TestS3AssumeRoleRequest { private static final String OM_HOST = "om-host"; private static final InetAddress LOOPBACK_IP = InetAddress.getLoopbackAddress(); private static final Set EMPTY_GRANTS = Collections.singleton(new OzoneGrant(emptySet(), emptySet())); + private static final String REQUEST_ID = UUID.randomUUID().toString(); + + private static final Pattern ABC_PATTERN_32 = Pattern.compile("^[ABC]{32}$"); + private static final Pattern XYZ_PATTERN = Pattern.compile("^[XYZ]$"); private OzoneManager ozoneManager; private ExecutionContext context; private IAccessAuthorizer accessAuthorizer; + private AuditLogger auditLogger; @BeforeEach public void setup() throws IOException { ozoneManager = mock(OzoneManager.class); + auditLogger = mock(AuditLogger.class); + when(ozoneManager.getAuditLogger()).thenReturn(auditLogger); final OzoneConfiguration configuration = new OzoneConfiguration(); when(ozoneManager.getConfiguration()).thenReturn(configuration); @@ -135,15 +146,17 @@ public void testInvalidDurationTooShort() { .setRoleArn(ROLE_ARN_1) .setRoleSessionName(SESSION_NAME) .setDurationSeconds(899) // less than 900 + .setRequestId(REQUEST_ID) ).build(); - final OMClientResponse response = new S3AssumeRoleRequest(omRequest, CLOCK) - .validateAndUpdateCache(ozoneManager, context); + final S3AssumeRoleRequest request = new S3AssumeRoleRequest(omRequest, CLOCK); + final OMClientResponse response = request.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(); + assertMarkForAuditCalled(request); } @Test @@ -154,15 +167,17 @@ public void testInvalidDurationTooLong() { .setRoleArn(ROLE_ARN_1) .setRoleSessionName(SESSION_NAME) .setDurationSeconds(43201) // more than 43200 + .setRequestId(REQUEST_ID) ).build(); - final OMClientResponse response = new S3AssumeRoleRequest(omRequest, CLOCK) - .validateAndUpdateCache(ozoneManager, context); + final S3AssumeRoleRequest request = new S3AssumeRoleRequest(omRequest, CLOCK); + final OMClientResponse response = request.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(); + assertMarkForAuditCalled(request); } @Test @@ -173,14 +188,16 @@ public void testValidDurationMaxBoundary() { .setRoleArn(ROLE_ARN_1) .setRoleSessionName(SESSION_NAME) .setDurationSeconds(43200) // exactly max + .setRequestId(REQUEST_ID) ).build(); - final OMClientResponse response = new S3AssumeRoleRequest(omRequest, CLOCK) - .validateAndUpdateCache(ozoneManager, context); + final S3AssumeRoleRequest request = new S3AssumeRoleRequest(omRequest, CLOCK); + final OMClientResponse response = request.validateAndUpdateCache(ozoneManager, context); final OMResponse omResponse = response.getOMResponse(); assertThat(omResponse.getStatus()).isEqualTo(Status.OK); assertThat(omResponse.hasAssumeRoleResponse()).isTrue(); + assertMarkForAuditCalled(request); } @Test @@ -191,14 +208,16 @@ public void testValidDurationMinBoundary() { .setRoleArn(ROLE_ARN_1) .setRoleSessionName(SESSION_NAME) .setDurationSeconds(900) // exactly min + .setRequestId(REQUEST_ID) ).build(); - final OMClientResponse response = new S3AssumeRoleRequest(omRequest, CLOCK) - .validateAndUpdateCache(ozoneManager, context); + final S3AssumeRoleRequest request = new S3AssumeRoleRequest(omRequest, CLOCK); + final OMClientResponse response = request.validateAndUpdateCache(ozoneManager, context); final OMResponse omResponse = response.getOMResponse(); assertThat(omResponse.getStatus()).isEqualTo(Status.OK); assertThat(omResponse.hasAssumeRoleResponse()).isTrue(); + assertMarkForAuditCalled(request); } @Test @@ -211,15 +230,17 @@ public void testMissingS3Authentication() { .setRoleArn(ROLE_ARN_1) .setRoleSessionName(SESSION_NAME) .setDurationSeconds(3600) + .setRequestId(REQUEST_ID) ).build(); - final OMClientResponse response = new S3AssumeRoleRequest(omRequest, CLOCK) - .validateAndUpdateCache(ozoneManager, context); + final S3AssumeRoleRequest request = new S3AssumeRoleRequest(omRequest, CLOCK); + final OMClientResponse response = request.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(); + assertMarkForAuditCalled(request); } @Test @@ -231,10 +252,11 @@ public void testSuccessfulAssumeRoleGeneratesCredentials() { .setRoleArn(ROLE_ARN_1) .setRoleSessionName(SESSION_NAME) .setDurationSeconds(durationSeconds) + .setRequestId(REQUEST_ID) ).build(); - final OMClientResponse clientResponse = new S3AssumeRoleRequest(omRequest, CLOCK) - .validateAndUpdateCache(ozoneManager, context); + final S3AssumeRoleRequest request = new S3AssumeRoleRequest(omRequest, CLOCK); + final OMClientResponse clientResponse = request.validateAndUpdateCache(ozoneManager, context); final OMResponse omResponse = clientResponse.getOMResponse(); assertThat(omResponse.getStatus()).isEqualTo(Status.OK); @@ -260,6 +282,7 @@ public void testSuccessfulAssumeRoleGeneratesCredentials() { // Verify expiration added durationSeconds final long expirationEpochSeconds = assumeRoleResponse.getExpirationEpochSeconds(); assertThat(expirationEpochSeconds).isEqualTo(CLOCK.instant().getEpochSecond() + durationSeconds); + assertMarkForAuditCalled(request); } @Test @@ -268,7 +291,7 @@ public void testGenerateSecureRandomStringUsingChars() { final int length = 32; final String s = S3AssumeRoleRequest.generateSecureRandomStringUsingChars( chars, chars.length(), length); - assertThat(s).hasSize(length).matches(Pattern.compile("^[ABC]{" + length + "}$")); + assertThat(s).hasSize(length).matches(ABC_PATTERN_32); // Test with length 0 final String empty = S3AssumeRoleRequest.generateSecureRandomStringUsingChars( @@ -278,7 +301,7 @@ public void testGenerateSecureRandomStringUsingChars() { // Test with length 1 final String single = S3AssumeRoleRequest.generateSecureRandomStringUsingChars( "XYZ", 3, 1); - assertThat(single).hasSize(1).matches(Pattern.compile("^[XYZ]$")); + assertThat(single).hasSize(1).matches(XYZ_PATTERN); } @Test @@ -290,12 +313,13 @@ public void testAssumeRoleCredentialsAreUnique() { .setRoleArn(ROLE_ARN_1) .setRoleSessionName(SESSION_NAME) .setDurationSeconds(3600) + .setRequestId(REQUEST_ID) ).build(); - final OMClientResponse response1 = new S3AssumeRoleRequest(omRequest, CLOCK) - .validateAndUpdateCache(ozoneManager, context); - final OMClientResponse response2 = new S3AssumeRoleRequest(omRequest, CLOCK) - .validateAndUpdateCache(ozoneManager, context); + final S3AssumeRoleRequest request1 = new S3AssumeRoleRequest(omRequest, CLOCK); + final OMClientResponse response1 = request1.validateAndUpdateCache(ozoneManager, context); + final S3AssumeRoleRequest request2 = new S3AssumeRoleRequest(omRequest, CLOCK); + final OMClientResponse response2 = request2.validateAndUpdateCache(ozoneManager, context); final AssumeRoleResponse assumeRoleResponse1 = response1.getOMResponse().getAssumeRoleResponse(); final AssumeRoleResponse assumeRoleResponse2 = response2.getOMResponse().getAssumeRoleResponse(); @@ -311,6 +335,10 @@ public void testAssumeRoleCredentialsAreUnique() { // Different assumed role IDs assertThat(assumeRoleResponse1.getAssumedRoleId()).isNotEqualTo(assumeRoleResponse2.getAssumedRoleId()); + + OMAuditLogger.log(request1.getAuditBuilder()); + OMAuditLogger.log(request2.getAuditBuilder()); + verify(auditLogger, times(2)).logWrite(any(AuditMessage.class)); } @Test @@ -321,12 +349,14 @@ public void testAssumeRoleWithEmptySessionName() { .setRoleArn(ROLE_ARN_1) .setRoleSessionName("") .setDurationSeconds(3600) + .setRequestId(REQUEST_ID) ).build(); - final OMClientResponse response = new S3AssumeRoleRequest(omRequest, CLOCK) - .validateAndUpdateCache(ozoneManager, context); + final S3AssumeRoleRequest request = new S3AssumeRoleRequest(omRequest, CLOCK); + final OMClientResponse response = request.validateAndUpdateCache(ozoneManager, context); assertThat(response.getOMResponse().getStatus()).isEqualTo(Status.INVALID_REQUEST); assertThat(response.getOMResponse().getMessage()).isEqualTo("RoleSessionName is required"); + assertMarkForAuditCalled(request); } @Test @@ -336,15 +366,17 @@ public void testInvalidAssumeRoleSessionNameTooShort() { AssumeRoleRequest.newBuilder() .setRoleArn(ROLE_ARN_1) .setRoleSessionName("T") // Less than 2 characters + .setRequestId(REQUEST_ID) ).build(); - final OMClientResponse response = new S3AssumeRoleRequest(omRequest, CLOCK) - .validateAndUpdateCache(ozoneManager, context); + final S3AssumeRoleRequest request = new S3AssumeRoleRequest(omRequest, CLOCK); + final OMClientResponse response = request.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(); + assertMarkForAuditCalled(request); } @Test @@ -355,15 +387,17 @@ public void testInvalidRoleSessionNameTooLong() { AssumeRoleRequest.newBuilder() .setRoleArn(ROLE_ARN_1) .setRoleSessionName(tooLongRoleSessionName) + .setRequestId(REQUEST_ID) ).build(); - final OMClientResponse response = new S3AssumeRoleRequest(omRequest, CLOCK) - .validateAndUpdateCache(ozoneManager, context); + final S3AssumeRoleRequest request = new S3AssumeRoleRequest(omRequest, CLOCK); + final OMClientResponse response = request.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(); + assertMarkForAuditCalled(request); } @Test @@ -374,14 +408,16 @@ public void testValidRoleSessionNameMaxLengthBoundary() { AssumeRoleRequest.newBuilder() .setRoleArn(ROLE_ARN_1) .setRoleSessionName(roleSessionName) // exactly max length + .setRequestId(REQUEST_ID) ).build(); - final OMClientResponse response = new S3AssumeRoleRequest(omRequest, CLOCK) - .validateAndUpdateCache(ozoneManager, context); + final S3AssumeRoleRequest request = new S3AssumeRoleRequest(omRequest, CLOCK); + final OMClientResponse response = request.validateAndUpdateCache(ozoneManager, context); final OMResponse omResponse = response.getOMResponse(); assertThat(omResponse.getStatus()).isEqualTo(Status.OK); assertThat(omResponse.hasAssumeRoleResponse()).isTrue(); + assertMarkForAuditCalled(request); } @Test @@ -391,14 +427,16 @@ public void testValidRoleSessionNameMinLengthBoundary() { AssumeRoleRequest.newBuilder() .setRoleArn(ROLE_ARN_1) .setRoleSessionName("TT") // exactly min length + .setRequestId(REQUEST_ID) ).build(); - final OMClientResponse response = new S3AssumeRoleRequest(omRequest, CLOCK) - .validateAndUpdateCache(ozoneManager, context); + final S3AssumeRoleRequest request = new S3AssumeRoleRequest(omRequest, CLOCK); + final OMClientResponse response = request.validateAndUpdateCache(ozoneManager, context); final OMResponse omResponse = response.getOMResponse(); assertThat(omResponse.getStatus()).isEqualTo(Status.OK); assertThat(omResponse.hasAssumeRoleResponse()).isTrue(); + assertMarkForAuditCalled(request); } @Test @@ -411,11 +449,13 @@ public void testAssumeRoleWithSessionPolicyPresent() { .setRoleSessionName(SESSION_NAME) .setDurationSeconds(3600) .setAwsIamSessionPolicy(sessionPolicy) + .setRequestId(REQUEST_ID) ).build(); - final OMClientResponse response = new S3AssumeRoleRequest(omRequest, CLOCK) - .validateAndUpdateCache(ozoneManager, context); + final S3AssumeRoleRequest request = new S3AssumeRoleRequest(omRequest, CLOCK); + final OMClientResponse response = request.validateAndUpdateCache(ozoneManager, context); assertThat(response.getOMResponse().getStatus()).isEqualTo(Status.OK); + assertMarkForAuditCalled(request); } @Test @@ -530,6 +570,11 @@ private static OMRequest.Builder baseOmRequestBuilder() { .setAccessId(ORIGINAL_ACCESS_KEY_ID) ); } + + private void assertMarkForAuditCalled(S3AssumeRoleRequest request) { + OMAuditLogger.log(request.getAuditBuilder()); + verify(auditLogger).logWrite(any(AuditMessage.class)); + } } diff --git a/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/audit/S3GAction.java b/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/audit/S3GAction.java index 6c295b7aafc7..991e0be15f90 100644 --- a/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/audit/S3GAction.java +++ b/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/audit/S3GAction.java @@ -53,7 +53,10 @@ public enum S3GAction implements AuditAction { GET_OBJECT_TAGGING, PUT_OBJECT_TAGGING, DELETE_OBJECT_TAGGING, - PUT_OBJECT_ACL; + PUT_OBJECT_ACL, + + // STS endpoint + ASSUME_ROLE; @Override public String getAction() { diff --git a/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/S3STSHeadersResponseFilter.java b/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/S3STSHeadersResponseFilter.java new file mode 100644 index 000000000000..675f3139a299 --- /dev/null +++ b/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/S3STSHeadersResponseFilter.java @@ -0,0 +1,49 @@ +/* + * 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.s3; + +import java.io.IOException; +import javax.inject.Inject; +import javax.ws.rs.container.ContainerRequestContext; +import javax.ws.rs.container.ContainerResponseContext; +import javax.ws.rs.container.ContainerResponseFilter; +import javax.ws.rs.ext.Provider; +import org.apache.hadoop.ozone.s3sts.S3STSEnabled; + +/** + * This class adds common header responses for STS requests. + */ +@Provider +@S3STSEnabled +public class S3STSHeadersResponseFilter implements ContainerResponseFilter { + + @Inject + private RequestIdentifier requestIdentifier; + + @Override + public void filter(ContainerRequestContext containerRequestContext, + ContainerResponseContext containerResponseContext) throws IOException { + + // Add STS-specific headers + containerResponseContext.getHeaders().add("Server", "Ozone"); + containerResponseContext.getHeaders() + .add("X-Amz-Sts-Extended-Request-Id", requestIdentifier.getAmzId()); + containerResponseContext.getHeaders() + .add("x-amzn-RequestId", requestIdentifier.getRequestId()); + } +} diff --git a/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3sts/Application.java b/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3sts/Application.java index 1605532db1c5..b4db14dfa611 100644 --- a/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3sts/Application.java +++ b/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3sts/Application.java @@ -17,6 +17,7 @@ package org.apache.hadoop.ozone.s3sts; +import org.apache.hadoop.ozone.s3.S3STSHeadersResponseFilter; import org.apache.hadoop.ozone.s3.exception.OSTSExceptionMapper; import org.glassfish.jersey.server.ResourceConfig; @@ -27,6 +28,8 @@ public class Application extends ResourceConfig { public Application() { packages("org.apache.hadoop.ozone.s3sts"); register(org.apache.hadoop.ozone.s3.AuthorizationFilter.class); + register(org.apache.hadoop.ozone.s3.ClientIpFilter.class); register(OSTSExceptionMapper.class); + register(S3STSHeadersResponseFilter.class); } } 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 9d6b1b8d77f7..62bef03586a5 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 @@ -22,13 +22,15 @@ import static javax.ws.rs.core.Response.Status.INTERNAL_SERVER_ERROR; import static javax.ws.rs.core.Response.Status.NOT_IMPLEMENTED; +import com.google.common.annotations.VisibleForTesting; import com.google.common.base.Strings; import java.io.IOException; import java.io.StringWriter; import java.time.Instant; import java.time.ZoneOffset; import java.time.format.DateTimeFormatter; -import java.util.UUID; +import java.util.Map; +import javax.inject.Inject; import javax.ws.rs.FormParam; import javax.ws.rs.GET; import javax.ws.rs.POST; @@ -40,8 +42,11 @@ import javax.xml.bind.JAXBContext; import javax.xml.bind.JAXBException; import javax.xml.bind.Marshaller; +import org.apache.hadoop.ozone.audit.S3GAction; import org.apache.hadoop.ozone.om.exceptions.OMException; import org.apache.hadoop.ozone.om.helpers.AssumeRoleResponseInfo; +import org.apache.hadoop.ozone.om.helpers.S3STSUtils; +import org.apache.hadoop.ozone.s3.RequestIdentifier; import org.apache.hadoop.ozone.s3.exception.OS3Exception; import org.apache.hadoop.ozone.s3.exception.OSTSException; import org.slf4j.Logger; @@ -75,6 +80,11 @@ public class S3STSEndpoint extends S3STSEndpointBase { private static final String GET_ACCESS_KEY_INFO_ACTION = "GetAccessKeyInfo"; private static final String EXPECTED_VERSION = "2011-06-15"; + private static final String VALIDATION_ERROR = "ValidationError"; + private static final String INVALID_ACTION = "InvalidAction"; + private static final String INTERNAL_FAILURE = "InternalFailure"; + private static final String ACCESS_DENIED = "AccessDenied"; + private static final String INVALID_CLIENT_TOKEN_ID = "InvalidClientTokenId"; // 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 @@ -83,6 +93,14 @@ public class S3STSEndpoint extends S3STSEndpointBase { private static final int MIN_DURATION_SECONDS = 900; // 15 minutes private static final int MAX_SESSION_POLICY_SIZE = 2048; + @Inject + private RequestIdentifier requestIdentifier; + + @VisibleForTesting + public void setRequestIdentifier(RequestIdentifier requestIdentifier) { + this.requestIdentifier = requestIdentifier; + } + /** * STS endpoint that handles GET requests with query parameters. * AWS STS supports both GET and POST requests. @@ -133,7 +151,8 @@ public Response post( private Response handleSTSRequest(String action, String roleArn, String roleSessionName, Integer durationSeconds, String version, String awsIamSessionPolicy) throws OS3Exception { - final String requestId = UUID.randomUUID().toString(); + final String requestId = requestIdentifier.getRequestId(); + // NOTE: invalid, missing or unsupported actions are not added to the audit log try { if (action == null) { // Amazon STS has a different structure for the XML error response when the action is missing @@ -154,10 +173,10 @@ private Response handleSTSRequest(String action, String roleArn, String roleSess case DECODE_AUTHORIZATION_MESSAGE_ACTION: case GET_ACCESS_KEY_INFO_ACTION: throw new OSTSException( - "InvalidAction", "Operation " + action + " is not supported yet.", NOT_IMPLEMENTED.getStatusCode()); + INVALID_ACTION, "Operation " + action + " is not supported yet.", NOT_IMPLEMENTED.getStatusCode()); default: throw new OSTSException( - "InvalidAction", "Could not find operation " + action + " for version " + + INVALID_ACTION, "Could not find operation " + action + " for version " + (version == null ? "NO_VERSION_SPECIFIED. Expected version is: " + EXPECTED_VERSION : version), BAD_REQUEST.getStatusCode()); } @@ -166,107 +185,136 @@ private Response handleSTSRequest(String action, String roleArn, String roleSess } catch (Exception ex) { LOG.error("Unexpected error during STS request", ex); throw new OSTSException( - "InternalFailure", "An internal error has occurred.", INTERNAL_SERVER_ERROR.getStatusCode(), "Receiver"); - } - } - - private int validateDuration(Integer durationSeconds) throws IllegalArgumentException { - 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"); + INTERNAL_FAILURE, "An internal error has occurred.", INTERNAL_SERVER_ERROR.getStatusCode(), "Receiver"); } - - return durationSeconds; } private Response handleAssumeRole(String roleArn, String roleSessionName, Integer durationSeconds, String awsIamSessionPolicy, String version, String requestId) throws OSTSException { - // Validate parameters final String action = "AssumeRole"; + final Map auditParams = getAuditParameters(); + S3STSUtils.addAssumeRoleAuditParams( + auditParams, roleArn, roleSessionName, awsIamSessionPolicy, + durationSeconds == null ? DEFAULT_DURATION_SECONDS : durationSeconds, + requestId); + int duration; try { + // Validate parameters duration = validateDuration(durationSeconds); } catch (IllegalArgumentException e) { - throw new OSTSException("ValidationError", e.getMessage(), BAD_REQUEST.getStatusCode()); + final OSTSException exception = new OSTSException(VALIDATION_ERROR, e.getMessage(), BAD_REQUEST.getStatusCode()); + getAuditLogger().logWriteFailure(buildAuditMessageForFailure(S3GAction.ASSUME_ROLE, auditParams, exception)); + throw exception; } if (version == null || !version.equals(EXPECTED_VERSION)) { - throw new OSTSException( - "InvalidAction", "Could not find operation " + action + " for version " + + final OSTSException exception = new OSTSException( + INVALID_ACTION, "Could not find operation " + action + " for version " + (version == null ? "NO_VERSION_SPECIFIED. Expected version is: " + EXPECTED_VERSION : version), BAD_REQUEST.getStatusCode()); + getAuditLogger().logWriteFailure(buildAuditMessageForFailure(S3GAction.ASSUME_ROLE, auditParams, exception)); + throw exception; } if (roleArn == null || roleArn.isEmpty()) { - throw new OSTSException( - "ValidationError", "Value null at 'roleArn' failed to satisfy constraint: Member must not be null", + final OSTSException exception = new OSTSException( + VALIDATION_ERROR, "Value null at 'roleArn' failed to satisfy constraint: Member must not be null", BAD_REQUEST.getStatusCode()); + getAuditLogger().logWriteFailure(buildAuditMessageForFailure(S3GAction.ASSUME_ROLE, auditParams, exception)); + throw exception; } if (roleSessionName == null || roleSessionName.isEmpty()) { - throw new OSTSException( - "ValidationError", "Value null at 'roleSessionName' failed to satisfy constraint: Member must not be null", + final OSTSException exception = new OSTSException( + VALIDATION_ERROR, "Value null at 'roleSessionName' failed to satisfy constraint: Member must not be null", BAD_REQUEST.getStatusCode()); + getAuditLogger().logWriteFailure(buildAuditMessageForFailure(S3GAction.ASSUME_ROLE, auditParams, exception)); + throw exception; } // Validate role session name format (AWS requirements) if (!isValidRoleSessionName(roleSessionName)) { - throw new OSTSException( - "ValidationError", "Invalid RoleSessionName: must be 2-64 characters long and " + + final OSTSException exception = new OSTSException( + VALIDATION_ERROR, "Invalid RoleSessionName: must be 2-64 characters long and " + "contain only alphanumeric characters, +, =, ,, ., @, -", BAD_REQUEST.getStatusCode()); + getAuditLogger().logWriteFailure(buildAuditMessageForFailure(S3GAction.ASSUME_ROLE, auditParams, exception)); + throw exception; } // Check Policy size if available if (awsIamSessionPolicy != null && awsIamSessionPolicy.length() > MAX_SESSION_POLICY_SIZE) { - throw new OSTSException( - "ValidationError", "Value '" + awsIamSessionPolicy + "' at 'policy' failed to satisfy constraint: Member " + + final OSTSException exception = new OSTSException( + VALIDATION_ERROR, "Value '" + awsIamSessionPolicy + "' at 'policy' failed to satisfy constraint: Member " + "must have length less than or equal to 2048", BAD_REQUEST.getStatusCode()); + getAuditLogger().logWriteFailure(buildAuditMessageForFailure(S3GAction.ASSUME_ROLE, auditParams, exception)); + throw exception; } final String assumedRoleUserArn; try { assumedRoleUserArn = toAssumedRoleUserArn(roleArn, roleSessionName); } catch (IllegalArgumentException e) { - throw new OSTSException("ValidationError", e.getMessage(), BAD_REQUEST.getStatusCode()); + final OSTSException exception = new OSTSException(VALIDATION_ERROR, e.getMessage(), BAD_REQUEST.getStatusCode()); + getAuditLogger().logWriteFailure(buildAuditMessageForFailure(S3GAction.ASSUME_ROLE, auditParams, exception)); + throw exception; } try { final AssumeRoleResponseInfo responseInfo = getClient() .getObjectStore() - .assumeRole(roleArn, roleSessionName, duration, awsIamSessionPolicy); + .assumeRole(roleArn, roleSessionName, duration, awsIamSessionPolicy, requestId); // Generate AssumeRole response final String responseXml = generateAssumeRoleResponse(assumedRoleUserArn, responseInfo, requestId); + + getAuditLogger().logWriteSuccess(buildAuditMessageForSuccess(S3GAction.ASSUME_ROLE, auditParams)); + return Response.ok(responseXml) .header("Content-Type", "text/xml") .build(); } catch (IOException e) { LOG.error("Error during AssumeRole processing", e); + + getAuditLogger().logWriteFailure(buildAuditMessageForFailure(S3GAction.ASSUME_ROLE, auditParams, e)); + if (e instanceof OMException) { final OMException omException = (OMException) e; if (omException.getResult() == OMException.ResultCodes.ACCESS_DENIED || omException.getResult() == OMException.ResultCodes.PERMISSION_DENIED || omException.getResult() == OMException.ResultCodes.TOKEN_EXPIRED) { throw new OSTSException( - "AccessDenied", "User is not authorized to perform: sts:AssumeRole on resource: " + roleArn, + ACCESS_DENIED, "User is not authorized to perform: sts:AssumeRole on resource: " + roleArn, FORBIDDEN.getStatusCode()); } if (omException.getResult() == OMException.ResultCodes.INVALID_TOKEN) { throw new OSTSException( - "InvalidClientTokenId", "The security token included in the request is invalid.", + INVALID_CLIENT_TOKEN_ID, "The security token included in the request is invalid.", FORBIDDEN.getStatusCode()); } } - throw new OSTSException("InternalFailure", "An internal error has occurred.", - INTERNAL_SERVER_ERROR.getStatusCode(), "Receiver"); + throw new OSTSException( + INTERNAL_FAILURE, "An internal error has occurred.", INTERNAL_SERVER_ERROR.getStatusCode(), "Receiver"); + } catch (Exception e) { + getAuditLogger().logWriteFailure(buildAuditMessageForFailure(S3GAction.ASSUME_ROLE, auditParams, e)); + throw e; } } + private int validateDuration(Integer durationSeconds) throws IllegalArgumentException { + 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 boolean isValidRoleSessionName(String roleSessionName) { if (roleSessionName.length() < 2 || roleSessionName.length() > 64) { return false; @@ -335,6 +383,7 @@ private String toAssumedRoleUserArn(String roleArn, String roleSessionName) { } final String roleName = resource.substring("role/".length()); + //noinspection StringBufferReplaceableByString final StringBuilder stringBuilder = new StringBuilder("arn:"); stringBuilder.append(partition); stringBuilder.append(":sts::"); 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 0de5e6c13743..027784b0edc9 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 @@ -48,8 +48,18 @@ public class S3STSEndpointBase implements Auditor { @Inject private SignatureInfo signatureInfo; - protected static final AuditLogger AUDIT = - new AuditLogger(AuditLoggerType.S3GLOGGER); + protected static final AuditLogger DEFAULT_AUDIT = new AuditLogger(AuditLoggerType.S3GLOGGER); + + private AuditLogger auditLogger = DEFAULT_AUDIT; + + protected AuditLogger getAuditLogger() { + return auditLogger; + } + + @VisibleForTesting + public void setAuditLogger(AuditLogger auditLogger) { + this.auditLogger = auditLogger; + } @PostConstruct public void initialization() { @@ -70,6 +80,14 @@ private AuditMessage.Builder auditMessageBaseBuilder(AuditAction op, AuditMessage.Builder builder = new AuditMessage.Builder() .forOperation(op) .withParams(auditMap); + + if (signatureInfo != null) { + String accessId = signatureInfo.getAwsAccessId(); + if (accessId != null && !accessId.isEmpty()) { + builder.setUser(accessId); + } + } + if (context != null) { builder.atIp(AuditUtils.getClientIpAddress(context)); } @@ -111,4 +129,8 @@ public void setContext(ContainerRequestContext context) { public void setSignatureInfo(SignatureInfo signatureInfo) { this.signatureInfo = signatureInfo; } + + protected Map getAuditParameters() { + return AuditUtils.getAuditParameters(context); + } } diff --git a/hadoop-ozone/s3gateway/src/test/java/org/apache/hadoop/ozone/client/ClientProtocolStub.java b/hadoop-ozone/s3gateway/src/test/java/org/apache/hadoop/ozone/client/ClientProtocolStub.java index 477b6876af85..304349f43717 100644 --- a/hadoop-ozone/s3gateway/src/test/java/org/apache/hadoop/ozone/client/ClientProtocolStub.java +++ b/hadoop-ozone/s3gateway/src/test/java/org/apache/hadoop/ozone/client/ClientProtocolStub.java @@ -805,12 +805,8 @@ public void deleteObjectTagging(String volumeName, String bucketName, String key } @Override - public AssumeRoleResponseInfo assumeRole( - String roleArn, - String roleSessionName, - int durationSeconds, - String awsIamSessionPolicy - ) throws IOException { + public AssumeRoleResponseInfo assumeRole(String roleArn, String roleSessionName, int durationSeconds, + String awsIamSessionPolicy, String requestId) throws IOException { return null; } 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 aefb525448b4..34891d089453 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 @@ -26,31 +26,37 @@ import static org.mockito.ArgumentMatchers.anyInt; import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; import static org.mockito.Mockito.spy; import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; import static org.mockito.Mockito.when; import java.io.IOException; import java.io.StringReader; import java.time.Instant; import javax.ws.rs.container.ContainerRequestContext; +import javax.ws.rs.core.MultivaluedHashMap; import javax.ws.rs.core.Response; +import javax.ws.rs.core.UriInfo; 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.audit.AuditLogger; +import org.apache.hadoop.ozone.audit.AuditMessage; 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.exceptions.OMException; import org.apache.hadoop.ozone.om.helpers.AssumeRoleResponseInfo; import org.apache.hadoop.ozone.s3.OzoneConfigurationHolder; +import org.apache.hadoop.ozone.s3.RequestIdentifier; import org.apache.hadoop.ozone.s3.exception.OSTSException; import org.apache.hadoop.ozone.s3.signature.SignatureInfo; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.mockito.ArgumentCaptor; -import org.mockito.Mock; import org.w3c.dom.Document; import org.w3c.dom.Element; import org.xml.sax.InputSource; @@ -61,15 +67,13 @@ public class TestS3STSEndpoint { private S3STSEndpoint endpoint; private ObjectStore objectStore; + private AuditLogger auditLogger; 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; private static final String STS_NS = "https://sts.amazonaws.com/doc/2011-06-15/"; private static final String AWS_FAULT_NS = "http://webservices.amazon.com/AWSFault/2005-15-09"; - @Mock - private ContainerRequestContext context; - @BeforeEach public void setup() throws Exception { OzoneConfiguration config = new OzoneConfiguration(); @@ -77,9 +81,16 @@ public void setup() throws Exception { OzoneConfigurationHolder.setConfiguration(config); OzoneClient clientStub = spy(new OzoneClientStub()); + final ContainerRequestContext context = mock(ContainerRequestContext.class); + final RequestIdentifier requestIdentifier = mock(RequestIdentifier.class); + final UriInfo uriInfo = mock(UriInfo.class); + when(context.getUriInfo()).thenReturn(uriInfo); + when(uriInfo.getPathParameters()).thenReturn(new MultivaluedHashMap<>()); + when(uriInfo.getQueryParameters()).thenReturn(new MultivaluedHashMap<>()); + // Stub assumeRole to return deterministic credentials. objectStore = mock(ObjectStore.class); - when(objectStore.assumeRole(anyString(), anyString(), anyInt(), any())) + when(objectStore.assumeRole(anyString(), anyString(), anyInt(), any(), anyString())) .thenReturn(new AssumeRoleResponseInfo( "ASIA1234567890123456", "mySecretAccessKey", @@ -91,6 +102,12 @@ public void setup() throws Exception { endpoint = new S3STSEndpoint(); endpoint.setClient(clientStub); endpoint.setContext(context); + auditLogger = mock(AuditLogger.class); + endpoint.setAuditLogger(auditLogger); + + when(requestIdentifier.getRequestId()).thenReturn("test-request-id"); + endpoint.setRequestIdentifier(requestIdentifier); + SignatureInfo signatureInfo = new SignatureInfo.Builder(SignatureInfo.Version.V4) .setAwsAccessId("test-user") .setSignature("some-signature") @@ -105,6 +122,8 @@ public void testStsAssumeRoleValidForGetMethod() throws Exception { "AssumeRole", ROLE_ARN, ROLE_SESSION_NAME, 3600, "2011-06-15", null); assertEquals(200, response.getStatus()); + verify(auditLogger).logWriteSuccess(any(AuditMessage.class)); + verify(auditLogger, never()).logWriteFailure(any(AuditMessage.class)); String responseXml = (String) response.getEntity(); assertNotNull(responseXml); @@ -141,6 +160,8 @@ public void testStsAssumeRoleValidForPostMethod() throws Exception { final Response response = endpoint.post("AssumeRole", ROLE_ARN, ROLE_SESSION_NAME, 3600, "2011-06-15", null); assertEquals(200, response.getStatus()); + verify(auditLogger).logWriteSuccess(any(AuditMessage.class)); + verify(auditLogger, never()).logWriteFailure(any(AuditMessage.class)); final String responseXml = (String) response.getEntity(); assertNotNull(responseXml); @@ -174,6 +195,7 @@ public void testStsNullAction() throws Exception { final Response response = endpoint.get(null, ROLE_ARN, ROLE_SESSION_NAME, 3600, "2011-06-15", null); assertEquals(400, response.getStatus()); + verifyNoInteractions(auditLogger); final String errorMessage = (String) response.getEntity(); assertEquals("", errorMessage); @@ -188,6 +210,7 @@ public void testStsUnsupportedActionWithVersionSupplied() throws Exception { endpoint.get("UnsupportedAction", ROLE_ARN, ROLE_SESSION_NAME, 3600, "2011-06-15", null)); assertEquals(400, ex.getHttpCode()); + verifyNoInteractions(auditLogger); final String requestId = "test-request-id"; ex.setRequestId(requestId); @@ -201,6 +224,7 @@ public void testStsUnsupportedActionWithVersionNotSupplied() throws Exception { endpoint.get("UnsupportedAction", ROLE_ARN, ROLE_SESSION_NAME, 3600, null, null)); assertEquals(400, ex.getHttpCode()); + verifyNoInteractions(auditLogger); final String requestId = "test-request-id"; ex.setRequestId(requestId); @@ -214,6 +238,8 @@ public void testStsAssumeRoleWithInvalidVersion() throws Exception { endpoint.get("AssumeRole", ROLE_ARN, ROLE_SESSION_NAME, 3600, "2000-01-01", null)); assertEquals(400, ex.getHttpCode()); + verify(auditLogger).logWriteFailure(any(AuditMessage.class)); + verify(auditLogger, never()).logWriteSuccess(any(AuditMessage.class)); final String requestId = "test-request-id"; ex.setRequestId(requestId); @@ -227,6 +253,8 @@ public void testStsInvalidDuration() throws Exception { endpoint.get("AssumeRole", ROLE_ARN, ROLE_SESSION_NAME, -1, "2011-06-15", null)); assertEquals(400, ex.getHttpCode()); + verify(auditLogger).logWriteFailure(any(AuditMessage.class)); + verify(auditLogger, never()).logWriteSuccess(any(AuditMessage.class)); final String requestId = "test-request-id"; ex.setRequestId(requestId); @@ -238,9 +266,11 @@ public void testStsNullDurationUsesDefault3600() throws Exception { final Response response = endpoint.get( "AssumeRole", ROLE_ARN, ROLE_SESSION_NAME, null, "2011-06-15", null); assertEquals(200, response.getStatus()); + verify(auditLogger).logWriteSuccess(any(AuditMessage.class)); + verify(auditLogger, never()).logWriteFailure(any(AuditMessage.class)); final ArgumentCaptor durationCaptor = ArgumentCaptor.forClass(Integer.class); - verify(objectStore).assumeRole(anyString(), anyString(), durationCaptor.capture(), any()); + verify(objectStore).assumeRole(anyString(), anyString(), durationCaptor.capture(), any(), anyString()); assertEquals(3600, durationCaptor.getValue()); } @@ -252,6 +282,8 @@ public void testStsPolicyTooLarge() throws Exception { endpoint.get("AssumeRole", ROLE_ARN, ROLE_SESSION_NAME, 3600, "2011-06-15", tooLargePolicy)); assertEquals(400, ex.getHttpCode()); + verify(auditLogger).logWriteFailure(any(AuditMessage.class)); + verify(auditLogger, never()).logWriteSuccess(any(AuditMessage.class)); final String requestId = "test-request-id"; ex.setRequestId(requestId); @@ -267,6 +299,8 @@ public void testStsInvalidRoleArn() throws Exception { endpoint.get("AssumeRole", invalidRoleArn, ROLE_SESSION_NAME, 3600, "2011-06-15", null)); assertEquals(400, ex.getHttpCode()); + verify(auditLogger).logWriteFailure(any(AuditMessage.class)); + verify(auditLogger, never()).logWriteSuccess(any(AuditMessage.class)); final String requestId = "test-request-id"; ex.setRequestId(requestId); @@ -280,6 +314,8 @@ public void testStsMissingRoleArn() throws Exception { endpoint.get("AssumeRole", null, ROLE_SESSION_NAME, 3600, "2011-06-15", null)); assertEquals(400, ex.getHttpCode()); + verify(auditLogger).logWriteFailure(any(AuditMessage.class)); + verify(auditLogger, never()).logWriteSuccess(any(AuditMessage.class)); final String requestId = "test-request-id"; ex.setRequestId(requestId); @@ -294,6 +330,8 @@ public void testStsInvalidRoleArnMissingRoleName() throws Exception { assertEquals(400, ex.getHttpCode()); assertEquals("ValidationError", ex.getCode()); + verify(auditLogger).logWriteFailure(any(AuditMessage.class)); + verify(auditLogger, never()).logWriteSuccess(any(AuditMessage.class)); final String requestId = "test-request-id"; ex.setRequestId(requestId); @@ -308,6 +346,8 @@ public void testStsInvalidRoleArnMissingAccountId() throws Exception { assertEquals(400, ex.getHttpCode()); assertEquals("ValidationError", ex.getCode()); + verify(auditLogger).logWriteFailure(any(AuditMessage.class)); + verify(auditLogger, never()).logWriteSuccess(any(AuditMessage.class)); final String requestId = "test-request-id"; ex.setRequestId(requestId); @@ -321,6 +361,7 @@ public void testStsWhenActionNotImplemented() throws Exception { endpoint.get("GetSessionToken", ROLE_ARN, ROLE_SESSION_NAME, 3600, "2011-06-15", null)); assertEquals(501, ex.getHttpCode()); + verifyNoInteractions(auditLogger); final String requestId = "test-request-id"; ex.setRequestId(requestId); @@ -334,6 +375,8 @@ public void testStsMissingRoleSessionName() throws Exception { endpoint.get("AssumeRole", ROLE_ARN, null, 3600, "2011-06-15", null)); assertEquals(400, ex.getHttpCode()); + verify(auditLogger).logWriteFailure(any(AuditMessage.class)); + verify(auditLogger, never()).logWriteSuccess(any(AuditMessage.class)); final String requestId = "test-request-id"; ex.setRequestId(requestId); @@ -347,6 +390,8 @@ public void testStsInvalidRoleSessionNameWithInvalidCharacter() throws Exception endpoint.get("AssumeRole", ROLE_ARN, invalidSession, 3600, "2011-06-15", null)); assertEquals(400, ex.getHttpCode()); + verify(auditLogger).logWriteFailure(any(AuditMessage.class)); + verify(auditLogger, never()).logWriteSuccess(any(AuditMessage.class)); final String requestId = "test-request-id"; ex.setRequestId(requestId); @@ -360,6 +405,8 @@ public void testStsInvalidRoleSessionNameTooShort() throws Exception { endpoint.get("AssumeRole", ROLE_ARN, invalidSession, 3600, "2011-06-15", null)); assertEquals(400, ex.getHttpCode()); + verify(auditLogger).logWriteFailure(any(AuditMessage.class)); + verify(auditLogger, never()).logWriteSuccess(any(AuditMessage.class)); final String requestId = "test-request-id"; ex.setRequestId(requestId); @@ -374,6 +421,8 @@ public void testStsInvalidRoleArnResourceType() throws Exception { endpoint.get("AssumeRole", invalidRoleArn, ROLE_SESSION_NAME, 3600, "2011-06-15", null)); assertEquals(400, ex.getHttpCode()); + verify(auditLogger).logWriteFailure(any(AuditMessage.class)); + verify(auditLogger, never()).logWriteSuccess(any(AuditMessage.class)); final String requestId = "test-request-id"; ex.setRequestId(requestId); @@ -382,13 +431,15 @@ public void testStsInvalidRoleArnResourceType() throws Exception { @Test public void testStsInternalFailureWhenBackendThrows() throws Exception { - when(objectStore.assumeRole(anyString(), anyString(), anyInt(), any())) + when(objectStore.assumeRole(anyString(), anyString(), anyInt(), any(), anyString())) .thenThrow(new RuntimeException("some unexpected error")); final OSTSException ex = assertThrows(OSTSException.class, () -> endpoint.get("AssumeRole", ROLE_ARN, ROLE_SESSION_NAME, 3600, "2011-06-15", null)); assertEquals(500, ex.getHttpCode()); + verify(auditLogger).logWriteFailure(any(AuditMessage.class)); + verify(auditLogger, never()).logWriteSuccess(any(AuditMessage.class)); final String requestId = "test-request-id"; ex.setRequestId(requestId); @@ -397,13 +448,15 @@ public void testStsInternalFailureWhenBackendThrows() throws Exception { @Test public void testStsAccessDenied() throws Exception { - when(objectStore.assumeRole(anyString(), anyString(), anyInt(), any())) + when(objectStore.assumeRole(anyString(), anyString(), anyInt(), any(), anyString())) .thenThrow(new OMException("Permission denied", OMException.ResultCodes.ACCESS_DENIED)); final OSTSException ex = assertThrows(OSTSException.class, () -> endpoint.get("AssumeRole", ROLE_ARN, ROLE_SESSION_NAME, 3600, "2011-06-15", null)); assertEquals(403, ex.getHttpCode()); + verify(auditLogger).logWriteFailure(any(AuditMessage.class)); + verify(auditLogger, never()).logWriteSuccess(any(AuditMessage.class)); final String requestId = "test-request-id"; ex.setRequestId(requestId); @@ -413,13 +466,15 @@ public void testStsAccessDenied() throws Exception { @Test public void testStsIOExceptionWrappedAsInternalFailure() throws Exception { - when(objectStore.assumeRole(anyString(), anyString(), anyInt(), any())) + when(objectStore.assumeRole(anyString(), anyString(), anyInt(), any(), anyString())) .thenThrow(new IOException("An IO error occurred")); final OSTSException ex = assertThrows(OSTSException.class, () -> endpoint.get("AssumeRole", ROLE_ARN, ROLE_SESSION_NAME, 3600, "2011-06-15", null)); assertEquals(500, ex.getHttpCode()); + verify(auditLogger).logWriteFailure(any(AuditMessage.class)); + verify(auditLogger, never()).logWriteSuccess(any(AuditMessage.class)); final String requestId = "test-request-id"; ex.setRequestId(requestId); From 4b450b4980c5ce40f5a96052b78bed4b1ab6ed15 Mon Sep 17 00:00:00 2001 From: fmorg-git Date: Fri, 30 Jan 2026 14:03:59 -0800 Subject: [PATCH 24/54] HDDS-14472. [STS] Refactor constants and validation methods to shared location (#9658) --- .../om/helpers}/AwsRoleArnValidator.java | 21 +-- .../hadoop/ozone/om/helpers/S3STSUtils.java | 126 +++++++++++++++ .../om/helpers}/TestAwsRoleArnValidator.java | 20 +-- .../s3/security/S3AssumeRoleRequest.java | 28 +--- .../s3/security/TestS3AssumeRoleRequest.java | 18 ++- .../hadoop/ozone/s3sts/S3STSEndpoint.java | 143 +++++------------- .../hadoop/ozone/s3sts/TestS3STSEndpoint.java | 50 +++++- 7 files changed, 242 insertions(+), 164 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 (87%) 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 (89%) 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 87% 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..a60a514c674d 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,9 +15,9 @@ * 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 com.google.common.base.Strings; import org.apache.hadoop.ozone.om.exceptions.OMException; /** @@ -46,8 +46,10 @@ private AwsRoleArnValidator() { * @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); + if (Strings.isNullOrEmpty(roleArn)) { + throw new OMException( + "Value null at 'roleArn' failed to satisfy constraint: Member must not be null", + OMException.ResultCodes.INVALID_REQUEST); } final int roleArnLength = roleArn.length(); @@ -125,7 +127,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 +136,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 index 8d261e6c68e7..c70c01a8723d 100644 --- 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 @@ -17,13 +17,28 @@ package org.apache.hadoop.ozone.om.helpers; +import static org.apache.hadoop.ozone.om.exceptions.OMException.ResultCodes.INVALID_REQUEST; + import com.google.common.base.Strings; import java.util.Map; +import net.jcip.annotations.Immutable; +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() { } @@ -41,4 +56,115 @@ public static void addAssumeRoleAuditParams(Map auditParams, Str auditParams.put("isPolicyIncluded", Strings.isNullOrEmpty(awsIamSessionPolicy) ? "N" : "Y"); auditParams.put("requestId", requestId); } + + /** + * 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 (Strings.isNullOrEmpty(roleSessionName)) { + throw new OMException( + "Value null at 'roleSessionName' failed to satisfy constraint: Member must not be null", 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 length " + roleSessionNameLength + ": it must be " + + ASSUME_ROLE_SESSION_NAME_MIN_LENGTH + "-" + ASSUME_ROLE_SESSION_NAME_MAX_LENGTH + " characters long and " + + "contain only alphanumeric characters and +, =, ,, ., @, -", 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 character '" + c + "' in RoleSessionName: it must be " + + ASSUME_ROLE_SESSION_NAME_MIN_LENGTH + "-" + ASSUME_ROLE_SESSION_NAME_MAX_LENGTH + " characters long and " + + "contain only alphanumeric characters and +, =, ,, ., @, -", 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( + "Value '" + awsIamSessionPolicy + "' at 'policy' failed to satisfy constraint: Member " + + "must have length less than or equal to " + 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 + */ + public static String toAssumedRoleUserArn(String validRoleArn, String roleSessionName) { + // 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()); + + //noinspection StringBufferReplaceableByString + 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 89% 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..ea6db63c5557 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); } @@ -61,7 +62,8 @@ public void testValidateAndExtractRoleNameFromArnFailureCases() { 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"); + assertThat(e2.getMessage()).isEqualTo( + "Value null at 'roleArn' failed to satisfy constraint: Member must not be null"); // String without role name final OMException e3 = assertThrows( @@ -90,7 +92,8 @@ public void testValidateAndExtractRoleNameFromArnFailureCases() { final OMException e6 = assertThrows( OMException.class, () -> AwsRoleArnValidator.validateAndExtractRoleNameFromArn("")); assertThat(e6.getResult()).isEqualTo(OMException.ResultCodes.INVALID_REQUEST); - assertThat(e6.getMessage()).isEqualTo("Role ARN is required"); + assertThat(e6.getMessage()).isEqualTo( + "Value null at 'roleArn' failed to satisfy constraint: Member must not be null"); // String with only slash final OMException e7 = assertThrows( @@ -102,10 +105,10 @@ public void testValidateAndExtractRoleNameFromArnFailureCases() { final OMException e8 = assertThrows( OMException.class, () -> AwsRoleArnValidator.validateAndExtractRoleNameFromArn(" ")); assertThat(e8.getResult()).isEqualTo(OMException.ResultCodes.INVALID_REQUEST); - assertThat(e8.getMessage()).isEqualTo("Role ARN is required"); + assertThat(e8.getMessage()).isEqualTo("Role ARN length must be between 20 and 2048"); // 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 +123,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 +131,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 1b00454b70cb..030a4aeffebf 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 @@ -37,6 +37,7 @@ 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; @@ -68,14 +69,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 + @@ -113,14 +110,10 @@ public OMClientResponse validateAndUpdateCache(OzoneManager ozoneManager, Execut OMClientResponse omClientResponse; try { // Validate duration - if (durationSeconds < MIN_TOKEN_EXPIRATION_SECONDS || durationSeconds > MAX_TOKEN_EXPIRATION_SECONDS) { - throw new OMException( - "Duration must be between " + MIN_TOKEN_EXPIRATION_SECONDS + " and " + MAX_TOKEN_EXPIRATION_SECONDS, - OMException.ResultCodes.INVALID_REQUEST); - } + S3STSUtils.validateDuration(durationSeconds); // Validate role session name - validateRoleSessionName(roleSessionName); + S3STSUtils.validateRoleSessionName(roleSessionName); // Validate role ARN and extract role final String targetRoleName = AwsRoleArnValidator.validateAndExtractRoleNameFromArn(roleArn); @@ -178,21 +171,6 @@ public OMClientResponse validateAndUpdateCache(OzoneManager ozoneManager, Execut return omClientResponse; } - /** - * Ensures RoleSessionName is valid. - */ - private void validateRoleSessionName(String roleSessionName) throws OMException { - if (StringUtils.isBlank(roleSessionName)) { - throw new OMException("RoleSessionName is required", OMException.ResultCodes.INVALID_REQUEST); - } - if (roleSessionName.length() < ASSUME_ROLE_SESSION_NAME_MIN_LENGTH || - roleSessionName.length() > ASSUME_ROLE_SESSION_NAME_MAX_LENGTH) { - throw new OMException( - "RoleSessionName length must be between " + ASSUME_ROLE_SESSION_NAME_MIN_LENGTH + " and " + - ASSUME_ROLE_SESSION_NAME_MAX_LENGTH, OMException.ResultCodes.INVALID_REQUEST); - } - } - /** * 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 bda871386fed..004a6b0ab695 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 @@ -154,7 +154,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(); assertMarkForAuditCalled(request); } @@ -175,7 +176,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(); assertMarkForAuditCalled(request); } @@ -355,7 +357,8 @@ public void testAssumeRoleWithEmptySessionName() { final S3AssumeRoleRequest request = new S3AssumeRoleRequest(omRequest, CLOCK); final OMClientResponse response = request.validateAndUpdateCache(ozoneManager, context); assertThat(response.getOMResponse().getStatus()).isEqualTo(Status.INVALID_REQUEST); - assertThat(response.getOMResponse().getMessage()).isEqualTo("RoleSessionName is required"); + assertThat(response.getOMResponse().getMessage()).isEqualTo( + "Value null at 'roleSessionName' failed to satisfy constraint: Member must not be null"); assertMarkForAuditCalled(request); } @@ -374,7 +377,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 length 1: it must be 2-64 characters long and contain only alphanumeric " + + "characters and +, =, ,, ., @, -"); assertThat(omResponse.hasAssumeRoleResponse()).isFalse(); assertMarkForAuditCalled(request); } @@ -395,7 +400,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 length 70: it must be 2-64 characters long and contain only alphanumeric " + + "characters and +, =, ,, ., @, -" + ); assertThat(omResponse.hasAssumeRoleResponse()).isFalse(); assertMarkForAuditCalled(request); } 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 62bef03586a5..e4da7b604c70 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 @@ -23,13 +23,14 @@ import static javax.ws.rs.core.Response.Status.NOT_IMPLEMENTED; import com.google.common.annotations.VisibleForTesting; -import com.google.common.base.Strings; import java.io.IOException; import java.io.StringWriter; import java.time.Instant; import java.time.ZoneOffset; import java.time.format.DateTimeFormatter; +import java.util.HashSet; import java.util.Map; +import java.util.Set; import javax.inject.Inject; import javax.ws.rs.FormParam; import javax.ws.rs.GET; @@ -45,6 +46,7 @@ import org.apache.hadoop.ozone.audit.S3GAction; 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.RequestIdentifier; import org.apache.hadoop.ozone.s3.exception.OS3Exception; @@ -71,7 +73,6 @@ public class S3STSEndpoint extends S3STSEndpointBase { // STS API constants private static final String ASSUME_ROLE_ACTION = "AssumeRole"; - 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"; @@ -86,13 +87,6 @@ public class S3STSEndpoint extends S3STSEndpointBase { private static final String ACCESS_DENIED = "AccessDenied"; private static final String INVALID_CLIENT_TOKEN_ID = "InvalidClientTokenId"; - // 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; - @Inject private RequestIdentifier requestIdentifier; @@ -195,19 +189,10 @@ private Response handleAssumeRole(String roleArn, String roleSessionName, Intege final Map auditParams = getAuditParameters(); S3STSUtils.addAssumeRoleAuditParams( auditParams, roleArn, roleSessionName, awsIamSessionPolicy, - durationSeconds == null ? DEFAULT_DURATION_SECONDS : durationSeconds, + durationSeconds == null ? S3STSUtils.DEFAULT_DURATION_SECONDS : durationSeconds, requestId); - int duration; - try { - // Validate parameters - duration = validateDuration(durationSeconds); - } catch (IllegalArgumentException e) { - final OSTSException exception = new OSTSException(VALIDATION_ERROR, e.getMessage(), BAD_REQUEST.getStatusCode()); - getAuditLogger().logWriteFailure(buildAuditMessageForFailure(S3GAction.ASSUME_ROLE, auditParams, exception)); - throw exception; - } - + // Validate parameters if (version == null || !version.equals(EXPECTED_VERSION)) { final OSTSException exception = new OSTSException( INVALID_ACTION, "Could not find operation " + action + " for version " + @@ -217,50 +202,48 @@ private Response handleAssumeRole(String roleArn, String roleSessionName, Intege throw exception; } - if (roleArn == null || roleArn.isEmpty()) { - final OSTSException exception = new OSTSException( - VALIDATION_ERROR, "Value null at 'roleArn' failed to satisfy constraint: Member must not be null", - BAD_REQUEST.getStatusCode()); - getAuditLogger().logWriteFailure(buildAuditMessageForFailure(S3GAction.ASSUME_ROLE, auditParams, exception)); - throw exception; + final Set 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 (roleSessionName == null || roleSessionName.isEmpty()) { - final OSTSException exception = new OSTSException( - VALIDATION_ERROR, "Value null at 'roleSessionName' failed to satisfy constraint: Member must not be null", - BAD_REQUEST.getStatusCode()); - getAuditLogger().logWriteFailure(buildAuditMessageForFailure(S3GAction.ASSUME_ROLE, auditParams, exception)); - throw exception; + try { + AwsRoleArnValidator.validateAndExtractRoleNameFromArn(roleArn); + } catch (OMException e) { + validationErrors.add(e.getMessage()); } - // Validate role session name format (AWS requirements) - if (!isValidRoleSessionName(roleSessionName)) { - final OSTSException exception = new OSTSException( - VALIDATION_ERROR, "Invalid RoleSessionName: must be 2-64 characters long and " + - "contain only alphanumeric characters, +, =, ,, ., @, -", - BAD_REQUEST.getStatusCode()); - getAuditLogger().logWriteFailure(buildAuditMessageForFailure(S3GAction.ASSUME_ROLE, auditParams, exception)); - throw exception; + try { + S3STSUtils.validateRoleSessionName(roleSessionName); + } catch (OMException e) { + validationErrors.add(e.getMessage()); } - // Check Policy size if available - if (awsIamSessionPolicy != null && awsIamSessionPolicy.length() > MAX_SESSION_POLICY_SIZE) { - final OSTSException exception = new OSTSException( - VALIDATION_ERROR, "Value '" + awsIamSessionPolicy + "' at 'policy' failed to satisfy constraint: Member " + - "must have length less than or equal to 2048", BAD_REQUEST.getStatusCode()); - getAuditLogger().logWriteFailure(buildAuditMessageForFailure(S3GAction.ASSUME_ROLE, auditParams, exception)); - throw exception; + try { + S3STSUtils.validateSessionPolicy(awsIamSessionPolicy); + } catch (OMException e) { + validationErrors.add(e.getMessage()); } - final String assumedRoleUserArn; - try { - assumedRoleUserArn = toAssumedRoleUserArn(roleArn, roleSessionName); - } catch (IllegalArgumentException e) { - final OSTSException exception = new OSTSException(VALIDATION_ERROR, e.getMessage(), BAD_REQUEST.getStatusCode()); + final int numValidationErrors = validationErrors.size(); + if (numValidationErrors > 0) { + //noinspection StringBufferReplaceableByString + final StringBuilder builder = new StringBuilder(); + builder.append(numValidationErrors); + builder.append(" validation "); + builder.append(numValidationErrors > 1 ? "errors detected: " : "error detected: "); + builder.append(String.join(";", validationErrors)); + final String validationMessage = builder.toString(); + final OSTSException exception = new OSTSException( + VALIDATION_ERROR, validationMessage, BAD_REQUEST.getStatusCode()); getAuditLogger().logWriteFailure(buildAuditMessageForFailure(S3GAction.ASSUME_ROLE, auditParams, exception)); throw exception; } + final String assumedRoleUserArn = S3STSUtils.toAssumedRoleUserArn(roleArn, roleSessionName); try { final AssumeRoleResponseInfo responseInfo = getClient() .getObjectStore() @@ -301,29 +284,6 @@ private Response handleAssumeRole(String roleArn, String roleSessionName, Intege } } - private int validateDuration(Integer durationSeconds) throws IllegalArgumentException { - 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 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, String requestId) throws IOException { final String accessKeyId = responseInfo.getAccessKeyId(); @@ -362,36 +322,5 @@ 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 (Strings.isNullOrEmpty(accountId) || Strings.isNullOrEmpty(resource) || !resource.startsWith("role/") || - resource.length() == "role/".length()) { - throw new IllegalArgumentException(errMsg); - } - - final String roleName = resource.substring("role/".length()); - //noinspection StringBufferReplaceableByString - 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 34891d089453..d0eaca9a5dca 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 @@ -118,8 +118,7 @@ public void setup() throws Exception { @Test public void testStsAssumeRoleValidForGetMethod() throws Exception { - Response response = endpoint.get( - "AssumeRole", ROLE_ARN, ROLE_SESSION_NAME, 3600, "2011-06-15", null); + final Response response = endpoint.get("AssumeRole", ROLE_ARN, ROLE_SESSION_NAME, 3600, "2011-06-15", null); assertEquals(200, response.getStatus()); verify(auditLogger).logWriteSuccess(any(AuditMessage.class)); @@ -157,6 +156,7 @@ public void testStsAssumeRoleValidForGetMethod() throws Exception { @Test public void testStsAssumeRoleValidForPostMethod() throws Exception { + //noinspection resource final Response response = endpoint.post("AssumeRole", ROLE_ARN, ROLE_SESSION_NAME, 3600, "2011-06-15", null); assertEquals(200, response.getStatus()); @@ -305,7 +305,7 @@ public void testStsInvalidRoleArn() throws Exception { final String requestId = "test-request-id"; ex.setRequestId(requestId); assertStsErrorXml(ex.toXml(), STS_NS, "Sender", "ValidationError", - "Invalid RoleArn: must be in the format arn:aws:iam:::role/"); + "Invalid role ARN (does not start with arn:aws:iam::)"); } @Test @@ -335,7 +335,7 @@ public void testStsInvalidRoleArnMissingRoleName() throws Exception { final String requestId = "test-request-id"; ex.setRequestId(requestId); - assertStsErrorXml(ex.toXml(), STS_NS, "Sender", "ValidationError", "Invalid RoleArn: must be in the format"); + assertStsErrorXml(ex.toXml(), STS_NS, "Sender", "ValidationError", "Invalid role ARN: missing role name"); } @Test @@ -351,7 +351,7 @@ public void testStsInvalidRoleArnMissingAccountId() throws Exception { final String requestId = "test-request-id"; ex.setRequestId(requestId); - assertStsErrorXml(ex.toXml(), STS_NS, "Sender", "ValidationError", "Invalid RoleArn: must be in the format" + assertStsErrorXml(ex.toXml(), STS_NS, "Sender", "ValidationError", "Invalid AWS account ID in ARN" ); } @@ -395,7 +395,10 @@ public void testStsInvalidRoleSessionNameWithInvalidCharacter() throws Exception final String requestId = "test-request-id"; ex.setRequestId(requestId); - assertStsErrorXml(ex.toXml(), STS_NS, "Sender", "ValidationError", "Invalid RoleSessionName"); + assertStsErrorXml( + ex.toXml(), STS_NS, "Sender", "ValidationError", "1 validation error detected: " + + "Invalid character '/' in RoleSessionName: it must be 2-64 characters long and contain only alphanumeric " + + "characters and +, =, ,, ., @, -"); } @Test @@ -410,7 +413,9 @@ public void testStsInvalidRoleSessionNameTooShort() throws Exception { final String requestId = "test-request-id"; ex.setRequestId(requestId); - assertStsErrorXml(ex.toXml(), STS_NS, "Sender", "ValidationError", "Invalid RoleSessionName"); + assertStsErrorXml( + ex.toXml(), STS_NS, "Sender", "ValidationError", "1 validation error detected: Invalid RoleSessionName " + + "length 1: it must be 2-64 characters long and contain only alphanumeric characters and +, =, ,, ., @, -"); } @Test @@ -426,7 +431,7 @@ public void testStsInvalidRoleArnResourceType() throws Exception { final String requestId = "test-request-id"; ex.setRequestId(requestId); - assertStsErrorXml(ex.toXml(), STS_NS, "Sender", "ValidationError", "Invalid RoleArn: must be in the format"); + assertStsErrorXml(ex.toXml(), STS_NS, "Sender", "ValidationError", "Invalid role ARN (unexpected field count)"); } @Test @@ -481,6 +486,35 @@ public void testStsIOExceptionWrappedAsInternalFailure() throws Exception { assertStsErrorXml(ex.toXml(), STS_NS, "Receiver", "InternalFailure", "An internal error has occurred."); } + @Test + public void testStsMultipleValidationErrors() throws Exception { + final String invalidRoleSessionName = "test/session"; + final String tooLargePolicy = RandomStringUtils.insecure().nextAlphanumeric(2049); + final int invalidDurationSeconds = -1; + + final OSTSException ex = assertThrows(OSTSException.class, () -> + endpoint.get("AssumeRole", ROLE_ARN, invalidRoleSessionName, invalidDurationSeconds, "2011-06-15", + tooLargePolicy)); + + assertEquals(400, ex.getHttpCode()); + verify(auditLogger).logWriteFailure(any(AuditMessage.class)); + verify(auditLogger, never()).logWriteSuccess(any(AuditMessage.class)); + + final String requestId = "test-request-id"; + ex.setRequestId(requestId); + + final String xml = ex.toXml(); + // The order of individual validation errors is not guaranteed because it's a HashSet, so check + // that multiple messages are included + final Document doc = parseXml(xml); + final String message = doc.getElementsByTagName("Message").item(0).getTextContent(); + assertTrue(message.contains("3 validation errors detected")); + assertTrue(message.contains("Invalid Value: DurationSeconds")); + assertTrue(message.contains("Invalid character '/' in RoleSessionName")); + assertTrue(message.contains( + "'policy' failed to satisfy constraint: Member must have length less than or equal to 2048")); + } + private static Document parseXml(String xml) throws Exception { final DocumentBuilderFactory documentBuilderFactory = DocumentBuilderFactory.newInstance(); documentBuilderFactory.setNamespaceAware(true); From 7991b3abb6de6451ce659885997fbebaeebc6a05 Mon Sep 17 00:00:00 2001 From: fmorg-git Date: Tue, 3 Feb 2026 23:01:04 -0800 Subject: [PATCH 25/54] HDDS-14538. [STS] Leader OM should generate access key and secret and pass to others (#9697) --- .../src/main/proto/OmClientProtocol.proto | 17 ++++ .../s3/security/S3AssumeRoleRequest.java | 81 ++++++++++++++++--- .../s3/security/TestS3AssumeRoleRequest.java | 71 ++++++++++------ 3 files changed, 133 insertions(+), 36 deletions(-) diff --git a/hadoop-ozone/interface-client/src/main/proto/OmClientProtocol.proto b/hadoop-ozone/interface-client/src/main/proto/OmClientProtocol.proto index 707f8ac567d0..173b444e6f1e 100644 --- a/hadoop-ozone/interface-client/src/main/proto/OmClientProtocol.proto +++ b/hadoop-ozone/interface-client/src/main/proto/OmClientProtocol.proto @@ -310,6 +310,7 @@ message OMRequest { optional AssumeRoleRequest assumeRoleRequest = 144; optional RevokeSTSTokenRequest revokeSTSTokenRequest = 145; optional DeleteRevokedSTSTokensRequest deleteRevokedSTSTokensRequest = 146; + optional UpdateAssumeRoleRequest updateAssumeRoleRequest = 147; } message OMResponse { @@ -2390,6 +2391,22 @@ message AssumeRoleResponse { required string assumedRoleId = 5; } +/** + This request will be used internally by OM to replicate credentials generated by the leader + across the OMs in HA mode. This ensures all OMs have identical audit logs. +*/ +message UpdateAssumeRoleRequest { + required string roleArn = 1; + required string roleSessionName = 2; + required int32 durationSeconds = 3; + optional string awsIamSessionPolicy = 4; + required string requestId = 5; + // Leader-generated credentials + required string tempAccessKeyId = 6; + required string secretAccessKey = 7; + required string roleId = 8; +} + message RevokeSTSTokenRequest { required string sessionToken = 1; } 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 030a4aeffebf..939fe57efb87 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 @@ -20,6 +20,7 @@ import static org.apache.hadoop.ozone.security.acl.AssumeRoleRequest.OzoneGrant; import com.google.common.annotations.VisibleForTesting; +import com.google.common.base.Strings; import java.io.IOException; import java.net.InetAddress; import java.security.SecureRandom; @@ -28,7 +29,6 @@ import java.util.Map; import java.util.Optional; import java.util.Set; -import org.apache.commons.lang3.StringUtils; import org.apache.hadoop.hdds.scm.client.HddsClientUtils; import org.apache.hadoop.ipc.ProtobufRpcEngine; import org.apache.hadoop.ozone.audit.AuditLogger; @@ -47,6 +47,7 @@ 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.UpdateAssumeRoleRequest; import org.apache.hadoop.ozone.security.acl.iam.IamSessionPolicyResolver; import org.apache.hadoop.security.UserGroupInformation; @@ -87,20 +88,78 @@ public S3AssumeRoleRequest(OMRequest omRequest, Clock clock) { this.clock = clock; } + @Override + public OMRequest preExecute(OzoneManager ozoneManager) throws IOException { + final AssumeRoleRequest assumeRoleRequest = getOmRequest().getAssumeRoleRequest(); + + // Brief overview of flow: + // The STS Endpoint makes the AssumeRole call, which when received by OM leader (via this method), + // it will generate the temporary credentials (tempAccessKeyId, secretAccessKey) and roleId. + // The original AssumeRole request is converted to an UpdateAssumeRoleRequest with the generated + // credentials. This update request will be submitted to Ratis and the credentials + // created by the leader will be replicated across all OMs. All OMs in + // HA mode therefore will have identical audit logs with the same tempAccessKeyId. + + // 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 roleId = ASSUME_ROLE_ID_PREFIX + generateSecureRandomStringUsingChars( + CHARS_FOR_ACCESS_KEY_IDS, CHARS_FOR_ACCESS_KEY_IDS_LENGTH, STS_ROLE_ID_LENGTH); + + // Build UpdateAssumeRoleRequest with leader-generated credentials + final UpdateAssumeRoleRequest.Builder updateAssumeRoleRequestBuilder = + UpdateAssumeRoleRequest.newBuilder() + .setRoleArn(assumeRoleRequest.getRoleArn()) + .setRoleSessionName(assumeRoleRequest.getRoleSessionName()) + .setDurationSeconds(assumeRoleRequest.getDurationSeconds()) + .setRequestId(assumeRoleRequest.getRequestId()) + .setTempAccessKeyId(tempAccessKeyId) + .setSecretAccessKey(secretAccessKey) + .setRoleId(roleId); + + if (assumeRoleRequest.hasAwsIamSessionPolicy()) { + updateAssumeRoleRequestBuilder.setAwsIamSessionPolicy(assumeRoleRequest.getAwsIamSessionPolicy()); + } + + // Build new OMRequest with both original and update requests + final OMRequest.Builder omRequest = OMRequest.newBuilder() + .setUserInfo(getUserInfo()) + .setCmdType(getOmRequest().getCmdType()) + .setClientId(getOmRequest().getClientId()) + .setAssumeRoleRequest(assumeRoleRequest) + .setUpdateAssumeRoleRequest(updateAssumeRoleRequestBuilder.build()); + + if (getOmRequest().hasS3Authentication()) { + omRequest.setS3Authentication(getOmRequest().getS3Authentication()); + } + + if (getOmRequest().hasTraceID()) { + omRequest.setTraceID(getOmRequest().getTraceID()); + } + + return omRequest.build(); + } + @Override public OMClientResponse validateAndUpdateCache(OzoneManager ozoneManager, ExecutionContext context) { final OMRequest omRequest = getOmRequest(); final AssumeRoleRequest assumeRoleRequest = omRequest.getAssumeRoleRequest(); + final UpdateAssumeRoleRequest updateAssumeRoleRequest = omRequest.getUpdateAssumeRoleRequest(); + final int durationSeconds = assumeRoleRequest.getDurationSeconds(); final String roleSessionName = assumeRoleRequest.getRoleSessionName(); final String roleArn = assumeRoleRequest.getRoleArn(); final String awsIamSessionPolicy = assumeRoleRequest.getAwsIamSessionPolicy(); final String requestId = assumeRoleRequest.getRequestId(); + // Extract leader-generated credentials and roleId from UpdateAssumeRoleRequest + final String tempAccessKeyId = updateAssumeRoleRequest.getTempAccessKeyId(); + final String secretAccessKey = updateAssumeRoleRequest.getSecretAccessKey(); + final String roleId = updateAssumeRoleRequest.getRoleId(); + final Map auditMap = new HashMap<>(); - // In HA environments, only the tempAccessKeyId on the leader is used by S3G, so it could be helpful to - // have the leader information - auditMap.put("omRole", ozoneManager.isLeaderReady() ? "LEADER" : "FOLLOWER"); final AuditLogger auditLogger = ozoneManager.getAuditLogger(); final OzoneManagerProtocolProtos.UserInfo userInfo = omRequest.getUserInfo(); S3STSUtils.addAssumeRoleAuditParams( @@ -118,22 +177,18 @@ public OMClientResponse validateAndUpdateCache(OzoneManager ozoneManager, Execut // Validate role ARN and extract role final String targetRoleName = AwsRoleArnValidator.validateAndExtractRoleNameFromArn(roleArn); + // Note: The IamSessionPolicyResolver validates the awsIamPolicy length internally + if (!omRequest.hasS3Authentication()) { throw new OMException( "S3AssumeRoleRequest does not have S3 authentication", OMException.ResultCodes.INVALID_REQUEST); } - // 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); + // Generate session token using leader-generated credentials final String sessionToken = generateSessionToken( targetRoleName, omRequest, ozoneManager, assumeRoleRequest, secretAccessKey, tempAccessKeyId); - // 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); + // Generate AssumedRoleId for response using leader-generated roleId final String assumedRoleId = roleId + ":" + roleSessionName; // Calculate expiration of session token @@ -227,7 +282,7 @@ String getSessionPolicy(OzoneManager ozoneManager, String originalAccessKeyId, S volumeName = HddsClientUtils.getDefaultS3VolumeName(ozoneManager.getConfiguration()); } - final Set grants = StringUtils.isBlank(awsIamPolicy) ? + final Set grants = Strings.isNullOrEmpty(awsIamPolicy) ? null : IamSessionPolicyResolver.resolve(awsIamPolicy, volumeName, IamSessionPolicyResolver.AuthorizerType.RANGER); 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 004a6b0ab695..ba1ac7004de1 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 @@ -183,7 +183,7 @@ public void testInvalidDurationTooLong() { } @Test - public void testValidDurationMaxBoundary() { + public void testValidDurationMaxBoundary() throws IOException { final OMRequest omRequest = baseOmRequestBuilder() .setAssumeRoleRequest( AssumeRoleRequest.newBuilder() @@ -193,17 +193,20 @@ public void testValidDurationMaxBoundary() { .setRequestId(REQUEST_ID) ).build(); + // Call preExecute first to generate credentials final S3AssumeRoleRequest request = new S3AssumeRoleRequest(omRequest, CLOCK); - final OMClientResponse response = request.validateAndUpdateCache(ozoneManager, context); + final OMRequest preExecutedRequest = request.preExecute(ozoneManager); + final S3AssumeRoleRequest requestWithCredentials = new S3AssumeRoleRequest(preExecutedRequest, CLOCK); + final OMClientResponse response = requestWithCredentials.validateAndUpdateCache(ozoneManager, context); final OMResponse omResponse = response.getOMResponse(); assertThat(omResponse.getStatus()).isEqualTo(Status.OK); assertThat(omResponse.hasAssumeRoleResponse()).isTrue(); - assertMarkForAuditCalled(request); + assertMarkForAuditCalled(requestWithCredentials); } @Test - public void testValidDurationMinBoundary() { + public void testValidDurationMinBoundary() throws IOException { final OMRequest omRequest = baseOmRequestBuilder() .setAssumeRoleRequest( AssumeRoleRequest.newBuilder() @@ -213,13 +216,16 @@ public void testValidDurationMinBoundary() { .setRequestId(REQUEST_ID) ).build(); + // Call preExecute first to generate credentials final S3AssumeRoleRequest request = new S3AssumeRoleRequest(omRequest, CLOCK); - final OMClientResponse response = request.validateAndUpdateCache(ozoneManager, context); + final OMRequest preExecutedRequest = request.preExecute(ozoneManager); + final S3AssumeRoleRequest requestWithCredentials = new S3AssumeRoleRequest(preExecutedRequest, CLOCK); + final OMClientResponse response = requestWithCredentials.validateAndUpdateCache(ozoneManager, context); final OMResponse omResponse = response.getOMResponse(); assertThat(omResponse.getStatus()).isEqualTo(Status.OK); assertThat(omResponse.hasAssumeRoleResponse()).isTrue(); - assertMarkForAuditCalled(request); + assertMarkForAuditCalled(requestWithCredentials); } @Test @@ -246,7 +252,7 @@ public void testMissingS3Authentication() { } @Test - public void testSuccessfulAssumeRoleGeneratesCredentials() { + public void testSuccessfulAssumeRoleGeneratesCredentials() throws IOException { final int durationSeconds = 3600; final OMRequest omRequest = baseOmRequestBuilder() .setAssumeRoleRequest( @@ -258,7 +264,10 @@ public void testSuccessfulAssumeRoleGeneratesCredentials() { ).build(); final S3AssumeRoleRequest request = new S3AssumeRoleRequest(omRequest, CLOCK); - final OMClientResponse clientResponse = request.validateAndUpdateCache(ozoneManager, context); + // Call preExecute first to generate credentials + final OMRequest preExecutedRequest = request.preExecute(ozoneManager); + final S3AssumeRoleRequest requestWithCredentials = new S3AssumeRoleRequest(preExecutedRequest, CLOCK); + final OMClientResponse clientResponse = requestWithCredentials.validateAndUpdateCache(ozoneManager, context); final OMResponse omResponse = clientResponse.getOMResponse(); assertThat(omResponse.getStatus()).isEqualTo(Status.OK); @@ -284,7 +293,7 @@ public void testSuccessfulAssumeRoleGeneratesCredentials() { // Verify expiration added durationSeconds final long expirationEpochSeconds = assumeRoleResponse.getExpirationEpochSeconds(); assertThat(expirationEpochSeconds).isEqualTo(CLOCK.instant().getEpochSecond() + durationSeconds); - assertMarkForAuditCalled(request); + assertMarkForAuditCalled(requestWithCredentials); } @Test @@ -307,7 +316,7 @@ public void testGenerateSecureRandomStringUsingChars() { } @Test - public void testAssumeRoleCredentialsAreUnique() { + public void testAssumeRoleCredentialsAreUnique() throws IOException { // Test that multiple calls generate different credentials final OMRequest omRequest = baseOmRequestBuilder() .setAssumeRoleRequest( @@ -319,9 +328,16 @@ public void testAssumeRoleCredentialsAreUnique() { ).build(); final S3AssumeRoleRequest request1 = new S3AssumeRoleRequest(omRequest, CLOCK); - final OMClientResponse response1 = request1.validateAndUpdateCache(ozoneManager, context); + // Call preExecute first to generate credentials + final OMRequest preExecutedRequest1 = request1.preExecute(ozoneManager); + final S3AssumeRoleRequest requestWithCredentials1 = new S3AssumeRoleRequest(preExecutedRequest1, CLOCK); + final OMClientResponse response1 = requestWithCredentials1.validateAndUpdateCache(ozoneManager, context); + final S3AssumeRoleRequest request2 = new S3AssumeRoleRequest(omRequest, CLOCK); - final OMClientResponse response2 = request2.validateAndUpdateCache(ozoneManager, context); + // Call preExecute again to generate different credentials + final OMRequest preExecutedRequest2 = request2.preExecute(ozoneManager); + final S3AssumeRoleRequest requestWithCredentials2 = new S3AssumeRoleRequest(preExecutedRequest2, CLOCK); + final OMClientResponse response2 = requestWithCredentials2.validateAndUpdateCache(ozoneManager, context); final AssumeRoleResponse assumeRoleResponse1 = response1.getOMResponse().getAssumeRoleResponse(); final AssumeRoleResponse assumeRoleResponse2 = response2.getOMResponse().getAssumeRoleResponse(); @@ -338,8 +354,8 @@ public void testAssumeRoleCredentialsAreUnique() { // Different assumed role IDs assertThat(assumeRoleResponse1.getAssumedRoleId()).isNotEqualTo(assumeRoleResponse2.getAssumedRoleId()); - OMAuditLogger.log(request1.getAuditBuilder()); - OMAuditLogger.log(request2.getAuditBuilder()); + OMAuditLogger.log(requestWithCredentials1.getAuditBuilder()); + OMAuditLogger.log(requestWithCredentials2.getAuditBuilder()); verify(auditLogger, times(2)).logWrite(any(AuditMessage.class)); } @@ -409,7 +425,7 @@ public void testInvalidRoleSessionNameTooLong() { } @Test - public void testValidRoleSessionNameMaxLengthBoundary() { + public void testValidRoleSessionNameMaxLengthBoundary() throws IOException { final String roleSessionName = S3SecurityTestUtils.repeat('g', 64); final OMRequest omRequest = baseOmRequestBuilder() .setAssumeRoleRequest( @@ -419,17 +435,20 @@ public void testValidRoleSessionNameMaxLengthBoundary() { .setRequestId(REQUEST_ID) ).build(); + // Call preExecute first to generate credentials final S3AssumeRoleRequest request = new S3AssumeRoleRequest(omRequest, CLOCK); - final OMClientResponse response = request.validateAndUpdateCache(ozoneManager, context); + final OMRequest preExecutedRequest = request.preExecute(ozoneManager); + final S3AssumeRoleRequest requestWithCredentials = new S3AssumeRoleRequest(preExecutedRequest, CLOCK); + final OMClientResponse response = requestWithCredentials.validateAndUpdateCache(ozoneManager, context); final OMResponse omResponse = response.getOMResponse(); assertThat(omResponse.getStatus()).isEqualTo(Status.OK); assertThat(omResponse.hasAssumeRoleResponse()).isTrue(); - assertMarkForAuditCalled(request); + assertMarkForAuditCalled(requestWithCredentials); } @Test - public void testValidRoleSessionNameMinLengthBoundary() { + public void testValidRoleSessionNameMinLengthBoundary() throws IOException { final OMRequest omRequest = baseOmRequestBuilder() .setAssumeRoleRequest( AssumeRoleRequest.newBuilder() @@ -438,17 +457,20 @@ public void testValidRoleSessionNameMinLengthBoundary() { .setRequestId(REQUEST_ID) ).build(); + // Call preExecute first to generate credentials final S3AssumeRoleRequest request = new S3AssumeRoleRequest(omRequest, CLOCK); - final OMClientResponse response = request.validateAndUpdateCache(ozoneManager, context); + final OMRequest preExecutedRequest = request.preExecute(ozoneManager); + final S3AssumeRoleRequest requestWithCredentials = new S3AssumeRoleRequest(preExecutedRequest, CLOCK); + final OMClientResponse response = requestWithCredentials.validateAndUpdateCache(ozoneManager, context); final OMResponse omResponse = response.getOMResponse(); assertThat(omResponse.getStatus()).isEqualTo(Status.OK); assertThat(omResponse.hasAssumeRoleResponse()).isTrue(); - assertMarkForAuditCalled(request); + assertMarkForAuditCalled(requestWithCredentials); } @Test - public void testAssumeRoleWithSessionPolicyPresent() { + public void testAssumeRoleWithSessionPolicyPresent() throws IOException { final String sessionPolicy = "{\"Version\":\"2012-10-17\",\"Statement\":[]}"; final OMRequest omRequest = baseOmRequestBuilder() .setAssumeRoleRequest( @@ -460,10 +482,13 @@ public void testAssumeRoleWithSessionPolicyPresent() { .setRequestId(REQUEST_ID) ).build(); + // Call preExecute first to generate credentials final S3AssumeRoleRequest request = new S3AssumeRoleRequest(omRequest, CLOCK); - final OMClientResponse response = request.validateAndUpdateCache(ozoneManager, context); + final OMRequest preExecutedRequest = request.preExecute(ozoneManager); + final S3AssumeRoleRequest requestWithCredentials = new S3AssumeRoleRequest(preExecutedRequest, CLOCK); + final OMClientResponse response = requestWithCredentials.validateAndUpdateCache(ozoneManager, context); assertThat(response.getOMResponse().getStatus()).isEqualTo(Status.OK); - assertMarkForAuditCalled(request); + assertMarkForAuditCalled(requestWithCredentials); } @Test From 6ff971244c34e94aeabda0cf7b68112a50813c1c Mon Sep 17 00:00:00 2001 From: fmorg-git Date: Fri, 20 Feb 2026 13:57:50 -0800 Subject: [PATCH 26/54] HDDS-14681. [STS] Support StringLike Condition operator in IAM session policy and handle certain errors more gracefully (#9795) Co-authored-by: Fabian Morgan --- .../acl/iam/IamSessionPolicyResolver.java | 60 +++++++------- .../acl/iam/TestIamSessionPolicyResolver.java | 78 ++++++++++--------- .../ozone/security/acl/iam/package-info.java | 21 +++++ .../hadoop/ozone/om/OmMetadataReader.java | 17 +++- .../hadoop/ozone/s3sts/S3STSEndpoint.java | 16 ++++ 5 files changed, 128 insertions(+), 64 deletions(-) create mode 100644 hadoop-ozone/common/src/test/java/org/apache/hadoop/ozone/security/acl/iam/package-info.java diff --git a/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/security/acl/iam/IamSessionPolicyResolver.java b/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/security/acl/iam/IamSessionPolicyResolver.java index b90bb43c1935..0da9781e8af3 100644 --- a/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/security/acl/iam/IamSessionPolicyResolver.java +++ b/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/security/acl/iam/IamSessionPolicyResolver.java @@ -18,6 +18,7 @@ package org.apache.hadoop.ozone.security.acl.iam; import static java.util.Collections.singleton; +import static org.apache.hadoop.ozone.om.exceptions.OMException.ResultCodes.INTERNAL_ERROR; import static org.apache.hadoop.ozone.om.exceptions.OMException.ResultCodes.INVALID_REQUEST; import static org.apache.hadoop.ozone.om.exceptions.OMException.ResultCodes.NOT_SUPPORTED_OPERATION; import static org.apache.hadoop.ozone.security.acl.IAccessAuthorizer.ACLType.CREATE; @@ -62,9 +63,9 @@ * The only supported ResourceArn has prefix arn:aws:s3::: - all others will throw * OMException with NOT_SUPPORTED_OPERATION. *

- * The only supported Condition operator is StringEquals - all others will throw + * The only supported Condition operators are StringEquals and StringLike - all others will throw * OMException with NOT_SUPPORTED_OPERATION. Furthermore, only one Condition is supported in a - * statement. The value StringEquals is case-sensitive per the + * statement. The value for both StringEquals and StringLike is case-sensitive per the * * AWS spec. *

@@ -95,6 +96,8 @@ public final class IamSessionPolicyResolver { // Used to group actions into s3:Get*, s3:Put*, s3:List*, s3:Delete*, s3:Create* private static final String[] S3_ACTION_PREFIXES = {"s3:Get", "s3:Put", "s3:List", "s3:Delete", "s3:Create"}; + private static final String ERROR_PREFIX = "IAM session policy: "; + @VisibleForTesting static final Map> S3_ACTION_MAP_CI = buildCaseInsensitiveS3ActionMap(); @@ -166,18 +169,19 @@ public static Set resolve(String policyJson, Strin private static void validateInputParameters(String policyJson, String volumeName, AuthorizerType authorizerType) throws OMException { if (StringUtils.isBlank(policyJson)) { - throw new OMException("The IAM session policy JSON is required", INVALID_REQUEST); + throw new OMException(ERROR_PREFIX + "The IAM session policy JSON is required", INTERNAL_ERROR); } if (StringUtils.isBlank(volumeName)) { - throw new OMException("The volume name is required", INVALID_REQUEST); + throw new OMException(ERROR_PREFIX + "The volume name is required", INTERNAL_ERROR); } Objects.requireNonNull(authorizerType, "The authorizer type is required"); if (policyJson.length() > MAX_JSON_LENGTH) { - throw new OMException("Invalid policy JSON - exceeds maximum length of " + - MAX_JSON_LENGTH + " characters", INVALID_REQUEST); + throw new OMException( + ERROR_PREFIX + "Invalid policy JSON - exceeds maximum length of " + MAX_JSON_LENGTH + " characters", + INVALID_REQUEST); } } @@ -189,12 +193,13 @@ private static Set parseJsonAndRetrieveStatements(String policyJson) t try { root = MAPPER.readTree(policyJson); } catch (Exception e) { - throw new OMException("Invalid policy JSON (most likely JSON structure is incorrect)", e, INVALID_REQUEST); + throw new OMException( + ERROR_PREFIX + "Invalid policy JSON (most likely JSON structure is incorrect)", e, INVALID_REQUEST); } final JsonNode statementsNode = root.path("Statement"); if (statementsNode.isMissingNode()) { - throw new OMException("Invalid policy JSON - missing Statement", INVALID_REQUEST); + throw new OMException(ERROR_PREFIX + "Invalid policy JSON - missing Statement", INVALID_REQUEST); } final Set statements = new HashSet<>(); @@ -216,16 +221,16 @@ private static void validateEffectInJsonStatement(JsonNode statement) throws OME if (effectNode.isTextual()) { final String effect = effectNode.asText(); if (!"Allow".equals(effect)) { - throw new OMException("Unsupported Effect - " + effect, NOT_SUPPORTED_OPERATION); + throw new OMException(ERROR_PREFIX + "Unsupported Effect - " + effect, NOT_SUPPORTED_OPERATION); } return; } throw new OMException( - "Invalid Effect in JSON policy (must be a String) - " + effectNode, INVALID_REQUEST); + ERROR_PREFIX + "Invalid Effect in JSON policy (must be a String) - " + effectNode, INVALID_REQUEST); } - throw new OMException("Effect is missing from JSON policy", INVALID_REQUEST); + throw new OMException(ERROR_PREFIX + "Effect is missing from JSON policy", INVALID_REQUEST); } /** @@ -265,32 +270,34 @@ private static Set parsePrefixesFromConditions(JsonNode stmt) throws OME final JsonNode cond = stmt.get("Condition"); if (cond != null && !cond.isMissingNode() && !cond.isNull()) { if (cond.size() != 1) { - throw new OMException("Only one Condition is supported", NOT_SUPPORTED_OPERATION); + throw new OMException(ERROR_PREFIX + "Only one Condition is supported", NOT_SUPPORTED_OPERATION); } if (!cond.isObject()) { throw new OMException( - "Invalid Condition (must have operator StringEquals " + "and key name s3:prefix) - " + - cond, INVALID_REQUEST); + ERROR_PREFIX + "Invalid Condition (must have operator StringEquals or StringLike " + + "and key name s3:prefix) - " + cond, INVALID_REQUEST); } final String operator = cond.fieldNames().next(); - if (!"StringEquals".equals(operator)) { - throw new OMException("Unsupported Condition operator - " + operator, NOT_SUPPORTED_OPERATION); + if (!"StringEquals".equals(operator) && !"StringLike".equals(operator)) { + throw new OMException(ERROR_PREFIX + "Unsupported Condition operator - " + operator, NOT_SUPPORTED_OPERATION); } - final JsonNode operatorValue = cond.get("StringEquals"); + final JsonNode operatorValue = cond.get(operator); if ("null".equals(operatorValue.asText())) { - throw new OMException("Missing Condition operator - StringEquals", INVALID_REQUEST); + throw new OMException( + ERROR_PREFIX + "Missing Condition operator value for " + operator, INVALID_REQUEST); } if (!operatorValue.isObject()) { - throw new OMException("Invalid Condition operator value structure - " + operatorValue, INVALID_REQUEST); + throw new OMException( + ERROR_PREFIX + "Invalid Condition operator value structure - " + operatorValue, INVALID_REQUEST); } final String keyName = operatorValue.fieldNames().hasNext() ? operatorValue.fieldNames().next() : null; if (!"s3:prefix".equalsIgnoreCase(keyName)) { - throw new OMException("Unsupported Condition key name - " + keyName, NOT_SUPPORTED_OPERATION); + throw new OMException(ERROR_PREFIX + "Unsupported Condition key name - " + keyName, NOT_SUPPORTED_OPERATION); } prefixes = readStringOrArray(operatorValue.get(keyName)); @@ -356,7 +363,8 @@ private static void validateNativeAuthorizerBucketPattern(AuthorizerType authori throws OMException { if (authorizerType == AuthorizerType.NATIVE && bucket.contains("*")) { throw new OMException( - "Wildcard bucket patterns are not supported for Ozone native authorizer", NOT_SUPPORTED_OPERATION); + ERROR_PREFIX + "Wildcard bucket patterns are not supported for Ozone native authorizer", + NOT_SUPPORTED_OPERATION); } } @@ -374,7 +382,7 @@ static Set validateAndCategorizeResources(AuthorizerType authorize Set resources) throws OMException { final Set resourceSpecs = new HashSet<>(); if (resources.isEmpty()) { - throw new OMException("No Resource(s) found in policy", INVALID_REQUEST); + throw new OMException(ERROR_PREFIX + "No Resource(s) found in policy", INVALID_REQUEST); } for (String resource : resources) { if ("*".equals(resource)) { @@ -384,12 +392,12 @@ static Set validateAndCategorizeResources(AuthorizerType authorize } if (!resource.startsWith(AWS_S3_ARN_PREFIX)) { - throw new OMException("Unsupported Resource Arn - " + resource, NOT_SUPPORTED_OPERATION); + throw new OMException(ERROR_PREFIX + "Unsupported Resource Arn - " + resource, NOT_SUPPORTED_OPERATION); } final String suffix = resource.substring(AWS_S3_ARN_PREFIX.length()); if (suffix.isEmpty()) { - throw new OMException("Invalid Resource Arn - " + resource, INVALID_REQUEST); + throw new OMException(ERROR_PREFIX + "Invalid Resource Arn - " + resource, INVALID_REQUEST); } ResourceSpec spec = parseResourceSpec(suffix); @@ -404,8 +412,8 @@ static Set validateAndCategorizeResources(AuthorizerType authorize spec = ResourceSpec.objectPrefix(spec.bucket, specPrefixExceptLastChar); } else { throw new OMException( - "Wildcard prefix patterns are not supported for Ozone native authorizer if wildcard is not at the end", - NOT_SUPPORTED_OPERATION); + ERROR_PREFIX + "Wildcard prefix patterns are not supported for Ozone native authorizer if " + + "wildcard is not at the end", NOT_SUPPORTED_OPERATION); } } resourceSpecs.add(spec); diff --git a/hadoop-ozone/common/src/test/java/org/apache/hadoop/ozone/security/acl/iam/TestIamSessionPolicyResolver.java b/hadoop-ozone/common/src/test/java/org/apache/hadoop/ozone/security/acl/iam/TestIamSessionPolicyResolver.java index b885c5130ec4..4ca478156a33 100644 --- a/hadoop-ozone/common/src/test/java/org/apache/hadoop/ozone/security/acl/iam/TestIamSessionPolicyResolver.java +++ b/hadoop-ozone/common/src/test/java/org/apache/hadoop/ozone/security/acl/iam/TestIamSessionPolicyResolver.java @@ -69,12 +69,13 @@ public void testUnsupportedConditionOperatorThrows() { " \"Effect\": \"Allow\",\n" + " \"Action\": \"s3:ListBucket\",\n" + " \"Resource\": \"arn:aws:s3:::b\",\n" + - " \"Condition\": { \"StringLike\": { \"s3:prefix\": \"x/*\" } }\n" + + " \"Condition\": { \"StringNotEqualsIgnoreCase\": { \"s3:prefix\": \"x/*\" } }\n" + " }]\n" + "}"; expectResolveThrowsForBothAuthorizers( - json, "Unsupported Condition operator - StringLike", NOT_SUPPORTED_OPERATION); + json, "IAM session policy: Unsupported Condition operator - StringNotEqualsIgnoreCase", + NOT_SUPPORTED_OPERATION); } @Test @@ -89,7 +90,7 @@ public void testUnsupportedConditionAttributeThrows() { "}"; expectResolveThrowsForBothAuthorizers( - json, "Unsupported Condition key name - aws:SourceArn", NOT_SUPPORTED_OPERATION); + json, "IAM session policy: Unsupported Condition key name - aws:SourceArn", NOT_SUPPORTED_OPERATION); } @Test @@ -103,7 +104,7 @@ public void testUnsupportedEffectThrows() { "}"; expectResolveThrowsForBothAuthorizers( - json, "Unsupported Effect - Deny", NOT_SUPPORTED_OPERATION); + json, "IAM session policy: Unsupported Effect - Deny", NOT_SUPPORTED_OPERATION); } @Test @@ -118,7 +119,7 @@ public void testInvalidJsonWithoutStatementThrows() { "}"; expectResolveThrowsForBothAuthorizers( - json, "Invalid policy JSON - missing Statement", INVALID_REQUEST); + json, "IAM session policy: Invalid policy JSON - missing Statement", INVALID_REQUEST); } @Test @@ -132,7 +133,7 @@ public void testInvalidEffectThrows() { "}"; expectResolveThrowsForBothAuthorizers( - json, "Invalid Effect in JSON policy (must be a String) - [\"Allow\"]", + json, "IAM session policy: Invalid Effect in JSON policy (must be a String) - [\"Allow\"]", INVALID_REQUEST); } @@ -146,7 +147,7 @@ public void testMissingEffectInStatementThrows() { "}"; expectResolveThrowsForBothAuthorizers( - json, "Effect is missing from JSON policy", INVALID_REQUEST); + json, "IAM session policy: Effect is missing from JSON policy", INVALID_REQUEST); } @Test @@ -174,7 +175,7 @@ public void testInvalidNumberOfConditionsThrows() { "}"; expectResolveThrowsForBothAuthorizers( - json, "Only one Condition is supported", NOT_SUPPORTED_OPERATION); + json, "IAM session policy: Only one Condition is supported", NOT_SUPPORTED_OPERATION); } @Test @@ -191,7 +192,7 @@ public void testInvalidConditionThrows() { "}"; expectResolveThrowsForBothAuthorizers( - json, "Invalid Condition (must have operator StringEquals and key name " + + json, "IAM session policy: Invalid Condition (must have operator StringEquals or StringLike and key name " + "s3:prefix) - [\"RandomCondition\"]", INVALID_REQUEST); } @@ -207,7 +208,8 @@ public void testInvalidConditionAttributeMissingStringEqualsThrows() { "}"; expectResolveThrowsForBothAuthorizers( - json, "Missing Condition operator - StringEquals", INVALID_REQUEST); + json, "IAM session policy: Missing Condition operator value for StringEquals", + INVALID_REQUEST); } @Test @@ -222,7 +224,7 @@ public void testInvalidConditionAttributeStructureThrows() { "}"; expectResolveThrowsForBothAuthorizers( - json, "Invalid Condition operator value structure - [{\"s3:prefix\":\"folder/\"}]", + json, "IAM session policy: Invalid Condition operator value structure - [{\"s3:prefix\":\"folder/\"}]", INVALID_REQUEST); } @@ -231,7 +233,7 @@ public void testInvalidJsonThrows() { final String invalidJson = "{[{{}]\"\""; expectResolveThrowsForBothAuthorizers( - invalidJson, "Invalid policy JSON (most likely JSON structure is incorrect)", + invalidJson, "IAM session policy: Invalid policy JSON (most likely JSON structure is incorrect)", INVALID_REQUEST); } @@ -240,7 +242,7 @@ public void testJsonExceedsMaxLengthThrows() { final String json = createJsonStringLargerThan2048Characters(); expectResolveThrowsForBothAuthorizers( - json, "Invalid policy JSON - exceeds maximum length of 2048 characters", INVALID_REQUEST); + json, "IAM session policy: Invalid policy JSON - exceeds maximum length of 2048 characters", INVALID_REQUEST); } @Test @@ -282,7 +284,7 @@ public void testEffectMustBeCaseSensitive() { "}"; expectResolveThrowsForBothAuthorizers( - json, "Unsupported Effect - aLLOw", NOT_SUPPORTED_OPERATION); + json, "IAM session policy: Unsupported Effect - aLLOw", NOT_SUPPORTED_OPERATION); } @Test @@ -434,7 +436,8 @@ public void testMapPolicyActionsToS3ActionsWithS3StarIgnoresOtherActions() { public void testValidateAndCategorizeResourcesWithWildcard() throws OMException { expectOMExceptionWithCode( () -> validateAndCategorizeResources(NATIVE, Collections.singleton("*")), - "Wildcard bucket patterns are not supported for Ozone native authorizer", NOT_SUPPORTED_OPERATION); + "IAM session policy: Wildcard bucket patterns are not supported for Ozone native authorizer", + NOT_SUPPORTED_OPERATION); final Set resultRanger = validateAndCategorizeResources( RANGER, Collections.singleton("*")); @@ -463,7 +466,7 @@ public void testValidateAndCategorizeResourcesWithBucketWildcard() throws OMExce expectOMExceptionWithCode( () -> validateAndCategorizeResources(NATIVE, Collections.singleton("arn:aws:s3:::my-bucket*")), - "Wildcard bucket patterns are not supported for Ozone native authorizer", + "IAM session policy: Wildcard bucket patterns are not supported for Ozone native authorizer", NOT_SUPPORTED_OPERATION); final Set resultRanger = validateAndCategorizeResources( @@ -478,7 +481,7 @@ public void testValidateAndCategorizeResourcesWithBucketWildcardAndExactObjectKe expectOMExceptionWithCode( () -> validateAndCategorizeResources(NATIVE, Collections.singleton("arn:aws:s3:::*/myKey.txt")), - "Wildcard bucket patterns are not supported for Ozone native authorizer", + "IAM session policy: Wildcard bucket patterns are not supported for Ozone native authorizer", NOT_SUPPORTED_OPERATION); final Set resultRanger = validateAndCategorizeResources( @@ -490,7 +493,7 @@ public void testValidateAndCategorizeResourcesWithBucketWildcardAndExactObjectKe public void testValidateAndCategorizeResourcesWithBucketWildcardAndObjectWildcard() throws OMException { expectOMExceptionWithCode( () -> validateAndCategorizeResources(NATIVE, Collections.singleton("arn:aws:s3:::*/*")), - "Wildcard bucket patterns are not supported for Ozone native authorizer", + "IAM session policy: Wildcard bucket patterns are not supported for Ozone native authorizer", NOT_SUPPORTED_OPERATION); final IamSessionPolicyResolver.ResourceSpec expectedResourceSpec = new IamSessionPolicyResolver.ResourceSpec( @@ -592,8 +595,8 @@ public void testValidateAndCategorizeResourcesWithBucketAndObjectPrefixAndNonEmp public void testValidateAndCategorizeResourcesWithBucketAndObjectPrefixWildcardNotAtEnd() throws OMException { expectOMExceptionWithCode( () -> validateAndCategorizeResources(NATIVE, Collections.singleton("arn:aws:s3:::bucket3/*.log")), - "Wildcard prefix patterns are not supported for Ozone native authorizer if wildcard is not " + - "at the end", NOT_SUPPORTED_OPERATION); + "IAM session policy: Wildcard prefix patterns are not supported for Ozone native authorizer " + + "if wildcard is not at the end", NOT_SUPPORTED_OPERATION); final IamSessionPolicyResolver.ResourceSpec expectedRangerResourceSpec = new IamSessionPolicyResolver.ResourceSpec( S3ResourceType.OBJECT_PREFIX_WILDCARD, "bucket3", "*.log", null); @@ -606,8 +609,8 @@ public void testValidateAndCategorizeResourcesWithBucketAndObjectPrefixWildcardN public void testValidateAndCategorizeResourcesWithBucketAndObjectPrefixWildcardNotAtEndWithPath() throws OMException { expectOMExceptionWithCode( () -> validateAndCategorizeResources(NATIVE, Collections.singleton("arn:aws:s3:::bucket/a/q/*.ps")), - "Wildcard prefix patterns are not supported for Ozone native authorizer if wildcard is not " + - "at the end", NOT_SUPPORTED_OPERATION); + "IAM session policy: Wildcard prefix patterns are not supported for Ozone native authorizer if " + + "wildcard is not at the end", NOT_SUPPORTED_OPERATION); final IamSessionPolicyResolver.ResourceSpec expectedRangerResourceSpec = new IamSessionPolicyResolver.ResourceSpec( S3ResourceType.OBJECT_PREFIX_WILDCARD, "bucket", "a/q/*.ps", null); @@ -621,8 +624,8 @@ public void testValidateAndCategorizeResourcesWithBucketAndObjectPrefixWildcardO throws OMException { expectOMExceptionWithCode( () -> validateAndCategorizeResources(NATIVE, Collections.singleton("arn:aws:s3:::bucket3/*key*")), - "Wildcard prefix patterns are not supported for Ozone native authorizer if wildcard is not " + - "at the end", NOT_SUPPORTED_OPERATION); + "IAM session policy: Wildcard prefix patterns are not supported for Ozone native authorizer " + + "if wildcard is not at the end", NOT_SUPPORTED_OPERATION); final IamSessionPolicyResolver.ResourceSpec expectedRangerResourceSpec = new IamSessionPolicyResolver.ResourceSpec( S3ResourceType.OBJECT_PREFIX_WILDCARD, "bucket3", "*key*", null); @@ -636,8 +639,8 @@ public void testValidateAndCategorizeResourcesWithBucketAndObjectPrefixWildcardO throws OMException { expectOMExceptionWithCode( () -> validateAndCategorizeResources(NATIVE, Collections.singleton("arn:aws:s3:::bucket3/a/b/t/*key*")), - "Wildcard prefix patterns are not supported for Ozone native authorizer if wildcard is not " + - "at the end", NOT_SUPPORTED_OPERATION); + "IAM session policy: Wildcard prefix patterns are not supported for Ozone native authorizer " + + "if wildcard is not at the end", NOT_SUPPORTED_OPERATION); final IamSessionPolicyResolver.ResourceSpec expectedRangerResourceSpec = new IamSessionPolicyResolver.ResourceSpec( S3ResourceType.OBJECT_PREFIX_WILDCARD, "bucket3", "a/b/t/*key*", null); @@ -668,28 +671,30 @@ public void testValidateAndCategorizeResourcesWithInvalidArnThrows() { final String invalidArn = "arn:aws:ec2:::bucket"; expectOMExceptionWithCode( () -> validateAndCategorizeResources(NATIVE, Collections.singleton(invalidArn)), - "Unsupported Resource Arn - " + invalidArn, NOT_SUPPORTED_OPERATION); + "IAM session policy: Unsupported Resource Arn - " + invalidArn, NOT_SUPPORTED_OPERATION); expectOMExceptionWithCode( () -> validateAndCategorizeResources(RANGER, Collections.singleton(invalidArn)), - "Unsupported Resource Arn - " + invalidArn, NOT_SUPPORTED_OPERATION); + "IAM session policy: Unsupported Resource Arn - " + invalidArn, NOT_SUPPORTED_OPERATION); } @Test public void testValidateAndCategorizeResourcesWithArnWithNoBucketThrows() { expectOMExceptionWithCode( () -> validateAndCategorizeResources(NATIVE, Collections.singleton("arn:aws:s3:::")), - "Invalid Resource Arn - arn:aws:s3:::", INVALID_REQUEST); + "IAM session policy: Invalid Resource Arn - arn:aws:s3:::", INVALID_REQUEST); expectOMExceptionWithCode( () -> validateAndCategorizeResources(RANGER, Collections.singleton("arn:aws:s3:::")), - "Invalid Resource Arn - arn:aws:s3:::", INVALID_REQUEST); + "IAM session policy: Invalid Resource Arn - arn:aws:s3:::", INVALID_REQUEST); } @Test public void testValidateAndCategorizeResourcesWithNoResourcesThrows() { expectOMExceptionWithCode( - () -> validateAndCategorizeResources(NATIVE, emptySet()), "No Resource(s) found in policy", INVALID_REQUEST); + () -> validateAndCategorizeResources(NATIVE, emptySet()), "IAM session policy: No Resource(s) found in policy", + INVALID_REQUEST); expectOMExceptionWithCode( - () -> validateAndCategorizeResources(RANGER, emptySet()), "No Resource(s) found in policy", INVALID_REQUEST); + () -> validateAndCategorizeResources(RANGER, emptySet()), "IAM session policy: No Resource(s) found in policy", + INVALID_REQUEST); } @Test @@ -1431,7 +1436,7 @@ public void testUnsupportedResourceArnThrows() { "}"; expectResolveThrowsForBothAuthorizers( - json, "Unsupported Resource Arn - " + + json, "IAM session policy: Unsupported Resource Arn - " + "arn:aws:dynamodb:us-east-2:123456789012:table/example-table", NOT_SUPPORTED_OPERATION); } @@ -1583,7 +1588,7 @@ public void testObjectResourceWithWildcardInMiddle() throws OMException { // Wildcards in middle of object resource are not supported for Native authorizer expectResolveThrows( - json, NATIVE, "Wildcard prefix patterns are not supported for Ozone native " + + json, NATIVE, "IAM session policy: Wildcard prefix patterns are not supported for Ozone native " + "authorizer if wildcard is not at the end", NOT_SUPPORTED_OPERATION); final Set resolvedFromRangerAuthorizer = resolve(json, VOLUME, RANGER); @@ -1920,7 +1925,7 @@ public void testInvalidResourceArnThrows() { "}"; expectResolveThrowsForBothAuthorizers( - json, "Invalid Resource Arn - arn:aws:s3:::", INVALID_REQUEST); + json, "IAM session policy: Invalid Resource Arn - arn:aws:s3:::", INVALID_REQUEST); } private static void expectIllegalArgumentException(Runnable runnable, String expectedMessage) { @@ -2029,7 +2034,8 @@ private static void expectBucketWildcardUnsupportedExceptionForNativeAuthorizer( resolve(json, VOLUME, NATIVE); throw new AssertionError("Expected exception not thrown"); } catch (OMException ex) { - assertThat(ex.getMessage()).isEqualTo("Wildcard bucket patterns are not supported for Ozone native authorizer"); + assertThat(ex.getMessage()).isEqualTo( + "IAM session policy: Wildcard bucket patterns are not supported for Ozone native authorizer"); assertThat(ex.getResult()).isEqualTo(NOT_SUPPORTED_OPERATION); } } diff --git a/hadoop-ozone/common/src/test/java/org/apache/hadoop/ozone/security/acl/iam/package-info.java b/hadoop-ozone/common/src/test/java/org/apache/hadoop/ozone/security/acl/iam/package-info.java new file mode 100644 index 000000000000..c5e8264687b8 --- /dev/null +++ b/hadoop-ozone/common/src/test/java/org/apache/hadoop/ozone/security/acl/iam/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 related to IAM policies. + */ +package org.apache.hadoop.ozone.security.acl.iam; diff --git a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/OmMetadataReader.java b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/OmMetadataReader.java index e25cd3d42719..8d123784197e 100644 --- a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/OmMetadataReader.java +++ b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/OmMetadataReader.java @@ -601,12 +601,25 @@ public boolean checkAcls(OzoneObj obj, RequestContext context, "Bucket:" + obj.getBucketName() + " " : ""; String keyName = obj.getKeyName() != null ? "Key:" + obj.getKeyName() : ""; + // For STS tokens, make clear that the user is using an assumed role, otherwise the access denied + // message could be confusing + String user = normalizedRequestContext.getClientUgi().getShortUserName(); + final STSTokenIdentifier stsTokenIdentifier = OzoneManager.getStsTokenIdentifier(); + if (stsTokenIdentifier != null) { + final StringBuilder builder = new StringBuilder(user); + builder.append(" (STS assumed role arn = "); + builder.append(stsTokenIdentifier.getRoleArn()); + builder.append(", tempAccessKeyId = "); + builder.append(stsTokenIdentifier.getTempAccessKeyId()); + builder.append(')'); + user = builder.toString(); + } log.warn("User {} doesn't have {} permission to access {} {}{}{}", - normalizedRequestContext.getClientUgi().getShortUserName(), + user, normalizedRequestContext.getAclRights(), obj.getResourceType(), volumeName, bucketName, keyName); throw new OMException( - "User " + normalizedRequestContext.getClientUgi().getShortUserName() + + "User " + user + " doesn't have " + normalizedRequestContext.getAclRights() + " permission to access " + obj.getResourceType() + " " + volumeName + bucketName + keyName, ResultCodes.PERMISSION_DENIED); 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 e4da7b604c70..e2dee7dc6cf9 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 @@ -43,6 +43,7 @@ import javax.xml.bind.JAXBContext; import javax.xml.bind.JAXBException; import javax.xml.bind.Marshaller; +import org.apache.commons.lang3.StringUtils; import org.apache.hadoop.ozone.audit.S3GAction; import org.apache.hadoop.ozone.om.exceptions.OMException; import org.apache.hadoop.ozone.om.helpers.AssumeRoleResponseInfo; @@ -86,6 +87,7 @@ public class S3STSEndpoint extends S3STSEndpointBase { private static final String INTERNAL_FAILURE = "InternalFailure"; private static final String ACCESS_DENIED = "AccessDenied"; private static final String INVALID_CLIENT_TOKEN_ID = "InvalidClientTokenId"; + private static final String UNSUPPORTED_OPERATION = "UnsupportedOperation"; @Inject private RequestIdentifier requestIdentifier; @@ -223,6 +225,11 @@ private Response handleAssumeRole(String roleArn, String roleSessionName, Intege } try { + if (LOG.isDebugEnabled() && StringUtils.isNotEmpty(awsIamSessionPolicy)) { + LOG.debug( + "AssumeRole requestId={} received Policy(len={}): {}", requestId, awsIamSessionPolicy.length(), + awsIamSessionPolicy); + } S3STSUtils.validateSessionPolicy(awsIamSessionPolicy); } catch (OMException e) { validationErrors.add(e.getMessage()); @@ -275,6 +282,15 @@ private Response handleAssumeRole(String roleArn, String roleSessionName, Intege INVALID_CLIENT_TOKEN_ID, "The security token included in the request is invalid.", FORBIDDEN.getStatusCode()); } + if (omException.getResult() == OMException.ResultCodes.NOT_SUPPORTED_OPERATION || + omException.getResult() == OMException.ResultCodes.FEATURE_NOT_ENABLED) { + throw new OSTSException( + UNSUPPORTED_OPERATION, omException.getMessage(), NOT_IMPLEMENTED.getStatusCode()); + } + if (omException.getResult() == OMException.ResultCodes.INVALID_REQUEST) { + throw new OSTSException( + VALIDATION_ERROR, omException.getMessage(), BAD_REQUEST.getStatusCode()); + } } throw new OSTSException( INTERNAL_FAILURE, "An internal error has occurred.", INTERNAL_SERVER_ERROR.getStatusCode(), "Receiver"); From f9bec4bde8ddc51126e1fd52cf9b64c518b2d65e Mon Sep 17 00:00:00 2001 From: fmorg-git Date: Wed, 25 Feb 2026 18:15:11 -0800 Subject: [PATCH 27/54] HDDS-14711. [STS] Ensure accessKeyId is valid for sessionToken (#9820) --- .../hadoop/ozone/security/S3SecurityUtil.java | 8 +++ .../ozone/security/TestS3SecurityUtil.java | 62 ++++++++++++++++--- 2 files changed, 61 insertions(+), 9 deletions(-) diff --git a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/security/S3SecurityUtil.java b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/security/S3SecurityUtil.java index 17b74bb74174..923bf9d9a78f 100644 --- a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/security/S3SecurityUtil.java +++ b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/security/S3SecurityUtil.java @@ -86,6 +86,14 @@ public static void validateS3Credential(OMRequest omRequest, throw new OMException("STS token no longer valid: OriginalAccessKeyId principal revoked", REVOKED_TOKEN); } + // Ensure the access key ID in the request matches the one encoded in the token. + // This prevents using a valid token and secretKey to authenticate arbitrary accessKeyIds. + final String requestAccessId = omRequest.getS3Authentication().getAccessId(); + if (!requestAccessId.equals(stsTokenIdentifier.getTempAccessKeyId())) { + throw new OMException( + "STS token validation failed - accessKeyId is invalid for session token", INVALID_TOKEN); + } + // HMAC signature and expiration were validated above. Now validate AWS signature. validateSTSTokenAwsSignature(stsTokenIdentifier, omRequest); OzoneManager.setStsTokenIdentifier(stsTokenIdentifier); diff --git a/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/security/TestS3SecurityUtil.java b/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/security/TestS3SecurityUtil.java index d642c6e5ace9..d82b338ce8f0 100644 --- a/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/security/TestS3SecurityUtil.java +++ b/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/security/TestS3SecurityUtil.java @@ -18,6 +18,7 @@ package org.apache.hadoop.ozone.security; import static org.apache.hadoop.ozone.om.exceptions.OMException.ResultCodes.INTERNAL_ERROR; +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.junit.jupiter.api.Assertions.assertDoesNotThrow; import static org.junit.jupiter.api.Assertions.assertEquals; @@ -57,6 +58,7 @@ public class TestS3SecurityUtil { private static final byte[] ENCRYPTION_KEY = new byte[5]; private static final TestClock CLOCK = TestClock.newInstance(); + private static final String TEMP_ACCESS_KEY_ID = "temp-access-key-id"; { ThreadLocalRandom.current().nextBytes(ENCRYPTION_KEY); @@ -132,6 +134,33 @@ public void testValidateS3CredentialFailsWhenOriginalAccessKeyIdCheckThrows() th .setExpectedMessage("Could not determine if original principal is revoked")); } + @Test + public void testValidateS3CredentialFailsWhenRequestAccessIdDoesNotMatchTokenOwner() throws Exception { + validateS3CredentialHelper( + new TestConfig() + .setRequestAccessId("some-other-access-id") + .setExpectedResult(INVALID_TOKEN) + .setExpectedMessage("STS token validation failed - accessKeyId is invalid for session token")); + } + + @Test + public void testValidateS3CredentialFailsWhenRequestAccessIdMissing() throws Exception { + validateS3CredentialHelper( + new TestConfig() + .setIncludeAccessId(false) + .setExpectedResult(INVALID_TOKEN) + .setExpectedMessage("STS token validation failed - accessKeyId is invalid for session token")); + } + + @Test + public void testValidateS3CredentialFailsWhenRequestAccessIdEmpty() throws Exception { + validateS3CredentialHelper( + new TestConfig() + .setRequestAccessId("") + .setExpectedResult(INVALID_TOKEN) + .setExpectedMessage("STS token validation failed - accessKeyId is invalid for session token")); + } + private void validateS3CredentialHelper(TestConfig config) throws Exception { try (OzoneManager ozoneManager = mock(OzoneManager.class)) { when(ozoneManager.isSecurityEnabled()).thenReturn(true); @@ -178,7 +207,8 @@ private void validateS3CredentialHelper(TestConfig config) throws Exception { awsV4AuthValidatorMock.when(() -> AWSV4AuthValidator.validateRequest(anyString(), anyString(), anyString())) .thenReturn(true); - final OMRequest omRequest = createRequestWithSessionToken(sessionToken); + final OMRequest omRequest = createRequestWithSessionToken( + config.requestAccessId, config.includeAccessId); if (config.expectedResult != null) { final OMException omException = assertThrows( @@ -199,19 +229,20 @@ private void validateS3CredentialHelper(TestConfig config) throws Exception { private STSTokenIdentifier createSTSTokenIdentifier() { return new STSTokenIdentifier( - "temp-access-key-id", "original-access-key-id", "arn:aws:iam::123456789012:role/test-role", + TEMP_ACCESS_KEY_ID, "original-access-key-id", "arn:aws:iam::123456789012:role/test-role", CLOCK.instant().plusSeconds(3600), "secret-access-key", "session-policy", ENCRYPTION_KEY); } - @SuppressWarnings("SameParameterValue") - private static OMRequest createRequestWithSessionToken(String sessionToken) { - final S3Authentication s3Authentication = S3Authentication.newBuilder() - .setAccessId("accessKeyId") + private static OMRequest createRequestWithSessionToken(String accessId, boolean includeAccessId) { + final S3Authentication.Builder s3AuthenticationBuilder = S3Authentication.newBuilder() .setStringToSign("string-to-sign") .setSignature("signature") - .setSessionToken(sessionToken) - .build(); + .setSessionToken("session-token"); + if (includeAccessId) { + s3AuthenticationBuilder.setAccessId(accessId); + } + final S3Authentication s3Authentication = s3AuthenticationBuilder.build(); return OMRequest.newBuilder() .setClientId(UUID.randomUUID().toString()) @@ -223,12 +254,14 @@ private static OMRequest createRequestWithSessionToken(String sessionToken) { /** * Helper class to create various scenarios for testing. */ - private static class TestConfig { + private static final class TestConfig { private OMMetadataManager metadataManager = mock(OMMetadataManager.class); private Table revokedSTSTokenTable = new InMemoryTestTable<>(); private boolean isTokenRevoked = false; private boolean isOriginalAccessKeyIdRevoked = false; private boolean shouldOriginalAccessKeyIdCheckThrowError = false; + private String requestAccessId = TEMP_ACCESS_KEY_ID; + private boolean includeAccessId = true; private OMException.ResultCodes expectedResult = null; private String expectedMessage = null; @@ -261,6 +294,17 @@ TestConfig setShouldOriginalAccessKeyIdCheckThrowError(boolean isError) { return this; } + TestConfig setRequestAccessId(String requestAccessId) { + this.requestAccessId = requestAccessId; + return this; + } + + @SuppressWarnings("SameParameterValue") + TestConfig setIncludeAccessId(boolean includeAccessId) { + this.includeAccessId = includeAccessId; + return this; + } + TestConfig setExpectedResult(OMException.ResultCodes result) { this.expectedResult = result; return this; From e63936bce0ed9c67b287266ec504925c18a7af30 Mon Sep 17 00:00:00 2001 From: fmorg-git Date: Thu, 26 Feb 2026 23:53:22 -0800 Subject: [PATCH 28/54] HDDS-14716. [STS] Use MalformedPolicyDocument error code for IAM Session Policy validation errors (#9823) --- .../ozone/om/exceptions/OMException.java | 1 + .../acl/iam/IamSessionPolicyResolver.java | 22 ++-- .../acl/iam/TestIamSessionPolicyResolver.java | 29 ++-- .../src/main/proto/OmClientProtocol.proto | 1 + .../hadoop/ozone/s3sts/S3STSEndpoint.java | 10 +- .../hadoop/ozone/s3sts/TestS3STSEndpoint.java | 124 ++++++++++++------ 6 files changed, 120 insertions(+), 67 deletions(-) diff --git a/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/om/exceptions/OMException.java b/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/om/exceptions/OMException.java index 70acadefed8b..fcd1e7807f1c 100644 --- a/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/om/exceptions/OMException.java +++ b/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/om/exceptions/OMException.java @@ -277,5 +277,6 @@ public enum ResultCodes { TOO_MANY_SNAPSHOTS, REVOKED_TOKEN, + MALFORMED_POLICY_DOCUMENT, } } diff --git a/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/security/acl/iam/IamSessionPolicyResolver.java b/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/security/acl/iam/IamSessionPolicyResolver.java index 0da9781e8af3..b8b032f1b358 100644 --- a/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/security/acl/iam/IamSessionPolicyResolver.java +++ b/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/security/acl/iam/IamSessionPolicyResolver.java @@ -19,7 +19,7 @@ import static java.util.Collections.singleton; import static org.apache.hadoop.ozone.om.exceptions.OMException.ResultCodes.INTERNAL_ERROR; -import static org.apache.hadoop.ozone.om.exceptions.OMException.ResultCodes.INVALID_REQUEST; +import static org.apache.hadoop.ozone.om.exceptions.OMException.ResultCodes.MALFORMED_POLICY_DOCUMENT; import static org.apache.hadoop.ozone.om.exceptions.OMException.ResultCodes.NOT_SUPPORTED_OPERATION; import static org.apache.hadoop.ozone.security.acl.IAccessAuthorizer.ACLType.CREATE; import static org.apache.hadoop.ozone.security.acl.IAccessAuthorizer.ACLType.DELETE; @@ -181,7 +181,7 @@ private static void validateInputParameters(String policyJson, String volumeName if (policyJson.length() > MAX_JSON_LENGTH) { throw new OMException( ERROR_PREFIX + "Invalid policy JSON - exceeds maximum length of " + MAX_JSON_LENGTH + " characters", - INVALID_REQUEST); + MALFORMED_POLICY_DOCUMENT); } } @@ -194,12 +194,12 @@ private static Set parseJsonAndRetrieveStatements(String policyJson) t root = MAPPER.readTree(policyJson); } catch (Exception e) { throw new OMException( - ERROR_PREFIX + "Invalid policy JSON (most likely JSON structure is incorrect)", e, INVALID_REQUEST); + ERROR_PREFIX + "Invalid policy JSON (most likely JSON structure is incorrect)", e, MALFORMED_POLICY_DOCUMENT); } final JsonNode statementsNode = root.path("Statement"); if (statementsNode.isMissingNode()) { - throw new OMException(ERROR_PREFIX + "Invalid policy JSON - missing Statement", INVALID_REQUEST); + throw new OMException(ERROR_PREFIX + "Invalid policy JSON - missing Statement", MALFORMED_POLICY_DOCUMENT); } final Set statements = new HashSet<>(); @@ -227,10 +227,10 @@ private static void validateEffectInJsonStatement(JsonNode statement) throws OME } throw new OMException( - ERROR_PREFIX + "Invalid Effect in JSON policy (must be a String) - " + effectNode, INVALID_REQUEST); + ERROR_PREFIX + "Invalid Effect in JSON policy (must be a String) - " + effectNode, MALFORMED_POLICY_DOCUMENT); } - throw new OMException(ERROR_PREFIX + "Effect is missing from JSON policy", INVALID_REQUEST); + throw new OMException(ERROR_PREFIX + "Effect is missing from JSON policy", MALFORMED_POLICY_DOCUMENT); } /** @@ -276,7 +276,7 @@ private static Set parsePrefixesFromConditions(JsonNode stmt) throws OME if (!cond.isObject()) { throw new OMException( ERROR_PREFIX + "Invalid Condition (must have operator StringEquals or StringLike " + - "and key name s3:prefix) - " + cond, INVALID_REQUEST); + "and key name s3:prefix) - " + cond, MALFORMED_POLICY_DOCUMENT); } final String operator = cond.fieldNames().next(); @@ -287,12 +287,12 @@ private static Set parsePrefixesFromConditions(JsonNode stmt) throws OME final JsonNode operatorValue = cond.get(operator); if ("null".equals(operatorValue.asText())) { throw new OMException( - ERROR_PREFIX + "Missing Condition operator value for " + operator, INVALID_REQUEST); + ERROR_PREFIX + "Missing Condition operator value for " + operator, MALFORMED_POLICY_DOCUMENT); } if (!operatorValue.isObject()) { throw new OMException( - ERROR_PREFIX + "Invalid Condition operator value structure - " + operatorValue, INVALID_REQUEST); + ERROR_PREFIX + "Invalid Condition operator value structure - " + operatorValue, MALFORMED_POLICY_DOCUMENT); } final String keyName = operatorValue.fieldNames().hasNext() ? operatorValue.fieldNames().next() : null; @@ -382,7 +382,7 @@ static Set validateAndCategorizeResources(AuthorizerType authorize Set resources) throws OMException { final Set resourceSpecs = new HashSet<>(); if (resources.isEmpty()) { - throw new OMException(ERROR_PREFIX + "No Resource(s) found in policy", INVALID_REQUEST); + throw new OMException(ERROR_PREFIX + "No Resource(s) found in policy", MALFORMED_POLICY_DOCUMENT); } for (String resource : resources) { if ("*".equals(resource)) { @@ -397,7 +397,7 @@ static Set validateAndCategorizeResources(AuthorizerType authorize final String suffix = resource.substring(AWS_S3_ARN_PREFIX.length()); if (suffix.isEmpty()) { - throw new OMException(ERROR_PREFIX + "Invalid Resource Arn - " + resource, INVALID_REQUEST); + throw new OMException(ERROR_PREFIX + "Invalid Resource Arn - " + resource, MALFORMED_POLICY_DOCUMENT); } ResourceSpec spec = parseResourceSpec(suffix); diff --git a/hadoop-ozone/common/src/test/java/org/apache/hadoop/ozone/security/acl/iam/TestIamSessionPolicyResolver.java b/hadoop-ozone/common/src/test/java/org/apache/hadoop/ozone/security/acl/iam/TestIamSessionPolicyResolver.java index 4ca478156a33..41d2fc338f30 100644 --- a/hadoop-ozone/common/src/test/java/org/apache/hadoop/ozone/security/acl/iam/TestIamSessionPolicyResolver.java +++ b/hadoop-ozone/common/src/test/java/org/apache/hadoop/ozone/security/acl/iam/TestIamSessionPolicyResolver.java @@ -18,7 +18,7 @@ package org.apache.hadoop.ozone.security.acl.iam; import static java.util.Collections.emptySet; -import static org.apache.hadoop.ozone.om.exceptions.OMException.ResultCodes.INVALID_REQUEST; +import static org.apache.hadoop.ozone.om.exceptions.OMException.ResultCodes.MALFORMED_POLICY_DOCUMENT; import static org.apache.hadoop.ozone.om.exceptions.OMException.ResultCodes.NOT_SUPPORTED_OPERATION; import static org.apache.hadoop.ozone.security.acl.AssumeRoleRequest.OzoneGrant; import static org.apache.hadoop.ozone.security.acl.IAccessAuthorizer.ACLType; @@ -119,7 +119,7 @@ public void testInvalidJsonWithoutStatementThrows() { "}"; expectResolveThrowsForBothAuthorizers( - json, "IAM session policy: Invalid policy JSON - missing Statement", INVALID_REQUEST); + json, "IAM session policy: Invalid policy JSON - missing Statement", MALFORMED_POLICY_DOCUMENT); } @Test @@ -134,7 +134,7 @@ public void testInvalidEffectThrows() { expectResolveThrowsForBothAuthorizers( json, "IAM session policy: Invalid Effect in JSON policy (must be a String) - [\"Allow\"]", - INVALID_REQUEST); + MALFORMED_POLICY_DOCUMENT); } @Test @@ -147,7 +147,7 @@ public void testMissingEffectInStatementThrows() { "}"; expectResolveThrowsForBothAuthorizers( - json, "IAM session policy: Effect is missing from JSON policy", INVALID_REQUEST); + json, "IAM session policy: Effect is missing from JSON policy", MALFORMED_POLICY_DOCUMENT); } @Test @@ -193,7 +193,7 @@ public void testInvalidConditionThrows() { expectResolveThrowsForBothAuthorizers( json, "IAM session policy: Invalid Condition (must have operator StringEquals or StringLike and key name " + - "s3:prefix) - [\"RandomCondition\"]", INVALID_REQUEST); + "s3:prefix) - [\"RandomCondition\"]", MALFORMED_POLICY_DOCUMENT); } @Test @@ -209,7 +209,7 @@ public void testInvalidConditionAttributeMissingStringEqualsThrows() { expectResolveThrowsForBothAuthorizers( json, "IAM session policy: Missing Condition operator value for StringEquals", - INVALID_REQUEST); + MALFORMED_POLICY_DOCUMENT); } @Test @@ -225,7 +225,7 @@ public void testInvalidConditionAttributeStructureThrows() { expectResolveThrowsForBothAuthorizers( json, "IAM session policy: Invalid Condition operator value structure - [{\"s3:prefix\":\"folder/\"}]", - INVALID_REQUEST); + MALFORMED_POLICY_DOCUMENT); } @Test @@ -234,7 +234,7 @@ public void testInvalidJsonThrows() { expectResolveThrowsForBothAuthorizers( invalidJson, "IAM session policy: Invalid policy JSON (most likely JSON structure is incorrect)", - INVALID_REQUEST); + MALFORMED_POLICY_DOCUMENT); } @Test @@ -242,7 +242,8 @@ public void testJsonExceedsMaxLengthThrows() { final String json = createJsonStringLargerThan2048Characters(); expectResolveThrowsForBothAuthorizers( - json, "IAM session policy: Invalid policy JSON - exceeds maximum length of 2048 characters", INVALID_REQUEST); + json, "IAM session policy: Invalid policy JSON - exceeds maximum length of 2048 characters", + MALFORMED_POLICY_DOCUMENT); } @Test @@ -681,20 +682,20 @@ public void testValidateAndCategorizeResourcesWithInvalidArnThrows() { public void testValidateAndCategorizeResourcesWithArnWithNoBucketThrows() { expectOMExceptionWithCode( () -> validateAndCategorizeResources(NATIVE, Collections.singleton("arn:aws:s3:::")), - "IAM session policy: Invalid Resource Arn - arn:aws:s3:::", INVALID_REQUEST); + "IAM session policy: Invalid Resource Arn - arn:aws:s3:::", MALFORMED_POLICY_DOCUMENT); expectOMExceptionWithCode( () -> validateAndCategorizeResources(RANGER, Collections.singleton("arn:aws:s3:::")), - "IAM session policy: Invalid Resource Arn - arn:aws:s3:::", INVALID_REQUEST); + "IAM session policy: Invalid Resource Arn - arn:aws:s3:::", MALFORMED_POLICY_DOCUMENT); } @Test public void testValidateAndCategorizeResourcesWithNoResourcesThrows() { expectOMExceptionWithCode( () -> validateAndCategorizeResources(NATIVE, emptySet()), "IAM session policy: No Resource(s) found in policy", - INVALID_REQUEST); + MALFORMED_POLICY_DOCUMENT); expectOMExceptionWithCode( () -> validateAndCategorizeResources(RANGER, emptySet()), "IAM session policy: No Resource(s) found in policy", - INVALID_REQUEST); + MALFORMED_POLICY_DOCUMENT); } @Test @@ -1925,7 +1926,7 @@ public void testInvalidResourceArnThrows() { "}"; expectResolveThrowsForBothAuthorizers( - json, "IAM session policy: Invalid Resource Arn - arn:aws:s3:::", INVALID_REQUEST); + json, "IAM session policy: Invalid Resource Arn - arn:aws:s3:::", MALFORMED_POLICY_DOCUMENT); } private static void expectIllegalArgumentException(Runnable runnable, String expectedMessage) { diff --git a/hadoop-ozone/interface-client/src/main/proto/OmClientProtocol.proto b/hadoop-ozone/interface-client/src/main/proto/OmClientProtocol.proto index 173b444e6f1e..1102d44966dd 100644 --- a/hadoop-ozone/interface-client/src/main/proto/OmClientProtocol.proto +++ b/hadoop-ozone/interface-client/src/main/proto/OmClientProtocol.proto @@ -578,6 +578,7 @@ enum Status { TOO_MANY_SNAPSHOTS = 98; REVOKED_TOKEN = 99; + MALFORMED_POLICY_DOCUMENT = 100; } /** 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 e2dee7dc6cf9..e0be5c5183d8 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 @@ -88,6 +88,7 @@ public class S3STSEndpoint extends S3STSEndpointBase { private static final String ACCESS_DENIED = "AccessDenied"; private static final String INVALID_CLIENT_TOKEN_ID = "InvalidClientTokenId"; private static final String UNSUPPORTED_OPERATION = "UnsupportedOperation"; + private static final String MALFORMED_POLICY_DOCUMENT = "MalformedPolicyDocument"; @Inject private RequestIdentifier requestIdentifier; @@ -284,12 +285,13 @@ private Response handleAssumeRole(String roleArn, String roleSessionName, Intege } if (omException.getResult() == OMException.ResultCodes.NOT_SUPPORTED_OPERATION || omException.getResult() == OMException.ResultCodes.FEATURE_NOT_ENABLED) { - throw new OSTSException( - UNSUPPORTED_OPERATION, omException.getMessage(), NOT_IMPLEMENTED.getStatusCode()); + throw new OSTSException(UNSUPPORTED_OPERATION, omException.getMessage(), NOT_IMPLEMENTED.getStatusCode()); } if (omException.getResult() == OMException.ResultCodes.INVALID_REQUEST) { - throw new OSTSException( - VALIDATION_ERROR, omException.getMessage(), BAD_REQUEST.getStatusCode()); + throw new OSTSException(VALIDATION_ERROR, omException.getMessage(), BAD_REQUEST.getStatusCode()); + } + if (omException.getResult() == OMException.ResultCodes.MALFORMED_POLICY_DOCUMENT) { + throw new OSTSException(MALFORMED_POLICY_DOCUMENT, omException.getMessage(), BAD_REQUEST.getStatusCode()); } } throw new OSTSException( 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 d0eaca9a5dca..059f54e0993c 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 @@ -73,6 +73,7 @@ public class TestS3STSEndpoint { private static final String ROLE_USER_ARN = "arn:aws:sts::123456789012:assumed-role/test-role/" + ROLE_SESSION_NAME; private static final String STS_NS = "https://sts.amazonaws.com/doc/2011-06-15/"; private static final String AWS_FAULT_NS = "http://webservices.amazon.com/AWSFault/2005-15-09"; + private static final String REQUEST_ID = "test-request-id"; @BeforeEach public void setup() throws Exception { @@ -105,7 +106,7 @@ public void setup() throws Exception { auditLogger = mock(AuditLogger.class); endpoint.setAuditLogger(auditLogger); - when(requestIdentifier.getRequestId()).thenReturn("test-request-id"); + when(requestIdentifier.getRequestId()).thenReturn(REQUEST_ID); endpoint.setRequestIdentifier(requestIdentifier); SignatureInfo signatureInfo = new SignatureInfo.Builder(SignatureInfo.Version.V4) @@ -212,8 +213,7 @@ public void testStsUnsupportedActionWithVersionSupplied() throws Exception { assertEquals(400, ex.getHttpCode()); verifyNoInteractions(auditLogger); - final String requestId = "test-request-id"; - ex.setRequestId(requestId); + ex.setRequestId(REQUEST_ID); assertStsErrorXml(ex.toXml(), AWS_FAULT_NS, "Sender", "InvalidAction", "Could not find operation UnsupportedAction for version 2011-06-15"); } @@ -226,8 +226,7 @@ public void testStsUnsupportedActionWithVersionNotSupplied() throws Exception { assertEquals(400, ex.getHttpCode()); verifyNoInteractions(auditLogger); - final String requestId = "test-request-id"; - ex.setRequestId(requestId); + ex.setRequestId(REQUEST_ID); assertStsErrorXml(ex.toXml(), AWS_FAULT_NS, "Sender", "InvalidAction", "Could not find operation UnsupportedAction for version NO_VERSION_SPECIFIED"); } @@ -241,8 +240,7 @@ public void testStsAssumeRoleWithInvalidVersion() throws Exception { verify(auditLogger).logWriteFailure(any(AuditMessage.class)); verify(auditLogger, never()).logWriteSuccess(any(AuditMessage.class)); - final String requestId = "test-request-id"; - ex.setRequestId(requestId); + ex.setRequestId(REQUEST_ID); assertStsErrorXml(ex.toXml(), AWS_FAULT_NS, "Sender", "InvalidAction", "Could not find operation AssumeRole for version 2000-01-01"); } @@ -256,8 +254,7 @@ public void testStsInvalidDuration() throws Exception { verify(auditLogger).logWriteFailure(any(AuditMessage.class)); verify(auditLogger, never()).logWriteSuccess(any(AuditMessage.class)); - final String requestId = "test-request-id"; - ex.setRequestId(requestId); + ex.setRequestId(REQUEST_ID); assertStsErrorXml(ex.toXml(), STS_NS, "Sender", "ValidationError", "Invalid Value: DurationSeconds"); } @@ -285,8 +282,7 @@ public void testStsPolicyTooLarge() throws Exception { verify(auditLogger).logWriteFailure(any(AuditMessage.class)); verify(auditLogger, never()).logWriteSuccess(any(AuditMessage.class)); - final String requestId = "test-request-id"; - ex.setRequestId(requestId); + ex.setRequestId(REQUEST_ID); assertStsErrorXml(ex.toXml(), STS_NS, "Sender", "ValidationError", "Value '" + tooLargePolicy + "' at 'policy' failed to satisfy constraint: Member " + "must have length less than or equal to 2048"); @@ -302,8 +298,7 @@ public void testStsInvalidRoleArn() throws Exception { verify(auditLogger).logWriteFailure(any(AuditMessage.class)); verify(auditLogger, never()).logWriteSuccess(any(AuditMessage.class)); - final String requestId = "test-request-id"; - ex.setRequestId(requestId); + ex.setRequestId(REQUEST_ID); assertStsErrorXml(ex.toXml(), STS_NS, "Sender", "ValidationError", "Invalid role ARN (does not start with arn:aws:iam::)"); } @@ -317,8 +312,7 @@ public void testStsMissingRoleArn() throws Exception { verify(auditLogger).logWriteFailure(any(AuditMessage.class)); verify(auditLogger, never()).logWriteSuccess(any(AuditMessage.class)); - final String requestId = "test-request-id"; - ex.setRequestId(requestId); + ex.setRequestId(REQUEST_ID); assertStsErrorXml(ex.toXml(), STS_NS, "Sender", "ValidationError", "Value null at 'roleArn'"); } @@ -333,8 +327,7 @@ public void testStsInvalidRoleArnMissingRoleName() throws Exception { verify(auditLogger).logWriteFailure(any(AuditMessage.class)); verify(auditLogger, never()).logWriteSuccess(any(AuditMessage.class)); - final String requestId = "test-request-id"; - ex.setRequestId(requestId); + ex.setRequestId(REQUEST_ID); assertStsErrorXml(ex.toXml(), STS_NS, "Sender", "ValidationError", "Invalid role ARN: missing role name"); } @@ -349,8 +342,7 @@ public void testStsInvalidRoleArnMissingAccountId() throws Exception { verify(auditLogger).logWriteFailure(any(AuditMessage.class)); verify(auditLogger, never()).logWriteSuccess(any(AuditMessage.class)); - final String requestId = "test-request-id"; - ex.setRequestId(requestId); + ex.setRequestId(REQUEST_ID); assertStsErrorXml(ex.toXml(), STS_NS, "Sender", "ValidationError", "Invalid AWS account ID in ARN" ); } @@ -363,8 +355,7 @@ public void testStsWhenActionNotImplemented() throws Exception { assertEquals(501, ex.getHttpCode()); verifyNoInteractions(auditLogger); - final String requestId = "test-request-id"; - ex.setRequestId(requestId); + ex.setRequestId(REQUEST_ID); assertStsErrorXml(ex.toXml(), AWS_FAULT_NS, "Sender", "InvalidAction", "Operation GetSessionToken is not supported yet."); } @@ -378,8 +369,7 @@ public void testStsMissingRoleSessionName() throws Exception { verify(auditLogger).logWriteFailure(any(AuditMessage.class)); verify(auditLogger, never()).logWriteSuccess(any(AuditMessage.class)); - final String requestId = "test-request-id"; - ex.setRequestId(requestId); + ex.setRequestId(REQUEST_ID); assertStsErrorXml(ex.toXml(), STS_NS, "Sender", "ValidationError", "Value null at 'roleSessionName'"); } @@ -393,8 +383,7 @@ public void testStsInvalidRoleSessionNameWithInvalidCharacter() throws Exception verify(auditLogger).logWriteFailure(any(AuditMessage.class)); verify(auditLogger, never()).logWriteSuccess(any(AuditMessage.class)); - final String requestId = "test-request-id"; - ex.setRequestId(requestId); + ex.setRequestId(REQUEST_ID); assertStsErrorXml( ex.toXml(), STS_NS, "Sender", "ValidationError", "1 validation error detected: " + "Invalid character '/' in RoleSessionName: it must be 2-64 characters long and contain only alphanumeric " + @@ -411,8 +400,7 @@ public void testStsInvalidRoleSessionNameTooShort() throws Exception { verify(auditLogger).logWriteFailure(any(AuditMessage.class)); verify(auditLogger, never()).logWriteSuccess(any(AuditMessage.class)); - final String requestId = "test-request-id"; - ex.setRequestId(requestId); + ex.setRequestId(REQUEST_ID); assertStsErrorXml( ex.toXml(), STS_NS, "Sender", "ValidationError", "1 validation error detected: Invalid RoleSessionName " + "length 1: it must be 2-64 characters long and contain only alphanumeric characters and +, =, ,, ., @, -"); @@ -429,8 +417,7 @@ public void testStsInvalidRoleArnResourceType() throws Exception { verify(auditLogger).logWriteFailure(any(AuditMessage.class)); verify(auditLogger, never()).logWriteSuccess(any(AuditMessage.class)); - final String requestId = "test-request-id"; - ex.setRequestId(requestId); + ex.setRequestId(REQUEST_ID); assertStsErrorXml(ex.toXml(), STS_NS, "Sender", "ValidationError", "Invalid role ARN (unexpected field count)"); } @@ -446,8 +433,7 @@ public void testStsInternalFailureWhenBackendThrows() throws Exception { verify(auditLogger).logWriteFailure(any(AuditMessage.class)); verify(auditLogger, never()).logWriteSuccess(any(AuditMessage.class)); - final String requestId = "test-request-id"; - ex.setRequestId(requestId); + ex.setRequestId(REQUEST_ID); assertStsErrorXml(ex.toXml(), STS_NS, "Receiver", "InternalFailure", "An internal error has occurred."); } @@ -463,12 +449,76 @@ public void testStsAccessDenied() throws Exception { verify(auditLogger).logWriteFailure(any(AuditMessage.class)); verify(auditLogger, never()).logWriteSuccess(any(AuditMessage.class)); - final String requestId = "test-request-id"; - ex.setRequestId(requestId); + ex.setRequestId(REQUEST_ID); assertStsErrorXml(ex.toXml(), STS_NS, "Sender", "AccessDenied", "User is not authorized to perform: sts:AssumeRole on resource: " + ROLE_ARN); } + @Test + public void testStsUnsupportedOperationWhenBackendThrowsNotSupportedOperation() throws Exception { + when(objectStore.assumeRole(anyString(), anyString(), anyInt(), any(), anyString())) + .thenThrow(new OMException("Operation is not supported", OMException.ResultCodes.NOT_SUPPORTED_OPERATION)); + + final OSTSException ex = assertThrows(OSTSException.class, () -> + endpoint.get("AssumeRole", ROLE_ARN, ROLE_SESSION_NAME, 3600, "2011-06-15", null)); + + assertEquals(501, ex.getHttpCode()); + verify(auditLogger).logWriteFailure(any(AuditMessage.class)); + verify(auditLogger, never()).logWriteSuccess(any(AuditMessage.class)); + + ex.setRequestId(REQUEST_ID); + assertStsErrorXml(ex.toXml(), STS_NS, "Sender", "UnsupportedOperation", "Operation is not supported"); + } + + @Test + public void testStsUnsupportedOperationWhenBackendThrowsFeatureNotEnabled() throws Exception { + when(objectStore.assumeRole(anyString(), anyString(), anyInt(), any(), anyString())) + .thenThrow(new OMException("Feature is not enabled", OMException.ResultCodes.FEATURE_NOT_ENABLED)); + + final OSTSException ex = assertThrows(OSTSException.class, () -> + endpoint.get("AssumeRole", ROLE_ARN, ROLE_SESSION_NAME, 3600, "2011-06-15", null)); + + assertEquals(501, ex.getHttpCode()); + verify(auditLogger).logWriteFailure(any(AuditMessage.class)); + verify(auditLogger, never()).logWriteSuccess(any(AuditMessage.class)); + + ex.setRequestId(REQUEST_ID); + assertStsErrorXml(ex.toXml(), STS_NS, "Sender", "UnsupportedOperation", "Feature is not enabled"); + } + + @Test + public void testStsValidationErrorWhenBackendThrowsInvalidRequest() throws Exception { + when(objectStore.assumeRole(anyString(), anyString(), anyInt(), any(), anyString())) + .thenThrow(new OMException("Invalid request parameter", OMException.ResultCodes.INVALID_REQUEST)); + + final OSTSException ex = assertThrows(OSTSException.class, () -> + endpoint.get("AssumeRole", ROLE_ARN, ROLE_SESSION_NAME, 3600, "2011-06-15", null)); + + assertEquals(400, ex.getHttpCode()); + verify(auditLogger).logWriteFailure(any(AuditMessage.class)); + verify(auditLogger, never()).logWriteSuccess(any(AuditMessage.class)); + + ex.setRequestId(REQUEST_ID); + assertStsErrorXml(ex.toXml(), STS_NS, "Sender", "ValidationError", "Invalid request parameter"); + } + + @Test + public void testStsMalformedPolicyDocumentWhenBackendThrowsMalformedPolicyDocument() throws Exception { + when(objectStore.assumeRole(anyString(), anyString(), anyInt(), any(), anyString())) + .thenThrow(new OMException("Malformed session policy", OMException.ResultCodes.MALFORMED_POLICY_DOCUMENT)); + + final OSTSException ex = assertThrows(OSTSException.class, () -> + endpoint.get("AssumeRole", ROLE_ARN, ROLE_SESSION_NAME, 3600, "2011-06-15", null)); + + assertEquals(400, ex.getHttpCode()); + verify(auditLogger).logWriteFailure(any(AuditMessage.class)); + verify(auditLogger, never()).logWriteSuccess(any(AuditMessage.class)); + + ex.setRequestId(REQUEST_ID); + assertStsErrorXml( + ex.toXml(), STS_NS, "Sender", "MalformedPolicyDocument", "Malformed session policy"); + } + @Test public void testStsIOExceptionWrappedAsInternalFailure() throws Exception { when(objectStore.assumeRole(anyString(), anyString(), anyInt(), any(), anyString())) @@ -481,8 +531,7 @@ public void testStsIOExceptionWrappedAsInternalFailure() throws Exception { verify(auditLogger).logWriteFailure(any(AuditMessage.class)); verify(auditLogger, never()).logWriteSuccess(any(AuditMessage.class)); - final String requestId = "test-request-id"; - ex.setRequestId(requestId); + ex.setRequestId(REQUEST_ID); assertStsErrorXml(ex.toXml(), STS_NS, "Receiver", "InternalFailure", "An internal error has occurred."); } @@ -500,8 +549,7 @@ public void testStsMultipleValidationErrors() throws Exception { verify(auditLogger).logWriteFailure(any(AuditMessage.class)); verify(auditLogger, never()).logWriteSuccess(any(AuditMessage.class)); - final String requestId = "test-request-id"; - ex.setRequestId(requestId); + ex.setRequestId(REQUEST_ID); final String xml = ex.toXml(); // The order of individual validation errors is not guaranteed because it's a HashSet, so check @@ -540,6 +588,6 @@ private static void assertStsErrorXml(String xml, String expectedNamespace, Stri assertTrue(message.contains(expectedMessageContains), "Expected message to contain: " + expectedMessageContains); final String requestId = doc.getElementsByTagName("RequestId").item(0).getTextContent(); - assertEquals("test-request-id", requestId); + assertEquals(REQUEST_ID, requestId); } } From 5a4e49f1956adbd8af37fd3d1b2f65b9f572b24d Mon Sep 17 00:00:00 2001 From: fmorg-git Date: Wed, 1 Apr 2026 02:25:06 -0700 Subject: [PATCH 29/54] HDDS-14779. [STS] Part 1 - IAM Session Policy and ListBucket improvements (#9894) --- .../hadoop/ozone/client/OzoneBucket.java | 46 ++++- .../ozone/client/protocol/ClientProtocol.java | 17 +- .../protocol/ListStatusLightOptions.java | 182 ++++++++++++++++++ .../hadoop/ozone/client/rpc/RpcClient.java | 22 ++- .../hadoop/ozone/om/helpers/OmKeyArgs.java | 19 ++ ...ManagerProtocolClientSideTranslatorPB.java | 9 +- .../src/main/proto/OmClientProtocol.proto | 3 + .../OzoneManagerRequestHandler.java | 9 +- .../ozone/client/ClientProtocolStub.java | 5 +- 9 files changed, 285 insertions(+), 27 deletions(-) create mode 100644 hadoop-ozone/client/src/main/java/org/apache/hadoop/ozone/client/protocol/ListStatusLightOptions.java diff --git a/hadoop-ozone/client/src/main/java/org/apache/hadoop/ozone/client/OzoneBucket.java b/hadoop-ozone/client/src/main/java/org/apache/hadoop/ozone/client/OzoneBucket.java index 75d7d82c5e11..d416918573ad 100644 --- a/hadoop-ozone/client/src/main/java/org/apache/hadoop/ozone/client/OzoneBucket.java +++ b/hadoop-ozone/client/src/main/java/org/apache/hadoop/ozone/client/OzoneBucket.java @@ -51,6 +51,7 @@ import org.apache.hadoop.ozone.client.io.OzoneInputStream; import org.apache.hadoop.ozone.client.io.OzoneOutputStream; import org.apache.hadoop.ozone.client.protocol.ClientProtocol; +import org.apache.hadoop.ozone.client.protocol.ListStatusLightOptions; import org.apache.hadoop.ozone.om.exceptions.OMException; import org.apache.hadoop.ozone.om.helpers.BasicOmKeyInfo; import org.apache.hadoop.ozone.om.helpers.BucketLayout; @@ -1433,9 +1434,19 @@ List getNextShallowListOfKeys(String prevKey) } // 2. Get immediate children by listStatusLight method - List statuses = - proxy.listStatusLight(volumeName, name, delimiterKeyPrefix, false, - startKey, listCacheSize, false); + // For STS auth, pass the original request prefix (if any) as listPrefix so OM + // checks LIST on that prefix instead of the internal traversal path. + final List statuses = proxy.listStatusLight( + ListStatusLightOptions.builder() + .setVolumeName(volumeName) + .setBucketName(name) + .setKeyName(delimiterKeyPrefix) + .setRecursive(false) + .setStartKey(startKey) + .setNumEntries(listCacheSize) + .setAllowPartialPrefixes(false) + .setListPrefix(getKeyPrefix()) + .build()); if (addedKeyPrefix && !statuses.isEmpty()) { // previous round already include the startKey, so remove it @@ -1674,9 +1685,19 @@ List getNextShallowListOfKeys(String prevKey) } // 2. Get immediate children by listStatus method. - List statuses = - proxy.listStatusLight(volumeName, name, getDelimiterKeyPrefix(), - false, startKey, listCacheSize, false); + // For STS auth, pass the original request prefix (if any) as listPrefix so OM + // checks LIST on that prefix instead of the internal traversal path. + List statuses = proxy.listStatusLight( + ListStatusLightOptions.builder() + .setVolumeName(volumeName) + .setBucketName(name) + .setKeyName(getDelimiterKeyPrefix()) + .setRecursive(false) + .setStartKey(startKey) + .setNumEntries(listCacheSize) + .setAllowPartialPrefixes(false) + .setListPrefix(getKeyPrefix()) + .build()); if (!statuses.isEmpty()) { // If findFirstStartKey is false, indicates that the keyPrefix is an @@ -1834,8 +1855,17 @@ private boolean getChildrenKeys(String keyPrefix, String startKey, startKey = startKey == null ? "" : startKey; // 1. Get immediate children of keyPrefix, starting with startKey - List statuses = proxy.listStatusLight(volumeName, - name, keyPrefix, false, startKey, listCacheSize, true); + List statuses = proxy.listStatusLight( + ListStatusLightOptions.builder() + .setVolumeName(volumeName) + .setBucketName(name) + .setKeyName(keyPrefix) + .setRecursive(false) + .setStartKey(startKey) + .setNumEntries(listCacheSize) + .setAllowPartialPrefixes(true) + .setListPrefix(getKeyPrefix()) + .build()); // 2. Special case: ListKey expects keyPrefix element should present in // the resultList, only if startKey is blank. If startKey is not blank diff --git a/hadoop-ozone/client/src/main/java/org/apache/hadoop/ozone/client/protocol/ClientProtocol.java b/hadoop-ozone/client/src/main/java/org/apache/hadoop/ozone/client/protocol/ClientProtocol.java index 96e8b654474e..1877d6bbed8a 100644 --- a/hadoop-ozone/client/src/main/java/org/apache/hadoop/ozone/client/protocol/ClientProtocol.java +++ b/hadoop-ozone/client/src/main/java/org/apache/hadoop/ozone/client/protocol/ClientProtocol.java @@ -1003,6 +1003,16 @@ List listStatus(String volumeName, String bucketName, /** * Lightweight listStatus API. * + * @param options Encapsulates volume, bucket, key, recursive, startKey, + * numEntries, allowPartialPrefixes, and optional listPrefix. + * @return list of file status + */ + List listStatusLight(ListStatusLightOptions options) + throws IOException; + + /** + * Lightweight listStatus API (convenience overload without listPrefix). + * * @param volumeName Volume name * @param bucketName Bucket name * @param keyName Absolute path of the entry to be listed @@ -1015,9 +1025,12 @@ List listStatus(String volumeName, String bucketName, * this is needed in context of ListKeys * @return list of file status */ - List listStatusLight(String volumeName, + default List listStatusLight(String volumeName, String bucketName, String keyName, boolean recursive, String startKey, - long numEntries, boolean allowPartialPrefixes) throws IOException; + long numEntries, boolean allowPartialPrefixes) throws IOException { + return listStatusLight(ListStatusLightOptions.of(volumeName, bucketName, + keyName, recursive, startKey, numEntries, allowPartialPrefixes)); + } /** * Add acl for Ozone object. Return true if acl is added successfully else diff --git a/hadoop-ozone/client/src/main/java/org/apache/hadoop/ozone/client/protocol/ListStatusLightOptions.java b/hadoop-ozone/client/src/main/java/org/apache/hadoop/ozone/client/protocol/ListStatusLightOptions.java new file mode 100644 index 000000000000..28fff17bbf59 --- /dev/null +++ b/hadoop-ozone/client/src/main/java/org/apache/hadoop/ozone/client/protocol/ListStatusLightOptions.java @@ -0,0 +1,182 @@ +/* + * 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.client.protocol; + +import java.util.Objects; + +/** + * Options for {@link ClientProtocol#listStatusLight(ListStatusLightOptions)}. + * Encapsulates all parameters to allow future extensibility without breaking + * the method signature. + */ +public final class ListStatusLightOptions { + + private final String volumeName; + private final String bucketName; + private final String keyName; + private final boolean recursive; + private final String startKey; + private final long numEntries; + private final boolean allowPartialPrefixes; + // When keyName is empty (root listing), this is the original S3/list + // prefix for STS auth. Enables LIST check on this prefix instead of "*". + private final String listPrefix; + + private ListStatusLightOptions(Builder b) { + this.volumeName = b.volumeName; + this.bucketName = b.bucketName; + this.keyName = b.keyName; + this.recursive = b.recursive; + this.startKey = b.startKey; + this.numEntries = b.numEntries; + this.allowPartialPrefixes = b.allowPartialPrefixes; + this.listPrefix = b.listPrefix; + } + + public String getVolumeName() { + return volumeName; + } + + public String getBucketName() { + return bucketName; + } + + public String getKeyName() { + return keyName; + } + + public boolean isRecursive() { + return recursive; + } + + public String getStartKey() { + return startKey; + } + + public long getNumEntries() { + return numEntries; + } + + public boolean isAllowPartialPrefixes() { + return allowPartialPrefixes; + } + + public String getListPrefix() { + return listPrefix; + } + + public static Builder builder() { + return new Builder(); + } + + /** + * Convenience factory for the common case (no listPrefix). + */ + public static ListStatusLightOptions of(String volumeName, String bucketName, + String keyName, boolean recursive, String startKey, long numEntries, + boolean allowPartialPrefixes) { + return builder() + .setVolumeName(volumeName) + .setBucketName(bucketName) + .setKeyName(keyName) + .setRecursive(recursive) + .setStartKey(startKey) + .setNumEntries(numEntries) + .setAllowPartialPrefixes(allowPartialPrefixes) + .build(); + } + + /** + * Builder for ListStatusLightOptions. + */ + public static final class Builder { + private String volumeName; + private String bucketName; + private String keyName; + private boolean recursive; + private String startKey; + private long numEntries; + private boolean allowPartialPrefixes; + private String listPrefix; + + public Builder setVolumeName(String volumeName) { + this.volumeName = volumeName; + return this; + } + + public Builder setBucketName(String bucketName) { + this.bucketName = bucketName; + return this; + } + + public Builder setKeyName(String keyName) { + this.keyName = keyName; + return this; + } + + public Builder setRecursive(boolean recursive) { + this.recursive = recursive; + return this; + } + + public Builder setStartKey(String startKey) { + this.startKey = startKey; + return this; + } + + public Builder setNumEntries(long numEntries) { + this.numEntries = numEntries; + return this; + } + + public Builder setAllowPartialPrefixes(boolean allowPartialPrefixes) { + this.allowPartialPrefixes = allowPartialPrefixes; + return this; + } + + public Builder setListPrefix(String listPrefix) { + this.listPrefix = listPrefix; + return this; + } + + public ListStatusLightOptions build() { + return new ListStatusLightOptions(this); + } + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + final ListStatusLightOptions that = (ListStatusLightOptions) o; + return recursive == that.recursive && numEntries == that.numEntries && + allowPartialPrefixes == that.allowPartialPrefixes && Objects.equals(volumeName, that.volumeName) && + Objects.equals(bucketName, that.bucketName) && Objects.equals(keyName, that.keyName) && + Objects.equals(startKey, that.startKey) && Objects.equals(listPrefix, that.listPrefix); + } + + @Override + public int hashCode() { + return Objects.hash( + volumeName, bucketName, keyName, recursive, startKey, numEntries, allowPartialPrefixes, listPrefix); + } +} diff --git a/hadoop-ozone/client/src/main/java/org/apache/hadoop/ozone/client/rpc/RpcClient.java b/hadoop-ozone/client/src/main/java/org/apache/hadoop/ozone/client/rpc/RpcClient.java index 02fc9eed32f0..482bb9fd9234 100644 --- a/hadoop-ozone/client/src/main/java/org/apache/hadoop/ozone/client/rpc/RpcClient.java +++ b/hadoop-ozone/client/src/main/java/org/apache/hadoop/ozone/client/rpc/RpcClient.java @@ -126,6 +126,7 @@ import org.apache.hadoop.ozone.client.io.OzoneInputStream; import org.apache.hadoop.ozone.client.io.OzoneOutputStream; import org.apache.hadoop.ozone.client.protocol.ClientProtocol; +import org.apache.hadoop.ozone.client.protocol.ListStatusLightOptions; import org.apache.hadoop.ozone.om.OmConfig; import org.apache.hadoop.ozone.om.exceptions.OMException; import org.apache.hadoop.ozone.om.helpers.AssumeRoleResponseInfo; @@ -2303,16 +2304,21 @@ public List listStatus(String volumeName, String bucketName, } @Override - public List listStatusLight(String volumeName, - String bucketName, String keyName, boolean recursive, String startKey, - long numEntries, boolean allowPartialPrefixes) throws IOException { - OmKeyArgs keyArgs = prepareOmKeyArgs(volumeName, bucketName, keyName); + public List listStatusLight(ListStatusLightOptions options) + throws IOException { + OmKeyArgs keyArgs = prepareOmKeyArgs(options.getVolumeName(), + options.getBucketName(), options.getKeyName()); + if (options.getListPrefix() != null && !options.getListPrefix().isEmpty()) { + keyArgs = keyArgs.toBuilder().setListPrefix(options.getListPrefix()).build(); + } if (omVersion.compareTo(OzoneManagerVersion.LIGHTWEIGHT_LIST_STATUS) >= 0) { - return ozoneManagerClient.listStatusLight(keyArgs, recursive, startKey, - numEntries, allowPartialPrefixes); + return ozoneManagerClient.listStatusLight( + keyArgs, options.isRecursive(), options.getStartKey(), options.getNumEntries(), + options.isAllowPartialPrefixes()); } else { - return ozoneManagerClient.listStatus(keyArgs, recursive, startKey, - numEntries, allowPartialPrefixes) + return ozoneManagerClient.listStatus( + keyArgs, options.isRecursive(), options.getStartKey(), options.getNumEntries(), + options.isAllowPartialPrefixes()) .stream() .map(OzoneFileStatusLight::fromOzoneFileStatus) .collect(Collectors.toList()); diff --git a/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/om/helpers/OmKeyArgs.java b/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/om/helpers/OmKeyArgs.java index dfe0329fbe67..b5cdcc525402 100644 --- a/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/om/helpers/OmKeyArgs.java +++ b/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/om/helpers/OmKeyArgs.java @@ -61,6 +61,9 @@ public final class OmKeyArgs extends WithMetadata implements Auditable { // This allows a key to be created an committed atomically if the original has not // been modified. private Long expectedDataGeneration = null; + // Original S3/list prefix when keyName is empty (root listing). Used for STS + // auth to check LIST on this prefix instead of "*". + private final String listPrefix; private OmKeyArgs(Builder b) { super(b); @@ -82,6 +85,7 @@ private OmKeyArgs(Builder b) { this.ownerName = b.ownerName; this.tags = b.tags.build(); this.expectedDataGeneration = b.expectedDataGeneration; + this.listPrefix = b.listPrefix; } public boolean getIsMultipartKey() { @@ -164,6 +168,14 @@ public Long getExpectedDataGeneration() { return expectedDataGeneration; } + /** + * Original S3/list prefix when keyName is empty (root listing). + * Used for STS auth to check LIST on this prefix instead of "*". + */ + public String getListPrefix() { + return listPrefix; + } + @Override public Map toAuditMap() { Map auditMap = new LinkedHashMap<>(); @@ -234,6 +246,7 @@ public static class Builder extends WithMetadata.Builder { private boolean forceUpdateContainerCacheFromSCM; private final MapBuilder tags; private Long expectedDataGeneration = null; + private String listPrefix = null; public Builder() { this(AclListBuilder.empty()); @@ -265,6 +278,7 @@ public Builder(OmKeyArgs obj) { this.expectedDataGeneration = obj.expectedDataGeneration; this.tags = MapBuilder.of(obj.tags); this.acls = AclListBuilder.of(obj.acls); + this.listPrefix = obj.listPrefix; } public Builder setVolumeName(String volume) { @@ -398,6 +412,11 @@ public Builder setExpectedDataGeneration(long generation) { return this; } + public Builder setListPrefix(String prefix) { + this.listPrefix = prefix; + return this; + } + public OmKeyArgs build() { return new OmKeyArgs(this); } diff --git a/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/om/protocolPB/OzoneManagerProtocolClientSideTranslatorPB.java b/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/om/protocolPB/OzoneManagerProtocolClientSideTranslatorPB.java index 10dc29c97e8c..9ca351bbb5a7 100644 --- a/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/om/protocolPB/OzoneManagerProtocolClientSideTranslatorPB.java +++ b/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/om/protocolPB/OzoneManagerProtocolClientSideTranslatorPB.java @@ -2370,7 +2370,7 @@ public List listStatus(OmKeyArgs args, boolean recursive, .build(); ListStatusRequest.Builder listStatusRequestBuilder = createListStatusRequestBuilder(keyArgs, recursive, startKey, - numEntries, allowPartialPrefixes); + numEntries, allowPartialPrefixes, null); OMRequest omRequest = createOMRequest(Type.ListStatus) .setListStatusRequest(listStatusRequestBuilder.build()) @@ -2399,7 +2399,7 @@ public List listStatusLight(OmKeyArgs args, .build(); ListStatusRequest.Builder listStatusRequestBuilder = createListStatusRequestBuilder(keyArgs, recursive, startKey, - numEntries, allowPartialPrefixes); + numEntries, allowPartialPrefixes, args.getListPrefix()); OMRequest omRequest = createOMRequest(Type.ListStatusLight) .setListStatusRequest(listStatusRequestBuilder.build()) @@ -2417,7 +2417,7 @@ public List listStatusLight(OmKeyArgs args, } private ListStatusRequest.Builder createListStatusRequestBuilder(KeyArgs keyArgs, boolean recursive, String startKey, - long numEntries, boolean allowPartialPrefixes) { + long numEntries, boolean allowPartialPrefixes, String listPrefix) { ListStatusRequest.Builder listStatusRequestBuilder = ListStatusRequest.newBuilder() .setKeyArgs(keyArgs) @@ -2433,6 +2433,9 @@ private ListStatusRequest.Builder createListStatusRequestBuilder(KeyArgs keyArgs if (allowPartialPrefixes) { listStatusRequestBuilder.setAllowPartialPrefix(allowPartialPrefixes); } + if (listPrefix != null && !listPrefix.isEmpty()) { + listStatusRequestBuilder.setListPrefix(listPrefix); + } return listStatusRequestBuilder; } diff --git a/hadoop-ozone/interface-client/src/main/proto/OmClientProtocol.proto b/hadoop-ozone/interface-client/src/main/proto/OmClientProtocol.proto index a6f797dd9739..9bb0d801ee7b 100644 --- a/hadoop-ozone/interface-client/src/main/proto/OmClientProtocol.proto +++ b/hadoop-ozone/interface-client/src/main/proto/OmClientProtocol.proto @@ -1307,6 +1307,9 @@ message ListStatusRequest { required string startKey = 3; required uint64 numEntries = 4; optional bool allowPartialPrefix = 5; + // When keyArgs.keyName is empty (root listing), this is the original S3/list + // prefix for STS auth. Enables LIST check on this prefix instead of "*". + optional string listPrefix = 6; } message ListStatusResponse { diff --git a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/protocolPB/OzoneManagerRequestHandler.java b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/protocolPB/OzoneManagerRequestHandler.java index bea7785bfbc2..8efc514a9d6a 100644 --- a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/protocolPB/OzoneManagerRequestHandler.java +++ b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/protocolPB/OzoneManagerRequestHandler.java @@ -1250,14 +1250,17 @@ private ListStatusResponse listStatus( private ListStatusLightResponse listStatusLight( ListStatusRequest request, int clientVersion) throws IOException { KeyArgs keyArgs = request.getKeyArgs(); - OmKeyArgs omKeyArgs = new OmKeyArgs.Builder() + OmKeyArgs.Builder omKeyArgsBuilder = new OmKeyArgs.Builder() .setVolumeName(keyArgs.getVolumeName()) .setBucketName(keyArgs.getBucketName()) .setKeyName(keyArgs.getKeyName()) .setSortDatanodesInPipeline(false) .setLatestVersionLocation(true) - .setHeadOp(keyArgs.getHeadOp()) - .build(); + .setHeadOp(keyArgs.getHeadOp()); + if (request.hasListPrefix() && !request.getListPrefix().isEmpty()) { + omKeyArgsBuilder.setListPrefix(request.getListPrefix()); + } + OmKeyArgs omKeyArgs = omKeyArgsBuilder.build(); boolean allowPartialPrefixes = request.hasAllowPartialPrefix() && request.getAllowPartialPrefix(); List statuses = diff --git a/hadoop-ozone/s3gateway/src/test/java/org/apache/hadoop/ozone/client/ClientProtocolStub.java b/hadoop-ozone/s3gateway/src/test/java/org/apache/hadoop/ozone/client/ClientProtocolStub.java index 304349f43717..5159f6214128 100644 --- a/hadoop-ozone/s3gateway/src/test/java/org/apache/hadoop/ozone/client/ClientProtocolStub.java +++ b/hadoop-ozone/s3gateway/src/test/java/org/apache/hadoop/ozone/client/ClientProtocolStub.java @@ -37,6 +37,7 @@ import org.apache.hadoop.ozone.client.io.OzoneInputStream; import org.apache.hadoop.ozone.client.io.OzoneOutputStream; import org.apache.hadoop.ozone.client.protocol.ClientProtocol; +import org.apache.hadoop.ozone.client.protocol.ListStatusLightOptions; import org.apache.hadoop.ozone.om.helpers.AssumeRoleResponseInfo; import org.apache.hadoop.ozone.om.helpers.DeleteTenantState; import org.apache.hadoop.ozone.om.helpers.ErrorInfo; @@ -575,9 +576,7 @@ public List listStatus(String volumeName, String bucketName, } @Override - public List listStatusLight(String volumeName, - String bucketName, String keyName, boolean recursive, String startKey, - long numEntries, boolean allowPartialPrefixes) throws IOException { + public List listStatusLight(ListStatusLightOptions options) throws IOException { return null; } From 538a8f2a7b7eb31a148e650fe2214c19bc5fc660 Mon Sep 17 00:00:00 2001 From: fmorg-git Date: Thu, 2 Apr 2026 04:16:11 -0700 Subject: [PATCH 30/54] HDDS-14801. [STS] Part 2 - IAM Session Policy and ListBucket improvements (#9895) --- .../hadoop/ozone/client/rpc/RpcClient.java | 23 +- ...ManagerProtocolClientSideTranslatorPB.java | 17 +- .../hadoop/ozone/om/OmMetadataReader.java | 83 +++- .../hadoop/ozone/om/TestOMMetadataReader.java | 453 ++++++++++++++++-- 4 files changed, 514 insertions(+), 62 deletions(-) diff --git a/hadoop-ozone/client/src/main/java/org/apache/hadoop/ozone/client/rpc/RpcClient.java b/hadoop-ozone/client/src/main/java/org/apache/hadoop/ozone/client/rpc/RpcClient.java index 482bb9fd9234..a9075320dbee 100644 --- a/hadoop-ozone/client/src/main/java/org/apache/hadoop/ozone/client/rpc/RpcClient.java +++ b/hadoop-ozone/client/src/main/java/org/apache/hadoop/ozone/client/rpc/RpcClient.java @@ -2253,14 +2253,17 @@ public OzoneOutputStream createFile(String volumeName, String bucketName, } private OmKeyArgs prepareOmKeyArgs(String volumeName, String bucketName, - String keyName) { - return new OmKeyArgs.Builder() + String keyName, String listPrefix) { + final OmKeyArgs.Builder builder = new OmKeyArgs.Builder() .setVolumeName(volumeName) .setBucketName(bucketName) .setKeyName(keyName) .setSortDatanodesInPipeline(topologyAwareReadEnabled) - .setLatestVersionLocation(getLatestVersionLocation) - .build(); + .setLatestVersionLocation(getLatestVersionLocation); + if (listPrefix != null && !listPrefix.isEmpty()) { + builder.setListPrefix(listPrefix); + } + return builder.build(); } @Override @@ -2288,7 +2291,8 @@ public OzoneDataStreamOutput createStreamFile(String volumeName, public List listStatus(String volumeName, String bucketName, String keyName, boolean recursive, String startKey, long numEntries) throws IOException { - OmKeyArgs keyArgs = prepareOmKeyArgs(volumeName, bucketName, keyName); + final OmKeyArgs keyArgs = prepareOmKeyArgs(volumeName, bucketName, keyName, + null); return ozoneManagerClient .listStatus(keyArgs, recursive, startKey, numEntries); } @@ -2297,7 +2301,7 @@ public List listStatus(String volumeName, String bucketName, public List listStatus(String volumeName, String bucketName, String keyName, boolean recursive, String startKey, long numEntries, boolean allowPartialPrefixes) throws IOException { - OmKeyArgs keyArgs = prepareOmKeyArgs(volumeName, bucketName, keyName); + final OmKeyArgs keyArgs = prepareOmKeyArgs(volumeName, bucketName, keyName, null); return ozoneManagerClient .listStatus(keyArgs, recursive, startKey, numEntries, allowPartialPrefixes); @@ -2306,11 +2310,8 @@ public List listStatus(String volumeName, String bucketName, @Override public List listStatusLight(ListStatusLightOptions options) throws IOException { - OmKeyArgs keyArgs = prepareOmKeyArgs(options.getVolumeName(), - options.getBucketName(), options.getKeyName()); - if (options.getListPrefix() != null && !options.getListPrefix().isEmpty()) { - keyArgs = keyArgs.toBuilder().setListPrefix(options.getListPrefix()).build(); - } + final OmKeyArgs keyArgs = prepareOmKeyArgs( + options.getVolumeName(), options.getBucketName(), options.getKeyName(), options.getListPrefix()); if (omVersion.compareTo(OzoneManagerVersion.LIGHTWEIGHT_LIST_STATUS) >= 0) { return ozoneManagerClient.listStatusLight( keyArgs, options.isRecursive(), options.getStartKey(), options.getNumEntries(), diff --git a/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/om/protocolPB/OzoneManagerProtocolClientSideTranslatorPB.java b/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/om/protocolPB/OzoneManagerProtocolClientSideTranslatorPB.java index 9ca351bbb5a7..b42b0e6ddf87 100644 --- a/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/om/protocolPB/OzoneManagerProtocolClientSideTranslatorPB.java +++ b/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/om/protocolPB/OzoneManagerProtocolClientSideTranslatorPB.java @@ -2369,8 +2369,8 @@ public List listStatus(OmKeyArgs args, boolean recursive, .setLatestVersionLocation(args.getLatestVersionLocation()) .build(); - ListStatusRequest.Builder listStatusRequestBuilder = createListStatusRequestBuilder(keyArgs, recursive, startKey, - numEntries, allowPartialPrefixes, null); + final ListStatusRequest.Builder listStatusRequestBuilder = createListStatusRequestBuilder( + keyArgs, recursive, startKey, numEntries, allowPartialPrefixes); OMRequest omRequest = createOMRequest(Type.ListStatus) .setListStatusRequest(listStatusRequestBuilder.build()) @@ -2398,8 +2398,12 @@ public List listStatusLight(OmKeyArgs args, .setLatestVersionLocation(true) .build(); - ListStatusRequest.Builder listStatusRequestBuilder = createListStatusRequestBuilder(keyArgs, recursive, startKey, - numEntries, allowPartialPrefixes, args.getListPrefix()); + final ListStatusRequest.Builder listStatusRequestBuilder = createListStatusRequestBuilder( + keyArgs, recursive, startKey, numEntries, allowPartialPrefixes); + final String listPrefix = args.getListPrefix(); + if (listPrefix != null && !listPrefix.isEmpty()) { + listStatusRequestBuilder.setListPrefix(listPrefix); + } OMRequest omRequest = createOMRequest(Type.ListStatusLight) .setListStatusRequest(listStatusRequestBuilder.build()) @@ -2417,7 +2421,7 @@ public List listStatusLight(OmKeyArgs args, } private ListStatusRequest.Builder createListStatusRequestBuilder(KeyArgs keyArgs, boolean recursive, String startKey, - long numEntries, boolean allowPartialPrefixes, String listPrefix) { + long numEntries, boolean allowPartialPrefixes) { ListStatusRequest.Builder listStatusRequestBuilder = ListStatusRequest.newBuilder() .setKeyArgs(keyArgs) @@ -2433,9 +2437,6 @@ private ListStatusRequest.Builder createListStatusRequestBuilder(KeyArgs keyArgs if (allowPartialPrefixes) { listStatusRequestBuilder.setAllowPartialPrefix(allowPartialPrefixes); } - if (listPrefix != null && !listPrefix.isEmpty()) { - listStatusRequestBuilder.setListPrefix(listPrefix); - } return listStatusRequestBuilder; } diff --git a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/OmMetadataReader.java b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/OmMetadataReader.java index 8ce694993780..b14f01cf6ba0 100644 --- a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/OmMetadataReader.java +++ b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/OmMetadataReader.java @@ -32,6 +32,7 @@ import java.util.List; import java.util.Map; import java.util.stream.Collectors; +import org.apache.commons.lang3.StringUtils; import org.apache.commons.lang3.tuple.Pair; import org.apache.hadoop.ipc_.ProtobufRpcEngine; import org.apache.hadoop.ipc_.Server; @@ -234,8 +235,34 @@ public List listStatus(OmKeyArgs args, boolean recursive, try { if (isAclEnabled) { - checkAcls(getResourceType(args), StoreType.OZONE, ACLType.READ, - bucket, args.getKeyName()); + if (isStsS3Request()) { + // We need to be able to tell the difference between being able to download a file and merely seeing the file + // name in a list. Use READ for download ability and LIST (here) for listing. + // When listPrefix is set (original S3 ListObjects prefix), authorize LIST on that prefix for the whole + // listing, including FSO traversal where keyName is an internal directory (e.g. userA) under prefix user. + final String listPrefix = args.getListPrefix(); + final String keyName = args.getKeyName(); + final String aclKey; + if (StringUtils.isNotBlank(listPrefix)) { + if (StringUtils.isBlank(keyName)) { + aclKey = listPrefix; + } else if (isStsListPathUnderRequestPrefix(keyName, listPrefix)) { + aclKey = listPrefix; + } else { + throw new OMException( + "STS listStatus: key path: " + keyName + " does not match authorized list prefix: " + listPrefix, + ResultCodes.PERMISSION_DENIED); + } + } else if (keyName != null && !keyName.isEmpty()) { + aclKey = keyName; + } else { + aclKey = "*"; + } + checkAcls(ResourceType.KEY, StoreType.OZONE, ACLType.LIST, bucket.realVolume(), bucket.realBucket(), aclKey); + } else { + checkAcls(getResourceType(args), StoreType.OZONE, ACLType.READ, + bucket, args.getKeyName()); + } } metrics.incNumListStatus(); return keyManager.listStatus(args, recursive, startKey, @@ -277,8 +304,12 @@ public OzoneFileStatus getFileStatus(OmKeyArgs args) throws IOException { try { if (isAclEnabled) { - checkAcls(getResourceType(args), StoreType.OZONE, ACLType.READ, - bucket, args.getKeyName()); + if (isStsS3Request()) { + checkAcls(getResourceType(args), StoreType.OZONE, ACLType.LIST, bucket, args.getKeyName()); + } else { + checkAcls(getResourceType(args), StoreType.OZONE, ACLType.READ, + bucket, args.getKeyName()); + } } metrics.incNumGetFileStatus(); return keyManager.getFileStatus(args, getClientAddress()); @@ -343,10 +374,23 @@ public ListKeysResult listKeys(String volumeName, String bucketName, try { if (isAclEnabled) { - captureLatencyNs(perfMetrics.getListKeysAclCheckLatencyNs(), () -> - checkAcls(ResourceType.BUCKET, StoreType.OZONE, ACLType.LIST, - bucket.realVolume(), bucket.realBucket(), keyPrefix) - ); + if (isStsS3Request()) { + // Check with key null to ensure there is LIST permission on the bucket + captureLatencyNs( + perfMetrics.getListKeysAclCheckLatencyNs(), () -> checkAcls( + ResourceType.BUCKET, StoreType.OZONE, ACLType.LIST, bucket.realVolume(), bucket.realBucket(), + null)); + // With STS we must check acl on the prefix to be compliant with AWS + final String aclKey = (keyPrefix == null || keyPrefix.isEmpty()) ? "*" : keyPrefix; + captureLatencyNs( + perfMetrics.getListKeysAclCheckLatencyNs(), () -> checkAcls( + ResourceType.KEY, StoreType.OZONE, ACLType.LIST, bucket.realVolume(), bucket.realBucket(), aclKey)); + } else { + captureLatencyNs(perfMetrics.getListKeysAclCheckLatencyNs(), () -> + checkAcls(ResourceType.BUCKET, StoreType.OZONE, ACLType.LIST, + bucket.realVolume(), bucket.realBucket(), keyPrefix) + ); + } } metrics.incNumKeyLists(); return keyManager.listKeys(bucket.realVolume(), bucket.realBucket(), @@ -698,6 +742,29 @@ public boolean isNativeAuthorizerEnabled() { return accessAuthorizer.isNative(); } + private boolean isStsS3Request() { + return getS3Auth() != null && OzoneManager.getStsTokenIdentifier() != null; + } + + /** + * For STS, {@code listPrefix} is the original S3 ListObjects prefix. Internal FSO listing may call + * {@code listStatus} with {@code keyName} set to a subdirectory (e.g. userA) while the session policy + * still authorizes only the request prefix (e.g. user). This returns true when {@code keyName} is the + * prefix itself, a key path under that prefix, or an ancestor directory on the way to a deeper prefix. + */ + private static boolean isStsListPathUnderRequestPrefix(String keyName, String listPrefix) { + if (StringUtils.isBlank(listPrefix) || StringUtils.isBlank(keyName)) { + return false; + } + if (keyName.equals(listPrefix)) { + return true; + } + if (keyName.startsWith(listPrefix)) { + return true; + } + return listPrefix.startsWith(keyName + "/"); + } + private ResourceType getResourceType(OmKeyArgs args) { if (args.getKeyName() == null || args.getKeyName().isEmpty()) { return ResourceType.BUCKET; diff --git a/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/om/TestOMMetadataReader.java b/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/om/TestOMMetadataReader.java index adfaa5d6cf03..8403d2203e01 100644 --- a/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/om/TestOMMetadataReader.java +++ b/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/om/TestOMMetadataReader.java @@ -17,20 +17,43 @@ package org.apache.hadoop.ozone.om; +import static org.apache.hadoop.ozone.om.helpers.BucketLayout.FILE_SYSTEM_OPTIMIZED; +import static org.apache.hadoop.ozone.security.acl.IAccessAuthorizer.ACLType.LIST; +import static org.apache.hadoop.ozone.security.acl.IAccessAuthorizer.ACLType.READ; +import static org.apache.hadoop.ozone.security.acl.OzoneObj.ResourceType.KEY; +import static org.apache.hadoop.ozone.security.acl.OzoneObj.ResourceType.VOLUME; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.reset; +import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import io.grpc.Context; +import java.io.IOException; import java.net.InetAddress; +import java.net.InetSocketAddress; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import org.apache.commons.lang3.tuple.Pair; +import org.apache.hadoop.hdds.conf.OzoneConfiguration; import org.apache.hadoop.ipc_.Server; +import org.apache.hadoop.metrics2.lib.MutableRate; import org.apache.hadoop.ozone.audit.AuditLogger; import org.apache.hadoop.ozone.om.exceptions.OMException; +import org.apache.hadoop.ozone.om.exceptions.OMException.ResultCodes; +import org.apache.hadoop.ozone.om.helpers.ListKeysResult; +import org.apache.hadoop.ozone.om.helpers.OmKeyArgs; +import org.apache.hadoop.ozone.om.helpers.OzoneFileStatus; +import org.apache.hadoop.ozone.om.protocolPB.grpc.GrpcClientConstants; +import org.apache.hadoop.ozone.protocol.proto.OzoneManagerProtocolProtos.S3Authentication; import org.apache.hadoop.ozone.security.STSTokenIdentifier; import org.apache.hadoop.ozone.security.acl.IAccessAuthorizer; import org.apache.hadoop.ozone.security.acl.OzoneObj; @@ -48,17 +71,20 @@ */ public class TestOMMetadataReader { + private static final String ACCESS_KEY_ID = "ASIA7O1AJD8VV4KCEAX5"; + private static final String VOLUME_NAME = "s3v"; + private static final String BUCKET_NAME = "bucket123"; + private static final String KEY_PREFIX = "prefix"; + private static final long MAX_KEYS = 100L; + @AfterEach public void clearStsThreadLocal() { OzoneManager.setStsTokenIdentifier(null); } @Test - public void testGetClientAddress() { - try ( - MockedStatic ipcServerStaticMock = mockStatic(Server.class); - MockedStatic grpcRequestContextStaticMock = mockStatic(Context.class); - ) { + public void testGetClientAddress() throws Exception { + try (MockedStatic ipcServerStaticMock = mockStatic(Server.class)) { // given String expectedClientAddressInCaseOfHadoopRpcCall = "hadoop.ipc.client.com"; @@ -66,15 +92,11 @@ public void testGetClientAddress() { .thenReturn(null, null, expectedClientAddressInCaseOfHadoopRpcCall); String expectedClientAddressInCaseOfGrpcCall = "172.45.23.4"; - Context.Key clientIpAddressKey = mock(Context.Key.class); - when(clientIpAddressKey.get()) - .thenReturn(expectedClientAddressInCaseOfGrpcCall, null); - - grpcRequestContextStaticMock.when(() -> Context.key("CLIENT_IP_ADDRESS")) - .thenReturn(clientIpAddressKey); - // when (GRPC call with defined client address) - String clientAddress = OmMetadataReader.getClientAddress(); + String clientAddress = Context.current() + .withValue(GrpcClientConstants.CLIENT_IP_ADDRESS_CTX_KEY, + expectedClientAddressInCaseOfGrpcCall) + .call(OmMetadataReader::getClientAddress); // then assertEquals(expectedClientAddressInCaseOfGrpcCall, clientAddress); @@ -93,12 +115,12 @@ public void testGetClientAddress() { @Test public void testCheckAclsAttachesSessionPolicyFromThreadLocal() throws Exception { final String sessionPolicy = "session-policy-from-thread-local"; - setupStsTokenIdentifier(sessionPolicy); + setupStsTokenIdentifier(); final IAccessAuthorizer accessAuthorizer = createMockIAccessAuthorizerReturningTrue(); final OmMetadataReader omMetadataReader = createMetadataReader(accessAuthorizer); - final RequestContext contextWithoutSessionPolicy = createTestRequestContext(null); + final RequestContext contextWithoutSessionPolicy = createTestRequestContext(); final OzoneObj obj = createTestOzoneObj(); assertTrue(omMetadataReader.checkAcls(obj, contextWithoutSessionPolicy, true)); @@ -114,7 +136,7 @@ public void testNoSessionPolicyWhenThreadLocalIsNull() throws Exception { final IAccessAuthorizer accessAuthorizer = createMockIAccessAuthorizerReturningTrue(); final OmMetadataReader omMetadataReader = createMetadataReader(accessAuthorizer); - final RequestContext contextWithoutSessionPolicy = createTestRequestContext(null); + final RequestContext contextWithoutSessionPolicy = createTestRequestContext(); final OzoneObj obj = createTestOzoneObj(); assertTrue(omMetadataReader.checkAcls(obj, contextWithoutSessionPolicy, true)); @@ -122,25 +144,265 @@ public void testNoSessionPolicyWhenThreadLocalIsNull() throws Exception { verifySessionPolicyPassedToAuthorizer(accessAuthorizer, obj, null); } - private OmMetadataReader createMetadataReader(IAccessAuthorizer accessAuthorizer) { + @Test + public void testListStatusUsesListAclForStsS3Request() throws Exception { + setupStsS3Request(); + + final IAccessAuthorizer accessAuthorizer = createMockIAccessAuthorizerReturningTrue(); + final KeyManager keyManager = createListStatusKeyManagerReturningEmpty(); + + final OmMetadataReader omMetadataReader = createMetadataReader(accessAuthorizer, keyManager); + final OmKeyArgs args = createOmKeyArgs(); + + final List statuses = omMetadataReader.listStatus(args, false, "", MAX_KEYS, false); + assertTrue(statuses.isEmpty()); + + final List checks = captureAclChecks(accessAuthorizer, 2); + + // For STS S3 requests, listStatus() performs these checks: + // 1. Volume READ (for volume access) + // 2) Key LIST (for the specific prefix being listed) - we need LIST permission for STS in order to tell whether the + // file should be listed only or downloadable (downloadable would be READ) + assertContainsVolumeReadCheck(checks); + assertContainsKeyListCheckWithName(checks, KEY_PREFIX); + } + + @Test + public void testListStatusUsesReadAclForNonStsRequest() throws Exception { + setupNonStsS3Request(); + + final IAccessAuthorizer accessAuthorizer = createMockIAccessAuthorizerReturningTrue(); + final KeyManager keyManager = createListStatusKeyManagerReturningEmpty(); + + final OmMetadataReader omMetadataReader = createMetadataReader(accessAuthorizer, keyManager); + final OmKeyArgs args = createOmKeyArgs(); + + final List statuses = omMetadataReader.listStatus(args, false, "", MAX_KEYS, false); + assertTrue(statuses.isEmpty()); + + final List checks = captureAclChecks(accessAuthorizer, 2); + assertTrue(checks.stream().allMatch(check -> check.getContext().getAclRights() == READ)); + + assertContainsVolumeReadCheck(checks); + // We want to ensure the current behavior for non-STS requests remains the same + assertContainsKeyReadCheckWithName(checks); + assertDoesNotContainKeyListCheck(checks); + } + + @Test + public void testListStatusUsesListPrefixForAclWhenKeyNameEmptyAndListPrefixSet() throws Exception { + setupStsS3Request(); + + final IAccessAuthorizer accessAuthorizer = createMockIAccessAuthorizerReturningTrue(); + final KeyManager keyManager = createListStatusKeyManagerReturningEmpty(); + + final OmMetadataReader omMetadataReader = createMetadataReader(accessAuthorizer, keyManager); + final OmKeyArgs args = new OmKeyArgs.Builder() + .setVolumeName(VOLUME_NAME) + .setBucketName(BUCKET_NAME) + .setKeyName("") + .setListPrefix("userA/") + .build(); + + final List statuses = omMetadataReader.listStatus(args, false, "", MAX_KEYS, false); + assertTrue(statuses.isEmpty()); + + final List checks = captureAclChecks(accessAuthorizer, 2); + assertContainsVolumeReadCheck(checks); + assertContainsKeyListCheckWithName(checks, "userA/"); + } + + @Test + public void testListStatusUsesWildcardForAclWhenKeyNameAndListPrefixEmpty() throws Exception { + setupStsS3Request(); + + final IAccessAuthorizer accessAuthorizer = createMockIAccessAuthorizerReturningTrue(); + final KeyManager keyManager = createListStatusKeyManagerReturningEmpty(); + + final OmMetadataReader omMetadataReader = createMetadataReader(accessAuthorizer, keyManager); + final OmKeyArgs args = new OmKeyArgs.Builder() + .setVolumeName(VOLUME_NAME) + .setBucketName(BUCKET_NAME) + .setKeyName("") + .build(); + + final List statuses = omMetadataReader.listStatus(args, false, "", MAX_KEYS, false); + assertTrue(statuses.isEmpty()); + + final List checks = captureAclChecks(accessAuthorizer, 2); + assertContainsVolumeReadCheck(checks); + assertContainsKeyListCheckWithName(checks, "*"); + } + + @Test + public void testListStatusUsesListPrefixForAclWhenKeyNameIsDescendantOfListPrefix() throws Exception { + setupStsS3Request(); + + final IAccessAuthorizer accessAuthorizer = createMockIAccessAuthorizerReturningTrue(); + final KeyManager keyManager = createListStatusKeyManagerReturningEmpty(); + + final OmMetadataReader omMetadataReader = createMetadataReader(accessAuthorizer, keyManager); + final OmKeyArgs args = new OmKeyArgs.Builder() + .setVolumeName(VOLUME_NAME) + .setBucketName(BUCKET_NAME) + .setKeyName("userA") + .setListPrefix("user") + .build(); + + final List statuses = omMetadataReader.listStatus(args, false, "", MAX_KEYS, false); + assertTrue(statuses.isEmpty()); + + final List checks = captureAclChecks(accessAuthorizer, 2); + assertContainsVolumeReadCheck(checks); + assertContainsKeyListCheckWithName(checks, "user"); + } + + @Test + public void testListStatusUsesListPrefixForAclWhenKeyNameIsAncestorOfListPrefix() throws Exception { + setupStsS3Request(); + + final IAccessAuthorizer accessAuthorizer = createMockIAccessAuthorizerReturningTrue(); + final KeyManager keyManager = createListStatusKeyManagerReturningEmpty(); + + final OmMetadataReader omMetadataReader = createMetadataReader(accessAuthorizer, keyManager); + final OmKeyArgs args = new OmKeyArgs.Builder() + .setVolumeName(VOLUME_NAME) + .setBucketName(BUCKET_NAME) + .setKeyName("user") + .setListPrefix("user/foo") + .build(); + + final List statuses = omMetadataReader.listStatus(args, false, "", MAX_KEYS, false); + assertTrue(statuses.isEmpty()); + + final List checks = captureAclChecks(accessAuthorizer, 2); + assertContainsVolumeReadCheck(checks); + assertContainsKeyListCheckWithName(checks, "user/foo"); + } + + @Test + public void testListStatusThrowsWhenStsKeyNameNotUnderListPrefix() throws Exception { + setupStsS3Request(); + + final IAccessAuthorizer accessAuthorizer = createMockIAccessAuthorizerReturningTrue(); + final KeyManager keyManager = createListStatusKeyManagerReturningEmpty(); + + final OmMetadataReader omMetadataReader = createMetadataReader(accessAuthorizer, keyManager); + final OmKeyArgs args = new OmKeyArgs.Builder() + .setVolumeName(VOLUME_NAME) + .setBucketName(BUCKET_NAME) + .setKeyName(KEY_PREFIX) + .setListPrefix("other/") + .build(); + + final OMException ex = assertThrows( + OMException.class, () -> omMetadataReader.listStatus(args, false, "", MAX_KEYS, false)); + assertEquals(ResultCodes.PERMISSION_DENIED, ex.getResult()); + } + + @Test + public void testGetFileStatusUsesListAclForStsS3Request() throws Exception { + setupStsS3Request(); + + final IAccessAuthorizer accessAuthorizer = createMockIAccessAuthorizerReturningTrue(); + final KeyManager keyManager = createGetFileStatusKeyManagerReturningStatus(); + + final OmMetadataReader omMetadataReader = createMetadataReader(accessAuthorizer, keyManager); + final OmKeyArgs args = createOmKeyArgs(); + + omMetadataReader.getFileStatus(args); + + final List checks = captureAclChecks(accessAuthorizer, 2); + assertContainsVolumeReadCheck(checks); + assertContainsKeyListCheckWithName(checks, KEY_PREFIX); + assertDoesNotContainKeyReadCheck(checks); + } + + @Test + public void testGetFileStatusUsesReadAclForNonStsS3Request() throws Exception { + setupNonStsS3Request(); + + final IAccessAuthorizer accessAuthorizer = createMockIAccessAuthorizerReturningTrue(); + final KeyManager keyManager = createGetFileStatusKeyManagerReturningStatus(); + + final OmMetadataReader omMetadataReader = createMetadataReader(accessAuthorizer, keyManager); + final OmKeyArgs args = createOmKeyArgs(); + + omMetadataReader.getFileStatus(args); + + final List checks = captureAclChecks(accessAuthorizer, 2); + assertContainsVolumeReadCheck(checks); + assertContainsKeyReadCheckWithName(checks); + assertDoesNotContainKeyListCheck(checks); + } + + @Test + public void testListKeysUsesPrefixCheckForStsS3Request() throws Exception { + setupStsS3Request(); + + final IAccessAuthorizer accessAuthorizer = createMockIAccessAuthorizerReturningTrue(); + final KeyManager keyManager = createListKeysKeyManagerReturningEmpty(); + + final OmMetadataReader omMetadataReader = createMetadataReader(accessAuthorizer, keyManager); + + // Case 1: List with prefix "userA/" + omMetadataReader.listKeys(VOLUME_NAME, BUCKET_NAME, "", "userA/", (int) MAX_KEYS); + + List checks = captureAclChecks(accessAuthorizer, 4); + assertContainsBucketListCheck(checks); + assertContainsKeyListCheckWithName(checks, "userA/"); + + // Reset to make case 2 assertions independent of case 1 captures. + reset(accessAuthorizer); + reenableAllowAllAccessChecks(accessAuthorizer); + + // Case 2: List with empty prefix (should check "*") + omMetadataReader.listKeys(VOLUME_NAME, BUCKET_NAME, "", "", (int) MAX_KEYS); + + checks = captureAclChecks(accessAuthorizer, 4); + assertContainsBucketListCheck(checks); + assertContainsKeyListCheckWithName(checks, "*"); + } + + private OmMetadataReader createMetadataReader(IAccessAuthorizer accessAuthorizer) throws IOException { + return createMetadataReader(accessAuthorizer, mock(KeyManager.class)); + } + + private OmMetadataReader createMetadataReader(IAccessAuthorizer accessAuthorizer, KeyManager keyManager) + throws IOException { final OzoneManager ozoneManager = mock(OzoneManager.class); when(ozoneManager.getBucketManager()).thenReturn(mock(BucketManager.class)); when(ozoneManager.getVolumeManager()).thenReturn(mock(VolumeManager.class)); + when(ozoneManager.getConfiguration()).thenReturn(new OzoneConfiguration()); when(ozoneManager.getAclsEnabled()).thenReturn(true); - when(ozoneManager.getPerfMetrics()).thenReturn(mock(OMPerformanceMetrics.class)); + final OMPerformanceMetrics perfMetrics = mock(OMPerformanceMetrics.class); + // OmMetadataReader uses these MutableRate metrics via MetricUtil.captureLatencyNs(...). + when(perfMetrics.getListKeysResolveBucketLatencyNs()).thenReturn(mock(MutableRate.class)); + when(perfMetrics.getListKeysAclCheckLatencyNs()).thenReturn(mock(MutableRate.class)); + when(ozoneManager.getPerfMetrics()).thenReturn(perfMetrics); + when(ozoneManager.getVolumeOwner(any(), any(), any())).thenReturn("volume-owner"); + when(ozoneManager.getBucketOwner(any(), any(), any(), any())).thenReturn("bucket-owner"); + when(ozoneManager.getOmRpcServerAddr()).thenReturn(new InetSocketAddress("127.0.0.1", 9874)); + when(ozoneManager.resolveBucketLink(any(Pair.class))) + .thenReturn( + new ResolvedBucket( + VOLUME_NAME, BUCKET_NAME, VOLUME_NAME, BUCKET_NAME, "bucket-owner", FILE_SYSTEM_OPTIMIZED)); + when(ozoneManager.resolveBucketLink(any(OmKeyArgs.class))) + .thenReturn( + new ResolvedBucket( + VOLUME_NAME, BUCKET_NAME, VOLUME_NAME, BUCKET_NAME, "bucket-owner", FILE_SYSTEM_OPTIMIZED)); return new OmMetadataReader( - mock(KeyManager.class), mock(PrefixManager.class), ozoneManager, mock(Logger.class), mock(AuditLogger.class), + keyManager, mock(PrefixManager.class), ozoneManager, mock(Logger.class), mock(AuditLogger.class), mock(OmMetadataReaderMetrics.class), accessAuthorizer); } /** - * Creates and sets a mock STSTokenIdentifier with the given session policy in the thread-local. - * @param sessionPolicy the session policy to return, or null + * Creates and sets a mock STSTokenIdentifier with a session policy in the thread-local. */ - private void setupStsTokenIdentifier(String sessionPolicy) { + private void setupStsTokenIdentifier() { final STSTokenIdentifier stsTokenIdentifier = mock(STSTokenIdentifier.class); - when(stsTokenIdentifier.getSessionPolicy()).thenReturn(sessionPolicy); + when(stsTokenIdentifier.getSessionPolicy()).thenReturn("session-policy-from-thread-local"); OzoneManager.setStsTokenIdentifier(stsTokenIdentifier); } @@ -156,23 +418,19 @@ private IAccessAuthorizer createMockIAccessAuthorizerReturningTrue() throws OMEx } /** - * Creates a test RequestContext with the given session policy. - * @param sessionPolicy the session policy to set, or null + * Creates a test RequestContext. + * * @return the constructed RequestContext */ - private RequestContext createTestRequestContext(String sessionPolicy) { + private RequestContext createTestRequestContext() { RequestContext.Builder builder = RequestContext.newBuilder() .setClientUgi(UserGroupInformation.createRemoteUser("testUser")) .setIp(InetAddress.getLoopbackAddress()) .setHost("localhost") .setAclType(IAccessAuthorizer.ACLIdentityType.USER) - .setAclRights(IAccessAuthorizer.ACLType.READ) + .setAclRights(READ) .setOwnerName("owner"); - if (sessionPolicy != null) { - builder.setSessionPolicy(sessionPolicy); - } - return builder.build(); } @@ -182,19 +440,63 @@ private RequestContext createTestRequestContext(String sessionPolicy) { */ private OzoneObj createTestOzoneObj() { return OzoneObjInfo.Builder.newBuilder() - .setResType(OzoneObj.ResourceType.KEY) + .setResType(KEY) .setStoreType(OzoneObj.StoreType.OZONE) - .setVolumeName("vol") - .setBucketName("bucket") + .setVolumeName(VOLUME_NAME) + .setBucketName(BUCKET_NAME) .setKeyName("key") .build(); } + private void setupStsS3Request() { + OzoneManager.setStsTokenIdentifier(mock(STSTokenIdentifier.class)); + OzoneManager.setS3Auth(S3Authentication.newBuilder().setAccessId(TestOMMetadataReader.ACCESS_KEY_ID).build()); + } + + private void setupNonStsS3Request() { + OzoneManager.setStsTokenIdentifier(null); + OzoneManager.setS3Auth(null); + } + + private OmKeyArgs createOmKeyArgs() { + return new OmKeyArgs.Builder() + .setVolumeName(VOLUME_NAME) + .setBucketName(BUCKET_NAME) + .setKeyName(TestOMMetadataReader.KEY_PREFIX) + .build(); + } + + private KeyManager createListStatusKeyManagerReturningEmpty() throws IOException { + final KeyManager keyManager = mock(KeyManager.class); + when(keyManager.listStatus(any(OmKeyArgs.class), eq(false), eq(""), eq(MAX_KEYS), any(), eq(false))) + .thenReturn(Collections.emptyList()); + return keyManager; + } + + private KeyManager createGetFileStatusKeyManagerReturningStatus() throws IOException { + final KeyManager keyManager = mock(KeyManager.class); + when(keyManager.getFileStatus(any(OmKeyArgs.class), any())) + .thenReturn(mock(OzoneFileStatus.class)); + return keyManager; + } + + private KeyManager createListKeysKeyManagerReturningEmpty() throws IOException { + final KeyManager keyManager = mock(KeyManager.class); + when(keyManager.listKeys(any(), any(), any(), any(), eq(100))) + .thenReturn(new ListKeysResult(Collections.emptyList(), false)); + return keyManager; + } + + private void reenableAllowAllAccessChecks(IAccessAuthorizer accessAuthorizer) throws OMException { + when(accessAuthorizer.checkAccess(any(OzoneObj.class), any(RequestContext.class))) + .thenReturn(true); + } + /** * Verifies that the accessAuthorizer received a call to checkAccess with the expected session policy. * @param accessAuthorizer the mock authorizer to verify * @param expectedObj the expected OzoneObj - * @param expectedSessionPolicy the expected session policy (may be null) + * @param expectedSessionPolicy the expected session policy (could be null) */ private void verifySessionPolicyPassedToAuthorizer(IAccessAuthorizer accessAuthorizer, OzoneObj expectedObj, String expectedSessionPolicy) throws OMException { @@ -202,4 +504,85 @@ private void verifySessionPolicyPassedToAuthorizer(IAccessAuthorizer accessAutho verify(accessAuthorizer).checkAccess(eq(expectedObj), captor.capture()); assertEquals(expectedSessionPolicy, captor.getValue().getSessionPolicy()); } + + private List captureAclChecks(IAccessAuthorizer accessAuthorizer, int expectedCheckCount) + throws OMException { + final ArgumentCaptor objCaptor = ArgumentCaptor.forClass(OzoneObj.class); + final ArgumentCaptor ctxCaptor = ArgumentCaptor.forClass(RequestContext.class); + verify(accessAuthorizer, times(expectedCheckCount)).checkAccess(objCaptor.capture(), ctxCaptor.capture()); + return toAclChecks(objCaptor.getAllValues(), ctxCaptor.getAllValues()); + } + + private List toAclChecks(List objs, List contexts) { + assertEquals(objs.size(), contexts.size(), "Captured ACL objects and contexts should align"); + final List checks = new ArrayList<>(); + for (int i = 0; i < objs.size(); i++) { + checks.add(new AclCheck(objs.get(i), contexts.get(i))); + } + return checks; + } + + private void assertContainsVolumeReadCheck(List checks) { + assertTrue(checks.stream().anyMatch(this::isVolumeReadCheck), "Expected a VOLUME READ ACL check"); + } + + private boolean isVolumeReadCheck(AclCheck check) { + return check.getObj().getResourceType() == VOLUME && check.getContext().getAclRights() == READ; + } + + private void assertContainsBucketListCheck(List checks) { + assertTrue( + checks.stream().anyMatch( + check -> check.getObj().getResourceType() == OzoneObj.ResourceType.BUCKET && + check.getContext().getAclRights() == LIST), + "Expected a BUCKET LIST ACL check"); + } + + private void assertContainsKeyListCheckWithName(List checks, String keyName) { + assertTrue( + checks.stream().anyMatch( + check -> check.getObj().getResourceType() == KEY && check.getContext().getAclRights() == LIST && + keyName.equals(check.getObj().getKeyName())), + "Expected a KEY LIST ACL check for key '" + keyName + "'"); + } + + private void assertContainsKeyReadCheckWithName(List checks) { + assertTrue( + checks.stream().anyMatch( + check -> check.getObj().getResourceType() == KEY && check.getContext().getAclRights() == READ && + TestOMMetadataReader.KEY_PREFIX.equals(check.getObj().getKeyName())), + "Expected a KEY READ ACL check for key '" + TestOMMetadataReader.KEY_PREFIX + "'"); + } + + private void assertDoesNotContainKeyReadCheck(List checks) { + assertFalse( + checks.stream().anyMatch( + check -> check.getObj().getResourceType() == KEY && check.getContext().getAclRights() == READ), + "Did not expect a KEY READ ACL check"); + } + + private void assertDoesNotContainKeyListCheck(List checks) { + assertFalse( + checks.stream().anyMatch( + check -> check.getObj().getResourceType() == KEY && check.getContext().getAclRights() == LIST), + "Did not expect a KEY LIST ACL check"); + } + + private static final class AclCheck { + private final OzoneObj obj; + private final RequestContext context; + + private AclCheck(OzoneObj obj, RequestContext context) { + this.obj = obj; + this.context = context; + } + + private OzoneObj getObj() { + return obj; + } + + private RequestContext getContext() { + return context; + } + } } From 2a855ae8a7274036006f591cc5226035ebd8b598 Mon Sep 17 00:00:00 2001 From: fmorg-git Date: Mon, 13 Apr 2026 02:38:34 -0700 Subject: [PATCH 31/54] HDDS-14808. [STS] Part 4 - IAM Session Policy and ListBucket improvements (#9899) --- .../acl/iam/IamSessionPolicyResolver.java | 76 +++++++++++------ .../acl/iam/TestIamSessionPolicyResolver.java | 85 ++++++++++++------- 2 files changed, 106 insertions(+), 55 deletions(-) diff --git a/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/security/acl/iam/IamSessionPolicyResolver.java b/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/security/acl/iam/IamSessionPolicyResolver.java index b8b032f1b358..e45c96629528 100644 --- a/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/security/acl/iam/IamSessionPolicyResolver.java +++ b/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/security/acl/iam/IamSessionPolicyResolver.java @@ -97,6 +97,8 @@ public final class IamSessionPolicyResolver { private static final String[] S3_ACTION_PREFIXES = {"s3:Get", "s3:Put", "s3:List", "s3:Delete", "s3:Create"}; private static final String ERROR_PREFIX = "IAM session policy: "; + private static final String STRING_EQUALS = "StringEquals"; + private static final String STRING_LIKE = "StringLike"; @VisibleForTesting static final Map> S3_ACTION_MAP_CI = buildCaseInsensitiveS3ActionMap(); @@ -143,7 +145,7 @@ public static Set resolve(String policyJson, Strin final Set resources = readStringOrArray(stmt.get("Resource")); // Parse prefixes from conditions, if any - final Set prefixes = parsePrefixesFromConditions(stmt); + final Condition condition = parsePrefixesFromConditions(stmt); // Map actions to S3Action enum if possible final Set mappedS3Actions = mapPolicyActionsToS3Actions(actions); @@ -156,7 +158,7 @@ public static Set resolve(String policyJson, Strin final Set resourceSpecs = validateAndCategorizeResources(authorizerType, resources); // For each action, map to Ozone objects (paths) and acls based on resource specs and prefixes - createPathsAndPermissions(volumeName, authorizerType, mappedS3Actions, resourceSpecs, prefixes, objToAclsMap); + createPathsAndPermissions(volumeName, authorizerType, mappedS3Actions, resourceSpecs, condition, objToAclsMap); } // Group accumulated objects by their ACL sets to create final result @@ -263,10 +265,10 @@ private static Set readStringOrArray(JsonNode node) { * that if there is a Condition, there is only one and that the Condition * operator and key name are supported. *

- * Only the StringEquals operator and s3:prefix key name are supported. + * Only the StringEquals and StringLike operators and s3:prefix key name are supported. */ - private static Set parsePrefixesFromConditions(JsonNode stmt) throws OMException { - Set prefixes = Collections.emptySet(); + private static Condition parsePrefixesFromConditions(JsonNode stmt) throws OMException { + Condition condition = null; final JsonNode cond = stmt.get("Condition"); if (cond != null && !cond.isMissingNode() && !cond.isNull()) { if (cond.size() != 1) { @@ -275,12 +277,12 @@ private static Set parsePrefixesFromConditions(JsonNode stmt) throws OME if (!cond.isObject()) { throw new OMException( - ERROR_PREFIX + "Invalid Condition (must have operator StringEquals or StringLike " + - "and key name s3:prefix) - " + cond, MALFORMED_POLICY_DOCUMENT); + ERROR_PREFIX + "Invalid Condition (must have operator " + STRING_EQUALS + " or " + STRING_LIKE + + " and key name s3:prefix) - " + cond, MALFORMED_POLICY_DOCUMENT); } final String operator = cond.fieldNames().next(); - if (!"StringEquals".equals(operator) && !"StringLike".equals(operator)) { + if (!STRING_EQUALS.equals(operator) && !STRING_LIKE.equals(operator)) { throw new OMException(ERROR_PREFIX + "Unsupported Condition operator - " + operator, NOT_SUPPORTED_OPERATION); } @@ -300,10 +302,11 @@ private static Set parsePrefixesFromConditions(JsonNode stmt) throws OME throw new OMException(ERROR_PREFIX + "Unsupported Condition key name - " + keyName, NOT_SUPPORTED_OPERATION); } - prefixes = readStringOrArray(operatorValue.get(keyName)); + final Set prefixes = readStringOrArray(operatorValue.get(keyName)); + condition = new Condition(operator, prefixes); } - return prefixes; + return condition; } /** @@ -427,10 +430,11 @@ static Set validateAndCategorizeResources(AuthorizerType authorize */ @VisibleForTesting static void createPathsAndPermissions(String volumeName, AuthorizerType authorizerType, Set mappedS3Actions, - Set resourceSpecs, Set prefixes, Map> objToAclsMap) { + Set resourceSpecs, Condition condition, Map> objToAclsMap) { // Process each resource spec with the given actions for (ResourceSpec resourceSpec : resourceSpecs) { - processResourceSpecWithActions(volumeName, authorizerType, mappedS3Actions, resourceSpec, prefixes, objToAclsMap); + processResourceSpecWithActions( + volumeName, authorizerType, mappedS3Actions, resourceSpec, condition, objToAclsMap); } } @@ -461,7 +465,7 @@ static Set groupObjectsByAcls(Map mappedS3Actions, ResourceSpec resourceSpec, Set prefixes, + Set mappedS3Actions, ResourceSpec resourceSpec, Condition condition, Map> objToAclsMap) { // Process based on ResourceSpec type @@ -473,13 +477,13 @@ private static void processResourceSpecWithActions(String volumeName, Authorizer processResourceTypeAny(volumeName, mappedS3Actions, objToAclsMap); break; case BUCKET: - processBucketResource(volumeName, mappedS3Actions, resourceSpec, prefixes, authorizerType, objToAclsMap); + processBucketResource(volumeName, mappedS3Actions, resourceSpec, condition, authorizerType, objToAclsMap); break; case BUCKET_WILDCARD: Preconditions.checkArgument( authorizerType != AuthorizerType.NATIVE, "ResourceSpec type BUCKET_WILDCARD not supported for OzoneNativeAuthorizer"); - processBucketResource(volumeName, mappedS3Actions, resourceSpec, prefixes, authorizerType, objToAclsMap); + processBucketResource(volumeName, mappedS3Actions, resourceSpec, condition, authorizerType, objToAclsMap); break; case OBJECT_EXACT: processObjectExactResource(volumeName, mappedS3Actions, resourceSpec, objToAclsMap); @@ -520,7 +524,7 @@ private static void processResourceTypeAny(String volumeName, Set mapp * "Resource": "arn:aws:s3:::*" */ private static void processBucketResource(String volumeName, Set mappedS3Actions, - ResourceSpec resourceSpec, Set prefixes, AuthorizerType authorizerType, + ResourceSpec resourceSpec, Condition condition, AuthorizerType authorizerType, Map> objToAclsMap) { for (S3Action action : mappedS3Actions) { // The s3:ListAllMyBuckets action can use either "*" or @@ -548,12 +552,16 @@ private static void processBucketResource(String volumeName, Set mappe if (action == S3Action.LIST_BUCKET || action == S3Action.ALL_S3) { // If condition prefixes are present, these would constrain the object permissions if the action // is s3:ListBucket or s3:* (which includes s3:ListBucket) - if (prefixes != null && !prefixes.isEmpty()) { - for (String prefix : prefixes) { + if (condition != null && condition.prefixes != null && !condition.prefixes.isEmpty()) { + for (String prefix : condition.prefixes) { + // If operator is StringEquals, we should ignore any prefix containing wildcards - this is AWS behavior + if (STRING_EQUALS.equals(condition.operator) && hasWildcard(prefix)) { + continue; + } createObjectResourcesFromConditionPrefix( volumeName, authorizerType, resourceSpec, prefix, objToAclsMap, EnumSet.of(READ)); } - } else { + } else if (condition == null) { // No condition prefixes, but we need READ access to all objects, so use "*" as the prefix createObjectResourcesFromConditionPrefix( volumeName, authorizerType, resourceSpec, "*", objToAclsMap, EnumSet.of(READ)); @@ -590,19 +598,21 @@ private static void processObjectExactResource(String volumeName, Set private static void processObjectPrefixResource(String volumeName, AuthorizerType authorizerType, Set mappedS3Actions, ResourceSpec resourceSpec, Map> objToAclsMap) { for (S3Action action : mappedS3Actions) { - // Object actions apply to prefix/key resources + // Object actions apply to prefix/key resources - ensure to add the acls only for the appropriate action type if (action.kind == ActionKind.OBJECT) { addAclsForObj(objToAclsMap, volumeObj(volumeName), action.volumePerms); addAclsForObj(objToAclsMap, bucketObj(volumeName, resourceSpec.bucket), action.bucketPerms); + // Handle the resource prefix itself (e.g., my-bucket/*) + createObjectResourcesFromResourcePrefix( + volumeName, authorizerType, resourceSpec, objToAclsMap, action.objectPerms); } else if (action == S3Action.ALL_S3) { addAclsForObj(objToAclsMap, volumeObj(volumeName), EnumSet.of(READ)); // For s3:*, ALL should only apply at the object/prefix level; grant READ at bucket level for navigation addAclsForObj(objToAclsMap, bucketObj(volumeName, resourceSpec.bucket), EnumSet.of(READ)); + // Handle the resource prefix itself (e.g., my-bucket/*) + createObjectResourcesFromResourcePrefix( + volumeName, authorizerType, resourceSpec, objToAclsMap, action.objectPerms); } - - // Handle the resource prefix itself (e.g., my-bucket/*) - createObjectResourcesFromResourcePrefix( - volumeName, authorizerType, resourceSpec, objToAclsMap, action.objectPerms); } } @@ -708,6 +718,20 @@ enum S3ResourceType { OBJECT_EXACT } + /** + * Encapsulates the Condition operator and values. + */ + @VisibleForTesting + public static final class Condition { + private final String operator; + private final Set prefixes; + + public Condition(String operator, Set prefixes) { + this.operator = operator; + this.prefixes = prefixes; + } + } + /** * Utility to help categorize IAM policy resources, whether for bucket, key, wildcards, etc. */ @@ -902,4 +926,8 @@ private static IOzoneObj volumeObj(String volumeName) { .setVolumeName(volumeName) .build(); } + + private static boolean hasWildcard(String prefix) { + return ((prefix.contains("*") || prefix.contains("?"))); + } } diff --git a/hadoop-ozone/common/src/test/java/org/apache/hadoop/ozone/security/acl/iam/TestIamSessionPolicyResolver.java b/hadoop-ozone/common/src/test/java/org/apache/hadoop/ozone/security/acl/iam/TestIamSessionPolicyResolver.java index 41d2fc338f30..ae066686e706 100644 --- a/hadoop-ozone/common/src/test/java/org/apache/hadoop/ozone/security/acl/iam/TestIamSessionPolicyResolver.java +++ b/hadoop-ozone/common/src/test/java/org/apache/hadoop/ozone/security/acl/iam/TestIamSessionPolicyResolver.java @@ -707,11 +707,11 @@ public void testCreatePathsAndPermissionsWithResourceAny() { new IamSessionPolicyResolver.ResourceSpec(S3ResourceType.ANY, "*", null, null)); expectIllegalArgumentException( - () -> createPathsAndPermissions(VOLUME, NATIVE, actions, resourceSpecs, emptySet(), new LinkedHashMap<>()), + () -> createPathsAndPermissions(VOLUME, NATIVE, actions, resourceSpecs, null, new LinkedHashMap<>()), "ResourceSpec type ANY not supported for OzoneNativeAuthorizer"); final Map> objToAclsMapRanger = new LinkedHashMap<>(); - createPathsAndPermissions(VOLUME, RANGER, actions, resourceSpecs, emptySet(), objToAclsMapRanger); + createPathsAndPermissions(VOLUME, RANGER, actions, resourceSpecs, null, objToAclsMapRanger); final Set resultRanger = groupObjectsByAcls(objToAclsMapRanger); final Set readAndListObjects = objSet(volume(), bucket("*")); // volume, bucket level have READ, LIST final Set readObject = objSet(key("*", "*")); // key level has READ @@ -729,14 +729,14 @@ public void testCreatePathsAndPermissionsWithBucketResourceThatIsListBucket() { final Map> objToAclsMapNative = new LinkedHashMap<>(); final Set nativeReadObjects = objSet(volume(), prefix("bucket1", "")); - createPathsAndPermissions(VOLUME, NATIVE, actions, resourceSpecs, emptySet(), objToAclsMapNative); + createPathsAndPermissions(VOLUME, NATIVE, actions, resourceSpecs, null, objToAclsMapNative); final Set resultNative = groupObjectsByAcls(objToAclsMapNative); assertThat(resultNative).containsExactlyInAnyOrder( new OzoneGrant(readAndListObject, acls(READ, LIST)), new OzoneGrant(nativeReadObjects, acls(READ))); final Map> objToAclsMapRanger = new LinkedHashMap<>(); final Set rangerReadObjects = objSet(volume(), key("bucket1", "*")); - createPathsAndPermissions(VOLUME, RANGER, actions, resourceSpecs, emptySet(), objToAclsMapRanger); + createPathsAndPermissions(VOLUME, RANGER, actions, resourceSpecs, null, objToAclsMapRanger); final Set resultRanger = groupObjectsByAcls(objToAclsMapRanger); assertThat(resultRanger).containsExactlyInAnyOrder( new OzoneGrant(readAndListObject, acls(READ, LIST)), new OzoneGrant(rangerReadObjects, acls(READ))); @@ -751,13 +751,13 @@ public void testCreatePathsAndPermissionsWithBucketResourceThatIsNotListBucket() final Set readObject = objSet(volume()); final Map> objToAclsMapNative = new LinkedHashMap<>(); - createPathsAndPermissions(VOLUME, NATIVE, actions, resourceSpecs, emptySet(), objToAclsMapNative); + createPathsAndPermissions(VOLUME, NATIVE, actions, resourceSpecs, null, objToAclsMapNative); final Set resultNative = groupObjectsByAcls(objToAclsMapNative); assertThat(resultNative).containsExactlyInAnyOrder( new OzoneGrant(createObject, acls(CREATE)), new OzoneGrant(readObject, acls(READ))); final Map> objToAclsMapRanger = new LinkedHashMap<>(); - createPathsAndPermissions(VOLUME, RANGER, actions, resourceSpecs, emptySet(), objToAclsMapRanger); + createPathsAndPermissions(VOLUME, RANGER, actions, resourceSpecs, null, objToAclsMapRanger); final Set resultRanger = groupObjectsByAcls(objToAclsMapRanger); assertThat(resultRanger).containsExactlyInAnyOrder( new OzoneGrant(createObject, acls(CREATE)), new OzoneGrant(readObject, acls(READ))); @@ -772,11 +772,11 @@ public void testCreatePathsAndPermissionsWithBucketWildcardResource() { final Set readVolume = objSet(volume()); expectIllegalArgumentException( - () -> createPathsAndPermissions(VOLUME, NATIVE, actions, resourceSpecs, emptySet(), new LinkedHashMap<>()), + () -> createPathsAndPermissions(VOLUME, NATIVE, actions, resourceSpecs, null, new LinkedHashMap<>()), "ResourceSpec type BUCKET_WILDCARD not supported for OzoneNativeAuthorizer"); final Map> objToAclsMapRanger = new LinkedHashMap<>(); - createPathsAndPermissions(VOLUME, RANGER, actions, resourceSpecs, emptySet(), objToAclsMapRanger); + createPathsAndPermissions(VOLUME, RANGER, actions, resourceSpecs, null, objToAclsMapRanger); final Set resultRanger = groupObjectsByAcls(objToAclsMapRanger); assertThat(resultRanger).containsExactlyInAnyOrder( new OzoneGrant(writeAclObject, acls(WRITE_ACL)), new OzoneGrant(readVolume, acls(READ))); @@ -794,11 +794,11 @@ public void testCreatePathsAndPermissionsWithBucketsWildcardResourceAll() { new IamSessionPolicyResolver.ResourceSpec(S3ResourceType.BUCKET_WILDCARD, "*", null, null)); expectIllegalArgumentException( - () -> createPathsAndPermissions(VOLUME, NATIVE, actions, resourceSpecs, emptySet(), new LinkedHashMap<>()), + () -> createPathsAndPermissions(VOLUME, NATIVE, actions, resourceSpecs, null, new LinkedHashMap<>()), "ResourceSpec type BUCKET_WILDCARD not supported for OzoneNativeAuthorizer"); final Map> objToAclsMapRanger = new LinkedHashMap<>(); - createPathsAndPermissions(VOLUME, RANGER, actions, resourceSpecs, emptySet(), objToAclsMapRanger); + createPathsAndPermissions(VOLUME, RANGER, actions, resourceSpecs, null, objToAclsMapRanger); // Both the volume and the wildcard bucket should end up with READ + LIST permissions. // We also need READ access on the keys @@ -817,12 +817,12 @@ public void testCreatePathsAndPermissionsWithObjectExactResource() { final Set readObjects = objSet(key("bucket1", "key.txt"), bucket("bucket1"), volume()); final Map> objToAclsMapNative = new LinkedHashMap<>(); - createPathsAndPermissions(VOLUME, NATIVE, actions, resourceSpecs, emptySet(), objToAclsMapNative); + createPathsAndPermissions(VOLUME, NATIVE, actions, resourceSpecs, null, objToAclsMapNative); final Set resultNative = groupObjectsByAcls(objToAclsMapNative); assertThat(resultNative).containsExactly(new OzoneGrant(readObjects, acls(READ))); final Map> objToAclsMapRanger = new LinkedHashMap<>(); - createPathsAndPermissions(VOLUME, RANGER, actions, resourceSpecs, emptySet(), objToAclsMapRanger); + createPathsAndPermissions(VOLUME, RANGER, actions, resourceSpecs, null, objToAclsMapRanger); final Set resultRanger = groupObjectsByAcls(objToAclsMapRanger); assertThat(resultRanger).containsExactly(new OzoneGrant(readObjects, acls(READ))); } @@ -835,12 +835,12 @@ public void testCreatePathsAndPermissionsWithObjectPrefixResource() { new IamSessionPolicyResolver.ResourceSpec(S3ResourceType.OBJECT_PREFIX, "bucket1", "prefix/", null)); final Set nativeReadObjects = objSet(prefix("bucket1", "prefix/"), bucket("bucket1"), volume()); final Map> objToAclsMapNative = new LinkedHashMap<>(); - createPathsAndPermissions(VOLUME, NATIVE, actions, resourceSpecs, emptySet(), objToAclsMapNative); + createPathsAndPermissions(VOLUME, NATIVE, actions, resourceSpecs, null, objToAclsMapNative); final Set resultNative = groupObjectsByAcls(objToAclsMapNative); assertThat(resultNative).containsExactly(new OzoneGrant(nativeReadObjects, acls(READ))); expectIllegalArgumentException( - () -> createPathsAndPermissions(VOLUME, RANGER, actions, resourceSpecs, emptySet(), new LinkedHashMap<>()), + () -> createPathsAndPermissions(VOLUME, RANGER, actions, resourceSpecs, null, new LinkedHashMap<>()), "ResourceSpec type OBJECT_PREFIX not supported for RangerOzoneAuthorizer"); } @@ -851,12 +851,12 @@ public void testCreatePathsAndPermissionsWithObjectPrefixWildcardResource() { new IamSessionPolicyResolver.ResourceSpec(S3ResourceType.OBJECT_PREFIX_WILDCARD, "bucket1", "prefix/*", null)); expectIllegalArgumentException( - () -> createPathsAndPermissions(VOLUME, NATIVE, actions, resourceSpecs, emptySet(), new LinkedHashMap<>()), + () -> createPathsAndPermissions(VOLUME, NATIVE, actions, resourceSpecs, null, new LinkedHashMap<>()), "ResourceSpec type OBJECT_PREFIX_WILDCARD not supported for OzoneNativeAuthorizer"); final Set rangerReadObjects = objSet(key("bucket1", "prefix/*"), bucket("bucket1"), volume()); final Map> objToAclsMapRanger = new LinkedHashMap<>(); - createPathsAndPermissions(VOLUME, RANGER, actions, resourceSpecs, emptySet(), objToAclsMapRanger); + createPathsAndPermissions(VOLUME, RANGER, actions, resourceSpecs, null, objToAclsMapRanger); final Set resultRanger = groupObjectsByAcls(objToAclsMapRanger); assertThat(resultRanger).containsExactly(new OzoneGrant(rangerReadObjects, acls(READ))); } @@ -865,12 +865,14 @@ public void testCreatePathsAndPermissionsWithObjectPrefixWildcardResource() { public void testCreatePathsAndPermissionsWithConditionPrefixesForObjectActionMustIgnoreConditionPrefixes() { final Set actions = Collections.singleton(S3Action.GET_OBJECT); final Set prefixes = strSet("folder1/", "folder2/"); + final IamSessionPolicyResolver.Condition condition = new IamSessionPolicyResolver.Condition( + "StringEquals", prefixes); final Set nativeResourceSpecs = Collections.singleton( new IamSessionPolicyResolver.ResourceSpec(S3ResourceType.OBJECT_PREFIX, "bucket1", "", null)); final Map> objToAclsMapNative = new LinkedHashMap<>(); final Set nativeReadObjects = objSet(prefix("bucket1", ""), bucket("bucket1"), volume()); - createPathsAndPermissions(VOLUME, NATIVE, actions, nativeResourceSpecs, prefixes, objToAclsMapNative); + createPathsAndPermissions(VOLUME, NATIVE, actions, nativeResourceSpecs, condition, objToAclsMapNative); final Set resultNative = groupObjectsByAcls(objToAclsMapNative); assertThat(resultNative).containsExactly(new OzoneGrant(nativeReadObjects, acls(READ))); @@ -878,7 +880,7 @@ public void testCreatePathsAndPermissionsWithConditionPrefixesForObjectActionMus new IamSessionPolicyResolver.ResourceSpec(S3ResourceType.OBJECT_PREFIX_WILDCARD, "bucket1", "*", null)); final Map> objToAclsMapRanger = new LinkedHashMap<>(); final Set rangerReadObjects = objSet(key("bucket1", "*"), bucket("bucket1"), volume()); - createPathsAndPermissions(VOLUME, RANGER, actions, rangerResourceSpecs, prefixes, objToAclsMapRanger); + createPathsAndPermissions(VOLUME, RANGER, actions, rangerResourceSpecs, condition, objToAclsMapRanger); final Set resultRanger = groupObjectsByAcls(objToAclsMapRanger); assertThat(resultRanger).containsExactly(new OzoneGrant(rangerReadObjects, acls(READ))); } @@ -887,6 +889,8 @@ public void testCreatePathsAndPermissionsWithConditionPrefixesForObjectActionMus public void testCreatePathsAndPermissionsWithConditionPrefixesForBucketActionWhenActionIsListBucket() { final Set actions = Collections.singleton(S3Action.LIST_BUCKET); final Set prefixes = strSet("folder1/", "folder2/"); + final IamSessionPolicyResolver.Condition condition = new IamSessionPolicyResolver.Condition( + "StringEquals", prefixes); final Set nativeResourceSpecs = Collections.singleton( new IamSessionPolicyResolver.ResourceSpec(S3ResourceType.BUCKET, "bucket1", null, null)); @@ -894,7 +898,7 @@ public void testCreatePathsAndPermissionsWithConditionPrefixesForBucketActionWhe prefix("bucket1", "folder1/"), prefix("bucket1", "folder2/"), volume()); final Set nativeReadAndListObject = objSet(bucket("bucket1")); final Map> objToAclsMapNative = new LinkedHashMap<>(); - createPathsAndPermissions(VOLUME, NATIVE, actions, nativeResourceSpecs, prefixes, objToAclsMapNative); + createPathsAndPermissions(VOLUME, NATIVE, actions, nativeResourceSpecs, condition, objToAclsMapNative); final Set resultNative = groupObjectsByAcls(objToAclsMapNative); assertThat(resultNative).containsExactlyInAnyOrder( new OzoneGrant(nativeReadObjects, acls(READ)), new OzoneGrant(nativeReadAndListObject, acls(READ, LIST))); @@ -905,7 +909,7 @@ public void testCreatePathsAndPermissionsWithConditionPrefixesForBucketActionWhe key("bucket1", "folder1/"), key("bucket1", "folder2/"), volume()); final Set rangerReadAndListObject = objSet(bucket("bucket1")); final Map> objToAclsMapRanger = new LinkedHashMap<>(); - createPathsAndPermissions(VOLUME, RANGER, actions, rangerResourceSpecs, prefixes, objToAclsMapRanger); + createPathsAndPermissions(VOLUME, RANGER, actions, rangerResourceSpecs, condition, objToAclsMapRanger); final Set resultRanger = groupObjectsByAcls(objToAclsMapRanger); assertThat(resultRanger).containsExactlyInAnyOrder( new OzoneGrant(rangerReadObjects, acls(READ)), new OzoneGrant(rangerReadAndListObject, acls(READ, LIST))); @@ -915,13 +919,15 @@ public void testCreatePathsAndPermissionsWithConditionPrefixesForBucketActionWhe public void testCreatePathsAndPermissionsWithConditionPrefixesForBucketActionWhenActionIsNotListBucket() { final Set actions = Collections.singleton(S3Action.GET_BUCKET_ACL); final Set prefixes = strSet("folder1/", "folder2/"); + final IamSessionPolicyResolver.Condition condition = new IamSessionPolicyResolver.Condition( + "StringEquals", prefixes); final Set readObject = objSet(volume()); final Set readAndReadAclObject = objSet(bucket("bucket1")); final Set nativeResourceSpecs = Collections.singleton( new IamSessionPolicyResolver.ResourceSpec(S3ResourceType.BUCKET, "bucket1", null, null)); final Map> objToAclsMapNative = new LinkedHashMap<>(); - createPathsAndPermissions(VOLUME, NATIVE, actions, nativeResourceSpecs, prefixes, objToAclsMapNative); + createPathsAndPermissions(VOLUME, NATIVE, actions, nativeResourceSpecs, condition, objToAclsMapNative); final Set resultNative = groupObjectsByAcls(objToAclsMapNative); assertThat(resultNative).containsExactlyInAnyOrder( new OzoneGrant(readObject, acls(READ)), new OzoneGrant(readAndReadAclObject, acls(READ, READ_ACL))); @@ -929,7 +935,7 @@ public void testCreatePathsAndPermissionsWithConditionPrefixesForBucketActionWhe final Set rangerResourceSpecs = Collections.singleton( new IamSessionPolicyResolver.ResourceSpec(S3ResourceType.BUCKET, "bucket1", null, null)); final Map> objToAclsMapRanger = new LinkedHashMap<>(); - createPathsAndPermissions(VOLUME, RANGER, actions, rangerResourceSpecs, prefixes, objToAclsMapRanger); + createPathsAndPermissions(VOLUME, RANGER, actions, rangerResourceSpecs, condition, objToAclsMapRanger); final Set resultRanger = groupObjectsByAcls(objToAclsMapRanger); assertThat(resultRanger).containsExactlyInAnyOrder( new OzoneGrant(readObject, acls(READ)), new OzoneGrant(readAndReadAclObject, acls(READ, READ_ACL))); @@ -942,14 +948,14 @@ public void testCreatePathsAndPermissionsWithNoMappedActions() { final Set nativeResourceSpecs = Collections.singleton( new IamSessionPolicyResolver.ResourceSpec(S3ResourceType.OBJECT_PREFIX, "bucket1", null, null)); final Map> objToAclsMapNative = new LinkedHashMap<>(); - createPathsAndPermissions(VOLUME, NATIVE, actions, nativeResourceSpecs, emptySet(), objToAclsMapNative); + createPathsAndPermissions(VOLUME, NATIVE, actions, nativeResourceSpecs, null, objToAclsMapNative); final Set resultNative = groupObjectsByAcls(objToAclsMapNative); assertThat(resultNative).isEmpty(); final Set rangerResourceSpecs = Collections.singleton( new IamSessionPolicyResolver.ResourceSpec(S3ResourceType.OBJECT_PREFIX_WILDCARD, "bucket1", null, null)); final Map> objToAclsMapRanger = new LinkedHashMap<>(); - createPathsAndPermissions(VOLUME, RANGER, actions, rangerResourceSpecs, emptySet(), objToAclsMapRanger); + createPathsAndPermissions(VOLUME, RANGER, actions, rangerResourceSpecs, null, objToAclsMapRanger); final Set resultRanger = groupObjectsByAcls(objToAclsMapRanger); assertThat(resultRanger).isEmpty(); } @@ -960,12 +966,12 @@ public void testCreatePathsAndPermissionsWithNoMappedResources() { final Set resourceSpecs = emptySet(); final Map> objToAclsMapNative = new LinkedHashMap<>(); - createPathsAndPermissions(VOLUME, NATIVE, actions, resourceSpecs, emptySet(), objToAclsMapNative); + createPathsAndPermissions(VOLUME, NATIVE, actions, resourceSpecs, null, objToAclsMapNative); final Set resultNative = groupObjectsByAcls(objToAclsMapNative); assertThat(resultNative).isEmpty(); final Map> objToAclsMapRanger = new LinkedHashMap<>(); - createPathsAndPermissions(VOLUME, RANGER, actions, resourceSpecs, emptySet(), objToAclsMapRanger); + createPathsAndPermissions(VOLUME, RANGER, actions, resourceSpecs, null, objToAclsMapRanger); final Set resultRanger = groupObjectsByAcls(objToAclsMapRanger); assertThat(resultRanger).isEmpty(); } @@ -981,13 +987,13 @@ public void testCreatePathsAndPermissionsDeduplicatesAcrossSameResourceTypes() { final Set readObjects = objSet(bucket("bucket1"), volume()); final Map> objToAclsMapNative = new LinkedHashMap<>(); - createPathsAndPermissions(VOLUME, NATIVE, actions, resourceSpecs, emptySet(), objToAclsMapNative); + createPathsAndPermissions(VOLUME, NATIVE, actions, resourceSpecs, null, objToAclsMapNative); final Set resultNative = groupObjectsByAcls(objToAclsMapNative); assertThat(resultNative).containsExactlyInAnyOrder( new OzoneGrant(readAndDeleteObject, acls(READ, DELETE)), new OzoneGrant(readObjects, acls(READ))); final Map> objToAclsMapRanger = new LinkedHashMap<>(); - createPathsAndPermissions(VOLUME, RANGER, actions, resourceSpecs, emptySet(), objToAclsMapRanger); + createPathsAndPermissions(VOLUME, RANGER, actions, resourceSpecs, null, objToAclsMapRanger); final Set resultRanger = groupObjectsByAcls(objToAclsMapRanger); assertThat(resultRanger).containsExactlyInAnyOrder( new OzoneGrant(readAndDeleteObject, acls(READ, DELETE)), new OzoneGrant(readObjects, acls(READ))); @@ -1006,14 +1012,14 @@ public void testCreatePathsAndPermissionsWithAllS3ActionsOverridesAnyOtherAction final Set nativeReadObjects = objSet(volume(), bucket("bucket1"), prefix("bucket2", "")); final Map> objToAclsMapNative = new LinkedHashMap<>(); - createPathsAndPermissions(VOLUME, NATIVE, actions, resourceSpecs, emptySet(), objToAclsMapNative); + createPathsAndPermissions(VOLUME, NATIVE, actions, resourceSpecs, null, objToAclsMapNative); final Set resultNative = groupObjectsByAcls(objToAclsMapNative); assertThat(resultNative).containsExactlyInAnyOrder( new OzoneGrant(allObjects, acls(ALL)), new OzoneGrant(nativeReadObjects, acls(READ))); final Set rangerReadObjects = objSet(volume(), bucket("bucket1"), key("bucket2", "*")); final Map> objToAclsMapRanger = new LinkedHashMap<>(); - createPathsAndPermissions(VOLUME, RANGER, actions, resourceSpecs, emptySet(), objToAclsMapRanger); + createPathsAndPermissions(VOLUME, RANGER, actions, resourceSpecs, null, objToAclsMapRanger); final Set resultRanger = groupObjectsByAcls(objToAclsMapRanger); assertThat(resultRanger).containsExactlyInAnyOrder( new OzoneGrant(allObjects, acls(ALL)), new OzoneGrant(rangerReadObjects, acls(READ))); @@ -1519,7 +1525,7 @@ public void testIgnoresUnsupportedActionsWhenSupportedActionsAreIncluded() throw " ],\n" + " \"Resource\": \"arn:aws:s3:::bucket1\",\n" + " \"Condition\": {\n" + - " \"StringEquals\": {\n" + + " \"StringLike\": {\n" + " \"s3:prefix\": [ \"team/folder\", \"team/folder/*\" ]\n" + " }\n" + " }\n" + @@ -1577,6 +1583,23 @@ public void testMultiplePrefixesWithWildcards() throws OMException { assertThat(resolvedFromRangerAuthorizer).isEqualTo(expectedResolvedRanger); } + @Test + public void testListBucketOnObjectResourceReturnsEmpty() throws OMException { + final String json = "{\n" + + " \"Statement\": [{\n" + + " \"Effect\": \"Allow\",\n" + + " \"Action\": \"s3:ListBucket\",\n" + + " \"Resource\": \"arn:aws:s3:::logs/*\"\n" + + " }]\n" + + "}"; + + final Set resolvedFromNativeAuthorizer = resolve(json, VOLUME, NATIVE); + final Set resolvedFromRangerAuthorizer = resolve(json, VOLUME, RANGER); + + assertThat(resolvedFromNativeAuthorizer).isEmpty(); + assertThat(resolvedFromRangerAuthorizer).isEmpty(); + } + @Test public void testObjectResourceWithWildcardInMiddle() throws OMException { final String json = "{\n" + From 1f723f7fbac02c5613a14373363ebcda1a72a685 Mon Sep 17 00:00:00 2001 From: fmorg-git Date: Tue, 14 Apr 2026 05:04:03 -0700 Subject: [PATCH 32/54] HDDS-14851. [STS] Better Handling for PayloadTooLarge and Small Perf Fix (#9938) Co-authored-by: Fabian Morgan --- .../ozone/s3/signature/AWSSignatureProcessor.java | 4 +++- .../s3sts/S3STSEnabledEndpointRequestFilter.java | 2 +- .../apache/hadoop/ozone/s3sts/S3STSEndpoint.java | 14 ++++++++++++-- 3 files changed, 16 insertions(+), 4 deletions(-) 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 92c2f102c905..343c341ee4f5 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 @@ -48,6 +48,7 @@ import org.apache.hadoop.ozone.audit.AuditMessage; import org.apache.hadoop.ozone.s3.HeaderPreprocessor; import org.apache.hadoop.ozone.s3.exception.OS3Exception; +import org.apache.hadoop.ozone.s3.exception.OSTSException; import org.apache.hadoop.ozone.s3.exception.S3ErrorTable; import org.apache.hadoop.ozone.s3.signature.SignatureInfo.Version; import org.apache.hadoop.ozone.s3.util.AuditUtils; @@ -209,7 +210,8 @@ private byte[] readAllBytes(InputStream in) throws OS3Exception, IOException { int n; while ((n = in.read(chunk)) != -1) { if (totalRead + n > OZONE_S3G_STS_PAYLOAD_HASH_MAX_VALUE) { - throw PAYLOAD_TOO_LARGE; + throw new OSTSException( + PAYLOAD_TOO_LARGE.getCode(), PAYLOAD_TOO_LARGE.getErrorMessage(), PAYLOAD_TOO_LARGE.getHttpCode()); } buffer.write(chunk, 0, n); totalRead += n; diff --git a/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3sts/S3STSEnabledEndpointRequestFilter.java b/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3sts/S3STSEnabledEndpointRequestFilter.java index 50157ea75b0f..08a93aeab3bc 100644 --- a/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3sts/S3STSEnabledEndpointRequestFilter.java +++ b/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3sts/S3STSEnabledEndpointRequestFilter.java @@ -31,7 +31,7 @@ /** * Filter that disables all endpoints annotated with {@link S3STSEnabled}. * Condition is based on the value of the configuration key - * ozone.s3g.s3sts.http.enabled. + * ozone.s3g.sts.http.enabled. */ @S3STSEnabled @Provider 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 e0be5c5183d8..091f8851fa3f 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 @@ -90,6 +90,17 @@ public class S3STSEndpoint extends S3STSEndpointBase { private static final String UNSUPPORTED_OPERATION = "UnsupportedOperation"; private static final String MALFORMED_POLICY_DOCUMENT = "MalformedPolicyDocument"; + // JAXBContext is relatively expensive to create and is threadsafe, so cache and reuse + private static final JAXBContext JAXB_CONTEXT; + + static { + try { + JAXB_CONTEXT = JAXBContext.newInstance(S3AssumeRoleResponseXml.class); + } catch (JAXBException e) { + throw new RuntimeException("Failed to initialize JAXBContext: " + e, e); + } + } + @Inject private RequestIdentifier requestIdentifier; @@ -330,8 +341,7 @@ private String generateAssumeRoleResponse(String assumedRoleUserArn, AssumeRoleR meta.setRequestId(requestId); response.setResponseMetadata(meta); - final JAXBContext jaxbContext = JAXBContext.newInstance(S3AssumeRoleResponseXml.class); - final Marshaller marshaller = jaxbContext.createMarshaller(); + final Marshaller marshaller = JAXB_CONTEXT.createMarshaller(); marshaller.setProperty(Marshaller.JAXB_FORMATTED_OUTPUT, Boolean.TRUE); final StringWriter stringWriter = new StringWriter(); marshaller.marshal(response, stringWriter); From 9b3577e8d7960f9dcbb4889e4ebe062c2f9de29b Mon Sep 17 00:00:00 2001 From: fmorg-git Date: Tue, 14 Apr 2026 08:37:40 -0700 Subject: [PATCH 33/54] HDDS-14847. [STS] Expose ExpiredToken Error (#9935) --- .../ozone/security/STSSecurityUtil.java | 13 +- .../ozone/security/TestSTSSecurityUtil.java | 11 +- hadoop-ozone/s3gateway/pom.xml | 4 + .../ozone/s3/endpoint/BucketAclHandler.java | 4 + .../ozone/s3/endpoint/BucketCrudHandler.java | 2 + .../ozone/s3/endpoint/BucketEndpoint.java | 120 +++++++++++------- .../ozone/s3/endpoint/EndpointBase.java | 14 ++ .../s3/endpoint/MultipartKeyHandler.java | 2 + .../ozone/s3/endpoint/ObjectEndpoint.java | 19 +++ .../s3/endpoint/ObjectEndpointStreaming.java | 4 + .../ozone/s3/exception/OS3Exception.java | 50 ++++++-- .../s3/exception/OS3ExceptionMapper.java | 18 ++- .../ozone/s3/exception/S3ErrorTable.java | 3 + .../ozone/s3/endpoint/TestEndpointBase.java | 11 ++ .../ozone/s3/exception/TestOS3Exceptions.java | 23 ++++ 15 files changed, 230 insertions(+), 68 deletions(-) diff --git a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/security/STSSecurityUtil.java b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/security/STSSecurityUtil.java index 44d8b63b973f..c414708cebee 100644 --- a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/security/STSSecurityUtil.java +++ b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/security/STSSecurityUtil.java @@ -18,6 +18,7 @@ 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.TOKEN_EXPIRED; import com.google.common.annotations.VisibleForTesting; import com.google.protobuf.InvalidProtocolBufferException; @@ -71,7 +72,7 @@ public static STSTokenIdentifier constructValidateAndDecryptSTSToken(String sess * @throws SecretManager.InvalidToken if the token is invalid */ private static STSTokenIdentifier verifyAndDecryptToken(Token token, - SecretKeyClient secretKeyClient, Clock clock) throws SecretManager.InvalidToken { + SecretKeyClient secretKeyClient, Clock clock) throws SecretManager.InvalidToken, OMException { if (!STSTokenIdentifier.KIND_NAME.equals(token.getKind())) { throw new SecretManager.InvalidToken("Invalid STS token - kind is incorrect: " + token.getKind()); } @@ -100,6 +101,8 @@ private static STSTokenIdentifier verifyAndDecryptToken(Token STSSecurityUtil.constructValidateAndDecryptSTSToken(tokenString, secretKeyClient, clock)) .isInstanceOf(OMException.class) - .hasMessageContaining("Invalid STS token format: Invalid STS token - token expired at"); + .satisfies(exception -> assertThat(((OMException) exception).getResult()).isEqualTo(TOKEN_EXPIRED)) + .hasMessageContaining("Invalid STS token - token expired at"); } @Test @@ -236,6 +238,8 @@ public void testConstructValidateAndDecryptSTSTokenExpiredSecretKey() throws Exc // Create a mock secret key that is expired final ManagedSecretKey expiredSecretKey = mock(ManagedSecretKey.class); when(expiredSecretKey.isExpired()).thenReturn(true); + final Instant now = Instant.now(); + when(expiredSecretKey.getExpiryTime()).thenReturn(now); final SecretKeyClient mockKeyClient = mock(SecretKeyClient.class); when(mockKeyClient.getSecretKey(any())).thenReturn(expiredSecretKey); @@ -244,9 +248,8 @@ public void testConstructValidateAndDecryptSTSTokenExpiredSecretKey() throws Exc assertThatThrownBy(() -> STSSecurityUtil.constructValidateAndDecryptSTSToken(validTokenString, mockKeyClient, clock)) .isInstanceOf(OMException.class) - .hasMessage( - "Invalid STS token format: Invalid STS token - could not readFromByteArray: Token cannot be " + - "verified due to expired secret key " + secretKeyId); + .satisfies(exception -> assertThat(((OMException) exception).getResult()).isEqualTo(TOKEN_EXPIRED)) + .hasMessage("Token cannot be verified due to expired secret key: " + secretKeyId + " Token expired at " + now); } @Test diff --git a/hadoop-ozone/s3gateway/pom.xml b/hadoop-ozone/s3gateway/pom.xml index bebe6d1d07ba..7278c9e5315f 100644 --- a/hadoop-ozone/s3gateway/pom.xml +++ b/hadoop-ozone/s3gateway/pom.xml @@ -30,6 +30,10 @@ + + com.fasterxml.jackson.core + jackson-annotations + com.fasterxml.jackson.core jackson-databind diff --git a/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/endpoint/BucketAclHandler.java b/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/endpoint/BucketAclHandler.java index 1ac30f49797c..21d00cd419bb 100644 --- a/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/endpoint/BucketAclHandler.java +++ b/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/endpoint/BucketAclHandler.java @@ -113,6 +113,8 @@ Response handleGetRequest(S3RequestContext context, String bucketName) auditReadFailure(context.getAction(), ex); if (ex.getResult() == ResultCodes.BUCKET_NOT_FOUND) { throw newError(S3ErrorTable.NO_SUCH_BUCKET, bucketName, ex); + } else if (isExpiredToken(ex)) { + throw newError(S3ErrorTable.EXPIRED_TOKEN, bucketName, ex); } else if (isAccessDenied(ex)) { throw newError(S3ErrorTable.ACCESS_DENIED, bucketName, ex); } else { @@ -232,6 +234,8 @@ Response handlePutRequest(S3RequestContext context, String bucketName, InputStre auditWriteFailure(context.getAction(), exception); if (exception.getResult() == ResultCodes.BUCKET_NOT_FOUND) { throw newError(S3ErrorTable.NO_SUCH_BUCKET, bucketName, exception); + } else if (isExpiredToken(exception)) { + throw newError(S3ErrorTable.EXPIRED_TOKEN, bucketName, exception); } else if (isAccessDenied(exception)) { throw newError(S3ErrorTable.ACCESS_DENIED, bucketName, exception); } diff --git a/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/endpoint/BucketCrudHandler.java b/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/endpoint/BucketCrudHandler.java index 982838d0dd04..81c9b2836a30 100644 --- a/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/endpoint/BucketCrudHandler.java +++ b/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/endpoint/BucketCrudHandler.java @@ -111,6 +111,8 @@ Response handleDeleteRequest(S3RequestContext context, String bucketName) throw newError(S3ErrorTable.BUCKET_NOT_EMPTY, bucketName, ex); } else if (ex.getResult() == OMException.ResultCodes.BUCKET_NOT_FOUND) { throw newError(S3ErrorTable.NO_SUCH_BUCKET, bucketName, ex); + } else if (isExpiredToken(ex)) { + throw newError(S3ErrorTable.EXPIRED_TOKEN, bucketName, ex); } else if (isAccessDenied(ex)) { throw newError(S3ErrorTable.ACCESS_DENIED, bucketName, ex); } else { diff --git a/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/endpoint/BucketEndpoint.java b/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/endpoint/BucketEndpoint.java index 18ba9f34934f..799af5d7fa95 100644 --- a/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/endpoint/BucketEndpoint.java +++ b/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/endpoint/BucketEndpoint.java @@ -163,14 +163,7 @@ public Response get( } catch (OMException ex) { auditReadFailure(s3GAction, ex); getMetrics().updateGetBucketFailureStats(startNanos); - if (isAccessDenied(ex)) { - throw newError(S3ErrorTable.ACCESS_DENIED, bucketName, ex); - } else if (ex.getResult() == ResultCodes.FILE_NOT_FOUND) { - // File not found, continue and send normal response with 0 keyCount - LOG.debug("Key Not found prefix: {}", prefix); - } else { - throw ex; - } + handleOMException(ex, bucketName, prefix); } catch (Exception ex) { getMetrics().updateGetBucketFailureStats(startNanos); auditReadFailure(s3GAction, ex); @@ -210,53 +203,67 @@ public Response get( String lastKey = null; int count = 0; if (maxKeys > 0) { - while (ozoneKeyIterator != null && ozoneKeyIterator.hasNext()) { - OzoneKey next = ozoneKeyIterator.next(); - if (bucket != null && bucket.getBucketLayout().isFileSystemOptimized() && - StringUtils.isNotEmpty(prefix) && - !next.getName().startsWith(prefix)) { - // prefix has delimiter but key don't have - // example prefix: dir1/ key: dir123 - continue; - } - if (startAfter != null && count == 0 && Objects.equals(startAfter, next.getName())) { - continue; - } - String relativeKeyName = next.getName().substring(prefix.length()); - - int depth = StringUtils.countMatches(relativeKeyName, delimiter); - if (!StringUtils.isEmpty(delimiter)) { - if (depth > 0) { - // means key has multiple delimiters in its value. - // ex: dir/dir1/dir2, where delimiter is "/" and prefix is dir/ - String dirName = relativeKeyName.substring(0, relativeKeyName - .indexOf(delimiter)); - if (!dirName.equals(prevDir)) { - response.addPrefix(EncodingTypeObject.createNullable( - prefix + dirName + delimiter, encodingType)); - prevDir = dirName; + try { + while (ozoneKeyIterator != null && ozoneKeyIterator.hasNext()) { + OzoneKey next = ozoneKeyIterator.next(); + if (bucket != null && bucket.getBucketLayout().isFileSystemOptimized() && + StringUtils.isNotEmpty(prefix) && + !next.getName().startsWith(prefix)) { + // prefix has delimiter but key don't have + // example prefix: dir1/ key: dir123 + continue; + } + if (startAfter != null && count == 0 && Objects.equals(startAfter, next.getName())) { + continue; + } + String relativeKeyName = next.getName().substring(prefix.length()); + + int depth = StringUtils.countMatches(relativeKeyName, delimiter); + if (!StringUtils.isEmpty(delimiter)) { + if (depth > 0) { + // means key has multiple delimiters in its value. + // ex: dir/dir1/dir2, where delimiter is "/" and prefix is dir/ + String dirName = relativeKeyName.substring(0, relativeKeyName + .indexOf(delimiter)); + if (!dirName.equals(prevDir)) { + response.addPrefix(EncodingTypeObject.createNullable( + prefix + dirName + delimiter, encodingType)); + prevDir = dirName; + count++; + } + } else if (relativeKeyName.endsWith(delimiter)) { + // means or key is same as prefix with delimiter at end and ends with + // delimiter. ex: dir/, where prefix is dir and delimiter is / + response.addPrefix( + EncodingTypeObject.createNullable(relativeKeyName, encodingType)); + count++; + } else { + // means our key is matched with prefix if prefix is given and it + // does not have any common prefix. + addKey(response, next); count++; } - } else if (relativeKeyName.endsWith(delimiter)) { - // means or key is same as prefix with delimiter at end and ends with - // delimiter. ex: dir/, where prefix is dir and delimiter is / - response.addPrefix( - EncodingTypeObject.createNullable(relativeKeyName, encodingType)); - count++; } else { - // means our key is matched with prefix if prefix is given and it - // does not have any common prefix. addKey(response, next); count++; } - } else { - addKey(response, next); - count++; - } - if (count == maxKeys) { - lastKey = next.getName(); - break; + if (count == maxKeys) { + lastKey = next.getName(); + break; + } + } + } catch (RuntimeException ex) { + getMetrics().updateGetBucketFailureStats(startNanos); + auditReadFailure(s3GAction, ex); + if (ex.getCause() instanceof OMException) { + final OMException omException = (OMException) ex.getCause(); + if (omException.getResult() == ResultCodes.FILE_NOT_FOUND) { + throw ex; + } + handleOMException(omException, bucketName, prefix); + } else { + throw ex; } } } @@ -362,7 +369,9 @@ public Response listMultipartUploads( } catch (OMException exception) { auditReadFailure(s3GAction, exception); getMetrics().updateListMultipartUploadsFailureStats(startNanos); - if (isAccessDenied(exception)) { + if (isExpiredToken(exception)) { + throw newError(S3ErrorTable.EXPIRED_TOKEN, prefix, exception); + } else if (isAccessDenied(exception)) { throw newError(S3ErrorTable.ACCESS_DENIED, prefix, exception); } throw exception; @@ -517,4 +526,17 @@ private void addHandler(BucketOperationHandler handler) { copyDependenciesTo(handler); handlers.add(handler); } + + private void handleOMException(OMException ex, String bucketName, String prefix) throws OMException { + if (isExpiredToken(ex)) { + throw newError(S3ErrorTable.EXPIRED_TOKEN, bucketName, ex); + } else if (isAccessDenied(ex)) { + throw newError(S3ErrorTable.ACCESS_DENIED, bucketName, ex); + } else if (ex.getResult() == ResultCodes.FILE_NOT_FOUND) { + // File not found, continue and send normal response with 0 keyCount + LOG.debug("Key Not found prefix: {}", prefix); + } else { + throw ex; + } + } } 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 3d7f70d06c35..53bba420e67e 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 @@ -194,6 +194,8 @@ protected OzoneBucket getBucket(OzoneVolume volume, String bucketName) } catch (OMException ex) { if (ex.getResult() == ResultCodes.BUCKET_NOT_FOUND) { throw newError(S3ErrorTable.NO_SUCH_BUCKET, bucketName, ex); + } else if (isExpiredToken(ex)) { + throw newError(S3ErrorTable.EXPIRED_TOKEN, s3Auth.getAccessID(), ex); } else if (ex.getResult() == ResultCodes.INVALID_TOKEN) { throw newError(S3ErrorTable.ACCESS_DENIED, s3Auth.getAccessID(), ex); @@ -259,6 +261,8 @@ protected OzoneBucket getBucket(String bucketName) if (ex.getResult() == ResultCodes.BUCKET_NOT_FOUND || ex.getResult() == ResultCodes.VOLUME_NOT_FOUND) { throw newError(S3ErrorTable.NO_SUCH_BUCKET, bucketName, ex); + } else if (isExpiredToken(ex)) { + throw newError(S3ErrorTable.EXPIRED_TOKEN, s3Auth.getAccessID(), ex); } else if (ex.getResult() == ResultCodes.INVALID_TOKEN) { throw newError(S3ErrorTable.ACCESS_DENIED, s3Auth.getAccessID(), ex); @@ -294,6 +298,8 @@ protected String createS3Bucket(String bucketName) throws getMetrics().updateCreateBucketFailureStats(startNanos); if (ex.getResult() == ResultCodes.PERMISSION_DENIED) { throw newError(S3ErrorTable.ACCESS_DENIED, bucketName, ex); + } else if (isExpiredToken(ex)) { + throw newError(S3ErrorTable.EXPIRED_TOKEN, s3Auth.getAccessID(), ex); } else if (ex.getResult() == ResultCodes.INVALID_TOKEN) { throw newError(S3ErrorTable.ACCESS_DENIED, s3Auth.getAccessID(), ex); @@ -322,6 +328,8 @@ protected void deleteS3Bucket(String s3BucketName) if (ex.getResult() == ResultCodes.PERMISSION_DENIED) { throw newError(S3ErrorTable.ACCESS_DENIED, s3BucketName, ex); + } else if (isExpiredToken(ex)) { + throw newError(S3ErrorTable.EXPIRED_TOKEN, s3Auth.getAccessID(), ex); } else if (ex.getResult() == ResultCodes.INVALID_TOKEN) { throw newError(S3ErrorTable.ACCESS_DENIED, s3Auth.getAccessID(), ex); @@ -377,6 +385,8 @@ private Iterator iterateBuckets( } else if (e.getResult() == ResultCodes.PERMISSION_DENIED) { throw newError(S3ErrorTable.ACCESS_DENIED, "listBuckets", e); + } else if (isExpiredToken(e)) { + throw newError(S3ErrorTable.EXPIRED_TOKEN, s3Auth.getAccessID(), e); } else if (e.getResult() == ResultCodes.INVALID_TOKEN) { throw newError(S3ErrorTable.ACCESS_DENIED, s3Auth.getAccessID(), e); @@ -685,6 +695,10 @@ protected boolean isAccessDenied(OMException ex) { || result == ResultCodes.REVOKED_TOKEN; } + protected boolean isExpiredToken(OMException ex) { + return ex.getResult() == ResultCodes.TOKEN_EXPIRED; + } + protected ReplicationConfig getReplicationConfig(OzoneBucket ozoneBucket) throws OS3Exception { String storageType = getHeaders().getHeaderString(STORAGE_CLASS_HEADER); String storageConfig = getHeaders().getHeaderString(CUSTOM_METADATA_HEADER_PREFIX + STORAGE_CONFIG_HEADER); diff --git a/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/endpoint/MultipartKeyHandler.java b/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/endpoint/MultipartKeyHandler.java index 69edae429207..5d31728929fd 100644 --- a/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/endpoint/MultipartKeyHandler.java +++ b/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/endpoint/MultipartKeyHandler.java @@ -164,6 +164,8 @@ private Response listParts(OzoneBucket ozoneBucket, String key, String uploadId, } catch (OMException ex) { if (ex.getResult() == ResultCodes.NO_SUCH_MULTIPART_UPLOAD_ERROR) { throw newError(NO_SUCH_UPLOAD, uploadId, ex); + } else if (isExpiredToken(ex)) { + throw newError(S3ErrorTable.EXPIRED_TOKEN, bucketName + "/" + key + "/" + uploadId, ex); } else if (isAccessDenied(ex)) { throw newError(S3ErrorTable.ACCESS_DENIED, bucketName + "/" + key + "/" + uploadId, ex); diff --git a/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/endpoint/ObjectEndpoint.java b/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/endpoint/ObjectEndpoint.java index b18cf35d0d32..d97c514f9ae6 100644 --- a/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/endpoint/ObjectEndpoint.java +++ b/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/endpoint/ObjectEndpoint.java @@ -179,6 +179,8 @@ public Response put( " considered as Unix Paths. Path has Violated FS Semantics " + "which caused put operation to fail."); throw os3Exception; + } else if (isExpiredToken(ex)) { + throw newError(S3ErrorTable.EXPIRED_TOKEN, keyPath, ex); } else if (isAccessDenied(ex)) { throw newError(S3ErrorTable.ACCESS_DENIED, keyPath, ex); } else if (ex.getResult() == ResultCodes.QUOTA_EXCEEDED) { @@ -375,6 +377,8 @@ public Response get( } catch (OMException ex) { if (ex.getResult() == ResultCodes.KEY_NOT_FOUND) { throw newError(S3ErrorTable.NO_SUCH_KEY, keyPath, ex); + } else if (isExpiredToken(ex)) { + throw newError(S3ErrorTable.EXPIRED_TOKEN, keyPath, ex); } else if (isAccessDenied(ex)) { throw newError(S3ErrorTable.ACCESS_DENIED, keyPath, ex); } else if (ex.getResult() == ResultCodes.BUCKET_NOT_FOUND) { @@ -554,6 +558,8 @@ public Response head( if (ex.getResult() == ResultCodes.KEY_NOT_FOUND) { // Just return 404 with no content return Response.status(Status.NOT_FOUND).build(); + } else if (isExpiredToken(ex)) { + throw newError(S3ErrorTable.EXPIRED_TOKEN, keyPath, ex); } else if (isAccessDenied(ex)) { throw newError(S3ErrorTable.ACCESS_DENIED, keyPath, ex); } else if (ex.getResult() == ResultCodes.BUCKET_NOT_FOUND) { @@ -640,6 +646,8 @@ public Response delete( // NOT_FOUND is not a problem, AWS doesn't throw exception for missing // keys. Just return 204 return Response.status(Status.NO_CONTENT).build(); + } else if (isExpiredToken(ex)) { + throw newError(S3ErrorTable.EXPIRED_TOKEN, keyPath, ex); } else if (isAccessDenied(ex)) { throw newError(S3ErrorTable.ACCESS_DENIED, keyPath, ex); } else if (ex.getResult() == ResultCodes.NOT_SUPPORTED_OPERATION) { @@ -714,6 +722,9 @@ public Response initializeMultipartUpload( } catch (OMException ex) { auditWriteFailure(s3GAction, ex); getMetrics().updateInitMultipartUploadFailureStats(startNanos); + if (isExpiredToken(ex)) { + throw newError(S3ErrorTable.EXPIRED_TOKEN, key, ex); + } if (isAccessDenied(ex)) { throw newError(S3ErrorTable.ACCESS_DENIED, key, ex); } @@ -794,6 +805,10 @@ public Response completeMultipartUpload( "considered as Unix Paths. A directory already exists with a " + "given KeyName caused failure for MPU"); throw os3Exception; + } else if (isExpiredToken(ex)) { + throw newError(S3ErrorTable.EXPIRED_TOKEN, key, ex); + } else if (isAccessDenied(ex)) { + throw newError(S3ErrorTable.ACCESS_DENIED, key, ex); } else if (ex.getResult() == ResultCodes.BUCKET_NOT_FOUND) { throw newError(S3ErrorTable.NO_SUCH_BUCKET, bucket, ex); } @@ -959,6 +974,8 @@ private Response createMultipartKey(OzoneVolume volume, OzoneBucket ozoneBucket, } if (ex.getResult() == ResultCodes.NO_SUCH_MULTIPART_UPLOAD_ERROR) { throw newError(NO_SUCH_UPLOAD, uploadID, ex); + } else if (isExpiredToken(ex)) { + throw newError(S3ErrorTable.EXPIRED_TOKEN, bucketName + "/" + key, ex); } else if (isAccessDenied(ex)) { throw newError(S3ErrorTable.ACCESS_DENIED, bucketName + "/" + key, ex); } else if (ex.getResult() == ResultCodes.INVALID_PART) { @@ -1114,6 +1131,8 @@ private CopyObjectResponse copyObject(OzoneVolume volume, throw newError(S3ErrorTable.NO_SUCH_KEY, sourceKey, ex); } else if (ex.getResult() == ResultCodes.BUCKET_NOT_FOUND) { throw newError(S3ErrorTable.NO_SUCH_BUCKET, sourceBucket, ex); + } else if (isExpiredToken(ex)) { + throw newError(S3ErrorTable.EXPIRED_TOKEN, destBucket + "/" + destkey, ex); } else if (isAccessDenied(ex)) { throw newError(S3ErrorTable.ACCESS_DENIED, destBucket + "/" + destkey, ex); diff --git a/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/endpoint/ObjectEndpointStreaming.java b/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/endpoint/ObjectEndpointStreaming.java index 767c11506dc1..7012734b1611 100644 --- a/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/endpoint/ObjectEndpointStreaming.java +++ b/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/endpoint/ObjectEndpointStreaming.java @@ -92,6 +92,8 @@ public static Pair put( " considered as Unix Paths. Path has Violated FS Semantics " + "which caused put operation to fail."); throw os3Exception; + } else if ((((OMException) ex).getResult() == OMException.ResultCodes.TOKEN_EXPIRED)) { + throw S3ErrorTable.newError(S3ErrorTable.EXPIRED_TOKEN, keyPath); } else if ((((OMException) ex).getResult() == OMException.ResultCodes.PERMISSION_DENIED)) { throw S3ErrorTable.newError(S3ErrorTable.ACCESS_DENIED, keyPath); @@ -230,6 +232,8 @@ public static Response createMultipartKey(OzoneBucket ozoneBucket, String key, OMException.ResultCodes.NO_SUCH_MULTIPART_UPLOAD_ERROR) { throw S3ErrorTable.newError(NO_SUCH_UPLOAD, uploadID); + } else if (ex.getResult() == OMException.ResultCodes.TOKEN_EXPIRED) { + throw S3ErrorTable.newError(S3ErrorTable.EXPIRED_TOKEN, ozoneBucket.getName() + "/" + key); } else if (ex.getResult() == OMException.ResultCodes.PERMISSION_DENIED) { throw S3ErrorTable.newError(S3ErrorTable.ACCESS_DENIED, ozoneBucket.getName() + "/" + key); diff --git a/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/exception/OS3Exception.java b/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/exception/OS3Exception.java index f93f4a7a4d7a..009e22c42cbf 100644 --- a/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/exception/OS3Exception.java +++ b/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/exception/OS3Exception.java @@ -17,10 +17,12 @@ package org.apache.hadoop.ozone.s3.exception; +import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.SerializationFeature; import com.fasterxml.jackson.dataformat.xml.XmlMapper; import com.fasterxml.jackson.module.jaxb.JaxbAnnotationModule; +import com.google.common.base.Strings; import javax.xml.bind.annotation.XmlAccessType; import javax.xml.bind.annotation.XmlAccessorType; import javax.xml.bind.annotation.XmlElement; @@ -59,6 +61,14 @@ public class OS3Exception extends RuntimeException { @XmlElement(name = "RequestId") private String requestId; + @JsonInclude(JsonInclude.Include.NON_EMPTY) + @XmlElement(name = "HostId") + private String hostId; + + @JsonInclude(JsonInclude.Include.NON_EMPTY) + @XmlElement(name = "Token-0") + private String token0; + @XmlTransient private int httpCode; @@ -125,6 +135,22 @@ public void setResource(String resource) { this.resource = resource; } + public String getHostId() { + return hostId; + } + + public void setHostId(String hostId) { + this.hostId = hostId; + } + + public String getToken0() { + return token0; + } + + public void setToken0(String token0) { + this.token0 = token0; + } + public int getHttpCode() { return httpCode; } @@ -146,16 +172,20 @@ public String toXml() { //When we get exception log it, and return exception as xml from actual // exception data. So, falling back to construct from exception. - String formatString = "" + - "" + - "%s" + - "%s" + - "%s" + - "%s" + - ""; - return String.format(formatString, this.getCode(), - this.getErrorMessage(), this.getResource(), - this.getRequestId()); + final StringBuilder builder = new StringBuilder("") + .append("") + .append("").append(this.getCode()).append("") + .append("").append(this.getErrorMessage()).append("") + .append("").append(this.getResource()).append("") + .append("").append(this.getRequestId()).append(""); + if (!Strings.isNullOrEmpty(this.getHostId())) { + builder.append("").append(this.getHostId()).append(""); + } + if (!Strings.isNullOrEmpty(this.getToken0())) { + builder.append("").append(this.getToken0()).append(""); + } + builder.append(""); + return builder.toString(); } /** Create a copy with specific message. */ diff --git a/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/exception/OS3ExceptionMapper.java b/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/exception/OS3ExceptionMapper.java index 5f110144c118..b576cb69b4c9 100644 --- a/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/exception/OS3ExceptionMapper.java +++ b/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/exception/OS3ExceptionMapper.java @@ -18,10 +18,12 @@ package org.apache.hadoop.ozone.s3.exception; import javax.inject.Inject; +import javax.ws.rs.core.MediaType; import javax.ws.rs.core.Response; import javax.ws.rs.ext.ExceptionMapper; import javax.ws.rs.ext.Provider; import org.apache.hadoop.ozone.s3.RequestIdentifier; +import org.apache.hadoop.ozone.s3.signature.SignatureInfo; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -32,19 +34,33 @@ @Provider public class OS3ExceptionMapper implements ExceptionMapper { + private static final String EXPIRED_TOKEN = "ExpiredToken"; + private static final Logger LOG = LoggerFactory.getLogger(OS3ExceptionMapper.class); @Inject private RequestIdentifier requestIdentifier; + @Inject + private SignatureInfo signatureInfo; + @Override public Response toResponse(OS3Exception exception) { if (LOG.isDebugEnabled()) { LOG.debug("Returning exception. ex: {}", exception.toString()); } exception.setRequestId(requestIdentifier.getRequestId()); + exception.setHostId(requestIdentifier.getAmzId()); + if (EXPIRED_TOKEN.equals(exception.getCode()) && signatureInfo != null) { + final String sessionToken = signatureInfo.getSessionToken(); + if (sessionToken != null && !sessionToken.isEmpty()) { + exception.setToken0(sessionToken); + } + } return Response.status(exception.getHttpCode()) - .entity(exception.toXml()).build(); + .entity(exception.toXml()) + .type(MediaType.APPLICATION_XML) + .build(); } } diff --git a/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/exception/S3ErrorTable.java b/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/exception/S3ErrorTable.java index 301f5940af67..c7baaa95080b 100644 --- a/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/exception/S3ErrorTable.java +++ b/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/exception/S3ErrorTable.java @@ -114,6 +114,9 @@ public final class S3ErrorTable { "AccessDenied", "User doesn't have the right to access this " + "resource.", HTTP_FORBIDDEN); + public static final OS3Exception EXPIRED_TOKEN = new OS3Exception( + "ExpiredToken", "The provided token has expired.", HTTP_FORBIDDEN); + public static final OS3Exception PRECOND_FAILED = new OS3Exception( "PreconditionFailed", "At least one of the pre-conditions you " + "specified did not hold", HTTP_PRECON_FAILED); diff --git a/hadoop-ozone/s3gateway/src/test/java/org/apache/hadoop/ozone/s3/endpoint/TestEndpointBase.java b/hadoop-ozone/s3gateway/src/test/java/org/apache/hadoop/ozone/s3/endpoint/TestEndpointBase.java index 2b29bb9bcfb0..89d5a26f21a5 100644 --- a/hadoop-ozone/s3gateway/src/test/java/org/apache/hadoop/ozone/s3/endpoint/TestEndpointBase.java +++ b/hadoop-ozone/s3gateway/src/test/java/org/apache/hadoop/ozone/s3/endpoint/TestEndpointBase.java @@ -126,4 +126,15 @@ public void init() { } assertFalse(endpointBase.isAccessDenied(new OMException(ResultCodes.BUCKET_NOT_FOUND))); } + @Test + public void testExpiredTokenResultCode() { + final EndpointBase endpointBase = new EndpointBase() { + @Override + public void init() { } + }; + + assertTrue(endpointBase.isExpiredToken(new OMException(ResultCodes.TOKEN_EXPIRED))); + assertFalse(endpointBase.isExpiredToken(new OMException(ResultCodes.INVALID_TOKEN))); + } + } diff --git a/hadoop-ozone/s3gateway/src/test/java/org/apache/hadoop/ozone/s3/exception/TestOS3Exceptions.java b/hadoop-ozone/s3gateway/src/test/java/org/apache/hadoop/ozone/s3/exception/TestOS3Exceptions.java index a4ae1fce25b1..9c1eeb41aa38 100644 --- a/hadoop-ozone/s3gateway/src/test/java/org/apache/hadoop/ozone/s3/exception/TestOS3Exceptions.java +++ b/hadoop-ozone/s3gateway/src/test/java/org/apache/hadoop/ozone/s3/exception/TestOS3Exceptions.java @@ -47,4 +47,27 @@ public void testOS3Exceptions() { ex.getRequestId()); assertEquals(expected, val); } + + @Test + public void testOS3ExceptionWithToken0() { + OS3Exception ex = new OS3Exception("ExpiredToken", "The provided token has expired.", 403); + ex = S3ErrorTable.newError(ex, "resource"); + ex.setRequestId(OzoneUtils.getRequestID()); + ex.setHostId(OzoneUtils.getRequestID()); + ex.setToken0("token-value"); + + final String val = ex.toXml(); + final String formatString = "%n" + + "%n" + + " %s%n" + + " %s%n" + + " %s%n" + + " %s%n" + + " %s%n" + + " %s%n" + + "%n"; + final String expected = String.format(formatString, ex.getCode(), ex.getErrorMessage(), ex.getResource(), + ex.getRequestId(), ex.getHostId(), ex.getToken0()); + assertEquals(expected, val); + } } From 9504881b5f8890ef6a0f09e054adccf1da667d24 Mon Sep 17 00:00:00 2001 From: fmorg-git Date: Mon, 20 Apr 2026 22:11:37 -0700 Subject: [PATCH 34/54] HDDS-14809. [STS] Part 5 - IAM Session Policy and ListBucket improvements (#9900) --- .../acl/iam/IamSessionPolicyResolver.java | 48 +++++- .../acl/iam/TestIamSessionPolicyResolver.java | 137 ++++++++++++++++-- 2 files changed, 168 insertions(+), 17 deletions(-) diff --git a/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/security/acl/iam/IamSessionPolicyResolver.java b/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/security/acl/iam/IamSessionPolicyResolver.java index e45c96629528..285a11c550f3 100644 --- a/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/security/acl/iam/IamSessionPolicyResolver.java +++ b/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/security/acl/iam/IamSessionPolicyResolver.java @@ -154,11 +154,20 @@ public static Set resolve(String policyJson, Strin continue; } + // s3:prefix is only applicable to the ListBucket action because we don't support ListBucketVersions + // (see https://docs.aws.amazon.com/service-authorization/latest/reference/list_amazons3.html and search for + // s3:prefix). If a statement carries a Condition, non-ListBucket actions (ex GetObject, PutObject, + // ListBucketMultipartUploads, etc.) in that statement do not apply. + final Set filteredS3Actions = filterActionsWhenConditionPresent(mappedS3Actions, condition); + if (filteredS3Actions.isEmpty()) { + continue; + } + // Categorize resources according to bucket resource, object resource, etc final Set resourceSpecs = validateAndCategorizeResources(authorizerType, resources); // For each action, map to Ozone objects (paths) and acls based on resource specs and prefixes - createPathsAndPermissions(volumeName, authorizerType, mappedS3Actions, resourceSpecs, condition, objToAclsMap); + createPathsAndPermissions(volumeName, authorizerType, filteredS3Actions, resourceSpecs, condition, objToAclsMap); } // Group accumulated objects by their ACL sets to create final result @@ -359,6 +368,23 @@ static Set mapPolicyActionsToS3Actions(Set actions) { return mappedActions; } + /** + * Filters out actions when a Condition is present if the action is not ListBucket. + */ + private static Set filterActionsWhenConditionPresent(Set mappedS3Actions, Condition condition) { + if (condition == null) { + return mappedS3Actions; + } + + if (mappedS3Actions.contains(S3Action.LIST_BUCKET) || mappedS3Actions.contains(S3Action.ALL_S3)) { + final Set filteredActions = new HashSet<>(); + filteredActions.add(S3Action.LIST_BUCKET); + return filteredActions; + } + + return Collections.emptySet(); + } + /** * Validates that wildcard bucket patterns are not used with native authorizer. */ @@ -474,7 +500,7 @@ private static void processResourceSpecWithActions(String volumeName, Authorizer Preconditions.checkArgument( authorizerType != AuthorizerType.NATIVE, "ResourceSpec type ANY not supported for OzoneNativeAuthorizer"); - processResourceTypeAny(volumeName, mappedS3Actions, objToAclsMap); + processResourceTypeAny(volumeName, authorizerType, mappedS3Actions, condition, objToAclsMap); break; case BUCKET: processBucketResource(volumeName, mappedS3Actions, resourceSpec, condition, authorizerType, objToAclsMap); @@ -509,12 +535,24 @@ private static void processResourceSpecWithActions(String volumeName, Authorizer * Handles ResourceType.ANY (*). * Example: "Resource": "*" */ - private static void processResourceTypeAny(String volumeName, Set mappedS3Actions, - Map> objToAclsMap) { + private static void processResourceTypeAny(String volumeName, AuthorizerType authorizerType, + Set mappedS3Actions, Condition condition, Map> objToAclsMap) { for (S3Action action : mappedS3Actions) { addAclsForObj(objToAclsMap, volumeObj(volumeName), action.volumePerms); addAclsForObj(objToAclsMap, bucketObj(volumeName, "*"), action.bucketPerms); - addAclsForObj(objToAclsMap, keyObj(volumeName, "*", "*"), action.objectPerms); + if (condition != null && condition.prefixes != null && !condition.prefixes.isEmpty() && + (action == S3Action.LIST_BUCKET || action == S3Action.ALL_S3)) { + for (String prefix : condition.prefixes) { + // If operator is StringEquals, ignore wildcard prefixes - this is AWS behavior + if (STRING_EQUALS.equals(condition.operator) && hasWildcard(prefix)) { + continue; + } + createObjectResourcesFromConditionPrefix( + volumeName, authorizerType, ResourceSpec.any(), prefix, objToAclsMap, EnumSet.of(READ)); + } + } else { + addAclsForObj(objToAclsMap, keyObj(volumeName, "*", "*"), action.objectPerms); + } } } diff --git a/hadoop-ozone/common/src/test/java/org/apache/hadoop/ozone/security/acl/iam/TestIamSessionPolicyResolver.java b/hadoop-ozone/common/src/test/java/org/apache/hadoop/ozone/security/acl/iam/TestIamSessionPolicyResolver.java index ae066686e706..0a8d1c47412d 100644 --- a/hadoop-ozone/common/src/test/java/org/apache/hadoop/ozone/security/acl/iam/TestIamSessionPolicyResolver.java +++ b/hadoop-ozone/common/src/test/java/org/apache/hadoop/ozone/security/acl/iam/TestIamSessionPolicyResolver.java @@ -1258,6 +1258,30 @@ public void testAllActionsForKey() throws OMException { assertThat(resolvedFromRangerAuthorizer).isEqualTo(expectedResolvedRanger); } + @Test + public void testAllActionsForKeyWithPrefixCondition() throws OMException { + final String json = "{\n" + + " \"Statement\": [{\n" + + " \"Effect\": \"Allow\",\n" + + " \"Action\": \"s3:*\",\n" + + " \"Resource\": \"arn:aws:s3:::my-bucket/*\",\n" + + " \"Condition\": {\n" + + " \"StringLike\": {\n" + + " \"s3:prefix\": [ \"team/folder\", \"team/folder/*\" ]\n" + + " }\n" + + " }\n" + + " }]\n" + + "}"; + + final Set resolvedFromNativeAuthorizer = resolve(json, VOLUME, NATIVE); + final Set resolvedFromRangerAuthorizer = resolve(json, VOLUME, RANGER); + + // Ensure what we got is what we expected - only ListBucket supports s3:prefix and that is a bucket action, + // not object action + assertThat(resolvedFromNativeAuthorizer).isEmpty(); + assertThat(resolvedFromRangerAuthorizer).isEmpty(); + } + @Test public void testAllActionsForBucket() throws OMException { final String json = "{\n" + @@ -1287,6 +1311,46 @@ public void testAllActionsForBucket() throws OMException { assertThat(resolvedFromRangerAuthorizer).isEqualTo(expectedResolvedRanger); } + @Test + public void testAllActionsForBucketWithPrefixCondition() throws OMException { + final String json = "{\n" + + " \"Statement\": [{\n" + + " \"Effect\": \"Allow\",\n" + + " \"Action\": \"s3:*\",\n" + + " \"Resource\": \"arn:aws:s3:::my-bucket\",\n" + + " \"Condition\": {\n" + + " \"StringLike\": {\n" + + " \"s3:prefix\": [ \"team/folder\", \"team/folder/*\" ]\n" + + " }\n" + + " }\n" + + " }]\n" + + "}"; + + final Set resolvedFromNativeAuthorizer = resolve(json, VOLUME, NATIVE); + final Set resolvedFromRangerAuthorizer = resolve(json, VOLUME, RANGER); + + // Ensure what we got is what we expected + final Set expectedResolvedNative = new LinkedHashSet<>(); + // Expected for native: READ, LIST ACLs for bucket (only ListBucket supports s3:prefix); volume READ; + // prefix "team/folder", "team/folder/" READ + final Set bucketSet = objSet(bucket("my-bucket")); + final Set bucketAcls = acls(READ, LIST); + expectedResolvedNative.add( + new OzoneGrant(objSet(volume(), prefix("my-bucket", "team/folder"), prefix("my-bucket", "team/folder/")), + acls(READ))); + expectedResolvedNative.add(new OzoneGrant(bucketSet, bucketAcls)); + assertThat(resolvedFromNativeAuthorizer).isEqualTo(expectedResolvedNative); + + // Expected for Ranger: READ, LIST ACLs for bucket (only ListBucket supports s3:prefix); volume READ, + // key "team/folder", "team/folder/*" READ + final Set expectedResolvedRanger = new LinkedHashSet<>(); + expectedResolvedRanger.add( + new OzoneGrant(objSet(volume(), key("my-bucket", "team/folder"), key("my-bucket", "team/folder/*")), + acls(READ))); + expectedResolvedRanger.add(new OzoneGrant(bucketSet, bucketAcls)); + assertThat(resolvedFromRangerAuthorizer).isEqualTo(expectedResolvedRanger); + } + @Test public void testMultipleResourcesInSeparateStatements() throws OMException { final String json = "{\n" + @@ -1517,11 +1581,11 @@ public void testIgnoresUnsupportedActionsWhenSupportedActionsAreIncluded() throw " \"Effect\": \"Allow\",\n" + " \"Action\": [\n" + " \"s3:GetAccelerateConfiguration\",\n" + // unsupported action - " \"s3:GetBucketAcl\",\n" + + " \"s3:GetBucketAcl\",\n" + // ignored because it doesn't support s3:prefix condition " \"s3:GetObject\",\n" + // object-level action not applied for bucket " \"s3:GetObjectAcl\",\n" + // unsupported action " \"s3:ListBucket\",\n" + - " \"s3:ListBucketMultipartUploads\"\n" + + " \"s3:ListBucketMultipartUploads\"\n" + // ignored because it doesn't support s3:prefix condition " ],\n" + " \"Resource\": \"arn:aws:s3:::bucket1\",\n" + " \"Condition\": {\n" + @@ -1539,16 +1603,16 @@ public void testIgnoresUnsupportedActionsWhenSupportedActionsAreIncluded() throw // Ensure what we got is what we expected final Set expectedResolvedNative = new LinkedHashSet<>(); - // Expected for native: READ, LIST, READ_ACL bucket acls; volume and prefixes "team/folder", "team/folder/" READ + // Expected for native: READ, LIST bucket acls; volume and prefixes "team/folder", "team/folder/" READ final Set bucketSet = objSet(bucket("bucket1")); - final Set bucketAcls = acls(READ, LIST, READ_ACL); + final Set bucketAcls = acls(READ, LIST); expectedResolvedNative.add(new OzoneGrant(bucketSet, bucketAcls)); expectedResolvedNative.add(new OzoneGrant( objSet(volume(), prefix("bucket1", "team/folder"), prefix("bucket1", "team/folder/")), acls(READ))); assertThat(resolvedFromNativeAuthorizer).isEqualTo(expectedResolvedNative); final Set expectedResolvedRanger = new LinkedHashSet<>(); - // Expected for Ranger: READ, LIST, READ_ACL bucket acls; volume and keys "team/folder" and "team/folder/*" READ + // Expected for Ranger: READ, LIST bucket acls; volume and keys "team/folder" and "team/folder/*" READ expectedResolvedRanger.add(new OzoneGrant(bucketSet, bucketAcls)); expectedResolvedRanger.add(new OzoneGrant( objSet(volume(), key("bucket1", "team/folder"), key("bucket1", "team/folder/*")), acls(READ))); @@ -1569,17 +1633,37 @@ public void testMultiplePrefixesWithWildcards() throws OMException { final Set resolvedFromNativeAuthorizer = resolve(json, VOLUME, NATIVE); final Set resolvedFromRangerAuthorizer = resolve(json, VOLUME, RANGER); - // Ensure what we got is what we expected + // s3:prefix conditions do not apply to object actions like s3:GetObject. + assertThat(resolvedFromNativeAuthorizer).isEmpty(); + assertThat(resolvedFromRangerAuthorizer).isEmpty(); + } + + @Test + public void testListAndGetWithPrefixConditionSkipsObjectAction() throws OMException { + final String json = "{\n" + + " \"Statement\": [{\n" + + " \"Effect\": \"Allow\",\n" + + " \"Action\": [\"s3:ListBucket\", \"s3:GetObject\"],\n" + + " \"Resource\": [\"arn:aws:s3:::logs\", \"arn:aws:s3:::logs/*\"],\n" + + " \"Condition\": { \"StringLike\": { \"s3:prefix\": \"team/*\" } }\n" + + " }]\n" + + "}"; + + final Set resolvedFromNativeAuthorizer = resolve(json, VOLUME, NATIVE); + final Set resolvedFromRangerAuthorizer = resolve(json, VOLUME, RANGER); + + // Expected for native (GetObject is ignored because s3:prefix is present): READ, LIST bucket acls; volume READ; + // prefix "log/team" READ final Set expectedResolvedNative = new LinkedHashSet<>(); - // Expected for native: READ acl on prefix "" (condition prefixes are ignored); bucket READ; volume READ; - final Set readObjectsNative = objSet(prefix("logs", ""), bucket("logs"), volume()); - expectedResolvedNative.add(new OzoneGrant(readObjectsNative, acls(READ))); + expectedResolvedNative.add(new OzoneGrant(objSet(bucket("logs")), acls(READ, LIST))); + expectedResolvedNative.add(new OzoneGrant(objSet(volume(), prefix("logs", "team/")), acls(READ))); assertThat(resolvedFromNativeAuthorizer).isEqualTo(expectedResolvedNative); + // Expected for Ranger (GetObject is ignored because s3:prefix is present): READ, LIST bucket acls; volume READ; + // key "log/team/*" READ final Set expectedResolvedRanger = new LinkedHashSet<>(); - // Expected for Ranger: READ acl on key "*" (condition prefixes are ignored) - final Set keySet = objSet(key("logs", "*"), bucket("logs"), volume()); - expectedResolvedRanger.add(new OzoneGrant(keySet, acls(READ))); + expectedResolvedRanger.add(new OzoneGrant(objSet(bucket("logs")), acls(READ, LIST))); + expectedResolvedRanger.add(new OzoneGrant(objSet(volume(), key("logs", "team/*")), acls(READ))); assertThat(resolvedFromRangerAuthorizer).isEqualTo(expectedResolvedRanger); } @@ -1701,6 +1785,35 @@ public void testObjectActionOnAllResources() throws OMException { assertThat(resolvedFromRangerAuthorizer).isEqualTo(expectedResolvedRanger); } + @Test + public void testAllActionsOnAllResourcesWithPrefixCondition() throws OMException { + final String json = "{\n" + + " \"Statement\": [{\n" + + " \"Effect\": \"Allow\",\n" + + " \"Action\": \"s3:*\",\n" + + " \"Resource\": \"*\",\n" + + " \"Condition\": {\n" + + " \"StringLike\": {\n" + + " \"s3:prefix\": [ \"team/folder\", \"team/folder/*\" ]\n" + + " }\n" + + " }\n" + + " }]\n" + + "}"; + + // Wildcards on bucket are not supported for Native authorizer + expectBucketWildcardUnsupportedExceptionForNativeAuthorizer(json); + + final Set resolvedFromRangerAuthorizer = resolve(json, VOLUME, RANGER); + // Ensure what we got is what we expected + final Set expectedResolvedRanger = new LinkedHashSet<>(); + // Expected for Ranger: (only ListBucket supports s3:prefix) READ volume; READ, LIST acl on bucket; + // READ on key "team/folder", "team/folder/*" + expectedResolvedRanger.add(new OzoneGrant(objSet(bucket("*")), acls(READ, LIST))); + expectedResolvedRanger.add( + new OzoneGrant(objSet(volume(), key("*", "team/folder"), key("*", "team/folder/*")), acls(READ))); + assertThat(resolvedFromRangerAuthorizer).isEqualTo(expectedResolvedRanger); + } + @Test public void testAllActionsOnAllResources() throws OMException { final String json = "{\n" + From 1810c0d32e0ed0734f6a4b8369c6616b7ff06aff Mon Sep 17 00:00:00 2001 From: fmorg-git Date: Tue, 21 Apr 2026 02:17:09 -0700 Subject: [PATCH 35/54] HDDS-14861. [STS] Fix Latent S3 API issue when ListBuckets Missing a Required Permission (#9949) --- .../ozone/s3/endpoint/EndpointBase.java | 43 +++++++++++------- .../ozone/s3/exception/S3ErrorTable.java | 2 +- .../ozone/s3/endpoint/TestEndpointBase.java | 45 +++++++++++++++++++ .../ozone/s3/exception/TestOS3Exceptions.java | 13 +++++- 4 files changed, 84 insertions(+), 19 deletions(-) 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 53bba420e67e..20ad21e23f11 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 @@ -379,24 +379,33 @@ private Iterator iterateBuckets( OzoneVolume volume = getVolume(); ownerSetter.accept(volume); return query.apply(volume); - } catch (OMException e) { - if (e.getResult() == ResultCodes.VOLUME_NOT_FOUND) { - return Collections.emptyIterator(); - } else if (e.getResult() == ResultCodes.PERMISSION_DENIED) { - throw newError(S3ErrorTable.ACCESS_DENIED, - "listBuckets", e); - } else if (isExpiredToken(e)) { - throw newError(S3ErrorTable.EXPIRED_TOKEN, s3Auth.getAccessID(), e); - } else if (e.getResult() == ResultCodes.INVALID_TOKEN) { - throw newError(S3ErrorTable.ACCESS_DENIED, - s3Auth.getAccessID(), e); - } else if (e.getResult() == ResultCodes.TIMEOUT || - e.getResult() == ResultCodes.INTERNAL_ERROR) { - throw newError(S3ErrorTable.INTERNAL_ERROR, - "listBuckets", e); - } else { - throw e; + } catch (RuntimeException e) { + if (e.getCause() instanceof OMException) { + return handleOMException((OMException) e.getCause()); } + throw e; + } catch (OMException e) { + return handleOMException(e); + } + } + + private Iterator handleOMException(OMException e) throws OMException { + if (e.getResult() == ResultCodes.VOLUME_NOT_FOUND) { + return Collections.emptyIterator(); + } else if (e.getResult() == ResultCodes.PERMISSION_DENIED) { + throw newError(S3ErrorTable.ACCESS_DENIED, + "listBuckets", e); + } else if (isExpiredToken(e)) { + throw newError(S3ErrorTable.EXPIRED_TOKEN, s3Auth.getAccessID(), e); + } else if (e.getResult() == ResultCodes.INVALID_TOKEN) { + throw newError(S3ErrorTable.ACCESS_DENIED, + s3Auth.getAccessID(), e); + } else if (e.getResult() == ResultCodes.TIMEOUT || + e.getResult() == ResultCodes.INTERNAL_ERROR) { + throw newError(S3ErrorTable.INTERNAL_ERROR, + "listBuckets", e); + } else { + throw e; } } diff --git a/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/exception/S3ErrorTable.java b/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/exception/S3ErrorTable.java index c7baaa95080b..f315012a9761 100644 --- a/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/exception/S3ErrorTable.java +++ b/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/exception/S3ErrorTable.java @@ -115,7 +115,7 @@ public final class S3ErrorTable { "resource.", HTTP_FORBIDDEN); public static final OS3Exception EXPIRED_TOKEN = new OS3Exception( - "ExpiredToken", "The provided token has expired.", HTTP_FORBIDDEN); + "ExpiredToken", "The provided token has expired.", HTTP_BAD_REQUEST); public static final OS3Exception PRECOND_FAILED = new OS3Exception( "PreconditionFailed", "At least one of the pre-conditions you " + diff --git a/hadoop-ozone/s3gateway/src/test/java/org/apache/hadoop/ozone/s3/endpoint/TestEndpointBase.java b/hadoop-ozone/s3gateway/src/test/java/org/apache/hadoop/ozone/s3/endpoint/TestEndpointBase.java index 89d5a26f21a5..c5042ab5b248 100644 --- a/hadoop-ozone/s3gateway/src/test/java/org/apache/hadoop/ozone/s3/endpoint/TestEndpointBase.java +++ b/hadoop-ozone/s3gateway/src/test/java/org/apache/hadoop/ozone/s3/endpoint/TestEndpointBase.java @@ -24,6 +24,9 @@ import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; import java.nio.charset.StandardCharsets; import java.util.Locale; @@ -31,6 +34,7 @@ import javax.ws.rs.core.MultivaluedHashMap; import javax.ws.rs.core.MultivaluedMap; import org.apache.hadoop.ozone.OzoneConsts; +import org.apache.hadoop.ozone.client.OzoneVolume; import org.apache.hadoop.ozone.om.exceptions.OMException; import org.apache.hadoop.ozone.s3.exception.OS3Exception; import org.junit.jupiter.api.Test; @@ -137,4 +141,45 @@ public void init() { } assertFalse(endpointBase.isExpiredToken(new OMException(ResultCodes.INVALID_TOKEN))); } + @Test + public void testListS3BucketsHandlesRuntimeExceptionWrappingOMException() throws Exception { + final EndpointBase endpointBase = new EndpointBase() { + @Override + public void init() { } + + @Override + protected OzoneVolume getVolume() { + final OzoneVolume volume = mock(OzoneVolume.class); + when(volume.listBuckets(anyString())).thenThrow( + new RuntimeException(new OMException("Permission Denied", ResultCodes.PERMISSION_DENIED))); + return volume; + } + }; + + final OS3Exception e = assertThrows( + OS3Exception.class, () -> endpointBase.listS3Buckets( + "prefix", volume -> { }), "listS3Buckets should fail."); + + // Ensure we get the correct code + assertEquals("AccessDenied", e.getCode()); + } + + @Test + public void testListS3BucketsHandlesRuntimeExceptionWrappingOMExceptionVolumeNotFound() throws Exception { + final EndpointBase endpointBase = new EndpointBase() { + @Override + public void init() { } + + @Override + protected OzoneVolume getVolume() { + final OzoneVolume volume = mock(OzoneVolume.class); + when(volume.listBuckets(anyString())).thenThrow( + new RuntimeException(new OMException("Volume Not Found", ResultCodes.VOLUME_NOT_FOUND))); + return volume; + } + }; + + // Ensure we get an empty iterator + assertFalse(endpointBase.listS3Buckets("prefix", volume -> { }).hasNext()); + } } diff --git a/hadoop-ozone/s3gateway/src/test/java/org/apache/hadoop/ozone/s3/exception/TestOS3Exceptions.java b/hadoop-ozone/s3gateway/src/test/java/org/apache/hadoop/ozone/s3/exception/TestOS3Exceptions.java index 9c1eeb41aa38..fd272fe02fc6 100644 --- a/hadoop-ozone/s3gateway/src/test/java/org/apache/hadoop/ozone/s3/exception/TestOS3Exceptions.java +++ b/hadoop-ozone/s3gateway/src/test/java/org/apache/hadoop/ozone/s3/exception/TestOS3Exceptions.java @@ -17,6 +17,7 @@ package org.apache.hadoop.ozone.s3.exception; +import static java.net.HttpURLConnection.HTTP_BAD_REQUEST; import static org.junit.jupiter.api.Assertions.assertEquals; import org.apache.hadoop.ozone.web.utils.OzoneUtils; @@ -48,9 +49,19 @@ public void testOS3Exceptions() { assertEquals(expected, val); } + /** + * AWS S3 returns HTTP 400 Bad Request for ExpiredToken (not 403). + */ + @Test + public void testExpiredTokenUsesBadRequestHttpStatus() { + assertEquals(HTTP_BAD_REQUEST, S3ErrorTable.EXPIRED_TOKEN.getHttpCode()); + final OS3Exception fromTable = S3ErrorTable.newError(S3ErrorTable.EXPIRED_TOKEN, "resource"); + assertEquals(HTTP_BAD_REQUEST, fromTable.getHttpCode()); + } + @Test public void testOS3ExceptionWithToken0() { - OS3Exception ex = new OS3Exception("ExpiredToken", "The provided token has expired.", 403); + OS3Exception ex = new OS3Exception("ExpiredToken", "The provided token has expired.", 400); ex = S3ErrorTable.newError(ex, "resource"); ex.setRequestId(OzoneUtils.getRequestID()); ex.setHostId(OzoneUtils.getRequestID()); From 37a224b21769e788fb9d9446341b0dc5f396eb4c Mon Sep 17 00:00:00 2001 From: fmorg-git Date: Fri, 1 May 2026 03:04:31 -0700 Subject: [PATCH 36/54] HDDS-14899. [STS] Updates to ACLs in IamSessionPolicyResolver (#9977) --- .../acl/iam/IamSessionPolicyResolver.java | 9 +- .../acl/iam/TestIamSessionPolicyResolver.java | 85 ++++++++++++++----- 2 files changed, 68 insertions(+), 26 deletions(-) diff --git a/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/security/acl/iam/IamSessionPolicyResolver.java b/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/security/acl/iam/IamSessionPolicyResolver.java index 285a11c550f3..7421aab3253a 100644 --- a/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/security/acl/iam/IamSessionPolicyResolver.java +++ b/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/security/acl/iam/IamSessionPolicyResolver.java @@ -26,6 +26,7 @@ import static org.apache.hadoop.ozone.security.acl.IAccessAuthorizer.ACLType.LIST; import static org.apache.hadoop.ozone.security.acl.IAccessAuthorizer.ACLType.READ; import static org.apache.hadoop.ozone.security.acl.IAccessAuthorizer.ACLType.READ_ACL; +import static org.apache.hadoop.ozone.security.acl.IAccessAuthorizer.ACLType.WRITE; import static org.apache.hadoop.ozone.security.acl.IAccessAuthorizer.ACLType.WRITE_ACL; import com.fasterxml.jackson.databind.JsonNode; @@ -868,24 +869,22 @@ enum S3Action { EnumSet.noneOf(ACLType.class)), GET_BUCKET_ACL("s3:GetBucketAcl", ActionKind.BUCKET, EnumSet.of(READ), EnumSet.of(READ, READ_ACL), EnumSet.noneOf(ACLType.class)), - GET_BUCKET_LOCATION("s3:GetBucketLocation", ActionKind.BUCKET, EnumSet.of(READ), EnumSet.of(READ), - EnumSet.noneOf(ACLType.class)), // Used for HeadBucket, ListObjects and ListObjectsV2 apis LIST_BUCKET("s3:ListBucket", ActionKind.BUCKET, EnumSet.of(READ), EnumSet.of(READ, LIST), EnumSet.of(READ)), // Used for ListMultipartUploads API LIST_BUCKET_MULTIPART_UPLOADS("s3:ListBucketMultipartUploads", ActionKind.BUCKET, EnumSet.of(READ), EnumSet.of(READ, LIST), EnumSet.noneOf(ACLType.class)), - PUT_BUCKET_ACL("s3:PutBucketAcl", ActionKind.BUCKET, EnumSet.of(READ), EnumSet.of(WRITE_ACL), + PUT_BUCKET_ACL("s3:PutBucketAcl", ActionKind.BUCKET, EnumSet.of(READ), EnumSet.of(READ, READ_ACL, WRITE_ACL), EnumSet.noneOf(ACLType.class)), // Object-scope ABORT_MULTIPART_UPLOAD("s3:AbortMultipartUpload", ActionKind.OBJECT, EnumSet.of(READ), EnumSet.of(READ), - EnumSet.of(DELETE)), + EnumSet.of(WRITE)), // Used for DeleteObject (when versionId parameter is not supplied), // DeleteObjects (when versionId parameter is not supplied) APIs DELETE_OBJECT("s3:DeleteObject", ActionKind.OBJECT, EnumSet.of(READ), EnumSet.of(READ), EnumSet.of(DELETE)), DELETE_OBJECT_TAGGING("s3:DeleteObjectTagging", ActionKind.OBJECT, EnumSet.of(READ), EnumSet.of(READ), - EnumSet.of(DELETE)), + EnumSet.of(WRITE)), // Used for HeadObject, CopyObject (for source bucket), GetObject (without versionId parameter) APIs GET_OBJECT("s3:GetObject", ActionKind.OBJECT, EnumSet.of(READ), EnumSet.of(READ), EnumSet.of(READ)), GET_OBJECT_TAGGING("s3:GetObjectTagging", ActionKind.OBJECT, EnumSet.of(READ), EnumSet.of(READ), EnumSet.of(READ)), diff --git a/hadoop-ozone/common/src/test/java/org/apache/hadoop/ozone/security/acl/iam/TestIamSessionPolicyResolver.java b/hadoop-ozone/common/src/test/java/org/apache/hadoop/ozone/security/acl/iam/TestIamSessionPolicyResolver.java index 0a8d1c47412d..1d9765146325 100644 --- a/hadoop-ozone/common/src/test/java/org/apache/hadoop/ozone/security/acl/iam/TestIamSessionPolicyResolver.java +++ b/hadoop-ozone/common/src/test/java/org/apache/hadoop/ozone/security/acl/iam/TestIamSessionPolicyResolver.java @@ -307,7 +307,7 @@ public void testBuildCaseInsensitiveS3ActionMap() { // Verify s3:Get* contains Get actions final Set getActions = caseInsensitiveS3ActionMap.get("s3:get*"); assertThat(getActions).containsOnly( - S3Action.GET_OBJECT, S3Action.GET_BUCKET_ACL, S3Action.GET_BUCKET_LOCATION, S3Action.GET_OBJECT_TAGGING); + S3Action.GET_OBJECT, S3Action.GET_BUCKET_ACL, S3Action.GET_OBJECT_TAGGING); // Verify s3:Put* contains Put actions final Set putActions = caseInsensitiveS3ActionMap.get("s3:put*"); @@ -380,13 +380,11 @@ public void testMapPolicyActionsToS3ActionsWithMultipleActionsMapAllCorrectly() @Test public void testMapPolicyActionsToS3ActionsWithWildcardExpansion() { final Set result = mapPolicyActionsToS3Actions(Collections.singleton("s3:Get*")); - assertThat(result).containsOnly(S3Action.GET_OBJECT, S3Action.GET_BUCKET_ACL, S3Action.GET_BUCKET_LOCATION, - S3Action.GET_OBJECT_TAGGING); + assertThat(result).containsOnly(S3Action.GET_OBJECT, S3Action.GET_BUCKET_ACL, S3Action.GET_OBJECT_TAGGING); // Ensure it is case-insensitive final Set resultCi = mapPolicyActionsToS3Actions(Collections.singleton("s3:gET*")); - assertThat(resultCi).containsOnly(S3Action.GET_OBJECT, S3Action.GET_BUCKET_ACL, S3Action.GET_BUCKET_LOCATION, - S3Action.GET_OBJECT_TAGGING); + assertThat(resultCi).containsOnly(S3Action.GET_OBJECT, S3Action.GET_BUCKET_ACL, S3Action.GET_OBJECT_TAGGING); } @Test @@ -415,15 +413,15 @@ public void testMapPolicyActionsToS3ActionsWithOnlyUnsupportedActionsReturnsEmpt @Test public void testMapPolicyActionsToS3ActionsDeduplicatesResults() { final Set result = mapPolicyActionsToS3Actions(strSet("s3:Get*", "s3:GetObject", "s3:GetBucketAcl")); - assertThat(result).containsOnly(S3Action.GET_OBJECT, S3Action.GET_BUCKET_ACL, S3Action.GET_BUCKET_LOCATION, - S3Action.GET_OBJECT_TAGGING); + assertThat(result).containsOnly(S3Action.GET_OBJECT, S3Action.GET_BUCKET_ACL, S3Action.GET_OBJECT_TAGGING); } @Test public void testMapPolicyActionsToS3ActionsHandlesMultipleWildcards() { final Set result = mapPolicyActionsToS3Actions(strSet("s3:Get*", "s3:Put*")); - assertThat(result).containsOnly(S3Action.GET_OBJECT, S3Action.GET_BUCKET_ACL, S3Action.GET_BUCKET_LOCATION, - S3Action.GET_OBJECT_TAGGING, S3Action.PUT_OBJECT, S3Action.PUT_OBJECT_TAGGING, S3Action.PUT_BUCKET_ACL); + assertThat(result).containsOnly( + S3Action.GET_OBJECT, S3Action.GET_BUCKET_ACL, S3Action.GET_OBJECT_TAGGING, S3Action.PUT_OBJECT, + S3Action.PUT_OBJECT_TAGGING, S3Action.PUT_BUCKET_ACL); } @Test @@ -768,7 +766,7 @@ public void testCreatePathsAndPermissionsWithBucketWildcardResource() { final Set actions = Collections.singleton(IamSessionPolicyResolver.S3Action.PUT_BUCKET_ACL); final Set resourceSpecs = Collections.singleton( new IamSessionPolicyResolver.ResourceSpec(S3ResourceType.BUCKET_WILDCARD, "bucket1*", null, null)); - final Set writeAclObject = objSet(bucket("bucket1*")); + final Set readReadAclAndWriteAclObject = objSet(bucket("bucket1*")); final Set readVolume = objSet(volume()); expectIllegalArgumentException( @@ -779,7 +777,8 @@ public void testCreatePathsAndPermissionsWithBucketWildcardResource() { createPathsAndPermissions(VOLUME, RANGER, actions, resourceSpecs, null, objToAclsMapRanger); final Set resultRanger = groupObjectsByAcls(objToAclsMapRanger); assertThat(resultRanger).containsExactlyInAnyOrder( - new OzoneGrant(writeAclObject, acls(WRITE_ACL)), new OzoneGrant(readVolume, acls(READ))); + new OzoneGrant(readReadAclAndWriteAclObject, acls(READ, READ_ACL, WRITE_ACL)), + new OzoneGrant(readVolume, acls(READ))); } @Test @@ -827,6 +826,48 @@ public void testCreatePathsAndPermissionsWithObjectExactResource() { assertThat(resultRanger).containsExactly(new OzoneGrant(readObjects, acls(READ))); } + @Test + public void testCreatePathsAndPermissionsWithDeleteObjectGrantsDeleteOnKey() { + final Set actions = Collections.singleton(S3Action.DELETE_OBJECT); + final Set resourceSpecs = Collections.singleton( + new IamSessionPolicyResolver.ResourceSpec(S3ResourceType.OBJECT_EXACT, "bucket1", null, "key.txt")); + final Set readVolumeAndBucket = objSet(volume(), bucket("bucket1")); + final Set deleteKey = objSet(key("bucket1", "key.txt")); + + final Map> objToAclsMapNative = new LinkedHashMap<>(); + createPathsAndPermissions(VOLUME, NATIVE, actions, resourceSpecs, null, objToAclsMapNative); + final Set resultNative = groupObjectsByAcls(objToAclsMapNative); + assertThat(resultNative).containsExactlyInAnyOrder( + new OzoneGrant(readVolumeAndBucket, acls(READ)), new OzoneGrant(deleteKey, acls(DELETE))); + + final Map> objToAclsMapRanger = new LinkedHashMap<>(); + createPathsAndPermissions(VOLUME, RANGER, actions, resourceSpecs, null, objToAclsMapRanger); + final Set resultRanger = groupObjectsByAcls(objToAclsMapRanger); + assertThat(resultRanger).containsExactlyInAnyOrder( + new OzoneGrant(readVolumeAndBucket, acls(READ)), new OzoneGrant(deleteKey, acls(DELETE))); + } + + @Test + public void testCreatePathsAndPermissionsWithAbortMultipartUploadGrantsWriteOnKey() { + final Set actions = Collections.singleton(S3Action.ABORT_MULTIPART_UPLOAD); + final Set resourceSpecs = Collections.singleton( + new IamSessionPolicyResolver.ResourceSpec(S3ResourceType.OBJECT_EXACT, "bucket1", null, "key.txt")); + final Set readVolumeAndBucket = objSet(volume(), bucket("bucket1")); + final Set writeKey = objSet(key("bucket1", "key.txt")); + + final Map> objToAclsMapNative = new LinkedHashMap<>(); + createPathsAndPermissions(VOLUME, NATIVE, actions, resourceSpecs, null, objToAclsMapNative); + final Set resultNative = groupObjectsByAcls(objToAclsMapNative); + assertThat(resultNative).containsExactlyInAnyOrder( + new OzoneGrant(readVolumeAndBucket, acls(READ)), new OzoneGrant(writeKey, acls(WRITE))); + + final Map> objToAclsMapRanger = new LinkedHashMap<>(); + createPathsAndPermissions(VOLUME, RANGER, actions, resourceSpecs, null, objToAclsMapRanger); + final Set resultRanger = groupObjectsByAcls(objToAclsMapRanger); + assertThat(resultRanger).containsExactlyInAnyOrder( + new OzoneGrant(readVolumeAndBucket, acls(READ)), new OzoneGrant(writeKey, acls(WRITE))); + } + @Test public void testCreatePathsAndPermissionsWithObjectPrefixResource() { final Set actions = Collections.singleton(S3Action.GET_OBJECT); @@ -983,20 +1024,22 @@ public void testCreatePathsAndPermissionsDeduplicatesAcrossSameResourceTypes() { .collect(Collectors.toSet()); final Set resourceSpecs = Collections.singleton( new IamSessionPolicyResolver.ResourceSpec(S3ResourceType.OBJECT_EXACT, "bucket1", null, "key.txt")); - final Set readAndDeleteObject = objSet(key("bucket1", "key.txt")); + final Set readAndDeleteAndWriteObject = objSet(key("bucket1", "key.txt")); final Set readObjects = objSet(bucket("bucket1"), volume()); final Map> objToAclsMapNative = new LinkedHashMap<>(); createPathsAndPermissions(VOLUME, NATIVE, actions, resourceSpecs, null, objToAclsMapNative); final Set resultNative = groupObjectsByAcls(objToAclsMapNative); assertThat(resultNative).containsExactlyInAnyOrder( - new OzoneGrant(readAndDeleteObject, acls(READ, DELETE)), new OzoneGrant(readObjects, acls(READ))); + new OzoneGrant(readAndDeleteAndWriteObject, acls(READ, DELETE, WRITE)), + new OzoneGrant(readObjects, acls(READ))); final Map> objToAclsMapRanger = new LinkedHashMap<>(); createPathsAndPermissions(VOLUME, RANGER, actions, resourceSpecs, null, objToAclsMapRanger); final Set resultRanger = groupObjectsByAcls(objToAclsMapRanger); assertThat(resultRanger).containsExactlyInAnyOrder( - new OzoneGrant(readAndDeleteObject, acls(READ, DELETE)), new OzoneGrant(readObjects, acls(READ))); + new OzoneGrant(readAndDeleteAndWriteObject, acls(READ, DELETE, WRITE)), + new OzoneGrant(readObjects, acls(READ))); } @Test @@ -1976,9 +2019,9 @@ public void testWildcardActionGroupPutStar() throws OMException { // Ensure what we got is what we expected final Set expectedResolvedNative = new LinkedHashSet<>(); - // Expected for native: bucket READ, WRITE_ACL acl + // Expected for native: bucket READ, READ_ACL, WRITE_ACL acl final Set bucketSet = objSet(bucket("my-bucket")); - final Set bucketAcl = acls(READ, WRITE_ACL); + final Set bucketAcl = acls(READ, READ_ACL, WRITE_ACL); expectedResolvedNative.add(new OzoneGrant(bucketSet, bucketAcl)); // Expected for native: CREATE, WRITE acls on prefix "" under bucket final Set keyPrefixSet = objSet(prefix("my-bucket", "")); @@ -1989,7 +2032,7 @@ public void testWildcardActionGroupPutStar() throws OMException { assertThat(resolvedFromNativeAuthorizer).isEqualTo(expectedResolvedNative); final Set expectedResolvedRanger = new LinkedHashSet<>(); - // Expected for Ranger: bucket READ, WRITE_ACL acl + // Expected for Ranger: bucket READ, READ_ACL, WRITE_ACL acl expectedResolvedRanger.add(new OzoneGrant(bucketSet, bucketAcl)); // Expected for Ranger: CREATE, WRITE key acls for resource type KEY with key name "*" final Set rangerKeySet = objSet(key("my-bucket", "*")); @@ -2017,17 +2060,17 @@ public void testWildcardActionGroupDeleteStar() throws OMException { // Ensure what we got is what we expected final Set expectedResolvedNative = new LinkedHashSet<>(); - // Expected for native: DELETE on prefix "" under bucket; bucket READ, DELETE; volume READ + // Expected for native: DELETE and WRITE on prefix "" under bucket; bucket READ, DELETE; volume READ final Set resourceSetNative = objSet(prefix("my-bucket", "")); - expectedResolvedNative.add(new OzoneGrant(resourceSetNative, acls(DELETE))); + expectedResolvedNative.add(new OzoneGrant(resourceSetNative, acls(DELETE, WRITE))); expectedResolvedNative.add(new OzoneGrant(objSet(bucket("my-bucket")), acls(READ, DELETE))); expectedResolvedNative.add(new OzoneGrant(objSet(volume()), acls(READ))); assertThat(resolvedFromNativeAuthorizer).isEqualTo(expectedResolvedNative); final Set expectedResolvedRanger = new LinkedHashSet<>(); - // Expected for Ranger: DELETE on resource type KEY with key name "*"; bucket READ, DELETE; volume READ + // Expected for Ranger: DELETE and WRITE on resource type KEY with key name "*"; bucket READ, DELETE; volume READ final Set resourceSetRanger = objSet(key("my-bucket", "*")); - expectedResolvedRanger.add(new OzoneGrant(resourceSetRanger, acls(DELETE))); + expectedResolvedRanger.add(new OzoneGrant(resourceSetRanger, acls(DELETE, WRITE))); expectedResolvedRanger.add(new OzoneGrant(objSet(bucket("my-bucket")), acls(READ, DELETE))); expectedResolvedRanger.add(new OzoneGrant(objSet(volume()), acls(READ))); assertThat(resolvedFromRangerAuthorizer).isEqualTo(expectedResolvedRanger); From 56654627b2042536695bccdc5c12e738bf697615 Mon Sep 17 00:00:00 2001 From: fmorg-git Date: Wed, 27 May 2026 06:51:04 -0700 Subject: [PATCH 37/54] HDDS-14935. [STS] Handle Latent Inconsistencies in S3 API Acl Checks (#10009) --- .../dev-support/checkstyle/checkstyle.xml | 6 + .../src/main/proto/OmClientProtocol.proto | 8 ++ .../om/ratis/OzoneManagerStateMachine.java | 28 ++++ .../ozone/om/request/OMClientRequest.java | 66 +++++++++- .../bucket/OMBucketSetOwnerRequest.java | 7 +- .../bucket/OMBucketSetPropertyRequest.java | 9 +- .../bucket/acl/OMBucketAddAclRequest.java | 11 +- .../bucket/acl/OMBucketRemoveAclRequest.java | 11 +- .../bucket/acl/OMBucketSetAclRequest.java | 9 +- .../om/request/file/OMFileCreateRequest.java | 2 +- .../request/file/OMRecoverLeaseRequest.java | 2 +- .../om/request/key/OMKeyCommitRequest.java | 2 +- .../om/request/key/OMKeyCreateRequest.java | 2 +- .../om/request/key/OMKeyDeleteRequest.java | 2 +- .../om/request/key/OMKeyRenameRequest.java | 2 +- .../om/request/key/OMKeySetTimesRequest.java | 2 +- .../request/key/acl/OMKeyAddAclRequest.java | 11 +- .../key/acl/OMKeyRemoveAclRequest.java | 11 +- .../request/key/acl/OMKeySetAclRequest.java | 11 +- .../S3MultipartUploadAbortRequest.java | 2 +- .../s3/security/OMSetSecretRequest.java | 6 +- .../s3/security/S3AssumeRoleRequest.java | 23 +--- .../S3DeleteRevokedSTSTokensRequest.java | 4 +- .../s3/security/S3GetSecretRequest.java | 21 ++- .../s3/security/S3RevokeSTSTokenRequest.java | 16 +-- .../s3/security/S3RevokeSecretRequest.java | 14 +- .../OMCancelDelegationTokenRequest.java | 2 +- .../security/OMGetDelegationTokenRequest.java | 2 +- .../OMRenewDelegationTokenRequest.java | 2 +- .../OMSnapshotMoveTableKeysRequest.java | 7 +- .../volume/OMVolumeSetOwnerRequest.java | 6 +- .../volume/OMVolumeSetQuotaRequest.java | 8 +- .../volume/acl/OMVolumeAddAclRequest.java | 11 +- .../volume/acl/OMVolumeRemoveAclRequest.java | 11 +- .../volume/acl/OMVolumeSetAclRequest.java | 11 +- .../ozone/security/STSSecurityUtil.java | 41 ++++++ .../ratis/TestOzoneManagerStateMachine.java | 74 +++++++++++ .../TestOMClientRequestWithUserInfo.java | 123 ++++++++++++++++++ .../s3/security/TestS3GetSecretRequest.java | 4 + .../ozone/security/TestSTSSecurityUtil.java | 75 +++++++++++ 40 files changed, 527 insertions(+), 138 deletions(-) diff --git a/hadoop-hdds/dev-support/checkstyle/checkstyle.xml b/hadoop-hdds/dev-support/checkstyle/checkstyle.xml index 1109bd9983b8..307d99261ff5 100644 --- a/hadoop-hdds/dev-support/checkstyle/checkstyle.xml +++ b/hadoop-hdds/dev-support/checkstyle/checkstyle.xml @@ -83,6 +83,12 @@ + + + + + diff --git a/hadoop-ozone/interface-client/src/main/proto/OmClientProtocol.proto b/hadoop-ozone/interface-client/src/main/proto/OmClientProtocol.proto index dde32437db54..5cf503275a2a 100644 --- a/hadoop-ozone/interface-client/src/main/proto/OmClientProtocol.proto +++ b/hadoop-ozone/interface-client/src/main/proto/OmClientProtocol.proto @@ -2338,6 +2338,14 @@ message S3Authentication { // If present, indicates this request uses STS temporary credentials // and carries the base64-encoded session token for validation. optional string sessionToken = 4; + // The following fields are resolved from the STS session token by OM. + // They are used to enforce STS session policies during Ratis apply. + // They must be written or cleared by the OM leader when the token is validated. + optional string resolvedStsSessionPolicy = 5; + optional string resolvedStsRoleArn = 6; + optional string resolvedStsOriginalAccessKeyId = 7; + optional string resolvedStsTempAccessKeyId = 8; + optional string resolvedStsSecretKeyId = 9; } message RecoverLeaseRequest { diff --git a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/ratis/OzoneManagerStateMachine.java b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/ratis/OzoneManagerStateMachine.java index 2abaf9ae5719..34c5a485d61c 100644 --- a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/ratis/OzoneManagerStateMachine.java +++ b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/ratis/OzoneManagerStateMachine.java @@ -57,6 +57,8 @@ import org.apache.hadoop.ozone.protocol.proto.OzoneManagerProtocolProtos.OMResponse; import org.apache.hadoop.ozone.protocolPB.OzoneManagerRequestHandler; import org.apache.hadoop.ozone.protocolPB.RequestHandler; +import org.apache.hadoop.ozone.security.STSSecurityUtil; +import org.apache.hadoop.ozone.security.STSTokenIdentifier; import org.apache.hadoop.security.UserGroupInformation; import org.apache.hadoop.util.Time; import org.apache.hadoop.util.concurrent.HadoopExecutors; @@ -662,7 +664,29 @@ public void close() { */ @VisibleForTesting OMResponse runCommand(OMRequest request, TermIndex termIndex) { + boolean isStsThreadLocalSet = false; try { + if (ozoneManager.isSecurityEnabled() && request.hasS3Authentication()) { + // STS token verification runs on the leader RPC path so we don't need to recheck here on the apply + // after the log is committed + STSSecurityUtil.ensureResolvedStsFieldsInvariants(request); + + final OzoneManagerProtocolProtos.S3Authentication s3Auth = request.getS3Authentication(); + if (s3Auth.hasSessionToken() && !s3Auth.getSessionToken().isEmpty()) { + // ThreadLocal carries session policy for OmMetadataReader + final STSTokenIdentifier rehydratedTokenIdentifier = new STSTokenIdentifier( + s3Auth.hasResolvedStsTempAccessKeyId() ? s3Auth.getResolvedStsTempAccessKeyId() : "", + s3Auth.hasResolvedStsOriginalAccessKeyId() ? s3Auth.getResolvedStsOriginalAccessKeyId() : "", + s3Auth.hasResolvedStsRoleArn() ? s3Auth.getResolvedStsRoleArn() : "", + java.time.Instant.MAX, // ensure it deterministically is not expired + "", // no secretAccessKey needed + s3Auth.hasResolvedStsSessionPolicy() ? s3Auth.getResolvedStsSessionPolicy() : "", + null // no encryption key needed + ); + OzoneManager.setStsTokenIdentifier(rehydratedTokenIdentifier); + isStsThreadLocalSet = true; + } + } ExecutionContext context = ExecutionContext.of(termIndex.getIndex(), termIndex); final OMClientResponse omClientResponse = handler.handleWriteRequest( request, context, ozoneManagerDoubleBuffer); @@ -681,6 +705,10 @@ OMResponse runCommand(OMRequest request, TermIndex termIndex) { // For any Runtime exceptions, terminate OM. String errorMessage = "Request " + request + " failed with exception"; ExitUtils.terminate(1, errorMessage, e, LOG); + } finally { + if (isStsThreadLocalSet) { + OzoneManager.setStsTokenIdentifier(null); + } } return null; } diff --git a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/OMClientRequest.java b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/OMClientRequest.java index 0420eef2fd5d..81d6925442b5 100644 --- a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/OMClientRequest.java +++ b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/OMClientRequest.java @@ -27,6 +27,7 @@ import java.util.LinkedHashMap; import java.util.Map; import java.util.Objects; +import java.util.UUID; import org.apache.commons.lang3.StringUtils; import org.apache.hadoop.hdds.utils.TransactionInfo; import org.apache.hadoop.ipc_.ProtobufRpcEngine; @@ -50,10 +51,12 @@ import org.apache.hadoop.ozone.om.ratis.utils.OzoneManagerRatisUtils; import org.apache.hadoop.ozone.om.request.s3.security.S3AssumeRoleRequest; import org.apache.hadoop.ozone.om.response.OMClientResponse; +import org.apache.hadoop.ozone.om.upgrade.OMLayoutVersionManager; import org.apache.hadoop.ozone.protocol.proto.OzoneManagerProtocolProtos; import org.apache.hadoop.ozone.protocol.proto.OzoneManagerProtocolProtos.LayoutVersion; import org.apache.hadoop.ozone.protocol.proto.OzoneManagerProtocolProtos.OMRequest; import org.apache.hadoop.ozone.protocol.proto.OzoneManagerProtocolProtos.OMResponse; +import org.apache.hadoop.ozone.security.STSTokenIdentifier; import org.apache.hadoop.ozone.security.acl.IAccessAuthorizer; import org.apache.hadoop.ozone.security.acl.OzoneObj; import org.apache.hadoop.ozone.security.acl.OzoneObjInfo; @@ -112,15 +115,66 @@ public OMClientRequest(OMRequest omRequest) { */ public OMRequest preExecute(OzoneManager ozoneManager) throws IOException { - LayoutVersion layoutVersion = LayoutVersion.newBuilder() - .setVersion(ozoneManager.getVersionManager().getMetadataLayoutVersion()) - .build(); - omRequest = getOmRequest().toBuilder() - .setUserInfo(getUserIfNotExists(ozoneManager)) - .setLayoutVersion(layoutVersion).build(); + final OMRequest.Builder requestBuilder = getOmRequest().toBuilder() + .setUserInfo(getUserIfNotExists(ozoneManager)); + + // VersionManager is always expected in production OzoneManager instances. + // Some unit tests use a minimal mocked OzoneManager, so perform null check here. + final OMLayoutVersionManager versionManager = ozoneManager.getVersionManager(); + if (versionManager != null) { + final LayoutVersion layoutVersion = LayoutVersion.newBuilder() + .setVersion(versionManager.getMetadataLayoutVersion()) + .build(); + requestBuilder.setLayoutVersion(layoutVersion); + } + + if (requestBuilder.hasS3Authentication()) { + final OzoneManagerProtocolProtos.S3Authentication s3Auth = requestBuilder.getS3Authentication(); + final boolean hasSessionToken = s3Auth.hasSessionToken() && !s3Auth.getSessionToken().isEmpty(); + final STSTokenIdentifier stsTokenIdentifier = OzoneManager.getStsTokenIdentifier(); + + // This should not happen, so explicitly throw an error. An existing sessionToken + // implies prior STS validation must have populated the ThreadLocal. + if (ozoneManager.isSecurityEnabled() && hasSessionToken && stsTokenIdentifier == null) { + throw new OMException( + "S3Authentication has session token but no STS token identifier in OzoneManager ThreadLocal", + OMException.ResultCodes.INVALID_REQUEST); + } + + requestBuilder.setS3Authentication(resolveS3Authentication(s3Auth, stsTokenIdentifier)); + } + + omRequest = requestBuilder.build(); return omRequest; } + private static OzoneManagerProtocolProtos.S3Authentication resolveS3Authentication( + OzoneManagerProtocolProtos.S3Authentication s3Auth, STSTokenIdentifier stsTokenIdentifier) { + final OzoneManagerProtocolProtos.S3Authentication.Builder s3AuthBuilder = s3Auth.toBuilder(); + + if (s3Auth.hasSessionToken() && !s3Auth.getSessionToken().isEmpty() && stsTokenIdentifier != null) { + s3AuthBuilder.setResolvedStsSessionPolicy( + StringUtils.defaultString(stsTokenIdentifier.getSessionPolicy())); + s3AuthBuilder.setResolvedStsRoleArn( + StringUtils.defaultString(stsTokenIdentifier.getRoleArn())); + s3AuthBuilder.setResolvedStsOriginalAccessKeyId( + StringUtils.defaultString(stsTokenIdentifier.getOriginalAccessKeyId())); + s3AuthBuilder.setResolvedStsTempAccessKeyId( + StringUtils.defaultString(stsTokenIdentifier.getTempAccessKeyId())); + final UUID secretKeyId = stsTokenIdentifier.getSecretKeyId(); + s3AuthBuilder.setResolvedStsSecretKeyId( + secretKeyId != null ? secretKeyId.toString() : ""); + } else { + s3AuthBuilder.clearResolvedStsSessionPolicy(); + s3AuthBuilder.clearResolvedStsRoleArn(); + s3AuthBuilder.clearResolvedStsOriginalAccessKeyId(); + s3AuthBuilder.clearResolvedStsTempAccessKeyId(); + s3AuthBuilder.clearResolvedStsSecretKeyId(); + } + + return s3AuthBuilder.build(); + } + /** * Performs any request specific failure handling during request * submission. An example of this would be an undo of any steps diff --git a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/bucket/OMBucketSetOwnerRequest.java b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/bucket/OMBucketSetOwnerRequest.java index 6d0c90cdca29..888e8b48dfb0 100644 --- a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/bucket/OMBucketSetOwnerRequest.java +++ b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/bucket/OMBucketSetOwnerRequest.java @@ -63,15 +63,16 @@ public OMBucketSetOwnerRequest(OMRequest omRequest) { @Override public OMRequest preExecute(OzoneManager ozoneManager) throws IOException { + final OMRequest omRequest = super.preExecute(ozoneManager); + long modificationTime = Time.now(); OzoneManagerProtocolProtos.SetBucketPropertyRequest.Builder - setBucketPropertyRequestBuilder = getOmRequest() + setBucketPropertyRequestBuilder = omRequest .getSetBucketPropertyRequest().toBuilder() .setModificationTime(modificationTime); - return getOmRequest().toBuilder() + return omRequest.toBuilder() .setSetBucketPropertyRequest(setBucketPropertyRequestBuilder) - .setUserInfo(getUserInfo()) .build(); } diff --git a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/bucket/OMBucketSetPropertyRequest.java b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/bucket/OMBucketSetPropertyRequest.java index a88e5fb73334..7563ba9e4b8b 100644 --- a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/bucket/OMBucketSetPropertyRequest.java +++ b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/bucket/OMBucketSetPropertyRequest.java @@ -79,14 +79,16 @@ public OMBucketSetPropertyRequest(OMRequest omRequest) { @Override public OMRequest preExecute(OzoneManager ozoneManager) throws IOException { + final OMRequest omRequest = super.preExecute(ozoneManager); + long modificationTime = Time.now(); OzoneManagerProtocolProtos.SetBucketPropertyRequest.Builder - setBucketPropertyRequestBuilder = getOmRequest() + setBucketPropertyRequestBuilder = omRequest .getSetBucketPropertyRequest().toBuilder() .setModificationTime(modificationTime); BucketArgs bucketArgs = - getOmRequest().getSetBucketPropertyRequest().getBucketArgs(); + omRequest.getSetBucketPropertyRequest().getBucketArgs(); if (bucketArgs.hasBekInfo()) { KeyProviderCryptoExtension kmsProvider = ozoneManager.getKmsProvider(); @@ -97,9 +99,8 @@ public OMRequest preExecute(OzoneManager ozoneManager) setBucketPropertyRequestBuilder.setBucketArgs(bucketArgsBuilder.build()); } - return getOmRequest().toBuilder() + return omRequest.toBuilder() .setSetBucketPropertyRequest(setBucketPropertyRequestBuilder) - .setUserInfo(getUserInfo()) .build(); } diff --git a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/bucket/acl/OMBucketAddAclRequest.java b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/bucket/acl/OMBucketAddAclRequest.java index 1b3ce4e3f84b..843e95865c2a 100644 --- a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/bucket/acl/OMBucketAddAclRequest.java +++ b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/bucket/acl/OMBucketAddAclRequest.java @@ -56,14 +56,15 @@ public class OMBucketAddAclRequest extends OMBucketAclRequest { @Override public OMRequest preExecute(OzoneManager ozoneManager) throws IOException { - long modificationTime = Time.now(); - OzoneManagerProtocolProtos.AddAclRequest.Builder addAclRequestBuilder = - getOmRequest().getAddAclRequest().toBuilder() + final OMRequest omRequest = super.preExecute(ozoneManager); + + final long modificationTime = Time.now(); + final OzoneManagerProtocolProtos.AddAclRequest.Builder addAclRequestBuilder = + omRequest.getAddAclRequest().toBuilder() .setModificationTime(modificationTime); - return getOmRequest().toBuilder() + return omRequest.toBuilder() .setAddAclRequest(addAclRequestBuilder) - .setUserInfo(getUserInfo()) .build(); } diff --git a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/bucket/acl/OMBucketRemoveAclRequest.java b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/bucket/acl/OMBucketRemoveAclRequest.java index 13839ddb58a7..4ac27878eb86 100644 --- a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/bucket/acl/OMBucketRemoveAclRequest.java +++ b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/bucket/acl/OMBucketRemoveAclRequest.java @@ -55,14 +55,15 @@ public class OMBucketRemoveAclRequest extends OMBucketAclRequest { @Override public OMRequest preExecute(OzoneManager ozoneManager) throws IOException { - long modificationTime = Time.now(); - OzoneManagerProtocolProtos.RemoveAclRequest.Builder removeAclRequestBuilder - = getOmRequest().getRemoveAclRequest().toBuilder() + final OMRequest omRequest = super.preExecute(ozoneManager); + + final long modificationTime = Time.now(); + final OzoneManagerProtocolProtos.RemoveAclRequest.Builder removeAclRequestBuilder = + omRequest.getRemoveAclRequest().toBuilder() .setModificationTime(modificationTime); - return getOmRequest().toBuilder() + return omRequest.toBuilder() .setRemoveAclRequest(removeAclRequestBuilder) - .setUserInfo(getUserInfo()) .build(); } diff --git a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/bucket/acl/OMBucketSetAclRequest.java b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/bucket/acl/OMBucketSetAclRequest.java index 97dca83c1978..678c4ba0dc86 100644 --- a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/bucket/acl/OMBucketSetAclRequest.java +++ b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/bucket/acl/OMBucketSetAclRequest.java @@ -54,14 +54,15 @@ public class OMBucketSetAclRequest extends OMBucketAclRequest { @Override public OMRequest preExecute(OzoneManager ozoneManager) throws IOException { - long modificationTime = Time.now(); - OzoneManagerProtocolProtos.SetAclRequest.Builder setAclRequestBuilder = + final long modificationTime = Time.now(); + final OzoneManagerProtocolProtos.SetAclRequest.Builder setAclRequestBuilder = getOmRequest().getSetAclRequest().toBuilder() .setModificationTime(modificationTime); - return getOmRequest().toBuilder() + // super.preExecute resolves S3Authentication (STS) for Ratis apply. Merge SetAclRequest changes on top. + final OMRequest request = super.preExecute(ozoneManager); + return request.toBuilder() .setSetAclRequest(setAclRequestBuilder) - .setUserInfo(getUserInfo()) .build(); } diff --git a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/file/OMFileCreateRequest.java b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/file/OMFileCreateRequest.java index 9788cfbafe17..71651506f1d5 100644 --- a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/file/OMFileCreateRequest.java +++ b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/file/OMFileCreateRequest.java @@ -82,7 +82,7 @@ public OMFileCreateRequest(OMRequest omRequest, BucketLayout bucketLayout) { @Override public OMRequest preExecute(OzoneManager ozoneManager) throws IOException { - CreateFileRequest createFileRequest = super.preExecute(ozoneManager) + final CreateFileRequest createFileRequest = super.preExecute(ozoneManager) .getCreateFileRequest(); Objects.requireNonNull(createFileRequest, "createFileRequest == null"); diff --git a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/file/OMRecoverLeaseRequest.java b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/file/OMRecoverLeaseRequest.java index ca1ea07ad6ed..b01c69c64066 100644 --- a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/file/OMRecoverLeaseRequest.java +++ b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/file/OMRecoverLeaseRequest.java @@ -96,7 +96,7 @@ public OMRecoverLeaseRequest(OMRequest omRequest) { @Override @DisallowedUntilLayoutVersion(HBASE_SUPPORT) public OMRequest preExecute(OzoneManager ozoneManager) throws IOException { - OMRequest request = super.preExecute(ozoneManager); + final OMRequest request = super.preExecute(ozoneManager); RecoverLeaseRequest recoverLeaseRequest = request.getRecoverLeaseRequest(); String keyPath = recoverLeaseRequest.getKeyName(); diff --git a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/key/OMKeyCommitRequest.java b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/key/OMKeyCommitRequest.java index 4e1d3603d734..3d8bf932e093 100644 --- a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/key/OMKeyCommitRequest.java +++ b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/key/OMKeyCommitRequest.java @@ -87,7 +87,7 @@ public OMKeyCommitRequest(OMRequest omRequest, BucketLayout bucketLayout) { @Override public OMRequest preExecute(OzoneManager ozoneManager) throws IOException { - OMRequest request = super.preExecute(ozoneManager); + final OMRequest request = super.preExecute(ozoneManager); CommitKeyRequest commitKeyRequest = request.getCommitKeyRequest(); Objects.requireNonNull(commitKeyRequest, "commitKeyRequest == null"); diff --git a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/key/OMKeyCreateRequest.java b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/key/OMKeyCreateRequest.java index d7b14455369f..d2de7e391e6e 100644 --- a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/key/OMKeyCreateRequest.java +++ b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/key/OMKeyCreateRequest.java @@ -87,7 +87,7 @@ public OMKeyCreateRequest(OMRequest omRequest, BucketLayout bucketLayout) { @Override public OMRequest preExecute(OzoneManager ozoneManager) throws IOException { - CreateKeyRequest createKeyRequest = super.preExecute(ozoneManager) + final CreateKeyRequest createKeyRequest = super.preExecute(ozoneManager) .getCreateKeyRequest(); Objects.requireNonNull(createKeyRequest, "createKeyRequest == null"); diff --git a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/key/OMKeyDeleteRequest.java b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/key/OMKeyDeleteRequest.java index 4726d4af2d5f..24babd76b85a 100644 --- a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/key/OMKeyDeleteRequest.java +++ b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/key/OMKeyDeleteRequest.java @@ -75,7 +75,7 @@ public OMKeyDeleteRequest(OMRequest omRequest, BucketLayout bucketLayout) { @Override public OMRequest preExecute(OzoneManager ozoneManager) throws IOException { - DeleteKeyRequest deleteKeyRequest = super.preExecute(ozoneManager) + final DeleteKeyRequest deleteKeyRequest = super.preExecute(ozoneManager) .getDeleteKeyRequest(); Objects.requireNonNull(deleteKeyRequest, "deleteKeyRequest == null"); diff --git a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/key/OMKeyRenameRequest.java b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/key/OMKeyRenameRequest.java index 850f111a913f..9c46eba8f46f 100644 --- a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/key/OMKeyRenameRequest.java +++ b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/key/OMKeyRenameRequest.java @@ -72,7 +72,7 @@ public OMKeyRenameRequest(OMRequest omRequest, BucketLayout bucketLayout) { @Override public OMRequest preExecute(OzoneManager ozoneManager) throws IOException { - RenameKeyRequest renameKeyRequest = super.preExecute(ozoneManager) + final RenameKeyRequest renameKeyRequest = super.preExecute(ozoneManager) .getRenameKeyRequest(); Objects.requireNonNull(renameKeyRequest, "renameKeyRequest == null"); diff --git a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/key/OMKeySetTimesRequest.java b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/key/OMKeySetTimesRequest.java index eef06ef2b41e..fc46fbe34091 100644 --- a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/key/OMKeySetTimesRequest.java +++ b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/key/OMKeySetTimesRequest.java @@ -62,7 +62,7 @@ public class OMKeySetTimesRequest extends OMKeyRequest { @Override public OMRequest preExecute(OzoneManager ozoneManager) throws IOException { - OMRequest request = super.preExecute(ozoneManager); + final OMRequest request = super.preExecute(ozoneManager); SetTimesRequest setTimesRequest = request.getSetTimesRequest(); String keyPath = setTimesRequest.getKeyArgs().getKeyName(); String normalizedKeyPath = diff --git a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/key/acl/OMKeyAddAclRequest.java b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/key/acl/OMKeyAddAclRequest.java index 367e3c87db68..777ff972453c 100644 --- a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/key/acl/OMKeyAddAclRequest.java +++ b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/key/acl/OMKeyAddAclRequest.java @@ -63,14 +63,15 @@ public class OMKeyAddAclRequest extends OMKeyAclRequest { @Override public OMRequest preExecute(OzoneManager ozoneManager) throws IOException { - long modificationTime = Time.now(); - OzoneManagerProtocolProtos.AddAclRequest.Builder addAclRequestBuilder = - getOmRequest().getAddAclRequest().toBuilder() + final OMRequest omRequest = super.preExecute(ozoneManager); + + final long modificationTime = Time.now(); + final OzoneManagerProtocolProtos.AddAclRequest.Builder addAclRequestBuilder = + omRequest.getAddAclRequest().toBuilder() .setModificationTime(modificationTime); - return getOmRequest().toBuilder() + return omRequest.toBuilder() .setAddAclRequest(addAclRequestBuilder) - .setUserInfo(getUserInfo()) .build(); } diff --git a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/key/acl/OMKeyRemoveAclRequest.java b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/key/acl/OMKeyRemoveAclRequest.java index 0de996fd28ad..8761b7db359b 100644 --- a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/key/acl/OMKeyRemoveAclRequest.java +++ b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/key/acl/OMKeyRemoveAclRequest.java @@ -63,14 +63,15 @@ public class OMKeyRemoveAclRequest extends OMKeyAclRequest { @Override public OMRequest preExecute(OzoneManager ozoneManager) throws IOException { - long modificationTime = Time.now(); - OzoneManagerProtocolProtos.RemoveAclRequest.Builder removeAclRequestBuilder - = getOmRequest().getRemoveAclRequest().toBuilder() + final OMRequest omRequest = super.preExecute(ozoneManager); + + final long modificationTime = Time.now(); + final OzoneManagerProtocolProtos.RemoveAclRequest.Builder removeAclRequestBuilder = + omRequest.getRemoveAclRequest().toBuilder() .setModificationTime(modificationTime); - return getOmRequest().toBuilder() + return omRequest.toBuilder() .setRemoveAclRequest(removeAclRequestBuilder) - .setUserInfo(getUserInfo()) .build(); } diff --git a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/key/acl/OMKeySetAclRequest.java b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/key/acl/OMKeySetAclRequest.java index b0bd2f8fe528..bd9b7f677b47 100644 --- a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/key/acl/OMKeySetAclRequest.java +++ b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/key/acl/OMKeySetAclRequest.java @@ -64,14 +64,15 @@ public class OMKeySetAclRequest extends OMKeyAclRequest { @Override public OMRequest preExecute(OzoneManager ozoneManager) throws IOException { - long modificationTime = Time.now(); - OzoneManagerProtocolProtos.SetAclRequest.Builder setAclRequestBuilder = - getOmRequest().getSetAclRequest().toBuilder() + final OMRequest omRequest = super.preExecute(ozoneManager); + + final long modificationTime = Time.now(); + final OzoneManagerProtocolProtos.SetAclRequest.Builder setAclRequestBuilder = + omRequest.getSetAclRequest().toBuilder() .setModificationTime(modificationTime); - return getOmRequest().toBuilder() + return omRequest.toBuilder() .setSetAclRequest(setAclRequestBuilder) - .setUserInfo(getUserInfo()) .build(); } diff --git a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/s3/multipart/S3MultipartUploadAbortRequest.java b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/s3/multipart/S3MultipartUploadAbortRequest.java index a9aeff0ac5d1..905288e6f682 100644 --- a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/s3/multipart/S3MultipartUploadAbortRequest.java +++ b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/s3/multipart/S3MultipartUploadAbortRequest.java @@ -77,7 +77,7 @@ public S3MultipartUploadAbortRequest(OMRequest omRequest, @Override public OMRequest preExecute(OzoneManager ozoneManager) throws IOException { - KeyArgs keyArgs = super.preExecute(ozoneManager) + final KeyArgs keyArgs = super.preExecute(ozoneManager) .getAbortMultiPartUploadRequest().getKeyArgs(); String keyPath = keyArgs.getKeyName(); keyPath = validateAndNormalizeKey(ozoneManager.getEnableFileSystemPaths(), diff --git a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/s3/security/OMSetSecretRequest.java b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/s3/security/OMSetSecretRequest.java index 4b0fceb821af..9f8d6ca1ee29 100644 --- a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/s3/security/OMSetSecretRequest.java +++ b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/s3/security/OMSetSecretRequest.java @@ -55,11 +55,11 @@ public OMSetSecretRequest(OMRequest omRequest) { @Override public OMRequest preExecute(OzoneManager ozoneManager) throws IOException { + final OMRequest omRequest = super.preExecute(ozoneManager); final OMMetadataManager omMetadataManager = ozoneManager.getMetadataManager(); - final SetS3SecretRequest request = - getOmRequest().getSetS3SecretRequest(); + final SetS3SecretRequest request = omRequest.getSetS3SecretRequest(); final String accessId = request.getAccessId(); @@ -94,7 +94,7 @@ public OMRequest preExecute(OzoneManager ozoneManager) throws IOException { S3SecretRequestHelper.checkAccessIdSecretOpPermission( ozoneManager, ugi, accessId); - return getOmRequest(); + return omRequest; } @Override 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 a87f2de54dde..4efd18b4b327 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 @@ -90,7 +90,8 @@ public S3AssumeRoleRequest(OMRequest omRequest, Clock clock) { @Override public OMRequest preExecute(OzoneManager ozoneManager) throws IOException { - final AssumeRoleRequest assumeRoleRequest = getOmRequest().getAssumeRoleRequest(); + final OMRequest omRequest = super.preExecute(ozoneManager); + final AssumeRoleRequest assumeRoleRequest = omRequest.getAssumeRoleRequest(); // Brief overview of flow: // The STS Endpoint makes the AssumeRole call, which when received by OM leader (via this method), @@ -123,23 +124,9 @@ public OMRequest preExecute(OzoneManager ozoneManager) throws IOException { updateAssumeRoleRequestBuilder.setAwsIamSessionPolicy(assumeRoleRequest.getAwsIamSessionPolicy()); } - // Build new OMRequest with both original and update requests - final OMRequest.Builder omRequest = OMRequest.newBuilder() - .setUserInfo(getUserInfo()) - .setCmdType(getOmRequest().getCmdType()) - .setClientId(getOmRequest().getClientId()) - .setAssumeRoleRequest(assumeRoleRequest) - .setUpdateAssumeRoleRequest(updateAssumeRoleRequestBuilder.build()); - - if (getOmRequest().hasS3Authentication()) { - omRequest.setS3Authentication(getOmRequest().getS3Authentication()); - } - - if (getOmRequest().hasTraceID()) { - omRequest.setTraceID(getOmRequest().getTraceID()); - } - - return omRequest.build(); + return omRequest.toBuilder() + .setUpdateAssumeRoleRequest(updateAssumeRoleRequestBuilder.build()) + .build(); } @Override diff --git a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/s3/security/S3DeleteRevokedSTSTokensRequest.java b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/s3/security/S3DeleteRevokedSTSTokensRequest.java index ee2a8656445c..f41b20353a83 100644 --- a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/s3/security/S3DeleteRevokedSTSTokensRequest.java +++ b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/s3/security/S3DeleteRevokedSTSTokensRequest.java @@ -54,9 +54,7 @@ public OMRequest preExecute(OzoneManager ozoneManager) throws IOException { throw new OMException("Only admins can delete revoked STS tokens", OMException.ResultCodes.PERMISSION_DENIED); } - return getOmRequest().toBuilder() - .setUserInfo(getUserInfo()) - .build(); + return super.preExecute(ozoneManager); } @Override diff --git a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/s3/security/S3GetSecretRequest.java b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/s3/security/S3GetSecretRequest.java index 2c698abefe06..c0609d7a52e3 100644 --- a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/s3/security/S3GetSecretRequest.java +++ b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/s3/security/S3GetSecretRequest.java @@ -59,9 +59,9 @@ public S3GetSecretRequest(OMRequest omRequest) { @Override public OMRequest preExecute(OzoneManager ozoneManager) throws IOException { + final OMRequest omRequest = super.preExecute(ozoneManager); - final GetS3SecretRequest s3GetSecretRequest = - getOmRequest().getGetS3SecretRequest(); + final GetS3SecretRequest s3GetSecretRequest = omRequest.getGetS3SecretRequest(); // The proto field kerberosID is effectively accessId w/ Multi-Tenancy // @@ -83,10 +83,7 @@ public OMRequest preExecute(OzoneManager ozoneManager) throws IOException { // way S3Secret created by leader, will be replicated across all // OMs. With this approach, original GetS3Secret request from // client does not need any proto changes. - OMRequest.Builder omRequest = OMRequest.newBuilder() - .setUserInfo(getUserInfo()) - .setCmdType(getOmRequest().getCmdType()) - .setClientId(getOmRequest().getClientId()); + final OMRequest.Builder omRequestBuilder = omRequest.toBuilder(); // createIfNotExist defaults to true if not specified. boolean createIfNotExist = !s3GetSecretRequest.hasCreateIfNotExist() @@ -98,7 +95,7 @@ public OMRequest preExecute(OzoneManager ozoneManager) throws IOException { .setKerberosID(accessId) // See Note 1 above .setCreateIfNotExist(createIfNotExist) .build(); - omRequest.setGetS3SecretRequest(newGetS3SecretRequest); + omRequestBuilder.setGetS3SecretRequest(newGetS3SecretRequest); // When createIfNotExist is true, pass UpdateGetS3SecretRequest message; // otherwise, just use GetS3SecretRequest message. @@ -113,14 +110,12 @@ public OMRequest preExecute(OzoneManager ozoneManager) throws IOException { .setAwsSecret(s3Secret) .build(); - omRequest.setUpdateGetS3SecretRequest(updateGetS3SecretRequest); - } - - if (getOmRequest().hasTraceID()) { - omRequest.setTraceID(getOmRequest().getTraceID()); + omRequestBuilder.setUpdateGetS3SecretRequest(updateGetS3SecretRequest); + } else { + omRequestBuilder.clearUpdateGetS3SecretRequest(); } - return omRequest.build(); + return omRequestBuilder.build(); } @Override diff --git a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/s3/security/S3RevokeSTSTokenRequest.java b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/s3/security/S3RevokeSTSTokenRequest.java index 94c2f8d50831..52a92d8a5560 100644 --- a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/s3/security/S3RevokeSTSTokenRequest.java +++ b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/s3/security/S3RevokeSTSTokenRequest.java @@ -60,8 +60,9 @@ public S3RevokeSTSTokenRequest(OMRequest omRequest) { @Override public OMRequest preExecute(OzoneManager ozoneManager) throws IOException { + final OMRequest omRequest = super.preExecute(ozoneManager); final OzoneManagerProtocolProtos.RevokeSTSTokenRequest revokeReq = - getOmRequest().getRevokeSTSTokenRequest(); + omRequest.getRevokeSTSTokenRequest(); // Get the original (long-lived) access key id from the session token // and enforce the same permission model that is used for S3 secret @@ -73,21 +74,10 @@ public OMRequest preExecute(OzoneManager ozoneManager) throws IOException { sessionToken, ozoneManager.getSecretKeyClient(), CLOCK); final String originalAccessKeyId = stsTokenIdentifier.getOriginalAccessKeyId(); - final OzoneManagerProtocolProtos.UserInfo userInfo = getUserInfo(); final UserGroupInformation ugi = S3SecretRequestHelper.getOrCreateUgi(originalAccessKeyId); S3SecretRequestHelper.checkAccessIdSecretOpPermission(ozoneManager, ugi, originalAccessKeyId); - final OMRequest.Builder omRequest = OMRequest.newBuilder() - .setRevokeSTSTokenRequest(revokeReq) - .setCmdType(getOmRequest().getCmdType()) - .setClientId(getOmRequest().getClientId()) - .setUserInfo(userInfo); - - if (getOmRequest().hasTraceID()) { - omRequest.setTraceID(getOmRequest().getTraceID()); - } - - return omRequest.build(); + return omRequest; } @Override diff --git a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/s3/security/S3RevokeSecretRequest.java b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/s3/security/S3RevokeSecretRequest.java index 24e9b1a3b46e..f74d5a57977a 100644 --- a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/s3/security/S3RevokeSecretRequest.java +++ b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/s3/security/S3RevokeSecretRequest.java @@ -50,8 +50,9 @@ public S3RevokeSecretRequest(OMRequest omRequest) { @Override public OMRequest preExecute(OzoneManager ozoneManager) throws IOException { + final OMRequest omRequest = super.preExecute(ozoneManager); final RevokeS3SecretRequest s3RevokeSecretRequest = - getOmRequest().getRevokeS3SecretRequest(); + omRequest.getRevokeS3SecretRequest(); final String accessId = s3RevokeSecretRequest.getKerberosID(); final UserGroupInformation ugi = S3SecretRequestHelper.getOrCreateUgi(accessId); @@ -63,16 +64,9 @@ public OMRequest preExecute(OzoneManager ozoneManager) throws IOException { RevokeS3SecretRequest.newBuilder() .setKerberosID(accessId).build(); - OMRequest.Builder omRequest = OMRequest.newBuilder() + return omRequest.toBuilder() .setRevokeS3SecretRequest(revokeS3SecretRequest) - .setCmdType(getOmRequest().getCmdType()) - .setClientId(getOmRequest().getClientId()); - - if (getOmRequest().hasTraceID()) { - omRequest.setTraceID(getOmRequest().getTraceID()); - } - - return omRequest.build(); + .build(); } @Override diff --git a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/security/OMCancelDelegationTokenRequest.java b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/security/OMCancelDelegationTokenRequest.java index ab3cc4fc0ab7..0b6ba0138601 100644 --- a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/security/OMCancelDelegationTokenRequest.java +++ b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/security/OMCancelDelegationTokenRequest.java @@ -59,7 +59,7 @@ public OMCancelDelegationTokenRequest(OMRequest omRequest) { @Override public OMRequest preExecute(OzoneManager ozoneManager) throws IOException { // We need to populate user info in our request object. - OMRequest request = super.preExecute(ozoneManager); + final OMRequest request = super.preExecute(ozoneManager); AuditLogger auditLogger = ozoneManager.getAuditLogger(); Map auditMap = null; diff --git a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/security/OMGetDelegationTokenRequest.java b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/security/OMGetDelegationTokenRequest.java index 109d39ddab98..8106c6331cb5 100644 --- a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/security/OMGetDelegationTokenRequest.java +++ b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/security/OMGetDelegationTokenRequest.java @@ -63,7 +63,7 @@ public OMGetDelegationTokenRequest(OMRequest omRequest) { @Override public OMRequest preExecute(OzoneManager ozoneManager) throws IOException { // We need to populate user info in our request object. - OMRequest request = super.preExecute(ozoneManager); + final OMRequest request = super.preExecute(ozoneManager); GetDelegationTokenRequestProto getDelegationTokenRequest = request.getGetDelegationTokenRequest(); diff --git a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/security/OMRenewDelegationTokenRequest.java b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/security/OMRenewDelegationTokenRequest.java index fbac8d4b14c9..38e86c3ce6fe 100644 --- a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/security/OMRenewDelegationTokenRequest.java +++ b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/security/OMRenewDelegationTokenRequest.java @@ -60,7 +60,7 @@ public OMRenewDelegationTokenRequest(OMRequest omRequest) { @Override public OMRequest preExecute(OzoneManager ozoneManager) throws IOException { // We need to populate user info in our request object. - OMRequest request = super.preExecute(ozoneManager); + final OMRequest request = super.preExecute(ozoneManager); RenewDelegationTokenRequestProto renewDelegationTokenRequest = request.getRenewDelegationTokenRequest(); diff --git a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/snapshot/OMSnapshotMoveTableKeysRequest.java b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/snapshot/OMSnapshotMoveTableKeysRequest.java index fef5dc76c4de..440e2aed7724 100644 --- a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/snapshot/OMSnapshotMoveTableKeysRequest.java +++ b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/snapshot/OMSnapshotMoveTableKeysRequest.java @@ -74,9 +74,10 @@ public OMSnapshotMoveTableKeysRequest(OMRequest omRequest) { @Override public OMRequest preExecute(OzoneManager ozoneManager) throws IOException { - OmMetadataManagerImpl omMetadataManager = (OmMetadataManagerImpl) ozoneManager.getMetadataManager(); + final OMRequest omRequest = super.preExecute(ozoneManager); + final OmMetadataManagerImpl omMetadataManager = (OmMetadataManagerImpl) ozoneManager.getMetadataManager(); SnapshotChainManager snapshotChainManager = omMetadataManager.getSnapshotChainManager(); - SnapshotMoveTableKeysRequest moveTableKeysRequest = getOmRequest().getSnapshotMoveTableKeysRequest(); + SnapshotMoveTableKeysRequest moveTableKeysRequest = omRequest.getSnapshotMoveTableKeysRequest(); UUID fromSnapshotID = fromProtobuf(moveTableKeysRequest.getFromSnapshotID()); SnapshotInfo fromSnapshot = SnapshotUtils.getSnapshotInfo(ozoneManager, snapshotChainManager, fromSnapshotID); @@ -179,7 +180,7 @@ public OMRequest preExecute(OzoneManager ozoneManager) throws IOException { } } } - return getOmRequest().toBuilder().setSnapshotMoveTableKeysRequest( + return omRequest.toBuilder().setSnapshotMoveTableKeysRequest( moveTableKeysRequest.toBuilder().clearDeletedDirs().clearDeletedKeys().clearRenamedKeys() .addAllDeletedKeys(deletedKeys).addAllDeletedDirs(deletedDirs) .addAllRenamedKeys(renamedKeysList).build()).build(); diff --git a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/volume/OMVolumeSetOwnerRequest.java b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/volume/OMVolumeSetOwnerRequest.java index 02c3b7874e99..f5435b1cfe1e 100644 --- a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/volume/OMVolumeSetOwnerRequest.java +++ b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/volume/OMVolumeSetOwnerRequest.java @@ -61,15 +61,15 @@ public OMVolumeSetOwnerRequest(OMRequest omRequest) { @Override public OMRequest preExecute(OzoneManager ozoneManager) throws IOException { + final OMRequest omRequest = super.preExecute(ozoneManager); long modificationTime = Time.now(); - SetVolumePropertyRequest.Builder setPropertyRequestBuilder = getOmRequest() + SetVolumePropertyRequest.Builder setPropertyRequestBuilder = omRequest .getSetVolumePropertyRequest().toBuilder() .setModificationTime(modificationTime); - return getOmRequest().toBuilder() + return omRequest.toBuilder() .setSetVolumePropertyRequest(setPropertyRequestBuilder) - .setUserInfo(getUserInfo()) .build(); } diff --git a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/volume/OMVolumeSetQuotaRequest.java b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/volume/OMVolumeSetQuotaRequest.java index c93e8cbeb6c3..780f63505161 100644 --- a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/volume/OMVolumeSetQuotaRequest.java +++ b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/volume/OMVolumeSetQuotaRequest.java @@ -63,15 +63,15 @@ public OMVolumeSetQuotaRequest(OMRequest omRequest) { @Override public OMRequest preExecute(OzoneManager ozoneManager) throws IOException { + final OMRequest omRequest = super.preExecute(ozoneManager); long modificationTime = Time.now(); - SetVolumePropertyRequest.Builder setPropertyRequestBuilde = getOmRequest() + SetVolumePropertyRequest.Builder setPropertyRequestBuilder = omRequest .getSetVolumePropertyRequest().toBuilder() .setModificationTime(modificationTime); - return getOmRequest().toBuilder() - .setSetVolumePropertyRequest(setPropertyRequestBuilde) - .setUserInfo(getUserInfo()) + return omRequest.toBuilder() + .setSetVolumePropertyRequest(setPropertyRequestBuilder) .build(); } diff --git a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/volume/acl/OMVolumeAddAclRequest.java b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/volume/acl/OMVolumeAddAclRequest.java index c0e87043ea34..72c6de925fa9 100644 --- a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/volume/acl/OMVolumeAddAclRequest.java +++ b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/volume/acl/OMVolumeAddAclRequest.java @@ -58,14 +58,15 @@ public class OMVolumeAddAclRequest extends OMVolumeAclRequest { @Override public OMRequest preExecute(OzoneManager ozoneManager) throws IOException { - long modificationTime = Time.now(); - OzoneManagerProtocolProtos.AddAclRequest.Builder addAclRequestBuilder = - getOmRequest().getAddAclRequest().toBuilder() + final OMRequest omRequest = super.preExecute(ozoneManager); + + final long modificationTime = Time.now(); + final OzoneManagerProtocolProtos.AddAclRequest.Builder addAclRequestBuilder = + omRequest.getAddAclRequest().toBuilder() .setModificationTime(modificationTime); - return getOmRequest().toBuilder() + return omRequest.toBuilder() .setAddAclRequest(addAclRequestBuilder) - .setUserInfo(getUserInfo()) .build(); } diff --git a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/volume/acl/OMVolumeRemoveAclRequest.java b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/volume/acl/OMVolumeRemoveAclRequest.java index 05f338957ee6..59267d7849f7 100644 --- a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/volume/acl/OMVolumeRemoveAclRequest.java +++ b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/volume/acl/OMVolumeRemoveAclRequest.java @@ -58,14 +58,15 @@ public class OMVolumeRemoveAclRequest extends OMVolumeAclRequest { @Override public OMRequest preExecute(OzoneManager ozoneManager) throws IOException { - long modificationTime = Time.now(); - OzoneManagerProtocolProtos.RemoveAclRequest.Builder removeAclRequestBuilder - = getOmRequest().getRemoveAclRequest().toBuilder() + final OMRequest omRequest = super.preExecute(ozoneManager); + + final long modificationTime = Time.now(); + final OzoneManagerProtocolProtos.RemoveAclRequest.Builder removeAclRequestBuilder = + omRequest.getRemoveAclRequest().toBuilder() .setModificationTime(modificationTime); - return getOmRequest().toBuilder() + return omRequest.toBuilder() .setRemoveAclRequest(removeAclRequestBuilder) - .setUserInfo(getUserInfo()) .build(); } diff --git a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/volume/acl/OMVolumeSetAclRequest.java b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/volume/acl/OMVolumeSetAclRequest.java index 6abffc2197fa..ce3eba72672c 100644 --- a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/volume/acl/OMVolumeSetAclRequest.java +++ b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/volume/acl/OMVolumeSetAclRequest.java @@ -57,14 +57,15 @@ public class OMVolumeSetAclRequest extends OMVolumeAclRequest { @Override public OMRequest preExecute(OzoneManager ozoneManager) throws IOException { - long modificationTime = Time.now(); - OzoneManagerProtocolProtos.SetAclRequest.Builder setAclRequestBuilder = - getOmRequest().getSetAclRequest().toBuilder() + final OMRequest omRequest = super.preExecute(ozoneManager); + + final long modificationTime = Time.now(); + final OzoneManagerProtocolProtos.SetAclRequest.Builder setAclRequestBuilder = + omRequest.getSetAclRequest().toBuilder() .setModificationTime(modificationTime); - return getOmRequest().toBuilder() + return omRequest.toBuilder() .setSetAclRequest(setAclRequestBuilder) - .setUserInfo(getUserInfo()) .build(); } diff --git a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/security/STSSecurityUtil.java b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/security/STSSecurityUtil.java index c414708cebee..2212ad6db797 100644 --- a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/security/STSSecurityUtil.java +++ b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/security/STSSecurityUtil.java @@ -31,7 +31,9 @@ import org.apache.hadoop.hdds.security.symmetric.ManagedSecretKey; import org.apache.hadoop.hdds.security.symmetric.SecretKeyClient; import org.apache.hadoop.ozone.om.exceptions.OMException; +import org.apache.hadoop.ozone.protocol.proto.OzoneManagerProtocolProtos; import org.apache.hadoop.ozone.protocol.proto.OzoneManagerProtocolProtos.OMTokenProto; +import org.apache.hadoop.ozone.protocol.proto.OzoneManagerProtocolProtos.S3Authentication; import org.apache.hadoop.security.token.SecretManager; import org.apache.hadoop.security.token.Token; @@ -179,5 +181,44 @@ static void ensureEssentialFieldsArePresentInToken(STSTokenIdentifier stsTokenId throw new SecretManager.InvalidToken("Invalid STS token - secretAccessKey is null/empty"); } } + + /** + * Ensures STS-related {@link S3Authentication} fields are structurally consistent on the Ratis + * apply path. Cryptographic validation (signature, expiry, secret key lookup) runs on the leader + * RPC path (e.g. {@code S3SecurityUtil.validateS3Credential}). This method performs no crypto and does + * not contact {@link SecretKeyClient}, keeping the apply thread deterministic and lightweight. + * + * @param request OM request possibly containing S3 authentication + * @throws OMException if resolved fields and session token presence are inconsistent + */ + public static void ensureResolvedStsFieldsInvariants(OzoneManagerProtocolProtos.OMRequest request) + throws OMException { + if (!request.hasS3Authentication()) { + return; + } + + final S3Authentication s3Auth = request.getS3Authentication(); + final boolean hasSessionToken = s3Auth.hasSessionToken() && !s3Auth.getSessionToken().isEmpty(); + + if (!hasSessionToken) { + // If sessionToken is missing/empty, resolved fields must be empty. + if (s3Auth.hasResolvedStsSessionPolicy() || s3Auth.hasResolvedStsRoleArn() || + s3Auth.hasResolvedStsOriginalAccessKeyId() || s3Auth.hasResolvedStsTempAccessKeyId() || + s3Auth.hasResolvedStsSecretKeyId()) { + throw new OMException("Resolved STS fields must be empty when sessionToken is not present", INVALID_TOKEN); + } + return; + } + + ensureResolvedFieldsArePresent(s3Auth); + } + + private static void ensureResolvedFieldsArePresent(S3Authentication s3Auth) throws OMException { + if (!s3Auth.hasResolvedStsSessionPolicy() || !s3Auth.hasResolvedStsRoleArn() || + !s3Auth.hasResolvedStsOriginalAccessKeyId() || !s3Auth.hasResolvedStsTempAccessKeyId() || + !s3Auth.hasResolvedStsSecretKeyId()) { + throw new OMException("Resolved STS fields must be present when sessionToken is present", INVALID_TOKEN); + } + } } diff --git a/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/om/ratis/TestOzoneManagerStateMachine.java b/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/om/ratis/TestOzoneManagerStateMachine.java index 111779b95734..1ab49ef67f23 100644 --- a/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/om/ratis/TestOzoneManagerStateMachine.java +++ b/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/om/ratis/TestOzoneManagerStateMachine.java @@ -125,6 +125,7 @@ public void setup() { @AfterEach public void tearDown() { sm.stop(); + OzoneManager.setStsTokenIdentifier(null); } // --- startTransaction tests --- @@ -405,6 +406,79 @@ public void testRunCommandRuntimeExceptionTerminates() throws Exception { () -> sm.runCommand(request, ti)); } + @Test + public void testRunCommandSetsAndClearsStsThreadLocal() throws Exception { + when(om.isSecurityEnabled()).thenReturn(true); + + final OzoneManagerProtocolProtos.S3Authentication s3Auth = + OzoneManagerProtocolProtos.S3Authentication.newBuilder() + .setAccessId("accessId") + .setSessionToken("sessionToken") + .setResolvedStsSessionPolicy("sessionPolicy") + .setResolvedStsRoleArn("roleArn") + .setResolvedStsOriginalAccessKeyId("originalAccessKeyId") + .setResolvedStsTempAccessKeyId("tempAccessKeyId") + .setResolvedStsSecretKeyId("secretKeyId") + .build(); + + final OMRequest request = sampleWriteRequest().toBuilder() + .setS3Authentication(s3Auth) + .build(); + final TermIndex ti = TermIndex.valueOf(1, 5); + + final OMResponse expectedResponse = OMResponse.newBuilder() + .setCmdType(Type.CreateKey) + .setStatus(Status.OK) + .setSuccess(true) + .build(); + + final OMClientResponse clientResponse = mock(OMClientResponse.class); + when(clientResponse.getOMResponse()).thenReturn(expectedResponse); + when(clientResponse.getOmLockDetails()).thenReturn(null); + + doAnswer(invocation -> { + assertNotNull(OzoneManager.getStsTokenIdentifier(), + "Expected STS ThreadLocal to be set during handler.handleWriteRequest"); + assertEquals("tempAccessKeyId", OzoneManager.getStsTokenIdentifier().getTempAccessKeyId()); + assertEquals("originalAccessKeyId", OzoneManager.getStsTokenIdentifier().getOriginalAccessKeyId()); + assertEquals("roleArn", OzoneManager.getStsTokenIdentifier().getRoleArn()); + assertEquals("sessionPolicy", OzoneManager.getStsTokenIdentifier().getSessionPolicy()); + return clientResponse; + }).when(handler).handleWriteRequest(eq(request), any(), eq(doubleBuffer)); + + assertNull(OzoneManager.getStsTokenIdentifier(), "Expected STS ThreadLocal to be clear before runCommand"); + + OMResponse result = sm.runCommand(request, ti); + + assertNotNull(result); + assertTrue(result.getSuccess()); + assertNull(OzoneManager.getStsTokenIdentifier(), "Expected STS ThreadLocal to be cleared after runCommand"); + } + + @Test + public void testRunCommandMissingResolvedStsFieldsReturnsErrorResponse() throws Exception { + when(om.isSecurityEnabled()).thenReturn(true); + + final OzoneManagerProtocolProtos.S3Authentication s3Auth = + OzoneManagerProtocolProtos.S3Authentication.newBuilder() + .setAccessId("accessId") + .setSessionToken("sessionToken") + .build(); + + final OMRequest request = sampleWriteRequest().toBuilder() + .setS3Authentication(s3Auth) + .build(); + final TermIndex ti = TermIndex.valueOf(1, 5); + + OMResponse result = sm.runCommand(request, ti); + + assertNotNull(result); + assertFalse(result.getSuccess()); + assertEquals(Status.INVALID_TOKEN, result.getStatus()); + assertNull(OzoneManager.getStsTokenIdentifier(), "Expected STS ThreadLocal to be cleared after runCommand"); + verify(handler, never()).handleWriteRequest(any(), any(), any()); + } + // --- processResponse tests --- @Test diff --git a/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/om/request/TestOMClientRequestWithUserInfo.java b/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/om/request/TestOMClientRequestWithUserInfo.java index fe11baef37e8..f97ed7276406 100644 --- a/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/om/request/TestOMClientRequestWithUserInfo.java +++ b/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/om/request/TestOMClientRequestWithUserInfo.java @@ -42,9 +42,12 @@ import org.apache.hadoop.ozone.om.OmConfig; import org.apache.hadoop.ozone.om.OmMetadataManagerImpl; 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.BucketLayout; import org.apache.hadoop.ozone.om.request.bucket.OMBucketCreateRequest; import org.apache.hadoop.ozone.om.request.key.OMKeyCommitRequest; +import org.apache.hadoop.ozone.om.response.OMClientResponse; import org.apache.hadoop.ozone.om.upgrade.OMLayoutVersionManager; import org.apache.hadoop.ozone.protocol.proto.OzoneManagerProtocolProtos; import org.apache.hadoop.ozone.protocol.proto.OzoneManagerProtocolProtos.BucketInfo; @@ -210,6 +213,72 @@ public void testUserInfoWithSTSToken() throws IOException { } } + @Test + public void testPreExecuteOverwritesResolvedStsFields() throws Exception { + try (MockedStatic mockedRpcServer = mockStatic(Server.class)) { + mockedRpcServer.when(Server::getRemoteUser).thenReturn(userGroupInformation); + mockedRpcServer.when(Server::getRemoteIp).thenReturn(inetAddress); + mockedRpcServer.when(Server::getRemoteAddress).thenReturn(inetAddress.toString()); + + final String accessId = "ASIA12345"; + final String signature = "Signature"; + final String stringToSign = "StringToSign"; + final String sessionToken = "SessionToken"; + final String originalAccessKeyId = "AKIAORIGINAL"; + final String roleArn = "arn:aws:iam::123456789012:role/test-role"; + final String sessionPolicy = "test-session-policy"; + final UUID secretKeyId = UUID.randomUUID(); + + final STSTokenIdentifier stsTokenIdentifier = mock(STSTokenIdentifier.class); + when(stsTokenIdentifier.getSessionPolicy()).thenReturn(sessionPolicy); + when(stsTokenIdentifier.getRoleArn()).thenReturn(roleArn); + when(stsTokenIdentifier.getOriginalAccessKeyId()).thenReturn(originalAccessKeyId); + when(stsTokenIdentifier.getTempAccessKeyId()).thenReturn(accessId); + when(stsTokenIdentifier.getSecretKeyId()).thenReturn(secretKeyId); + + final S3Authentication s3Authentication = S3Authentication.newBuilder() + .setAccessId(accessId) + .setSignature(signature) + .setStringToSign(stringToSign) + .setSessionToken(sessionToken) + .setResolvedStsSessionPolicy("client-session-policy") + .setResolvedStsRoleArn("client-role") + .setResolvedStsOriginalAccessKeyId("client-original-access-key-id") + .setResolvedStsTempAccessKeyId("client-temp-access-key-id") + .setResolvedStsSecretKeyId("client-secret-key-id") + .build(); + + OzoneManager.setS3Auth(s3Authentication); + OzoneManager.setStsTokenIdentifier(stsTokenIdentifier); + + try { + final String bucketName = UUID.randomUUID().toString(); + final String volumeName = UUID.randomUUID().toString(); + final BucketInfo.Builder bucketInfo = + newBucketInfoBuilder(bucketName, volumeName) + .setIsVersionEnabled(true) + .setStorageType(StorageTypeProto.DISK); + + final OMRequest omRequest = newCreateBucketRequest(bucketInfo) + .setS3Authentication(s3Authentication) + .build(); + + final OMBucketCreateRequest omBucketCreateRequest = new OMBucketCreateRequest(omRequest); + final OMRequest modifiedRequest = omBucketCreateRequest.preExecute(ozoneManager); + final S3Authentication modifiedS3Auth = modifiedRequest.getS3Authentication(); + + assertEquals(sessionPolicy, modifiedS3Auth.getResolvedStsSessionPolicy()); + assertEquals(roleArn, modifiedS3Auth.getResolvedStsRoleArn()); + assertEquals(originalAccessKeyId, modifiedS3Auth.getResolvedStsOriginalAccessKeyId()); + assertEquals(accessId, modifiedS3Auth.getResolvedStsTempAccessKeyId()); + assertEquals(secretKeyId.toString(), modifiedS3Auth.getResolvedStsSecretKeyId()); + } finally { + OzoneManager.setStsTokenIdentifier(null); + OzoneManager.setS3Auth(null); + } + } + } + @Test public void testUserInfoWithSTSAccessKeyMissingSessionToken() { final String accessId = "ASIA12345"; @@ -306,4 +375,58 @@ public void testUserInfoWithSessionTokenButEmptyOriginalAccessKeyId() { } } + @Test + public void testPreExecuteRejectsSessionTokenWithoutStsTokenIdentifierWhenSecurityEnabled() { + when(ozoneManager.isSecurityEnabled()).thenReturn(true); + + final String accessId = "ASIA12345"; + final String signature = "Signature"; + final String stringToSign = "StringToSign"; + final String sessionToken = "SessionToken"; + + final S3Authentication s3Authentication = S3Authentication.newBuilder() + .setAccessId(accessId) + .setSignature(signature) + .setStringToSign(stringToSign) + .setSessionToken(sessionToken) + .build(); + + final OMRequest omRequest = OMRequest.newBuilder() + .setCmdType(OzoneManagerProtocolProtos.Type.CommitKey) + .setClientId(UUID.randomUUID().toString()) + .setS3Authentication(s3Authentication) + .build(); + + try { + OzoneManager.setStsTokenIdentifier(null); + final OMClientRequest omClientRequest = new DummyOMClientRequest(omRequest); + + final OMException ex = assertThrows(OMException.class, () -> omClientRequest.preExecute(ozoneManager)); + assertEquals(OMException.ResultCodes.INVALID_REQUEST, ex.getResult()); + assertTrue(ex.getMessage().contains("session token")); + } finally { + OzoneManager.setStsTokenIdentifier(null); + } + } + + private static final class DummyOMClientRequest extends OMClientRequest { + private DummyOMClientRequest(OMRequest omRequest) { + super(omRequest); + } + + @Override + public OzoneManagerProtocolProtos.UserInfo getUserIfNotExists(OzoneManager ozoneManager) { + return OzoneManagerProtocolProtos.UserInfo.newBuilder() + .setUserName("test-user") + .setHostName("localhost") + .setRemoteAddress("127.0.0.1") + .build(); + } + + @Override + public OMClientResponse validateAndUpdateCache(OzoneManager ozoneManager, ExecutionContext context) { + return null; + } + } + } diff --git a/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/om/request/s3/security/TestS3GetSecretRequest.java b/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/om/request/s3/security/TestS3GetSecretRequest.java index 0067b2b38047..5f23bfb14e2e 100644 --- a/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/om/request/s3/security/TestS3GetSecretRequest.java +++ b/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/om/request/s3/security/TestS3GetSecretRequest.java @@ -179,8 +179,12 @@ public void setUp() throws Exception { @AfterEach public void tearDown() throws Exception { + if (omMetadataManager != null) { + omMetadataManager.close(); + } omMetrics.unRegister(); framework().clearInlineMocks(); + Server.getCurCall().remove(); } private OMRequest createTenantRequest(String tenantNameStr) { diff --git a/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/security/TestSTSSecurityUtil.java b/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/security/TestSTSSecurityUtil.java index c93df8a49009..8decf4fd316f 100644 --- a/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/security/TestSTSSecurityUtil.java +++ b/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/security/TestSTSSecurityUtil.java @@ -35,7 +35,10 @@ import org.apache.hadoop.hdds.security.symmetric.SecretKeyClient; import org.apache.hadoop.io.Text; 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.OMTokenProto; +import org.apache.hadoop.ozone.protocol.proto.OzoneManagerProtocolProtos.S3Authentication; +import org.apache.hadoop.ozone.protocol.proto.OzoneManagerProtocolProtos.Type; import org.apache.hadoop.security.token.SecretManager; import org.apache.hadoop.security.token.Token; import org.apache.ozone.test.TestClock; @@ -374,4 +377,76 @@ public void testEnsureEssentialFieldsArePresentInTokenMissingSecretAccessKey() { .isInstanceOf(SecretManager.InvalidToken.class) .hasMessage("Invalid STS token - secretAccessKey is null/empty"); } + + @Test + public void testEnsureResolvedStsFieldsInvariantsSuccess() throws Exception { + final String tokenString = tokenSecretManager.createSTSTokenString( + TEMP_ACCESS_KEY, ORIGINAL_ACCESS_KEY, ROLE_ARN, DURATION_SECONDS, SECRET_ACCESS_KEY, SESSION_POLICY, clock); + + final S3Authentication s3Auth = S3Authentication.newBuilder() + .setSessionToken(tokenString) + .setResolvedStsSessionPolicy(SESSION_POLICY) + .setResolvedStsRoleArn(ROLE_ARN) + .setResolvedStsOriginalAccessKeyId(ORIGINAL_ACCESS_KEY) + .setResolvedStsTempAccessKeyId(TEMP_ACCESS_KEY) + .setResolvedStsSecretKeyId(secretKeyId.toString()) + .build(); + + final OMRequest request = OMRequest.newBuilder() + .setCmdType(Type.CreateBucket) + .setClientId("client-id") + .setS3Authentication(s3Auth) + .build(); + + STSSecurityUtil.ensureResolvedStsFieldsInvariants(request); + } + + @Test + public void testEnsureResolvedStsFieldsInvariantsMissingSessionToken() { + final S3Authentication s3Auth = S3Authentication.newBuilder() + .setResolvedStsSessionPolicy(SESSION_POLICY) + .build(); + + final OMRequest request = OMRequest.newBuilder() + .setCmdType(Type.CreateBucket) + .setClientId("client-id") + .setS3Authentication(s3Auth) + .build(); + + assertThatThrownBy(() -> STSSecurityUtil.ensureResolvedStsFieldsInvariants(request)) + .isInstanceOf(OMException.class) + .hasMessageContaining("Resolved STS fields must be empty when sessionToken is not present"); + } + + @Test + public void testEnsureResolvedStsFieldsInvariantsMissingResolvedFields() throws Exception { + final String tokenString = tokenSecretManager.createSTSTokenString( + TEMP_ACCESS_KEY, ORIGINAL_ACCESS_KEY, ROLE_ARN, DURATION_SECONDS, + SECRET_ACCESS_KEY, SESSION_POLICY, clock); + + final S3Authentication s3Auth = S3Authentication.newBuilder() + .setSessionToken(tokenString) + .build(); + + final OMRequest request = OMRequest.newBuilder() + .setCmdType(Type.CreateBucket) + .setClientId("client-id") + .setS3Authentication(s3Auth) + .build(); + + assertThatThrownBy(() -> STSSecurityUtil.ensureResolvedStsFieldsInvariants(request)) + .isInstanceOf(OMException.class) + .hasMessageContaining("Resolved STS fields must be present when sessionToken is present"); + } + + @Test + public void testEnsureResolvedStsFieldsInvariantsNoS3Auth() throws Exception { + final OMRequest request = OMRequest.newBuilder() + .setCmdType(Type.CreateBucket) + .setClientId("client-id") + .build(); + + // Should not throw + STSSecurityUtil.ensureResolvedStsFieldsInvariants(request); + } } From fd0ae5c7f7ec626c326a20be1b972a5e8e784771 Mon Sep 17 00:00:00 2001 From: fmorg-git Date: Fri, 12 Jun 2026 01:10:31 -0700 Subject: [PATCH 38/54] HDDS-15137. [STS] Ensure each S3 API has an associated S3 Action (#10197) --- .../hadoop/ozone/om/protocol/S3Auth.java | 10 ++ ...ManagerProtocolClientSideTranslatorPB.java | 8 +- .../src/main/proto/OmClientProtocol.proto | 3 + .../hadoop/ozone/om/OmMetadataReader.java | 59 ++++--- .../apache/hadoop/ozone/om/OzoneManager.java | 7 +- .../om/ratis/OzoneManagerStateMachine.java | 8 + .../ozone/om/request/OMClientRequest.java | 2 +- .../hadoop/ozone/om/TestOMMetadataReader.java | 140 +++++++++++------ .../ozone/s3/endpoint/BucketEndpoint.java | 17 +-- .../ozone/s3/endpoint/EndpointBase.java | 36 +++++ .../ozone/s3/endpoint/ObjectEndpoint.java | 144 ++++++++++-------- .../ozone/s3/endpoint/RootEndpoint.java | 8 +- .../ozone/s3/endpoint/S3RequestContext.java | 2 + .../ozone/s3/util/S3GActionIamMapper.java | 92 +++++++++++ .../s3/endpoint/TestCopyActionsAudit.java | 133 ++++++++++++++++ .../ozone/s3/util/TestS3GActionIamMapper.java | 68 +++++++++ .../hadoop/ozone/s3/util/package-info.java | 21 +++ 17 files changed, 593 insertions(+), 165 deletions(-) create mode 100644 hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/util/S3GActionIamMapper.java create mode 100644 hadoop-ozone/s3gateway/src/test/java/org/apache/hadoop/ozone/s3/endpoint/TestCopyActionsAudit.java create mode 100644 hadoop-ozone/s3gateway/src/test/java/org/apache/hadoop/ozone/s3/util/TestS3GActionIamMapper.java create mode 100644 hadoop-ozone/s3gateway/src/test/java/org/apache/hadoop/ozone/s3/util/package-info.java diff --git a/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/om/protocol/S3Auth.java b/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/om/protocol/S3Auth.java index fa023dfc8119..577339c96ac3 100644 --- a/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/om/protocol/S3Auth.java +++ b/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/om/protocol/S3Auth.java @@ -29,6 +29,8 @@ public class S3Auth { private String userPrincipal; // Optional STS session token when using temporary credentials private String sessionToken; + // S3 action without s3: prefix (e.g. PutObject), set by S3 Gateway for use in finer-grained STS permissions. + private String s3Action; public S3Auth(final String stringToSign, final String signature, @@ -67,4 +69,12 @@ public String getSessionToken() { public void setSessionToken(String sessionToken) { this.sessionToken = sessionToken; } + + public String getS3Action() { + return s3Action; + } + + public void setS3Action(String s3Action) { + this.s3Action = s3Action; + } } diff --git a/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/om/protocolPB/OzoneManagerProtocolClientSideTranslatorPB.java b/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/om/protocolPB/OzoneManagerProtocolClientSideTranslatorPB.java index 3030052d75d3..8fdf9712c08a 100644 --- a/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/om/protocolPB/OzoneManagerProtocolClientSideTranslatorPB.java +++ b/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/om/protocolPB/OzoneManagerProtocolClientSideTranslatorPB.java @@ -332,6 +332,9 @@ private OMResponse submitRequest(OMRequest omRequest) if (threadLocalS3Auth.get().getSessionToken() != null) { s3AuthBuilder.setSessionToken(threadLocalS3Auth.get().getSessionToken()); } + if (threadLocalS3Auth.get().getS3Action() != null) { + s3AuthBuilder.setS3Action(threadLocalS3Auth.get().getS3Action()); + } builder.setS3Authentication(s3AuthBuilder.build()); } @@ -1788,10 +1791,7 @@ public OmMultipartCommitUploadPartInfo commitMultipartUploadPart( handleError(submitRequest(omRequest)) .getCommitMultiPartUploadResponse(); - OmMultipartCommitUploadPartInfo info = new - OmMultipartCommitUploadPartInfo(response.getPartName(), - response.getETag()); - return info; + return new OmMultipartCommitUploadPartInfo(response.getPartName(), response.getETag()); } @Override diff --git a/hadoop-ozone/interface-client/src/main/proto/OmClientProtocol.proto b/hadoop-ozone/interface-client/src/main/proto/OmClientProtocol.proto index 5cf503275a2a..7674d85ca925 100644 --- a/hadoop-ozone/interface-client/src/main/proto/OmClientProtocol.proto +++ b/hadoop-ozone/interface-client/src/main/proto/OmClientProtocol.proto @@ -2346,6 +2346,9 @@ message S3Authentication { optional string resolvedStsOriginalAccessKeyId = 7; optional string resolvedStsTempAccessKeyId = 8; optional string resolvedStsSecretKeyId = 9; + // S3 action without the s3: prefix for this request (e.g. GetObject), set by S3 Gateway for use + // in finer-grained STS permissions. + optional string s3Action = 10; } message RecoverLeaseRequest { diff --git a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/OmMetadataReader.java b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/OmMetadataReader.java index 46573917c883..40caed205544 100644 --- a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/OmMetadataReader.java +++ b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/OmMetadataReader.java @@ -55,6 +55,7 @@ import org.apache.hadoop.ozone.om.helpers.OzoneFileStatusLight; import org.apache.hadoop.ozone.om.helpers.S3VolumeContext; import org.apache.hadoop.ozone.om.protocolPB.grpc.GrpcClientConstants; +import org.apache.hadoop.ozone.protocol.proto.OzoneManagerProtocolProtos.S3Authentication; import org.apache.hadoop.ozone.security.STSTokenIdentifier; import org.apache.hadoop.ozone.security.acl.IAccessAuthorizer; import org.apache.hadoop.ozone.security.acl.IAccessAuthorizer.ACLIdentityType; @@ -236,9 +237,7 @@ public List listStatus(OmKeyArgs args, boolean recursive, try { if (isAclEnabled) { if (isStsS3Request()) { - // We need to be able to tell the difference between being able to download a file and merely seeing the file - // name in a list. Use READ for download ability and LIST (here) for listing. - // When listPrefix is set (original S3 ListObjects prefix), authorize LIST on that prefix for the whole + // When listPrefix is set (original S3 ListObjects prefix), authorize READ on that prefix for the whole // listing, including FSO traversal where keyName is an internal directory (e.g. userA) under prefix user. final String listPrefix = args.getListPrefix(); final String keyName = args.getKeyName(); @@ -258,7 +257,7 @@ public List listStatus(OmKeyArgs args, boolean recursive, } else { aclKey = "*"; } - checkAcls(ResourceType.KEY, StoreType.OZONE, ACLType.LIST, bucket.realVolume(), bucket.realBucket(), aclKey); + checkAcls(ResourceType.KEY, StoreType.OZONE, ACLType.READ, bucket.realVolume(), bucket.realBucket(), aclKey); } else { checkAcls(getResourceType(args), StoreType.OZONE, ACLType.READ, bucket, args.getKeyName()); @@ -304,12 +303,7 @@ public OzoneFileStatus getFileStatus(OmKeyArgs args) throws IOException { try { if (isAclEnabled) { - if (isStsS3Request()) { - checkAcls(getResourceType(args), StoreType.OZONE, ACLType.LIST, bucket, args.getKeyName()); - } else { - checkAcls(getResourceType(args), StoreType.OZONE, ACLType.READ, - bucket, args.getKeyName()); - } + checkAcls(getResourceType(args), StoreType.OZONE, ACLType.READ, bucket, args.getKeyName()); } metrics.incNumGetFileStatus(); return keyManager.getFileStatus(args, getClientAddress()); @@ -384,7 +378,7 @@ public ListKeysResult listKeys(String volumeName, String bucketName, final String aclKey = (keyPrefix == null || keyPrefix.isEmpty()) ? "*" : keyPrefix; captureLatencyNs( perfMetrics.getListKeysAclCheckLatencyNs(), () -> checkAcls( - ResourceType.KEY, StoreType.OZONE, ACLType.LIST, bucket.realVolume(), bucket.realBucket(), aclKey)); + ResourceType.KEY, StoreType.OZONE, ACLType.READ, bucket.realVolume(), bucket.realBucket(), aclKey)); } else { captureLatencyNs(perfMetrics.getListKeysAclCheckLatencyNs(), () -> checkAcls(ResourceType.BUCKET, StoreType.OZONE, ACLType.LIST, @@ -612,16 +606,15 @@ public boolean checkAcls(ResourceType resType, StoreType storeType, .setVolumeName(vol) .setBucketName(bucket) .setKeyName(key).build(); - RequestContext context = RequestContext.newBuilder() + RequestContext.Builder contextBuilder = RequestContext.newBuilder() .setClientUgi(ugi) .setIp(remoteAddress) .setHost(hostName) .setAclType(ACLIdentityType.USER) .setAclRights(aclType) - .setOwnerName(owner) - .build(); + .setOwnerName(owner); - return checkAcls(obj, context, throwIfPermissionDenied); + return checkAcls(obj, contextBuilder, throwIfPermissionDenied); } /** @@ -631,13 +624,14 @@ public boolean checkAcls(ResourceType resType, StoreType storeType, * @throws OMException ResultCodes.PERMISSION_DENIED if permission denied * and throwOnPermissionDenied set to true. */ - public boolean checkAcls(OzoneObj obj, RequestContext context, + public boolean checkAcls(OzoneObj obj, RequestContext.Builder contextBuilder, boolean throwIfPermissionDenied) throws OMException { - final RequestContext normalizedRequestContext = maybeAttachSessionPolicyFromThreadLocal(context); + maybeAddToContextFromThreadLocal(contextBuilder); + final RequestContext context = contextBuilder.build(); if (!captureLatencyNs(perfMetrics::setCheckAccessLatencyNs, - () -> accessAuthorizer.checkAccess(obj, normalizedRequestContext))) { + () -> accessAuthorizer.checkAccess(obj, context))) { if (throwIfPermissionDenied) { String volumeName = obj.getVolumeName() != null ? "Volume:" + obj.getVolumeName() + " " : ""; @@ -647,7 +641,7 @@ public boolean checkAcls(OzoneObj obj, RequestContext context, "Key:" + obj.getKeyName() : ""; // For STS tokens, make clear that the user is using an assumed role, otherwise the access denied // message could be confusing - String user = normalizedRequestContext.getClientUgi().getShortUserName(); + String user = context.getClientUgi().getShortUserName(); final STSTokenIdentifier stsTokenIdentifier = OzoneManager.getStsTokenIdentifier(); if (stsTokenIdentifier != null) { final StringBuilder builder = new StringBuilder(user) @@ -660,11 +654,11 @@ public boolean checkAcls(OzoneObj obj, RequestContext context, } log.warn("User {} doesn't have {} permission to access {} {}{}{}", user, - normalizedRequestContext.getAclRights(), + context.getAclRights(), obj.getResourceType(), volumeName, bucketName, keyName); throw new OMException( "User " + user + - " doesn't have " + normalizedRequestContext.getAclRights() + + " doesn't have " + context.getAclRights() + " permission to access " + obj.getResourceType() + " " + volumeName + bucketName + keyName, ResultCodes.PERMISSION_DENIED); } @@ -675,21 +669,22 @@ public boolean checkAcls(OzoneObj obj, RequestContext context, } /** - * Attaches session policy to RequestContext if an STSTokenIdentifier is found in the Ozone Manager thread local - * (meaning this is an STS request), and the STSTokenIdentifier has a session policy. Otherwise, returns the - * RequestContext as it was before. - * @param context the original RequestContext - * @return RequestContext as before or with sessionPolicy embedded + * Enriches the given {@link RequestContext.Builder} with per-request fields from the Ozone Manager + * thread locals: the session policy from {@link STSTokenIdentifier} (set on STS requests) and the + * S3 action from {@link S3Authentication} (set on S3 requests). Either or both may be absent, in + * which case the corresponding field is left untouched on the builder. + * @param contextBuilder the builder to enrich in-place */ - private RequestContext maybeAttachSessionPolicyFromThreadLocal(RequestContext context) { + public static void maybeAddToContextFromThreadLocal(RequestContext.Builder contextBuilder) { final STSTokenIdentifier stsTokenIdentifier = OzoneManager.getStsTokenIdentifier(); - if (stsTokenIdentifier == null) { - return context; + if (stsTokenIdentifier != null) { + contextBuilder.setSessionPolicy(stsTokenIdentifier.getSessionPolicy()); } - return context.toBuilder() - .setSessionPolicy(stsTokenIdentifier.getSessionPolicy()) - .build(); + final S3Authentication s3Authentication = OzoneManager.getS3Auth(); + if (s3Authentication != null && s3Authentication.hasS3Action() && !s3Authentication.getS3Action().isEmpty()) { + contextBuilder.setS3Action(s3Authentication.getS3Action()); + } } static String getClientAddress() { 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 1986b34acb3b..012f7c25dda9 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 @@ -2891,16 +2891,15 @@ public boolean checkAcls(ResourceType resType, StoreType storeType, .setVolumeName(vol) .setBucketName(bucket) .setKeyName(key).build(); - RequestContext context = RequestContext.newBuilder() + RequestContext.Builder contextBuilder = RequestContext.newBuilder() .setClientUgi(ugi) .setIp(remoteAddress) .setHost(hostName) .setAclType(ACLIdentityType.USER) .setAclRights(aclType) - .setOwnerName(owner) - .build(); + .setOwnerName(owner); - return omMetadataReader.checkAcls(obj, context, throwIfPermissionDenied); + return omMetadataReader.checkAcls(obj, contextBuilder, throwIfPermissionDenied); } /** diff --git a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/ratis/OzoneManagerStateMachine.java b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/ratis/OzoneManagerStateMachine.java index 34c5a485d61c..2603cd789492 100644 --- a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/ratis/OzoneManagerStateMachine.java +++ b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/ratis/OzoneManagerStateMachine.java @@ -664,6 +664,7 @@ public void close() { */ @VisibleForTesting OMResponse runCommand(OMRequest request, TermIndex termIndex) { + boolean isS3AuthThreadLocalSet = false; boolean isStsThreadLocalSet = false; try { if (ozoneManager.isSecurityEnabled() && request.hasS3Authentication()) { @@ -672,6 +673,10 @@ OMResponse runCommand(OMRequest request, TermIndex termIndex) { STSSecurityUtil.ensureResolvedStsFieldsInvariants(request); final OzoneManagerProtocolProtos.S3Authentication s3Auth = request.getS3Authentication(); + // ThreadLocal carries S3 action for OmMetadataReader. + OzoneManager.setS3Auth(s3Auth); + isS3AuthThreadLocalSet = true; + if (s3Auth.hasSessionToken() && !s3Auth.getSessionToken().isEmpty()) { // ThreadLocal carries session policy for OmMetadataReader final STSTokenIdentifier rehydratedTokenIdentifier = new STSTokenIdentifier( @@ -706,6 +711,9 @@ OMResponse runCommand(OMRequest request, TermIndex termIndex) { String errorMessage = "Request " + request + " failed with exception"; ExitUtils.terminate(1, errorMessage, e, LOG); } finally { + if (isS3AuthThreadLocalSet) { + OzoneManager.setS3Auth(null); + } if (isStsThreadLocalSet) { OzoneManager.setStsTokenIdentifier(null); } diff --git a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/OMClientRequest.java b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/OMClientRequest.java index 81d6925442b5..6b9c6698cf9f 100644 --- a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/OMClientRequest.java +++ b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/OMClientRequest.java @@ -385,7 +385,7 @@ protected void checkACLsWithFSO(OzoneManager ozoneManager, String volumeName, OmMetadataReader omMetadataReader = (OmMetadataReader) rcMetadataReader.get(); - omMetadataReader.checkAcls(obj, contextBuilder.build(), true); + omMetadataReader.checkAcls(obj, contextBuilder, true); } } } diff --git a/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/om/TestOMMetadataReader.java b/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/om/TestOMMetadataReader.java index 8403d2203e01..e35a39521930 100644 --- a/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/om/TestOMMetadataReader.java +++ b/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/om/TestOMMetadataReader.java @@ -23,7 +23,6 @@ import static org.apache.hadoop.ozone.security.acl.OzoneObj.ResourceType.KEY; import static org.apache.hadoop.ozone.security.acl.OzoneObj.ResourceType.VOLUME; import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.any; @@ -78,8 +77,9 @@ public class TestOMMetadataReader { private static final long MAX_KEYS = 100L; @AfterEach - public void clearStsThreadLocal() { + public void clearOmThreadLocals() { OzoneManager.setStsTokenIdentifier(null); + OzoneManager.setS3Auth(null); } @Test @@ -120,10 +120,10 @@ public void testCheckAclsAttachesSessionPolicyFromThreadLocal() throws Exception final IAccessAuthorizer accessAuthorizer = createMockIAccessAuthorizerReturningTrue(); final OmMetadataReader omMetadataReader = createMetadataReader(accessAuthorizer); - final RequestContext contextWithoutSessionPolicy = createTestRequestContext(); + final RequestContext.Builder contextWithoutSessionPolicyBuilder = createTestRequestContextBuilder(); final OzoneObj obj = createTestOzoneObj(); - assertTrue(omMetadataReader.checkAcls(obj, contextWithoutSessionPolicy, true)); + assertTrue(omMetadataReader.checkAcls(obj, contextWithoutSessionPolicyBuilder, true)); verifySessionPolicyPassedToAuthorizer(accessAuthorizer, obj, sessionPolicy); } @@ -136,16 +136,67 @@ public void testNoSessionPolicyWhenThreadLocalIsNull() throws Exception { final IAccessAuthorizer accessAuthorizer = createMockIAccessAuthorizerReturningTrue(); final OmMetadataReader omMetadataReader = createMetadataReader(accessAuthorizer); - final RequestContext contextWithoutSessionPolicy = createTestRequestContext(); + final RequestContext.Builder contextWithoutSessionPolicyBuilder = createTestRequestContextBuilder(); final OzoneObj obj = createTestOzoneObj(); - assertTrue(omMetadataReader.checkAcls(obj, contextWithoutSessionPolicy, true)); + assertTrue(omMetadataReader.checkAcls(obj, contextWithoutSessionPolicyBuilder, true)); verifySessionPolicyPassedToAuthorizer(accessAuthorizer, obj, null); } @Test - public void testListStatusUsesListAclForStsS3Request() throws Exception { + public void testCheckAclsAttachesS3ActionFromThreadLocal() throws Exception { + OzoneManager.setS3Auth(S3Authentication.newBuilder() + .setAccessId(ACCESS_KEY_ID) + .setS3Action("GetObject") + .build()); + + final IAccessAuthorizer accessAuthorizer = createMockIAccessAuthorizerReturningTrue(); + final OmMetadataReader omMetadataReader = createMetadataReader(accessAuthorizer); + + final RequestContext.Builder contextWithoutS3ActionBuilder = createTestRequestContextBuilder(); + final OzoneObj obj = createTestOzoneObj(); + + assertTrue(omMetadataReader.checkAcls(obj, contextWithoutS3ActionBuilder, true)); + + verifyS3ActionPassedToAuthorizer(accessAuthorizer, obj, "GetObject"); + } + + @Test + public void testCheckAclsLeavesS3ActionUnsetWhenS3AuthThreadLocalNull() throws Exception { + final IAccessAuthorizer accessAuthorizer = createMockIAccessAuthorizerReturningTrue(); + final OmMetadataReader omMetadataReader = createMetadataReader(accessAuthorizer); + + final RequestContext.Builder contextWithoutS3ActionBuilder = createTestRequestContextBuilder(); + final OzoneObj obj = createTestOzoneObj(); + + assertTrue(omMetadataReader.checkAcls(obj, contextWithoutS3ActionBuilder, true)); + + verifyS3ActionPassedToAuthorizer(accessAuthorizer, obj, null); + } + + @Test + public void testCheckAclsAttachesSessionPolicyAndS3ActionFromThreadLocals() throws Exception { + setupStsTokenIdentifier(); + + OzoneManager.setS3Auth(S3Authentication.newBuilder() + .setAccessId(ACCESS_KEY_ID) + .setS3Action("PutObject") + .build()); + + final IAccessAuthorizer accessAuthorizer = createMockIAccessAuthorizerReturningTrue(); + final OmMetadataReader omMetadataReader = createMetadataReader(accessAuthorizer); + + final RequestContext.Builder baseContextBuilder = createTestRequestContextBuilder(); + final OzoneObj obj = createTestOzoneObj(); + + assertTrue(omMetadataReader.checkAcls(obj, baseContextBuilder, true)); + + verifySessionPolicyAndS3ActionPassedToAuthorizer(accessAuthorizer, obj); + } + + @Test + public void testListStatusUsesReadAclForStsS3Request() throws Exception { setupStsS3Request(); final IAccessAuthorizer accessAuthorizer = createMockIAccessAuthorizerReturningTrue(); @@ -161,10 +212,9 @@ public void testListStatusUsesListAclForStsS3Request() throws Exception { // For STS S3 requests, listStatus() performs these checks: // 1. Volume READ (for volume access) - // 2) Key LIST (for the specific prefix being listed) - we need LIST permission for STS in order to tell whether the - // file should be listed only or downloadable (downloadable would be READ) + // 2) Key READ (for the specific prefix being listed) assertContainsVolumeReadCheck(checks); - assertContainsKeyListCheckWithName(checks, KEY_PREFIX); + assertContainsKeyReadCheckWithName(checks, KEY_PREFIX); } @Test @@ -186,7 +236,6 @@ public void testListStatusUsesReadAclForNonStsRequest() throws Exception { assertContainsVolumeReadCheck(checks); // We want to ensure the current behavior for non-STS requests remains the same assertContainsKeyReadCheckWithName(checks); - assertDoesNotContainKeyListCheck(checks); } @Test @@ -209,7 +258,7 @@ public void testListStatusUsesListPrefixForAclWhenKeyNameEmptyAndListPrefixSet() final List checks = captureAclChecks(accessAuthorizer, 2); assertContainsVolumeReadCheck(checks); - assertContainsKeyListCheckWithName(checks, "userA/"); + assertContainsKeyReadCheckWithName(checks, "userA/"); } @Test @@ -231,7 +280,7 @@ public void testListStatusUsesWildcardForAclWhenKeyNameAndListPrefixEmpty() thro final List checks = captureAclChecks(accessAuthorizer, 2); assertContainsVolumeReadCheck(checks); - assertContainsKeyListCheckWithName(checks, "*"); + assertContainsKeyReadCheckWithName(checks, "*"); } @Test @@ -254,7 +303,7 @@ public void testListStatusUsesListPrefixForAclWhenKeyNameIsDescendantOfListPrefi final List checks = captureAclChecks(accessAuthorizer, 2); assertContainsVolumeReadCheck(checks); - assertContainsKeyListCheckWithName(checks, "user"); + assertContainsKeyReadCheckWithName(checks, "user"); } @Test @@ -277,7 +326,7 @@ public void testListStatusUsesListPrefixForAclWhenKeyNameIsAncestorOfListPrefix( final List checks = captureAclChecks(accessAuthorizer, 2); assertContainsVolumeReadCheck(checks); - assertContainsKeyListCheckWithName(checks, "user/foo"); + assertContainsKeyReadCheckWithName(checks, "user/foo"); } @Test @@ -301,7 +350,7 @@ public void testListStatusThrowsWhenStsKeyNameNotUnderListPrefix() throws Except } @Test - public void testGetFileStatusUsesListAclForStsS3Request() throws Exception { + public void testGetFileStatusUsesReadAclForStsS3Request() throws Exception { setupStsS3Request(); final IAccessAuthorizer accessAuthorizer = createMockIAccessAuthorizerReturningTrue(); @@ -314,8 +363,7 @@ public void testGetFileStatusUsesListAclForStsS3Request() throws Exception { final List checks = captureAclChecks(accessAuthorizer, 2); assertContainsVolumeReadCheck(checks); - assertContainsKeyListCheckWithName(checks, KEY_PREFIX); - assertDoesNotContainKeyReadCheck(checks); + assertContainsKeyReadCheckWithName(checks, KEY_PREFIX); } @Test @@ -333,7 +381,6 @@ public void testGetFileStatusUsesReadAclForNonStsS3Request() throws Exception { final List checks = captureAclChecks(accessAuthorizer, 2); assertContainsVolumeReadCheck(checks); assertContainsKeyReadCheckWithName(checks); - assertDoesNotContainKeyListCheck(checks); } @Test @@ -350,7 +397,7 @@ public void testListKeysUsesPrefixCheckForStsS3Request() throws Exception { List checks = captureAclChecks(accessAuthorizer, 4); assertContainsBucketListCheck(checks); - assertContainsKeyListCheckWithName(checks, "userA/"); + assertContainsKeyReadCheckWithName(checks, "userA/"); // Reset to make case 2 assertions independent of case 1 captures. reset(accessAuthorizer); @@ -361,7 +408,7 @@ public void testListKeysUsesPrefixCheckForStsS3Request() throws Exception { checks = captureAclChecks(accessAuthorizer, 4); assertContainsBucketListCheck(checks); - assertContainsKeyListCheckWithName(checks, "*"); + assertContainsKeyReadCheckWithName(checks, "*"); } private OmMetadataReader createMetadataReader(IAccessAuthorizer accessAuthorizer) throws IOException { @@ -418,20 +465,18 @@ private IAccessAuthorizer createMockIAccessAuthorizerReturningTrue() throws OMEx } /** - * Creates a test RequestContext. + * Creates a test RequestContext.Builder. * - * @return the constructed RequestContext + * @return the constructed RequestContext.Builder */ - private RequestContext createTestRequestContext() { - RequestContext.Builder builder = RequestContext.newBuilder() + private RequestContext.Builder createTestRequestContextBuilder() { + return RequestContext.newBuilder() .setClientUgi(UserGroupInformation.createRemoteUser("testUser")) .setIp(InetAddress.getLoopbackAddress()) .setHost("localhost") .setAclType(IAccessAuthorizer.ACLIdentityType.USER) .setAclRights(READ) .setOwnerName("owner"); - - return builder.build(); } /** @@ -505,6 +550,27 @@ private void verifySessionPolicyPassedToAuthorizer(IAccessAuthorizer accessAutho assertEquals(expectedSessionPolicy, captor.getValue().getSessionPolicy()); } + /** + * Verifies that the accessAuthorizer received a call to checkAccess with the expected s3 action. + * @param accessAuthorizer the mock authorizer to verify + * @param expectedObj the expected OzoneObj + * @param expectedS3Action the expected s3 action (could be null) + */ + private void verifyS3ActionPassedToAuthorizer(IAccessAuthorizer accessAuthorizer, OzoneObj expectedObj, + String expectedS3Action) throws OMException { + final ArgumentCaptor captor = ArgumentCaptor.forClass(RequestContext.class); + verify(accessAuthorizer).checkAccess(eq(expectedObj), captor.capture()); + assertEquals(expectedS3Action, captor.getValue().getS3Action()); + } + + private void verifySessionPolicyAndS3ActionPassedToAuthorizer(IAccessAuthorizer accessAuthorizer, + OzoneObj expectedObj) throws OMException { + final ArgumentCaptor captor = ArgumentCaptor.forClass(RequestContext.class); + verify(accessAuthorizer).checkAccess(eq(expectedObj), captor.capture()); + assertEquals("session-policy-from-thread-local", captor.getValue().getSessionPolicy()); + assertEquals("PutObject", captor.getValue().getS3Action()); + } + private List captureAclChecks(IAccessAuthorizer accessAuthorizer, int expectedCheckCount) throws OMException { final ArgumentCaptor objCaptor = ArgumentCaptor.forClass(OzoneObj.class); @@ -538,12 +604,12 @@ private void assertContainsBucketListCheck(List checks) { "Expected a BUCKET LIST ACL check"); } - private void assertContainsKeyListCheckWithName(List checks, String keyName) { + private void assertContainsKeyReadCheckWithName(List checks, String keyName) { assertTrue( checks.stream().anyMatch( - check -> check.getObj().getResourceType() == KEY && check.getContext().getAclRights() == LIST && + check -> check.getObj().getResourceType() == KEY && check.getContext().getAclRights() == READ && keyName.equals(check.getObj().getKeyName())), - "Expected a KEY LIST ACL check for key '" + keyName + "'"); + "Expected a KEY READ ACL check for key '" + keyName + "'"); } private void assertContainsKeyReadCheckWithName(List checks) { @@ -554,20 +620,6 @@ private void assertContainsKeyReadCheckWithName(List checks) { "Expected a KEY READ ACL check for key '" + TestOMMetadataReader.KEY_PREFIX + "'"); } - private void assertDoesNotContainKeyReadCheck(List checks) { - assertFalse( - checks.stream().anyMatch( - check -> check.getObj().getResourceType() == KEY && check.getContext().getAclRights() == READ), - "Did not expect a KEY READ ACL check"); - } - - private void assertDoesNotContainKeyListCheck(List checks) { - assertFalse( - checks.stream().anyMatch( - check -> check.getObj().getResourceType() == KEY && check.getContext().getAclRights() == LIST), - "Did not expect a KEY LIST ACL check"); - } - private static final class AclCheck { private final OzoneObj obj; private final RequestContext context; diff --git a/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/endpoint/BucketEndpoint.java b/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/endpoint/BucketEndpoint.java index 1e78813e9714..66806be8e384 100644 --- a/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/endpoint/BucketEndpoint.java +++ b/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/endpoint/BucketEndpoint.java @@ -64,7 +64,6 @@ import org.apache.hadoop.ozone.s3.util.ContinueToken; import org.apache.hadoop.ozone.s3.util.S3Consts.QueryParams; import org.apache.hadoop.ozone.s3.util.S3StorageType; -import org.apache.hadoop.util.Time; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -286,19 +285,19 @@ Response handlePutRequest(S3RequestContext context, String bucketName, InputStre @HEAD public Response head(@PathParam(BUCKET) String bucketName) throws OS3Exception, IOException { - long startNanos = Time.monotonicNowNanos(); - S3GAction s3GAction = S3GAction.HEAD_BUCKET; + S3RequestContext context = new S3RequestContext(this, S3GAction.HEAD_BUCKET); + long startNanos = context.getStartNanos(); try { OzoneBucket bucket = getVolume().getBucket(bucketName); S3Owner.verifyBucketOwnerCondition(getHeaders(), bucketName, bucket.getOwner()); - auditReadSuccess(s3GAction); + auditReadSuccess(context.getAction()); getMetrics().updateHeadBucketSuccessStats(startNanos); return Response.ok().build(); } catch (OMException e) { - auditReadFailure(s3GAction, e); + auditReadFailure(context.getAction(), e); throw newError(bucketName, e); } catch (Exception e) { - auditReadFailure(s3GAction, e); + auditReadFailure(context.getAction(), e); throw e; } } @@ -338,7 +337,7 @@ public MultiDeleteResponse multiDelete( @QueryParam(QueryParams.DELETE) String delete, MultiDeleteRequest request ) throws OS3Exception, IOException { - S3GAction s3GAction = S3GAction.MULTI_DELETE; + S3RequestContext context = new S3RequestContext(this, S3GAction.MULTI_DELETE); OzoneBucket bucket = getVolume().getBucket(bucketName); MultiDeleteResponse result = new MultiDeleteResponse(); @@ -349,7 +348,7 @@ public MultiDeleteResponse multiDelete( for (DeleteObject keyToDelete : request.getObjects()) { deleteKeys.add(keyToDelete.getKey()); } - long startNanos = Time.monotonicNowNanos(); + long startNanos = context.getStartNanos(); try { S3Owner.verifyBucketOwnerCondition(getHeaders(), bucketName, bucket.getOwner()); undeletedKeyResultMap = bucket.deleteKeys(deleteKeys, true); @@ -377,7 +376,7 @@ public MultiDeleteResponse multiDelete( } } - AuditMessage.Builder message = auditMessageFor(s3GAction); + AuditMessage.Builder message = auditMessageFor(context.getAction()); message.getParams().put("failedDeletes", deleteKeys.toString()); if (!result.getErrors().isEmpty()) { 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 0d6fbe1657bd..fd6ef9c3c0b8 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 @@ -85,6 +85,7 @@ import org.apache.hadoop.ozone.audit.AuditLogger.PerformanceStringBuilder; import org.apache.hadoop.ozone.audit.AuditLoggerType; import org.apache.hadoop.ozone.audit.AuditMessage; +import org.apache.hadoop.ozone.audit.S3GAction; import org.apache.hadoop.ozone.client.OzoneBucket; import org.apache.hadoop.ozone.client.OzoneClient; import org.apache.hadoop.ozone.client.OzoneClientUtils; @@ -104,9 +105,11 @@ import org.apache.hadoop.ozone.s3.metrics.S3GatewayMetrics; import org.apache.hadoop.ozone.s3.signature.SignatureInfo; import org.apache.hadoop.ozone.s3.util.AuditUtils; +import org.apache.hadoop.ozone.s3.util.S3GActionIamMapper; import org.apache.hadoop.ozone.s3.util.S3Utils; import org.apache.http.NameValuePair; import org.apache.http.client.utils.URLEncodedUtils; +import org.apache.ratis.util.function.CheckedSupplier; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -225,6 +228,39 @@ protected void init() { // hook method } + /** + * Sets the IAM S3 action on thread-local {@link S3Auth} for fine-grained STS authorization. + * Called when the handler resolves the {@link S3GAction}. + */ + protected void applyS3Action(S3GAction action) { + if (s3Auth != null) { + s3Auth.setS3Action(S3GActionIamMapper.toS3ActionString(action)); + } + } + + /** + * Temporarily override the S3 action string set on {@link S3Auth} for authorization. + *

+ * This does not change S3G auditing (which is based on {@link S3GAction}). + * The action string is the IAM-style S3 action name without the {@code s3:} prefix (for example + * {@code GetObject}, {@code PutObject}, {@code GetObjectTagging}). + * This is used for special case APIs like CopyObject that don't have a 1-1 s3 action mapping, but + * requires GetObject on the source file and PutObject on the destination file. + */ + protected T runWithS3ActionString(String s3Action, CheckedSupplier checkedSupplier) + throws E { + if (s3Auth == null) { + return checkedSupplier.get(); + } + final String originalS3Action = s3Auth.getS3Action(); + s3Auth.setS3Action(s3Action); + try { + return checkedSupplier.get(); + } finally { + s3Auth.setS3Action(originalS3Action); + } + } + protected OzoneVolume getVolume() throws IOException { return client.getObjectStore().getS3Volume(); } diff --git a/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/endpoint/ObjectEndpoint.java b/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/endpoint/ObjectEndpoint.java index dd09bff5a416..bfa3c3d5c79a 100644 --- a/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/endpoint/ObjectEndpoint.java +++ b/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/endpoint/ObjectEndpoint.java @@ -535,8 +535,8 @@ static void addEntityTagHeader(ResponseBuilder responseBuilder, OzoneKey key) { public Response head( @PathParam(BUCKET) String bucketName, @PathParam(PATH) String keyPath) throws IOException, OS3Exception { - long startNanos = Time.monotonicNowNanos(); - S3GAction s3GAction = S3GAction.HEAD_KEY; + ObjectRequestContext context = new ObjectRequestContext(S3GAction.HEAD_KEY, bucketName); + long startNanos = context.getStartNanos(); OzoneKey key; try { @@ -551,12 +551,12 @@ public Response head( getHeaders(), keyPath, key, S3ConditionalRequest.PreconditionContext.READ); if (conditionalResponse != null) { getMetrics().updateHeadKeySuccessStats(startNanos); - auditReadSuccess(s3GAction); + auditReadSuccess(context.getAction()); return conditionalResponse; } // TODO: return the specified range bytes of this object. } catch (OMException ex) { - auditReadFailure(s3GAction, ex); + auditReadFailure(context.getAction(), ex); getMetrics().updateHeadKeyFailureStats(startNanos); if (ex.getResult() == ResultCodes.KEY_NOT_FOUND) { // Just return 404 with no content @@ -571,7 +571,7 @@ public Response head( throw newError(bucketName, keyPath, ex); } } catch (Exception ex) { - auditReadFailure(s3GAction, ex); + auditReadFailure(context.getAction(), ex); throw ex; } @@ -588,7 +588,7 @@ public Response head( addLastModifiedDate(response, key); addCustomMetadataHeaders(response, key); getMetrics().updateHeadKeySuccessStats(startNanos); - auditReadSuccess(s3GAction); + auditReadSuccess(context.getAction()); return response.build(); } @@ -674,8 +674,8 @@ public Response initializeMultipartUpload( @PathParam(BUCKET) String bucket, @PathParam(PATH) String key ) throws IOException, OS3Exception { - long startNanos = Time.monotonicNowNanos(); - S3GAction s3GAction = S3GAction.INIT_MULTIPART_UPLOAD; + ObjectRequestContext context = new ObjectRequestContext(S3GAction.INIT_MULTIPART_UPLOAD, bucket); + long startNanos = context.getStartNanos(); try { OzoneBucket ozoneBucket = getVolume().getBucket(bucket); @@ -698,16 +698,16 @@ public Response initializeMultipartUpload( multipartUploadInitiateResponse.setKey(key); multipartUploadInitiateResponse.setUploadID(multipartInfo.getUploadID()); - auditWriteSuccess(s3GAction); + auditWriteSuccess(context.getAction()); getMetrics().updateInitMultipartUploadSuccessStats(startNanos); return Response.status(Status.OK).entity( multipartUploadInitiateResponse).build(); } catch (OMException ex) { - auditWriteFailure(s3GAction, ex); + auditWriteFailure(context.getAction(), ex); getMetrics().updateInitMultipartUploadFailureStats(startNanos); throw newError(bucket, key, ex); } catch (Exception ex) { - auditWriteFailure(s3GAction, ex); + auditWriteFailure(context.getAction(), ex); getMetrics().updateInitMultipartUploadFailureStats(startNanos); throw ex; } @@ -723,9 +723,9 @@ public Response completeMultipartUpload( @PathParam(PATH) String key, CompleteMultipartUploadRequest multipartUploadRequest ) throws IOException, OS3Exception { + ObjectRequestContext context = new ObjectRequestContext(S3GAction.COMPLETE_MULTIPART_UPLOAD, bucket); final String uploadID = queryParams().get(QueryParams.UPLOAD_ID, ""); - long startNanos = Time.monotonicNowNanos(); - S3GAction s3GAction = S3GAction.COMPLETE_MULTIPART_UPLOAD; + long startNanos = context.getStartNanos(); // Using LinkedHashMap to preserve ordering of parts list. Map partsMap = new LinkedHashMap<>(); List partList = @@ -765,12 +765,12 @@ public Response completeMultipartUpload( wrapInQuotes(omMultipartUploadCompleteInfo.getHash())); // Location also setting as bucket name. completeMultipartUploadResponse.setLocation(bucket); - auditWriteSuccess(s3GAction); + auditWriteSuccess(context.getAction()); getMetrics().updateCompleteMultipartUploadSuccessStats(startNanos); return Response.status(Status.OK).entity(completeMultipartUploadResponse) .build(); } catch (OMException ex) { - auditWriteFailure(s3GAction, ex); + auditWriteFailure(context.getAction(), ex); getMetrics().updateCompleteMultipartUploadFailureStats(startNanos); if (ex.getResult() == ResultCodes.NO_SUCH_MULTIPART_UPLOAD_ERROR) { throw newError(NO_SUCH_UPLOAD, uploadID, ex); @@ -798,7 +798,7 @@ public Response completeMultipartUpload( } throw newError(bucket, key, ex); } catch (Exception ex) { - auditWriteFailure(s3GAction, ex); + auditWriteFailure(context.getAction(), ex); getMetrics().updateCompleteMultipartUploadFailureStats(startNanos); throw ex; } @@ -839,8 +839,8 @@ private Response createMultipartKey(OzoneVolume volume, OzoneBucket ozoneBucket, uploadID, getChunkSize(), multiDigestInputStream, perf, getHeaders()); } // OmMultipartCommitUploadPartInfo can only be gotten after the - // OzoneOutputStream is closed, so we need to save the OzoneOutputStream - final OzoneOutputStream outputStream; + // OzoneOutputStream is closed, so we need to get and save the commit info. + final OmMultipartCommitUploadPartInfo omMultipartCommitUploadPartInfo; long metadataLatencyNs; if (copyHeader != null) { Pair result = parseSourceHeader(copyHeader); @@ -852,8 +852,8 @@ private Response createMultipartKey(OzoneVolume volume, OzoneBucket ozoneBucket, ozoneBucket.getOwner()); } - OzoneKeyDetails sourceKeyDetails = getClientProtocol().getKeyDetails( - volume.getName(), sourceBucket, sourceKey); + final OzoneKeyDetails sourceKeyDetails = runWithS3ActionString( + "GetObject", () -> getClientProtocol().getKeyDetails(volume.getName(), sourceBucket, sourceKey)); String range = getHeaders().getHeaderString(COPY_SOURCE_HEADER_RANGE); RangeHeader rangeHeader = null; @@ -879,7 +879,8 @@ private Response createMultipartKey(OzoneVolume volume, OzoneBucket ozoneBucket, } try (OzoneInputStream sourceObject = sourceKeyDetails.getContent()) { - long copyLength; + final long[] copyLengthHolder = new long[1]; + final long[] metadataLatencyHolder = new long[1]; if (range != null) { final long skipped = sourceObject.skip(rangeHeader.getStartOffset()); @@ -889,52 +890,60 @@ private Response createMultipartKey(OzoneVolume volume, OzoneBucket ozoneBucket, + rangeHeader.getStartOffset() + " actual: " + skipped); } } - try (OzoneOutputStream ozoneOutputStream = getClientProtocol() - .createMultipartKey(volume.getName(), bucketName, key, length, - partNumber, uploadID)) { - metadataLatencyNs = - getMetrics().updateCopyKeyMetadataStats(startNanos); - copyLength = IOUtils.copyLarge(sourceObject, ozoneOutputStream, 0, length, - new byte[getIOBufferSize(length)]); - ozoneOutputStream.getMetadata() - .putAll(sourceKeyDetails.getMetadata()); - String raw = ozoneOutputStream.getMetadata().get(OzoneConsts.ETAG); - if (raw != null) { - ozoneOutputStream.getMetadata().put(OzoneConsts.ETAG, stripQuotes(raw)); + final long finalLength = length; + final long bytesToCopy = length; + omMultipartCommitUploadPartInfo = runWithS3ActionString("PutObject", () -> { + final OzoneOutputStream ozoneOutputStream = getClientProtocol().createMultipartKey( + volume.getName(), bucketName, key, finalLength, partNumber, uploadID); + try (OzoneOutputStream ignored = ozoneOutputStream) { + metadataLatencyHolder[0] = getMetrics().updateCopyKeyMetadataStats(startNanos); + copyLengthHolder[0] = IOUtils.copyLarge( + sourceObject, ozoneOutputStream, 0, bytesToCopy, new byte[getIOBufferSize(bytesToCopy)]); + ozoneOutputStream.getMetadata() + .putAll(sourceKeyDetails.getMetadata()); + final String raw = ozoneOutputStream.getMetadata().get(OzoneConsts.ETAG); + if (raw != null) { + ozoneOutputStream.getMetadata().put(OzoneConsts.ETAG, stripQuotes(raw)); + } } - outputStream = ozoneOutputStream; - } - getMetrics().incCopyObjectSuccessLength(copyLength); - perf.appendSizeBytes(copyLength); + return ozoneOutputStream.getCommitUploadPartInfo(); + }); + metadataLatencyNs = metadataLatencyHolder[0]; + getMetrics().incCopyObjectSuccessLength(copyLengthHolder[0]); + perf.appendSizeBytes(copyLengthHolder[0]); } } else { - long putLength; - try (OzoneOutputStream ozoneOutputStream = getClientProtocol() + final long putLength; + // We don't need runWithS3ActionString("PutObject"...) here because the action in this else branch is + // S3GAction.CREATE_MULTIPART_KEY and this has a mapping in S3GActionIamMapper to "PutObject", so it's covered. + // In the if branch of the code, the request action is set to S3GAction.CREATE_MULTIPART_KEY_BY_COPY which is + // mapped to null in S3GActionIamMapper (by design, since it needs "GetObject" on the source and "PutObject" + // on the destination). + final OzoneOutputStream ozoneOutputStream = getClientProtocol() .createMultipartKey(volume.getName(), bucketName, key, length, - partNumber, uploadID)) { + partNumber, uploadID); + try (OzoneOutputStream ignored = ozoneOutputStream) { metadataLatencyNs = getMetrics().updatePutKeyMetadataStats(startNanos); putLength = IOUtils.copyLarge(multiDigestInputStream, ozoneOutputStream, 0, length, new byte[getIOBufferSize(length)]); - byte[] digest = multiDigestInputStream.getMessageDigest(OzoneConsts.MD5_HASH).digest(); - String md5Hash = DatatypeConverter.printHexBinary(digest).toLowerCase(); - String clientContentMD5 = getHeaders().getHeaderString(S3Consts.CHECKSUM_HEADER); + final byte[] digest = multiDigestInputStream.getMessageDigest(OzoneConsts.MD5_HASH).digest(); + final String md5Hash = DatatypeConverter.printHexBinary(digest).toLowerCase(); + final String clientContentMD5 = getHeaders().getHeaderString(S3Consts.CHECKSUM_HEADER); if (clientContentMD5 != null) { - CheckedRunnable checkContentMD5Hook = () -> { + final CheckedRunnable checkContentMD5Hook = () -> { S3Utils.validateContentMD5(clientContentMD5, md5Hash, key); }; ozoneOutputStream.getKeyOutputStream().setPreCommits(Collections.singletonList(checkContentMD5Hook)); } ozoneOutputStream.getMetadata().put(OzoneConsts.ETAG, md5Hash); - outputStream = ozoneOutputStream; } + omMultipartCommitUploadPartInfo = ozoneOutputStream.getCommitUploadPartInfo(); getMetrics().incPutKeySuccessLength(putLength); perf.appendSizeBytes(putLength); } perf.appendMetaLatencyNanos(metadataLatencyNs); - OmMultipartCommitUploadPartInfo omMultipartCommitUploadPartInfo = - outputStream.getCommitUploadPartInfo(); String eTag = omMultipartCommitUploadPartInfo.getETag(); // If the OmMultipartCommitUploadPartInfo does not contain eTag, // fall back to MPU part name for compatibility in case the (old) OM @@ -1003,7 +1012,7 @@ srcKeyLen > getDatastreamMinLength()) { getMetrics().updateCopyKeyMetadataStats(startNanos); perf.appendMetaLatencyNanos(metadataLatencyNs); copyLength = IOUtils.copyLarge(src, dest, 0, srcKeyLen, new byte[getIOBufferSize(srcKeyLen)]); - String md5Hash = DatatypeConverter.printHexBinary(src.getMessageDigest().digest()).toLowerCase(); + final String md5Hash = DatatypeConverter.printHexBinary(src.getMessageDigest().digest()).toLowerCase(); dest.getMetadata().put(OzoneConsts.ETAG, md5Hash); } } @@ -1024,7 +1033,7 @@ private CopyObjectResponse copyObject(OzoneVolume volume, String sourceBucket = result.getLeft(); String sourceKey = result.getRight(); - DigestInputStream sourceDigestInputStream = null; + final MessageDigest md5Digest = getMD5DigestInstance(); if (S3Owner.hasBucketOwnershipVerificationConditions(getHeaders())) { String sourceBucketOwner = volume.getBucket(sourceBucket).getOwner(); @@ -1032,8 +1041,8 @@ private CopyObjectResponse copyObject(OzoneVolume volume, S3Owner.verifyBucketOwnerConditionOnCopyOperation(getHeaders(), sourceBucket, sourceBucketOwner, null, null); } try { - OzoneKeyDetails sourceKeyDetails = getClientProtocol().getKeyDetails( - volume.getName(), sourceBucket, sourceKey); + final OzoneKeyDetails sourceKeyDetails = runWithS3ActionString( + "GetObject", () -> getClientProtocol().getKeyDetails(volume.getName(), sourceBucket, sourceKey)); // Checking whether we trying to copying to it self. if (sourceBucket.equals(destBucket) && sourceKey .equals(destkey)) { @@ -1103,22 +1112,25 @@ private CopyObjectResponse copyObject(OzoneVolume volume, throw ex; } - try (OzoneInputStream src = getClientProtocol().getKey(volume.getName(), - sourceBucket, sourceKey)) { + try (OzoneInputStream src = runWithS3ActionString( + "GetObject", () -> getClientProtocol().getKey(volume.getName(), sourceBucket, sourceKey)); + DigestInputStream sourceDigestInputStream = new DigestInputStream(src, md5Digest)) { getMetrics().updateCopyKeyMetadataStats(startNanos); - sourceDigestInputStream = new DigestInputStream(src, getMD5DigestInstance()); - copy(volume, sourceDigestInputStream, sourceKeyLen, destkey, destBucket, replicationConfig, - customMetadata, perf, startNanos, tags, writeConditions); - } + runWithS3ActionString("PutObject", () -> { + copy(volume, sourceDigestInputStream, sourceKeyLen, destkey, destBucket, + replicationConfig, customMetadata, perf, startNanos, tags, writeConditions); + return null; + }); - final OzoneKeyDetails destKeyDetails = getClientProtocol().getKeyDetails( - volume.getName(), destBucket, destkey); + final OzoneKeyDetails destKeyDetails = getClientProtocol().getKeyDetails( + volume.getName(), destBucket, destkey); - getMetrics().updateCopyObjectSuccessStats(startNanos); - CopyObjectResponse copyObjectResponse = new CopyObjectResponse(); - copyObjectResponse.setETag(wrapInQuotes(destKeyDetails.getMetadata().get(OzoneConsts.ETAG))); - copyObjectResponse.setLastModified(destKeyDetails.getModificationTime()); - return copyObjectResponse; + getMetrics().updateCopyObjectSuccessStats(startNanos); + CopyObjectResponse copyObjectResponse = new CopyObjectResponse(); + copyObjectResponse.setETag(wrapInQuotes(destKeyDetails.getMetadata().get(OzoneConsts.ETAG))); + copyObjectResponse.setLastModified(destKeyDetails.getModificationTime()); + return copyObjectResponse; + } } catch (OMException ex) { if (ex.getResult() == ResultCodes.KEY_NOT_FOUND) { if (getHeaders().getHeaderString(S3Consts.IF_MATCH_HEADER) != null) { @@ -1137,9 +1149,7 @@ private CopyObjectResponse copyObject(OzoneVolume volume, } finally { // Reset the thread-local message digest instance in case of exception // and MessageDigest#digest is never called - if (sourceDigestInputStream != null) { - sourceDigestInputStream.getMessageDigest().reset(); - } + md5Digest.reset(); } } diff --git a/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/endpoint/RootEndpoint.java b/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/endpoint/RootEndpoint.java index 9e638a112a76..8fdb80d9a488 100644 --- a/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/endpoint/RootEndpoint.java +++ b/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/endpoint/RootEndpoint.java @@ -26,7 +26,6 @@ import org.apache.hadoop.ozone.client.OzoneBucket; import org.apache.hadoop.ozone.s3.commontypes.BucketMetadata; import org.apache.hadoop.ozone.s3.exception.OS3Exception; -import org.apache.hadoop.util.Time; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -48,7 +47,8 @@ public class RootEndpoint extends EndpointBase { @GET public Response get() throws OS3Exception, IOException { - long startNanos = Time.monotonicNowNanos(); + S3RequestContext context = new S3RequestContext(this, S3GAction.LIST_S3_BUCKETS); + long startNanos = context.getStartNanos(); boolean auditSuccess = true; try { ListBucketResponse response = new ListBucketResponse(); @@ -73,11 +73,11 @@ public Response get() return Response.ok(response).build(); } catch (Exception ex) { auditSuccess = false; - auditReadFailure(S3GAction.LIST_S3_BUCKETS, ex); + auditReadFailure(context.getAction(), ex); throw ex; } finally { if (auditSuccess) { - auditReadSuccess(S3GAction.LIST_S3_BUCKETS); + auditReadSuccess(context.getAction()); } } } diff --git a/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/endpoint/S3RequestContext.java b/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/endpoint/S3RequestContext.java index 4130feaf6fdb..ebcb773cef45 100644 --- a/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/endpoint/S3RequestContext.java +++ b/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/endpoint/S3RequestContext.java @@ -36,6 +36,7 @@ class S3RequestContext { this.startNanos = Time.monotonicNowNanos(); this.perf = new PerformanceStringBuilder(); this.action = action; + endpoint.applyS3Action(action); } long getStartNanos() { @@ -59,6 +60,7 @@ S3GAction getAction() { void setAction(S3GAction action) { this.action = action; + endpoint.applyS3Action(action); } /** diff --git a/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/util/S3GActionIamMapper.java b/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/util/S3GActionIamMapper.java new file mode 100644 index 000000000000..9953ebe2020b --- /dev/null +++ b/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/util/S3GActionIamMapper.java @@ -0,0 +1,92 @@ +/* + * 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.s3.util; + +import jakarta.annotation.Nullable; +import org.apache.hadoop.ozone.audit.S3GAction; + +/** + * Maps S3 Gateway operations to AWS IAM S3 action names. Values align with + * {@code org.apache.hadoop.ozone.security.acl.iam.IamSessionPolicyResolver} so STS session + * policies and Ranger policy conditions use the same vocabulary. + */ +public final class S3GActionIamMapper { + + private S3GActionIamMapper() { + } + + /** + * @return S3 action string, or null if not applicable to IAM S3 + */ + public static @Nullable String toS3ActionString(@Nullable S3GAction action) { + if (action == null) { + return null; + } + switch (action) { + case GET_BUCKET: + case HEAD_BUCKET: + return "ListBucket"; + case CREATE_BUCKET: + return "CreateBucket"; + case DELETE_BUCKET: + return "DeleteBucket"; + case GET_ACL: + return "GetBucketAcl"; + case PUT_ACL: + return "PutBucketAcl"; + case LIST_MULTIPART_UPLOAD: + return "ListBucketMultipartUploads"; + case MULTI_DELETE: + case DELETE_KEY: + return "DeleteObject"; + case LIST_S3_BUCKETS: + return "ListAllMyBuckets"; + case CREATE_MULTIPART_KEY: + case CREATE_KEY: + case INIT_MULTIPART_UPLOAD: + case COMPLETE_MULTIPART_UPLOAD: + case CREATE_DIRECTORY: + return "PutObject"; + case LIST_PARTS: + return "ListMultipartUploadParts"; + case GET_KEY: + case HEAD_KEY: + return "GetObject"; + case ABORT_MULTIPART_UPLOAD: + return "AbortMultipartUpload"; + case GET_OBJECT_TAGGING: + return "GetObjectTagging"; + case PUT_OBJECT_TAGGING: + return "PutObjectTagging"; + case DELETE_OBJECT_TAGGING: + return "DeleteObjectTagging"; + case PUT_OBJECT_ACL: + return "PutObjectAcl"; + case COPY_OBJECT: + case CREATE_MULTIPART_KEY_BY_COPY: + // CopyObject / UploadPartCopy require distinct source (GetObject) and destination (PutObject) + // authorization. The endpoint code explicitly sets the IAM action string for each phase. + return null; + case GENERATE_SECRET: + case REVOKE_SECRET: + case ASSUME_ROLE: + default: + return null; + } + } +} diff --git a/hadoop-ozone/s3gateway/src/test/java/org/apache/hadoop/ozone/s3/endpoint/TestCopyActionsAudit.java b/hadoop-ozone/s3gateway/src/test/java/org/apache/hadoop/ozone/s3/endpoint/TestCopyActionsAudit.java new file mode 100644 index 000000000000..380a7780e9e6 --- /dev/null +++ b/hadoop-ozone/s3gateway/src/test/java/org/apache/hadoop/ozone/s3/endpoint/TestCopyActionsAudit.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.s3.endpoint; + +import static java.nio.charset.StandardCharsets.UTF_8; +import static org.apache.hadoop.ozone.s3.util.S3Consts.COPY_SOURCE_HEADER; +import static org.apache.hadoop.ozone.s3.util.S3Consts.STORAGE_CLASS_HEADER; +import static org.apache.hadoop.ozone.s3.util.S3Consts.X_AMZ_CONTENT_SHA256; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.io.ByteArrayInputStream; +import java.io.OutputStream; +import java.util.HashMap; +import javax.ws.rs.core.HttpHeaders; +import org.apache.hadoop.hdds.client.ReplicationConfig; +import org.apache.hadoop.hdds.client.ReplicationFactor; +import org.apache.hadoop.hdds.client.ReplicationType; +import org.apache.hadoop.ozone.OzoneConsts; +import org.apache.hadoop.ozone.audit.AuditLogger.PerformanceStringBuilder; +import org.apache.hadoop.ozone.audit.S3GAction; +import org.apache.hadoop.ozone.client.OzoneBucket; +import org.apache.hadoop.ozone.client.OzoneClient; +import org.apache.hadoop.ozone.client.OzoneClientStub; +import org.apache.hadoop.ozone.s3.endpoint.ObjectEndpoint.ObjectRequestContext; +import org.apache.hadoop.ozone.s3.util.S3Consts; +import org.junit.jupiter.api.Test; + +/** + * Verifies audit logging action for copy operations even if S3 action authorization strings are overridden internally. + * For example, S3G.COPY_OBJECT must use S3G.COPY_OBJECT as the audit action, even though internally the S3 actions + * checked are GetObject and PutObject. + */ +public class TestCopyActionsAudit { + + @Test + public void testCopyObjectAuditActionRemainsCopyObject() throws Exception { + final String bucketName = OzoneConsts.S3_BUCKET; + final String srcKey = "src.txt"; + final String destKey = "dest.txt"; + + final OzoneClient client = new OzoneClientStub(); + client.getObjectStore().createS3Bucket(bucketName); + final OzoneBucket bucket = client.getObjectStore().getS3Bucket(bucketName); + + try (OutputStream out = bucket.createKey( + srcKey, 3, ReplicationConfig.fromTypeAndFactor(ReplicationType.RATIS, ReplicationFactor.ONE), + new HashMap<>())) { + out.write("src".getBytes(UTF_8)); + } + + final HttpHeaders headers = mock(HttpHeaders.class); + when(headers.getHeaderString(STORAGE_CLASS_HEADER)).thenReturn("STANDARD"); + when(headers.getHeaderString(X_AMZ_CONTENT_SHA256)).thenReturn("mockSignature"); + when(headers.getHeaderString(COPY_SOURCE_HEADER)).thenReturn(bucketName + "/" + srcKey); + when(headers.getHeaderString(HttpHeaders.CONTENT_LENGTH)).thenReturn("0"); + + final ObjectEndpoint endpoint = newEndpoint(client, headers); + final AuditingObjectOperationHandler auditing = spy(new AuditingObjectOperationHandler(endpoint)); + + final ObjectRequestContext requestContext = endpoint.new ObjectRequestContext(S3GAction.CREATE_KEY, bucketName); + + auditing.handlePutRequest(requestContext, destKey, new ByteArrayInputStream(new byte[0])); + + verify(auditing).auditWriteSuccess(eq(S3GAction.COPY_OBJECT), any(PerformanceStringBuilder.class)); + } + + @Test + public void testUploadPartCopyAuditActionRemainsCreateMultipartKeyByCopy() throws Exception { + final String bucketName = OzoneConsts.S3_BUCKET; + final String srcKey = "src-part.txt"; + final String destKey = "dest-mpu.txt"; + + final OzoneClient client = new OzoneClientStub(); + client.getObjectStore().createS3Bucket(bucketName); + final OzoneBucket bucket = client.getObjectStore().getS3Bucket(bucketName); + + try (OutputStream out = bucket.createKey( + srcKey, 4, ReplicationConfig.fromTypeAndFactor(ReplicationType.RATIS, ReplicationFactor.ONE), + new HashMap<>())) { + out.write("part".getBytes(UTF_8)); + } + + final HttpHeaders headers = mock(HttpHeaders.class); + when(headers.getHeaderString(STORAGE_CLASS_HEADER)).thenReturn("STANDARD"); + when(headers.getHeaderString(X_AMZ_CONTENT_SHA256)).thenReturn("mockSignature"); + when(headers.getHeaderString(COPY_SOURCE_HEADER)).thenReturn(bucketName + "/" + srcKey); + when(headers.getHeaderString(HttpHeaders.CONTENT_LENGTH)).thenReturn("0"); + + final ObjectEndpoint endpoint = newEndpoint(client, headers); + + final String uploadId = EndpointTestUtils.initiateMultipartUpload(endpoint, bucketName, destKey); + assertNotNull(uploadId); + + endpoint.queryParamsForTest().set(S3Consts.QueryParams.UPLOAD_ID, uploadId); + endpoint.queryParamsForTest().setInt(S3Consts.QueryParams.PART_NUMBER, 1); + + final AuditingObjectOperationHandler auditing = spy(new AuditingObjectOperationHandler(endpoint)); + final ObjectRequestContext requestContext = endpoint.new ObjectRequestContext(S3GAction.CREATE_KEY, bucketName); + + auditing.handlePutRequest(requestContext, destKey, new ByteArrayInputStream(new byte[0])); + + verify(auditing).auditWriteSuccess(eq(S3GAction.CREATE_MULTIPART_KEY_BY_COPY), any(PerformanceStringBuilder.class)); + } + + private static ObjectEndpoint newEndpoint(OzoneClient client, HttpHeaders headers) { + return EndpointBuilder.newObjectEndpointBuilder() + .setClient(client) + .setHeaders(headers) + .build(); + } +} + diff --git a/hadoop-ozone/s3gateway/src/test/java/org/apache/hadoop/ozone/s3/util/TestS3GActionIamMapper.java b/hadoop-ozone/s3gateway/src/test/java/org/apache/hadoop/ozone/s3/util/TestS3GActionIamMapper.java new file mode 100644 index 000000000000..c7ae9e4e924c --- /dev/null +++ b/hadoop-ozone/s3gateway/src/test/java/org/apache/hadoop/ozone/s3/util/TestS3GActionIamMapper.java @@ -0,0 +1,68 @@ +/* + * 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.s3.util; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + +import org.apache.hadoop.ozone.audit.S3GAction; +import org.junit.jupiter.api.Test; + +/** Unit tests for {@link S3GActionIamMapper}. */ +public class TestS3GActionIamMapper { + + @Test + public void mapsCoreObjectActions() { + assertEquals("ListBucket", S3GActionIamMapper.toS3ActionString(S3GAction.GET_BUCKET)); + assertEquals("ListBucket", S3GActionIamMapper.toS3ActionString(S3GAction.HEAD_BUCKET)); + assertEquals("CreateBucket", S3GActionIamMapper.toS3ActionString(S3GAction.CREATE_BUCKET)); + assertEquals("DeleteBucket", S3GActionIamMapper.toS3ActionString(S3GAction.DELETE_BUCKET)); + assertEquals("GetBucketAcl", S3GActionIamMapper.toS3ActionString(S3GAction.GET_ACL)); + assertEquals("PutBucketAcl", S3GActionIamMapper.toS3ActionString(S3GAction.PUT_ACL)); + assertEquals("ListBucketMultipartUploads", S3GActionIamMapper.toS3ActionString(S3GAction.LIST_MULTIPART_UPLOAD)); + assertEquals("DeleteObject", S3GActionIamMapper.toS3ActionString(S3GAction.MULTI_DELETE)); + assertEquals("DeleteObject", S3GActionIamMapper.toS3ActionString(S3GAction.DELETE_KEY)); + assertEquals("ListAllMyBuckets", S3GActionIamMapper.toS3ActionString(S3GAction.LIST_S3_BUCKETS)); + assertEquals("PutObject", S3GActionIamMapper.toS3ActionString(S3GAction.CREATE_MULTIPART_KEY)); + assertEquals("PutObject", S3GActionIamMapper.toS3ActionString(S3GAction.CREATE_KEY)); + assertEquals("PutObject", S3GActionIamMapper.toS3ActionString(S3GAction.INIT_MULTIPART_UPLOAD)); + assertEquals("PutObject", S3GActionIamMapper.toS3ActionString(S3GAction.COMPLETE_MULTIPART_UPLOAD)); + assertEquals("PutObject", S3GActionIamMapper.toS3ActionString(S3GAction.CREATE_DIRECTORY)); + assertEquals("ListMultipartUploadParts", S3GActionIamMapper.toS3ActionString(S3GAction.LIST_PARTS)); + assertEquals("GetObject", S3GActionIamMapper.toS3ActionString(S3GAction.GET_KEY)); + assertEquals("GetObject", S3GActionIamMapper.toS3ActionString(S3GAction.HEAD_KEY)); + assertEquals("AbortMultipartUpload", S3GActionIamMapper.toS3ActionString(S3GAction.ABORT_MULTIPART_UPLOAD)); + assertEquals("GetObjectTagging", S3GActionIamMapper.toS3ActionString(S3GAction.GET_OBJECT_TAGGING)); + assertEquals("PutObjectTagging", S3GActionIamMapper.toS3ActionString(S3GAction.PUT_OBJECT_TAGGING)); + assertEquals("DeleteObjectTagging", S3GActionIamMapper.toS3ActionString(S3GAction.DELETE_OBJECT_TAGGING)); + assertEquals("PutObjectAcl", S3GActionIamMapper.toS3ActionString(S3GAction.PUT_OBJECT_ACL)); + } + + @Test + public void copyActionsReturnNull() { + assertNull(S3GActionIamMapper.toS3ActionString(S3GAction.COPY_OBJECT)); + assertNull(S3GActionIamMapper.toS3ActionString(S3GAction.CREATE_MULTIPART_KEY_BY_COPY)); + } + + @Test + public void nonIamActionsReturnNull() { + assertNull(S3GActionIamMapper.toS3ActionString(S3GAction.ASSUME_ROLE)); + assertNull(S3GActionIamMapper.toS3ActionString(S3GAction.GENERATE_SECRET)); + assertNull(S3GActionIamMapper.toS3ActionString(S3GAction.REVOKE_SECRET)); + } +} diff --git a/hadoop-ozone/s3gateway/src/test/java/org/apache/hadoop/ozone/s3/util/package-info.java b/hadoop-ozone/s3gateway/src/test/java/org/apache/hadoop/ozone/s3/util/package-info.java new file mode 100644 index 000000000000..4b7b37a574a1 --- /dev/null +++ b/hadoop-ozone/s3gateway/src/test/java/org/apache/hadoop/ozone/s3/util/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 s3 utilities. + */ +package org.apache.hadoop.ozone.s3.util; From 9f233336da2de800b4ab6a29b195627ff5a21370 Mon Sep 17 00:00:00 2001 From: fmorg-git Date: Wed, 24 Jun 2026 01:21:52 -0700 Subject: [PATCH 39/54] HDDS-15182. Avoid extra read for modification time on CopyObject/CopyPart (#10203) --- .../io/BlockDataStreamOutputEntryPool.java | 8 +- .../client/io/BlockOutputStreamEntryPool.java | 8 +- .../ozone/client/io/KeyDataStreamOutput.java | 4 + .../ozone/client/io/KeyOutputStream.java | 4 + .../client/io/OzoneDataStreamOutput.java | 10 +- .../ozone/client/io/OzoneOutputStream.java | 10 +- .../OmMultipartCommitUploadPartInfo.java | 9 +- .../om/protocol/OzoneManagerProtocol.java | 3 +- ...ManagerProtocolClientSideTranslatorPB.java | 20 ++- .../ozone/s3/awssdk/S3SDKTestUtils.java | 33 ++++ .../hadoop/ozone/s3/awssdk/package-info.java | 21 +++ .../s3/awssdk/v1/AbstractS3SDKV1Tests.java | 67 ++++++++ .../s3/awssdk/v2/AbstractS3SDKV2Tests.java | 70 ++++++++ .../ozone/s3/awssdk/v2/package-info.java | 21 +++ .../src/main/proto/OmClientProtocol.proto | 4 + .../om/request/key/OMKeyCommitRequest.java | 5 + .../key/OMKeyCommitRequestWithFSO.java | 5 + .../S3MultipartUploadCommitPartRequest.java | 1 + .../ozone/s3/endpoint/CopyPartResult.java | 4 +- .../hadoop/ozone/s3/endpoint/CopyResult.java | 45 ++++++ .../ozone/s3/endpoint/ObjectEndpoint.java | 58 ++++--- .../s3/endpoint/ObjectEndpointStreaming.java | 15 +- .../hadoop/ozone/client/OzoneBucketStub.java | 51 ++++-- .../client/OzoneDataStreamOutputStub.java | 18 ++- .../ozone/client/OzoneOutputStreamStub.java | 20 ++- ...pyObjectAndUploadPartCopyLastModified.java | 150 ++++++++++++++++++ 26 files changed, 608 insertions(+), 56 deletions(-) create mode 100644 hadoop-ozone/integration-test-s3/src/test/java/org/apache/hadoop/ozone/s3/awssdk/package-info.java create mode 100644 hadoop-ozone/integration-test-s3/src/test/java/org/apache/hadoop/ozone/s3/awssdk/v2/package-info.java create mode 100644 hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/endpoint/CopyResult.java create mode 100644 hadoop-ozone/s3gateway/src/test/java/org/apache/hadoop/ozone/s3/endpoint/TestCopyObjectAndUploadPartCopyLastModified.java diff --git a/hadoop-ozone/client/src/main/java/org/apache/hadoop/ozone/client/io/BlockDataStreamOutputEntryPool.java b/hadoop-ozone/client/src/main/java/org/apache/hadoop/ozone/client/io/BlockDataStreamOutputEntryPool.java index ff054c350956..be05a294dcfc 100644 --- a/hadoop-ozone/client/src/main/java/org/apache/hadoop/ozone/client/io/BlockDataStreamOutputEntryPool.java +++ b/hadoop-ozone/client/src/main/java/org/apache/hadoop/ozone/client/io/BlockDataStreamOutputEntryPool.java @@ -60,6 +60,7 @@ public class BlockDataStreamOutputEntryPool implements KeyMetadataAware { private final Map metadata = new HashMap<>(); private final XceiverClientFactory xceiverClientFactory; private OmMultipartCommitUploadPartInfo commitUploadPartInfo; + private long modificationTime; private final long openID; private final ExcludeList excludeList; private List bufferList; @@ -254,8 +255,9 @@ void commitKey(long offset) throws IOException { if (keyArgs.getIsMultipartKey()) { commitUploadPartInfo = omClient.commitMultipartUploadPart(buildKeyArgs(), openID); + modificationTime = commitUploadPartInfo.getModificationTime(); } else { - omClient.commitKey(buildKeyArgs(), openID); + modificationTime = omClient.commitKey(buildKeyArgs(), openID); } } else { LOG.warn("Closing KeyDataStreamOutput, but key args is null"); @@ -304,6 +306,10 @@ public OmMultipartCommitUploadPartInfo getCommitUploadPartInfo() { return commitUploadPartInfo; } + public long getModificationTime() { + return modificationTime; + } + public ExcludeList getExcludeList() { return excludeList; } diff --git a/hadoop-ozone/client/src/main/java/org/apache/hadoop/ozone/client/io/BlockOutputStreamEntryPool.java b/hadoop-ozone/client/src/main/java/org/apache/hadoop/ozone/client/io/BlockOutputStreamEntryPool.java index f3b98626f093..cef5a7ed056a 100644 --- a/hadoop-ozone/client/src/main/java/org/apache/hadoop/ozone/client/io/BlockOutputStreamEntryPool.java +++ b/hadoop-ozone/client/src/main/java/org/apache/hadoop/ozone/client/io/BlockOutputStreamEntryPool.java @@ -83,6 +83,7 @@ public class BlockOutputStreamEntryPool implements KeyMetadataAware { */ private final BufferPool bufferPool; private OmMultipartCommitUploadPartInfo commitUploadPartInfo; + private long modificationTime; private final long openID; private final ExcludeList excludeList; private final ContainerClientMetrics clientMetrics; @@ -329,8 +330,9 @@ void commitKey(long offset) throws IOException { if (keyArgs.getIsMultipartKey()) { commitUploadPartInfo = omClient.commitMultipartUploadPart(buildKeyArgs(), openID); + modificationTime = commitUploadPartInfo.getModificationTime(); } else { - omClient.commitKey(buildKeyArgs(), openID); + modificationTime = omClient.commitKey(buildKeyArgs(), openID); } } else { LOG.warn("Closing KeyOutputStream, but key args is null"); @@ -422,6 +424,10 @@ public OmMultipartCommitUploadPartInfo getCommitUploadPartInfo() { return commitUploadPartInfo; } + public long getModificationTime() { + return modificationTime; + } + public ExcludeList getExcludeList() { return excludeList; } diff --git a/hadoop-ozone/client/src/main/java/org/apache/hadoop/ozone/client/io/KeyDataStreamOutput.java b/hadoop-ozone/client/src/main/java/org/apache/hadoop/ozone/client/io/KeyDataStreamOutput.java index ceacd624e935..2d4bc9486cf7 100644 --- a/hadoop-ozone/client/src/main/java/org/apache/hadoop/ozone/client/io/KeyDataStreamOutput.java +++ b/hadoop-ozone/client/src/main/java/org/apache/hadoop/ozone/client/io/KeyDataStreamOutput.java @@ -476,6 +476,10 @@ public OmMultipartCommitUploadPartInfo getCommitUploadPartInfo() { return blockDataStreamOutputEntryPool.getCommitUploadPartInfo(); } + public long getModificationTime() { + return blockDataStreamOutputEntryPool.getModificationTime(); + } + @VisibleForTesting public ExcludeList getExcludeList() { return blockDataStreamOutputEntryPool.getExcludeList(); diff --git a/hadoop-ozone/client/src/main/java/org/apache/hadoop/ozone/client/io/KeyOutputStream.java b/hadoop-ozone/client/src/main/java/org/apache/hadoop/ozone/client/io/KeyOutputStream.java index 2f9edfa94ea8..8c805607883d 100644 --- a/hadoop-ozone/client/src/main/java/org/apache/hadoop/ozone/client/io/KeyOutputStream.java +++ b/hadoop-ozone/client/src/main/java/org/apache/hadoop/ozone/client/io/KeyOutputStream.java @@ -676,6 +676,10 @@ private void closeInternal() throws IOException { return blockOutputStreamEntryPool.getCommitUploadPartInfo(); } + public long getModificationTime() { + return blockOutputStreamEntryPool.getModificationTime(); + } + @VisibleForTesting public ExcludeList getExcludeList() { return blockOutputStreamEntryPool.getExcludeList(); diff --git a/hadoop-ozone/client/src/main/java/org/apache/hadoop/ozone/client/io/OzoneDataStreamOutput.java b/hadoop-ozone/client/src/main/java/org/apache/hadoop/ozone/client/io/OzoneDataStreamOutput.java index 7ce3f71b375b..1351c90ad33f 100644 --- a/hadoop-ozone/client/src/main/java/org/apache/hadoop/ozone/client/io/OzoneDataStreamOutput.java +++ b/hadoop-ozone/client/src/main/java/org/apache/hadoop/ozone/client/io/OzoneDataStreamOutput.java @@ -100,7 +100,7 @@ public synchronized void close() throws IOException { } public OmMultipartCommitUploadPartInfo getCommitUploadPartInfo() { - KeyDataStreamOutput keyDataStreamOutput = getKeyDataStreamOutput(); + final KeyDataStreamOutput keyDataStreamOutput = getKeyDataStreamOutput(); if (keyDataStreamOutput != null) { return keyDataStreamOutput.getCommitUploadPartInfo(); } @@ -108,6 +108,14 @@ public OmMultipartCommitUploadPartInfo getCommitUploadPartInfo() { return null; } + public long getModificationTime() { + final KeyDataStreamOutput keyDataStreamOutput = getKeyDataStreamOutput(); + if (keyDataStreamOutput != null) { + return keyDataStreamOutput.getModificationTime(); + } + throw new IllegalStateException("OutputStream is not a KeyDataStreamOutput: " + byteBufferStreamOutput.getClass()); + } + public KeyDataStreamOutput getKeyDataStreamOutput() { if (byteBufferStreamOutput instanceof OzoneOutputStream) { OutputStream outputStream = diff --git a/hadoop-ozone/client/src/main/java/org/apache/hadoop/ozone/client/io/OzoneOutputStream.java b/hadoop-ozone/client/src/main/java/org/apache/hadoop/ozone/client/io/OzoneOutputStream.java index c0e14b089ef4..1e01998bc458 100644 --- a/hadoop-ozone/client/src/main/java/org/apache/hadoop/ozone/client/io/OzoneOutputStream.java +++ b/hadoop-ozone/client/src/main/java/org/apache/hadoop/ozone/client/io/OzoneOutputStream.java @@ -128,7 +128,7 @@ public void hsync() throws IOException { } public OmMultipartCommitUploadPartInfo getCommitUploadPartInfo() { - KeyOutputStream keyOutputStream = getKeyOutputStream(); + final KeyOutputStream keyOutputStream = getKeyOutputStream(); if (keyOutputStream != null) { return keyOutputStream.getCommitUploadPartInfo(); } @@ -136,6 +136,14 @@ public OmMultipartCommitUploadPartInfo getCommitUploadPartInfo() { return null; } + public long getModificationTime() { + final KeyOutputStream keyOutputStream = getKeyOutputStream(); + if (keyOutputStream != null) { + return keyOutputStream.getModificationTime(); + } + throw new IllegalStateException("OutputStream is not a KeyOutputStream: " + outputStream.getClass()); + } + public OutputStream getOutputStream() { return outputStream; } diff --git a/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/om/helpers/OmMultipartCommitUploadPartInfo.java b/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/om/helpers/OmMultipartCommitUploadPartInfo.java index 93774a82dc5a..0c072b65277f 100644 --- a/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/om/helpers/OmMultipartCommitUploadPartInfo.java +++ b/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/om/helpers/OmMultipartCommitUploadPartInfo.java @@ -27,9 +27,12 @@ public class OmMultipartCommitUploadPartInfo { private final String eTag; - public OmMultipartCommitUploadPartInfo(String partName, String eTag) { + private final long modificationTime; + + public OmMultipartCommitUploadPartInfo(String partName, String eTag, long modificationTime) { this.partName = partName; this.eTag = eTag; + this.modificationTime = modificationTime; } public String getETag() { @@ -39,4 +42,8 @@ public String getETag() { public String getPartName() { return partName; } + + public long getModificationTime() { + return modificationTime; + } } diff --git a/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/om/protocol/OzoneManagerProtocol.java b/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/om/protocol/OzoneManagerProtocol.java index dd884fdf29c0..16f9e3dc39f1 100644 --- a/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/om/protocol/OzoneManagerProtocol.java +++ b/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/om/protocol/OzoneManagerProtocol.java @@ -244,9 +244,10 @@ default OpenKeySession openKey(OmKeyArgs args) throws IOException { * * @param args the key to commit * @param clientID the client identification + * @return the modification time of the committed key in epoch milliseconds * @throws IOException */ - default void commitKey(OmKeyArgs args, long clientID) + default long commitKey(OmKeyArgs args, long clientID) throws IOException { throw new UnsupportedOperationException("OzoneManager does not require " + "this to be implemented, as write requests use a new approach."); diff --git a/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/om/protocolPB/OzoneManagerProtocolClientSideTranslatorPB.java b/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/om/protocolPB/OzoneManagerProtocolClientSideTranslatorPB.java index 8fdf9712c08a..a074d7b6228f 100644 --- a/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/om/protocolPB/OzoneManagerProtocolClientSideTranslatorPB.java +++ b/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/om/protocolPB/OzoneManagerProtocolClientSideTranslatorPB.java @@ -253,6 +253,7 @@ import org.apache.hadoop.ozone.upgrade.UpgradeFinalization.StatusAndMessages; import org.apache.hadoop.ozone.util.ProtobufUtils; import org.apache.hadoop.security.token.Token; +import org.apache.hadoop.util.Time; /** * The client side implementation of OzoneManagerProtocol. @@ -823,9 +824,9 @@ public void hsyncKey(OmKeyArgs args, long clientId) } @Override - public void commitKey(OmKeyArgs args, long clientId) + public long commitKey(OmKeyArgs args, long clientId) throws IOException { - updateKey(args, clientId, false, false); + return updateKeyAndGetModificationTime(args, clientId, false, false); } @Override @@ -849,6 +850,12 @@ public static void setReplicationConfig(ReplicationConfig replication, private void updateKey(OmKeyArgs args, long clientId, boolean hsync, boolean recovery) throws IOException { + // Preserve legacy behavior (ignore response payload). + updateKeyAndGetModificationTime(args, clientId, hsync, recovery); + } + + private long updateKeyAndGetModificationTime(OmKeyArgs args, long clientId, boolean hsync, boolean recovery) + throws IOException { CommitKeyRequest.Builder req = CommitKeyRequest.newBuilder(); List locationInfoList = args.getLocationInfoList(); Objects.requireNonNull(locationInfoList, "locationInfoList == null"); @@ -874,7 +881,11 @@ private void updateKey(OmKeyArgs args, long clientId, boolean hsync, boolean rec .setCommitKeyRequest(req) .build(); - handleError(submitRequest(omRequest)); + final OMResponse resp = handleError(submitRequest(omRequest)); + if (resp.hasCommitKeyResponse() && resp.getCommitKeyResponse().hasModificationTime()) { + return resp.getCommitKeyResponse().getModificationTime(); + } + return Time.now(); } @Override @@ -1791,7 +1802,8 @@ public OmMultipartCommitUploadPartInfo commitMultipartUploadPart( handleError(submitRequest(omRequest)) .getCommitMultiPartUploadResponse(); - return new OmMultipartCommitUploadPartInfo(response.getPartName(), response.getETag()); + final long modificationTime = response.hasModificationTime() ? response.getModificationTime() : Time.now(); + return new OmMultipartCommitUploadPartInfo(response.getPartName(), response.getETag(), modificationTime); } @Override diff --git a/hadoop-ozone/integration-test-s3/src/test/java/org/apache/hadoop/ozone/s3/awssdk/S3SDKTestUtils.java b/hadoop-ozone/integration-test-s3/src/test/java/org/apache/hadoop/ozone/s3/awssdk/S3SDKTestUtils.java index ec42a0d7b4f1..7c5a12dce95b 100644 --- a/hadoop-ozone/integration-test-s3/src/test/java/org/apache/hadoop/ozone/s3/awssdk/S3SDKTestUtils.java +++ b/hadoop-ozone/integration-test-s3/src/test/java/org/apache/hadoop/ozone/s3/awssdk/S3SDKTestUtils.java @@ -31,6 +31,10 @@ import java.util.regex.Pattern; import org.apache.commons.io.IOUtils; import org.apache.commons.lang3.RandomUtils; +import org.apache.hadoop.ozone.MiniOzoneCluster; +import org.apache.hadoop.ozone.client.OzoneBucket; +import org.apache.hadoop.ozone.client.OzoneClient; +import org.apache.hadoop.ozone.client.OzoneMultipartUploadPartListParts; import org.apache.ozone.test.InputSubstream; /** @@ -40,9 +44,38 @@ public final class S3SDKTestUtils { public static final Pattern UPLOAD_ID_PATTERN = Pattern.compile("(.+?)"); + private static final int DEFAULT_LIST_PARTS_MAX = 100; + private S3SDKTestUtils() { } + /** + * Returns the modification time of a key stored in OM, in epoch milliseconds. + */ + public static long getOmKeyModificationTime(MiniOzoneCluster cluster, String bucketName, String keyName) + throws IOException { + try (OzoneClient ozoneClient = cluster.newClient()) { + final OzoneBucket bucket = ozoneClient.getObjectStore().getS3Volume().getBucket(bucketName); + return bucket.getKey(keyName).getModificationTime().toEpochMilli(); + } + } + + /** + * Returns the modification time of a multipart upload part stored in OM, in epoch milliseconds. + */ + public static long getOmPartModificationTime(MiniOzoneCluster cluster, String bucketName, String keyName, + String uploadId, int partNumber) throws IOException { + try (OzoneClient ozoneClient = cluster.newClient()) { + final OzoneBucket bucket = ozoneClient.getObjectStore().getS3Volume().getBucket(bucketName); + final OzoneMultipartUploadPartListParts parts = bucket.listParts(keyName, uploadId, 0, DEFAULT_LIST_PARTS_MAX); + return parts.getPartInfoList().stream() + .filter(part -> part.getPartNumber() == partNumber) + .findFirst() + .orElseThrow(() -> new IllegalStateException("Part " + partNumber + " not found for upload " + uploadId)) + .getModificationTime(); + } + } + /** * Calculate the MD5 digest from an input stream from a specific offset and length. * @param inputStream The input stream where the digest will be read from. diff --git a/hadoop-ozone/integration-test-s3/src/test/java/org/apache/hadoop/ozone/s3/awssdk/package-info.java b/hadoop-ozone/integration-test-s3/src/test/java/org/apache/hadoop/ozone/s3/awssdk/package-info.java new file mode 100644 index 000000000000..3ecbb322cb40 --- /dev/null +++ b/hadoop-ozone/integration-test-s3/src/test/java/org/apache/hadoop/ozone/s3/awssdk/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. + */ + +/** + * Shared utilities and integration tests for the AWS S3 SDK. + */ +package org.apache.hadoop.ozone.s3.awssdk; diff --git a/hadoop-ozone/integration-test-s3/src/test/java/org/apache/hadoop/ozone/s3/awssdk/v1/AbstractS3SDKV1Tests.java b/hadoop-ozone/integration-test-s3/src/test/java/org/apache/hadoop/ozone/s3/awssdk/v1/AbstractS3SDKV1Tests.java index 8238ade39217..4769bbf27fb9 100644 --- a/hadoop-ozone/integration-test-s3/src/test/java/org/apache/hadoop/ozone/s3/awssdk/v1/AbstractS3SDKV1Tests.java +++ b/hadoop-ozone/integration-test-s3/src/test/java/org/apache/hadoop/ozone/s3/awssdk/v1/AbstractS3SDKV1Tests.java @@ -20,6 +20,8 @@ import static org.apache.hadoop.ozone.OzoneConsts.MB; import static org.apache.hadoop.ozone.s3.awssdk.S3SDKTestUtils.calculateDigest; import static org.apache.hadoop.ozone.s3.awssdk.S3SDKTestUtils.createFile; +import static org.apache.hadoop.ozone.s3.awssdk.S3SDKTestUtils.getOmKeyModificationTime; +import static org.apache.hadoop.ozone.s3.awssdk.S3SDKTestUtils.getOmPartModificationTime; import static org.apache.hadoop.ozone.s3.util.S3Consts.CUSTOM_METADATA_HEADER_PREFIX; import static org.apache.hadoop.ozone.s3.util.S3Utils.stripQuotes; import static org.assertj.core.api.Assertions.assertThat; @@ -43,6 +45,8 @@ import com.amazonaws.services.s3.model.CompleteMultipartUploadResult; import com.amazonaws.services.s3.model.CopyObjectRequest; import com.amazonaws.services.s3.model.CopyObjectResult; +import com.amazonaws.services.s3.model.CopyPartRequest; +import com.amazonaws.services.s3.model.CopyPartResult; import com.amazonaws.services.s3.model.CreateBucketRequest; import com.amazonaws.services.s3.model.GeneratePresignedUrlRequest; import com.amazonaws.services.s3.model.GetObjectRequest; @@ -514,6 +518,69 @@ public void testCopyObject() { assertEquals("37b51d194a7513e45b56f6524f2d51f2", copyResult.getETag()); } + @Test + public void testCopyObjectLastModifiedMatchesOmDb() throws Exception { + final String sourceBucketName = getBucketName(); + final String destBucketName = getBucketName(); + final String sourceKey = getKeyName(); + final String destKey = getKeyName(); + final String content = "copy-last-modified-test-content"; + s3Client.createBucket(sourceBucketName); + s3Client.createBucket(destBucketName); + + final InputStream inputStream = new ByteArrayInputStream(content.getBytes(StandardCharsets.UTF_8)); + s3Client.putObject(sourceBucketName, sourceKey, inputStream, new ObjectMetadata()); + + final long sourceModificationTime = getOmKeyModificationTime(cluster, sourceBucketName, sourceKey); + + Thread.sleep(100); + + final CopyObjectResult copyResult = s3Client.copyObject(sourceBucketName, sourceKey, destBucketName, destKey); + + assertNotNull(copyResult.getLastModifiedDate()); + final long responseModificationTime = copyResult.getLastModifiedDate().getTime(); + final long destModificationTime = getOmKeyModificationTime(cluster, destBucketName, destKey); + + assertEquals(destModificationTime, responseModificationTime); + assertNotEquals(sourceModificationTime, responseModificationTime); + } + + @Test + public void testUploadPartCopyLastModifiedMatchesOmDb() throws Exception { + final String bucketName = getBucketName(); + final String sourceKey = getKeyName(); + final String destKey = getKeyName(); + final String content = "copy-last-modified-test-content"; + s3Client.createBucket(bucketName); + + final InputStream inputStream = new ByteArrayInputStream(content.getBytes(StandardCharsets.UTF_8)); + s3Client.putObject(bucketName, sourceKey, inputStream, new ObjectMetadata()); + + final long sourceModificationTime = getOmKeyModificationTime(cluster, bucketName, sourceKey); + + Thread.sleep(100); + + final InitiateMultipartUploadResult initResponse = s3Client.initiateMultipartUpload( + new InitiateMultipartUploadRequest(bucketName, destKey)); + final String uploadId = initResponse.getUploadId(); + + final CopyPartResult copyPartResult = s3Client.copyPart( + new CopyPartRequest() + .withSourceBucketName(bucketName) + .withSourceKey(sourceKey) + .withDestinationBucketName(bucketName) + .withDestinationKey(destKey) + .withUploadId(uploadId) + .withPartNumber(1)); + + assertNotNull(copyPartResult.getLastModifiedDate()); + final long responseModificationTime = copyPartResult.getLastModifiedDate().getTime(); + final long destPartModificationTime = getOmPartModificationTime(cluster, bucketName, destKey, uploadId, 1); + + assertEquals(destPartModificationTime, responseModificationTime); + assertNotEquals(sourceModificationTime, responseModificationTime); + } + @Test public void testCopyObjectWithSourceIfMatch() { final String sourceBucketName = getBucketName("source"); diff --git a/hadoop-ozone/integration-test-s3/src/test/java/org/apache/hadoop/ozone/s3/awssdk/v2/AbstractS3SDKV2Tests.java b/hadoop-ozone/integration-test-s3/src/test/java/org/apache/hadoop/ozone/s3/awssdk/v2/AbstractS3SDKV2Tests.java index 28d8cbf1f61c..97c40fa15679 100644 --- a/hadoop-ozone/integration-test-s3/src/test/java/org/apache/hadoop/ozone/s3/awssdk/v2/AbstractS3SDKV2Tests.java +++ b/hadoop-ozone/integration-test-s3/src/test/java/org/apache/hadoop/ozone/s3/awssdk/v2/AbstractS3SDKV2Tests.java @@ -20,6 +20,8 @@ import static org.apache.hadoop.ozone.OzoneConsts.MB; import static org.apache.hadoop.ozone.s3.awssdk.S3SDKTestUtils.calculateDigest; import static org.apache.hadoop.ozone.s3.awssdk.S3SDKTestUtils.createFile; +import static org.apache.hadoop.ozone.s3.awssdk.S3SDKTestUtils.getOmKeyModificationTime; +import static org.apache.hadoop.ozone.s3.awssdk.S3SDKTestUtils.getOmPartModificationTime; import static org.apache.hadoop.ozone.s3.util.S3Utils.stripQuotes; import static org.assertj.core.api.Assertions.assertThat; import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; @@ -141,6 +143,7 @@ import software.amazon.awssdk.services.s3.model.Tag; import software.amazon.awssdk.services.s3.model.Tagging; import software.amazon.awssdk.services.s3.model.UploadPartCopyRequest; +import software.amazon.awssdk.services.s3.model.UploadPartCopyResponse; import software.amazon.awssdk.services.s3.model.UploadPartRequest; import software.amazon.awssdk.services.s3.model.UploadPartResponse; import software.amazon.awssdk.services.s3.presigner.S3Presigner; @@ -992,6 +995,73 @@ public void testCopyObject() { assertEquals("\"37b51d194a7513e45b56f6524f2d51f2\"", copyObjectResponse.copyObjectResult().eTag()); } + @Test + public void testCopyObjectLastModifiedMatchesOmDb() throws Exception { + final String sourceBucketName = getBucketName(); + final String destBucketName = getBucketName(); + final String sourceKey = getKeyName(); + final String destKey = getKeyName(); + final String content = "copy-last-modified-test-content"; + s3Client.createBucket(b -> b.bucket(sourceBucketName)); + s3Client.createBucket(b -> b.bucket(destBucketName)); + + s3Client.putObject(b -> b.bucket(sourceBucketName).key(sourceKey), RequestBody.fromString(content)); + + final long sourceModificationTime = getOmKeyModificationTime(cluster, sourceBucketName, sourceKey); + + Thread.sleep(100); + + final CopyObjectResponse copyObjectResponse = s3Client.copyObject( + CopyObjectRequest.builder() + .sourceBucket(sourceBucketName) + .sourceKey(sourceKey) + .destinationBucket(destBucketName) + .destinationKey(destKey) + .build()); + + assertNotNull(copyObjectResponse.copyObjectResult().lastModified()); + final long responseModificationTime = copyObjectResponse.copyObjectResult().lastModified().toEpochMilli(); + final long destModificationTime = getOmKeyModificationTime(cluster, destBucketName, destKey); + + assertEquals(destModificationTime, responseModificationTime); + assertNotEquals(sourceModificationTime, responseModificationTime); + } + + @Test + public void testUploadPartCopyLastModifiedMatchesOmDb() throws Exception { + final String bucketName = getBucketName(); + final String sourceKey = getKeyName(); + final String destKey = getKeyName(); + final String content = "copy-last-modified-test-content"; + s3Client.createBucket(b -> b.bucket(bucketName)); + + s3Client.putObject(b -> b.bucket(bucketName).key(sourceKey), RequestBody.fromString(content)); + + final long sourceModificationTime = getOmKeyModificationTime(cluster, bucketName, sourceKey); + + Thread.sleep(100); + + final CreateMultipartUploadResponse initResponse = s3Client.createMultipartUpload( + b -> b.bucket(bucketName).key(destKey)); + final String uploadId = initResponse.uploadId(); + + final UploadPartCopyResponse copyPartResponse = s3Client.uploadPartCopy( + b -> b + .sourceBucket(bucketName) + .sourceKey(sourceKey) + .destinationBucket(bucketName) + .destinationKey(destKey) + .uploadId(uploadId) + .partNumber(1)); + + assertNotNull(copyPartResponse.copyPartResult().lastModified()); + final long responseModificationTime = copyPartResponse.copyPartResult().lastModified().toEpochMilli(); + final long destPartModificationTime = getOmPartModificationTime(cluster, bucketName, destKey, uploadId, 1); + + assertEquals(destPartModificationTime, responseModificationTime); + assertNotEquals(sourceModificationTime, responseModificationTime); + } + @Test public void testCopyObjectWithSourceIfMatch() { final String sourceBucketName = getBucketName("source"); diff --git a/hadoop-ozone/integration-test-s3/src/test/java/org/apache/hadoop/ozone/s3/awssdk/v2/package-info.java b/hadoop-ozone/integration-test-s3/src/test/java/org/apache/hadoop/ozone/s3/awssdk/v2/package-info.java new file mode 100644 index 000000000000..9e921b645750 --- /dev/null +++ b/hadoop-ozone/integration-test-s3/src/test/java/org/apache/hadoop/ozone/s3/awssdk/v2/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. + */ + +/** + * Integration tests for the AWS Java SDK v2 S3 client. + */ +package org.apache.hadoop.ozone.s3.awssdk.v2; diff --git a/hadoop-ozone/interface-client/src/main/proto/OmClientProtocol.proto b/hadoop-ozone/interface-client/src/main/proto/OmClientProtocol.proto index 7674d85ca925..16a7e844ab13 100644 --- a/hadoop-ozone/interface-client/src/main/proto/OmClientProtocol.proto +++ b/hadoop-ozone/interface-client/src/main/proto/OmClientProtocol.proto @@ -1586,6 +1586,8 @@ message CommitKeyRequest { message CommitKeyResponse { + // Modification time of the committed key, set by OM during preExecute. + optional uint64 modificationTime = 1; } message AllocateBlockRequest { @@ -1790,6 +1792,8 @@ message MultipartCommitUploadPartResponse { optional string partName = 1; // This one is returned as Etag for S3. optional string eTag = 2; + // Modification time of the committed part key, set by OM during preExecute. + optional uint64 modificationTime = 3; } message MultipartUploadCompleteRequest { diff --git a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/key/OMKeyCommitRequest.java b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/key/OMKeyCommitRequest.java index 3d8bf932e093..4184b3908cac 100644 --- a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/key/OMKeyCommitRequest.java +++ b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/key/OMKeyCommitRequest.java @@ -62,6 +62,7 @@ import org.apache.hadoop.ozone.om.response.key.OMKeyCommitResponse; import org.apache.hadoop.ozone.om.upgrade.OMLayoutFeature; import org.apache.hadoop.ozone.protocol.proto.OzoneManagerProtocolProtos.CommitKeyRequest; +import org.apache.hadoop.ozone.protocol.proto.OzoneManagerProtocolProtos.CommitKeyResponse; import org.apache.hadoop.ozone.protocol.proto.OzoneManagerProtocolProtos.KeyArgs; import org.apache.hadoop.ozone.protocol.proto.OzoneManagerProtocolProtos.KeyLocation; import org.apache.hadoop.ozone.protocol.proto.OzoneManagerProtocolProtos.OMRequest; @@ -409,6 +410,10 @@ public OMClientResponse validateAndUpdateCache(OzoneManager ozoneManager, Execut omBucketInfo.incrUsedBytes(correctedSpace); + omResponse.setCommitKeyResponse(CommitKeyResponse.newBuilder() + .setModificationTime(commitKeyArgs.getModificationTime()) + .build()); + omClientResponse = new OMKeyCommitResponse(omResponse.build(), omKeyInfo, dbOzoneKey, dbOpenKey, omBucketInfo.copyObject(), oldKeyVersionsToDeleteMap, isHSync, newOpenKeyInfo, dbOpenKeyToDeleteKey, openKeyToDelete); diff --git a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/key/OMKeyCommitRequestWithFSO.java b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/key/OMKeyCommitRequestWithFSO.java index 25b5a4b15d41..b3d06b3173b6 100644 --- a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/key/OMKeyCommitRequestWithFSO.java +++ b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/key/OMKeyCommitRequestWithFSO.java @@ -52,6 +52,7 @@ import org.apache.hadoop.ozone.om.response.OMClientResponse; import org.apache.hadoop.ozone.om.response.key.OMKeyCommitResponseWithFSO; import org.apache.hadoop.ozone.protocol.proto.OzoneManagerProtocolProtos.CommitKeyRequest; +import org.apache.hadoop.ozone.protocol.proto.OzoneManagerProtocolProtos.CommitKeyResponse; import org.apache.hadoop.ozone.protocol.proto.OzoneManagerProtocolProtos.KeyArgs; import org.apache.hadoop.ozone.protocol.proto.OzoneManagerProtocolProtos.OMRequest; import org.apache.hadoop.ozone.protocol.proto.OzoneManagerProtocolProtos.OMResponse; @@ -347,6 +348,10 @@ public OMClientResponse validateAndUpdateCache(OzoneManager ozoneManager, Execut omBucketInfo.incrUsedBytes(correctedSpace); + omResponse.setCommitKeyResponse(CommitKeyResponse.newBuilder() + .setModificationTime(commitKeyArgs.getModificationTime()) + .build()); + omClientResponse = new OMKeyCommitResponseWithFSO(omResponse.build(), omKeyInfo, dbFileKey, dbOpenFileKey, omBucketInfo.copyObject(), oldKeyVersionsToDeleteMap, volumeId, isHSync, newOpenKeyInfo, dbOpenKeyToDeleteKey, openKeyToDelete); diff --git a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/s3/multipart/S3MultipartUploadCommitPartRequest.java b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/s3/multipart/S3MultipartUploadCommitPartRequest.java index ac123ff680ac..fd9eead76d3e 100644 --- a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/s3/multipart/S3MultipartUploadCommitPartRequest.java +++ b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/s3/multipart/S3MultipartUploadCommitPartRequest.java @@ -268,6 +268,7 @@ public OMClientResponse validateAndUpdateCache(OzoneManager ozoneManager, Execut if (eTag != null) { commitResponseBuilder.setETag(eTag); } + commitResponseBuilder.setModificationTime(keyArgs.getModificationTime()); omResponse.setCommitMultiPartUploadResponse(commitResponseBuilder); omClientResponse = getOmClientResponse(ozoneManager, keyVersionsToDeleteMap, openKey, diff --git a/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/endpoint/CopyPartResult.java b/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/endpoint/CopyPartResult.java index f3b8b6e60e60..7f652bad3313 100644 --- a/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/endpoint/CopyPartResult.java +++ b/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/endpoint/CopyPartResult.java @@ -45,9 +45,9 @@ public class CopyPartResult { public CopyPartResult() { } - public CopyPartResult(String eTag) { + public CopyPartResult(String eTag, Instant lastModified) { this.eTag = eTag; - this.lastModified = Instant.now(); + this.lastModified = lastModified; } public Instant getLastModified() { diff --git a/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/endpoint/CopyResult.java b/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/endpoint/CopyResult.java new file mode 100644 index 000000000000..8b67ddbcbaaa --- /dev/null +++ b/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/endpoint/CopyResult.java @@ -0,0 +1,45 @@ +/* + * 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.s3.endpoint; + +/** + * Result of a copy operation. + */ +public class CopyResult { + private final String eTag; + private final long size; + private final long modificationTime; + + public CopyResult(String eTag, long size, long modificationTime) { + this.eTag = eTag; + this.size = size; + this.modificationTime = modificationTime; + } + + public String getETag() { + return eTag; + } + + public long getSize() { + return size; + } + + public long getModificationTime() { + return modificationTime; + } +} diff --git a/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/endpoint/ObjectEndpoint.java b/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/endpoint/ObjectEndpoint.java index bfa3c3d5c79a..9ab04c8502e5 100644 --- a/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/endpoint/ObjectEndpoint.java +++ b/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/endpoint/ObjectEndpoint.java @@ -18,7 +18,6 @@ package org.apache.hadoop.ozone.s3.endpoint; import static org.apache.hadoop.hdds.protocol.proto.HddsProtos.ReplicationType.EC; -import static org.apache.hadoop.ozone.audit.AuditLogger.PerformanceStringBuilder; import static org.apache.hadoop.ozone.s3.S3GatewayConfigKeys.OZONE_S3G_FSO_DIRECTORY_CREATION_ENABLED; import static org.apache.hadoop.ozone.s3.S3GatewayConfigKeys.OZONE_S3G_FSO_DIRECTORY_CREATION_ENABLED_DEFAULT; import static org.apache.hadoop.ozone.s3.exception.S3ErrorTable.INVALID_ARGUMENT; @@ -81,7 +80,9 @@ import org.apache.commons.lang3.tuple.Pair; import org.apache.hadoop.hdds.client.ECReplicationConfig; import org.apache.hadoop.hdds.client.ReplicationConfig; +import org.apache.hadoop.hdds.scm.client.HddsClientUtils; import org.apache.hadoop.ozone.OzoneConsts; +import org.apache.hadoop.ozone.audit.AuditLogger.PerformanceStringBuilder; import org.apache.hadoop.ozone.audit.S3GAction; import org.apache.hadoop.ozone.client.OzoneBucket; import org.apache.hadoop.ozone.client.OzoneKey; @@ -955,7 +956,8 @@ private Response createMultipartKey(OzoneVolume volume, OzoneBucket ozoneBucket, if (copyHeader != null) { getMetrics().updateCopyObjectSuccessStats(startNanos); - return Response.ok(new CopyPartResult(eTag)).build(); + final Instant lastModified = Instant.ofEpochMilli(omMultipartCommitUploadPartInfo.getModificationTime()); + return Response.ok(new CopyPartResult(eTag, lastModified)).build(); } else { getMetrics().updateCreateMultipartKeySuccessStats(startNanos); return Response.ok().header(HttpHeaders.ETAG, eTag).build(); @@ -976,6 +978,22 @@ private Response createMultipartKey(OzoneVolume volume, OzoneBucket ozoneBucket, throw os3Exception; } throw newError(bucketName, key, ex); + } catch (IOException ex) { + // Ensure we handle permission failures - these can surface as IOException wrapping OMException. + if (copyHeader != null) { + getMetrics().updateCopyObjectFailureStats(startNanos); + } else { + getMetrics().updateCreateMultipartKeyFailureStats(startNanos); + } + final OMException omEx = (OMException) HddsClientUtils.containsException(ex, OMException.class); + if (omEx != null) { + if (omEx.getResult() == ResultCodes.NO_SUCH_MULTIPART_UPLOAD_ERROR) { + throw newError(NO_SUCH_UPLOAD, uploadID, omEx); + } else { + throw newError(bucketName, key, omEx); + } + } + throw ex; } finally { // Reset the thread-local message digest instance in case of exception // and MessageDigest#digest is never called @@ -986,7 +1004,7 @@ private Response createMultipartKey(OzoneVolume volume, OzoneBucket ozoneBucket, } @SuppressWarnings("checkstyle:ParameterNumber") - void copy(OzoneVolume volume, DigestInputStream src, long srcKeyLen, + CopyResult copy(OzoneVolume volume, DigestInputStream src, long srcKeyLen, String destKey, String destBucket, ReplicationConfig replication, Map metadata, @@ -995,29 +1013,36 @@ void copy(OzoneVolume volume, DigestInputStream src, long srcKeyLen, S3ConditionalRequest.WriteConditions writeConditions) throws IOException { long copyLength; - + final String eTag; + final long modificationTime; if (isDatastreamEnabled() && !(replication != null && replication.getReplicationType() == EC) && srcKeyLen > getDatastreamMinLength()) { perf.appendStreamMode(); - copyLength = ObjectEndpointStreaming + final CopyResult copyResult = ObjectEndpointStreaming .copyKeyWithStream(volume.getBucket(destBucket), destKey, srcKeyLen, getChunkSize(), replication, metadata, src, perf, startNanos, tags, writeConditions); + eTag = copyResult.getETag(); + copyLength = copyResult.getSize(); + modificationTime = copyResult.getModificationTime(); } else { - try (OzoneOutputStream dest = openKeyForPut( + final OzoneOutputStream destStream = openKeyForPut( volume.getName(), destBucket, destKey, srcKeyLen, - replication, metadata, tags, writeConditions)) { + replication, metadata, tags, writeConditions); + try (OzoneOutputStream dest = destStream) { long metadataLatencyNs = getMetrics().updateCopyKeyMetadataStats(startNanos); perf.appendMetaLatencyNanos(metadataLatencyNs); copyLength = IOUtils.copyLarge(src, dest, 0, srcKeyLen, new byte[getIOBufferSize(srcKeyLen)]); - final String md5Hash = DatatypeConverter.printHexBinary(src.getMessageDigest().digest()).toLowerCase(); - dest.getMetadata().put(OzoneConsts.ETAG, md5Hash); + eTag = DatatypeConverter.printHexBinary(src.getMessageDigest().digest()).toLowerCase(); + destStream.getMetadata().put(OzoneConsts.ETAG, eTag); } + modificationTime = destStream.getModificationTime(); } getMetrics().incCopyObjectSuccessLength(copyLength); perf.appendSizeBytes(copyLength); + return new CopyResult(eTag, copyLength, modificationTime); } private CopyObjectResponse copyObject(OzoneVolume volume, @@ -1116,19 +1141,14 @@ private CopyObjectResponse copyObject(OzoneVolume volume, "GetObject", () -> getClientProtocol().getKey(volume.getName(), sourceBucket, sourceKey)); DigestInputStream sourceDigestInputStream = new DigestInputStream(src, md5Digest)) { getMetrics().updateCopyKeyMetadataStats(startNanos); - runWithS3ActionString("PutObject", () -> { - copy(volume, sourceDigestInputStream, sourceKeyLen, destkey, destBucket, - replicationConfig, customMetadata, perf, startNanos, tags, writeConditions); - return null; - }); - - final OzoneKeyDetails destKeyDetails = getClientProtocol().getKeyDetails( - volume.getName(), destBucket, destkey); + final CopyResult copyResult = runWithS3ActionString("PutObject", () -> + copy(volume, sourceDigestInputStream, sourceKeyLen, destkey, destBucket, + replicationConfig, customMetadata, perf, startNanos, tags, writeConditions)); getMetrics().updateCopyObjectSuccessStats(startNanos); CopyObjectResponse copyObjectResponse = new CopyObjectResponse(); - copyObjectResponse.setETag(wrapInQuotes(destKeyDetails.getMetadata().get(OzoneConsts.ETAG))); - copyObjectResponse.setLastModified(destKeyDetails.getModificationTime()); + copyObjectResponse.setETag(wrapInQuotes(copyResult.getETag())); + copyObjectResponse.setLastModified(Instant.ofEpochMilli(copyResult.getModificationTime())); return copyObjectResponse; } } catch (OMException ex) { diff --git a/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/endpoint/ObjectEndpointStreaming.java b/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/endpoint/ObjectEndpointStreaming.java index 9688c84c67bd..cd018ef09f39 100644 --- a/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/endpoint/ObjectEndpointStreaming.java +++ b/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/endpoint/ObjectEndpointStreaming.java @@ -180,7 +180,7 @@ private static OzoneDataStreamOutput openStreamKeyForPut(OzoneBucket bucket, } @SuppressWarnings("checkstyle:ParameterNumber") - public static long copyKeyWithStream( + public static CopyResult copyKeyWithStream( OzoneBucket bucket, String keyPath, long length, @@ -192,18 +192,21 @@ public static long copyKeyWithStream( S3ConditionalRequest.WriteConditions writeConditions) throws IOException { long writeLen; - try (OzoneDataStreamOutput streamOutput = openStreamKeyForPut(bucket, + String eTag; + final OzoneDataStreamOutput streamOutput = openStreamKeyForPut(bucket, keyPath, length, replicationConfig, keyMetadata, tags, - writeConditions)) { + writeConditions); + try (OzoneDataStreamOutput stream = streamOutput) { long metadataLatencyNs = METRICS.updateCopyKeyMetadataStats(startNanos); writeLen = writeToStreamOutput(streamOutput, body, bufferSize, length); - String eTag = DatatypeConverter.printHexBinary(body.getMessageDigest().digest()) + eTag = DatatypeConverter.printHexBinary(body.getMessageDigest().digest()) .toLowerCase(); perf.appendMetaLatencyNanos(metadataLatencyNs); - ((KeyMetadataAware)streamOutput).getMetadata().put(OzoneConsts.ETAG, eTag); + streamOutput.getMetadata().put(OzoneConsts.ETAG, eTag); } - return writeLen; + + return new CopyResult(eTag, writeLen, streamOutput.getModificationTime()); } private static long writeToStreamOutput(OzoneDataStreamOutput streamOutput, diff --git a/hadoop-ozone/s3gateway/src/test/java/org/apache/hadoop/ozone/client/OzoneBucketStub.java b/hadoop-ozone/s3gateway/src/test/java/org/apache/hadoop/ozone/client/OzoneBucketStub.java index 8a8864c398bf..af853b318e67 100644 --- a/hadoop-ozone/s3gateway/src/test/java/org/apache/hadoop/ozone/client/OzoneBucketStub.java +++ b/hadoop-ozone/s3gateway/src/test/java/org/apache/hadoop/ozone/client/OzoneBucketStub.java @@ -145,20 +145,21 @@ public OzoneOutputStream createKey(String key, long size, new KeyMetadataAwareOutputStream(metadata) { @Override public void close() throws IOException { + super.close(); keyContents.put(key, toByteArray()); + final long mtime = getModificationTime(); keyDetails.put(key, new OzoneKeyDetails( getVolumeName(), getName(), key, size, - System.currentTimeMillis(), - System.currentTimeMillis(), + mtime, + mtime, new ArrayList<>(), finalReplicationCon, getMetadata(), null, () -> readKey(key), true, UserGroupInformation.getCurrentUser().getShortUserName(), tags )); - super.close(); } }; @@ -179,18 +180,19 @@ public OzoneOutputStream rewriteKey(String keyName, long size, long existingKeyG new KeyMetadataAwareOutputStream(metadata) { @Override public void close() throws IOException { + super.close(); keyContents.put(keyName, toByteArray()); + final long mtime = getModificationTime(); keyDetails.put(keyName, new OzoneKeyDetails( getVolumeName(), getName(), keyName, size, - System.currentTimeMillis(), - System.currentTimeMillis(), + mtime, + mtime, new ArrayList<>(), finalReplicationCon, metadata, null, () -> readKey(keyName), true, null, null )); - super.close(); } }; @@ -254,13 +256,14 @@ public void close() throws IOException { Map objectMetadata = keyMetadata == null ? new HashMap<>() : keyMetadata; + final long mtime = getModificationTime(); keyDetails.put(key, new OzoneKeyDetails( getVolumeName(), getName(), key, size, - System.currentTimeMillis(), - System.currentTimeMillis(), + mtime, + mtime, new ArrayList<>(), rConfig, objectMetadata, null, null, false, UserGroupInformation.getCurrentUser().getShortUserName(), @@ -340,7 +343,7 @@ public void close() throws IOException { buffer.get(bytes); Part part = new Part(key + size, bytes, - getMetadata().get(ETAG)); + getMetadata().get(ETAG), getModificationTime()); if (partList.get(key) == null) { Map parts = new TreeMap<>(); parts.put(partNumber, part); @@ -518,8 +521,9 @@ public OzoneOutputStream createMultipartKey(String key, long size, new KeyMetadataAwareOutputStream((int) size, new HashMap<>()) { @Override public void close() throws IOException { + super.close(); Part part = new Part(key + size, - toByteArray(), getMetadata().get(ETAG)); + toByteArray(), getMetadata().get(ETAG), getModificationTime()); if (partList.get(key) == null) { Map parts = new TreeMap<>(); parts.put(partNumber, part); @@ -527,7 +531,6 @@ public void close() throws IOException { } else { partList.get(key).put(partNumber, part); } - super.close(); } }; return new OzoneOutputStreamStub(keyOutputStream, key + size); @@ -649,7 +652,7 @@ public OzoneMultipartUploadPartListParts listParts(String key, if (partEntry.getKey() > partNumberMarker) { PartInfo partInfo = new PartInfo(partEntry.getKey(), partEntry.getValue().getPartName(), - Time.now(), partEntry.getValue().getContent().length, + partEntry.getValue().getModificationTime(), partEntry.getValue().getContent().length, DatatypeConverter.printHexBinary(eTagProvider.digest(partEntry .getValue().getContent())).toLowerCase()); partInfoList.add(partInfo); @@ -732,13 +735,18 @@ public void deleteObjectTagging(String keyName) throws IOException { public static class Part { private String partName; private byte[] content; - private String eTag; + private long modificationTime; - public Part(String name, byte[] data, String eTag) { + public Part(String name, byte[] data, String eTag, long modificationTime) { this.partName = name; this.content = data.clone(); this.eTag = eTag; + this.modificationTime = modificationTime; + } + + public long getModificationTime() { + return modificationTime; } public String getPartName() { @@ -798,6 +806,7 @@ public static class KeyMetadataAwareOutputStream extends KeyOutputStream impleme private final ByteArrayOutputStream buffer = new ByteArrayOutputStream(); private final Map metadata; private List> preCommits = Collections.emptyList(); + private long modificationTime; public KeyMetadataAwareOutputStream(Map metadata) { super(null, null); @@ -831,9 +840,15 @@ public void close() throws IOException { for (CheckedRunnable preCommit : preCommits) { preCommit.run(); } + modificationTime = Time.now(); buffer.close(); } + @Override + public long getModificationTime() { + return modificationTime; + } + @Override public void setPreCommits(List> preCommits) { this.preCommits = preCommits != null ? preCommits : Collections.emptyList(); @@ -859,6 +874,7 @@ public static class KeyMetadataAwareByteBufferStreamOutput private final Map metadata; private List> preCommits = Collections.emptyList(); + private long modificationTime; public KeyMetadataAwareByteBufferStreamOutput( Map metadata) { @@ -878,10 +894,15 @@ public void flush() throws IOException { @Override public void close() throws IOException { - for (CheckedRunnable preCommit : preCommits) { preCommit.run(); } + modificationTime = Time.now(); + } + + @Override + public long getModificationTime() { + return modificationTime; } @Override diff --git a/hadoop-ozone/s3gateway/src/test/java/org/apache/hadoop/ozone/client/OzoneDataStreamOutputStub.java b/hadoop-ozone/s3gateway/src/test/java/org/apache/hadoop/ozone/client/OzoneDataStreamOutputStub.java index c19312692b49..28ebbe04bf8a 100644 --- a/hadoop-ozone/s3gateway/src/test/java/org/apache/hadoop/ozone/client/OzoneDataStreamOutputStub.java +++ b/hadoop-ozone/s3gateway/src/test/java/org/apache/hadoop/ozone/client/OzoneDataStreamOutputStub.java @@ -63,8 +63,22 @@ public synchronized void close() throws IOException { @Override public OmMultipartCommitUploadPartInfo getCommitUploadPartInfo() { - return closed ? new OmMultipartCommitUploadPartInfo(partName, - getMetadata().get(OzoneConsts.ETAG)) : null; + final KeyDataStreamOutput keyDataStreamOutput = getKeyDataStreamOutput(); + if (closed && keyDataStreamOutput != null) { + return new OmMultipartCommitUploadPartInfo( + partName, getMetadata().get(OzoneConsts.ETAG), keyDataStreamOutput.getModificationTime()); + } + return null; + } + + @Override + public long getModificationTime() { + final KeyDataStreamOutput keyDataStreamOutput = getKeyDataStreamOutput(); + if (keyDataStreamOutput != null) { + return keyDataStreamOutput.getModificationTime(); + } + throw new IllegalStateException( + "OutputStream is not a KeyDataStreamOutput: " + getByteBufStreamOutput().getClass()); } @Override diff --git a/hadoop-ozone/s3gateway/src/test/java/org/apache/hadoop/ozone/client/OzoneOutputStreamStub.java b/hadoop-ozone/s3gateway/src/test/java/org/apache/hadoop/ozone/client/OzoneOutputStreamStub.java index ac3dc8b4da17..2661e98edc4f 100644 --- a/hadoop-ozone/s3gateway/src/test/java/org/apache/hadoop/ozone/client/OzoneOutputStreamStub.java +++ b/hadoop-ozone/s3gateway/src/test/java/org/apache/hadoop/ozone/client/OzoneOutputStreamStub.java @@ -21,6 +21,7 @@ import java.io.OutputStream; import org.apache.hadoop.ozone.OzoneConsts; import org.apache.hadoop.ozone.client.io.KeyMetadataAware; +import org.apache.hadoop.ozone.client.io.KeyOutputStream; import org.apache.hadoop.ozone.client.io.OzoneOutputStream; import org.apache.hadoop.ozone.om.helpers.OmMultipartCommitUploadPartInfo; @@ -69,7 +70,22 @@ public synchronized void close() throws IOException { @Override public OmMultipartCommitUploadPartInfo getCommitUploadPartInfo() { - return closed ? new OmMultipartCommitUploadPartInfo(partName, - ((KeyMetadataAware)getOutputStream()).getMetadata().get(OzoneConsts.ETAG)) : null; + final KeyOutputStream keyOutputStream = getKeyOutputStream(); + if (closed && keyOutputStream != null) { + return new OmMultipartCommitUploadPartInfo( + partName, ((KeyMetadataAware) getOutputStream()).getMetadata().get(OzoneConsts.ETAG), + keyOutputStream.getModificationTime()); + } + return null; + } + + @Override + public long getModificationTime() { + final KeyOutputStream keyOutputStream = getKeyOutputStream(); + if (keyOutputStream != null) { + return keyOutputStream.getModificationTime(); + } + throw new IllegalStateException( + "OutputStream is not a KeyOutputStream: " + getOutputStream().getClass()); } } diff --git a/hadoop-ozone/s3gateway/src/test/java/org/apache/hadoop/ozone/s3/endpoint/TestCopyObjectAndUploadPartCopyLastModified.java b/hadoop-ozone/s3gateway/src/test/java/org/apache/hadoop/ozone/s3/endpoint/TestCopyObjectAndUploadPartCopyLastModified.java new file mode 100644 index 000000000000..8de1e246904e --- /dev/null +++ b/hadoop-ozone/s3gateway/src/test/java/org/apache/hadoop/ozone/s3/endpoint/TestCopyObjectAndUploadPartCopyLastModified.java @@ -0,0 +1,150 @@ +/* + * 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.s3.endpoint; + +import static java.nio.charset.StandardCharsets.UTF_8; +import static org.apache.hadoop.ozone.s3.endpoint.EndpointTestUtils.initiateMultipartUpload; +import static org.apache.hadoop.ozone.s3.endpoint.EndpointTestUtils.put; +import static org.apache.hadoop.ozone.s3.util.S3Consts.COPY_SOURCE_HEADER; +import static org.apache.hadoop.ozone.s3.util.S3Consts.STORAGE_CLASS_HEADER; +import static org.apache.hadoop.ozone.s3.util.S3Consts.X_AMZ_CONTENT_SHA256; +import static org.apache.hadoop.ozone.s3.util.S3Utils.urlEncode; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.io.OutputStream; +import java.util.HashMap; +import javax.ws.rs.core.HttpHeaders; +import javax.ws.rs.core.Response; +import org.apache.hadoop.hdds.client.ReplicationConfig; +import org.apache.hadoop.hdds.client.ReplicationFactor; +import org.apache.hadoop.hdds.client.ReplicationType; +import org.apache.hadoop.ozone.client.OzoneBucket; +import org.apache.hadoop.ozone.client.OzoneClient; +import org.apache.hadoop.ozone.client.OzoneMultipartUploadPartListParts; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +/** + * Verifies CopyObject and UploadPartCopy return LastModified from the commit + * response rather than the source key time or a synthetic gateway timestamp. + */ +public class TestCopyObjectAndUploadPartCopyLastModified { + + private static final String SOURCE_BUCKET = "source-bucket"; + private static final String DEST_BUCKET = "dest-bucket"; + private static final String SOURCE_KEY = "source-key"; + private static final String DEST_KEY = "dest-key"; + private static final String MPU_KEY = "mpu-key"; + private static final String CONTENT = "copy-last-modified-test-content"; + private static final long SOURCE_AGE_MS = 100L; + + private ObjectEndpoint endpoint; + private HttpHeaders headers; + private OzoneBucket sourceBucket; + private OzoneBucket destBucket; + + @BeforeEach + void setUp() throws Exception { + headers = mock(HttpHeaders.class); + when(headers.getHeaderString(X_AMZ_CONTENT_SHA256)).thenReturn("UNSIGNED-PAYLOAD"); + when(headers.getHeaderString(STORAGE_CLASS_HEADER)).thenReturn("STANDARD"); + + final OzoneClient client = EndpointBuilder.newObjectEndpointBuilder() + .setHeaders(headers) + .build() + .getClient(); + client.getObjectStore().createS3Bucket(SOURCE_BUCKET); + client.getObjectStore().createS3Bucket(DEST_BUCKET); + sourceBucket = client.getObjectStore().getS3Bucket(SOURCE_BUCKET); + destBucket = client.getObjectStore().getS3Bucket(DEST_BUCKET); + + createSourceKey(); + + endpoint = EndpointBuilder.newObjectEndpointBuilder() + .setHeaders(headers) + .setClient(client) + .build(); + } + + @Test + void testCopyObjectLastModifiedReflectsDestCommitTime() throws Exception { + final long sourceModificationTime = sourceBucket.getKey(SOURCE_KEY) + .getModificationTime().toEpochMilli(); + Thread.sleep(SOURCE_AGE_MS); + + when(headers.getHeaderString(COPY_SOURCE_HEADER)).thenReturn( + SOURCE_BUCKET + "/" + urlEncode(SOURCE_KEY)); + + try (Response response = put(endpoint, DEST_BUCKET, DEST_KEY, CONTENT)) { + assertEquals(200, response.getStatus()); + + final CopyObjectResponse copyObjectResponse = (CopyObjectResponse) response.getEntity(); + assertNotNull(copyObjectResponse.getLastModified()); + assertNotNull(copyObjectResponse.getETag()); + + final long responseModificationTime = copyObjectResponse.getLastModified().toEpochMilli(); + final long destModificationTime = destBucket.getKey(DEST_KEY).getModificationTime().toEpochMilli(); + + assertEquals(destModificationTime, responseModificationTime); + assertNotEquals(sourceModificationTime, responseModificationTime); + } + } + + @Test + void testUploadPartCopyLastModifiedReflectsPartCommitTime() throws Exception { + final long sourceModificationTime = sourceBucket.getKey(SOURCE_KEY) + .getModificationTime().toEpochMilli(); + Thread.sleep(SOURCE_AGE_MS); + + final String uploadId = initiateMultipartUpload(endpoint, DEST_BUCKET, MPU_KEY); + + when(headers.getHeaderString(COPY_SOURCE_HEADER)).thenReturn(SOURCE_BUCKET + "/" + urlEncode(SOURCE_KEY)); + + try (Response response = put(endpoint, DEST_BUCKET, MPU_KEY, 1, uploadId, "")) { + assertEquals(200, response.getStatus()); + + final CopyPartResult copyPartResult = (CopyPartResult) response.getEntity(); + assertNotNull(copyPartResult.getETag()); + assertNotNull(copyPartResult.getLastModified()); + + final long responseModificationTime = copyPartResult.getLastModified().toEpochMilli(); + + // Retrieve the actual modification time of the newly uploaded part from the bucket + final OzoneMultipartUploadPartListParts parts = destBucket.listParts( + MPU_KEY, uploadId, 0, 100); + final long destPartModificationTime = parts.getPartInfoList().get(0).getModificationTime(); + + // Assert that the response precisely matches the part's actual commit time + assertEquals(destPartModificationTime, responseModificationTime); + assertNotEquals(sourceModificationTime, responseModificationTime); + } + } + + private void createSourceKey() throws Exception { + try (OutputStream stream = sourceBucket.createKey( + SOURCE_KEY, CONTENT.length(), ReplicationConfig.fromTypeAndFactor( + ReplicationType.RATIS, ReplicationFactor.THREE), new HashMap<>())) { + stream.write(CONTENT.getBytes(UTF_8)); + } + Thread.sleep(SOURCE_AGE_MS); + } +} From 11f957cea3a5952bf17c46808adae37849aae6e7 Mon Sep 17 00:00:00 2001 From: fmorg-git Date: Sun, 28 Jun 2026 13:45:17 -0700 Subject: [PATCH 40/54] HDDS-15194. [STS] Update IamSessionPolicyResolver to return S3 Actions (#10204) Co-authored-by: Fabian Morgan --- .../acl/iam/IamSessionPolicyResolver.java | 250 ++++-- .../acl/iam/TestIamSessionPolicyResolver.java | 811 +++++++++++------- 2 files changed, 692 insertions(+), 369 deletions(-) diff --git a/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/security/acl/iam/IamSessionPolicyResolver.java b/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/security/acl/iam/IamSessionPolicyResolver.java index 7421aab3253a..4e591d14b5f0 100644 --- a/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/security/acl/iam/IamSessionPolicyResolver.java +++ b/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/security/acl/iam/IamSessionPolicyResolver.java @@ -135,6 +135,7 @@ public static Set resolve(String policyJson, Strin // Accumulate ACLs across ALL statements using a single map to allow // cross-statement deduplication and ALL-permission collapsing. final Map> objToAclsMap = new LinkedHashMap<>(); + final Map> objToActionsMap = new LinkedHashMap<>(); // Parse JSON into set of statements final Set statements = parseJsonAndRetrieveStatements(policyJson); @@ -168,11 +169,12 @@ public static Set resolve(String policyJson, Strin final Set resourceSpecs = validateAndCategorizeResources(authorizerType, resources); // For each action, map to Ozone objects (paths) and acls based on resource specs and prefixes - createPathsAndPermissions(volumeName, authorizerType, filteredS3Actions, resourceSpecs, condition, objToAclsMap); + createPathsAndPermissions( + volumeName, authorizerType, filteredS3Actions, resourceSpecs, condition, objToAclsMap, objToActionsMap); } - // Group accumulated objects by their ACL sets to create final result - return groupObjectsByAcls(objToAclsMap); + // Group accumulated objects by their ACL sets and S3 actions to create final result + return groupObjectsByAclsAndActions(objToAclsMap, objToActionsMap); } /** @@ -356,7 +358,8 @@ static Set mapPolicyActionsToS3Actions(Set actions) { final Set mappedActions = new LinkedHashSet<>(); for (String action : actions) { if ("s3:*".equalsIgnoreCase(action)) { - return EnumSet.of(S3Action.ALL_S3); + // Expand into all supported concrete actions + return EnumSet.allOf(S3Action.class); } // Unsupported actions are silently ignored @@ -377,7 +380,7 @@ private static Set filterActionsWhenConditionPresent(Set map return mappedS3Actions; } - if (mappedS3Actions.contains(S3Action.LIST_BUCKET) || mappedS3Actions.contains(S3Action.ALL_S3)) { + if (mappedS3Actions.contains(S3Action.LIST_BUCKET)) { final Set filteredActions = new HashSet<>(); filteredActions.add(S3Action.LIST_BUCKET); return filteredActions; @@ -457,30 +460,68 @@ static Set validateAndCategorizeResources(AuthorizerType authorize */ @VisibleForTesting static void createPathsAndPermissions(String volumeName, AuthorizerType authorizerType, Set mappedS3Actions, - Set resourceSpecs, Condition condition, Map> objToAclsMap) { + Set resourceSpecs, Condition condition, Map> objToAclsMap, + Map> objToActionsMap) { // Process each resource spec with the given actions for (ResourceSpec resourceSpec : resourceSpecs) { processResourceSpecWithActions( - volumeName, authorizerType, mappedS3Actions, resourceSpec, condition, objToAclsMap); + volumeName, authorizerType, mappedS3Actions, resourceSpec, condition, objToAclsMap, objToActionsMap); } } /** - * Groups objects by their ACL sets. + * Groups objects by their ACL sets and S3 actions. */ @VisibleForTesting - static Set groupObjectsByAcls(Map> objToAclsMap) { - final Map, Set> groupMap = new LinkedHashMap<>(); + static Set groupObjectsByAclsAndActions(Map> objToAclsMap, + Map> objToActionsMap) { - // Group objects by their ACL sets only (across resource types) - objToAclsMap.forEach((obj, acls) -> - groupMap.computeIfAbsent(acls, k -> new LinkedHashSet<>()).add(obj)); + // Composite key to group by both ACLs and S3 actions + class GrantKey { + private final Set acls; + private final Set actions; + + GrantKey(Set acls, Set actions) { + this.acls = acls; + this.actions = actions; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + GrantKey grantKey = (GrantKey) o; + return Objects.equals(acls, grantKey.acls) && Objects.equals(actions, grantKey.actions); + } + + @Override + public int hashCode() { + return Objects.hash(acls, actions); + } + } + + final Map> groupMap = new LinkedHashMap<>(); + + // Group objects by their ACL sets and S3 actions + objToAclsMap.forEach((obj, acls) -> { + Set actions = objToActionsMap.getOrDefault(obj, Collections.emptySet()); + if (actions.size() == S3Action.values().length) { + // An empty set of actions means all actions are allowed + actions = Collections.emptySet(); + } + final GrantKey key = new GrantKey(acls, actions); + groupMap.computeIfAbsent(key, k -> new LinkedHashSet<>()).add(obj); + }); // Convert to result format, filtering out entries with empty ACLs final Set result = new LinkedHashSet<>(); groupMap.forEach((key, objs) -> { - if (!key.isEmpty()) { - result.add(new AssumeRoleRequest.OzoneGrant(objs, key)); + if (!key.acls.isEmpty()) { + result.add(new AssumeRoleRequest.OzoneGrant(objs, key.acls, key.actions)); } }); @@ -493,7 +534,7 @@ static Set groupObjectsByAcls(Map mappedS3Actions, ResourceSpec resourceSpec, Condition condition, - Map> objToAclsMap) { + Map> objToAclsMap, Map> objToActionsMap) { // Process based on ResourceSpec type switch (resourceSpec.type) { @@ -501,31 +542,35 @@ private static void processResourceSpecWithActions(String volumeName, Authorizer Preconditions.checkArgument( authorizerType != AuthorizerType.NATIVE, "ResourceSpec type ANY not supported for OzoneNativeAuthorizer"); - processResourceTypeAny(volumeName, authorizerType, mappedS3Actions, condition, objToAclsMap); + processResourceTypeAny(volumeName, authorizerType, mappedS3Actions, condition, objToAclsMap, objToActionsMap); break; case BUCKET: - processBucketResource(volumeName, mappedS3Actions, resourceSpec, condition, authorizerType, objToAclsMap); + processBucketResource( + volumeName, mappedS3Actions, resourceSpec, condition, authorizerType, objToAclsMap, objToActionsMap); break; case BUCKET_WILDCARD: Preconditions.checkArgument( authorizerType != AuthorizerType.NATIVE, "ResourceSpec type BUCKET_WILDCARD not supported for OzoneNativeAuthorizer"); - processBucketResource(volumeName, mappedS3Actions, resourceSpec, condition, authorizerType, objToAclsMap); + processBucketResource( + volumeName, mappedS3Actions, resourceSpec, condition, authorizerType, objToAclsMap, objToActionsMap); break; case OBJECT_EXACT: - processObjectExactResource(volumeName, mappedS3Actions, resourceSpec, objToAclsMap); + processObjectExactResource(volumeName, mappedS3Actions, resourceSpec, objToAclsMap, objToActionsMap); break; case OBJECT_PREFIX: Preconditions.checkArgument( authorizerType != AuthorizerType.RANGER, "ResourceSpec type OBJECT_PREFIX not supported for RangerOzoneAuthorizer"); - processObjectPrefixResource(volumeName, authorizerType, mappedS3Actions, resourceSpec, objToAclsMap); + processObjectPrefixResource( + volumeName, authorizerType, mappedS3Actions, resourceSpec, objToAclsMap, objToActionsMap); break; case OBJECT_PREFIX_WILDCARD: Preconditions.checkArgument( authorizerType != AuthorizerType.NATIVE, "ResourceSpec type OBJECT_PREFIX_WILDCARD not supported for OzoneNativeAuthorizer"); - processObjectPrefixResource(volumeName, authorizerType, mappedS3Actions, resourceSpec, objToAclsMap); + processObjectPrefixResource( + volumeName, authorizerType, mappedS3Actions, resourceSpec, objToAclsMap, objToActionsMap); break; default: throw new IllegalStateException("Unexpected resourceSpec type found: " + resourceSpec.type); @@ -537,22 +582,34 @@ private static void processResourceSpecWithActions(String volumeName, Authorizer * Example: "Resource": "*" */ private static void processResourceTypeAny(String volumeName, AuthorizerType authorizerType, - Set mappedS3Actions, Condition condition, Map> objToAclsMap) { + Set mappedS3Actions, Condition condition, Map> objToAclsMap, + Map> objToActionsMap) { + final IOzoneObj volumeObj = volumeObj(volumeName); + final IOzoneObj bucketObj = bucketObj(volumeName, "*"); + final IOzoneObj keyObj = keyObj(volumeName, "*", "*"); for (S3Action action : mappedS3Actions) { - addAclsForObj(objToAclsMap, volumeObj(volumeName), action.volumePerms); - addAclsForObj(objToAclsMap, bucketObj(volumeName, "*"), action.bucketPerms); + addAclsForObj(objToAclsMap, volumeObj, action.volumePerms); + addAclsForObj(objToAclsMap, bucketObj, action.bucketPerms); + if (condition != null && condition.prefixes != null && !condition.prefixes.isEmpty() && - (action == S3Action.LIST_BUCKET || action == S3Action.ALL_S3)) { + action == S3Action.LIST_BUCKET) { + + // Ensure the volume and bucket get the action + addActionForKind(objToActionsMap, action, volumeObj, bucketObj, null); + for (String prefix : condition.prefixes) { // If operator is StringEquals, ignore wildcard prefixes - this is AWS behavior if (STRING_EQUALS.equals(condition.operator) && hasWildcard(prefix)) { continue; } - createObjectResourcesFromConditionPrefix( + + final IOzoneObj listObj = createObjectResourcesFromConditionPrefix( volumeName, authorizerType, ResourceSpec.any(), prefix, objToAclsMap, EnumSet.of(READ)); + addActionForKind(objToActionsMap, action, null, null, listObj); } } else { - addAclsForObj(objToAclsMap, keyObj(volumeName, "*", "*"), action.objectPerms); + addAclsForObj(objToAclsMap, keyObj, action.objectPerms); + addActionForKind(objToActionsMap, action, volumeObj, bucketObj, keyObj); } } } @@ -564,7 +621,9 @@ private static void processResourceTypeAny(String volumeName, AuthorizerType aut */ private static void processBucketResource(String volumeName, Set mappedS3Actions, ResourceSpec resourceSpec, Condition condition, AuthorizerType authorizerType, - Map> objToAclsMap) { + Map> objToAclsMap, Map> objToActionsMap) { + final IOzoneObj volumeObj = volumeObj(volumeName); + final IOzoneObj bucketObj = bucketObj(volumeName, resourceSpec.bucket); for (S3Action action : mappedS3Actions) { // The s3:ListAllMyBuckets action can use either "*" or // "arn:aws:s3:::*" as its Resource. The former is already handled via the @@ -574,36 +633,31 @@ private static void processBucketResource(String volumeName, Set mappe // actions (currently s3:ListAllMyBuckets). if (action.kind == ActionKind.BUCKET || (action.kind == ActionKind.VOLUME && "*".equals(resourceSpec.bucket))) { // this handles s3:ListAllMyBuckets - addAclsForObj(objToAclsMap, volumeObj(volumeName), action.volumePerms); - addAclsForObj(objToAclsMap, bucketObj(volumeName, resourceSpec.bucket), action.bucketPerms); - } else if (action == S3Action.ALL_S3) { - // For s3:*, ALL should only apply at the bucket level; grant READ at volume for navigation - // However, resource "arn:aws:s3:::*" can apply to volume as well (as explained above) - // If the bucket is "*", include the volumePerms, otherwise just include READ for navigation. - if ("*".equals(resourceSpec.bucket)) { - addAclsForObj(objToAclsMap, volumeObj(volumeName), action.volumePerms); - } else { - addAclsForObj(objToAclsMap, volumeObj(volumeName), EnumSet.of(READ)); - } - addAclsForObj(objToAclsMap, bucketObj(volumeName, resourceSpec.bucket), action.bucketPerms); + addAclsForObj(objToAclsMap, volumeObj, action.volumePerms); + addAclsForObj(objToAclsMap, bucketObj, action.bucketPerms); + addActionForKind(objToActionsMap, action, volumeObj, bucketObj, null); } - if (action == S3Action.LIST_BUCKET || action == S3Action.ALL_S3) { + if (action == S3Action.LIST_BUCKET) { // If condition prefixes are present, these would constrain the object permissions if the action - // is s3:ListBucket or s3:* (which includes s3:ListBucket) + // is s3:ListBucket if (condition != null && condition.prefixes != null && !condition.prefixes.isEmpty()) { for (String prefix : condition.prefixes) { // If operator is StringEquals, we should ignore any prefix containing wildcards - this is AWS behavior if (STRING_EQUALS.equals(condition.operator) && hasWildcard(prefix)) { continue; } - createObjectResourcesFromConditionPrefix( + final IOzoneObj listObj = createObjectResourcesFromConditionPrefix( volumeName, authorizerType, resourceSpec, prefix, objToAclsMap, EnumSet.of(READ)); + // Add action for the key/prefix + addActionForKind(objToActionsMap, action, null, null, listObj); } } else if (condition == null) { // No condition prefixes, but we need READ access to all objects, so use "*" as the prefix - createObjectResourcesFromConditionPrefix( + final IOzoneObj readObj = createObjectResourcesFromConditionPrefix( volumeName, authorizerType, resourceSpec, "*", objToAclsMap, EnumSet.of(READ)); + // Add action for the key/prefix + addActionForKind(objToActionsMap, action, null, null, readObj); } } } @@ -614,17 +668,17 @@ private static void processBucketResource(String volumeName, Set mappe * Example: "Resource": "arn:aws:s3:::my-bucket/file.txt" */ private static void processObjectExactResource(String volumeName, Set mappedS3Actions, - ResourceSpec resourceSpec, Map> objToAclsMap) { + ResourceSpec resourceSpec, Map> objToAclsMap, + Map> objToActionsMap) { + final IOzoneObj volumeObj = volumeObj(volumeName); + final IOzoneObj bucketObj = bucketObj(volumeName, resourceSpec.bucket); + final IOzoneObj keyObj = keyObj(volumeName, resourceSpec.bucket, resourceSpec.key); for (S3Action action : mappedS3Actions) { if (action.kind == ActionKind.OBJECT) { - addAclsForObj(objToAclsMap, volumeObj(volumeName), action.volumePerms); - addAclsForObj(objToAclsMap, bucketObj(volumeName, resourceSpec.bucket), action.bucketPerms); - addAclsForObj(objToAclsMap, keyObj(volumeName, resourceSpec.bucket, resourceSpec.key), action.objectPerms); - } else if (action == S3Action.ALL_S3) { - addAclsForObj(objToAclsMap, volumeObj(volumeName), EnumSet.of(READ)); - // For s3:*, ALL should only apply at the object level; grant READ at bucket level for navigation - addAclsForObj(objToAclsMap, bucketObj(volumeName, resourceSpec.bucket), EnumSet.of(READ)); - addAclsForObj(objToAclsMap, keyObj(volumeName, resourceSpec.bucket, resourceSpec.key), action.objectPerms); + addAclsForObj(objToAclsMap, volumeObj, action.volumePerms); + addAclsForObj(objToAclsMap, bucketObj, action.bucketPerms); + addAclsForObj(objToAclsMap, keyObj, action.objectPerms); + addActionForKind(objToActionsMap, action, volumeObj, bucketObj, keyObj); } } } @@ -635,22 +689,20 @@ private static void processObjectExactResource(String volumeName, Set * Example: "Resource": "arn:aws:s3:::my-bucket/path/folder" */ private static void processObjectPrefixResource(String volumeName, AuthorizerType authorizerType, - Set mappedS3Actions, ResourceSpec resourceSpec, Map> objToAclsMap) { + Set mappedS3Actions, ResourceSpec resourceSpec, Map> objToAclsMap, + Map> objToActionsMap) { + final IOzoneObj volumeObj = volumeObj(volumeName); + final IOzoneObj bucketObj = bucketObj(volumeName, resourceSpec.bucket); for (S3Action action : mappedS3Actions) { // Object actions apply to prefix/key resources - ensure to add the acls only for the appropriate action type if (action.kind == ActionKind.OBJECT) { - addAclsForObj(objToAclsMap, volumeObj(volumeName), action.volumePerms); - addAclsForObj(objToAclsMap, bucketObj(volumeName, resourceSpec.bucket), action.bucketPerms); + addAclsForObj(objToAclsMap, volumeObj, action.volumePerms); + addAclsForObj(objToAclsMap, bucketObj, action.bucketPerms); // Handle the resource prefix itself (e.g., my-bucket/*) createObjectResourcesFromResourcePrefix( - volumeName, authorizerType, resourceSpec, objToAclsMap, action.objectPerms); - } else if (action == S3Action.ALL_S3) { - addAclsForObj(objToAclsMap, volumeObj(volumeName), EnumSet.of(READ)); - // For s3:*, ALL should only apply at the object/prefix level; grant READ at bucket level for navigation - addAclsForObj(objToAclsMap, bucketObj(volumeName, resourceSpec.bucket), EnumSet.of(READ)); - // Handle the resource prefix itself (e.g., my-bucket/*) - createObjectResourcesFromResourcePrefix( - volumeName, authorizerType, resourceSpec, objToAclsMap, action.objectPerms); + volumeName, authorizerType, resourceSpec, objToAclsMap, objToActionsMap, action.objectPerms, action.name); + // Object-level action was already applied inside createObjectResourcesFromResourcePrefix. + addActionForKind(objToActionsMap, action, volumeObj, bucketObj, null); } } } @@ -659,20 +711,23 @@ private static void processObjectPrefixResource(String volumeName, AuthorizerTyp * Creates object resources from resource prefix (e.g., my-bucket/*). */ private static void createObjectResourcesFromResourcePrefix(String volumeName, AuthorizerType authorizerType, - ResourceSpec resourceSpec, Map> objToAclsMap, Set acls) { + ResourceSpec resourceSpec, Map> objToAclsMap, + Map> objToActionsMap, Set acls, String actionName) { if (authorizerType == AuthorizerType.NATIVE) { final IOzoneObj prefixObj = prefixObj(volumeName, resourceSpec.bucket, resourceSpec.prefix); addAclsForObj(objToAclsMap, prefixObj, acls); + addActionForObj(objToActionsMap, prefixObj, actionName); } else { final IOzoneObj keyObj = keyObj(volumeName, resourceSpec.bucket, resourceSpec.prefix); addAclsForObj(objToAclsMap, keyObj, acls); + addActionForObj(objToActionsMap, keyObj, actionName); } } /** * Creates object resources from condition prefixes (i.e. the s3:prefix conditions). */ - private static void createObjectResourcesFromConditionPrefix(String volumeName, AuthorizerType authorizerType, + private static IOzoneObj createObjectResourcesFromConditionPrefix(String volumeName, AuthorizerType authorizerType, ResourceSpec resourceSpec, String conditionPrefix, Map> objToAclsMap, Set acls) { if (authorizerType == AuthorizerType.NATIVE) { // For native authorizer, use PREFIX resource type with normalized prefix. @@ -686,12 +741,40 @@ private static void createObjectResourcesFromConditionPrefix(String volumeName, } final IOzoneObj prefixObj = prefixObj(volumeName, resourceSpec.bucket, normalizedPrefix); addAclsForObj(objToAclsMap, prefixObj, acls); + return prefixObj; } else { // For Ranger authorizer, use KEY resource type with original prefix // Map "x" in condition list prefix to "x". Map "x/*" in condition list prefix to "x/*". // Map "* in condition list prefix to "*". final IOzoneObj keyObj = keyObj(volumeName, resourceSpec.bucket, conditionPrefix); addAclsForObj(objToAclsMap, keyObj, acls); + return keyObj; + } + } + + private static void addActionForKind(Map> objToActionsMap, S3Action action, + IOzoneObj volumeObj, IOzoneObj bucketObj, IOzoneObj objectObj) { + if (action == null) { + return; + } + final ActionKind kind = action.kind; + if (kind == ActionKind.VOLUME) { + if (volumeObj != null) { + addActionForObj(objToActionsMap, volumeObj, action.name); + } + return; + } + + if (volumeObj != null) { + addActionForObj(objToActionsMap, volumeObj, action.name); + } + if (bucketObj != null) { + addActionForObj(objToActionsMap, bucketObj, action.name); + } + if (objectObj != null) { + if (kind == ActionKind.OBJECT || action == S3Action.LIST_BUCKET) { + addActionForObj(objToActionsMap, objectObj, action.name); + } } } @@ -720,6 +803,31 @@ private static void addAclsForObj(Map> objToAclsMap, IOz } } + /** + * Helper method to add an S3 action for an IOzoneObj. It basically strips off + * the s3: prefix, such that s3:GetObject becomes GetObject. + */ + private static String normalizeS3ActionForGrant(String action) { + if (action == null) { + return null; + } + if (action.isEmpty()) { + return action; + } + if (action.regionMatches(true, 0, "s3:", 0, 3)) { + return action.substring(3); + } + return action; + } + + private static void addActionForObj(Map> objToActionsMap, IOzoneObj obj, String action) { + final String normalizedAction = normalizeS3ActionForGrant(action); + if (normalizedAction != null && !normalizedAction.isEmpty()) { + final OzoneObj ozoneObj = (OzoneObj) obj; + objToActionsMap.computeIfAbsent(ozoneObj, k -> new LinkedHashSet<>()).add(normalizedAction); + } + } + /** * The authorizer type, whether for OzoneNativeAuthorizer or RangerOzoneAuthorizer. * The IOzoneObjs generated differ in certain cases depending on the type. @@ -736,8 +844,7 @@ public enum AuthorizerType { private enum ActionKind { VOLUME, BUCKET, - OBJECT, - ALL + OBJECT } /** @@ -896,10 +1003,7 @@ enum S3Action { PUT_OBJECT("s3:PutObject", ActionKind.OBJECT, EnumSet.of(READ), EnumSet.of(READ), EnumSet.of(CREATE, ACLType.WRITE)), PUT_OBJECT_TAGGING("s3:PutObjectTagging", ActionKind.OBJECT, EnumSet.of(READ), EnumSet.of(READ), - EnumSet.of(ACLType.WRITE)), - - // Wildcard all - ALL_S3("s3:*", ActionKind.ALL, EnumSet.of(READ, LIST), EnumSet.of(ACLType.ALL), EnumSet.of(ACLType.ALL)); + EnumSet.of(ACLType.WRITE)); private final String name; private final ActionKind kind; diff --git a/hadoop-ozone/common/src/test/java/org/apache/hadoop/ozone/security/acl/iam/TestIamSessionPolicyResolver.java b/hadoop-ozone/common/src/test/java/org/apache/hadoop/ozone/security/acl/iam/TestIamSessionPolicyResolver.java index f8d37a113d46..2ac9d195cf53 100644 --- a/hadoop-ozone/common/src/test/java/org/apache/hadoop/ozone/security/acl/iam/TestIamSessionPolicyResolver.java +++ b/hadoop-ozone/common/src/test/java/org/apache/hadoop/ozone/security/acl/iam/TestIamSessionPolicyResolver.java @@ -22,7 +22,6 @@ import static org.apache.hadoop.ozone.om.exceptions.OMException.ResultCodes.NOT_SUPPORTED_OPERATION; import static org.apache.hadoop.ozone.security.acl.AssumeRoleRequest.OzoneGrant; import static org.apache.hadoop.ozone.security.acl.IAccessAuthorizer.ACLType; -import static org.apache.hadoop.ozone.security.acl.IAccessAuthorizer.ACLType.ALL; import static org.apache.hadoop.ozone.security.acl.IAccessAuthorizer.ACLType.CREATE; import static org.apache.hadoop.ozone.security.acl.IAccessAuthorizer.ACLType.DELETE; import static org.apache.hadoop.ozone.security.acl.IAccessAuthorizer.ACLType.LIST; @@ -32,16 +31,32 @@ import static org.apache.hadoop.ozone.security.acl.IAccessAuthorizer.ACLType.WRITE_ACL; import static org.apache.hadoop.ozone.security.acl.iam.IamSessionPolicyResolver.AuthorizerType.NATIVE; import static org.apache.hadoop.ozone.security.acl.iam.IamSessionPolicyResolver.AuthorizerType.RANGER; +import static org.apache.hadoop.ozone.security.acl.iam.IamSessionPolicyResolver.S3Action.ABORT_MULTIPART_UPLOAD; +import static org.apache.hadoop.ozone.security.acl.iam.IamSessionPolicyResolver.S3Action.CREATE_BUCKET; +import static org.apache.hadoop.ozone.security.acl.iam.IamSessionPolicyResolver.S3Action.DELETE_BUCKET; +import static org.apache.hadoop.ozone.security.acl.iam.IamSessionPolicyResolver.S3Action.DELETE_OBJECT; +import static org.apache.hadoop.ozone.security.acl.iam.IamSessionPolicyResolver.S3Action.DELETE_OBJECT_TAGGING; +import static org.apache.hadoop.ozone.security.acl.iam.IamSessionPolicyResolver.S3Action.GET_BUCKET_ACL; +import static org.apache.hadoop.ozone.security.acl.iam.IamSessionPolicyResolver.S3Action.GET_OBJECT; +import static org.apache.hadoop.ozone.security.acl.iam.IamSessionPolicyResolver.S3Action.GET_OBJECT_TAGGING; +import static org.apache.hadoop.ozone.security.acl.iam.IamSessionPolicyResolver.S3Action.LIST_ALL_MY_BUCKETS; +import static org.apache.hadoop.ozone.security.acl.iam.IamSessionPolicyResolver.S3Action.LIST_BUCKET; +import static org.apache.hadoop.ozone.security.acl.iam.IamSessionPolicyResolver.S3Action.LIST_BUCKET_MULTIPART_UPLOADS; +import static org.apache.hadoop.ozone.security.acl.iam.IamSessionPolicyResolver.S3Action.LIST_MULTIPART_UPLOAD_PARTS; +import static org.apache.hadoop.ozone.security.acl.iam.IamSessionPolicyResolver.S3Action.PUT_BUCKET_ACL; +import static org.apache.hadoop.ozone.security.acl.iam.IamSessionPolicyResolver.S3Action.PUT_OBJECT; +import static org.apache.hadoop.ozone.security.acl.iam.IamSessionPolicyResolver.S3Action.PUT_OBJECT_TAGGING; import static org.apache.hadoop.ozone.security.acl.iam.IamSessionPolicyResolver.S3ResourceType; import static org.apache.hadoop.ozone.security.acl.iam.IamSessionPolicyResolver.buildCaseInsensitiveS3ActionMap; import static org.apache.hadoop.ozone.security.acl.iam.IamSessionPolicyResolver.createPathsAndPermissions; -import static org.apache.hadoop.ozone.security.acl.iam.IamSessionPolicyResolver.groupObjectsByAcls; +import static org.apache.hadoop.ozone.security.acl.iam.IamSessionPolicyResolver.groupObjectsByAclsAndActions; import static org.apache.hadoop.ozone.security.acl.iam.IamSessionPolicyResolver.mapPolicyActionsToS3Actions; import static org.apache.hadoop.ozone.security.acl.iam.IamSessionPolicyResolver.resolve; import static org.apache.hadoop.ozone.security.acl.iam.IamSessionPolicyResolver.validateAndCategorizeResources; import static org.assertj.core.api.Assertions.assertThat; import java.util.Collections; +import java.util.HashSet; import java.util.LinkedHashMap; import java.util.LinkedHashSet; import java.util.Map; @@ -61,6 +76,36 @@ public class TestIamSessionPolicyResolver { private static final String VOLUME = "s3v"; + private static final Set ALL_OBJECT_ACTIONS = strSet( + "AbortMultipartUpload", "DeleteObject", "DeleteObjectTagging", "GetObject", "GetObjectTagging", + "ListMultipartUploadParts", "PutObject", "PutObjectTagging"); + + private static final Set ALL_BUCKET_ACTIONS = strSet( + "CreateBucket", "DeleteBucket", "GetBucketAcl", "ListBucket", "ListBucketMultipartUploads", "PutBucketAcl"); + + private static final Set ALL_BUCKET_ACTIONS_WITH_LIST_ALL_MY_BUCKETS; + + static { + final Set tempSet = new HashSet<>(ALL_BUCKET_ACTIONS); + tempSet.add("ListAllMyBuckets"); + ALL_BUCKET_ACTIONS_WITH_LIST_ALL_MY_BUCKETS = Collections.unmodifiableSet(tempSet); + } + + private static final Set ALL_BUCKET_AND_OBJECT_ACTIONS; + + static { + final Set tempSet = new HashSet<>(ALL_BUCKET_ACTIONS); + tempSet.addAll(ALL_OBJECT_ACTIONS); + ALL_BUCKET_AND_OBJECT_ACTIONS = Collections.unmodifiableSet(tempSet); + } + + private static final Set ALL_OBJECT_ACTIONS_WITH_LIST_BUCKET; + + static { + final Set tempSet = new HashSet<>(ALL_OBJECT_ACTIONS); + tempSet.add("ListBucket"); + ALL_OBJECT_ACTIONS_WITH_LIST_BUCKET = Collections.unmodifiableSet(tempSet); + } @Test public void testUnsupportedConditionOperatorThrows() { @@ -302,42 +347,38 @@ public void testBuildCaseInsensitiveS3ActionMap() { // Verify that wildcard actions are present assertThat(caseInsensitiveS3ActionMap).containsKeys( - "s3:*", "s3:get*", "s3:put*", "s3:list*", "s3:delete*", "s3:create*"); + "s3:get*", "s3:put*", "s3:list*", "s3:delete*", "s3:create*"); // Verify s3:Get* contains Get actions final Set getActions = caseInsensitiveS3ActionMap.get("s3:get*"); - assertThat(getActions).containsOnly( - S3Action.GET_OBJECT, S3Action.GET_BUCKET_ACL, S3Action.GET_OBJECT_TAGGING); + assertThat(getActions).containsOnly(GET_OBJECT, GET_BUCKET_ACL, GET_OBJECT_TAGGING); // Verify s3:Put* contains Put actions final Set putActions = caseInsensitiveS3ActionMap.get("s3:put*"); - assertThat(putActions).containsOnly( - S3Action.PUT_OBJECT, S3Action.PUT_OBJECT_TAGGING, S3Action.PUT_BUCKET_ACL); + assertThat(putActions).containsOnly(PUT_OBJECT, PUT_OBJECT_TAGGING, PUT_BUCKET_ACL); // Verify s3:List* contains List actions final Set listActions = caseInsensitiveS3ActionMap.get("s3:list*"); assertThat(listActions).containsOnly( - S3Action.LIST_BUCKET, S3Action.LIST_ALL_MY_BUCKETS, S3Action.LIST_BUCKET_MULTIPART_UPLOADS, - S3Action.LIST_MULTIPART_UPLOAD_PARTS); + LIST_BUCKET, LIST_ALL_MY_BUCKETS, LIST_BUCKET_MULTIPART_UPLOADS, LIST_MULTIPART_UPLOAD_PARTS); // Verify s3:Delete* contains Delete actions final Set deleteActions = caseInsensitiveS3ActionMap.get("s3:delete*"); - assertThat(deleteActions).containsOnly( - S3Action.DELETE_OBJECT, S3Action.DELETE_BUCKET, S3Action.DELETE_OBJECT_TAGGING); + assertThat(deleteActions).containsOnly(DELETE_OBJECT, DELETE_BUCKET, DELETE_OBJECT_TAGGING); // Verify s3:Create* contains Create actions final Set createActions = caseInsensitiveS3ActionMap.get("s3:create*"); - assertThat(createActions).containsOnly(S3Action.CREATE_BUCKET); + assertThat(createActions).containsOnly(CREATE_BUCKET); } @Test public void testBuildCaseInsensitiveS3ActionMapIndividualActionsContainSingleEntry() { final Map> actionMap = buildCaseInsensitiveS3ActionMap(); - + // Individual actions should map to a set with exactly one entry final Set listBucketAction = actionMap.get("s3:listbucket"); assertThat(listBucketAction).hasSize(1); - + final Set getObjectAction = actionMap.get("s3:getobject"); assertThat(getObjectAction).hasSize(1); } @@ -357,50 +398,53 @@ public void testMapPolicyActionsToS3ActionsWithEmptyListReturnsEmpty() { @Test public void testMapPolicyActionsToS3ActionsWithSingleActionMapsCorrectly() { final Set listBucket = mapPolicyActionsToS3Actions(Collections.singleton("s3:ListBucket")); - assertThat(listBucket).containsOnly(S3Action.LIST_BUCKET); + assertThat(listBucket).containsOnly(LIST_BUCKET); // Ensure case-insensitive action works final Set listBucketCi = mapPolicyActionsToS3Actions(Collections.singleton("S3:ListBuCKet")); - assertThat(listBucketCi).containsOnly(S3Action.LIST_BUCKET); + assertThat(listBucketCi).containsOnly(LIST_BUCKET); final Set deleteObject = mapPolicyActionsToS3Actions(Collections.singleton("s3:DeleteObject")); - assertThat(deleteObject).containsOnly(S3Action.DELETE_OBJECT); + assertThat(deleteObject).containsOnly(DELETE_OBJECT); // Ensure case-insensitive action works final Set deleteObjectCi = mapPolicyActionsToS3Actions(Collections.singleton("S3:DeLETeObjeCT")); - assertThat(deleteObjectCi).containsOnly(S3Action.DELETE_OBJECT); + assertThat(deleteObjectCi).containsOnly(DELETE_OBJECT); } @Test public void testMapPolicyActionsToS3ActionsWithMultipleActionsMapAllCorrectly() { final Set result = mapPolicyActionsToS3Actions(strSet("s3:ListBucket", "s3:GetObject", "s3:PutObject")); - assertThat(result).containsOnly(S3Action.LIST_BUCKET, S3Action.GET_OBJECT, S3Action.PUT_OBJECT); + assertThat(result).containsOnly(LIST_BUCKET, GET_OBJECT, PUT_OBJECT); } @Test public void testMapPolicyActionsToS3ActionsWithWildcardExpansion() { final Set result = mapPolicyActionsToS3Actions(Collections.singleton("s3:Get*")); - assertThat(result).containsOnly(S3Action.GET_OBJECT, S3Action.GET_BUCKET_ACL, S3Action.GET_OBJECT_TAGGING); + assertThat(result).containsOnly(GET_OBJECT, GET_BUCKET_ACL, GET_OBJECT_TAGGING); // Ensure it is case-insensitive final Set resultCi = mapPolicyActionsToS3Actions(Collections.singleton("s3:gET*")); - assertThat(resultCi).containsOnly(S3Action.GET_OBJECT, S3Action.GET_BUCKET_ACL, S3Action.GET_OBJECT_TAGGING); + assertThat(resultCi).containsOnly(GET_OBJECT, GET_BUCKET_ACL, GET_OBJECT_TAGGING); } @Test public void testMapPolicyActionsToS3ActionsWithS3StarReturnsAll() { final Set result = mapPolicyActionsToS3Actions(Collections.singleton("s3:*")); - assertThat(result).containsOnly(S3Action.ALL_S3); + assertThat(result).containsOnly( + LIST_ALL_MY_BUCKETS, CREATE_BUCKET, DELETE_BUCKET, GET_BUCKET_ACL, LIST_BUCKET, LIST_BUCKET_MULTIPART_UPLOADS, + PUT_BUCKET_ACL, ABORT_MULTIPART_UPLOAD, DELETE_OBJECT, DELETE_OBJECT_TAGGING, GET_OBJECT, GET_OBJECT_TAGGING, + LIST_MULTIPART_UPLOAD_PARTS, PUT_OBJECT, PUT_OBJECT_TAGGING); final Set resultCi = mapPolicyActionsToS3Actions(Collections.singleton("S3:*")); - assertThat(resultCi).containsOnly(S3Action.ALL_S3); + assertThat(resultCi).isEqualTo(result); } @Test public void testMapPolicyActionsToS3ActionsIgnoresUnsupportedActions() { final Set result = mapPolicyActionsToS3Actions(strSet("s3:GetAccelerateConfiguration", "s3:GetObject")); // Unsupported action should be silently ignored - assertThat(result).containsOnly(S3Action.GET_OBJECT); + assertThat(result).containsOnly(GET_OBJECT); } @Test @@ -413,22 +457,24 @@ public void testMapPolicyActionsToS3ActionsWithOnlyUnsupportedActionsReturnsEmpt @Test public void testMapPolicyActionsToS3ActionsDeduplicatesResults() { final Set result = mapPolicyActionsToS3Actions(strSet("s3:Get*", "s3:GetObject", "s3:GetBucketAcl")); - assertThat(result).containsOnly(S3Action.GET_OBJECT, S3Action.GET_BUCKET_ACL, S3Action.GET_OBJECT_TAGGING); + assertThat(result).containsOnly(GET_OBJECT, GET_BUCKET_ACL, GET_OBJECT_TAGGING); } @Test public void testMapPolicyActionsToS3ActionsHandlesMultipleWildcards() { final Set result = mapPolicyActionsToS3Actions(strSet("s3:Get*", "s3:Put*")); assertThat(result).containsOnly( - S3Action.GET_OBJECT, S3Action.GET_BUCKET_ACL, S3Action.GET_OBJECT_TAGGING, S3Action.PUT_OBJECT, - S3Action.PUT_OBJECT_TAGGING, S3Action.PUT_BUCKET_ACL); + GET_OBJECT, GET_BUCKET_ACL, GET_OBJECT_TAGGING, PUT_OBJECT, PUT_OBJECT_TAGGING, PUT_BUCKET_ACL); } @Test public void testMapPolicyActionsToS3ActionsWithS3StarIgnoresOtherActions() { final Set result = mapPolicyActionsToS3Actions(strSet("s3:*", "s3:GetObject", "s3:PutObject")); - // When s3:* is present, it should return only the ALL_S3 action - assertThat(result).containsOnly(S3Action.ALL_S3); + // When s3:* is present, it should return all supported concrete actions + assertThat(result).containsOnly( + LIST_ALL_MY_BUCKETS, CREATE_BUCKET, DELETE_BUCKET, GET_BUCKET_ACL, LIST_BUCKET, LIST_BUCKET_MULTIPART_UPLOADS, + PUT_BUCKET_ACL, ABORT_MULTIPART_UPLOAD, DELETE_OBJECT, DELETE_OBJECT_TAGGING, GET_OBJECT, GET_OBJECT_TAGGING, + LIST_MULTIPART_UPLOAD_PARTS, PUT_OBJECT, PUT_OBJECT_TAGGING); } @Test @@ -699,86 +745,98 @@ public void testValidateAndCategorizeResourcesWithNoResourcesThrows() { @Test public void testCreatePathsAndPermissionsWithResourceAny() { // This also tests that acls are deduplicated across different resource types - final Set actions = Stream.of(S3Action.LIST_ALL_MY_BUCKETS, S3Action.LIST_BUCKET, S3Action.GET_OBJECT) + final Set actions = Stream.of(LIST_ALL_MY_BUCKETS, LIST_BUCKET, GET_OBJECT) .collect(Collectors.toSet()); // actions at volume, bucket and key levels final Set resourceSpecs = Collections.singleton( new IamSessionPolicyResolver.ResourceSpec(S3ResourceType.ANY, "*", null, null)); expectIllegalArgumentException( - () -> createPathsAndPermissions(VOLUME, NATIVE, actions, resourceSpecs, null, new LinkedHashMap<>()), + () -> createPathsAndPermissions( + VOLUME, NATIVE, actions, resourceSpecs, null, new LinkedHashMap<>(), new LinkedHashMap<>()), "ResourceSpec type ANY not supported for OzoneNativeAuthorizer"); final Map> objToAclsMapRanger = new LinkedHashMap<>(); - createPathsAndPermissions(VOLUME, RANGER, actions, resourceSpecs, null, objToAclsMapRanger); - final Set resultRanger = groupObjectsByAcls(objToAclsMapRanger); - final Set readAndListObjects = objSet(volume(), bucket("*")); // volume, bucket level have READ, LIST - final Set readObject = objSet(key("*", "*")); // key level has READ + final Map> objToActionsMapRanger = new LinkedHashMap<>(); + createPathsAndPermissions(VOLUME, RANGER, actions, resourceSpecs, null, objToAclsMapRanger, objToActionsMapRanger); + final Set resultRanger = groupObjectsByAclsAndActions(objToAclsMapRanger, objToActionsMapRanger); + // volume and bucket have READ, LIST; key has READ; result is now grouped by (ACLs, S3 actions) assertThat(resultRanger).containsExactlyInAnyOrder( - new OzoneGrant(readAndListObjects, acls(READ, LIST)), - new OzoneGrant(readObject, acls(READ))); + new OzoneGrant(objSet(volume()), acls(READ, LIST), strSet("ListAllMyBuckets", "ListBucket", "GetObject")), + new OzoneGrant(objSet(bucket("*")), acls(READ, LIST), strSet("ListBucket", "GetObject")), + new OzoneGrant(objSet(key("*", "*")), acls(READ), strSet("ListBucket", "GetObject"))); } @Test public void testCreatePathsAndPermissionsWithBucketResourceThatIsListBucket() { - final Set actions = Collections.singleton(IamSessionPolicyResolver.S3Action.LIST_BUCKET); + final Set actions = Collections.singleton(LIST_BUCKET); final Set resourceSpecs = Collections.singleton( new IamSessionPolicyResolver.ResourceSpec(S3ResourceType.BUCKET, "bucket1", null, null)); final Set readAndListObject = objSet(bucket("bucket1")); final Map> objToAclsMapNative = new LinkedHashMap<>(); + final Map> objToActionsMapNative = new LinkedHashMap<>(); final Set nativeReadObjects = objSet(volume(), prefix("bucket1", "")); - createPathsAndPermissions(VOLUME, NATIVE, actions, resourceSpecs, null, objToAclsMapNative); - final Set resultNative = groupObjectsByAcls(objToAclsMapNative); + createPathsAndPermissions(VOLUME, NATIVE, actions, resourceSpecs, null, objToAclsMapNative, objToActionsMapNative); + final Set resultNative = groupObjectsByAclsAndActions(objToAclsMapNative, objToActionsMapNative); assertThat(resultNative).containsExactlyInAnyOrder( - new OzoneGrant(readAndListObject, acls(READ, LIST)), new OzoneGrant(nativeReadObjects, acls(READ))); + new OzoneGrant(readAndListObject, acls(READ, LIST), strSet("ListBucket")), + new OzoneGrant(nativeReadObjects, acls(READ), strSet("ListBucket"))); final Map> objToAclsMapRanger = new LinkedHashMap<>(); + final Map> objToActionsMapRanger = new LinkedHashMap<>(); final Set rangerReadObjects = objSet(volume(), key("bucket1", "*")); - createPathsAndPermissions(VOLUME, RANGER, actions, resourceSpecs, null, objToAclsMapRanger); - final Set resultRanger = groupObjectsByAcls(objToAclsMapRanger); + createPathsAndPermissions(VOLUME, RANGER, actions, resourceSpecs, null, objToAclsMapRanger, objToActionsMapRanger); + final Set resultRanger = groupObjectsByAclsAndActions(objToAclsMapRanger, objToActionsMapRanger); assertThat(resultRanger).containsExactlyInAnyOrder( - new OzoneGrant(readAndListObject, acls(READ, LIST)), new OzoneGrant(rangerReadObjects, acls(READ))); + new OzoneGrant(readAndListObject, acls(READ, LIST), strSet("ListBucket")), + new OzoneGrant(rangerReadObjects, acls(READ), strSet("ListBucket"))); } @Test public void testCreatePathsAndPermissionsWithBucketResourceThatIsNotListBucket() { - final Set actions = Collections.singleton(S3Action.CREATE_BUCKET); + final Set actions = Collections.singleton(CREATE_BUCKET); final Set resourceSpecs = Collections.singleton( new IamSessionPolicyResolver.ResourceSpec(S3ResourceType.BUCKET, "bucket1", null, null)); final Set createObject = objSet(bucket("bucket1")); final Set readObject = objSet(volume()); final Map> objToAclsMapNative = new LinkedHashMap<>(); - createPathsAndPermissions(VOLUME, NATIVE, actions, resourceSpecs, null, objToAclsMapNative); - final Set resultNative = groupObjectsByAcls(objToAclsMapNative); + final Map> objToActionsMapNative = new LinkedHashMap<>(); + createPathsAndPermissions(VOLUME, NATIVE, actions, resourceSpecs, null, objToAclsMapNative, objToActionsMapNative); + final Set resultNative = groupObjectsByAclsAndActions(objToAclsMapNative, objToActionsMapNative); assertThat(resultNative).containsExactlyInAnyOrder( - new OzoneGrant(createObject, acls(CREATE)), new OzoneGrant(readObject, acls(READ))); + new OzoneGrant(createObject, acls(CREATE), strSet("CreateBucket")), + new OzoneGrant(readObject, acls(READ), strSet("CreateBucket"))); final Map> objToAclsMapRanger = new LinkedHashMap<>(); - createPathsAndPermissions(VOLUME, RANGER, actions, resourceSpecs, null, objToAclsMapRanger); - final Set resultRanger = groupObjectsByAcls(objToAclsMapRanger); + final Map> objToActionsMapRanger = new LinkedHashMap<>(); + createPathsAndPermissions(VOLUME, RANGER, actions, resourceSpecs, null, objToAclsMapRanger, objToActionsMapRanger); + final Set resultRanger = groupObjectsByAclsAndActions(objToAclsMapRanger, objToActionsMapRanger); assertThat(resultRanger).containsExactlyInAnyOrder( - new OzoneGrant(createObject, acls(CREATE)), new OzoneGrant(readObject, acls(READ))); + new OzoneGrant(createObject, acls(CREATE), strSet("CreateBucket")), + new OzoneGrant(readObject, acls(READ), strSet("CreateBucket"))); } @Test public void testCreatePathsAndPermissionsWithBucketWildcardResource() { - final Set actions = Collections.singleton(IamSessionPolicyResolver.S3Action.PUT_BUCKET_ACL); + final Set actions = Collections.singleton(PUT_BUCKET_ACL); final Set resourceSpecs = Collections.singleton( new IamSessionPolicyResolver.ResourceSpec(S3ResourceType.BUCKET_WILDCARD, "bucket1*", null, null)); final Set readReadAclAndWriteAclObject = objSet(bucket("bucket1*")); final Set readVolume = objSet(volume()); expectIllegalArgumentException( - () -> createPathsAndPermissions(VOLUME, NATIVE, actions, resourceSpecs, null, new LinkedHashMap<>()), + () -> createPathsAndPermissions( + VOLUME, NATIVE, actions, resourceSpecs, null, new LinkedHashMap<>(), new LinkedHashMap<>()), "ResourceSpec type BUCKET_WILDCARD not supported for OzoneNativeAuthorizer"); final Map> objToAclsMapRanger = new LinkedHashMap<>(); - createPathsAndPermissions(VOLUME, RANGER, actions, resourceSpecs, null, objToAclsMapRanger); - final Set resultRanger = groupObjectsByAcls(objToAclsMapRanger); + final Map> objToActionsMapRanger = new LinkedHashMap<>(); + createPathsAndPermissions(VOLUME, RANGER, actions, resourceSpecs, null, objToAclsMapRanger, objToActionsMapRanger); + final Set resultRanger = groupObjectsByAclsAndActions(objToAclsMapRanger, objToActionsMapRanger); assertThat(resultRanger).containsExactlyInAnyOrder( - new OzoneGrant(readReadAclAndWriteAclObject, acls(READ, READ_ACL, WRITE_ACL)), - new OzoneGrant(readVolume, acls(READ))); + new OzoneGrant(readReadAclAndWriteAclObject, acls(READ, READ_ACL, WRITE_ACL), strSet("PutBucketAcl")), + new OzoneGrant(readVolume, acls(READ), strSet("PutBucketAcl"))); } @Test @@ -787,124 +845,148 @@ public void testCreatePathsAndPermissionsWithBucketsWildcardResourceAll() { // Resource values. The "*" case is covered by testCreatePathsAndPermissionsWithResourceAny. // This test ensures that "arn:aws:s3:::*" (parsed as BUCKET_WILDCARD with bucket="*") // also grants the expected volume-level permissions for ListAllMyBuckets. - final Set actions = Stream.of(S3Action.LIST_ALL_MY_BUCKETS, S3Action.LIST_BUCKET) + final Set actions = Stream.of(LIST_ALL_MY_BUCKETS, LIST_BUCKET) .collect(Collectors.toSet()); final Set resourceSpecs = Collections.singleton( new IamSessionPolicyResolver.ResourceSpec(S3ResourceType.BUCKET_WILDCARD, "*", null, null)); expectIllegalArgumentException( - () -> createPathsAndPermissions(VOLUME, NATIVE, actions, resourceSpecs, null, new LinkedHashMap<>()), + () -> createPathsAndPermissions( + VOLUME, NATIVE, actions, resourceSpecs, null, new LinkedHashMap<>(), new LinkedHashMap<>()), "ResourceSpec type BUCKET_WILDCARD not supported for OzoneNativeAuthorizer"); final Map> objToAclsMapRanger = new LinkedHashMap<>(); - createPathsAndPermissions(VOLUME, RANGER, actions, resourceSpecs, null, objToAclsMapRanger); + final Map> objToActionsMapRanger = new LinkedHashMap<>(); + createPathsAndPermissions(VOLUME, RANGER, actions, resourceSpecs, null, objToAclsMapRanger, objToActionsMapRanger); // Both the volume and the wildcard bucket should end up with READ + LIST permissions. // We also need READ access on the keys - final Set resultRanger = groupObjectsByAcls(objToAclsMapRanger); - final Set readAndListObjects = objSet(volume(), bucket("*")); + final Set resultRanger = groupObjectsByAclsAndActions(objToAclsMapRanger, objToActionsMapRanger); + final Set volumeObj = objSet(volume()); + final Set bucketObj = objSet(bucket("*")); final Set readObjects = objSet(key("*", "*")); assertThat(resultRanger).containsExactlyInAnyOrder( - new OzoneGrant(readAndListObjects, acls(READ, LIST)), new OzoneGrant(readObjects, acls(READ))); + new OzoneGrant(volumeObj, acls(READ, LIST), strSet("ListAllMyBuckets", "ListBucket")), + new OzoneGrant(bucketObj, acls(READ, LIST), strSet("ListBucket")), + new OzoneGrant(readObjects, acls(READ), strSet("ListBucket"))); } @Test public void testCreatePathsAndPermissionsWithObjectExactResource() { - final Set actions = Collections.singleton(S3Action.GET_OBJECT); + final Set actions = Collections.singleton(GET_OBJECT); final Set resourceSpecs = Collections.singleton( new IamSessionPolicyResolver.ResourceSpec(S3ResourceType.OBJECT_EXACT, "bucket1", null, "key.txt")); - final Set readObjects = objSet(key("bucket1", "key.txt"), bucket("bucket1"), volume()); + final Set readVolumeBucketAndKey = objSet(volume(), bucket("bucket1"), key("bucket1", "key.txt")); final Map> objToAclsMapNative = new LinkedHashMap<>(); - createPathsAndPermissions(VOLUME, NATIVE, actions, resourceSpecs, null, objToAclsMapNative); - final Set resultNative = groupObjectsByAcls(objToAclsMapNative); - assertThat(resultNative).containsExactly(new OzoneGrant(readObjects, acls(READ))); + final Map> objToActionsMapNative = new LinkedHashMap<>(); + createPathsAndPermissions(VOLUME, NATIVE, actions, resourceSpecs, null, objToAclsMapNative, objToActionsMapNative); + final Set resultNative = groupObjectsByAclsAndActions(objToAclsMapNative, objToActionsMapNative); + assertThat(resultNative).containsExactlyInAnyOrder( + new OzoneGrant(readVolumeBucketAndKey, acls(READ), strSet("GetObject"))); final Map> objToAclsMapRanger = new LinkedHashMap<>(); - createPathsAndPermissions(VOLUME, RANGER, actions, resourceSpecs, null, objToAclsMapRanger); - final Set resultRanger = groupObjectsByAcls(objToAclsMapRanger); - assertThat(resultRanger).containsExactly(new OzoneGrant(readObjects, acls(READ))); + final Map> objToActionsMapRanger = new LinkedHashMap<>(); + createPathsAndPermissions(VOLUME, RANGER, actions, resourceSpecs, null, objToAclsMapRanger, objToActionsMapRanger); + final Set resultRanger = groupObjectsByAclsAndActions(objToAclsMapRanger, objToActionsMapRanger); + assertThat(resultRanger).containsExactlyInAnyOrder( + new OzoneGrant(readVolumeBucketAndKey, acls(READ), strSet("GetObject"))); } @Test public void testCreatePathsAndPermissionsWithDeleteObjectGrantsDeleteOnKey() { - final Set actions = Collections.singleton(S3Action.DELETE_OBJECT); + final Set actions = Collections.singleton(DELETE_OBJECT); final Set resourceSpecs = Collections.singleton( new IamSessionPolicyResolver.ResourceSpec(S3ResourceType.OBJECT_EXACT, "bucket1", null, "key.txt")); final Set readVolumeAndBucket = objSet(volume(), bucket("bucket1")); final Set deleteKey = objSet(key("bucket1", "key.txt")); final Map> objToAclsMapNative = new LinkedHashMap<>(); - createPathsAndPermissions(VOLUME, NATIVE, actions, resourceSpecs, null, objToAclsMapNative); - final Set resultNative = groupObjectsByAcls(objToAclsMapNative); + final Map> objToActionsMapNative = new LinkedHashMap<>(); + createPathsAndPermissions(VOLUME, NATIVE, actions, resourceSpecs, null, objToAclsMapNative, objToActionsMapNative); + final Set resultNative = groupObjectsByAclsAndActions(objToAclsMapNative, objToActionsMapNative); assertThat(resultNative).containsExactlyInAnyOrder( - new OzoneGrant(readVolumeAndBucket, acls(READ)), new OzoneGrant(deleteKey, acls(DELETE))); + new OzoneGrant(readVolumeAndBucket, acls(READ), strSet("DeleteObject")), + new OzoneGrant(deleteKey, acls(DELETE), strSet("DeleteObject"))); final Map> objToAclsMapRanger = new LinkedHashMap<>(); - createPathsAndPermissions(VOLUME, RANGER, actions, resourceSpecs, null, objToAclsMapRanger); - final Set resultRanger = groupObjectsByAcls(objToAclsMapRanger); + final Map> objToActionsMapRanger = new LinkedHashMap<>(); + createPathsAndPermissions(VOLUME, RANGER, actions, resourceSpecs, null, objToAclsMapRanger, objToActionsMapRanger); + final Set resultRanger = groupObjectsByAclsAndActions(objToAclsMapRanger, objToActionsMapRanger); assertThat(resultRanger).containsExactlyInAnyOrder( - new OzoneGrant(readVolumeAndBucket, acls(READ)), new OzoneGrant(deleteKey, acls(DELETE))); + new OzoneGrant(readVolumeAndBucket, acls(READ), strSet("DeleteObject")), + new OzoneGrant(deleteKey, acls(DELETE), strSet("DeleteObject"))); } @Test public void testCreatePathsAndPermissionsWithAbortMultipartUploadGrantsWriteOnKey() { - final Set actions = Collections.singleton(S3Action.ABORT_MULTIPART_UPLOAD); + final Set actions = Collections.singleton(ABORT_MULTIPART_UPLOAD); final Set resourceSpecs = Collections.singleton( new IamSessionPolicyResolver.ResourceSpec(S3ResourceType.OBJECT_EXACT, "bucket1", null, "key.txt")); final Set readVolumeAndBucket = objSet(volume(), bucket("bucket1")); final Set writeKey = objSet(key("bucket1", "key.txt")); final Map> objToAclsMapNative = new LinkedHashMap<>(); - createPathsAndPermissions(VOLUME, NATIVE, actions, resourceSpecs, null, objToAclsMapNative); - final Set resultNative = groupObjectsByAcls(objToAclsMapNative); + final Map> objToActionsMapNative = new LinkedHashMap<>(); + createPathsAndPermissions(VOLUME, NATIVE, actions, resourceSpecs, null, objToAclsMapNative, objToActionsMapNative); + final Set resultNative = groupObjectsByAclsAndActions(objToAclsMapNative, objToActionsMapNative); assertThat(resultNative).containsExactlyInAnyOrder( - new OzoneGrant(readVolumeAndBucket, acls(READ)), new OzoneGrant(writeKey, acls(WRITE))); + new OzoneGrant(readVolumeAndBucket, acls(READ), strSet("AbortMultipartUpload")), + new OzoneGrant(writeKey, acls(WRITE), strSet("AbortMultipartUpload"))); final Map> objToAclsMapRanger = new LinkedHashMap<>(); - createPathsAndPermissions(VOLUME, RANGER, actions, resourceSpecs, null, objToAclsMapRanger); - final Set resultRanger = groupObjectsByAcls(objToAclsMapRanger); + final Map> objToActionsMapRanger = new LinkedHashMap<>(); + createPathsAndPermissions(VOLUME, RANGER, actions, resourceSpecs, null, objToAclsMapRanger, objToActionsMapRanger); + final Set resultRanger = groupObjectsByAclsAndActions(objToAclsMapRanger, objToActionsMapRanger); assertThat(resultRanger).containsExactlyInAnyOrder( - new OzoneGrant(readVolumeAndBucket, acls(READ)), new OzoneGrant(writeKey, acls(WRITE))); + new OzoneGrant(readVolumeAndBucket, acls(READ), strSet("AbortMultipartUpload")), + new OzoneGrant(writeKey, acls(WRITE), strSet("AbortMultipartUpload"))); } @Test public void testCreatePathsAndPermissionsWithObjectPrefixResource() { - final Set actions = Collections.singleton(S3Action.GET_OBJECT); + final Set actions = Collections.singleton(GET_OBJECT); final Set resourceSpecs = Collections.singleton( new IamSessionPolicyResolver.ResourceSpec(S3ResourceType.OBJECT_PREFIX, "bucket1", "prefix/", null)); - final Set nativeReadObjects = objSet(prefix("bucket1", "prefix/"), bucket("bucket1"), volume()); + final Set nativeReadVolumeBucketAndPrefix = objSet( + bucket("bucket1"), volume(), prefix("bucket1", "prefix/")); final Map> objToAclsMapNative = new LinkedHashMap<>(); - createPathsAndPermissions(VOLUME, NATIVE, actions, resourceSpecs, null, objToAclsMapNative); - final Set resultNative = groupObjectsByAcls(objToAclsMapNative); - assertThat(resultNative).containsExactly(new OzoneGrant(nativeReadObjects, acls(READ))); + final Map> objToActionsMapNative = new LinkedHashMap<>(); + createPathsAndPermissions(VOLUME, NATIVE, actions, resourceSpecs, null, objToAclsMapNative, objToActionsMapNative); + final Set resultNative = groupObjectsByAclsAndActions(objToAclsMapNative, objToActionsMapNative); + assertThat(resultNative).containsExactlyInAnyOrder( + new OzoneGrant(nativeReadVolumeBucketAndPrefix, acls(READ), strSet("GetObject"))); expectIllegalArgumentException( - () -> createPathsAndPermissions(VOLUME, RANGER, actions, resourceSpecs, null, new LinkedHashMap<>()), + () -> createPathsAndPermissions( + VOLUME, RANGER, actions, resourceSpecs, null, new LinkedHashMap<>(), new LinkedHashMap<>()), "ResourceSpec type OBJECT_PREFIX not supported for RangerOzoneAuthorizer"); } @Test public void testCreatePathsAndPermissionsWithObjectPrefixWildcardResource() { - final Set actions = Collections.singleton(S3Action.GET_OBJECT); + final Set actions = Collections.singleton(GET_OBJECT); final Set resourceSpecs = Collections.singleton( new IamSessionPolicyResolver.ResourceSpec(S3ResourceType.OBJECT_PREFIX_WILDCARD, "bucket1", "prefix/*", null)); expectIllegalArgumentException( - () -> createPathsAndPermissions(VOLUME, NATIVE, actions, resourceSpecs, null, new LinkedHashMap<>()), + () -> createPathsAndPermissions( + VOLUME, NATIVE, actions, resourceSpecs, null, new LinkedHashMap<>(), new LinkedHashMap<>()), "ResourceSpec type OBJECT_PREFIX_WILDCARD not supported for OzoneNativeAuthorizer"); - final Set rangerReadObjects = objSet(key("bucket1", "prefix/*"), bucket("bucket1"), volume()); final Map> objToAclsMapRanger = new LinkedHashMap<>(); - createPathsAndPermissions(VOLUME, RANGER, actions, resourceSpecs, null, objToAclsMapRanger); - final Set resultRanger = groupObjectsByAcls(objToAclsMapRanger); - assertThat(resultRanger).containsExactly(new OzoneGrant(rangerReadObjects, acls(READ))); + final Map> objToActionsMapRanger = new LinkedHashMap<>(); + createPathsAndPermissions(VOLUME, RANGER, actions, resourceSpecs, null, objToAclsMapRanger, objToActionsMapRanger); + final Set resultRanger = groupObjectsByAclsAndActions(objToAclsMapRanger, objToActionsMapRanger); + assertThat(resultRanger).containsExactlyInAnyOrder( + new OzoneGrant( + objSet(bucket("bucket1"), volume(), key("bucket1", "prefix/*")), acls(READ), strSet("GetObject"))); } @Test public void testCreatePathsAndPermissionsWithConditionPrefixesForObjectActionMustIgnoreConditionPrefixes() { - final Set actions = Collections.singleton(S3Action.GET_OBJECT); + final Set actions = Collections.singleton(GET_OBJECT); final Set prefixes = strSet("folder1/", "folder2/"); final IamSessionPolicyResolver.Condition condition = new IamSessionPolicyResolver.Condition( "StringEquals", prefixes); @@ -912,23 +994,29 @@ public void testCreatePathsAndPermissionsWithConditionPrefixesForObjectActionMus final Set nativeResourceSpecs = Collections.singleton( new IamSessionPolicyResolver.ResourceSpec(S3ResourceType.OBJECT_PREFIX, "bucket1", "", null)); final Map> objToAclsMapNative = new LinkedHashMap<>(); - final Set nativeReadObjects = objSet(prefix("bucket1", ""), bucket("bucket1"), volume()); - createPathsAndPermissions(VOLUME, NATIVE, actions, nativeResourceSpecs, condition, objToAclsMapNative); - final Set resultNative = groupObjectsByAcls(objToAclsMapNative); - assertThat(resultNative).containsExactly(new OzoneGrant(nativeReadObjects, acls(READ))); + final Map> objToActionsMapNative = new LinkedHashMap<>(); + final Set nativeReadVolumeBucketAndPrefix = objSet(bucket("bucket1"), volume(), prefix("bucket1", "")); + createPathsAndPermissions( + VOLUME, NATIVE, actions, nativeResourceSpecs, condition, objToAclsMapNative, objToActionsMapNative); + final Set resultNative = groupObjectsByAclsAndActions(objToAclsMapNative, objToActionsMapNative); + assertThat(resultNative).containsExactlyInAnyOrder( + new OzoneGrant(nativeReadVolumeBucketAndPrefix, acls(READ), strSet("GetObject"))); final Set rangerResourceSpecs = Collections.singleton( new IamSessionPolicyResolver.ResourceSpec(S3ResourceType.OBJECT_PREFIX_WILDCARD, "bucket1", "*", null)); final Map> objToAclsMapRanger = new LinkedHashMap<>(); - final Set rangerReadObjects = objSet(key("bucket1", "*"), bucket("bucket1"), volume()); - createPathsAndPermissions(VOLUME, RANGER, actions, rangerResourceSpecs, condition, objToAclsMapRanger); - final Set resultRanger = groupObjectsByAcls(objToAclsMapRanger); - assertThat(resultRanger).containsExactly(new OzoneGrant(rangerReadObjects, acls(READ))); + final Map> objToActionsMapRanger = new LinkedHashMap<>(); + final Set rangerReadVolumeBucketAndKey = objSet(bucket("bucket1"), volume(), key("bucket1", "*")); + createPathsAndPermissions( + VOLUME, RANGER, actions, rangerResourceSpecs, condition, objToAclsMapRanger, objToActionsMapRanger); + final Set resultRanger = groupObjectsByAclsAndActions(objToAclsMapRanger, objToActionsMapRanger); + assertThat(resultRanger).containsExactlyInAnyOrder( + new OzoneGrant(rangerReadVolumeBucketAndKey, acls(READ), strSet("GetObject"))); } @Test public void testCreatePathsAndPermissionsWithConditionPrefixesForBucketActionWhenActionIsListBucket() { - final Set actions = Collections.singleton(S3Action.LIST_BUCKET); + final Set actions = Collections.singleton(LIST_BUCKET); final Set prefixes = strSet("folder1/", "folder2/"); final IamSessionPolicyResolver.Condition condition = new IamSessionPolicyResolver.Condition( "StringEquals", prefixes); @@ -939,10 +1027,13 @@ public void testCreatePathsAndPermissionsWithConditionPrefixesForBucketActionWhe prefix("bucket1", "folder1/"), prefix("bucket1", "folder2/"), volume()); final Set nativeReadAndListObject = objSet(bucket("bucket1")); final Map> objToAclsMapNative = new LinkedHashMap<>(); - createPathsAndPermissions(VOLUME, NATIVE, actions, nativeResourceSpecs, condition, objToAclsMapNative); - final Set resultNative = groupObjectsByAcls(objToAclsMapNative); + final Map> objToActionsMapNative = new LinkedHashMap<>(); + createPathsAndPermissions( + VOLUME, NATIVE, actions, nativeResourceSpecs, condition, objToAclsMapNative, objToActionsMapNative); + final Set resultNative = groupObjectsByAclsAndActions(objToAclsMapNative, objToActionsMapNative); assertThat(resultNative).containsExactlyInAnyOrder( - new OzoneGrant(nativeReadObjects, acls(READ)), new OzoneGrant(nativeReadAndListObject, acls(READ, LIST))); + new OzoneGrant(nativeReadAndListObject, acls(READ, LIST), strSet("ListBucket")), + new OzoneGrant(nativeReadObjects, acls(READ), strSet("ListBucket"))); final Set rangerResourceSpecs = Collections.singleton( new IamSessionPolicyResolver.ResourceSpec(S3ResourceType.BUCKET, "bucket1", null, null)); @@ -950,15 +1041,18 @@ public void testCreatePathsAndPermissionsWithConditionPrefixesForBucketActionWhe key("bucket1", "folder1/"), key("bucket1", "folder2/"), volume()); final Set rangerReadAndListObject = objSet(bucket("bucket1")); final Map> objToAclsMapRanger = new LinkedHashMap<>(); - createPathsAndPermissions(VOLUME, RANGER, actions, rangerResourceSpecs, condition, objToAclsMapRanger); - final Set resultRanger = groupObjectsByAcls(objToAclsMapRanger); + final Map> objToActionsMapRanger = new LinkedHashMap<>(); + createPathsAndPermissions( + VOLUME, RANGER, actions, rangerResourceSpecs, condition, objToAclsMapRanger, objToActionsMapRanger); + final Set resultRanger = groupObjectsByAclsAndActions(objToAclsMapRanger, objToActionsMapRanger); assertThat(resultRanger).containsExactlyInAnyOrder( - new OzoneGrant(rangerReadObjects, acls(READ)), new OzoneGrant(rangerReadAndListObject, acls(READ, LIST))); + new OzoneGrant(rangerReadAndListObject, acls(READ, LIST), strSet("ListBucket")), + new OzoneGrant(rangerReadObjects, acls(READ), strSet("ListBucket"))); } @Test public void testCreatePathsAndPermissionsWithConditionPrefixesForBucketActionWhenActionIsNotListBucket() { - final Set actions = Collections.singleton(S3Action.GET_BUCKET_ACL); + final Set actions = Collections.singleton(GET_BUCKET_ACL); final Set prefixes = strSet("folder1/", "folder2/"); final IamSessionPolicyResolver.Condition condition = new IamSessionPolicyResolver.Condition( "StringEquals", prefixes); @@ -968,18 +1062,24 @@ public void testCreatePathsAndPermissionsWithConditionPrefixesForBucketActionWhe final Set nativeResourceSpecs = Collections.singleton( new IamSessionPolicyResolver.ResourceSpec(S3ResourceType.BUCKET, "bucket1", null, null)); final Map> objToAclsMapNative = new LinkedHashMap<>(); - createPathsAndPermissions(VOLUME, NATIVE, actions, nativeResourceSpecs, condition, objToAclsMapNative); - final Set resultNative = groupObjectsByAcls(objToAclsMapNative); + final Map> objToActionsMapNative = new LinkedHashMap<>(); + createPathsAndPermissions( + VOLUME, NATIVE, actions, nativeResourceSpecs, condition, objToAclsMapNative, objToActionsMapNative); + final Set resultNative = groupObjectsByAclsAndActions(objToAclsMapNative, objToActionsMapNative); assertThat(resultNative).containsExactlyInAnyOrder( - new OzoneGrant(readObject, acls(READ)), new OzoneGrant(readAndReadAclObject, acls(READ, READ_ACL))); + new OzoneGrant(readObject, acls(READ), strSet("GetBucketAcl")), + new OzoneGrant(readAndReadAclObject, acls(READ, READ_ACL), strSet("GetBucketAcl"))); final Set rangerResourceSpecs = Collections.singleton( new IamSessionPolicyResolver.ResourceSpec(S3ResourceType.BUCKET, "bucket1", null, null)); final Map> objToAclsMapRanger = new LinkedHashMap<>(); - createPathsAndPermissions(VOLUME, RANGER, actions, rangerResourceSpecs, condition, objToAclsMapRanger); - final Set resultRanger = groupObjectsByAcls(objToAclsMapRanger); + final Map> objToActionsMapRanger = new LinkedHashMap<>(); + createPathsAndPermissions( + VOLUME, RANGER, actions, rangerResourceSpecs, condition, objToAclsMapRanger, objToActionsMapRanger); + final Set resultRanger = groupObjectsByAclsAndActions(objToAclsMapRanger, objToActionsMapRanger); assertThat(resultRanger).containsExactlyInAnyOrder( - new OzoneGrant(readObject, acls(READ)), new OzoneGrant(readAndReadAclObject, acls(READ, READ_ACL))); + new OzoneGrant(readObject, acls(READ), strSet("GetBucketAcl")), + new OzoneGrant(readAndReadAclObject, acls(READ, READ_ACL), strSet("GetBucketAcl"))); } @Test @@ -989,38 +1089,45 @@ public void testCreatePathsAndPermissionsWithNoMappedActions() { final Set nativeResourceSpecs = Collections.singleton( new IamSessionPolicyResolver.ResourceSpec(S3ResourceType.OBJECT_PREFIX, "bucket1", null, null)); final Map> objToAclsMapNative = new LinkedHashMap<>(); - createPathsAndPermissions(VOLUME, NATIVE, actions, nativeResourceSpecs, null, objToAclsMapNative); - final Set resultNative = groupObjectsByAcls(objToAclsMapNative); + final Map> objToActionsMapNative = new LinkedHashMap<>(); + createPathsAndPermissions( + VOLUME, NATIVE, actions, nativeResourceSpecs, null, objToAclsMapNative, objToActionsMapNative); + final Set resultNative = groupObjectsByAclsAndActions(objToAclsMapNative, objToActionsMapNative); assertThat(resultNative).isEmpty(); final Set rangerResourceSpecs = Collections.singleton( new IamSessionPolicyResolver.ResourceSpec(S3ResourceType.OBJECT_PREFIX_WILDCARD, "bucket1", null, null)); final Map> objToAclsMapRanger = new LinkedHashMap<>(); - createPathsAndPermissions(VOLUME, RANGER, actions, rangerResourceSpecs, null, objToAclsMapRanger); - final Set resultRanger = groupObjectsByAcls(objToAclsMapRanger); + final Map> objToActionsMapRanger = new LinkedHashMap<>(); + createPathsAndPermissions( + VOLUME, RANGER, actions, rangerResourceSpecs, null, objToAclsMapRanger, objToActionsMapRanger); + final Set resultRanger = groupObjectsByAclsAndActions(objToAclsMapRanger, objToActionsMapRanger); assertThat(resultRanger).isEmpty(); } @Test public void testCreatePathsAndPermissionsWithNoMappedResources() { - final Set actions = Collections.singleton(S3Action.GET_OBJECT); + final Set actions = Collections.singleton(GET_OBJECT); final Set resourceSpecs = emptySet(); final Map> objToAclsMapNative = new LinkedHashMap<>(); - createPathsAndPermissions(VOLUME, NATIVE, actions, resourceSpecs, null, objToAclsMapNative); - final Set resultNative = groupObjectsByAcls(objToAclsMapNative); + final Map> objToActionsMapNative = new LinkedHashMap<>(); + createPathsAndPermissions( + VOLUME, NATIVE, actions, resourceSpecs, null, objToAclsMapNative, objToActionsMapNative); + final Set resultNative = groupObjectsByAclsAndActions(objToAclsMapNative, objToActionsMapNative); assertThat(resultNative).isEmpty(); final Map> objToAclsMapRanger = new LinkedHashMap<>(); - createPathsAndPermissions(VOLUME, RANGER, actions, resourceSpecs, null, objToAclsMapRanger); - final Set resultRanger = groupObjectsByAcls(objToAclsMapRanger); + final Map> objToActionsMapRanger = new LinkedHashMap<>(); + createPathsAndPermissions( + VOLUME, RANGER, actions, resourceSpecs, null, objToAclsMapRanger, objToActionsMapRanger); + final Set resultRanger = groupObjectsByAclsAndActions(objToAclsMapRanger, objToActionsMapRanger); assertThat(resultRanger).isEmpty(); } @Test public void testCreatePathsAndPermissionsDeduplicatesAcrossSameResourceTypes() { - final Set actions = Stream.of( - S3Action.GET_OBJECT, S3Action.GET_OBJECT_TAGGING, S3Action.DELETE_OBJECT, S3Action.DELETE_OBJECT_TAGGING) + final Set actions = Stream.of(GET_OBJECT, GET_OBJECT_TAGGING, DELETE_OBJECT, DELETE_OBJECT_TAGGING) .collect(Collectors.toSet()); final Set resourceSpecs = Collections.singleton( new IamSessionPolicyResolver.ResourceSpec(S3ResourceType.OBJECT_EXACT, "bucket1", null, "key.txt")); @@ -1028,44 +1135,60 @@ public void testCreatePathsAndPermissionsDeduplicatesAcrossSameResourceTypes() { final Set readObjects = objSet(bucket("bucket1"), volume()); final Map> objToAclsMapNative = new LinkedHashMap<>(); - createPathsAndPermissions(VOLUME, NATIVE, actions, resourceSpecs, null, objToAclsMapNative); - final Set resultNative = groupObjectsByAcls(objToAclsMapNative); + final Map> objToActionsMapNative = new LinkedHashMap<>(); + createPathsAndPermissions(VOLUME, NATIVE, actions, resourceSpecs, null, objToAclsMapNative, objToActionsMapNative); + final Set resultNative = groupObjectsByAclsAndActions(objToAclsMapNative, objToActionsMapNative); assertThat(resultNative).containsExactlyInAnyOrder( - new OzoneGrant(readAndDeleteAndWriteObject, acls(READ, DELETE, WRITE)), - new OzoneGrant(readObjects, acls(READ))); + new OzoneGrant( + readAndDeleteAndWriteObject, acls(READ, DELETE, WRITE), + strSet("GetObject", "GetObjectTagging", "DeleteObject", "DeleteObjectTagging")), + new OzoneGrant(readObjects, acls(READ), + strSet("GetObject", "GetObjectTagging", "DeleteObject", "DeleteObjectTagging"))); final Map> objToAclsMapRanger = new LinkedHashMap<>(); - createPathsAndPermissions(VOLUME, RANGER, actions, resourceSpecs, null, objToAclsMapRanger); - final Set resultRanger = groupObjectsByAcls(objToAclsMapRanger); + final Map> objToActionsMapRanger = new LinkedHashMap<>(); + createPathsAndPermissions(VOLUME, RANGER, actions, resourceSpecs, null, objToAclsMapRanger, objToActionsMapRanger); + final Set resultRanger = groupObjectsByAclsAndActions(objToAclsMapRanger, objToActionsMapRanger); assertThat(resultRanger).containsExactlyInAnyOrder( - new OzoneGrant(readAndDeleteAndWriteObject, acls(READ, DELETE, WRITE)), - new OzoneGrant(readObjects, acls(READ))); + new OzoneGrant( + readAndDeleteAndWriteObject, acls(READ, DELETE, WRITE), + strSet("GetObject", "GetObjectTagging", "DeleteObject", "DeleteObjectTagging")), + new OzoneGrant(readObjects, acls(READ), + strSet("GetObject", "GetObjectTagging", "DeleteObject", "DeleteObjectTagging"))); } @Test - public void testCreatePathsAndPermissionsWithAllS3ActionsOverridesAnyOtherAction() { - final Set actions = Stream.of( - S3Action.ALL_S3, S3Action.GET_OBJECT, S3Action.DELETE_OBJECT, S3Action.LIST_BUCKET) + public void testCreatePathsAndPermissionsWithAllConcreteS3Actions() { + final Set actions = Stream.of(S3Action.values()) .collect(Collectors.toSet()); final Set resourceSpecs = Stream.of( new IamSessionPolicyResolver.ResourceSpec(S3ResourceType.OBJECT_EXACT, "bucket1", null, "key.txt"), new IamSessionPolicyResolver.ResourceSpec(S3ResourceType.BUCKET, "bucket2", null, null)) .collect(Collectors.toSet()); - final Set allObjects = objSet(key("bucket1", "key.txt"), bucket("bucket2")); - - final Set nativeReadObjects = objSet(volume(), bucket("bucket1"), prefix("bucket2", "")); final Map> objToAclsMapNative = new LinkedHashMap<>(); - createPathsAndPermissions(VOLUME, NATIVE, actions, resourceSpecs, null, objToAclsMapNative); - final Set resultNative = groupObjectsByAcls(objToAclsMapNative); + final Map> objToActionsMapNative = new LinkedHashMap<>(); + createPathsAndPermissions(VOLUME, NATIVE, actions, resourceSpecs, null, objToAclsMapNative, objToActionsMapNative); + final Set resultNative = groupObjectsByAclsAndActions(objToAclsMapNative, objToActionsMapNative); assertThat(resultNative).containsExactlyInAnyOrder( - new OzoneGrant(allObjects, acls(ALL)), new OzoneGrant(nativeReadObjects, acls(READ))); + new OzoneGrant(objSet(key("bucket1", "key.txt")), acls(READ, CREATE, WRITE, DELETE), ALL_OBJECT_ACTIONS), + new OzoneGrant(objSet(bucket("bucket1")), acls(READ), ALL_OBJECT_ACTIONS), + new OzoneGrant(objSet(volume()), acls(READ), ALL_BUCKET_AND_OBJECT_ACTIONS), + new OzoneGrant( + objSet(bucket("bucket2")), acls(READ, LIST, CREATE, DELETE, READ_ACL, WRITE_ACL), ALL_BUCKET_ACTIONS), + new OzoneGrant(objSet(prefix("bucket2", "")), acls(READ), strSet("ListBucket"))); - final Set rangerReadObjects = objSet(volume(), bucket("bucket1"), key("bucket2", "*")); final Map> objToAclsMapRanger = new LinkedHashMap<>(); - createPathsAndPermissions(VOLUME, RANGER, actions, resourceSpecs, null, objToAclsMapRanger); - final Set resultRanger = groupObjectsByAcls(objToAclsMapRanger); + final Map> objToActionsMapRanger = new LinkedHashMap<>(); + createPathsAndPermissions(VOLUME, RANGER, actions, resourceSpecs, null, objToAclsMapRanger, objToActionsMapRanger); + final Set resultRanger = groupObjectsByAclsAndActions(objToAclsMapRanger, objToActionsMapRanger); assertThat(resultRanger).containsExactlyInAnyOrder( - new OzoneGrant(allObjects, acls(ALL)), new OzoneGrant(rangerReadObjects, acls(READ))); + new OzoneGrant(objSet(key("bucket1", "key.txt")), acls(READ, CREATE, WRITE, DELETE), ALL_OBJECT_ACTIONS), + new OzoneGrant(objSet(bucket("bucket1")), acls(READ), ALL_OBJECT_ACTIONS), + new OzoneGrant(objSet(volume()), acls(READ), ALL_BUCKET_AND_OBJECT_ACTIONS), + new OzoneGrant( + objSet(bucket("bucket2")), acls(READ, LIST, CREATE, DELETE, READ_ACL, WRITE_ACL), ALL_BUCKET_ACTIONS), + new OzoneGrant(objSet(key("bucket2", "*")), acls(READ), strSet("ListBucket"))); + } @Test @@ -1102,14 +1225,21 @@ public void testDeduplicatesAcrossMultipleStatementsWhenSameStatementsArePresent // Expected for native: bucket READ, LIST, READ_ACL, WRITE_ACL; volume and prefix "" READ final Set bucketSet = objSet(bucket("my-bucket")); final Set bucketAcls = acls(READ, LIST, READ_ACL, WRITE_ACL); - expectedResolvedNative.add(new OzoneGrant(bucketSet, bucketAcls)); - expectedResolvedNative.add(new OzoneGrant(objSet(volume(), prefix("my-bucket", "")), acls(READ))); + expectedResolvedNative.add( + new OzoneGrant(bucketSet, bucketAcls, strSet("GetBucketAcl", "PutBucketAcl", "ListBucket"))); + expectedResolvedNative.add(new OzoneGrant(objSet(prefix("my-bucket", "")), acls(READ), strSet("ListBucket"))); + expectedResolvedNative.add( + new OzoneGrant(objSet(volume()), acls(READ), strSet("GetBucketAcl", "PutBucketAcl", "ListBucket"))); assertThat(resolvedFromNativeAuthorizer).isEqualTo(expectedResolvedNative); final Set expectedResolvedRanger = new LinkedHashSet<>(); - // Expected for Ranger: bucket READ, LIST, READ_ACL, WRITE_ACL; volume and key "*" READ - expectedResolvedRanger.add(new OzoneGrant(bucketSet, bucketAcls)); - expectedResolvedRanger.add(new OzoneGrant(objSet(volume(), key("my-bucket", "*")), acls(READ))); + // Expected for Ranger: bucket READ, LIST, READ_ACL, WRITE_ACL; volume and key "*" READ + expectedResolvedRanger.add( + new OzoneGrant(bucketSet, bucketAcls, strSet("GetBucketAcl", "PutBucketAcl", "ListBucket"))); + expectedResolvedRanger.add( + new OzoneGrant( + objSet(volume()), acls(READ), strSet("GetBucketAcl", "PutBucketAcl", "ListBucket"))); + expectedResolvedRanger.add(new OzoneGrant(objSet(key("my-bucket", "*")), acls(READ), strSet("ListBucket"))); assertThat(resolvedFromRangerAuthorizer).isEqualTo(expectedResolvedRanger); } @@ -1147,16 +1277,22 @@ public void testDeduplicatesAcrossMultipleStatementsForSameActionsButDifferentRe // Expected for native: bucket READ, LIST, READ_ACL, WRITE_ACL; volume and prefix "" READ final Set bucketSet = objSet(bucket("my-bucket"), bucket("my-bucket2")); final Set bucketAcls = acls(READ, LIST, READ_ACL, WRITE_ACL); - expectedResolvedNative.add(new OzoneGrant(bucketSet, bucketAcls)); + expectedResolvedNative.add( + new OzoneGrant(bucketSet, bucketAcls, strSet("GetBucketAcl", "PutBucketAcl", "ListBucket"))); expectedResolvedNative.add(new OzoneGrant( - objSet(volume(), prefix("my-bucket2", ""), prefix("my-bucket", "")), acls(READ))); + objSet(prefix("my-bucket2", ""), prefix("my-bucket", "")), acls(READ), strSet("ListBucket"))); + expectedResolvedNative.add( + new OzoneGrant(objSet(volume()), acls(READ), strSet("GetBucketAcl", "PutBucketAcl", "ListBucket"))); assertThat(resolvedFromNativeAuthorizer).isEqualTo(expectedResolvedNative); final Set expectedResolvedRanger = new LinkedHashSet<>(); // Expected for Ranger: bucket READ, LIST, READ_ACL, WRITE_ACL; volume and key "*" READ - expectedResolvedRanger.add(new OzoneGrant(bucketSet, bucketAcls)); - expectedResolvedRanger.add(new OzoneGrant( - objSet(volume(), key("my-bucket2", "*"), key("my-bucket", "*")), acls(READ))); + expectedResolvedRanger.add( + new OzoneGrant(bucketSet, bucketAcls, strSet("GetBucketAcl", "PutBucketAcl", "ListBucket"))); + expectedResolvedRanger.add( + new OzoneGrant(objSet(key("my-bucket2", "*"), key("my-bucket", "*")), acls(READ), strSet("ListBucket"))); + expectedResolvedRanger.add( + new OzoneGrant(objSet(volume()), acls(READ), strSet("GetBucketAcl", "PutBucketAcl", "ListBucket"))); assertThat(resolvedFromRangerAuthorizer).isEqualTo(expectedResolvedRanger); } @@ -1193,14 +1329,22 @@ public void testDeduplicatesAcrossMultipleStatementsForDifferentActionsButSameRe // Expected for native: bucket READ, LIST, READ_ACL, WRITE_ACL, CREATE; volume, prefix "" READ final Set bucketSet = objSet(bucket("my-bucket")); final Set bucketAcls = acls(READ, LIST, READ_ACL, WRITE_ACL, CREATE); - expectedResolvedNative.add(new OzoneGrant(bucketSet, bucketAcls)); - expectedResolvedNative.add(new OzoneGrant(objSet(volume(), prefix("my-bucket", "")), acls(READ))); + expectedResolvedNative.add( + new OzoneGrant(bucketSet, bucketAcls, strSet("GetBucketAcl", "PutBucketAcl", "ListBucket", "CreateBucket"))); + expectedResolvedNative.add(new OzoneGrant(objSet(prefix("my-bucket", "")), acls(READ), strSet("ListBucket"))); + expectedResolvedNative.add( + new OzoneGrant( + objSet(volume()), acls(READ), strSet("GetBucketAcl", "PutBucketAcl", "ListBucket", "CreateBucket"))); assertThat(resolvedFromNativeAuthorizer).isEqualTo(expectedResolvedNative); final Set expectedResolvedRanger = new LinkedHashSet<>(); // Expected for Ranger: bucket READ, LIST, READ_ACL, WRITE_ACL, CREATE; volume, key "*" READ - expectedResolvedRanger.add(new OzoneGrant(bucketSet, bucketAcls)); - expectedResolvedRanger.add(new OzoneGrant(objSet(volume(), key("my-bucket", "*")), acls(READ))); + expectedResolvedRanger.add( + new OzoneGrant(bucketSet, bucketAcls, strSet("GetBucketAcl", "PutBucketAcl", "ListBucket", "CreateBucket"))); + expectedResolvedRanger.add(new OzoneGrant(objSet(key("my-bucket", "*")), acls(READ), strSet("ListBucket"))); + expectedResolvedRanger.add( + new OzoneGrant( + objSet(volume()), acls(READ), strSet("GetBucketAcl", "PutBucketAcl", "ListBucket", "CreateBucket"))); assertThat(resolvedFromRangerAuthorizer).isEqualTo(expectedResolvedRanger); } @@ -1231,17 +1375,19 @@ public void testDeduplicatesAcrossMultipleStatementsWhenAllActionPresent() throw // Ensure what we got is what we expected final Set expectedResolvedNative = new LinkedHashSet<>(); - // Expected for native: bucket ALL (instead of individual actions); volume and prefix "" READ + // Expected for native: bucket union of supported bucket ACLs; volume READ; prefix "" READ (from ListBucket) final Set bucketSet = objSet(bucket("my-bucket")); - final Set bucketAcls = acls(ALL); - expectedResolvedNative.add(new OzoneGrant(bucketSet, bucketAcls)); - expectedResolvedNative.add(new OzoneGrant(objSet(volume(), prefix("my-bucket", "")), acls(READ))); + final Set bucketAcls = acls(READ, LIST, CREATE, DELETE, READ_ACL, WRITE_ACL); + expectedResolvedNative.add(new OzoneGrant(bucketSet, bucketAcls, ALL_BUCKET_ACTIONS)); + expectedResolvedNative.add(new OzoneGrant(objSet(prefix("my-bucket", "")), acls(READ), strSet("ListBucket"))); + expectedResolvedNative.add(new OzoneGrant(objSet(volume()), acls(READ), ALL_BUCKET_ACTIONS)); assertThat(resolvedFromNativeAuthorizer).isEqualTo(expectedResolvedNative); final Set expectedResolvedRanger = new LinkedHashSet<>(); - // Expected for Ranger: bucket ALL (instead of individual actions); volume and key "*" READ - expectedResolvedRanger.add(new OzoneGrant(bucketSet, bucketAcls)); - expectedResolvedRanger.add(new OzoneGrant(objSet(volume(), key("my-bucket", "*")), acls(READ))); + // Expected for Ranger: bucket union of supported bucket ACLs; volume READ; key "*" READ (from ListBucket) + expectedResolvedRanger.add(new OzoneGrant(bucketSet, bucketAcls, ALL_BUCKET_ACTIONS)); + expectedResolvedRanger.add(new OzoneGrant(objSet(key("my-bucket", "*")), acls(READ), strSet("ListBucket"))); + expectedResolvedRanger.add(new OzoneGrant(objSet(volume()), acls(READ), ALL_BUCKET_ACTIONS)); assertThat(resolvedFromRangerAuthorizer).isEqualTo(expectedResolvedRanger); } @@ -1264,8 +1410,10 @@ public void testAllowGetPutOnKey() throws OMException { // Expected: READ, CREATE, WRITE on key; bucket READ; volume READ final Set keySet = objSet(key("my-bucket", "folder/file.txt")); final Set keyAcls = acls(READ, CREATE, WRITE); - expectedResolvedFromBothAuthorizers.add(new OzoneGrant(objSet(volume(), bucket("my-bucket")), acls(READ))); - expectedResolvedFromBothAuthorizers.add(new OzoneGrant(keySet, keyAcls)); + expectedResolvedFromBothAuthorizers.add( + new OzoneGrant(objSet(volume(), bucket("my-bucket")), acls(READ), strSet("GetObject", "PutObject"))); + expectedResolvedFromBothAuthorizers.add( + new OzoneGrant(keySet, keyAcls, strSet("GetObject", "PutObject"))); assertThat(resolvedFromNativeAuthorizer).isEqualTo(expectedResolvedFromBothAuthorizers); assertThat(resolvedFromRangerAuthorizer).isEqualTo(expectedResolvedFromBothAuthorizers); @@ -1286,18 +1434,18 @@ public void testAllActionsForKey() throws OMException { // Ensure what we got is what we expected final Set expectedResolvedNative = new LinkedHashSet<>(); - // Expected for native: all key ACLs on prefix "" under bucket; bucket READ, volume READ + // Expected for native: all supported object ACLs on prefix "" under bucket; bucket READ, volume READ final Set keyPrefixSet = objSet(prefix("my-bucket", "")); - final Set allKeyAcls = acls(ALL); - expectedResolvedNative.add(new OzoneGrant(keyPrefixSet, allKeyAcls)); - expectedResolvedNative.add(new OzoneGrant(objSet(volume(), bucket("my-bucket")), acls(READ))); + final Set allKeyAcls = acls(READ, CREATE, WRITE, DELETE); + expectedResolvedNative.add(new OzoneGrant(keyPrefixSet, allKeyAcls, ALL_OBJECT_ACTIONS)); + expectedResolvedNative.add(new OzoneGrant(objSet(volume(), bucket("my-bucket")), acls(READ), ALL_OBJECT_ACTIONS)); assertThat(resolvedFromNativeAuthorizer).isEqualTo(expectedResolvedNative); - // Expected for Ranger: all key acls for resource type KEY with key name "*" + // Expected for Ranger: all supported object ACLs for resource type KEY with key name "*" final Set expectedResolvedRanger = new LinkedHashSet<>(); final Set rangerKeySet = objSet(key("my-bucket", "*")); - expectedResolvedRanger.add(new OzoneGrant(rangerKeySet, allKeyAcls)); - expectedResolvedRanger.add(new OzoneGrant(objSet(volume(), bucket("my-bucket")), acls(READ))); + expectedResolvedRanger.add(new OzoneGrant(rangerKeySet, allKeyAcls, ALL_OBJECT_ACTIONS)); + expectedResolvedRanger.add(new OzoneGrant(objSet(volume(), bucket("my-bucket")), acls(READ), ALL_OBJECT_ACTIONS)); assertThat(resolvedFromRangerAuthorizer).isEqualTo(expectedResolvedRanger); } @@ -1340,17 +1488,19 @@ public void testAllActionsForBucket() throws OMException { // Ensure what we got is what we expected final Set expectedResolvedNative = new LinkedHashSet<>(); - // Expected for native: all Bucket ACLs for bucket; volume, prefix "" READ + // Expected for native: union of supported bucket ACLs for bucket; volume READ; prefix "" READ (from ListBucket) final Set bucketSet = objSet(bucket("my-bucket")); - final Set allBucketAcls = acls(ALL); - expectedResolvedNative.add(new OzoneGrant(objSet(volume(), prefix("my-bucket", "")), acls(READ))); - expectedResolvedNative.add(new OzoneGrant(bucketSet, allBucketAcls)); + final Set allBucketAcls = acls(READ, LIST, CREATE, DELETE, READ_ACL, WRITE_ACL); + expectedResolvedNative.add(new OzoneGrant(bucketSet, allBucketAcls, ALL_BUCKET_ACTIONS)); + expectedResolvedNative.add(new OzoneGrant(objSet(prefix("my-bucket", "")), acls(READ), strSet("ListBucket"))); + expectedResolvedNative.add(new OzoneGrant(objSet(volume()), acls(READ), ALL_BUCKET_ACTIONS)); assertThat(resolvedFromNativeAuthorizer).isEqualTo(expectedResolvedNative); - // Expected for Ranger: all Bucket ACLs for bucket; volume, key "*" READ + // Expected for Ranger: union of supported bucket ACLs for bucket; volume READ; key "*" READ (from ListBucket) final Set expectedResolvedRanger = new LinkedHashSet<>(); - expectedResolvedRanger.add(new OzoneGrant(objSet(volume(), key("my-bucket", "*")), acls(READ))); - expectedResolvedRanger.add(new OzoneGrant(bucketSet, allBucketAcls)); + expectedResolvedRanger.add(new OzoneGrant(bucketSet, allBucketAcls, ALL_BUCKET_ACTIONS)); + expectedResolvedRanger.add(new OzoneGrant(objSet(key("my-bucket", "*")), acls(READ), strSet("ListBucket"))); + expectedResolvedRanger.add(new OzoneGrant(objSet(volume()), acls(READ), ALL_BUCKET_ACTIONS)); assertThat(resolvedFromRangerAuthorizer).isEqualTo(expectedResolvedRanger); } @@ -1379,18 +1529,20 @@ public void testAllActionsForBucketWithPrefixCondition() throws OMException { final Set bucketSet = objSet(bucket("my-bucket")); final Set bucketAcls = acls(READ, LIST); expectedResolvedNative.add( - new OzoneGrant(objSet(volume(), prefix("my-bucket", "team/folder"), prefix("my-bucket", "team/folder/")), - acls(READ))); - expectedResolvedNative.add(new OzoneGrant(bucketSet, bucketAcls)); + new OzoneGrant( + objSet(volume(), prefix("my-bucket", "team/folder"), + prefix("my-bucket", "team/folder/")), acls(READ), strSet("ListBucket"))); + expectedResolvedNative.add(new OzoneGrant(bucketSet, bucketAcls, strSet("ListBucket"))); assertThat(resolvedFromNativeAuthorizer).isEqualTo(expectedResolvedNative); // Expected for Ranger: READ, LIST ACLs for bucket (only ListBucket supports s3:prefix); volume READ, // key "team/folder", "team/folder/*" READ final Set expectedResolvedRanger = new LinkedHashSet<>(); expectedResolvedRanger.add( - new OzoneGrant(objSet(volume(), key("my-bucket", "team/folder"), key("my-bucket", "team/folder/*")), - acls(READ))); - expectedResolvedRanger.add(new OzoneGrant(bucketSet, bucketAcls)); + new OzoneGrant( + objSet(volume(), key("my-bucket", "team/folder"), key("my-bucket", "team/folder/*")), + acls(READ), strSet("ListBucket"))); + expectedResolvedRanger.add(new OzoneGrant(bucketSet, bucketAcls, strSet("ListBucket"))); assertThat(resolvedFromRangerAuthorizer).isEqualTo(expectedResolvedRanger); } @@ -1421,24 +1573,27 @@ public void testMultipleResourcesInSeparateStatements() throws OMException { // Ensure what we got is what we expected final Set expectedResolvedNative = new LinkedHashSet<>(); - // Expected for native: bucket READ, LIST, READ_ACL, WRITE_ACL; volume READ + // Expected for native: bucket READ, LIST, READ_ACL, WRITE_ACL; volume READ; prefix "" has all supported object + // ACLs and is further restricted to the object actions + ListBucket. final Set bucketSet = objSet(bucket("my-bucket")); final Set bucketAcls = acls(READ, LIST, READ_ACL, WRITE_ACL); - expectedResolvedNative.add(new OzoneGrant(bucketSet, bucketAcls)); - expectedResolvedNative.add(new OzoneGrant(objSet(volume()), acls(READ))); - // Expected for native: all key ACLs on prefix "" under bucket + final Set bucketAndObjectActions = new LinkedHashSet<>( + strSet("GetBucketAcl", "PutBucketAcl", "ListBucket")); + bucketAndObjectActions.addAll(ALL_OBJECT_ACTIONS); + expectedResolvedNative.add(new OzoneGrant(bucketSet, bucketAcls, bucketAndObjectActions)); + expectedResolvedNative.add(new OzoneGrant(objSet(volume()), acls(READ), bucketAndObjectActions)); final Set keyPrefixSet = objSet(prefix("my-bucket", "")); - final Set keyAllAcls = acls(ALL); - expectedResolvedNative.add(new OzoneGrant(keyPrefixSet, keyAllAcls)); + final Set allKeyAcls = acls(READ, CREATE, WRITE, DELETE); + expectedResolvedNative.add(new OzoneGrant(keyPrefixSet, allKeyAcls, ALL_OBJECT_ACTIONS_WITH_LIST_BUCKET)); assertThat(resolvedFromNativeAuthorizer).isEqualTo(expectedResolvedNative); final Set expectedResolvedRanger = new LinkedHashSet<>(); - // Expected for Ranger: bucket READ, LIST, READ_ACL, WRITE_ACL; volume READ - expectedResolvedRanger.add(new OzoneGrant(bucketSet, bucketAcls)); - expectedResolvedRanger.add(new OzoneGrant(objSet(volume()), acls(READ))); - // Expected for Ranger: all key acls for resource type KEY with key name "*" + // Expected for Ranger: bucket READ, LIST, READ_ACL, WRITE_ACL; volume READ; key "*" has all supported object ACLs + // and is further restricted to the object actions + ListBucket. + expectedResolvedRanger.add(new OzoneGrant(bucketSet, bucketAcls, bucketAndObjectActions)); + expectedResolvedRanger.add(new OzoneGrant(objSet(volume()), acls(READ), bucketAndObjectActions)); final Set rangerKeySet = objSet(key("my-bucket", "*")); - expectedResolvedRanger.add(new OzoneGrant(rangerKeySet, keyAllAcls)); + expectedResolvedRanger.add(new OzoneGrant(rangerKeySet, allKeyAcls, ALL_OBJECT_ACTIONS_WITH_LIST_BUCKET)); assertThat(resolvedFromRangerAuthorizer).isEqualTo(expectedResolvedRanger); } @@ -1465,17 +1620,26 @@ public void testMultipleResourcesInOneStatement() throws OMException { // Ensure what we got is what we expected final Set expectedResolvedNative = new LinkedHashSet<>(); - // Expected for native: all for bucket and key acls; volume READ - final Set resourceSetNative = objSet(bucket("my-bucket"), prefix("my-bucket", "")); - expectedResolvedNative.add(new OzoneGrant(resourceSetNative, acls(ALL))); - expectedResolvedNative.add(new OzoneGrant(objSet(volume()), acls(READ))); + // Expected for native: bucket union of supported bucket ACLs; prefix "" union of supported object ACLs; volume READ + final Set bucketSet = objSet(bucket("my-bucket")); + expectedResolvedNative.add( + new OzoneGrant( + bucketSet, acls(READ, LIST, CREATE, DELETE, READ_ACL, WRITE_ACL), ALL_BUCKET_AND_OBJECT_ACTIONS)); + expectedResolvedNative.add( + new OzoneGrant( + objSet(prefix("my-bucket", "")), acls(READ, CREATE, WRITE, DELETE), ALL_OBJECT_ACTIONS_WITH_LIST_BUCKET)); + expectedResolvedNative.add(new OzoneGrant(objSet(volume()), acls(READ), ALL_BUCKET_AND_OBJECT_ACTIONS)); assertThat(resolvedFromNativeAuthorizer).isEqualTo(expectedResolvedNative); final Set expectedResolvedRanger = new LinkedHashSet<>(); - // Expected for Ranger: all for bucket and key acls; volume READ - final Set resourceSetRanger = objSet(bucket("my-bucket"), key("my-bucket", "*")); - expectedResolvedRanger.add(new OzoneGrant(resourceSetRanger, acls(ALL))); - expectedResolvedRanger.add(new OzoneGrant(objSet(volume()), acls(READ))); + // Expected for Ranger: bucket union of supported bucket ACLs; key "*" union of supported object ACLs; volume READ + expectedResolvedRanger.add( + new OzoneGrant( + bucketSet, acls(READ, LIST, CREATE, DELETE, READ_ACL, WRITE_ACL), ALL_BUCKET_AND_OBJECT_ACTIONS)); + expectedResolvedRanger.add( + new OzoneGrant( + objSet(key("my-bucket", "*")), acls(READ, CREATE, WRITE, DELETE), ALL_OBJECT_ACTIONS_WITH_LIST_BUCKET)); + expectedResolvedRanger.add(new OzoneGrant(objSet(volume()), acls(READ), ALL_BUCKET_AND_OBJECT_ACTIONS)); assertThat(resolvedFromRangerAuthorizer).isEqualTo(expectedResolvedRanger); } @@ -1502,22 +1666,22 @@ public void testMultipleResourcesWithDifferentBucketsAndDeepPathsInOneStatement( // Ensure what we got is what we expected final Set expectedResolvedNative = new LinkedHashSet<>(); - // Expected for native: all key ACLs on prefix "team/folder1/security/" under - // my-bucket and all key ACLs on prefix "team/folder2/misc/" under my-bucket2; bucket READ; volume READ + // Expected for native: all supported object ACLs on both prefixes; bucket READ; volume READ final Set keyPrefixSet = objSet( prefix("my-bucket", "team/folder1/security/"), prefix("my-bucket2", "team/folder2/misc/")); - final Set keyAllAcls = acls(ALL); - expectedResolvedNative.add(new OzoneGrant(keyPrefixSet, keyAllAcls)); - expectedResolvedNative.add(new OzoneGrant(objSet(volume(), bucket("my-bucket"), bucket("my-bucket2")), acls(READ))); + expectedResolvedNative.add(new OzoneGrant(keyPrefixSet, acls(READ, CREATE, WRITE, DELETE), ALL_OBJECT_ACTIONS)); + expectedResolvedNative.add( + new OzoneGrant(objSet(volume(), bucket("my-bucket"), bucket("my-bucket2")), acls(READ), ALL_OBJECT_ACTIONS)); assertThat(resolvedFromNativeAuthorizer).isEqualTo(expectedResolvedNative); final Set expectedResolvedRanger = new LinkedHashSet<>(); - // Expected for Ranger: all key acls for resource type KEY with key name + // Expected for Ranger: all supported object ACLs for resource type KEY with key name // "team/folder1/security/*" under my-bucket and "team/folder2/misc/*" under my-bucket2; bucket READ; volume READ final Set rangerKeySet = objSet( key("my-bucket", "team/folder1/security/*"), key("my-bucket2", "team/folder2/misc/*")); - expectedResolvedRanger.add(new OzoneGrant(rangerKeySet, keyAllAcls)); - expectedResolvedRanger.add(new OzoneGrant(objSet(volume(), bucket("my-bucket"), bucket("my-bucket2")), acls(READ))); + expectedResolvedRanger.add(new OzoneGrant(rangerKeySet, acls(READ, CREATE, WRITE, DELETE), ALL_OBJECT_ACTIONS)); + expectedResolvedRanger.add( + new OzoneGrant(objSet(volume(), bucket("my-bucket"), bucket("my-bucket2")), acls(READ), ALL_OBJECT_ACTIONS)); assertThat(resolvedFromRangerAuthorizer).isEqualTo(expectedResolvedRanger); } @@ -1573,8 +1737,8 @@ public void testListBucketWithWildcard() throws OMException { // Expected for Ranger: bucket READ and LIST on wildcard pattern; volume and key "*" READ final Set bucketSet = objSet(bucket("proj-*")); final Set bucketAcls = acls(READ, LIST); - expectedResolvedRanger.add(new OzoneGrant(bucketSet, bucketAcls)); - expectedResolvedRanger.add(new OzoneGrant(objSet(volume(), key("proj-*", "*")), acls(READ))); + expectedResolvedRanger.add(new OzoneGrant(bucketSet, bucketAcls, strSet("ListBucket"))); + expectedResolvedRanger.add(new OzoneGrant(objSet(volume(), key("proj-*", "*")), acls(READ), strSet("ListBucket"))); assertThat(resolvedFromRangerAuthorizer).isEqualTo(expectedResolvedRanger); } @@ -1601,16 +1765,22 @@ public void testListBucketOperationsWithNoPrefixes() throws OMException { // Expected for native: bucket READ and LIST; volume, prefix "" READ final Set bucketSet = objSet(bucket("proj")); final Set bucketAcls = acls(READ, LIST); - final Set nativeReadObjects = objSet(volume(), prefix("proj", "")); - expectedResolvedNative.add(new OzoneGrant(bucketSet, bucketAcls)); - expectedResolvedNative.add(new OzoneGrant(nativeReadObjects, acls(READ))); + expectedResolvedNative.add( + new OzoneGrant(bucketSet, bucketAcls, strSet("ListBucket", "ListBucketMultipartUploads"))); + expectedResolvedNative.add( + new OzoneGrant(objSet(volume()), acls(READ), strSet("ListBucket", "ListBucketMultipartUploads"))); + expectedResolvedNative.add( + new OzoneGrant(objSet(prefix("proj", "")), acls(READ), strSet("ListBucket"))); assertThat(resolvedFromNativeAuthorizer).isEqualTo(expectedResolvedNative); // Expected for Ranger: bucket READ and LIST; volume, key "*" READ - final Set rangerReadObjects = objSet(volume(), key("proj", "*")); final Set expectedResolvedRanger = new LinkedHashSet<>(); - expectedResolvedRanger.add(new OzoneGrant(bucketSet, bucketAcls)); - expectedResolvedRanger.add(new OzoneGrant(rangerReadObjects, acls(READ))); + expectedResolvedRanger.add( + new OzoneGrant(bucketSet, bucketAcls, strSet("ListBucket", "ListBucketMultipartUploads"))); + expectedResolvedRanger.add( + new OzoneGrant(objSet(volume()), acls(READ), strSet("ListBucket", "ListBucketMultipartUploads"))); + expectedResolvedRanger.add( + new OzoneGrant(objSet(key("proj", "*")), acls(READ), strSet("ListBucket"))); assertThat(resolvedFromRangerAuthorizer).isEqualTo(expectedResolvedRanger); } @@ -1649,16 +1819,20 @@ public void testIgnoresUnsupportedActionsWhenSupportedActionsAreIncluded() throw // Expected for native: READ, LIST bucket acls; volume and prefixes "team/folder", "team/folder/" READ final Set bucketSet = objSet(bucket("bucket1")); final Set bucketAcls = acls(READ, LIST); - expectedResolvedNative.add(new OzoneGrant(bucketSet, bucketAcls)); - expectedResolvedNative.add(new OzoneGrant( - objSet(volume(), prefix("bucket1", "team/folder"), prefix("bucket1", "team/folder/")), acls(READ))); + expectedResolvedNative.add(new OzoneGrant(bucketSet, bucketAcls, strSet("ListBucket"))); + expectedResolvedNative.add( + new OzoneGrant( + objSet(volume(), prefix("bucket1", "team/folder"), prefix("bucket1", "team/folder/")), + acls(READ), strSet("ListBucket"))); assertThat(resolvedFromNativeAuthorizer).isEqualTo(expectedResolvedNative); final Set expectedResolvedRanger = new LinkedHashSet<>(); // Expected for Ranger: READ, LIST bucket acls; volume and keys "team/folder" and "team/folder/*" READ - expectedResolvedRanger.add(new OzoneGrant(bucketSet, bucketAcls)); - expectedResolvedRanger.add(new OzoneGrant( - objSet(volume(), key("bucket1", "team/folder"), key("bucket1", "team/folder/*")), acls(READ))); + expectedResolvedRanger.add(new OzoneGrant(bucketSet, bucketAcls, strSet("ListBucket"))); + expectedResolvedRanger.add( + new OzoneGrant( + objSet(volume(), key("bucket1", "team/folder"), key("bucket1", "team/folder/*")), + acls(READ), strSet("ListBucket"))); assertThat(resolvedFromRangerAuthorizer).isEqualTo(expectedResolvedRanger); } @@ -1698,15 +1872,17 @@ public void testListAndGetWithPrefixConditionSkipsObjectAction() throws OMExcept // Expected for native (GetObject is ignored because s3:prefix is present): READ, LIST bucket acls; volume READ; // prefix "log/team" READ final Set expectedResolvedNative = new LinkedHashSet<>(); - expectedResolvedNative.add(new OzoneGrant(objSet(bucket("logs")), acls(READ, LIST))); - expectedResolvedNative.add(new OzoneGrant(objSet(volume(), prefix("logs", "team/")), acls(READ))); + expectedResolvedNative.add(new OzoneGrant(objSet(bucket("logs")), acls(READ, LIST), strSet("ListBucket"))); + expectedResolvedNative.add( + new OzoneGrant(objSet(volume(), prefix("logs", "team/")), acls(READ), strSet("ListBucket"))); assertThat(resolvedFromNativeAuthorizer).isEqualTo(expectedResolvedNative); // Expected for Ranger (GetObject is ignored because s3:prefix is present): READ, LIST bucket acls; volume READ; // key "log/team/*" READ final Set expectedResolvedRanger = new LinkedHashSet<>(); - expectedResolvedRanger.add(new OzoneGrant(objSet(bucket("logs")), acls(READ, LIST))); - expectedResolvedRanger.add(new OzoneGrant(objSet(volume(), key("logs", "team/*")), acls(READ))); + expectedResolvedRanger.add(new OzoneGrant(objSet(bucket("logs")), acls(READ, LIST), strSet("ListBucket"))); + expectedResolvedRanger.add( + new OzoneGrant(objSet(volume(), key("logs", "team/*")), acls(READ), strSet("ListBucket"))); assertThat(resolvedFromRangerAuthorizer).isEqualTo(expectedResolvedRanger); } @@ -1746,8 +1922,8 @@ public void testObjectResourceWithWildcardInMiddle() throws OMException { // Ensure what we got is what we expected final Set expectedResolvedRanger = new LinkedHashSet<>(); // Expected for Ranger: READ acl on key "file*.log", bucket READ, volume READ - final Set readObjectsRanger = objSet(key("logs", "file*.log"), bucket("logs"), volume()); - expectedResolvedRanger.add(new OzoneGrant(readObjectsRanger, acls(READ))); + expectedResolvedRanger.add( + new OzoneGrant(objSet(key("logs", "file*.log"), bucket("logs"), volume()), acls(READ), strSet("GetObject"))); assertThat(resolvedFromRangerAuthorizer).isEqualTo(expectedResolvedRanger); } @@ -1767,14 +1943,16 @@ public void testObjectResourceWithPrefixWildcard() throws OMException { // Ensure what we got is what we expected final Set expectedResolvedNative = new LinkedHashSet<>(); // Expected for native: READ acl on prefix "file" under bucket, bucket READ, volume READ - final Set readObjectsNative = objSet(prefix("myBucket", "file"), bucket("myBucket"), volume()); - expectedResolvedNative.add(new OzoneGrant(readObjectsNative, acls(READ))); + expectedResolvedNative.add( + new OzoneGrant( + objSet(prefix("myBucket", "file"), bucket("myBucket"), volume()), acls(READ), strSet("GetObject"))); assertThat(resolvedFromNativeAuthorizer).isEqualTo(expectedResolvedNative); final Set expectedResolvedRanger = new LinkedHashSet<>(); // Expected for Ranger: READ acl on key "file*", bucket READ, volume READ - final Set readObjectsRanger = objSet(key("myBucket", "file*"), bucket("myBucket"), volume()); - expectedResolvedRanger.add(new OzoneGrant(readObjectsRanger, acls(READ))); + expectedResolvedRanger.add( + new OzoneGrant( + objSet(key("myBucket", "file*"), bucket("myBucket"), volume()), acls(READ), strSet("GetObject"))); assertThat(resolvedFromRangerAuthorizer).isEqualTo(expectedResolvedRanger); } @@ -1798,9 +1976,10 @@ public void testBucketActionOnAllResources() throws OMException { // Ensure what we got is what we expected final Set expectedResolvedRanger = new LinkedHashSet<>(); // Expected for Ranger: READ and LIST on volume and bucket (wildcard), READ on key "*" - final Set resourceSet = objSet(volume(), bucket("*")); - expectedResolvedRanger.add(new OzoneGrant(resourceSet, acls(READ, LIST))); - expectedResolvedRanger.add(new OzoneGrant(objSet(key("*", "*")), acls(READ))); + expectedResolvedRanger.add( + new OzoneGrant(objSet(volume()), acls(READ, LIST), strSet("ListAllMyBuckets", "ListBucket"))); + expectedResolvedRanger.add(new OzoneGrant(objSet(bucket("*")), acls(READ, LIST), strSet("ListBucket"))); + expectedResolvedRanger.add(new OzoneGrant(objSet(key("*", "*")), acls(READ), strSet("ListBucket"))); assertThat(resolvedFromRangerAuthorizer).isEqualTo(expectedResolvedRanger); } @@ -1823,8 +2002,8 @@ public void testObjectActionOnAllResources() throws OMException { // Expected for Ranger: CREATE and WRITE key acls on wildcard pattern, bucket READ, volume READ final Set keySet = objSet(key("*", "*")); final Set keyAcls = acls(CREATE, WRITE); - expectedResolvedRanger.add(new OzoneGrant(keySet, keyAcls)); - expectedResolvedRanger.add(new OzoneGrant(objSet(volume(), bucket("*")), acls(READ))); + expectedResolvedRanger.add(new OzoneGrant(keySet, keyAcls, strSet("PutObject"))); + expectedResolvedRanger.add(new OzoneGrant(objSet(volume(), bucket("*")), acls(READ), strSet("PutObject"))); assertThat(resolvedFromRangerAuthorizer).isEqualTo(expectedResolvedRanger); } @@ -1851,9 +2030,10 @@ public void testAllActionsOnAllResourcesWithPrefixCondition() throws OMException final Set expectedResolvedRanger = new LinkedHashSet<>(); // Expected for Ranger: (only ListBucket supports s3:prefix) READ volume; READ, LIST acl on bucket; // READ on key "team/folder", "team/folder/*" - expectedResolvedRanger.add(new OzoneGrant(objSet(bucket("*")), acls(READ, LIST))); + expectedResolvedRanger.add(new OzoneGrant(objSet(bucket("*")), acls(READ, LIST), strSet("ListBucket"))); expectedResolvedRanger.add( - new OzoneGrant(objSet(volume(), key("*", "team/folder"), key("*", "team/folder/*")), acls(READ))); + new OzoneGrant( + objSet(volume(), key("*", "team/folder"), key("*", "team/folder/*")), acls(READ), strSet("ListBucket"))); assertThat(resolvedFromRangerAuthorizer).isEqualTo(expectedResolvedRanger); } @@ -1873,10 +2053,16 @@ public void testAllActionsOnAllResources() throws OMException { final Set resolvedFromRangerAuthorizer = resolve(json, VOLUME, RANGER); // Ensure what we got is what we expected final Set expectedResolvedRanger = new LinkedHashSet<>(); - // Expected for Ranger: READ, LIST acl on volume, ALL acl bucket (wildcard) and key (wildcard) - final Set resourceSet = objSet(bucket("*"), key("*", "*")); - expectedResolvedRanger.add(new OzoneGrant(resourceSet, acls(ALL))); - expectedResolvedRanger.add(new OzoneGrant(objSet(volume()), acls(READ, LIST))); + // Expected for Ranger: + // - volume READ, LIST (ListAllMyBuckets applies at volume scope; other actions imply navigation READ) + // - bucket union of supported bucket ACLs + // - key union of supported object ACLs (and is only action-restricted to object actions + ListBucket) + expectedResolvedRanger.add(new OzoneGrant(objSet(volume()), acls(READ, LIST), emptySet())); + expectedResolvedRanger.add( + new OzoneGrant( + objSet(bucket("*")), acls(READ, LIST, CREATE, DELETE, READ_ACL, WRITE_ACL), ALL_BUCKET_AND_OBJECT_ACTIONS)); + expectedResolvedRanger.add( + new OzoneGrant(objSet(key("*", "*")), acls(READ, CREATE, WRITE, DELETE), ALL_OBJECT_ACTIONS_WITH_LIST_BUCKET)); assertThat(resolvedFromRangerAuthorizer).isEqualTo(expectedResolvedRanger); } @@ -1896,12 +2082,13 @@ public void testAllActionsOnAllBucketResources() throws OMException { final Set resolvedFromRangerAuthorizer = resolve(json, VOLUME, RANGER); // Ensure what we got is what we expected final Set expectedResolvedRanger = new LinkedHashSet<>(); - // Expected for Ranger: ALL bucket acls on wildcard pattern, volume READ, key "*" READ + // Expected for Ranger: union of supported bucket ACLs on wildcard bucket; volume READ, LIST; key "*" READ final Set bucketSet = objSet(bucket("*")); - final Set bucketAcls = acls(ALL); - expectedResolvedRanger.add(new OzoneGrant(bucketSet, bucketAcls)); - expectedResolvedRanger.add(new OzoneGrant(objSet(key("*", "*")), acls(READ))); - expectedResolvedRanger.add(new OzoneGrant(objSet(volume()), acls(READ, LIST))); + final Set bucketAcls = acls(READ, LIST, CREATE, DELETE, READ_ACL, WRITE_ACL); + expectedResolvedRanger.add(new OzoneGrant(bucketSet, bucketAcls, ALL_BUCKET_ACTIONS)); + expectedResolvedRanger.add(new OzoneGrant(objSet(key("*", "*")), acls(READ), strSet("ListBucket"))); + expectedResolvedRanger.add( + new OzoneGrant(objSet(volume()), acls(READ, LIST), ALL_BUCKET_ACTIONS_WITH_LIST_ALL_MY_BUCKETS)); assertThat(resolvedFromRangerAuthorizer).isEqualTo(expectedResolvedRanger); } @@ -1921,11 +2108,11 @@ public void testAllActionsOnAllObjectResources() throws OMException { final Set resolvedFromRangerAuthorizer = resolve(json, VOLUME, RANGER); // Ensure what we got is what we expected final Set expectedResolvedRanger = new LinkedHashSet<>(); - // Expected for Ranger: ALL key acls on wildcard pattern; bucket READ; volume READ + // Expected for Ranger: union of supported object ACLs on wildcard key; bucket READ; volume READ final Set keySet = objSet(key("*", "*")); - final Set keyAcls = acls(ALL); - expectedResolvedRanger.add(new OzoneGrant(keySet, keyAcls)); - expectedResolvedRanger.add(new OzoneGrant(objSet(volume(), bucket("*")), acls(READ))); + final Set keyAcls = acls(READ, CREATE, WRITE, DELETE); + expectedResolvedRanger.add(new OzoneGrant(keySet, keyAcls, ALL_OBJECT_ACTIONS)); + expectedResolvedRanger.add(new OzoneGrant(objSet(volume(), bucket("*")), acls(READ), ALL_OBJECT_ACTIONS)); assertThat(resolvedFromRangerAuthorizer).isEqualTo(expectedResolvedRanger); } @@ -1950,18 +2137,24 @@ public void testWildcardActionGroupGetStar() throws OMException { // Expected for native: bucket READ, READ_ACL acls final Set bucketSet = objSet(bucket("my-bucket")); final Set bucketAcls = acls(READ, READ_ACL); - expectedResolvedNative.add(new OzoneGrant(bucketSet, bucketAcls)); + expectedResolvedNative.add( + new OzoneGrant(bucketSet, bucketAcls, strSet("GetBucketAcl", "GetObject", "GetObjectTagging"))); // Expected for native: READ acl on prefix "" under bucket; volume READ - final Set readObjectsNative = objSet(prefix("my-bucket", ""), volume()); - expectedResolvedNative.add(new OzoneGrant(readObjectsNative, acls(READ))); + expectedResolvedNative.add( + new OzoneGrant(objSet(prefix("my-bucket", "")), acls(READ), strSet("GetObject", "GetObjectTagging"))); + expectedResolvedNative.add( + new OzoneGrant(objSet(volume()), acls(READ), strSet("GetBucketAcl", "GetObject", "GetObjectTagging"))); assertThat(resolvedFromNativeAuthorizer).isEqualTo(expectedResolvedNative); final Set expectedResolvedRanger = new LinkedHashSet<>(); // Expected for Ranger: bucket READ, READ_ACL acls - expectedResolvedRanger.add(new OzoneGrant(bucketSet, bucketAcls)); + expectedResolvedRanger.add( + new OzoneGrant(bucketSet, bucketAcls, strSet("GetBucketAcl", "GetObject", "GetObjectTagging"))); // Expected for Ranger: READ key acl for resource type KEY with key name "*"; volume READ - final Set readObjectsRanger = objSet(key("my-bucket", "*"), volume()); - expectedResolvedRanger.add(new OzoneGrant(readObjectsRanger, acls(READ))); + expectedResolvedRanger.add( + new OzoneGrant(objSet(key("my-bucket", "*")), acls(READ), strSet("GetObject", "GetObjectTagging"))); + expectedResolvedRanger.add( + new OzoneGrant(objSet(volume()), acls(READ), strSet("GetBucketAcl", "GetObject", "GetObjectTagging"))); assertThat(resolvedFromRangerAuthorizer).isEqualTo(expectedResolvedRanger); } @@ -1983,21 +2176,33 @@ public void testWildcardActionGroupListStar() throws OMException { // Ensure what we got is what we expected final Set expectedResolvedNative = new LinkedHashSet<>(); - // Expected for native: READ, LIST bucket acls - final Set bucketSet = objSet(bucket("my-bucket")); - final Set bucketAcls = acls(READ, LIST); - expectedResolvedNative.add(new OzoneGrant(bucketSet, bucketAcls)); - // Expected for native: READ acl on prefix "" under bucket; volume READ - final Set readObjectsNative = objSet(prefix("my-bucket", ""), volume()); - expectedResolvedNative.add(new OzoneGrant(readObjectsNative, acls(READ))); + // Expected for native: READ, LIST bucket acls, READ acl on prefix "" under bucket; volume READ + final Set readAndListObjectsNative = objSet(bucket("my-bucket")); + expectedResolvedNative.add( + new OzoneGrant( + readAndListObjectsNative, acls(READ, LIST), + strSet("ListBucket", "ListBucketMultipartUploads", "ListMultipartUploadParts"))); + expectedResolvedNative.add( + new OzoneGrant(objSet(volume()), acls(READ), + strSet("ListBucket", "ListBucketMultipartUploads", "ListMultipartUploadParts"))); + expectedResolvedNative.add( + new OzoneGrant(objSet(prefix("my-bucket", "")), acls(READ), strSet("ListBucket", "ListMultipartUploadParts"))); assertThat(resolvedFromNativeAuthorizer).isEqualTo(expectedResolvedNative); final Set expectedResolvedRanger = new LinkedHashSet<>(); - // Expected for Ranger: READ, LIST bucket acls - expectedResolvedRanger.add(new OzoneGrant(bucketSet, bucketAcls)); - // Expected for Ranger: READ key acl for resource type KEY with key name "*"; volume READ - final Set readObjectsRanger = objSet(key("my-bucket", "*"), volume()); - expectedResolvedRanger.add(new OzoneGrant(readObjectsRanger, acls(READ))); + // Expected for Ranger: READ, LIST bucket acls; READ key acl for resource type KEY with key name "*"; + // volume READ + final Set readAndListObjectRanger = objSet(bucket("my-bucket")); + expectedResolvedRanger.add( + new OzoneGrant( + readAndListObjectRanger, acls(READ, LIST), + strSet("ListBucket", "ListBucketMultipartUploads", "ListMultipartUploadParts"))); + expectedResolvedRanger.add( + new OzoneGrant( + objSet(volume()), acls(READ), + strSet("ListBucket", "ListBucketMultipartUploads", "ListMultipartUploadParts"))); + expectedResolvedRanger.add( + new OzoneGrant(objSet(key("my-bucket", "*")), acls(READ), strSet("ListBucket", "ListMultipartUploadParts"))); assertThat(resolvedFromRangerAuthorizer).isEqualTo(expectedResolvedRanger); } @@ -2022,23 +2227,27 @@ public void testWildcardActionGroupPutStar() throws OMException { // Expected for native: bucket READ, READ_ACL, WRITE_ACL acl final Set bucketSet = objSet(bucket("my-bucket")); final Set bucketAcl = acls(READ, READ_ACL, WRITE_ACL); - expectedResolvedNative.add(new OzoneGrant(bucketSet, bucketAcl)); + expectedResolvedNative.add( + new OzoneGrant(bucketSet, bucketAcl, strSet("PutBucketAcl", "PutObject", "PutObjectTagging"))); // Expected for native: CREATE, WRITE acls on prefix "" under bucket final Set keyPrefixSet = objSet(prefix("my-bucket", "")); final Set keyAcls = acls(CREATE, WRITE); - expectedResolvedNative.add(new OzoneGrant(keyPrefixSet, keyAcls)); + expectedResolvedNative.add(new OzoneGrant(keyPrefixSet, keyAcls, strSet("PutObject", "PutObjectTagging"))); // Expected for native: volume READ - expectedResolvedNative.add(new OzoneGrant(objSet(volume()), acls(READ))); + expectedResolvedNative.add( + new OzoneGrant(objSet(volume()), acls(READ), strSet("PutBucketAcl", "PutObject", "PutObjectTagging"))); assertThat(resolvedFromNativeAuthorizer).isEqualTo(expectedResolvedNative); final Set expectedResolvedRanger = new LinkedHashSet<>(); // Expected for Ranger: bucket READ, READ_ACL, WRITE_ACL acl - expectedResolvedRanger.add(new OzoneGrant(bucketSet, bucketAcl)); + expectedResolvedRanger.add( + new OzoneGrant(bucketSet, bucketAcl, strSet("PutBucketAcl", "PutObject", "PutObjectTagging"))); // Expected for Ranger: CREATE, WRITE key acls for resource type KEY with key name "*" final Set rangerKeySet = objSet(key("my-bucket", "*")); - expectedResolvedRanger.add(new OzoneGrant(rangerKeySet, keyAcls)); + expectedResolvedRanger.add(new OzoneGrant(rangerKeySet, keyAcls, strSet("PutObject", "PutObjectTagging"))); // Expected for Ranger: volume READ - expectedResolvedRanger.add(new OzoneGrant(objSet(volume()), acls(READ))); + expectedResolvedRanger.add( + new OzoneGrant(objSet(volume()), acls(READ), strSet("PutBucketAcl", "PutObject", "PutObjectTagging"))); assertThat(resolvedFromRangerAuthorizer).isEqualTo(expectedResolvedRanger); } @@ -2062,17 +2271,27 @@ public void testWildcardActionGroupDeleteStar() throws OMException { final Set expectedResolvedNative = new LinkedHashSet<>(); // Expected for native: DELETE and WRITE on prefix "" under bucket; bucket READ, DELETE; volume READ final Set resourceSetNative = objSet(prefix("my-bucket", "")); - expectedResolvedNative.add(new OzoneGrant(resourceSetNative, acls(DELETE, WRITE))); - expectedResolvedNative.add(new OzoneGrant(objSet(bucket("my-bucket")), acls(READ, DELETE))); - expectedResolvedNative.add(new OzoneGrant(objSet(volume()), acls(READ))); + expectedResolvedNative.add( + new OzoneGrant(resourceSetNative, acls(DELETE, WRITE), strSet("DeleteObject", "DeleteObjectTagging"))); + expectedResolvedNative.add( + new OzoneGrant( + objSet(bucket("my-bucket")), acls(READ, DELETE), + strSet("DeleteBucket", "DeleteObject", "DeleteObjectTagging"))); + expectedResolvedNative.add( + new OzoneGrant(objSet(volume()), acls(READ), strSet("DeleteBucket", "DeleteObject", "DeleteObjectTagging"))); assertThat(resolvedFromNativeAuthorizer).isEqualTo(expectedResolvedNative); final Set expectedResolvedRanger = new LinkedHashSet<>(); // Expected for Ranger: DELETE and WRITE on resource type KEY with key name "*"; bucket READ, DELETE; volume READ final Set resourceSetRanger = objSet(key("my-bucket", "*")); - expectedResolvedRanger.add(new OzoneGrant(resourceSetRanger, acls(DELETE, WRITE))); - expectedResolvedRanger.add(new OzoneGrant(objSet(bucket("my-bucket")), acls(READ, DELETE))); - expectedResolvedRanger.add(new OzoneGrant(objSet(volume()), acls(READ))); + expectedResolvedRanger.add( + new OzoneGrant(resourceSetRanger, acls(DELETE, WRITE), strSet("DeleteObject", "DeleteObjectTagging"))); + expectedResolvedRanger.add( + new OzoneGrant( + objSet(bucket("my-bucket")), acls(READ, DELETE), + strSet("DeleteBucket", "DeleteObject", "DeleteObjectTagging"))); + expectedResolvedRanger.add( + new OzoneGrant(objSet(volume()), acls(READ), strSet("DeleteBucket", "DeleteObject", "DeleteObjectTagging"))); assertThat(resolvedFromRangerAuthorizer).isEqualTo(expectedResolvedRanger); } From ef10673605cb2598ea7e9b592b1703f84bf374c3 Mon Sep 17 00:00:00 2001 From: fmorg-git Date: Wed, 8 Jul 2026 16:28:30 -0700 Subject: [PATCH 41/54] HDDS-15771. [STS] Add s3Action for expected bucket owner condition checks (#10688) Co-authored-by: Fabian Morgan --- .../ozone/s3/endpoint/ObjectEndpoint.java | 6 +- ...tS3ActionOverrideForOwnerVerification.java | 149 ++++++++++++++++++ 2 files changed, 153 insertions(+), 2 deletions(-) create mode 100644 hadoop-ozone/s3gateway/src/test/java/org/apache/hadoop/ozone/s3/endpoint/TestS3ActionOverrideForOwnerVerification.java diff --git a/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/endpoint/ObjectEndpoint.java b/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/endpoint/ObjectEndpoint.java index 9ab04c8502e5..e1d03ef3a0dd 100644 --- a/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/endpoint/ObjectEndpoint.java +++ b/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/endpoint/ObjectEndpoint.java @@ -848,7 +848,8 @@ private Response createMultipartKey(OzoneVolume volume, OzoneBucket ozoneBucket, String sourceBucket = result.getLeft(); String sourceKey = result.getRight(); if (S3Owner.hasBucketOwnershipVerificationConditions(getHeaders())) { - String sourceBucketOwner = volume.getBucket(sourceBucket).getOwner(); + final String sourceBucketOwner = runWithS3ActionString( + "GetObject", () -> volume.getBucket(sourceBucket).getOwner()); S3Owner.verifyBucketOwnerConditionOnCopyOperation(getHeaders(), sourceBucket, sourceBucketOwner, bucketName, ozoneBucket.getOwner()); } @@ -1061,7 +1062,8 @@ private CopyObjectResponse copyObject(OzoneVolume volume, final MessageDigest md5Digest = getMD5DigestInstance(); if (S3Owner.hasBucketOwnershipVerificationConditions(getHeaders())) { - String sourceBucketOwner = volume.getBucket(sourceBucket).getOwner(); + final String sourceBucketOwner = runWithS3ActionString( + "GetObject", () -> volume.getBucket(sourceBucket).getOwner()); // The destBucket owner has already been checked in the caller method S3Owner.verifyBucketOwnerConditionOnCopyOperation(getHeaders(), sourceBucket, sourceBucketOwner, null, null); } diff --git a/hadoop-ozone/s3gateway/src/test/java/org/apache/hadoop/ozone/s3/endpoint/TestS3ActionOverrideForOwnerVerification.java b/hadoop-ozone/s3gateway/src/test/java/org/apache/hadoop/ozone/s3/endpoint/TestS3ActionOverrideForOwnerVerification.java new file mode 100644 index 000000000000..0cb57755670f --- /dev/null +++ b/hadoop-ozone/s3gateway/src/test/java/org/apache/hadoop/ozone/s3/endpoint/TestS3ActionOverrideForOwnerVerification.java @@ -0,0 +1,149 @@ +/* + * 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.s3.endpoint; + +import static org.apache.hadoop.ozone.s3.endpoint.EndpointTestUtils.put; +import static org.apache.hadoop.ozone.s3.util.S3Consts.COPY_SOURCE_HEADER; +import static org.apache.hadoop.ozone.s3.util.S3Consts.EXPECTED_BUCKET_OWNER_HEADER; +import static org.apache.hadoop.ozone.s3.util.S3Consts.EXPECTED_SOURCE_BUCKET_OWNER_HEADER; +import static org.apache.hadoop.ozone.s3.util.S3Consts.STORAGE_CLASS_HEADER; +import static org.apache.hadoop.ozone.s3.util.S3Consts.UNSIGNED_PAYLOAD; +import static org.apache.hadoop.ozone.s3.util.S3Consts.X_AMZ_CONTENT_SHA256; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +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.doAnswer; +import static org.mockito.Mockito.doNothing; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.util.concurrent.atomic.AtomicReference; +import javax.ws.rs.core.HttpHeaders; +import org.apache.hadoop.hdds.conf.OzoneConfiguration; +import org.apache.hadoop.ozone.client.ObjectStore; +import org.apache.hadoop.ozone.client.OzoneBucket; +import org.apache.hadoop.ozone.client.OzoneClient; +import org.apache.hadoop.ozone.client.OzoneVolume; +import org.apache.hadoop.ozone.client.protocol.ClientProtocol; +import org.apache.hadoop.ozone.om.exceptions.OMException; +import org.apache.hadoop.ozone.om.exceptions.OMException.ResultCodes; +import org.apache.hadoop.ozone.om.protocol.S3Auth; +import org.apache.hadoop.ozone.s3.signature.SignatureInfo; +import org.junit.jupiter.api.Test; + +/** + * Verifies bucket-owner-condition verification (source bucket owner lookup) runs under the correct IAM S3 action + * string for copy-style operations. + */ +public class TestS3ActionOverrideForOwnerVerification { + + private static final String DEST_BUCKET = "dest-bucket"; + private static final String DEST_KEY = "dest-key"; + private static final String SOURCE_BUCKET = "source-bucket"; + private static final String SOURCE_KEY = "source-key"; + private static final String SOURCE_OWNER = "source-owner"; + private static final String DEST_OWNER = "dest-owner"; + + @Test + public void testUploadPartCopyUsesGetObjectActionForSourceBucketOwnerLookup() throws Exception { + final AtomicReference actionAtSourceBucketOwnerLookup = new AtomicReference<>(); + final ObjectEndpoint endpoint = newEndpoint(actionAtSourceBucketOwnerLookup); + + // Trigger UploadPartCopy (MPU part upload with copy header). + final String uploadId = "upload-id"; + assertThrows(Exception.class, () -> put(endpoint, DEST_BUCKET, DEST_KEY, 1, uploadId, "")); + + assertEquals("GetObject", actionAtSourceBucketOwnerLookup.get()); + } + + @Test + public void testCopyObjectUsesGetObjectActionForSourceBucketOwnerLookup() throws Exception { + final AtomicReference actionAtSourceBucketOwnerLookup = new AtomicReference<>(); + final ObjectEndpoint endpoint = newEndpoint(actionAtSourceBucketOwnerLookup); + + // Trigger CopyObject (PUT with copy header, no upload ID). + assertThrows(Exception.class, () -> put(endpoint, DEST_BUCKET, DEST_KEY, "")); + + assertEquals("GetObject", actionAtSourceBucketOwnerLookup.get()); + } + + private static ObjectEndpoint newEndpoint(AtomicReference actionAtSourceBucketOwnerLookup) throws Exception { + final HttpHeaders headers = mock(HttpHeaders.class); + when(headers.getHeaderString(X_AMZ_CONTENT_SHA256)).thenReturn(UNSIGNED_PAYLOAD); + when(headers.getHeaderString(STORAGE_CLASS_HEADER)).thenReturn("STANDARD"); + when(headers.getHeaderString(COPY_SOURCE_HEADER)).thenReturn(SOURCE_BUCKET + "/" + SOURCE_KEY); + when(headers.getHeaderString(EXPECTED_SOURCE_BUCKET_OWNER_HEADER)).thenReturn(SOURCE_OWNER); + when(headers.getHeaderString(EXPECTED_BUCKET_OWNER_HEADER)).thenReturn(DEST_OWNER); + + final SignatureInfo signatureInfo = mock(SignatureInfo.class); + when(signatureInfo.isSignPayload()).thenReturn(true); + when(signatureInfo.getStringToSign()).thenReturn("string-to-sign"); + when(signatureInfo.getSignature()).thenReturn("signature"); + when(signatureInfo.getAwsAccessId()).thenReturn("access-id"); + when(signatureInfo.getSessionToken()).thenReturn(null); + + final OzoneClient client = mock(OzoneClient.class); + final ObjectStore objectStore = mock(ObjectStore.class); + final ClientProtocol clientProtocol = mock(ClientProtocol.class); + final OzoneVolume volume = mock(OzoneVolume.class); + final OzoneBucket destBucket = mock(OzoneBucket.class); + final OzoneBucket sourceBucket = mock(OzoneBucket.class); + + final AtomicReference s3AuthRef = new AtomicReference<>(); + doAnswer(invocationOnMock -> { + s3AuthRef.set(invocationOnMock.getArgument(0)); + return null; + }).when(clientProtocol).setThreadLocalS3Auth(any(S3Auth.class)); + doNothing().when(clientProtocol).setIsS3Request(true); + + when(client.getObjectStore()).thenReturn(objectStore); + when(client.getProxy()).thenReturn(clientProtocol); + when(objectStore.getClientProxy()).thenReturn(clientProtocol); + when(objectStore.getS3Volume()).thenReturn(volume); + + when(volume.getName()).thenReturn("s3Volume"); + + when(destBucket.getName()).thenReturn(DEST_BUCKET); + when(destBucket.getOwner()).thenReturn(DEST_OWNER); + when(volume.getBucket(DEST_BUCKET)).thenReturn(destBucket); + + when(sourceBucket.getOwner()).thenAnswer(invocationOnMock -> { + final S3Auth s3Auth = s3AuthRef.get(); + assertNotNull(s3Auth, "S3Auth must be initialized before owner lookup"); + actionAtSourceBucketOwnerLookup.set(s3Auth.getS3Action()); + return SOURCE_OWNER; + }); + when(volume.getBucket(SOURCE_BUCKET)).thenReturn(sourceBucket); + + // Stop the request after the source-bucket owner check, without needing to set up full copy behavior. + when(clientProtocol.getKeyDetails(anyString(), eq(SOURCE_BUCKET), eq(SOURCE_KEY))) + .thenThrow(new OMException("stop-after-owner-check", ResultCodes.KEY_NOT_FOUND)); + + final OzoneConfiguration conf = new OzoneConfiguration(); + return EndpointBuilder.newObjectEndpointBuilder() + .setClient(client) + .setConfig(conf) + .setHeaders(headers) + .setSignatureInfo(signatureInfo) + .build(); + } +} + From 79b822725956f090427ae877046ff96b0e16e6f0 Mon Sep 17 00:00:00 2001 From: fmorg-git Date: Thu, 16 Jul 2026 07:35:28 -0700 Subject: [PATCH 42/54] HDDS-15861. [STS] Actions must not be sent in RequestContext to Authorizer if feature flag is off (#10771) --- .../om/helpers/AssumeRoleResponseInfo.java | 5 +-- .../helpers/TestAssumeRoleResponseInfo.java | 4 +-- .../hadoop/ozone/om/OmMetadataReader.java | 9 ++++- .../hadoop/ozone/om/TestOMMetadataReader.java | 24 ++++++++++++++ .../ozone/s3/endpoint/EndpointBase.java | 11 +++++-- ...tS3ActionOverrideForOwnerVerification.java | 33 +++++++++++++++++-- 6 files changed, 76 insertions(+), 10 deletions(-) diff --git a/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/om/helpers/AssumeRoleResponseInfo.java b/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/om/helpers/AssumeRoleResponseInfo.java index 5f21abb3cbd6..ae674bfcfb2b 100644 --- a/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/om/helpers/AssumeRoleResponseInfo.java +++ b/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/om/helpers/AssumeRoleResponseInfo.java @@ -80,8 +80,9 @@ public AssumeRoleResponse getProtobuf() { @Override public String toString() { - return "AssumeRoleResponseInfo{" + "accessKeyId='" + accessKeyId + "', secretAccessKey='" + secretAccessKey + - "', sessionToken='" + sessionToken + "', expirationEpochSeconds=" + expirationEpochSeconds + + // Intentionally left off secretAccessKey + return "AssumeRoleResponseInfo{" + "accessKeyId='" + accessKeyId + "'" + + ", sessionToken='" + sessionToken + "', expirationEpochSeconds=" + expirationEpochSeconds + ", assumedRoleId='" + assumedRoleId + "'}"; } diff --git a/hadoop-ozone/common/src/test/java/org/apache/hadoop/ozone/om/helpers/TestAssumeRoleResponseInfo.java b/hadoop-ozone/common/src/test/java/org/apache/hadoop/ozone/om/helpers/TestAssumeRoleResponseInfo.java index 38c74dc1f261..ce42b815470a 100644 --- a/hadoop-ozone/common/src/test/java/org/apache/hadoop/ozone/om/helpers/TestAssumeRoleResponseInfo.java +++ b/hadoop-ozone/common/src/test/java/org/apache/hadoop/ozone/om/helpers/TestAssumeRoleResponseInfo.java @@ -187,8 +187,8 @@ public void testToString() { final String toString = response.toString(); final String expectedString = "AssumeRoleResponseInfo{" + "accessKeyId='" + ACCESS_KEY_ID + - "', secretAccessKey='" + SECRET_ACCESS_KEY + "', sessionToken='" + SESSION_TOKEN + - "', expirationEpochSeconds=" + EXPIRATION_EPOCH_SECONDS + ", assumedRoleId='" + ASSUMED_ROLE_ID + "'}"; + "', sessionToken='" + SESSION_TOKEN + "', expirationEpochSeconds=" + EXPIRATION_EPOCH_SECONDS + + ", assumedRoleId='" + ASSUMED_ROLE_ID + "'}"; assertNotNull(toString); assertEquals(expectedString, toString); diff --git a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/OmMetadataReader.java b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/OmMetadataReader.java index 40caed205544..7fbdf798b8e5 100644 --- a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/OmMetadataReader.java +++ b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/OmMetadataReader.java @@ -673,9 +673,16 @@ public boolean checkAcls(OzoneObj obj, RequestContext.Builder contextBuilder, * thread locals: the session policy from {@link STSTokenIdentifier} (set on STS requests) and the * S3 action from {@link S3Authentication} (set on S3 requests). Either or both may be absent, in * which case the corresponding field is left untouched on the builder. + *

+ * The S3 action is only propagated when the S3 STS feature flag is enabled, since it is only used + * for fine-grained STS authorization. * @param contextBuilder the builder to enrich in-place */ - public static void maybeAddToContextFromThreadLocal(RequestContext.Builder contextBuilder) { + private void maybeAddToContextFromThreadLocal(RequestContext.Builder contextBuilder) { + if (!ozoneManager.isS3STSEnabled()) { + return; + } + final STSTokenIdentifier stsTokenIdentifier = OzoneManager.getStsTokenIdentifier(); if (stsTokenIdentifier != null) { contextBuilder.setSessionPolicy(stsTokenIdentifier.getSessionPolicy()); diff --git a/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/om/TestOMMetadataReader.java b/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/om/TestOMMetadataReader.java index e35a39521930..cf94366d567e 100644 --- a/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/om/TestOMMetadataReader.java +++ b/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/om/TestOMMetadataReader.java @@ -162,6 +162,24 @@ public void testCheckAclsAttachesS3ActionFromThreadLocal() throws Exception { verifyS3ActionPassedToAuthorizer(accessAuthorizer, obj, "GetObject"); } + @Test + public void testCheckAclsDoesNotAttachS3ActionWhenStsFeatureDisabled() throws Exception { + OzoneManager.setS3Auth(S3Authentication.newBuilder() + .setAccessId(ACCESS_KEY_ID) + .setS3Action("GetObject") + .build()); + + final IAccessAuthorizer accessAuthorizer = createMockIAccessAuthorizerReturningTrue(); + final OmMetadataReader omMetadataReader = createMetadataReader(accessAuthorizer, mock(KeyManager.class), false); + + final RequestContext.Builder contextWithoutS3ActionBuilder = createTestRequestContextBuilder(); + final OzoneObj obj = createTestOzoneObj(); + + assertTrue(omMetadataReader.checkAcls(obj, contextWithoutS3ActionBuilder, true)); + + verifyS3ActionPassedToAuthorizer(accessAuthorizer, obj, null); + } + @Test public void testCheckAclsLeavesS3ActionUnsetWhenS3AuthThreadLocalNull() throws Exception { final IAccessAuthorizer accessAuthorizer = createMockIAccessAuthorizerReturningTrue(); @@ -417,11 +435,17 @@ private OmMetadataReader createMetadataReader(IAccessAuthorizer accessAuthorizer private OmMetadataReader createMetadataReader(IAccessAuthorizer accessAuthorizer, KeyManager keyManager) throws IOException { + return createMetadataReader(accessAuthorizer, keyManager, true); + } + + private OmMetadataReader createMetadataReader(IAccessAuthorizer accessAuthorizer, KeyManager keyManager, + boolean isS3StsEnabled) throws IOException { final OzoneManager ozoneManager = mock(OzoneManager.class); when(ozoneManager.getBucketManager()).thenReturn(mock(BucketManager.class)); when(ozoneManager.getVolumeManager()).thenReturn(mock(VolumeManager.class)); when(ozoneManager.getConfiguration()).thenReturn(new OzoneConfiguration()); when(ozoneManager.getAclsEnabled()).thenReturn(true); + when(ozoneManager.isS3STSEnabled()).thenReturn(isS3StsEnabled); final OMPerformanceMetrics perfMetrics = mock(OMPerformanceMetrics.class); // OmMetadataReader uses these MutableRate metrics via MetricUtil.captureLatencyNs(...). when(perfMetrics.getListKeysResolveBucketLatencyNs()).thenReturn(mock(MutableRate.class)); 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 fd6ef9c3c0b8..1ab1ba0d9ff0 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 @@ -24,6 +24,8 @@ import static org.apache.hadoop.ozone.OzoneConfigKeys.HDDS_CONTAINER_RATIS_DATASTREAM_ENABLED_DEFAULT; import static org.apache.hadoop.ozone.OzoneConfigKeys.OZONE_FS_DATASTREAM_AUTO_THRESHOLD; import static org.apache.hadoop.ozone.OzoneConfigKeys.OZONE_FS_DATASTREAM_AUTO_THRESHOLD_DEFAULT; +import static org.apache.hadoop.ozone.OzoneConfigKeys.OZONE_S3G_STS_HTTP_ENABLED_DEFAULT; +import static org.apache.hadoop.ozone.OzoneConfigKeys.OZONE_S3G_STS_HTTP_ENABLED_KEY; import static org.apache.hadoop.ozone.OzoneConsts.ETAG; import static org.apache.hadoop.ozone.OzoneConsts.KB; import static org.apache.hadoop.ozone.s3.S3GatewayConfigKeys.OZONE_S3G_CLIENT_BUFFER_SIZE_DEFAULT; @@ -157,6 +159,7 @@ public abstract class EndpointBase { private int chunkSize; private boolean datastreamEnabled; private long datastreamMinLength; + private boolean s3StsEnabled; @Context private ContainerRequestContext context; @@ -221,6 +224,10 @@ public void initialization() { OZONE_FS_DATASTREAM_AUTO_THRESHOLD, OZONE_FS_DATASTREAM_AUTO_THRESHOLD_DEFAULT, StorageUnit.BYTES); + s3StsEnabled = getOzoneConfiguration().getBoolean( + OZONE_S3G_STS_HTTP_ENABLED_KEY, + OZONE_S3G_STS_HTTP_ENABLED_DEFAULT); + init(); } @@ -233,7 +240,7 @@ protected void init() { * Called when the handler resolves the {@link S3GAction}. */ protected void applyS3Action(S3GAction action) { - if (s3Auth != null) { + if (s3Auth != null && s3StsEnabled) { s3Auth.setS3Action(S3GActionIamMapper.toS3ActionString(action)); } } @@ -249,7 +256,7 @@ protected void applyS3Action(S3GAction action) { */ protected T runWithS3ActionString(String s3Action, CheckedSupplier checkedSupplier) throws E { - if (s3Auth == null) { + if (s3Auth == null || !s3StsEnabled) { return checkedSupplier.get(); } final String originalS3Action = s3Auth.getS3Action(); diff --git a/hadoop-ozone/s3gateway/src/test/java/org/apache/hadoop/ozone/s3/endpoint/TestS3ActionOverrideForOwnerVerification.java b/hadoop-ozone/s3gateway/src/test/java/org/apache/hadoop/ozone/s3/endpoint/TestS3ActionOverrideForOwnerVerification.java index 0cb57755670f..01dcc0acb27f 100644 --- a/hadoop-ozone/s3gateway/src/test/java/org/apache/hadoop/ozone/s3/endpoint/TestS3ActionOverrideForOwnerVerification.java +++ b/hadoop-ozone/s3gateway/src/test/java/org/apache/hadoop/ozone/s3/endpoint/TestS3ActionOverrideForOwnerVerification.java @@ -26,6 +26,7 @@ import static org.apache.hadoop.ozone.s3.util.S3Consts.X_AMZ_CONTENT_SHA256; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyString; @@ -38,6 +39,7 @@ import java.util.concurrent.atomic.AtomicReference; import javax.ws.rs.core.HttpHeaders; import org.apache.hadoop.hdds.conf.OzoneConfiguration; +import org.apache.hadoop.ozone.OzoneConfigKeys; import org.apache.hadoop.ozone.client.ObjectStore; import org.apache.hadoop.ozone.client.OzoneBucket; import org.apache.hadoop.ozone.client.OzoneClient; @@ -65,7 +67,7 @@ public class TestS3ActionOverrideForOwnerVerification { @Test public void testUploadPartCopyUsesGetObjectActionForSourceBucketOwnerLookup() throws Exception { final AtomicReference actionAtSourceBucketOwnerLookup = new AtomicReference<>(); - final ObjectEndpoint endpoint = newEndpoint(actionAtSourceBucketOwnerLookup); + final ObjectEndpoint endpoint = newEndpoint(actionAtSourceBucketOwnerLookup, true); // Trigger UploadPartCopy (MPU part upload with copy header). final String uploadId = "upload-id"; @@ -77,7 +79,7 @@ public void testUploadPartCopyUsesGetObjectActionForSourceBucketOwnerLookup() th @Test public void testCopyObjectUsesGetObjectActionForSourceBucketOwnerLookup() throws Exception { final AtomicReference actionAtSourceBucketOwnerLookup = new AtomicReference<>(); - final ObjectEndpoint endpoint = newEndpoint(actionAtSourceBucketOwnerLookup); + final ObjectEndpoint endpoint = newEndpoint(actionAtSourceBucketOwnerLookup, true); // Trigger CopyObject (PUT with copy header, no upload ID). assertThrows(Exception.class, () -> put(endpoint, DEST_BUCKET, DEST_KEY, "")); @@ -85,7 +87,31 @@ public void testCopyObjectUsesGetObjectActionForSourceBucketOwnerLookup() throws assertEquals("GetObject", actionAtSourceBucketOwnerLookup.get()); } - private static ObjectEndpoint newEndpoint(AtomicReference actionAtSourceBucketOwnerLookup) throws Exception { + @Test + public void testUploadPartCopyDoesNotSetActionWhenStsDisabled() throws Exception { + final AtomicReference actionAtSourceBucketOwnerLookup = new AtomicReference<>(); + final ObjectEndpoint endpoint = newEndpoint(actionAtSourceBucketOwnerLookup, false); + + // Trigger UploadPartCopy (MPU part upload with copy header). + final String uploadId = "upload-id"; + assertThrows(Exception.class, () -> put(endpoint, DEST_BUCKET, DEST_KEY, 1, uploadId, "")); + + assertNull(actionAtSourceBucketOwnerLookup.get()); + } + + @Test + public void testCopyObjectDoesNotSetActionWhenStsDisabled() throws Exception { + final AtomicReference actionAtSourceBucketOwnerLookup = new AtomicReference<>(); + final ObjectEndpoint endpoint = newEndpoint(actionAtSourceBucketOwnerLookup, false); + + // Trigger CopyObject (PUT with copy header, no upload ID). + assertThrows(Exception.class, () -> put(endpoint, DEST_BUCKET, DEST_KEY, "")); + + assertNull(actionAtSourceBucketOwnerLookup.get()); + } + + private static ObjectEndpoint newEndpoint(AtomicReference actionAtSourceBucketOwnerLookup, + boolean isStsEnabled) throws Exception { final HttpHeaders headers = mock(HttpHeaders.class); when(headers.getHeaderString(X_AMZ_CONTENT_SHA256)).thenReturn(UNSIGNED_PAYLOAD); when(headers.getHeaderString(STORAGE_CLASS_HEADER)).thenReturn("STANDARD"); @@ -138,6 +164,7 @@ private static ObjectEndpoint newEndpoint(AtomicReference actionAtSource .thenThrow(new OMException("stop-after-owner-check", ResultCodes.KEY_NOT_FOUND)); final OzoneConfiguration conf = new OzoneConfiguration(); + conf.setBoolean(OzoneConfigKeys.OZONE_S3G_STS_HTTP_ENABLED_KEY, isStsEnabled); return EndpointBuilder.newObjectEndpointBuilder() .setClient(client) .setConfig(conf) From dc3b37d57017d016f194947d469c0983e338cef0 Mon Sep 17 00:00:00 2001 From: fmorg-git Date: Thu, 30 Jul 2026 21:43:16 -0700 Subject: [PATCH 43/54] HDDS-15984. [STS] Improve s3:prefix Condition handling and reject unsupported AssumeRole parameters (#10875) --- .../acl/iam/IamSessionPolicyResolver.java | 7 +- .../acl/iam/TestIamSessionPolicyResolver.java | 30 +++ .../hadoop/ozone/s3sts/S3STSEndpoint.java | 173 +++++++++++-- .../hadoop/ozone/s3sts/S3STSEndpointBase.java | 10 + .../hadoop/ozone/s3sts/TestS3STSEndpoint.java | 227 +++++++++++++++++- 5 files changed, 419 insertions(+), 28 deletions(-) diff --git a/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/security/acl/iam/IamSessionPolicyResolver.java b/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/security/acl/iam/IamSessionPolicyResolver.java index 4e591d14b5f0..415ae5e8af75 100644 --- a/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/security/acl/iam/IamSessionPolicyResolver.java +++ b/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/security/acl/iam/IamSessionPolicyResolver.java @@ -309,7 +309,12 @@ private static Condition parsePrefixesFromConditions(JsonNode stmt) throws OMExc ERROR_PREFIX + "Invalid Condition operator value structure - " + operatorValue, MALFORMED_POLICY_DOCUMENT); } - final String keyName = operatorValue.fieldNames().hasNext() ? operatorValue.fieldNames().next() : null; + if (operatorValue.size() != 1) { + throw new OMException( + ERROR_PREFIX + "Only one Condition key is supported per operator", NOT_SUPPORTED_OPERATION); + } + + final String keyName = operatorValue.fieldNames().next(); if (!"s3:prefix".equalsIgnoreCase(keyName)) { throw new OMException(ERROR_PREFIX + "Unsupported Condition key name - " + keyName, NOT_SUPPORTED_OPERATION); } diff --git a/hadoop-ozone/common/src/test/java/org/apache/hadoop/ozone/security/acl/iam/TestIamSessionPolicyResolver.java b/hadoop-ozone/common/src/test/java/org/apache/hadoop/ozone/security/acl/iam/TestIamSessionPolicyResolver.java index 2ac9d195cf53..2902aa4fba09 100644 --- a/hadoop-ozone/common/src/test/java/org/apache/hadoop/ozone/security/acl/iam/TestIamSessionPolicyResolver.java +++ b/hadoop-ozone/common/src/test/java/org/apache/hadoop/ozone/security/acl/iam/TestIamSessionPolicyResolver.java @@ -138,6 +138,36 @@ public void testUnsupportedConditionAttributeThrows() { json, "IAM session policy: Unsupported Condition key name - aws:SourceArn", NOT_SUPPORTED_OPERATION); } + @Test + public void testMultipleConditionKeysWithPrefixFirstThrows() { + final String json = "{\n" + + " \"Statement\": [{\n" + + " \"Effect\": \"Allow\",\n" + + " \"Action\": \"s3:ListBucket\",\n" + + " \"Resource\": \"arn:aws:s3:::b\",\n" + + " \"Condition\": { \"StringEquals\": { \"s3:prefix\": \"x\", \"aws:SourceIp\": \"1.2.3.4\" } }\n" + + " }]\n" + + "}"; + + expectResolveThrowsForBothAuthorizers( + json, "IAM session policy: Only one Condition key is supported per operator", NOT_SUPPORTED_OPERATION); + } + + @Test + public void testMultipleConditionKeysWithUnsupportedKeyFirstThrows() { + final String json = "{\n" + + " \"Statement\": [{\n" + + " \"Effect\": \"Allow\",\n" + + " \"Action\": \"s3:ListBucket\",\n" + + " \"Resource\": \"arn:aws:s3:::b\",\n" + + " \"Condition\": { \"StringEquals\": { \"aws:SourceIp\": \"1.2.3.4\", \"s3:prefix\": \"x\" } }\n" + + " }]\n" + + "}"; + + expectResolveThrowsForBothAuthorizers( + json, "IAM session policy: Only one Condition key is supported per operator", NOT_SUPPORTED_OPERATION); + } + @Test public void testUnsupportedEffectThrows() { final String json = "{\n" + 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 1cc922301498..365115a4aa93 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 @@ -28,27 +28,34 @@ import static org.apache.hadoop.ozone.s3.exception.S3ErrorTable.STS_VALIDATION_ERROR; import com.google.common.annotations.VisibleForTesting; +import com.google.common.collect.ImmutableSet; import java.io.IOException; import java.io.StringWriter; import java.time.Instant; import java.time.ZoneOffset; import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.Collections; import java.util.HashSet; +import java.util.List; import java.util.Map; import java.util.Set; import javax.inject.Inject; -import javax.ws.rs.FormParam; +import javax.ws.rs.Consumes; import javax.ws.rs.GET; import javax.ws.rs.POST; import javax.ws.rs.Path; import javax.ws.rs.Produces; import javax.ws.rs.QueryParam; +import javax.ws.rs.core.Form; import javax.ws.rs.core.MediaType; +import javax.ws.rs.core.MultivaluedMap; import javax.ws.rs.core.Response; import javax.xml.bind.JAXBContext; import javax.xml.bind.JAXBException; import javax.xml.bind.Marshaller; import org.apache.commons.lang3.StringUtils; +import org.apache.commons.lang3.Strings; import org.apache.hadoop.ozone.audit.S3GAction; import org.apache.hadoop.ozone.om.exceptions.OMException; import org.apache.hadoop.ozone.om.helpers.AssumeRoleResponseInfo; @@ -88,6 +95,19 @@ public class S3STSEndpoint extends S3STSEndpointBase { private static final String EXPECTED_VERSION = "2011-06-15"; + private static final String SIGV4_PARAM_PREFIX = "X-Amz-"; + + private static final Set ASSUME_ROLE_ALLOWED_PARAMS = ImmutableSet.of( + "Action", "RoleArn", "RoleSessionName", "DurationSeconds", "Version", "Policy"); + + private static final String POLICY_ARNS_MEMBER_PREFIX = "PolicyArns.member."; + private static final String PROVIDED_CONTEXTS_MEMBER_PREFIX = "ProvidedContexts.member."; + private static final String TAGS_MEMBER_PREFIX = "Tags.member."; + private static final String TRANSITIVE_TAG_KEYS_MEMBER_PREFIX = "TransitiveTagKeys.member."; + + private static final Set AWS_VALID_ASSUME_ROLE_OPTIONAL_PARAMS = ImmutableSet.of( + "ExternalId", "SerialNumber", "SourceIdentity", "TokenCode"); + // JAXBContext is relatively expensive to create and is threadsafe, so cache and reuse private static final JAXBContext JAXB_CONTEXT; @@ -128,49 +148,51 @@ public Response get( @QueryParam("Version") String version, @QueryParam("Policy") String awsIamSessionPolicy) throws OS3Exception { - return handleSTSRequest(action, roleArn, roleSessionName, durationSeconds, version, awsIamSessionPolicy); + return handleSTSRequest( + getQueryParameters().keySet(), action, roleArn, roleSessionName, durationSeconds, version, awsIamSessionPolicy); } /** * STS endpoint that handles POST requests with form data. * AWS STS typically uses POST requests with form-encoded parameters. * - * @param action The STS action to perform - * @param roleArn The ARN of the role to assume - * @param roleSessionName Session name for the role - * @param durationSeconds Duration of the token validity - * @param version AWS STS API version + * @param form form-encoded request parameters * @return Response containing STS response XML or error */ @POST + @Consumes(MediaType.APPLICATION_FORM_URLENCODED) @Produces(MediaType.APPLICATION_XML) - public Response post( - @FormParam("Action") String action, - @FormParam("RoleArn") String roleArn, - @FormParam("RoleSessionName") String roleSessionName, - @FormParam("DurationSeconds") Integer durationSeconds, - @FormParam("Version") String version, - @FormParam("Policy") String awsIamSessionPolicy) throws OS3Exception { - - return handleSTSRequest(action, roleArn, roleSessionName, durationSeconds, version, awsIamSessionPolicy); + public Response post(Form form) throws OS3Exception { + if (form == null) { + return unknownOperationExceptionResponse(); + } + + final MultivaluedMap formParams = form.asMap(); + 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 version = formParams.getFirst("Version"); + final String awsIamSessionPolicy = formParams.getFirst("Policy"); + + return handleSTSRequest( + formParams.keySet(), action, roleArn, roleSessionName, durationSeconds, version, awsIamSessionPolicy); } - private Response handleSTSRequest(String action, String roleArn, String roleSessionName, - Integer durationSeconds, String version, String awsIamSessionPolicy) throws OS3Exception { + private Response handleSTSRequest(Set paramNamesToValidate, String action, String roleArn, + String roleSessionName, Integer 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 { if (action == null) { // Amazon STS has a different structure for the XML error response when the action is missing - return Response.status(BAD_REQUEST) - .entity("") - .type(MediaType.APPLICATION_XML) - .build(); + return unknownOperationExceptionResponse(); } switch (action) { case ASSUME_ROLE_ACTION: - return handleAssumeRole(roleArn, roleSessionName, durationSeconds, awsIamSessionPolicy, version, requestId); + return handleAssumeRole( + paramNamesToValidate, roleArn, roleSessionName, durationSeconds, awsIamSessionPolicy, version, requestId); // These operations are not supported yet case GET_SESSION_TOKEN_ACTION: case ASSUME_ROLE_WITH_SAML_ACTION: @@ -193,8 +215,8 @@ private Response handleSTSRequest(String action, String roleArn, String roleSess } } - private Response handleAssumeRole(String roleArn, String roleSessionName, Integer durationSeconds, - String awsIamSessionPolicy, String version, String requestId) throws OSTSException { + private Response handleAssumeRole(Set paramNamesToValidate, String roleArn, String roleSessionName, + Integer durationSeconds, String awsIamSessionPolicy, String version, String requestId) throws OSTSException { final String action = "AssumeRole"; final Map auditParams = getAuditParameters(); S3STSUtils.addAssumeRoleAuditParams( @@ -211,6 +233,16 @@ private Response handleAssumeRole(String roleArn, String roleSessionName, Intege throw exception; } + final AssumeRoleParamValidationResult assumeRoleParamValidationResult = validateAssumeRoleParameters( + paramNamesToValidate); + if (!assumeRoleParamValidationResult.getNotImplementedOptionalParams().isEmpty()) { + final OSTSException exception = new OSTSException(STS_UNSUPPORTED_OPERATION).withMessage( + "AssumeRole optional parameter(s) not implemented: " + + String.join(", ", assumeRoleParamValidationResult.getNotImplementedOptionalParams())); + getAuditLogger().logWriteFailure(buildAuditMessageForFailure(S3GAction.ASSUME_ROLE, auditParams, exception)); + throw exception; + } + final Set validationErrors = new HashSet<>(); int duration = durationSeconds == null ? S3STSUtils.DEFAULT_DURATION_SECONDS : durationSeconds; try { @@ -242,6 +274,11 @@ private Response handleAssumeRole(String roleArn, String roleSessionName, Intege validationErrors.add(e.getMessage()); } + if (!assumeRoleParamValidationResult.getUnsupportedParams().isEmpty()) { + validationErrors.add("Unsupported AssumeRole parameter(s): " + String.join(", ", + assumeRoleParamValidationResult.getUnsupportedParams())); + } + final int numValidationErrors = validationErrors.size(); if (numValidationErrors > 0) { //noinspection StringBufferReplaceableByString @@ -303,6 +340,92 @@ private Response handleAssumeRole(String roleArn, String roleSessionName, Intege } } + private AssumeRoleParamValidationResult validateAssumeRoleParameters(Set paramNamesToValidate) { + if (paramNamesToValidate == null || paramNamesToValidate.isEmpty()) { + return AssumeRoleParamValidationResult.empty(); + } + + final List notImplementedOptionalParams = new ArrayList<>(); + final List unsupportedParams = new ArrayList<>(); + for (String paramName : paramNamesToValidate) { + if (isAllowedAssumeRoleParameter(paramName)) { + continue; + } + + if (isAwsValidButNotImplementedAssumeRoleParameter(paramName)) { + notImplementedOptionalParams.add(paramName); + } else { + unsupportedParams.add(paramName); + } + } + + Collections.sort(notImplementedOptionalParams); + Collections.sort(unsupportedParams); + return new AssumeRoleParamValidationResult(notImplementedOptionalParams, unsupportedParams); + } + + private static boolean isAllowedAssumeRoleParameter(String paramName) { + return ASSUME_ROLE_ALLOWED_PARAMS.contains(paramName) || Strings.CI.startsWith(paramName, SIGV4_PARAM_PREFIX); + } + + private static Response unknownOperationExceptionResponse() { + return Response.status(BAD_REQUEST) + .entity("") + .type(MediaType.APPLICATION_XML) + .build(); + } + + private static boolean isAwsValidButNotImplementedAssumeRoleParameter(String paramName) { + if (StringUtils.isBlank(paramName)) { + return false; + } + if (AWS_VALID_ASSUME_ROLE_OPTIONAL_PARAMS.contains(paramName)) { + return true; + } + + return Strings.CI.startsWith(paramName, POLICY_ARNS_MEMBER_PREFIX) + || Strings.CI.startsWith(paramName, PROVIDED_CONTEXTS_MEMBER_PREFIX) + || Strings.CI.startsWith(paramName, TAGS_MEMBER_PREFIX) + || 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()); + + private final List notImplementedOptionalParams; + private final List unsupportedParams; + + private AssumeRoleParamValidationResult(List notImplementedOptionalParams, List unsupportedParams) { + this.notImplementedOptionalParams = notImplementedOptionalParams; + this.unsupportedParams = unsupportedParams; + } + + private static AssumeRoleParamValidationResult empty() { + return EMPTY; + } + + private List getNotImplementedOptionalParams() { + return notImplementedOptionalParams; + } + + private List getUnsupportedParams() { + return unsupportedParams; + } + } + private String generateAssumeRoleResponse(String assumedRoleUserArn, AssumeRoleResponseInfo responseInfo, String requestId) throws IOException { final String accessKeyId = responseInfo.getAccessKeyId(); 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 027784b0edc9..de52a29d6af2 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 @@ -23,6 +23,8 @@ import javax.inject.Inject; import javax.ws.rs.container.ContainerRequestContext; import javax.ws.rs.core.Context; +import javax.ws.rs.core.MultivaluedHashMap; +import javax.ws.rs.core.MultivaluedMap; import org.apache.hadoop.ozone.audit.AuditAction; import org.apache.hadoop.ozone.audit.AuditEventStatus; import org.apache.hadoop.ozone.audit.AuditLogger; @@ -133,4 +135,12 @@ public void setSignatureInfo(SignatureInfo signatureInfo) { protected Map getAuditParameters() { return AuditUtils.getAuditParameters(context); } + + protected MultivaluedMap getQueryParameters() { + if (context == null || context.getUriInfo() == null) { + return new MultivaluedHashMap<>(); + } + final MultivaluedMap params = context.getUriInfo().getQueryParameters(); + return params == null ? new MultivaluedHashMap<>() : params; + } } 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 059f54e0993c..36adf2359c4a 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 @@ -36,6 +36,7 @@ import java.io.StringReader; import java.time.Instant; import javax.ws.rs.container.ContainerRequestContext; +import javax.ws.rs.core.Form; import javax.ws.rs.core.MultivaluedHashMap; import javax.ws.rs.core.Response; import javax.ws.rs.core.UriInfo; @@ -68,6 +69,8 @@ public class TestS3STSEndpoint { private S3STSEndpoint endpoint; private ObjectStore objectStore; private AuditLogger auditLogger; + private MultivaluedHashMap queryParameters; + private Form formParameters; 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; @@ -87,7 +90,9 @@ public void setup() throws Exception { final UriInfo uriInfo = mock(UriInfo.class); when(context.getUriInfo()).thenReturn(uriInfo); when(uriInfo.getPathParameters()).thenReturn(new MultivaluedHashMap<>()); - when(uriInfo.getQueryParameters()).thenReturn(new MultivaluedHashMap<>()); + queryParameters = new MultivaluedHashMap<>(); + when(uriInfo.getQueryParameters()).thenReturn(queryParameters); + formParameters = new Form(); // Stub assumeRole to return deterministic credentials. objectStore = mock(ObjectStore.class); @@ -117,6 +122,184 @@ public void setup() throws Exception { endpoint.setSignatureInfo(signatureInfo); } + @Test + public void testStsAssumeRoleRejectsUnsupportedParameterForGetMethod() throws Exception { + setAssumeRoleQueryParameters("PolicyArns.member.1", "arn:aws:iam::123456789012:policy/test-policy"); + + final OSTSException ex = assertThrows( + OSTSException.class, () -> endpoint.get("AssumeRole", ROLE_ARN, ROLE_SESSION_NAME, 3600, "2011-06-15", null)); + + assertEquals(501, ex.getHttpCode()); + verify(auditLogger).logWriteFailure(any(AuditMessage.class)); + verify(auditLogger, never()).logWriteSuccess(any(AuditMessage.class)); + verify(objectStore, never()).assumeRole(anyString(), anyString(), anyInt(), any(), anyString()); + + ex.setRequestId(REQUEST_ID); + assertStsErrorXml( + ex.toXml(), STS_NS, "Sender", "UnsupportedOperation", + "AssumeRole optional parameter(s) not implemented: PolicyArns.member.1"); + } + + @Test + public void testStsAssumeRoleRejectsUnsupportedParameterForPostMethod() throws Exception { + setBaseAssumeRoleFormParameters(); + formParameters.param("ExternalId", "external-id"); + + final OSTSException ex = assertThrows(OSTSException.class, () -> endpoint.post(formParameters).close()); + + assertEquals(501, ex.getHttpCode()); + verify(auditLogger).logWriteFailure(any(AuditMessage.class)); + verify(auditLogger, never()).logWriteSuccess(any(AuditMessage.class)); + verify(objectStore, never()).assumeRole(anyString(), anyString(), anyInt(), any(), anyString()); + + ex.setRequestId(REQUEST_ID); + assertStsErrorXml( + ex.toXml(), STS_NS, "Sender", "UnsupportedOperation", + "AssumeRole optional parameter(s) not implemented: ExternalId"); + } + + @Test + public void testStsAssumeRoleAllowsSupportedParametersForGetMethod() { + setAssumeRoleQueryParameters( + "Action", "AssumeRole", + "RoleArn", ROLE_ARN, + "RoleSessionName", ROLE_SESSION_NAME, + "DurationSeconds", "3600", + "Version", "2011-06-15"); + + final Response response = endpoint.get("AssumeRole", ROLE_ARN, ROLE_SESSION_NAME, 3600, "2011-06-15", null); + + assertEquals(200, response.getStatus()); + verify(auditLogger).logWriteSuccess(any(AuditMessage.class)); + verify(auditLogger, never()).logWriteFailure(any(AuditMessage.class)); + } + + @Test + public void testStsAssumeRoleAllowsSignatureParametersForGetMethod() { + setAssumeRoleQueryParameters( + "Action", "AssumeRole", + "RoleArn", ROLE_ARN, + "RoleSessionName", ROLE_SESSION_NAME, + "DurationSeconds", "3600", + "Version", "2011-06-15", + "X-Amz-Algorithm", "AWS4-HMAC-SHA256", + "X-Amz-Credential", "test-user/20260101/us-east-1/sts/aws4_request", + "X-Amz-Date", "20260101T000000Z", + "X-Amz-Expires", "3600", + "X-Amz-SignedHeaders", "host", + "X-Amz-Signature", "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"); + + final Response response = endpoint.get("AssumeRole", ROLE_ARN, ROLE_SESSION_NAME, 3600, "2011-06-15", null); + + assertEquals(200, response.getStatus()); + verify(auditLogger).logWriteSuccess(any(AuditMessage.class)); + verify(auditLogger, never()).logWriteFailure(any(AuditMessage.class)); + } + + @Test + public void testStsAssumeRoleRejectsUnknownParameterForGetMethod() throws Exception { + setAssumeRoleQueryParameters("TotallyUnknownParam", "x"); + + final OSTSException ex = assertThrows( + OSTSException.class, () -> endpoint.get("AssumeRole", ROLE_ARN, ROLE_SESSION_NAME, 3600, "2011-06-15", null)); + + assertEquals(400, ex.getHttpCode()); + verify(auditLogger).logWriteFailure(any(AuditMessage.class)); + verify(auditLogger, never()).logWriteSuccess(any(AuditMessage.class)); + verify(objectStore, never()).assumeRole(anyString(), anyString(), anyInt(), any(), anyString()); + + ex.setRequestId(REQUEST_ID); + assertStsErrorXml( + ex.toXml(), STS_NS, "Sender", "ValidationError", "Unsupported AssumeRole parameter(s): TotallyUnknownParam"); + } + + @Test + public void testStsAssumeRoleRejectsUnknownParameterForPostMethod() throws Exception { + setBaseAssumeRoleFormParameters(); + formParameters.param("TotallyUnknownParam", "y"); + + final OSTSException ex = assertThrows(OSTSException.class, () -> endpoint.post(formParameters).close()); + + assertEquals(400, ex.getHttpCode()); + verify(auditLogger).logWriteFailure(any(AuditMessage.class)); + verify(auditLogger, never()).logWriteSuccess(any(AuditMessage.class)); + verify(objectStore, never()).assumeRole(anyString(), anyString(), anyInt(), any(), anyString()); + + ex.setRequestId(REQUEST_ID); + assertStsErrorXml( + ex.toXml(), STS_NS, "Sender", "ValidationError", + "Unsupported AssumeRole parameter(s): TotallyUnknownParam"); + } + + @Test + public void testStsAssumeRoleRejectsBlankParameterNameForGetMethod() throws Exception { + setAssumeRoleQueryParameters("", "x"); + + final OSTSException ex = assertThrows( + OSTSException.class, () -> endpoint.get("AssumeRole", ROLE_ARN, ROLE_SESSION_NAME, 3600, "2011-06-15", null)); + + assertEquals(400, ex.getHttpCode()); + verify(auditLogger).logWriteFailure(any(AuditMessage.class)); + verify(auditLogger, never()).logWriteSuccess(any(AuditMessage.class)); + verify(objectStore, never()).assumeRole(anyString(), anyString(), anyInt(), any(), anyString()); + + ex.setRequestId(REQUEST_ID); + assertStsErrorXml( + ex.toXml(), STS_NS, "Sender", "ValidationError", "Unsupported AssumeRole parameter(s): "); + } + + @Test + public void testStsAssumeRoleRejectsBlankParameterNameForPostMethod() throws Exception { + setBaseAssumeRoleFormParameters(); + formParameters.param("", "x"); + + final OSTSException ex = assertThrows(OSTSException.class, () -> endpoint.post(formParameters).close()); + + assertEquals(400, ex.getHttpCode()); + verify(auditLogger).logWriteFailure(any(AuditMessage.class)); + verify(auditLogger, never()).logWriteSuccess(any(AuditMessage.class)); + verify(objectStore, never()).assumeRole(anyString(), anyString(), anyInt(), any(), anyString()); + + ex.setRequestId(REQUEST_ID); + assertStsErrorXml( + ex.toXml(), STS_NS, "Sender", "ValidationError", "Unsupported AssumeRole parameter(s): "); + } + + @Test + public void testStsAssumeRoleIgnoresUnknownQueryStringParameterForPostMethod() { + queryParameters.add("foo", "bar"); + + // For POST requests, only body (form) parameters should be validated. + // Query string parameters should not affect validation results. + setBaseAssumeRoleFormParameters(); + final Response response = endpoint.post(formParameters); + response.close(); + + assertEquals(200, response.getStatus()); + verify(auditLogger).logWriteSuccess(any(AuditMessage.class)); + verify(auditLogger, never()).logWriteFailure(any(AuditMessage.class)); + } + + @Test + public void testStsAssumeRoleRejectsUnsupportedSigningParametersForPostMethod() throws Exception { + setBaseAssumeRoleFormParameters(); + formParameters.param("AWSAccessKeyId", "test-user"); + formParameters.param("Signature", "signature"); + formParameters.param("Expires", "3600"); + + final OSTSException ex = assertThrows(OSTSException.class, () -> endpoint.post(formParameters).close()); + + assertEquals(400, ex.getHttpCode()); + verify(auditLogger).logWriteFailure(any(AuditMessage.class)); + verify(auditLogger, never()).logWriteSuccess(any(AuditMessage.class)); + verify(objectStore, never()).assumeRole(anyString(), anyString(), anyInt(), any(), anyString()); + + ex.setRequestId(REQUEST_ID); + assertStsErrorXml( + ex.toXml(), STS_NS, "Sender", "ValidationError", + "Unsupported AssumeRole parameter(s): AWSAccessKeyId, Expires, Signature"); + } + @Test public void testStsAssumeRoleValidForGetMethod() throws Exception { final Response response = endpoint.get("AssumeRole", ROLE_ARN, ROLE_SESSION_NAME, 3600, "2011-06-15", null); @@ -157,8 +340,9 @@ public void testStsAssumeRoleValidForGetMethod() throws Exception { @Test public void testStsAssumeRoleValidForPostMethod() throws Exception { + setBaseAssumeRoleFormParameters(); //noinspection resource - final Response response = endpoint.post("AssumeRole", ROLE_ARN, ROLE_SESSION_NAME, 3600, "2011-06-15", null); + final Response response = endpoint.post(formParameters); assertEquals(200, response.getStatus()); verify(auditLogger).logWriteSuccess(any(AuditMessage.class)); @@ -205,6 +389,22 @@ public void testStsNullAction() throws Exception { assertEquals("UnknownOperationException", root.getLocalName()); } + @Test + public void testStsNullFormForPostMethod() throws Exception { + final Response response = endpoint.post(null); + + assertEquals(400, response.getStatus()); + verifyNoInteractions(auditLogger); + final String errorMessage = (String) response.getEntity(); + assertEquals("", errorMessage); + + final Document doc = parseXml(errorMessage); + final Element root = doc.getDocumentElement(); + assertEquals("UnknownOperationException", root.getLocalName()); + + response.close(); + } + @Test public void testStsUnsupportedActionWithVersionSupplied() throws Exception { final OSTSException ex = assertThrows(OSTSException.class, () -> @@ -563,6 +763,29 @@ public void testStsMultipleValidationErrors() throws Exception { "'policy' failed to satisfy constraint: Member must have length less than or equal to 2048")); } + private void setAssumeRoleQueryParameters(String... nameValuePairs) { + queryParameters.clear(); + for (int i = 0; i < nameValuePairs.length; i += 2) { + queryParameters.add(nameValuePairs[i], nameValuePairs[i + 1]); + } + } + + private void setBaseAssumeRoleFormParameters() { + setAssumeRoleFormParameters( + "Action", "AssumeRole", + "RoleArn", ROLE_ARN, + "RoleSessionName", ROLE_SESSION_NAME, + "DurationSeconds", "3600", + "Version", "2011-06-15"); + } + + private void setAssumeRoleFormParameters(String... nameValuePairs) { + formParameters = new Form(); + for (int i = 0; i < nameValuePairs.length; i += 2) { + formParameters.param(nameValuePairs[i], nameValuePairs[i + 1]); + } + } + private static Document parseXml(String xml) throws Exception { final DocumentBuilderFactory documentBuilderFactory = DocumentBuilderFactory.newInstance(); documentBuilderFactory.setNamespaceAware(true); From fd455023fcec9b3c575071092ed71d82fed1cba6 Mon Sep 17 00:00:00 2001 From: fmorg-git Date: Fri, 14 Aug 2026 00:25:48 -0700 Subject: [PATCH 44/54] HDDS-14810. [STS] Part 2 - STS Ranger Smoke Tests (#9902) --- .../dist/src/main/compose/common/init-kdc.sh | 3 + .../dist/src/main/compose/common/ranger.yaml | 16 +- .../dist/src/main/compose/ozonesecure-ha/.env | 1 + .../main/compose/ozonesecure-ha/docker-config | 3 + .../main/compose/ozonesecure-ha/ranger.yaml | 9 +- .../compose/ozonesecure-ha/test-ranger.sh | 40 + .../generate_oversized_session_policy.py | 36 + .../security/mutate_sts_session_token.py | 216 +++ .../security/ozone-secure-sts.resource | 273 ++++ .../smoketest/security/ozone-secure-sts.robot | 1236 +++++++++++++++++ 10 files changed, 1829 insertions(+), 4 deletions(-) create mode 100644 hadoop-ozone/dist/src/main/smoketest/security/generate_oversized_session_policy.py create mode 100644 hadoop-ozone/dist/src/main/smoketest/security/mutate_sts_session_token.py create mode 100644 hadoop-ozone/dist/src/main/smoketest/security/ozone-secure-sts.resource create mode 100644 hadoop-ozone/dist/src/main/smoketest/security/ozone-secure-sts.robot diff --git a/hadoop-ozone/dist/src/main/compose/common/init-kdc.sh b/hadoop-ozone/dist/src/main/compose/common/init-kdc.sh index 3fd00a2b4f36..665d3da8264c 100755 --- a/hadoop-ozone/dist/src/main/compose/common/init-kdc.sh +++ b/hadoop-ozone/dist/src/main/compose/common/init-kdc.sh @@ -37,6 +37,9 @@ export_keytab testuser/om testuser export_keytab testuser/recon testuser export_keytab testuser/s3g testuser export_keytab testuser/scm testuser +export_keytab svc-iceberg-rest-catalog/s3g svc-iceberg-rest-catalog +export_keytab svc-iceberg-userA/s3g svc-iceberg-userA +export_keytab svc-iceberg-userB/s3g svc-iceberg-userB export_keytab testuser2/dn testuser2 export_keytab testuser2/httpfs testuser2 diff --git a/hadoop-ozone/dist/src/main/compose/common/ranger.yaml b/hadoop-ozone/dist/src/main/compose/common/ranger.yaml index 8ecc69afcffb..210ae3d2d6c0 100644 --- a/hadoop-ozone/dist/src/main/compose/common/ranger.yaml +++ b/hadoop-ozone/dist/src/main/compose/common/ranger.yaml @@ -27,7 +27,7 @@ services: POSTGRES_PASSWORD: "rangerR0cks!" volumes: # The location of the init_postgres.sh file changed in Ranger 2.8 - - ${RANGER_SOURCE_DIR}/dev-support/ranger-docker/scripts/rdbms/init_postgres.sh:/docker-entrypoint-initdb.d/init_postgres.sh + - ${RANGER_INIT_POSTGRES_SH}:/docker-entrypoint-initdb.d/init_postgres.sh healthcheck: test: 'su -c "pg_isready -q" postgres' interval: 10s @@ -63,8 +63,20 @@ services: RANGER_AUDIT_DB_USERNAME: "rangeradmin" RANGER_AUDIT_DB_PASSWORD: "rangerR0cks!" RANGER_VERSION: ${RANGER_VERSION} + FF_ENABLE_OZONE_ACTION_MATCHES_CONDITION: "${FF_ENABLE_OZONE_ACTION_MATCHES_CONDITION:-false}" + # apache/ranger:2.9.0 currently uses Java 8, so Java 9+ flags like + # --add-opens must be opt-in for Ranger images that need them. + JAVA_OPTS: "${RANGER_JAVA_OPTS:-}" + entrypoint: + - bash + - -c + - | + if [ -f /opt/ranger/admin/install.properties ]; then + cp /opt/ranger/admin/install.properties /home/ranger/scripts/ranger-admin-install.properties + fi + exec /home/ranger/scripts/ranger.sh volumes: - - ${RANGER_SOURCE_DIR}/dev-support/ranger-docker/scripts/admin/ranger-admin-install-postgres.properties:/opt/ranger/admin/install.properties + - ${RANGER_ADMIN_INSTALL_PROPERTIES}:/opt/ranger/admin/install.properties healthcheck: test: 'grep "Successfully retrieved .*dev_ozone" /var/log/ranger/ranger-admin*log' interval: 2s diff --git a/hadoop-ozone/dist/src/main/compose/ozonesecure-ha/.env b/hadoop-ozone/dist/src/main/compose/ozonesecure-ha/.env index ac0582396f84..10da8c215beb 100644 --- a/hadoop-ozone/dist/src/main/compose/ozonesecure-ha/.env +++ b/hadoop-ozone/dist/src/main/compose/ozonesecure-ha/.env @@ -28,3 +28,4 @@ RANGER_IMAGE=apache/ranger RANGER_IMAGE_VERSION=${ranger.version} RANGER_VERSION=${ranger.version} WAITFOR_TIMEOUT=3000 +FF_ENABLE_OZONE_ACTION_MATCHES_CONDITION=true diff --git a/hadoop-ozone/dist/src/main/compose/ozonesecure-ha/docker-config b/hadoop-ozone/dist/src/main/compose/ozonesecure-ha/docker-config index 38487ac51de9..a2f4208c01af 100644 --- a/hadoop-ozone/dist/src/main/compose/ozonesecure-ha/docker-config +++ b/hadoop-ozone/dist/src/main/compose/ozonesecure-ha/docker-config @@ -104,6 +104,9 @@ OZONE-SITE.XML_ozone.security.http.kerberos.enabled=true OZONE-SITE.XML_ozone.s3g.secret.http.enabled=true OZONE-SITE.XML_ozone.http.filter.initializers=org.apache.hadoop.security.AuthenticationFilterInitializer +# Enable S3 Gateway STS (AWS STS compatible) endpoint on s3g (http://s3g:9880/sts) +OZONE-SITE.XML_ozone.s3g.sts.http.enabled=true + OZONE-SITE.XML_ozone.om.http.auth.type=kerberos OZONE-SITE.XML_hdds.scm.http.auth.type=kerberos OZONE-SITE.XML_hdds.datanode.http.auth.type=kerberos diff --git a/hadoop-ozone/dist/src/main/compose/ozonesecure-ha/ranger.yaml b/hadoop-ozone/dist/src/main/compose/ozonesecure-ha/ranger.yaml index 5cc29a134241..579dbfc0cc12 100644 --- a/hadoop-ozone/dist/src/main/compose/ozonesecure-ha/ranger.yaml +++ b/hadoop-ozone/dist/src/main/compose/ozonesecure-ha/ranger.yaml @@ -17,7 +17,9 @@ x-om-ranger-config: &om-ranger-config environment: - OZONE_MANAGER_CLASSPATH: "/opt/ranger/ozone-plugin/lib/libext/*:/opt/ozone/conf" + # Ranger 2.8.x+ ozone-plugin tarball: jars live under lib/libext/ and + # lib/libext/ranger-ozone-plugin-impl/ (ranger-audit-dest-solr-*.jar is in the impl dir). + OZONE_MANAGER_CLASSPATH: "/opt/ranger/ozone-plugin/lib/libext/*:/opt/ranger/ozone-plugin/lib/libext/ranger-ozone-plugin-impl/*:/opt/ozone/conf" OZONE-SITE.XML_ozone.acl.authorizer.class: "org.apache.ranger.authorization.ozone.authorizer.RangerOzoneAuthorizer" OZONE-SITE.XML_ozone.om.multitenancy.enabled: "true" OZONE-SITE.XML_ozone.om.ranger.https-address: "http://ranger:6080" @@ -37,7 +39,10 @@ x-om-ranger-config: - ${RANGER_OZONE_PLUGIN_DIR}:/opt/ranger/ozone-plugin tmpfs: - /opt/ozone/conf - command: bash -c "sudo --preserve-env /opt/ranger/ozone-plugin/enable-ozone-plugin.sh && /opt/hadoop/bin/ozone om" + # JDK17+ no longer ships javax.annotation, but the Ranger Ozone plugin's Jersey2 + # client needs javax.annotation.Priority. Copy the jar into the plugin impl dir + # so the isolated RangerPluginClassLoader can find it. + command: bash -c 'sudo mkdir -p /opt/ranger/ozone-plugin/lib/libext/ranger-ozone-plugin-impl && for j in /opt/hadoop/share/ozone/lib/javax.annotation-api-*.jar; do [ -e "$$j" ] || continue; sudo cp -n "$$j" /opt/ranger/ozone-plugin/lib/libext/ranger-ozone-plugin-impl/; done && sudo --preserve-env /opt/ranger/ozone-plugin/enable-ozone-plugin.sh && exec /opt/hadoop/bin/ozone om' services: om1: diff --git a/hadoop-ozone/dist/src/main/compose/ozonesecure-ha/test-ranger.sh b/hadoop-ozone/dist/src/main/compose/ozonesecure-ha/test-ranger.sh index f28be9c8f7e3..9ffefe58a113 100755 --- a/hadoop-ozone/dist/src/main/compose/ozonesecure-ha/test-ranger.sh +++ b/hadoop-ozone/dist/src/main/compose/ozonesecure-ha/test-ranger.sh @@ -20,6 +20,24 @@ COMPOSE_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" >/dev/null 2>&1 && pwd )" export COMPOSE_DIR +# Load FF_ENABLE_OZONE_ACTION_MATCHES_CONDITION from .env without overriding other env. +# Ranger reads this value from install.properties (not process env), but we allow +# controlling the mounted install.properties via .env. +if [[ -z "${FF_ENABLE_OZONE_ACTION_MATCHES_CONDITION:-}" ]] && [[ -f "${COMPOSE_DIR}/.env" ]]; then + _ff_from_dotenv="$( + ( + set -a + # shellcheck source=/dev/null + source "${COMPOSE_DIR}/.env" + echo "${FF_ENABLE_OZONE_ACTION_MATCHES_CONDITION:-}" + ) 2>/dev/null + )" + if [[ -n "${_ff_from_dotenv}" ]]; then + export FF_ENABLE_OZONE_ACTION_MATCHES_CONDITION="${_ff_from_dotenv}" + fi + unset _ff_from_dotenv +fi + if [[ -z "${RANGER_VERSION:-}" ]]; then export RANGER_VERSION="${ranger.version}" fi @@ -43,6 +61,27 @@ download_and_verify_apache_release "ranger/${RANGER_VERSION}/apache-ranger-${RAN tar -C "${DOWNLOAD_DIR}" -x -z -f "${DOWNLOAD_DIR}/apache-ranger-${RANGER_VERSION}.tar.gz" export RANGER_SOURCE_DIR="${DOWNLOAD_DIR}/apache-ranger-${RANGER_VERSION}" chmod -R a+rX "${RANGER_SOURCE_DIR}" +export RANGER_INIT_POSTGRES_SH="${RANGER_SOURCE_DIR}/dev-support/ranger-docker/scripts/rdbms/init_postgres.sh" + +# Create a temp install.properties so we can override feature flags from .env. +RANGER_ADMIN_INSTALL_PROPERTIES_SRC="${RANGER_SOURCE_DIR}/dev-support/ranger-docker/scripts/admin/ranger-admin-install-postgres.properties" +RANGER_ADMIN_INSTALL_PROPERTIES="$(mktemp "${DOWNLOAD_DIR%/}/ranger-admin-install-postgres.XXXXXX")" +cp -f "${RANGER_ADMIN_INSTALL_PROPERTIES_SRC}" "${RANGER_ADMIN_INSTALL_PROPERTIES}" +chmod a+r "${RANGER_ADMIN_INSTALL_PROPERTIES}" + +_ff="$(echo "${FF_ENABLE_OZONE_ACTION_MATCHES_CONDITION:-false}" | tr '[:upper:]' '[:lower:]')" +if [[ "${_ff}" != "true" ]]; then + _ff="false" +fi +if grep -Eq '^[[:space:]]*#?[[:space:]]*FF_ENABLE_OZONE_ACTION_MATCHES_CONDITION=' "${RANGER_ADMIN_INSTALL_PROPERTIES}"; then + perl -pi -e "s@^[[:space:]]*#?[[:space:]]*FF_ENABLE_OZONE_ACTION_MATCHES_CONDITION=.*@FF_ENABLE_OZONE_ACTION_MATCHES_CONDITION=${_ff}@g" \ + "${RANGER_ADMIN_INSTALL_PROPERTIES}" +else + printf '\nFF_ENABLE_OZONE_ACTION_MATCHES_CONDITION=%s\n' "${_ff}" >> "${RANGER_ADMIN_INSTALL_PROPERTIES}" +fi +unset _ff + +export RANGER_ADMIN_INSTALL_PROPERTIES # Ranger docker support scripts moved between releases (eg: from config/*.sh to scripts/**). # Ensure we don't fail if a glob doesn't match, but still make init scripts executable when present. @@ -74,3 +113,4 @@ execute_robot_test s3g freon/generate.robot execute_robot_test s3g freon/validate.robot execute_robot_test s3g -v RANGER_ENDPOINT_URL:"http://ranger:6080" -v USER:hdfs security/ozone-secure-tenant.robot +execute_robot_test s3g -v RANGER_ENDPOINT_URL:"http://ranger:6080" -v USER:hdfs security/ozone-secure-sts.robot diff --git a/hadoop-ozone/dist/src/main/smoketest/security/generate_oversized_session_policy.py b/hadoop-ozone/dist/src/main/smoketest/security/generate_oversized_session_policy.py new file mode 100644 index 000000000000..7eb416151bdf --- /dev/null +++ b/hadoop-ozone/dist/src/main/smoketest/security/generate_oversized_session_policy.py @@ -0,0 +1,36 @@ +#!/usr/bin/env python3 + +# 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. + +import json + + +def main() -> None: + statement = { + "Effect": "Allow", + "Action": "s3:GetObject", + "Resource": "arn:aws:s3:::bucket123/*", + } + policy = {"Version": "2012-10-17", "Statement": [statement]} + base = json.dumps(policy, separators=(",", ":")) + # Keep the payload comfortably above the STS policy size limit. + policy["Pad"] = "X" * (35000 - len(base) + 64) + print(json.dumps(policy, separators=(",", ":"))) + + +if __name__ == "__main__": + main() diff --git a/hadoop-ozone/dist/src/main/smoketest/security/mutate_sts_session_token.py b/hadoop-ozone/dist/src/main/smoketest/security/mutate_sts_session_token.py new file mode 100644 index 000000000000..68c8c17b9ec2 --- /dev/null +++ b/hadoop-ozone/dist/src/main/smoketest/security/mutate_sts_session_token.py @@ -0,0 +1,216 @@ +#!/usr/bin/env python3 + +# 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. + +""" +Mutate an STS session token to simulate tampering attacks. + +The token is base64url-encoded binary data whose fields are length-prefixed +with Hadoop VInt (variable-length integer) encoding, in this order: + 1. identifier (contains the session policy JSON) + 2. password (the HMAC signature bytes) + 3. kind (token type string, e.g. "STSToken") + 4. service (the service type, e.g. "STS") + +Supported MUTATION_TYPE values: + service - corrupt the service type so token lookup fails + signature - flip a bit in the password so signature verification fails + session_policy - alter the policy inside the identifier to test policy enforcement +""" + +import base64 +import os + + +# --------------------------------------------------------------------------- +# Hadoop VInt encoding +# +# Single-byte range: values -112 .. 127 are stored as one byte (as signed). +# Multi-byte: a leading "length byte" encodes the sign and number of +# additional bytes: +# positive multi-byte: first byte is -113..-120 → 2..9 extra bytes +# negative multi-byte: first byte is -121..-128 → 2..9 extra bytes +# --------------------------------------------------------------------------- + +def _vint_byte(value: int) -> int: + """Return value in the range 0-255 (treat Python int as signed byte).""" + return value & 0xFF + + +def read_vint(buf: bytes, pos: int) -> tuple[int, int]: + """Read a Hadoop VInt from buf at pos. Returns (value, bytes_consumed). + + The first byte encodes the sign and the total byte count: + -113 .. -120 → positive, (total - 1) extra bytes (-111 - first extra bytes) + -121 .. -128 → negative, (total - 1) extra bytes (-119 - first extra bytes) + """ + if pos >= len(buf): + raise ValueError(f"VInt read out of bounds at position {pos}") + + first = buf[pos] if buf[pos] < 128 else buf[pos] - 256 # unsigned → signed + + # Single-byte: -112 to 127 + if first >= -112: + return first, 1 + + # Multi-byte: first byte encodes sign and number of extra bytes + is_negative = first < -120 + # decode_vint_size returns *total* bytes including the first byte; + # subtract 1 to get the number of payload bytes that follow. + total_bytes = (-119 - first) if is_negative else (-111 - first) + n_extra = total_bytes - 1 + + end = pos + 1 + n_extra + if end > len(buf): + raise ValueError(f"Truncated VInt at position {pos}: need {total_bytes} bytes, have {len(buf) - pos}") + + magnitude = int.from_bytes(buf[pos + 1:end], byteorder="big") + value = ~magnitude if is_negative else magnitude + return value, total_bytes + + +def write_vint(value: int) -> bytes: + """Encode an integer as a Hadoop VInt. + + For multi-byte values the first byte is: + positive: -113 - (n_extra - 1) → -113 down to -120 (1..8 extra bytes) + negative: -121 - (n_extra - 1) → -121 down to -128 (1..8 extra bytes) + Followed by the magnitude bytes big-endian. + """ + # Single-byte range: stored directly as signed byte + if -112 <= value <= 127: + return bytes([_vint_byte(value)]) + + # For multi-byte we store the magnitude (complement for negatives) + magnitude = (~value) if value < 0 else value + + # Count bytes needed for the magnitude (at least 1) + tmp = magnitude + n_extra = 0 + while tmp != 0: + tmp >>= 8 + n_extra += 1 + if n_extra == 0: + n_extra = 1 + + # First byte encodes sign and extra count + first = (-120 - n_extra) if value < 0 else (-112 - n_extra) + + return bytes([_vint_byte(first)]) + magnitude.to_bytes(n_extra, byteorder="big") + + +# --------------------------------------------------------------------------- +# Token parsing / reassembly +# --------------------------------------------------------------------------- + +def read_field(buf: bytes, pos: int) -> tuple[bytearray, int]: + """Read one length-prefixed field. Returns (field_bytes, new_pos).""" + length, n = read_vint(buf, pos) + if length < 0: + raise ValueError(f"Negative field length {length} at position {pos}") + pos += n + if pos + length > len(buf): + raise ValueError(f"Field length {length} exceeds remaining bytes at position {pos}") + return bytearray(buf[pos:pos + length]), pos + length + + +def write_field(data: bytes) -> bytes: + """Encode one length-prefixed field.""" + return write_vint(len(data)) + bytes(data) + + +def decode_token(token: str) -> tuple[bytearray, bytearray, bytearray, bytearray]: + """Base64url-decode and parse token into its four fields.""" + raw = base64.urlsafe_b64decode(token + "=" * ((4 - len(token) % 4) % 4)) + pos = 0 + identifier, pos = read_field(raw, pos) + password, pos = read_field(raw, pos) + kind, pos = read_field(raw, pos) + service, pos = read_field(raw, pos) + return identifier, password, kind, service + + +def encode_token(identifier: bytes, password: bytes, kind: bytes, service: bytes) -> str: + """Reassemble the four fields and base64url-encode the result.""" + raw = write_field(identifier) + write_field(password) + write_field(kind) + write_field(service) + return base64.urlsafe_b64encode(raw).decode().rstrip("=") + + +# --------------------------------------------------------------------------- +# Mutations +# --------------------------------------------------------------------------- + +def mutate_service(service: bytearray) -> bytearray: + """Replace service bytes with garbage of the same length.""" + return bytearray(b"BAD" if len(service) == 3 else b"X" * len(service)) + + +def mutate_signature(password: bytearray) -> bytearray: + """Flip the first bit of the signature.""" + if not password: + raise ValueError("Token password is empty") + password[0] ^= 0x01 + return password + + +def mutate_session_policy(identifier: bytearray) -> bytearray: + """Corrupt the first permission value in the embedded session policy. + + The identifier contains the session policy JSON (UTF-8) as a substring. + """ + marker = b'"permissions":["' + start = identifier.find(marker) + if start < 0: + raise ValueError('Could not find \'"permissions":["\' in identifier to mutate') + + value_start = start + len(marker) + value_end = identifier.find(b'"', value_start) + if value_end < 0: + raise ValueError('Could not find end-quote for first permission to mutate') + + if value_end == value_start: + raise ValueError("First permission value is empty; nothing to mutate") + + # Flip one byte in the permission string to ensure token tampering is detected. + identifier[value_start] ^= 0x01 + return identifier + + +# --------------------------------------------------------------------------- +# Entry point +# --------------------------------------------------------------------------- + +def main() -> None: + token = os.environ["SESSION_TOKEN"] + mutation = os.environ["MUTATION_TYPE"] + + identifier, password, kind, service = decode_token(token) + + if mutation == "service": + service = mutate_service(service) + elif mutation == "signature": + password = mutate_signature(password) + elif mutation == "session_policy": + identifier = mutate_session_policy(identifier) + else: + raise ValueError(f"Unsupported mutation type: {mutation!r}") + + print(encode_token(identifier, password, kind, service)) + + +if __name__ == "__main__": + main() diff --git a/hadoop-ozone/dist/src/main/smoketest/security/ozone-secure-sts.resource b/hadoop-ozone/dist/src/main/smoketest/security/ozone-secure-sts.resource new file mode 100644 index 000000000000..e5f19b5205e1 --- /dev/null +++ b/hadoop-ozone/dist/src/main/smoketest/security/ozone-secure-sts.resource @@ -0,0 +1,273 @@ +# 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. + +*** Settings *** +Library OperatingSystem +Library String +Library BuiltIn +Library DateTime +Library Collections +Resource ../commonlib.robot +Resource ../s3/commonawslib.robot + +*** Variables *** +${RANGER_ENDPOINT_URL} ${EMPTY} +${STS_ENDPOINT_URL} http://s3g:9880/sts +${S3G_ENDPOINT_URL} http://s3g:9878 +${ROLE_SESSION_NAME} sts-session-name + +*** Keywords *** +Configure AWS Profile + [Arguments] ${profile} ${access_key} ${secret_key} ${session_token}=${EMPTY} ${region}=us-east-1 + Run Keyword Install aws cli + # Use v4 signatures so presign URL will work (Ozone rejects v2 signatures) + Execute aws configure set s3.signature_version s3v4 --profile ${profile} + Execute aws configure set aws_access_key_id ${access_key} --profile ${profile} + Execute aws configure set aws_secret_access_key ${secret_key} --profile ${profile} + Execute aws configure set region ${region} --profile ${profile} + Run Keyword If '${session_token}' != '${EMPTY}' Execute aws configure set aws_session_token ${session_token} --profile ${profile} + +Configure STS Profile + [Arguments] ${access_key} ${secret_key} ${session_token} + Configure AWS Profile sts ${access_key} ${secret_key} ${session_token} + +Create Ranger Artifact + [Arguments] ${json} ${endpoint_url} + Pass Execution If '${RANGER_ENDPOINT_URL}' == '' No Ranger + ${result} = Execute curl --silent --show-error --include --location --netrc --request POST --header "Content-Type: application/json" --header "accept: application/json" --data '${json}' '${endpoint_url}' + Should Contain ${result} HTTP/1.1 200 + +Create Ranger User + [Arguments] ${user_json} + # Note: the /service/xusers/secure/users endpoint must be used below so that the userPermList can be set. Without + # the userPermList being set, the user cannot be added to a Ranger policy. + Create Ranger Artifact ${user_json} '${RANGER_ENDPOINT_URL}/service/xusers/secure/users' + +Create Ranger Role + [Arguments] ${role_json} + Create Ranger Artifact ${role_json} '${RANGER_ENDPOINT_URL}/service/roles/roles' + +Create Ranger Policy + [Arguments] ${policy_json} + Create Ranger Artifact ${policy_json} '${RANGER_ENDPOINT_URL}/service/public/v2/api/policy' + +Create Ranger Assume Role Policy + [Arguments] ${role_name} ${user_name} + ${policy_json} = Set Variable { "isEnabled": true, "service": "dev_ozone", "name": "${role_name} assume role policy", "policyType": 0, "policyPriority": 0, "isAuditEnabled": true, "resources": { "role": { "values": ["${role_name}"], "isExcludes": false, "isRecursive": false } }, "policyItems": [ { "accesses": [ { "type": "assume_role", "isAllowed": true } ], "users": [ "${user_name}" ], "delegateAdmin": false } ], "serviceType": "ozone", "isDenyAllElse": false } + Create Ranger Policy ${policy_json} + +Update Ranger Policy Items + [Arguments] ${policy_name} ${policy_item_json} + Pass Execution If '${RANGER_ENDPOINT_URL}' == '' No Ranger + # Fetch the existing policy json, update by id. + ${encoded_name} = Execute python3 -c "import urllib.parse,sys; print(urllib.parse.quote(sys.argv[1]))" "${policy_name}" + ${policy} = Execute curl --silent --show-error --location --netrc --request GET --header "accept: application/json" "${RANGER_ENDPOINT_URL}/service/public/v2/api/service/dev_ozone/policy/${encoded_name}" + ${policy_id} = Execute printf '%s' '${policy}' | jq -r '.id // empty' + Should Not Be Empty ${policy_id} + ${updated} = Execute printf '%s' '${policy}' | jq '.policyItems += ${policy_item_json}' + ${result} = Execute curl --silent --show-error --include --location --netrc -X PUT -H "Content-Type: application/json" -H "accept: application/json" --data '${updated}' "${RANGER_ENDPOINT_URL}/service/public/v2/api/policy/${policy_id}" + Should Contain ${result} HTTP/1.1 200 + +Assume Role And Get Temporary Credentials + [Arguments] ${perm_access_key_id} ${perm_secret_key} ${policy_json}=${EMPTY} ${role_arn}=${ROLE_ARN_OBS} ${role_session_name}=${ROLE_SESSION_NAME} ${duration_seconds}=900 + Configure AWS Profile permanent ${perm_access_key_id} ${perm_secret_key} + + ${cmd} = Set Variable aws sts assume-role --endpoint-url ${STS_ENDPOINT_URL} --role-arn ${role_arn} --role-session-name ${role_session_name} --output json --profile permanent + ${cmd} = Set Variable If '${duration_seconds}' != '${EMPTY}' ${cmd} --duration-seconds ${duration_seconds} ${cmd} + ${cmd} = Set Variable If '${policy_json}' != '${EMPTY}' ${cmd} --policy '${policy_json}' ${cmd} + + ${json} = Execute ${cmd} + + # Don't include the latency of the AssumeRole call when checking the expiration + ${now} = Get Current Date time_zone=UTC + + Should Contain ${json} Credentials + + ${stsAccessKeyId} = Execute echo '${json}' | jq -r '.Credentials.AccessKeyId' + ${stsSecretKey} = Execute echo '${json}' | jq -r '.Credentials.SecretAccessKey' + ${stsSessionToken} = Execute echo '${json}' | jq -r '.Credentials.SessionToken' + Should Start With ${stsAccessKeyId} ASIA + Set Global Variable ${STS_ACCESS_KEY_ID} ${stsAccessKeyId} + Set Global Variable ${STS_SECRET_KEY} ${stsSecretKey} + Set Global Variable ${STS_SESSION_TOKEN} ${stsSessionToken} + + ${expected_duration} = Set Variable ${duration_seconds} + # Ensure the expected duration defaults to 3600 seconds (1 hour) if not specified + ${expected_duration} = Set Variable If '${duration_seconds}' == '${EMPTY}' 3600 ${expected_duration} + ${minimum_expected} = Evaluate int(${expected_duration}) - 2 + ${maximum_expected} = Evaluate int(${expected_duration}) + 2 + + # Verify Expiration based on requested duration (or default 1h), with small grace (plus or minus 2 seconds) for clock skew. + ${expiration} = Execute echo '${json}' | jq -r '.Credentials.Expiration' + ${time_diff} = Subtract Date From Date ${expiration} ${now} + Should Be True ${time_diff} >= ${minimum_expected} Expected expiration to be at least ${minimum_expected}s in the future, but was ${time_diff}s + Should Be True ${time_diff} <= ${maximum_expected} Expected expiration to be at most ${maximum_expected}s in the future, but was ${time_diff}s + +Assume Role And Configure STS Profile + [Arguments] ${perm_access_key_id} ${perm_secret_key} ${policy_json}=${EMPTY} ${role_arn}=${ROLE_ARN_OBS} ${role_session_name}=${ROLE_SESSION_NAME} ${duration_seconds}=900 + Assume Role And Get Temporary Credentials perm_access_key_id=${perm_access_key_id} perm_secret_key=${perm_secret_key} policy_json=${policy_json} role_arn=${role_arn} role_session_name=${role_session_name} duration_seconds=${duration_seconds} + Configure STS Profile ${STS_ACCESS_KEY_ID} ${STS_SECRET_KEY} ${STS_SESSION_TOKEN} + +Assume Role Should Fail + [Arguments] ${perm_access_key_id} ${perm_secret_key} ${policy_json}=${EMPTY} ${expected_error}=AccessDenied ${expected_http_code}=${EMPTY} ${role_arn}=${ROLE_ARN_OBS} ${role_session_name}=${ROLE_SESSION_NAME} ${duration_seconds}=900 ${extra_cli_args}=${EMPTY} + Configure AWS Profile permanent ${perm_access_key_id} ${perm_secret_key} + + IF '${expected_http_code}' != '${EMPTY}' + # Note: curl in the s3g container doesn't reliably support --aws-sigv4, + # so use awscli debug output to capture the HTTP response code. + ${cmd} = Set Variable aws sts assume-role --endpoint-url ${STS_ENDPOINT_URL} --role-arn ${role_arn} --role-session-name ${role_session_name} --profile permanent --debug 2>&1 + ${cmd} = Set Variable If '${duration_seconds}' != '${EMPTY}' ${cmd} --duration-seconds ${duration_seconds} ${cmd} + ${cmd} = Set Variable If '${policy_json}' != '${EMPTY}' ${cmd} --policy '${policy_json}' ${cmd} + ${cmd} = Set Variable If '${extra_cli_args}' != '${EMPTY}' ${cmd} ${extra_cli_args} ${cmd} + + ${output} = Execute And Ignore Error ${cmd} + Should Contain ${output} ${expected_error} + + @{http_codes} = Get Regexp Matches ${output} (?m)^.*"POST .*" ([0-9]{3}) .* 1 + ${code_count} = Get Length ${http_codes} + Should Be True ${code_count} > 0 Expected to find an HTTP status code in awscli --debug output, but none was found. + ${http_code} = Get From List ${http_codes} -1 + Should Be Equal As Strings ${http_code} ${expected_http_code} + ELSE + ${cmd} = Set Variable aws sts assume-role --endpoint-url ${STS_ENDPOINT_URL} --role-arn ${role_arn} --role-session-name ${role_session_name} --output json --profile permanent + ${cmd} = Set Variable If '${duration_seconds}' != '${EMPTY}' ${cmd} --duration-seconds ${duration_seconds} ${cmd} + ${cmd} = Set Variable If '${policy_json}' != '${EMPTY}' ${cmd} --policy '${policy_json}' ${cmd} + + ${output} = Execute And Ignore Error ${cmd} + Should Contain ${output} ${expected_error} + END + +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} + ${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} + @{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. + ${http_code} = Get From List ${http_codes} -1 + Should Be Equal As Strings ${http_code} ${expected_http_code} + +Assume Role Should Fail Using Curl Get + # STS supports GET requests with query parameters. Use this to validate query-string parsing / validation. + [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} + ${cmd} = Set Variable curl --silent --show-error --include --request GET --get --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} + @{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. + ${http_code} = Get From List ${http_codes} -1 + Should Be Equal As Strings ${http_code} ${expected_http_code} + +Get Assume Role Debug Output + # This keyword is needed to check headers on the AssumeRole to ensure they comply with AWS + [Arguments] ${perm_access_key_id} ${perm_secret_key} ${policy_json}=${EMPTY} ${role_arn}=${ROLE_ARN_OBS} ${role_session_name}=${ROLE_SESSION_NAME} ${duration_seconds}=900 + Configure AWS Profile permanent ${perm_access_key_id} ${perm_secret_key} + ${cmd} = Set Variable aws sts assume-role --endpoint-url ${STS_ENDPOINT_URL} --role-arn ${role_arn} --role-session-name ${role_session_name} --profile permanent --debug 2>&1 + ${cmd} = Set Variable If '${duration_seconds}' != '${EMPTY}' ${cmd} --duration-seconds ${duration_seconds} ${cmd} + ${cmd} = Set Variable If '${policy_json}' != '${EMPTY}' ${cmd} --policy '${policy_json}' ${cmd} + ${output} = Execute And Ignore Error ${cmd} + [Return] ${output} + +Assume Role Response Headers Should Be Present + [Arguments] ${perm_access_key_id} ${perm_secret_key} ${role_arn}=${ROLE_ARN_OBS} + ${output} = Get Assume Role Debug Output ${perm_access_key_id} ${perm_secret_key} role_arn=${role_arn} + Should Contain ${output} X-Amz-Sts-Extended-Request-Id + Should Contain ${output} x-amzn-RequestId + +Generate Oversized Session Policy + ${policy} = Execute python3 /opt/hadoop/smoketest/security/generate_oversized_session_policy.py + [Return] ${policy} + +Mutate STS Session Token + [Arguments] ${session_token} ${mutation_type} + ${mutated} = Execute SESSION_TOKEN='${session_token}' MUTATION_TYPE='${mutation_type}' python3 /opt/hadoop/smoketest/security/mutate_sts_session_token.py + [Return] ${mutated} + +Assign User To Tenant And Get Credentials + [Arguments] ${user} ${tenant} + ${output} = Execute ozone tenant --verbose user assign ${user} --tenant=${tenant} + Should Contain ${output} Assigned '${user}' to '${tenant}' + @{access_key_matches} = Get Regexp Matches ${output} (?m)(?<=export AWS_ACCESS_KEY_ID=).*$ + @{secret_key_matches} = Get Regexp Matches ${output} (?m)(?<=export AWS_SECRET_ACCESS_KEY=).*$ + ${access_key_id} = Strip String ${access_key_matches[0]} + ${secret_key} = Strip String ${secret_key_matches[0]} + [Return] ${access_key_id} ${secret_key} + +Get Object Should Succeed + [Arguments] ${bucket} ${key} ${destination}=${TEMP_DIR}/${key} ${profile}=sts + ${output} = Execute And Ignore Error aws s3api --endpoint-url ${S3G_ENDPOINT_URL} get-object --bucket ${bucket} --key ${key} ${destination} --profile ${profile} + Should Contain ${output} "AcceptRanges": "bytes" + +Get Object Should Fail + [Arguments] ${bucket} ${key} ${expectedFailureMessage} ${destination}=${TEMP_DIR}/${key} ${profile}=sts + ${output} = Execute And Ignore Error aws s3api --endpoint-url ${S3G_ENDPOINT_URL} get-object --bucket ${bucket} --key ${key} ${destination} --profile ${profile} + Should Contain ${output} ${expectedFailureMessage} + +Put Object Should Succeed + [Arguments] ${bucket} ${key} ${body}=${TEMP_DIR}/${key} ${profile}=sts + ${output} = Execute And Ignore Error aws s3api --endpoint-url ${S3G_ENDPOINT_URL} put-object --bucket ${bucket} --key ${key} --body ${body} --profile ${profile} + Should Contain ${output} "ETag" + +Put Object Should Fail + [Arguments] ${bucket} ${key} ${expectedFailureMessage} ${body}=${TEMP_DIR}/${key} ${profile}=sts + ${output} = Execute And Ignore Error aws s3api --endpoint-url ${S3G_ENDPOINT_URL} put-object --bucket ${bucket} --key ${key} --body ${body} --profile ${profile} + Should Contain ${output} ${expectedFailureMessage} + +Create Bucket Should Fail + [Arguments] ${bucket} ${expectedFailureMessage}=AccessDenied ${profile}=sts + ${output} = Execute And Ignore Error aws s3api --endpoint-url ${S3G_ENDPOINT_URL} create-bucket --bucket ${bucket} --profile ${profile} + Should Contain ${output} ${expectedFailureMessage} + +List Object Keys Should Succeed + [Arguments] ${bucket} ${api}=list-objects ${request_prefix}=${EMPTY} ${profile}=sts ${delimiter}=${EMPTY} + ${cmd} = Set Variable aws s3api --endpoint-url ${S3G_ENDPOINT_URL} ${api} --bucket ${bucket} --output json --profile ${profile} + ${cmd} = Set Variable If '${request_prefix}' != '${EMPTY}' ${cmd} --prefix ${request_prefix} ${cmd} + ${cmd} = Set Variable If '${delimiter}' != '${EMPTY}' ${cmd} --delimiter '${delimiter}' ${cmd} + ${output} = Execute And Ignore Error ${cmd} + Should Not Contain ${output} AccessDenied + ${keys_json} = Execute echo '${output}' | jq -c '[(.Contents // [])[] | .Key] | sort' + [Return] ${keys_json} + +List Object Keys Should Fail + [Arguments] ${bucket} ${api}=list-objects ${expected_failure}=AccessDenied ${request_prefix}=${EMPTY} ${profile}=sts ${delimiter}=${EMPTY} + ${cmd} = Set Variable aws s3api --endpoint-url ${S3G_ENDPOINT_URL} ${api} --bucket ${bucket} --output json --profile ${profile} + ${cmd} = Set Variable If '${request_prefix}' != '${EMPTY}' ${cmd} --prefix ${request_prefix} ${cmd} + ${cmd} = Set Variable If '${delimiter}' != '${EMPTY}' ${cmd} --delimiter '${delimiter}' ${cmd} + ${output} = Execute And Ignore Error ${cmd} + Should Contain ${output} ${expected_failure} + +Assert Listed Keys Json Should Equal + [Arguments] ${actual_keys_json} ${expected_keys_json} + # This compares JSON to another JSON + ${actual_list} = Evaluate json.loads($actual_keys_json) modules=json + ${expected_list} = Evaluate json.loads($expected_keys_json) modules=json + Lists Should Be Equal ${actual_list} ${expected_list} + +Assert Listed Keys Should Equal + [Arguments] ${actual_keys_json} @{expected_keys} + # This compares JSON to a list of strings + ${expected_sorted} = Copy List ${expected_keys} + Sort List ${expected_sorted} + ${actual_list} = Evaluate json.loads($actual_keys_json) modules=json + Lists Should Be Equal ${actual_list} ${expected_sorted} diff --git a/hadoop-ozone/dist/src/main/smoketest/security/ozone-secure-sts.robot b/hadoop-ozone/dist/src/main/smoketest/security/ozone-secure-sts.robot new file mode 100644 index 000000000000..68e12e40b5e0 --- /dev/null +++ b/hadoop-ozone/dist/src/main/smoketest/security/ozone-secure-sts.robot @@ -0,0 +1,1236 @@ +# 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. + +*** Settings *** +Suite Setup Skip If '${RANGER_ENDPOINT_URL}' == '' No Ranger +Documentation Smoke test for S3 STS AssumeRole + Temp Creds +Resource ./ozone-secure-sts.resource +Test Timeout 10 minutes + +*** Variables *** +${ICEBERG_SVC_CATALOG_USER} svc-iceberg-rest-catalog +${ICEBERG_ALL_ACCESS_ROLE_OBS} iceberg-data-all-access-obs +${ICEBERG_ALL_ACCESS_ROLE_FSO} iceberg-data-all-access-fso +${ICEBERG_MULTI_BUCKET_ROLE} iceberg-data-all-access-multi +# Role used for sts-bucket-* resources. The create/delete bucket smoke tests need it so they can create a +# temporary bucket, exercise the session policy under test, and then clean up that bucket in the same test. +${STS_TEMP_BUCKET_ROLE} sts-temp-bucket-access +${ICEBERG_READ_ONLY_ROLE_OBS} iceberg-data-read-only-obs +${ICEBERG_READ_ONLY_ROLE_FSO} iceberg-data-read-only-fso +${ICEBERG_BUCKET_OBS} iceberg-obs +${ICEBERG_BUCKET_FSO} iceberg-fso +${ICEBERG_LAYOUT_OBS} OBJECT_STORE +${ICEBERG_LAYOUT_FSO} FILE_SYSTEM_OPTIMIZED +${ICEBERG_BUCKET_TESTFILE} file1.txt +${ICEBERG_ALL_ACCESS_ROLE_OBS_ARN} arn:aws:iam::123456789012:role/${ICEBERG_ALL_ACCESS_ROLE_OBS} +${ICEBERG_ALL_ACCESS_ROLE_FSO_ARN} arn:aws:iam::123456789012:role/${ICEBERG_ALL_ACCESS_ROLE_FSO} +${ICEBERG_MULTI_BUCKET_ROLE_ARN} arn:aws:iam::123456789012:role/${ICEBERG_MULTI_BUCKET_ROLE} +${STS_TEMP_BUCKET_ROLE_ARN} arn:aws:iam::123456789012:role/${STS_TEMP_BUCKET_ROLE} +${READ_ONLY_ROLE_OBS_ARN} arn:aws:iam::123456789012:role/${ICEBERG_READ_ONLY_ROLE_OBS} +${READ_ONLY_ROLE_FSO_ARN} arn:aws:iam::123456789012:role/${ICEBERG_READ_ONLY_ROLE_FSO} +${PARTIAL_LIST_ALL_BUCKETS_VOL_READ_ROLE} partial-list-all-buckets-vol-read +${PARTIAL_LIST_ALL_BUCKETS_VOL_LIST_ROLE} partial-list-all-buckets-vol-list +${PARTIAL_BUCKET_READ_ROLE} partial-bucket-read +${PARTIAL_BUCKET_READ_UPLOAD_PREFIX_ROLE} partial-bucket-read-upload-prefix +${PARTIAL_BUCKET_LIST_ROLE} partial-bucket-list +${PARTIAL_BUCKET_READ_ACL_ROLE} partial-bucket-read-acl +${PARTIAL_PUT_OBJECT_KEY_CREATE_ROLE} partial-put-object-key-create +${PARTIAL_PUT_OBJECT_KEY_WRITE_ROLE} partial-put-object-key-write +${ACTION_MATCHES_PUTOBJECT_READ_ROLE} action-matches-putobject-read +${ACTION_MATCHES_PUTOBJECT_CREATE_WRITE_ROLE} action-matches-putobject-create-write +${ACTION_MATCHES_GETOBJECT_PUTOBJECT_ROLE} action-matches-getobject-putobject +${ACTION_MATCHES_UPLOADPARTCOPY_EXPECTED_OWNER_ROLE} action-matches-uploadpartcopy-expected-owner +${ACTION_MATCHES_GET_STAR_READ_ROLE} action-matches-get-star-read +${PARTIAL_LIST_ALL_BUCKETS_VOL_READ_ROLE_ARN} arn:aws:iam::123456789012:role/${PARTIAL_LIST_ALL_BUCKETS_VOL_READ_ROLE} +${PARTIAL_LIST_ALL_BUCKETS_VOL_LIST_ROLE_ARN} arn:aws:iam::123456789012:role/${PARTIAL_LIST_ALL_BUCKETS_VOL_LIST_ROLE} +${PARTIAL_BUCKET_READ_ROLE_ARN} arn:aws:iam::123456789012:role/${PARTIAL_BUCKET_READ_ROLE} +${PARTIAL_BUCKET_READ_UPLOAD_PREFIX_ROLE_ARN} arn:aws:iam::123456789012:role/${PARTIAL_BUCKET_READ_UPLOAD_PREFIX_ROLE} +${PARTIAL_BUCKET_LIST_ROLE_ARN} arn:aws:iam::123456789012:role/${PARTIAL_BUCKET_LIST_ROLE} +${PARTIAL_BUCKET_READ_ACL_ROLE_ARN} arn:aws:iam::123456789012:role/${PARTIAL_BUCKET_READ_ACL_ROLE} +${PARTIAL_PUT_OBJECT_KEY_CREATE_ROLE_ARN} arn:aws:iam::123456789012:role/${PARTIAL_PUT_OBJECT_KEY_CREATE_ROLE} +${PARTIAL_PUT_OBJECT_KEY_WRITE_ROLE_ARN} arn:aws:iam::123456789012:role/${PARTIAL_PUT_OBJECT_KEY_WRITE_ROLE} +${ACTION_MATCHES_PUTOBJECT_READ_ROLE_ARN} arn:aws:iam::123456789012:role/${ACTION_MATCHES_PUTOBJECT_READ_ROLE} +${ACTION_MATCHES_PUTOBJECT_CREATE_WRITE_ROLE_ARN} arn:aws:iam::123456789012:role/${ACTION_MATCHES_PUTOBJECT_CREATE_WRITE_ROLE} +${ACTION_MATCHES_GETOBJECT_PUTOBJECT_ROLE_ARN} arn:aws:iam::123456789012:role/${ACTION_MATCHES_GETOBJECT_PUTOBJECT_ROLE} +${ACTION_MATCHES_UPLOADPARTCOPY_EXPECTED_OWNER_ROLE_ARN} arn:aws:iam::123456789012:role/${ACTION_MATCHES_UPLOADPARTCOPY_EXPECTED_OWNER_ROLE} +${ACTION_MATCHES_GET_STAR_READ_ROLE_ARN} arn:aws:iam::123456789012:role/${ACTION_MATCHES_GET_STAR_READ_ROLE} +${TEST_USER_NON_ADMIN} testuser2 +@{ICEBERG_OBJECT_KEYS} file1.txt file1again.txt folder/pepper.txt folder/salt.txt userA/userA.txt userB/userB.txt userAfile.txt +@{ICEBERG_LISTABLE_OBJECT_KEYS_OBS} file1.txt file1again.txt folder/pepper.txt folder/salt.txt userA/userA.txt userB/userB.txt userAfile.txt zeroByteFile zeroByteFolder/ +@{ICEBERG_LISTABLE_OBJECT_KEYS_FSO} file1.txt file1again.txt folder/ folder/pepper.txt folder/salt.txt userA/ userA/userA.txt userAfile.txt userB/ userB/userB.txt zeroByteFile zeroByteFolder +@{ICEBERG_PREFIX_USERA_OBS} userA/userA.txt +@{ICEBERG_PREFIX_USERA_FSO} userA/ userA/userA.txt +@{ICEBERG_PREFIX_USERA_STAR_OBS} userA/userA.txt userAfile.txt +@{ICEBERG_PREFIX_USERA_STAR_FSO} userA/ userA/userA.txt +@{ICEBERG_PREFIX_USER_OBS} userA/userA.txt userB/userB.txt userAfile.txt +@{ICEBERG_PREFIX_USER_FSO} userA/ userA/userA.txt userB/ userB/userB.txt userAfile.txt + +*** Keywords *** +Populate Iceberg Bucket + [Arguments] ${bucket} + FOR ${key} IN @{ICEBERG_OBJECT_KEYS} + ${parent} = Evaluate __import__('os').path.dirname($key) + Run Keyword If '${parent}' != '' Execute mkdir -p ${TEMP_DIR}/${parent} + ${local_path} = Set Variable ${TEMP_DIR}/${key} + Create File ${local_path} iceberg test content + Execute ozone sh key put /s3v/${bucket}/${key} ${local_path} + END + Create File ${TEMP_DIR}/zeroByteFile + Execute ozone sh key put /s3v/${bucket}/zeroByteFile ${TEMP_DIR}/zeroByteFile + # Upload an explicit folder marker object to match AWS semantics. + Create File ${TEMP_DIR}/zero-byte-marker + Execute ozone sh key put /s3v/${bucket}/zeroByteFolder/ ${TEMP_DIR}/zero-byte-marker + +Run List Prefix And Delimiter Policy Matrix For Bucket And Api + [Arguments] ${bucket} ${role_arn} ${api} + # Capture baseline (non-STS) behavior using the permanent credentials. When using STS, it will behave just like + # non-STS S3 behavior in terms of listing results, with the addition of checking authorization if any s3:prefix + # was specified in the inline session policy + Configure AWS Profile permanent ${PERMANENT_ACCESS_KEY_ID} ${PERMANENT_SECRET_KEY} + ${baseline_no_prefix_no_delimiter} = List Object Keys Should Succeed ${bucket} ${api} profile=permanent + ${baseline_userA_slash_prefix_no_delimiter} = List Object Keys Should Succeed ${bucket} ${api} userA/ profile=permanent + ${baseline_userA_prefix_no_delimiter} = List Object Keys Should Succeed ${bucket} ${api} userA profile=permanent + ${baseline_no_prefix_slash_delimiter} = List Object Keys Should Succeed ${bucket} ${api} profile=permanent delimiter=/ + ${baseline_userA_slash_prefix_slash_delimiter} = List Object Keys Should Succeed ${bucket} ${api} userA/ profile=permanent delimiter=/ + ${baseline_userA_prefix_slash_delimiter} = List Object Keys Should Succeed ${bucket} ${api} userA profile=permanent delimiter=/ + ${baseline_userA_userA_prefix_no_delimiter} = List Object Keys Should Succeed ${bucket} ${api} userA/userA profile=permanent + ${baseline_userA_userA_prefix_slash_delimiter} = List Object Keys Should Succeed ${bucket} ${api} userA/userA profile=permanent delimiter=/ + ${baseline_user_prefix_no_delimiter} = List Object Keys Should Succeed ${bucket} ${api} user profile=permanent + ${baseline_user_prefix_slash_delimiter} = List Object Keys Should Succeed ${bucket} ${api} user profile=permanent delimiter=/ + IF '${bucket}' == '${ICEBERG_BUCKET_OBS}' + Assert Listed Keys Should Equal ${baseline_no_prefix_no_delimiter} @{ICEBERG_LISTABLE_OBJECT_KEYS_OBS} + Assert Listed Keys Should Equal ${baseline_userA_slash_prefix_no_delimiter} @{ICEBERG_PREFIX_USERA_OBS} + Assert Listed Keys Should Equal ${baseline_userA_prefix_no_delimiter} @{ICEBERG_PREFIX_USERA_STAR_OBS} + Assert Listed Keys Should Equal ${baseline_user_prefix_no_delimiter} @{ICEBERG_PREFIX_USER_OBS} + ELSE IF '${bucket}' == '${ICEBERG_BUCKET_FSO}' + Assert Listed Keys Should Equal ${baseline_no_prefix_no_delimiter} @{ICEBERG_LISTABLE_OBJECT_KEYS_FSO} + Assert Listed Keys Should Equal ${baseline_userA_slash_prefix_no_delimiter} @{ICEBERG_PREFIX_USERA_FSO} + Assert Listed Keys Should Equal ${baseline_userA_prefix_no_delimiter} @{ICEBERG_PREFIX_USERA_STAR_FSO} + Assert Listed Keys Should Equal ${baseline_user_prefix_no_delimiter} @{ICEBERG_PREFIX_USER_FSO} + END + + # a) IAM session policy with no s3:prefix condition. + ${session_policy} = Set Variable {"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:ListBucket","Resource":"arn:aws:s3:::${bucket}"}]} + Assume Role And Configure STS Profile policy_json=${session_policy} perm_access_key_id=${PERMANENT_ACCESS_KEY_ID} perm_secret_key=${PERMANENT_SECRET_KEY} role_arn=${role_arn} + ${keys_json} = List Object Keys Should Succeed ${bucket} ${api} + Assert Listed Keys Json Should Equal ${keys_json} ${baseline_no_prefix_no_delimiter} + ${keys_json} = List Object Keys Should Succeed ${bucket} ${api} userA/ + Assert Listed Keys Json Should Equal ${keys_json} ${baseline_userA_slash_prefix_no_delimiter} + ${keys_json} = List Object Keys Should Succeed ${bucket} ${api} delimiter=/ + Assert Listed Keys Json Should Equal ${keys_json} ${baseline_no_prefix_slash_delimiter} + ${keys_json} = List Object Keys Should Succeed ${bucket} ${api} userA/ delimiter=/ + Assert Listed Keys Json Should Equal ${keys_json} ${baseline_userA_slash_prefix_slash_delimiter} + + # b1) StringEquals without wildcard. + ${session_policy} = Set Variable {"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:ListBucket","Resource":"arn:aws:s3:::${bucket}","Condition":{"StringEquals":{"s3:prefix":"userA/"}}}]} + Assume Role And Configure STS Profile policy_json=${session_policy} perm_access_key_id=${PERMANENT_ACCESS_KEY_ID} perm_secret_key=${PERMANENT_SECRET_KEY} role_arn=${role_arn} + ${keys_json} = List Object Keys Should Succeed ${bucket} ${api} userA/ + Assert Listed Keys Json Should Equal ${keys_json} ${baseline_userA_slash_prefix_no_delimiter} + ${keys_json} = List Object Keys Should Succeed ${bucket} ${api} userA/ delimiter=/ + Assert Listed Keys Json Should Equal ${keys_json} ${baseline_userA_slash_prefix_slash_delimiter} + # If a prefix was authorized in the session policy, but the user did not supply any prefix, verify access denied + List Object Keys Should Fail ${bucket} ${api} AccessDenied + # If a prefix was authorized in the session policy, but the user supplied a different prefix, verify access denied (userA does not match userA/) + List Object Keys Should Fail ${bucket} ${api} AccessDenied userA + + # b2) StringEquals with wildcard only, wildcard prefix should be ignored and deny. + ${session_policy} = Set Variable {"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:ListBucket","Resource":"arn:aws:s3:::${bucket}","Condition":{"StringEquals":{"s3:prefix":"userA/*"}}}]} + Assume Role And Configure STS Profile policy_json=${session_policy} perm_access_key_id=${PERMANENT_ACCESS_KEY_ID} perm_secret_key=${PERMANENT_SECRET_KEY} role_arn=${role_arn} + List Object Keys Should Fail ${bucket} ${api} AccessDenied userA/ + List Object Keys Should Fail ${bucket} ${api} AccessDenied userA/ delimiter=/ + List Object Keys Should Fail ${bucket} ${api} AccessDenied + List Object Keys Should Fail ${bucket} ${api} AccessDenied delimiter=/ + List Object Keys Should Fail ${bucket} ${api} AccessDenied userA + List Object Keys Should Fail ${bucket} ${api} AccessDenied userA delimiter=/ + + # b3) StringEquals mixed values, wildcard entry is ignored but exact entry still works. Ranger doesn't support literal asterisk matching + ${session_policy} = Set Variable {"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:ListBucket","Resource":"arn:aws:s3:::${bucket}","Condition":{"StringEquals":{"s3:prefix":["userA/","userA/*"]}}}]} + Assume Role And Configure STS Profile policy_json=${session_policy} perm_access_key_id=${PERMANENT_ACCESS_KEY_ID} perm_secret_key=${PERMANENT_SECRET_KEY} role_arn=${role_arn} + ${keys_json} = List Object Keys Should Succeed ${bucket} ${api} userA/ + Assert Listed Keys Json Should Equal ${keys_json} ${baseline_userA_slash_prefix_no_delimiter} + ${keys_json} = List Object Keys Should Succeed ${bucket} ${api} userA/ delimiter=/ + Assert Listed Keys Json Should Equal ${keys_json} ${baseline_userA_slash_prefix_slash_delimiter} + List Object Keys Should Fail ${bucket} ${api} AccessDenied + List Object Keys Should Fail ${bucket} ${api} AccessDenied delimiter=/ + List Object Keys Should Fail ${bucket} ${api} AccessDenied userA + List Object Keys Should Fail ${bucket} ${api} AccessDenied userA delimiter=/ + + # b4) StringEquals without wildcard, deeper prefix with delimiter. + ${session_policy} = Set Variable {"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:ListBucket","Resource":"arn:aws:s3:::${bucket}","Condition":{"StringEquals":{"s3:prefix":"userA/userA"}}}]} + Assume Role And Configure STS Profile policy_json=${session_policy} perm_access_key_id=${PERMANENT_ACCESS_KEY_ID} perm_secret_key=${PERMANENT_SECRET_KEY} role_arn=${role_arn} + ${keys_json} = List Object Keys Should Succeed ${bucket} ${api} userA/userA delimiter=/ + Assert Listed Keys Json Should Equal ${keys_json} ${baseline_userA_userA_prefix_slash_delimiter} + ${keys_json} = List Object Keys Should Succeed ${bucket} ${api} userA/userA + Assert Listed Keys Json Should Equal ${keys_json} ${baseline_userA_userA_prefix_no_delimiter} + List Object Keys Should Fail ${bucket} ${api} AccessDenied + List Object Keys Should Fail ${bucket} ${api} AccessDenied delimiter=/ + List Object Keys Should Fail ${bucket} ${api} AccessDenied userA + List Object Keys Should Fail ${bucket} ${api} AccessDenied userA delimiter=/ + + # c1) StringLike without wildcard. + ${session_policy} = Set Variable {"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:ListBucket","Resource":"arn:aws:s3:::${bucket}","Condition":{"StringLike":{"s3:prefix":"userA/"}}}]} + Assume Role And Configure STS Profile policy_json=${session_policy} perm_access_key_id=${PERMANENT_ACCESS_KEY_ID} perm_secret_key=${PERMANENT_SECRET_KEY} role_arn=${role_arn} + ${keys_json} = List Object Keys Should Succeed ${bucket} ${api} userA/ + Assert Listed Keys Json Should Equal ${keys_json} ${baseline_userA_slash_prefix_no_delimiter} + ${keys_json} = List Object Keys Should Succeed ${bucket} ${api} userA/ delimiter=/ + Assert Listed Keys Json Should Equal ${keys_json} ${baseline_userA_slash_prefix_slash_delimiter} + List Object Keys Should Fail ${bucket} ${api} AccessDenied + List Object Keys Should Fail ${bucket} ${api} AccessDenied delimiter=/ + List Object Keys Should Fail ${bucket} ${api} AccessDenied userA + List Object Keys Should Fail ${bucket} ${api} AccessDenied userA delimiter=/ + + # c2) StringLike with wildcard and slash (userA/* edge case). + ${session_policy} = Set Variable {"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:ListBucket","Resource":"arn:aws:s3:::${bucket}","Condition":{"StringLike":{"s3:prefix":"userA/*"}}}]} + Assume Role And Configure STS Profile policy_json=${session_policy} perm_access_key_id=${PERMANENT_ACCESS_KEY_ID} perm_secret_key=${PERMANENT_SECRET_KEY} role_arn=${role_arn} + ${keys_json} = List Object Keys Should Succeed ${bucket} ${api} userA/ + Assert Listed Keys Json Should Equal ${keys_json} ${baseline_userA_slash_prefix_no_delimiter} + ${keys_json} = List Object Keys Should Succeed ${bucket} ${api} userA/ delimiter=/ + Assert Listed Keys Json Should Equal ${keys_json} ${baseline_userA_slash_prefix_slash_delimiter} + List Object Keys Should Fail ${bucket} ${api} AccessDenied + List Object Keys Should Fail ${bucket} ${api} AccessDenied delimiter=/ + List Object Keys Should Fail ${bucket} ${api} AccessDenied userA + List Object Keys Should Fail ${bucket} ${api} AccessDenied userA delimiter=/ + + # c3) StringLike with wildcard only (userA* edge case). + ${session_policy} = Set Variable {"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:ListBucket","Resource":"arn:aws:s3:::${bucket}","Condition":{"StringLike":{"s3:prefix":"userA*"}}}]} + Assume Role And Configure STS Profile policy_json=${session_policy} perm_access_key_id=${PERMANENT_ACCESS_KEY_ID} perm_secret_key=${PERMANENT_SECRET_KEY} role_arn=${role_arn} + ${keys_json} = List Object Keys Should Succeed ${bucket} ${api} userA + Assert Listed Keys Json Should Equal ${keys_json} ${baseline_userA_prefix_no_delimiter} + ${keys_json} = List Object Keys Should Succeed ${bucket} ${api} userA delimiter=/ + Assert Listed Keys Json Should Equal ${keys_json} ${baseline_userA_prefix_slash_delimiter} + ${keys_json} = List Object Keys Should Succeed ${bucket} ${api} userA/ + Assert Listed Keys Json Should Equal ${keys_json} ${baseline_userA_slash_prefix_no_delimiter} + ${keys_json} = List Object Keys Should Succeed ${bucket} ${api} userA/ delimiter=/ + Assert Listed Keys Json Should Equal ${keys_json} ${baseline_userA_slash_prefix_slash_delimiter} + List Object Keys Should Fail ${bucket} ${api} AccessDenied + List Object Keys Should Fail ${bucket} ${api} AccessDenied delimiter=/ + + # c4) StringLike with prefix only (user edge case). + ${session_policy} = Set Variable {"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:ListBucket","Resource":"arn:aws:s3:::${bucket}","Condition":{"StringLike":{"s3:prefix":"user"}}}]} + Assume Role And Configure STS Profile policy_json=${session_policy} perm_access_key_id=${PERMANENT_ACCESS_KEY_ID} perm_secret_key=${PERMANENT_SECRET_KEY} role_arn=${role_arn} + ${keys_json} = List Object Keys Should Succeed ${bucket} ${api} user + Assert Listed Keys Json Should Equal ${keys_json} ${baseline_user_prefix_no_delimiter} + ${keys_json} = List Object Keys Should Succeed ${bucket} ${api} user delimiter=/ + Assert Listed Keys Json Should Equal ${keys_json} ${baseline_user_prefix_slash_delimiter} + List Object Keys Should Fail ${bucket} ${api} AccessDenied + List Object Keys Should Fail ${bucket} ${api} AccessDenied delimiter=/ + List Object Keys Should Fail ${bucket} ${api} AccessDenied userA + List Object Keys Should Fail ${bucket} ${api} AccessDenied userA delimiter=/ + + # d) No IAM session policy (it should work just like the baseline) + Assume Role And Configure STS Profile perm_access_key_id=${PERMANENT_ACCESS_KEY_ID} perm_secret_key=${PERMANENT_SECRET_KEY} role_arn=${role_arn} + ${keys_json} = List Object Keys Should Succeed ${bucket} ${api} + Assert Listed Keys Json Should Equal ${keys_json} ${baseline_no_prefix_no_delimiter} + ${keys_json} = List Object Keys Should Succeed ${bucket} ${api} delimiter=/ + Assert Listed Keys Json Should Equal ${keys_json} ${baseline_no_prefix_slash_delimiter} + ${keys_json} = List Object Keys Should Succeed ${bucket} ${api} userA + Assert Listed Keys Json Should Equal ${keys_json} ${baseline_userA_prefix_no_delimiter} + ${keys_json} = List Object Keys Should Succeed ${bucket} ${api} userA delimiter=/ + Assert Listed Keys Json Should Equal ${keys_json} ${baseline_userA_prefix_slash_delimiter} + ${keys_json} = List Object Keys Should Succeed ${bucket} ${api} userA/ + Assert Listed Keys Json Should Equal ${keys_json} ${baseline_userA_slash_prefix_no_delimiter} + ${keys_json} = List Object Keys Should Succeed ${bucket} ${api} userA/ delimiter=/ + Assert Listed Keys Json Should Equal ${keys_json} ${baseline_userA_slash_prefix_slash_delimiter} + +Configure STS Profile With Bogus Credential Part + [Arguments] ${bogus_part} + IF '${bogus_part}' == 'accessKeyId' + Configure STS Profile bogusAccessKeyId ${STS_SECRET_KEY} ${STS_SESSION_TOKEN} + ELSE IF '${bogus_part}' == 'secretKey' + Configure STS Profile ${STS_ACCESS_KEY_ID} bogusSecretKey ${STS_SESSION_TOKEN} + ELSE IF '${bogus_part}' == 'sessionToken' + Configure STS Profile ${STS_ACCESS_KEY_ID} ${STS_SECRET_KEY} bogusSessionToken + END + +*** Test Cases *** +Create User in Ranger + ${user_json} = Set Variable { "loginId": "${ICEBERG_SVC_CATALOG_USER}", "name": "${ICEBERG_SVC_CATALOG_USER}", "password": "Password123", "firstName": "Iceberg REST", "lastName": "Catalog", "emailAddress": "${ICEBERG_SVC_CATALOG_USER}@example.com", "userRoleList": ["ROLE_USER"], "userPermList": [ { "moduleId": 1, "isAllowed": 1 }, { "moduleId": 3, "isAllowed": 1 }, { "moduleId": 7, "isAllowed": 1 } ] } + Create Ranger User ${user_json} + +Create All Access Roles in Ranger + FOR ${role} IN ${ICEBERG_ALL_ACCESS_ROLE_OBS} ${ICEBERG_ALL_ACCESS_ROLE_FSO} ${ICEBERG_MULTI_BUCKET_ROLE} + ${role_json} = Set Variable { "name": "${role}", "description": "Iceberg data all access" } + Create Ranger Role ${role_json} + END + +Create Read Only Roles in Ranger + FOR ${role} IN ${ICEBERG_READ_ONLY_ROLE_OBS} ${ICEBERG_READ_ONLY_ROLE_FSO} + ${role_json} = Set Variable { "name": "${role}", "description": "Iceberg data read only" } + Create Ranger Role ${role_json} + END + +Create Temp Bucket Access Policies + # ${STS_TEMP_BUCKET_ROLE} keeps the sts-bucket-* namespace available for the create/delete bucket tests. + ${role_json} = Set Variable { "name": "${STS_TEMP_BUCKET_ROLE}", "description": "STS temp bucket access" } + Create Ranger Role ${role_json} + + Create Ranger Assume Role Policy ${STS_TEMP_BUCKET_ROLE} ${ICEBERG_SVC_CATALOG_USER} + + ${bucket_policy} = Set Variable { "isEnabled": true, "service": "dev_ozone", "name": "sts temp bucket access", "policyType": 0, "policyPriority": 0, "isAuditEnabled": true, "resources": { "volume": { "values": [ "s3v" ], "isExcludes": false, "isRecursive": false }, "bucket": { "values": [ "sts-bucket-*" ], "isExcludes": false, "isRecursive": false }, "key": { "values": [ "*" ], "isExcludes": false, "isRecursive": true } }, "policyItems": [ { "accesses": [ { "type": "all", "isAllowed": true } ], "roles": [ "${STS_TEMP_BUCKET_ROLE}" ], "delegateAdmin": false }, { "accesses": [ { "type": "read", "isAllowed": true } ], "roles": [ "${ICEBERG_READ_ONLY_ROLE_OBS}" ], "delegateAdmin": false } ], "serviceType": "ozone", "isDenyAllElse": false } + Create Ranger Policy ${bucket_policy} + +Create All Access Assume Role Policies + # This policy gives '${ICEBERG_SVC_CATALOG_USER}' user ASSUME_ROLE permission on each all-access role. + FOR ${role} IN ${ICEBERG_ALL_ACCESS_ROLE_OBS} ${ICEBERG_ALL_ACCESS_ROLE_FSO} ${ICEBERG_MULTI_BUCKET_ROLE} + Create Ranger Assume Role Policy ${role} ${ICEBERG_SVC_CATALOG_USER} + END + +Create Read Only Assume Role Policies + # This policy gives '${ICEBERG_SVC_CATALOG_USER}' user ASSUME_ROLE permission on each read-only role. + FOR ${role} IN ${ICEBERG_READ_ONLY_ROLE_OBS} ${ICEBERG_READ_ONLY_ROLE_FSO} + Create Ranger Assume Role Policy ${role} ${ICEBERG_SVC_CATALOG_USER} + END + +Create Iceberg Volume Access Policy + # This policy gives all iceberg roles READ,LIST permission on volume s3v. + # It also gives '${ICEBERG_SVC_CATALOG_USER}' user READ permission on volume s3v. + ${policy_json} = Set Variable { "isEnabled": true, "service": "dev_ozone", "name": "iceberg volume access", "policyType": 0, "policyPriority": 0, "isAuditEnabled": true, "resources": { "volume": { "values": [ "s3v" ], "isExcludes": false, "isRecursive": false } }, "policyItems": [ { "accesses": [ { "type": "read", "isAllowed": true }, { "type": "list", "isAllowed": true } ], "roles": [ "${ICEBERG_ALL_ACCESS_ROLE_OBS}", "${ICEBERG_ALL_ACCESS_ROLE_FSO}", "${ICEBERG_MULTI_BUCKET_ROLE}", "${STS_TEMP_BUCKET_ROLE}", "${ICEBERG_READ_ONLY_ROLE_OBS}", "${ICEBERG_READ_ONLY_ROLE_FSO}" ], "delegateAdmin": false }, { "accesses": [ { "type": "read", "isAllowed": true } ], "users": [ "${ICEBERG_SVC_CATALOG_USER}" ], "delegateAdmin": false } ], "serviceType": "ozone", "isDenyAllElse": false } + Create Ranger Policy ${policy_json} + +Create Iceberg Bucket Access Policies + # This loop gives '${ICEBERG_ALL_ACCESS_ROLE_OBS}' ALL permission on '${ICEBERG_BUCKET_OBS}', '${ICEBERG_SVC_CATALOG_USER}' user READ, LIST permission on '${ICEBERG_BUCKET_OBS}' [because hdfs user creates the buckets, we need READ, LIST to get the baseline results], '${ICEBERG_READ_ONLY_ROLE_OBS}' READ permission on '${ICEBERG_BUCKET_OBS}' + # It also gives '${ICEBERG_ALL_ACCESS_ROLE_FSO}' ALL permission on '${ICEBERG_BUCKET_FSO}', ${ICEBERG_SVC_CATALOG_USER}' user READ, LIST permission on '${ICEBERG_BUCKET_FSO}' [because hdfs user creates the buckets, we need READ, LIST to get the baseline results], '${ICEBERG_READ_ONLY_ROLE_FSO}' READ permission on '${ICEBERG_BUCKET_FSO}' + FOR ${bucket} ${all_access_role} ${read_only_role} IN + ... ${ICEBERG_BUCKET_OBS} ${ICEBERG_ALL_ACCESS_ROLE_OBS} ${ICEBERG_READ_ONLY_ROLE_OBS} + ... ${ICEBERG_BUCKET_FSO} ${ICEBERG_ALL_ACCESS_ROLE_FSO} ${ICEBERG_READ_ONLY_ROLE_FSO} + ${policy_json} = Set Variable { "isEnabled": true, "service": "dev_ozone", "name": "iceberg ${bucket} bucket access", "policyType": 0, "policyPriority": 0, "isAuditEnabled": true, "resources": { "volume": { "values": [ "s3v" ], "isExcludes": false, "isRecursive": false }, "bucket": { "values": [ "${bucket}" ], "isExcludes": false, "isRecursive": false } }, "policyItems": [ { "accesses": [ { "type": "all", "isAllowed": true } ], "roles": [ "${all_access_role}" ], "delegateAdmin": false }, { "accesses": [ { "type": "read", "isAllowed": true }, { "type": "list", "isAllowed": true } ], "users": [ "${ICEBERG_SVC_CATALOG_USER}" ], "delegateAdmin": false }, { "accesses": [ { "type": "read", "isAllowed": true } ], "roles": [ "${read_only_role}" ], "delegateAdmin": false } ], "serviceType": "ozone", "isDenyAllElse": false } + Create Ranger Policy ${policy_json} + END + +Create Iceberg Table Access Policies + # This loop gives '${ICEBERG_ALL_ACCESS_ROLE_OBS}' ALL permission on '${ICEBERG_BUCKET_OBS}'/* keys, '${ICEBERG_SVC_CATALOG_USER}' user READ permission on '${ICEBERG_BUCKET_OBS}'/* keys [because hdfs user creates the buckets, we need READ to get the baseline results], '${ICEBERG_READ_ONLY_ROLE_OBS}' READ permission on '${ICEBERG_BUCKET_OBS}'/* keys + # It also gives '${ICEBERG_ALL_ACCESS_ROLE_FSO}' ALL permission on '${ICEBERG_BUCKET_FSO}'/* keys, '${ICEBERG_SVC_CATALOG_USER}' user READ permission on '${ICEBERG_BUCKET_FSO}'/* keys [because hdfs user creates the buckets, we need READ to get the baseline results], '${ICEBERG_READ_ONLY_ROLE_FSO}' READ permission on '${ICEBERG_BUCKET_FSO}'/* keys + FOR ${bucket} ${all_access_role} ${read_only_role} IN + ... ${ICEBERG_BUCKET_OBS} ${ICEBERG_ALL_ACCESS_ROLE_OBS} ${ICEBERG_READ_ONLY_ROLE_OBS} + ... ${ICEBERG_BUCKET_FSO} ${ICEBERG_ALL_ACCESS_ROLE_FSO} ${ICEBERG_READ_ONLY_ROLE_FSO} + ${policy_json} = Set Variable { "isEnabled": true, "service": "dev_ozone", "name": "iceberg ${bucket} table access", "policyType": 0, "policyPriority": 0, "isAuditEnabled": true, "resources": { "volume": { "values": [ "s3v" ], "isExcludes": false, "isRecursive": false }, "bucket": { "values": [ "${bucket}" ], "isExcludes": false, "isRecursive": false }, "key": { "values": [ "*" ], "isExcludes": false, "isRecursive": true } }, "policyItems": [ { "accesses": [ { "type": "all", "isAllowed": true } ], "roles": [ "${all_access_role}" ], "delegateAdmin": false }, { "accesses": [ { "type": "read", "isAllowed": true } ], "users": [ "${ICEBERG_SVC_CATALOG_USER}" ], "delegateAdmin": false }, { "accesses": [ { "type": "read", "isAllowed": true } ], "roles": [ "${read_only_role}" ], "delegateAdmin": false } ], "serviceType": "ozone", "isDenyAllElse": false } + Create Ranger Policy ${policy_json} + END + +Create Iceberg Multi-Bucket Role Policies + ${bucket_policy} = Set Variable { "isEnabled": true, "service": "dev_ozone", "name": "iceberg multi bucket access", "policyType": 0, "policyPriority": 0, "isAuditEnabled": true, "resources": { "volume": { "values": [ "s3v" ], "isExcludes": false, "isRecursive": false }, "bucket": { "values": [ "${ICEBERG_BUCKET_OBS}", "${ICEBERG_BUCKET_FSO}" ], "isExcludes": false, "isRecursive": false } }, "policyItems": [ { "accesses": [ { "type": "all", "isAllowed": true } ], "roles": [ "${ICEBERG_MULTI_BUCKET_ROLE}" ], "delegateAdmin": false } ], "serviceType": "ozone", "isDenyAllElse": false } + Create Ranger Policy ${bucket_policy} + ${key_policy} = Set Variable { "isEnabled": true, "service": "dev_ozone", "name": "iceberg multi table access", "policyType": 0, "policyPriority": 0, "isAuditEnabled": true, "resources": { "volume": { "values": [ "s3v" ], "isExcludes": false, "isRecursive": false }, "bucket": { "values": [ "${ICEBERG_BUCKET_OBS}", "${ICEBERG_BUCKET_FSO}" ], "isExcludes": false, "isRecursive": false }, "key": { "values": [ "*" ], "isExcludes": false, "isRecursive": true } }, "policyItems": [ { "accesses": [ { "type": "all", "isAllowed": true } ], "roles": [ "${ICEBERG_MULTI_BUCKET_ROLE}" ], "delegateAdmin": false } ], "serviceType": "ozone", "isDenyAllElse": false } + Create Ranger Policy ${key_policy} + +Create Partial Access Roles in Ranger + FOR ${role} IN ${PARTIAL_LIST_ALL_BUCKETS_VOL_READ_ROLE} ${PARTIAL_LIST_ALL_BUCKETS_VOL_LIST_ROLE} ${PARTIAL_BUCKET_READ_ROLE} ${PARTIAL_BUCKET_READ_UPLOAD_PREFIX_ROLE} ${PARTIAL_BUCKET_LIST_ROLE} ${PARTIAL_BUCKET_READ_ACL_ROLE} ${PARTIAL_PUT_OBJECT_KEY_CREATE_ROLE} ${PARTIAL_PUT_OBJECT_KEY_WRITE_ROLE} + ${role_json} = Set Variable { "name": "${role}", "description": "Partial access role" } + Create Ranger Role ${role_json} + END + +Create Partial Access Assume Role Policies + FOR ${role} IN ${PARTIAL_LIST_ALL_BUCKETS_VOL_READ_ROLE} ${PARTIAL_LIST_ALL_BUCKETS_VOL_LIST_ROLE} ${PARTIAL_BUCKET_READ_ROLE} ${PARTIAL_BUCKET_READ_UPLOAD_PREFIX_ROLE} ${PARTIAL_BUCKET_LIST_ROLE} ${PARTIAL_BUCKET_READ_ACL_ROLE} ${PARTIAL_PUT_OBJECT_KEY_CREATE_ROLE} ${PARTIAL_PUT_OBJECT_KEY_WRITE_ROLE} + Create Ranger Assume Role Policy ${role} ${ICEBERG_SVC_CATALOG_USER} + END + +Create Partial Access Volume Policies + # Append partial-role items to existing "iceberg volume access" policy to avoid duplicate resourceSignature conflicts. + ${policy_items} = Set Variable [ { "accesses": [ { "type": "read", "isAllowed": true } ], "roles": [ "${PARTIAL_LIST_ALL_BUCKETS_VOL_READ_ROLE}" ], "delegateAdmin": false }, { "accesses": [ { "type": "list", "isAllowed": true } ], "roles": [ "${PARTIAL_LIST_ALL_BUCKETS_VOL_LIST_ROLE}" ], "delegateAdmin": false }, { "accesses": [ { "type": "read", "isAllowed": true } ], "roles": [ "${PARTIAL_BUCKET_READ_ROLE}", "${PARTIAL_BUCKET_READ_UPLOAD_PREFIX_ROLE}", "${PARTIAL_BUCKET_LIST_ROLE}", "${PARTIAL_BUCKET_READ_ACL_ROLE}", "${PARTIAL_PUT_OBJECT_KEY_CREATE_ROLE}", "${PARTIAL_PUT_OBJECT_KEY_WRITE_ROLE}" ], "delegateAdmin": false } ] + Update Ranger Policy Items iceberg volume access ${policy_items} + +Create Partial Access Bucket Policies + # Append partial-role items to existing "iceberg ${ICEBERG_BUCKET_OBS} bucket access" policy. + ${policy_items} = Set Variable [ { "accesses": [ { "type": "read", "isAllowed": true } ], "roles": [ "${PARTIAL_BUCKET_READ_ROLE}" ], "delegateAdmin": false }, { "accesses": [ { "type": "read", "isAllowed": true } ], "roles": [ "${PARTIAL_BUCKET_READ_UPLOAD_PREFIX_ROLE}" ], "delegateAdmin": false }, { "accesses": [ { "type": "list", "isAllowed": true } ], "roles": [ "${PARTIAL_BUCKET_LIST_ROLE}" ], "delegateAdmin": false }, { "accesses": [ { "type": "read_acl", "isAllowed": true } ], "roles": [ "${PARTIAL_BUCKET_READ_ACL_ROLE}" ], "delegateAdmin": false }, { "accesses": [ { "type": "read", "isAllowed": true } ], "roles": [ "${PARTIAL_PUT_OBJECT_KEY_CREATE_ROLE}", "${PARTIAL_PUT_OBJECT_KEY_WRITE_ROLE}" ], "delegateAdmin": false } ] + Update Ranger Policy Items iceberg ${ICEBERG_BUCKET_OBS} bucket access ${policy_items} + +Create Partial Access Table Policies + # Append partial-role items to existing "iceberg ${ICEBERG_BUCKET_OBS} table access" policy. + ${policy_items} = Set Variable [ { "accesses": [ { "type": "list", "isAllowed": true } ], "roles": [ "${PARTIAL_BUCKET_READ_ROLE}", "${PARTIAL_BUCKET_LIST_ROLE}" ], "delegateAdmin": false }, { "accesses": [ { "type": "create", "isAllowed": true } ], "roles": [ "${PARTIAL_PUT_OBJECT_KEY_CREATE_ROLE}" ], "delegateAdmin": false }, { "accesses": [ { "type": "write", "isAllowed": true } ], "roles": [ "${PARTIAL_PUT_OBJECT_KEY_WRITE_ROLE}" ], "delegateAdmin": false } ] + Update Ranger Policy Items iceberg ${ICEBERG_BUCKET_OBS} table access ${policy_items} + # One-off policy for Negative A in "STS session policy ListBucket must require bucket READ and LIST": + # Grant LIST on key prefix "upload*" (not "*") so it doesn't implicitly satisfy bucket-level LIST in Ranger matching. + ${upload_prefix_policy} = Set Variable { "isEnabled": true, "service": "dev_ozone", "name": "iceberg ${ICEBERG_BUCKET_OBS} upload prefix list", "policyType": 0, "policyPriority": 0, "isAuditEnabled": true, "resources": { "volume": { "values": [ "s3v" ], "isExcludes": false, "isRecursive": false }, "bucket": { "values": [ "${ICEBERG_BUCKET_OBS}" ], "isExcludes": false, "isRecursive": false }, "key": { "values": [ "upload*" ], "isExcludes": false, "isRecursive": true } }, "policyItems": [ { "accesses": [ { "type": "list", "isAllowed": true } ], "roles": [ "${PARTIAL_BUCKET_READ_UPLOAD_PREFIX_ROLE}" ], "delegateAdmin": false } ], "serviceType": "ozone", "isDenyAllElse": false } + Create Ranger Policy ${upload_prefix_policy} + +Create Action Matches Roles in Ranger + FOR ${role} IN ${ACTION_MATCHES_PUTOBJECT_READ_ROLE} ${ACTION_MATCHES_PUTOBJECT_CREATE_WRITE_ROLE} ${ACTION_MATCHES_GETOBJECT_PUTOBJECT_ROLE} ${ACTION_MATCHES_UPLOADPARTCOPY_EXPECTED_OWNER_ROLE} ${ACTION_MATCHES_GET_STAR_READ_ROLE} + ${role_json} = Set Variable { "name": "${role}", "description": "Action-matches scoped role" } + Create Ranger Role ${role_json} + END + +Create Action Matches Assume Role Policies + FOR ${role} IN ${ACTION_MATCHES_PUTOBJECT_READ_ROLE} ${ACTION_MATCHES_PUTOBJECT_CREATE_WRITE_ROLE} ${ACTION_MATCHES_GETOBJECT_PUTOBJECT_ROLE} ${ACTION_MATCHES_UPLOADPARTCOPY_EXPECTED_OWNER_ROLE} ${ACTION_MATCHES_GET_STAR_READ_ROLE} + Create Ranger Assume Role Policy ${role} ${ICEBERG_SVC_CATALOG_USER} + END + +Create Action Matches Volume Policies + # Apply action-matches conditions at volume level so READ is scoped to the intended S3 action. + ${policy_items} = Set Variable [ { "accesses": [ { "type": "read", "isAllowed": true } ], "roles": [ "${ACTION_MATCHES_PUTOBJECT_READ_ROLE}" ], "conditions": [ { "type": "action-matches", "values": [ "PutObject" ] } ], "delegateAdmin": false }, { "accesses": [ { "type": "read", "isAllowed": true } ], "roles": [ "${ACTION_MATCHES_PUTOBJECT_CREATE_WRITE_ROLE}" ], "conditions": [ { "type": "action-matches", "values": [ "PutObject" ] } ], "delegateAdmin": false }, { "accesses": [ { "type": "read", "isAllowed": true } ], "roles": [ "${ACTION_MATCHES_GETOBJECT_PUTOBJECT_ROLE}" ], "conditions": [ { "type": "action-matches", "values": [ "GetObject", "PutObject" ] } ], "delegateAdmin": false }, { "accesses": [ { "type": "read", "isAllowed": true } ], "roles": [ "${ACTION_MATCHES_UPLOADPARTCOPY_EXPECTED_OWNER_ROLE}" ], "conditions": [ { "type": "action-matches", "values": [ "GetObject", "PutObject" ] } ], "delegateAdmin": false }, { "accesses": [ { "type": "read", "isAllowed": true } ], "roles": [ "${ACTION_MATCHES_GET_STAR_READ_ROLE}" ], "conditions": [ { "type": "action-matches", "values": [ "Get*" ] } ], "delegateAdmin": false } ] + Update Ranger Policy Items iceberg volume access ${policy_items} + +Create Action Matches Bucket Policies + ${policy_items} = Set Variable [ { "accesses": [ { "type": "read", "isAllowed": true } ], "roles": [ "${ACTION_MATCHES_PUTOBJECT_READ_ROLE}" ], "conditions": [ { "type": "action-matches", "values": [ "PutObject" ] } ], "delegateAdmin": false }, { "accesses": [ { "type": "read", "isAllowed": true } ], "roles": [ "${ACTION_MATCHES_PUTOBJECT_CREATE_WRITE_ROLE}" ], "conditions": [ { "type": "action-matches", "values": [ "PutObject" ] } ], "delegateAdmin": false }, { "accesses": [ { "type": "read", "isAllowed": true } ], "roles": [ "${ACTION_MATCHES_GETOBJECT_PUTOBJECT_ROLE}" ], "conditions": [ { "type": "action-matches", "values": [ "GetObject", "PutObject" ] } ], "delegateAdmin": false }, { "accesses": [ { "type": "read", "isAllowed": true } ], "roles": [ "${ACTION_MATCHES_UPLOADPARTCOPY_EXPECTED_OWNER_ROLE}" ], "conditions": [ { "type": "action-matches", "values": [ "GetObject", "PutObject" ] } ], "delegateAdmin": false }, { "accesses": [ { "type": "read", "isAllowed": true } ], "roles": [ "${ACTION_MATCHES_GET_STAR_READ_ROLE}" ], "conditions": [ { "type": "action-matches", "values": [ "Get*" ] } ], "delegateAdmin": false } ] + Update Ranger Policy Items iceberg ${ICEBERG_BUCKET_OBS} bucket access ${policy_items} + +Create Action Matches Table Policies + # READ with action-matches=PutObject must not authorize PutObject; CREATE+WRITE with action-matches=PutObject must only authorize PutObject. + # READ+CREATE+WRITE with action-matches=GetObject and PutObject supports UploadPartCopy source read and destination write. + # READ with action-matches=Get* must authorize GetObject but not PutObject. + ${policy_items} = Set Variable [ { "accesses": [ { "type": "read", "isAllowed": true } ], "roles": [ "${ACTION_MATCHES_PUTOBJECT_READ_ROLE}" ], "conditions": [ { "type": "action-matches", "values": [ "PutObject" ] } ], "delegateAdmin": false }, { "accesses": [ { "type": "create", "isAllowed": true }, { "type": "write", "isAllowed": true } ], "roles": [ "${ACTION_MATCHES_PUTOBJECT_CREATE_WRITE_ROLE}" ], "conditions": [ { "type": "action-matches", "values": [ "PutObject" ] } ], "delegateAdmin": false }, { "accesses": [ { "type": "read", "isAllowed": true }, { "type": "create", "isAllowed": true }, { "type": "write", "isAllowed": true } ], "roles": [ "${ACTION_MATCHES_GETOBJECT_PUTOBJECT_ROLE}" ], "conditions": [ { "type": "action-matches", "values": [ "GetObject", "PutObject" ] } ], "delegateAdmin": false }, { "accesses": [ { "type": "read", "isAllowed": true }, { "type": "create", "isAllowed": true }, { "type": "write", "isAllowed": true } ], "roles": [ "${ACTION_MATCHES_UPLOADPARTCOPY_EXPECTED_OWNER_ROLE}" ], "conditions": [ { "type": "action-matches", "values": [ "GetObject", "PutObject" ] } ], "delegateAdmin": false }, { "accesses": [ { "type": "read", "isAllowed": true } ], "roles": [ "${ACTION_MATCHES_GET_STAR_READ_ROLE}" ], "conditions": [ { "type": "action-matches", "values": [ "Get*" ] } ], "delegateAdmin": false } ] + Update Ranger Policy Items iceberg ${ICEBERG_BUCKET_OBS} table access ${policy_items} + +Get S3 Credentials for Service Catalog Principal, Create Iceberg Buckets, and Upload Files + Kinit test user ${ICEBERG_SVC_CATALOG_USER} ${ICEBERG_SVC_CATALOG_USER}.keytab + + # Waiting for Ranger policy cache refresh - ${ICEBERG_SVC_CATALOG_USER} needs to be able to read s3v volume + Wait Until Keyword Succeeds 30s 5s Execute ozone sh volume info s3v + + ${output} = Execute ozone s3 getsecret + ${accessKeyId} = Get Regexp Matches ${output} (?<=awsAccessKey=).* + ${secretKey} = Get Regexp Matches ${output} (?<=awsSecret=).* + ${accessKeyId} = Set Variable ${accessKeyId[0]} + ${secretKey} = Set Variable ${secretKey[0]} + Set Global Variable ${PERMANENT_ACCESS_KEY_ID} ${accessKeyId} + Set Global Variable ${PERMANENT_SECRET_KEY} ${secretKey} + + # Create buckets as a different user so the permanent credential principal isn't the bucket owner. + # Otherwise Ranger's default owner privileges can mask missing READ/LIST permissions in STS tests. + # Populate the buckets as this user as well. + Kinit test user hdfs hdfs.keytab + Execute ozone sh bucket create --layout ${ICEBERG_LAYOUT_OBS} /s3v/${ICEBERG_BUCKET_OBS} + Execute ozone sh bucket create --layout ${ICEBERG_LAYOUT_FSO} /s3v/${ICEBERG_BUCKET_FSO} + Populate Iceberg Bucket ${ICEBERG_BUCKET_OBS} + Populate Iceberg Bucket ${ICEBERG_BUCKET_FSO} + + # Switch back to the service catalog principal for running S3/STS requests. + Kinit test user ${ICEBERG_SVC_CATALOG_USER} ${ICEBERG_SVC_CATALOG_USER}.keytab + +Assume Role for Limited-Scope Token + # All access role is limited to read-only via session policy + FOR ${bucket} ${role_arn} IN + ... ${ICEBERG_BUCKET_OBS} ${ICEBERG_ALL_ACCESS_ROLE_OBS_ARN} + ... ${ICEBERG_BUCKET_FSO} ${ICEBERG_ALL_ACCESS_ROLE_FSO_ARN} + ${session_policy} = Set Variable {"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:GetObject","Resource":"arn:aws:s3:::${bucket}/*"}]} + Assume Role And Configure STS Profile policy_json=${session_policy} perm_access_key_id=${PERMANENT_ACCESS_KEY_ID} perm_secret_key=${PERMANENT_SECRET_KEY} role_arn=${role_arn} + ${bucketSuffix} = Generate Random String 8 [LOWER] + ${tmp_bucket} = Set Variable sts-bucket-${bucketSuffix} + Create Bucket Should Fail ${tmp_bucket} + Get Object Should Succeed ${bucket} ${ICEBERG_BUCKET_TESTFILE} + Put Object Should Fail ${bucket} ${ICEBERG_BUCKET_TESTFILE} AccessDenied + END + +Assume Role for Role-Scoped Token + # Create token with full permissions of all access role + FOR ${bucket} ${role_arn} IN + ... ${ICEBERG_BUCKET_OBS} ${ICEBERG_ALL_ACCESS_ROLE_OBS_ARN} + ... ${ICEBERG_BUCKET_FSO} ${ICEBERG_ALL_ACCESS_ROLE_FSO_ARN} + Assume Role And Configure STS Profile perm_access_key_id=${PERMANENT_ACCESS_KEY_ID} perm_secret_key=${PERMANENT_SECRET_KEY} role_arn=${role_arn} + ${bucketSuffix} = Generate Random String 8 [LOWER] + ${tmp_bucket} = Set Variable sts-bucket-${bucketSuffix} + Create Bucket Should Fail ${tmp_bucket} + Get Object Should Succeed ${bucket} ${ICEBERG_BUCKET_TESTFILE} + Put Object Should Succeed ${bucket} ${ICEBERG_BUCKET_TESTFILE} + END + +Assume Role with Invalid Action in Session Policy + # s3:InvalidAction in the session policy is not valid => no access is given to the token + FOR ${bucket} ${role_arn} IN + ... ${ICEBERG_BUCKET_OBS} ${ICEBERG_ALL_ACCESS_ROLE_OBS_ARN} + ... ${ICEBERG_BUCKET_FSO} ${ICEBERG_ALL_ACCESS_ROLE_FSO_ARN} + ${session_policy} = Set Variable {"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:InvalidAction","Resource":"arn:aws:s3:::${bucket}/*"}]} + Assume Role And Configure STS Profile policy_json=${session_policy} perm_access_key_id=${PERMANENT_ACCESS_KEY_ID} perm_secret_key=${PERMANENT_SECRET_KEY} role_arn=${role_arn} + Get Object Should Fail ${bucket} ${ICEBERG_BUCKET_TESTFILE} AccessDenied + Put Object Should Fail ${bucket} ${ICEBERG_BUCKET_TESTFILE} AccessDenied + END + +Assume Role with Mismatched Action and Resource in Session Policy + # s3:GetObject is for object resources but the Resource "arn:aws:s3:::bucket" is a bucket resource => no access is given to the token + FOR ${bucket} ${role_arn} IN + ... ${ICEBERG_BUCKET_OBS} ${ICEBERG_ALL_ACCESS_ROLE_OBS_ARN} + ... ${ICEBERG_BUCKET_FSO} ${ICEBERG_ALL_ACCESS_ROLE_FSO_ARN} + ${session_policy} = Set Variable {"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:GetObject","Resource":"arn:aws:s3:::${bucket}"}]} + Assume Role And Configure STS Profile policy_json=${session_policy} perm_access_key_id=${PERMANENT_ACCESS_KEY_ID} perm_secret_key=${PERMANENT_SECRET_KEY} role_arn=${role_arn} + Get Object Should Fail ${bucket} ${ICEBERG_BUCKET_TESTFILE} AccessDenied + Put Object Should Fail ${bucket} ${ICEBERG_BUCKET_TESTFILE} AccessDenied + END + +Assume Role with Elevated Access in Session Policy Should Fail + # Assume read-only role but try to grant write access in session policy - this should not be allowed => no access given to the token + FOR ${bucket} ${read_only_role_arn} IN + ... ${ICEBERG_BUCKET_OBS} ${READ_ONLY_ROLE_OBS_ARN} + ... ${ICEBERG_BUCKET_FSO} ${READ_ONLY_ROLE_FSO_ARN} + ${session_policy} = Set Variable {"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:PutObject","Resource":"arn:aws:s3:::${bucket}/*"}]} + Assume Role And Configure STS Profile policy_json=${session_policy} perm_access_key_id=${PERMANENT_ACCESS_KEY_ID} perm_secret_key=${PERMANENT_SECRET_KEY} role_arn=${read_only_role_arn} + Get Object Should Fail ${bucket} ${ICEBERG_BUCKET_TESTFILE} AccessDenied + Put Object Should Fail ${bucket} ${ICEBERG_BUCKET_TESTFILE} AccessDenied + END + +Assume Role with Malformed Session Policy JSON Should Fail + ${session_policy} = Set Variable {"ThisIsMalformed"} + FOR ${role_arn} IN ${ICEBERG_ALL_ACCESS_ROLE_OBS_ARN} ${ICEBERG_ALL_ACCESS_ROLE_FSO_ARN} + Assume Role Should Fail perm_access_key_id=${PERMANENT_ACCESS_KEY_ID} perm_secret_key=${PERMANENT_SECRET_KEY} policy_json=${session_policy} expected_error=MalformedPolicyDocument expected_http_code=400 role_arn=${role_arn} + END + +Assume Role with Unsupported Condition Operator in Session Policy Should Fail + # StringNotEqualsIgnoreCase is currently unsupported + FOR ${bucket} ${role_arn} IN + ... ${ICEBERG_BUCKET_OBS} ${ICEBERG_ALL_ACCESS_ROLE_OBS_ARN} + ... ${ICEBERG_BUCKET_FSO} ${ICEBERG_ALL_ACCESS_ROLE_FSO_ARN} + ${session_policy} = Set Variable {"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:GetObject","Resource":"arn:aws:s3:::${bucket}/*","Condition":{"StringNotEqualsIgnoreCase":{"s3:prefix":"my_table/*"}}}]} + Assume Role Should Fail perm_access_key_id=${PERMANENT_ACCESS_KEY_ID} perm_secret_key=${PERMANENT_SECRET_KEY} policy_json=${session_policy} expected_error=UnsupportedOperation expected_http_code=501 role_arn=${role_arn} + END + +Assume Role with GetObject in Session Policy Using s3:prefix Should Fail + FOR ${bucket} ${role_arn} IN + ... ${ICEBERG_BUCKET_OBS} ${ICEBERG_ALL_ACCESS_ROLE_OBS_ARN} + ... ${ICEBERG_BUCKET_FSO} ${ICEBERG_ALL_ACCESS_ROLE_FSO_ARN} + ${session_policy} = Set Variable {"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:GetObject","Resource":"arn:aws:s3:::${bucket}/*","Condition":{"StringLike":{"s3:prefix":"file1*"}}}]} + Assume Role And Configure STS Profile policy_json=${session_policy} perm_access_key_id=${PERMANENT_ACCESS_KEY_ID} perm_secret_key=${PERMANENT_SECRET_KEY} role_arn=${role_arn} + Get Object Should Fail ${bucket} ${ICEBERG_BUCKET_TESTFILE} AccessDenied + END + +Assume Role with GetObject in Session Policy Should Not Allow List Buckets + FOR ${bucket} ${role_arn} IN + ... ${ICEBERG_BUCKET_OBS} ${ICEBERG_ALL_ACCESS_ROLE_OBS_ARN} + ... ${ICEBERG_BUCKET_FSO} ${ICEBERG_ALL_ACCESS_ROLE_FSO_ARN} + ${session_policy} = Set Variable {"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:GetObject","Resource":"arn:aws:s3:::${bucket}/*"}]} + Assume Role And Configure STS Profile policy_json=${session_policy} perm_access_key_id=${PERMANENT_ACCESS_KEY_ID} perm_secret_key=${PERMANENT_SECRET_KEY} role_arn=${role_arn} + List Object Keys Should Fail ${bucket} list-objects AccessDenied + List Object Keys Should Fail ${bucket} list-objects-v2 AccessDenied + END + +STS Token with Bogus Credential Part Should Fail + # If two of the three STS credential components are valid and one is bogus, the token must not give any access + FOR ${bucket} ${role_arn} IN + ... ${ICEBERG_BUCKET_OBS} ${ICEBERG_ALL_ACCESS_ROLE_OBS_ARN} + ... ${ICEBERG_BUCKET_FSO} ${ICEBERG_ALL_ACCESS_ROLE_FSO_ARN} + Assume Role And Get Temporary Credentials perm_access_key_id=${PERMANENT_ACCESS_KEY_ID} perm_secret_key=${PERMANENT_SECRET_KEY} role_arn=${role_arn} + FOR ${bogus_part} IN accessKeyId secretKey sessionToken + Configure STS Profile With Bogus Credential Part ${bogus_part} + ${bucketSuffix} = Generate Random String 8 [LOWER] + ${tmp_bucket} = Set Variable sts-bucket-${bucketSuffix} + Create Bucket Should Fail ${tmp_bucket} + Get Object Should Fail ${bucket} ${ICEBERG_BUCKET_TESTFILE} AccessDenied + Put Object Should Fail ${bucket} ${ICEBERG_BUCKET_TESTFILE} AccessDenied + END + END + +Assume Role Without Ranger Permission Should Fail + # A user who doesn't have assume role permission in Ranger should not be able to invoke the api + Kinit test user ${TEST_USER_NON_ADMIN} ${TEST_USER_NON_ADMIN}.keytab + + ${output} = Execute ozone s3 getsecret + ${accessKeyId} = Get Regexp Matches ${output} (?<=awsAccessKey=).* + ${secretKey} = Get Regexp Matches ${output} (?<=awsSecret=).* + ${accessKeyId} = Set Variable ${accessKeyId[0]} + ${secretKey} = Set Variable ${secretKey[0]} + + FOR ${role_arn} IN ${ICEBERG_ALL_ACCESS_ROLE_OBS_ARN} ${ICEBERG_ALL_ACCESS_ROLE_FSO_ARN} + Assume Role Should Fail perm_access_key_id=${accessKeyId} perm_secret_key=${secretKey} expected_error=AccessDenied role_arn=${role_arn} + END + +Assume Role With Incorrect Permanent Credentials Should Fail + Kinit test user ${ICEBERG_SVC_CATALOG_USER} ${ICEBERG_SVC_CATALOG_USER}.keytab + + FOR ${role_arn} IN ${ICEBERG_ALL_ACCESS_ROLE_OBS_ARN} ${ICEBERG_ALL_ACCESS_ROLE_FSO_ARN} + Assume Role Should Fail perm_access_key_id=${PERMANENT_ACCESS_KEY_ID} perm_secret_key=InvalidSecretKey expected_error=InvalidClientTokenId role_arn=${role_arn} + END + +STS Token with Presigned URL + FOR ${bucket} ${role_arn} IN + ... ${ICEBERG_BUCKET_OBS} ${ICEBERG_ALL_ACCESS_ROLE_OBS_ARN} + ... ${ICEBERG_BUCKET_FSO} ${ICEBERG_ALL_ACCESS_ROLE_FSO_ARN} + Assume Role And Configure STS Profile perm_access_key_id=${PERMANENT_ACCESS_KEY_ID} perm_secret_key=${PERMANENT_SECRET_KEY} role_arn=${role_arn} + ${presigned_url} = Execute aws s3 presign s3://${bucket}/${ICEBERG_BUCKET_TESTFILE} --endpoint-url ${S3G_ENDPOINT_URL} --profile sts + Should Contain ${presigned_url} X-Amz-Algorithm=AWS4-HMAC-SHA256 + Should Contain ${presigned_url} X-Amz-Security-Token= + ${output} = Execute curl -v '${presigned_url}' + Should Contain ${output} HTTP/1.1 200 OK + END + +Verify Token Revocation via CLI + FOR ${bucket} ${role_arn} IN + ... ${ICEBERG_BUCKET_OBS} ${ICEBERG_ALL_ACCESS_ROLE_OBS_ARN} + ... ${ICEBERG_BUCKET_FSO} ${ICEBERG_ALL_ACCESS_ROLE_FSO_ARN} + Assume Role And Configure STS Profile perm_access_key_id=${PERMANENT_ACCESS_KEY_ID} perm_secret_key=${PERMANENT_SECRET_KEY} role_arn=${role_arn} + ${output} = Execute ozone s3 revokeststoken -t ${STS_SESSION_TOKEN} -y ${OM_HA_PARAM} + Should Contain ${output} STS token revoked for sessionToken + # Trying to use the token for even get-object should now fail. + Get Object Should Fail ${bucket} ${ICEBERG_BUCKET_TESTFILE} AccessDenied + END + +Non-Admin Cannot Revoke STS Token + FOR ${role_arn} IN ${ICEBERG_ALL_ACCESS_ROLE_OBS_ARN} ${ICEBERG_ALL_ACCESS_ROLE_FSO_ARN} + # Create a token first. + Assume Role And Get Temporary Credentials perm_access_key_id=${PERMANENT_ACCESS_KEY_ID} perm_secret_key=${PERMANENT_SECRET_KEY} role_arn=${role_arn} + ${token_to_revoke} = Set Variable ${STS_SESSION_TOKEN} + + # Kinit as non-admin user. + Kinit test user ${TEST_USER_NON_ADMIN} ${TEST_USER_NON_ADMIN}.keytab + + # Try to revoke - should give USER_MISMATCH error. + ${output} = Execute And Ignore Error ozone s3 revokeststoken -t ${token_to_revoke} -y ${OM_HA_PARAM} + Should Contain ${output} USER_MISMATCH + END + +List Objects V1 and V2 IAM Session Policy Matrix for OBS and FSO + Kinit test user ${ICEBERG_SVC_CATALOG_USER} ${ICEBERG_SVC_CATALOG_USER}.keytab + + FOR ${bucket} ${role_arn} IN + ... ${ICEBERG_BUCKET_OBS} ${ICEBERG_ALL_ACCESS_ROLE_OBS_ARN} + ... ${ICEBERG_BUCKET_FSO} ${ICEBERG_ALL_ACCESS_ROLE_FSO_ARN} + FOR ${api} IN list-objects list-objects-v2 + Run List Prefix And Delimiter Policy Matrix For Bucket And Api ${bucket} ${role_arn} ${api} + END + END + +Assume Role Request With Oversized Payload Should Fail + ${large_policy} = Generate Oversized Session Policy + Assume Role Should Fail perm_access_key_id=${PERMANENT_ACCESS_KEY_ID} perm_secret_key=${PERMANENT_SECRET_KEY} policy_json=${large_policy} expected_error=PayloadTooLarge role_arn=${ICEBERG_ALL_ACCESS_ROLE_OBS_ARN} + +Tampered STS Token Service, Policy, or Signature Must Fail + # Taking valid STS session token and mutating different parts of it must render it unusable + ${session_policy} = Set Variable {"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:GetObject","Resource":"arn:aws:s3:::${ICEBERG_BUCKET_OBS}/*"}]} + Assume Role And Get Temporary Credentials policy_json=${session_policy} perm_access_key_id=${PERMANENT_ACCESS_KEY_ID} perm_secret_key=${PERMANENT_SECRET_KEY} role_arn=${ICEBERG_ALL_ACCESS_ROLE_OBS_ARN} + ${token_with_service_tamper} = Mutate STS Session Token ${STS_SESSION_TOKEN} service + Configure STS Profile ${STS_ACCESS_KEY_ID} ${STS_SECRET_KEY} ${token_with_service_tamper} + Get Object Should Fail ${ICEBERG_BUCKET_OBS} ${ICEBERG_BUCKET_TESTFILE} AccessDenied + Put Object Should Fail ${ICEBERG_BUCKET_OBS} ${ICEBERG_BUCKET_TESTFILE} AccessDenied + + ${token_with_policy_tamper} = Mutate STS Session Token ${STS_SESSION_TOKEN} session_policy + Configure STS Profile ${STS_ACCESS_KEY_ID} ${STS_SECRET_KEY} ${token_with_policy_tamper} + Get Object Should Fail ${ICEBERG_BUCKET_OBS} ${ICEBERG_BUCKET_TESTFILE} AccessDenied + Put Object Should Fail ${ICEBERG_BUCKET_OBS} ${ICEBERG_BUCKET_TESTFILE} AccessDenied + + ${token_with_signature_tamper} = Mutate STS Session Token ${STS_SESSION_TOKEN} signature + Configure STS Profile ${STS_ACCESS_KEY_ID} ${STS_SECRET_KEY} ${token_with_signature_tamper} + Get Object Should Fail ${ICEBERG_BUCKET_OBS} ${ICEBERG_BUCKET_TESTFILE} AccessDenied + Put Object Should Fail ${ICEBERG_BUCKET_OBS} ${ICEBERG_BUCKET_TESTFILE} AccessDenied + +Assume Role Session Policy With Multiple Buckets Should Access All Buckets + ${session_policy} = Set Variable {"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:GetObject","Resource":"arn:aws:s3:::${ICEBERG_BUCKET_OBS}/*"},{"Effect":"Allow","Action":"s3:GetObject","Resource":"arn:aws:s3:::${ICEBERG_BUCKET_FSO}/*"}]} + Assume Role And Get Temporary Credentials policy_json=${session_policy} perm_access_key_id=${PERMANENT_ACCESS_KEY_ID} perm_secret_key=${PERMANENT_SECRET_KEY} role_arn=${ICEBERG_MULTI_BUCKET_ROLE_ARN} + Configure STS Profile ${STS_ACCESS_KEY_ID} ${STS_SECRET_KEY} ${STS_SESSION_TOKEN} + Get Object Should Succeed ${ICEBERG_BUCKET_OBS} ${ICEBERG_BUCKET_TESTFILE} + Get Object Should Succeed ${ICEBERG_BUCKET_FSO} ${ICEBERG_BUCKET_TESTFILE} + Put Object Should Fail ${ICEBERG_BUCKET_OBS} ${ICEBERG_BUCKET_TESTFILE} AccessDenied + Put Object Should Fail ${ICEBERG_BUCKET_FSO} ${ICEBERG_BUCKET_TESTFILE} AccessDenied + +Assume Role Session Policy With Wildcard Bucket Should Work + ${session_policy} = Set Variable {"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:GetObject","Resource":"arn:aws:s3:::iceberg-*/*"}]} + Assume Role And Get Temporary Credentials policy_json=${session_policy} perm_access_key_id=${PERMANENT_ACCESS_KEY_ID} perm_secret_key=${PERMANENT_SECRET_KEY} role_arn=${ICEBERG_MULTI_BUCKET_ROLE_ARN} + Configure STS Profile ${STS_ACCESS_KEY_ID} ${STS_SECRET_KEY} ${STS_SESSION_TOKEN} + Get Object Should Succeed ${ICEBERG_BUCKET_OBS} ${ICEBERG_BUCKET_TESTFILE} + Get Object Should Succeed ${ICEBERG_BUCKET_FSO} ${ICEBERG_BUCKET_TESTFILE} + Put Object Should Fail ${ICEBERG_BUCKET_OBS} ${ICEBERG_BUCKET_TESTFILE} AccessDenied + +Assume Role Without Duration Should Default To One Hour + # The Assume Role and Get Temporary Credentials keyword has a check for default expiration of 3600 seconds (i.e. one hour) if duration is not supplied, which it is not supplied here + Assume Role And Get Temporary Credentials perm_access_key_id=${PERMANENT_ACCESS_KEY_ID} perm_secret_key=${PERMANENT_SECRET_KEY} role_arn=${ICEBERG_ALL_ACCESS_ROLE_OBS_ARN} duration_seconds=${EMPTY} + Configure STS Profile ${STS_ACCESS_KEY_ID} ${STS_SECRET_KEY} ${STS_SESSION_TOKEN} + Get Object Should Succeed ${ICEBERG_BUCKET_OBS} ${ICEBERG_BUCKET_TESTFILE} + +Assume Role Should Fail For Too Short Role Arn + 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=a + +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 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 + +Assume Role With Session Tags 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=--tags Key=tag-key1,Value=tag-value1 + +Assume Role With ExternalId In Query Should Fail As UnsupportedOperation + Assume Role Should Fail Using Curl Get 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_curl_params=--data-urlencode "ExternalId=test-external-id" + +Assume Role With Session Tags In Query Should Fail As UnsupportedOperation + Assume Role Should Fail Using Curl Get 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_curl_params=--data-urlencode "Tags.member.1.Key=tag-key1" --data-urlencode "Tags.member.1.Value=tag-value1" + +Assume Role With Transitive Tag Keys 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=--transitive-tag-keys tag-key1 + +Assume Role With PolicyArns Should Fail As UnsupportedOperation + Assume Role Should Fail Using Curl 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_curl_params=--data-urlencode "PolicyArns.member.1=arn:aws:iam::123456789012:policy/test-policy" + +Assume Role Response Should Include STS Request Headers + Assume Role Response Headers Should Be Present ${PERMANENT_ACCESS_KEY_ID} ${PERMANENT_SECRET_KEY} role_arn=${ICEBERG_ALL_ACCESS_ROLE_OBS_ARN} + +STS session policy ListAllMyBuckets must require volume READ and LIST + # Positive control + ${session_policy} = Set Variable {"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:ListAllMyBuckets","Resource":"*"}]} + Assume Role And Configure STS Profile policy_json=${session_policy} perm_access_key_id=${PERMANENT_ACCESS_KEY_ID} perm_secret_key=${PERMANENT_SECRET_KEY} role_arn=${ICEBERG_ALL_ACCESS_ROLE_OBS_ARN} + ${output} = Execute aws s3api --endpoint-url ${S3G_ENDPOINT_URL} list-buckets --profile sts + Should Contain ${output} ${ICEBERG_BUCKET_OBS} + + # Negative A: missing LIST + Assume Role And Configure STS Profile policy_json=${session_policy} perm_access_key_id=${PERMANENT_ACCESS_KEY_ID} perm_secret_key=${PERMANENT_SECRET_KEY} role_arn=${PARTIAL_LIST_ALL_BUCKETS_VOL_READ_ROLE_ARN} + ${output} = Execute And Ignore Error aws s3api --endpoint-url ${S3G_ENDPOINT_URL} list-buckets --profile sts + Should Contain ${output} AccessDenied + + # Negative B: missing READ + Assume Role And Configure STS Profile policy_json=${session_policy} perm_access_key_id=${PERMANENT_ACCESS_KEY_ID} perm_secret_key=${PERMANENT_SECRET_KEY} role_arn=${PARTIAL_LIST_ALL_BUCKETS_VOL_LIST_ROLE_ARN} + ${output} = Execute And Ignore Error aws s3api --endpoint-url ${S3G_ENDPOINT_URL} list-buckets --profile sts + Should Contain ${output} AccessDenied + +STS session policy ListBucket must require bucket READ and LIST + # Positive control + ${session_policy} = Set Variable {"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:ListBucket","Resource":"arn:aws:s3:::${ICEBERG_BUCKET_OBS}"}]} + Assume Role And Configure STS Profile policy_json=${session_policy} perm_access_key_id=${PERMANENT_ACCESS_KEY_ID} perm_secret_key=${PERMANENT_SECRET_KEY} role_arn=${ICEBERG_ALL_ACCESS_ROLE_OBS_ARN} + List Object Keys Should Succeed ${ICEBERG_BUCKET_OBS} + + # Negative A: missing LIST + Assume Role And Configure STS Profile policy_json=${session_policy} perm_access_key_id=${PERMANENT_ACCESS_KEY_ID} perm_secret_key=${PERMANENT_SECRET_KEY} role_arn=${PARTIAL_BUCKET_READ_UPLOAD_PREFIX_ROLE_ARN} + List Object Keys Should Fail ${ICEBERG_BUCKET_OBS} list-objects AccessDenied upload + + # Negative B: missing READ + Assume Role And Configure STS Profile policy_json=${session_policy} perm_access_key_id=${PERMANENT_ACCESS_KEY_ID} perm_secret_key=${PERMANENT_SECRET_KEY} role_arn=${PARTIAL_BUCKET_LIST_ROLE_ARN} + List Object Keys Should Fail ${ICEBERG_BUCKET_OBS} list-objects AccessDenied + +STS session policy ListBucketMultipartUploads must require bucket READ and LIST + # Positive control + ${session_policy} = Set Variable {"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:ListBucketMultipartUploads","Resource":"arn:aws:s3:::${ICEBERG_BUCKET_OBS}"}]} + Assume Role And Configure STS Profile policy_json=${session_policy} perm_access_key_id=${PERMANENT_ACCESS_KEY_ID} perm_secret_key=${PERMANENT_SECRET_KEY} role_arn=${ICEBERG_ALL_ACCESS_ROLE_OBS_ARN} + ${output} = Execute aws s3api --endpoint-url ${S3G_ENDPOINT_URL} list-multipart-uploads --bucket ${ICEBERG_BUCKET_OBS} --profile sts + Should Not Contain ${output} AccessDenied + + # Negative A: missing LIST + Assume Role And Configure STS Profile policy_json=${session_policy} perm_access_key_id=${PERMANENT_ACCESS_KEY_ID} perm_secret_key=${PERMANENT_SECRET_KEY} role_arn=${READ_ONLY_ROLE_OBS_ARN} + ${output} = Execute And Ignore Error aws s3api --endpoint-url ${S3G_ENDPOINT_URL} list-multipart-uploads --bucket ${ICEBERG_BUCKET_OBS} --profile sts + Should Contain ${output} AccessDenied + + # Negative B: missing READ + Assume Role And Configure STS Profile policy_json=${session_policy} perm_access_key_id=${PERMANENT_ACCESS_KEY_ID} perm_secret_key=${PERMANENT_SECRET_KEY} role_arn=${PARTIAL_BUCKET_LIST_ROLE_ARN} + ${output} = Execute And Ignore Error aws s3api --endpoint-url ${S3G_ENDPOINT_URL} list-multipart-uploads --bucket ${ICEBERG_BUCKET_OBS} --profile sts + Should Contain ${output} AccessDenied + +STS session policy GetBucketAcl must require bucket READ and READ_ACL + # Positive control + ${session_policy} = Set Variable {"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:GetBucketAcl","Resource":"arn:aws:s3:::${ICEBERG_BUCKET_OBS}"}]} + Assume Role And Configure STS Profile policy_json=${session_policy} perm_access_key_id=${PERMANENT_ACCESS_KEY_ID} perm_secret_key=${PERMANENT_SECRET_KEY} role_arn=${ICEBERG_ALL_ACCESS_ROLE_OBS_ARN} + ${output} = Execute aws s3api --endpoint-url ${S3G_ENDPOINT_URL} get-bucket-acl --bucket ${ICEBERG_BUCKET_OBS} --profile sts + Should Contain ${output} Owner + + # Negative A: missing READ_ACL + Assume Role And Configure STS Profile policy_json=${session_policy} perm_access_key_id=${PERMANENT_ACCESS_KEY_ID} perm_secret_key=${PERMANENT_SECRET_KEY} role_arn=${PARTIAL_BUCKET_READ_ROLE_ARN} + ${output} = Execute And Ignore Error aws s3api --endpoint-url ${S3G_ENDPOINT_URL} get-bucket-acl --bucket ${ICEBERG_BUCKET_OBS} --profile sts + Should Contain ${output} AccessDenied + + # Negative B: missing READ + Assume Role And Configure STS Profile policy_json=${session_policy} perm_access_key_id=${PERMANENT_ACCESS_KEY_ID} perm_secret_key=${PERMANENT_SECRET_KEY} role_arn=${PARTIAL_BUCKET_READ_ACL_ROLE_ARN} + ${output} = Execute And Ignore Error aws s3api --endpoint-url ${S3G_ENDPOINT_URL} get-bucket-acl --bucket ${ICEBERG_BUCKET_OBS} --profile sts + Should Contain ${output} AccessDenied + +STS session policy PutObject must require key CREATE and WRITE + # Positive control + ${session_policy} = Set Variable {"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:PutObject","Resource":"arn:aws:s3:::${ICEBERG_BUCKET_OBS}/*"}]} + Assume Role And Configure STS Profile policy_json=${session_policy} perm_access_key_id=${PERMANENT_ACCESS_KEY_ID} perm_secret_key=${PERMANENT_SECRET_KEY} role_arn=${ICEBERG_ALL_ACCESS_ROLE_OBS_ARN} + Put Object Should Succeed ${ICEBERG_BUCKET_OBS} ${ICEBERG_BUCKET_TESTFILE} + + # Negative A: missing WRITE + Assume Role And Configure STS Profile policy_json=${session_policy} perm_access_key_id=${PERMANENT_ACCESS_KEY_ID} perm_secret_key=${PERMANENT_SECRET_KEY} role_arn=${PARTIAL_PUT_OBJECT_KEY_CREATE_ROLE_ARN} + Put Object Should Fail ${ICEBERG_BUCKET_OBS} ${ICEBERG_BUCKET_TESTFILE} AccessDenied + + # Negative B: missing CREATE + Assume Role And Configure STS Profile policy_json=${session_policy} perm_access_key_id=${PERMANENT_ACCESS_KEY_ID} perm_secret_key=${PERMANENT_SECRET_KEY} role_arn=${PARTIAL_PUT_OBJECT_KEY_WRITE_ROLE_ARN} + Put Object Should Fail ${ICEBERG_BUCKET_OBS} ${ICEBERG_BUCKET_TESTFILE} AccessDenied + +Ranger action-matches PutObject with READ only must deny PutObject + ${key_suffix} = Generate Random String 8 [LOWER] + ${key} = Set Variable sts-object-${key_suffix}.txt + ${local_path} = Set Variable ${TEMP_DIR}/${key} + Create File ${local_path} action-matches read-only content + ${session_policy} = Set Variable {"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:PutObject","Resource":"arn:aws:s3:::${ICEBERG_BUCKET_OBS}/${key}"}]} + + # Role alone: READ permission scoped to PutObject must not allow PutObject. + Assume Role And Configure STS Profile perm_access_key_id=${PERMANENT_ACCESS_KEY_ID} perm_secret_key=${PERMANENT_SECRET_KEY} role_arn=${ACTION_MATCHES_PUTOBJECT_READ_ROLE_ARN} + Put Object Should Fail ${ICEBERG_BUCKET_OBS} ${key} AccessDenied ${local_path} + + # Role plus session policy PutObject: still must not allow PutObject. + Assume Role And Configure STS Profile policy_json=${session_policy} perm_access_key_id=${PERMANENT_ACCESS_KEY_ID} perm_secret_key=${PERMANENT_SECRET_KEY} role_arn=${ACTION_MATCHES_PUTOBJECT_READ_ROLE_ARN} + Put Object Should Fail ${ICEBERG_BUCKET_OBS} ${key} AccessDenied ${local_path} + +Ranger action-matches PutObject with CREATE and WRITE allows only PutObject + ${key_suffix} = Generate Random String 8 [LOWER] + ${key} = Set Variable sts-object-${key_suffix}.txt + ${local_path} = Set Variable ${TEMP_DIR}/${key} + Create File ${local_path} action-matches create-write content + + Assume Role And Configure STS Profile perm_access_key_id=${PERMANENT_ACCESS_KEY_ID} perm_secret_key=${PERMANENT_SECRET_KEY} role_arn=${ACTION_MATCHES_PUTOBJECT_CREATE_WRITE_ROLE_ARN} + Put Object Should Succeed ${ICEBERG_BUCKET_OBS} ${key} ${local_path} + Get Object Should Fail ${ICEBERG_BUCKET_OBS} ${key} AccessDenied + ${output} = Execute And Ignore Error aws s3api --endpoint-url ${S3G_ENDPOINT_URL} put-object-tagging --bucket ${ICEBERG_BUCKET_OBS} --key ${key} --tagging '{"TagSet":[{"Key":"tag-key1","Value":"tag-value1"}]}' --profile sts + Should Contain ${output} AccessDenied + +Ranger action-matches GetObject with READ allows UploadPartCopy source read + ${src_key} = Set Variable ${ICEBERG_BUCKET_TESTFILE} + ${key_suffix} = Generate Random String 8 [LOWER] + ${dest_key} = Set Variable sts-mpu-action-matches-get-${key_suffix}.txt + + # Positive: GetObject on source + PutObject on destination via action-matches-getobject-putobject Ranger policies and session policy should allow UploadPartCopy. + ${allow_policy} = Set Variable {"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:GetObject","Resource":"arn:aws:s3:::${ICEBERG_BUCKET_OBS}/${src_key}"},{"Effect":"Allow","Action":"s3:PutObject","Resource":"arn:aws:s3:::${ICEBERG_BUCKET_OBS}/${dest_key}"}]} + Assume Role And Configure STS Profile policy_json=${allow_policy} perm_access_key_id=${PERMANENT_ACCESS_KEY_ID} perm_secret_key=${PERMANENT_SECRET_KEY} role_arn=${ACTION_MATCHES_GETOBJECT_PUTOBJECT_ROLE_ARN} + ${output} = Execute aws s3api --endpoint-url ${S3G_ENDPOINT_URL} create-multipart-upload --bucket ${ICEBERG_BUCKET_OBS} --key ${dest_key} --profile sts + ${upload_id} = Execute echo '${output}' | jq -r '.UploadId' + Should Not Be Empty ${upload_id} + ${output} = Execute aws s3api --endpoint-url ${S3G_ENDPOINT_URL} upload-part-copy --bucket ${ICEBERG_BUCKET_OBS} --key ${dest_key} --part-number 1 --upload-id ${upload_id} --copy-source ${ICEBERG_BUCKET_OBS}/${src_key} --profile sts + Should Contain ${output} CopyPartResult + +Ranger action-matches Get* with READ allows GetObject but denies PutObject + ${key_suffix} = Generate Random String 8 [LOWER] + ${key} = Set Variable sts-object-${key_suffix}.txt + ${local_path} = Set Variable ${TEMP_DIR}/${key} + Create File ${local_path} action-matches get-star content + + Assume Role And Configure STS Profile perm_access_key_id=${PERMANENT_ACCESS_KEY_ID} perm_secret_key=${PERMANENT_SECRET_KEY} role_arn=${ACTION_MATCHES_GET_STAR_READ_ROLE_ARN} + Get Object Should Succeed ${ICEBERG_BUCKET_OBS} ${ICEBERG_BUCKET_TESTFILE} + Put Object Should Fail ${ICEBERG_BUCKET_OBS} ${key} AccessDenied ${local_path} + +STS session policy CreateBucket must require bucket CREATE + ${bucket_suffix} = Generate Random String 8 [LOWER] + ${bucket} = Set Variable sts-bucket-${bucket_suffix} + ${create_policy} = Set Variable {"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:CreateBucket","Resource":"arn:aws:s3:::${bucket}"}]} + ${delete_policy} = Set Variable {"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:DeleteBucket","Resource":"arn:aws:s3:::${bucket}"}]} + + # Negative: missing CREATE + Assume Role And Configure STS Profile policy_json=${create_policy} perm_access_key_id=${PERMANENT_ACCESS_KEY_ID} perm_secret_key=${PERMANENT_SECRET_KEY} role_arn=${READ_ONLY_ROLE_OBS_ARN} + ${output} = Execute And Ignore Error aws s3api --endpoint-url ${S3G_ENDPOINT_URL} create-bucket --bucket ${bucket} --profile sts + Should Contain ${output} AccessDenied + + # Positive control + Assume Role And Configure STS Profile policy_json=${create_policy} perm_access_key_id=${PERMANENT_ACCESS_KEY_ID} perm_secret_key=${PERMANENT_SECRET_KEY} role_arn=${STS_TEMP_BUCKET_ROLE_ARN} + ${output} = Execute aws s3api --endpoint-url ${S3G_ENDPOINT_URL} create-bucket --bucket ${bucket} --profile sts + Should Contain ${output} Location + Should Contain ${output} ${bucket} + + # Cleanup + Assume Role And Configure STS Profile policy_json=${delete_policy} perm_access_key_id=${PERMANENT_ACCESS_KEY_ID} perm_secret_key=${PERMANENT_SECRET_KEY} role_arn=${STS_TEMP_BUCKET_ROLE_ARN} + ${output} = Execute aws s3api --endpoint-url ${S3G_ENDPOINT_URL} delete-bucket --bucket ${bucket} --profile sts + Should Not Contain ${output} AccessDenied + +STS session policy DeleteBucket must require bucket DELETE + ${bucket_suffix} = Generate Random String 8 [LOWER] + ${bucket} = Set Variable sts-bucket-${bucket_suffix} + ${delete_policy} = Set Variable {"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:DeleteBucket","Resource":"arn:aws:s3:::${bucket}"}]} + + # Prepare the bucket as a different principal so bucket-owner privileges + # do not mask missing DELETE permissions in the negative case. + Kinit test user hdfs hdfs.keytab + ${output} = Execute ozone sh bucket create --layout ${ICEBERG_LAYOUT_OBS} /s3v/${bucket} + Kinit test user ${ICEBERG_SVC_CATALOG_USER} svc-iceberg-rest-catalog.keytab + + # Negative: missing DELETE + Assume Role And Configure STS Profile policy_json=${delete_policy} perm_access_key_id=${PERMANENT_ACCESS_KEY_ID} perm_secret_key=${PERMANENT_SECRET_KEY} role_arn=${READ_ONLY_ROLE_OBS_ARN} + ${output} = Execute And Ignore Error aws s3api --endpoint-url ${S3G_ENDPOINT_URL} delete-bucket --bucket ${bucket} --profile sts + Should Contain ${output} AccessDenied + + # Positive control + Assume Role And Configure STS Profile policy_json=${delete_policy} perm_access_key_id=${PERMANENT_ACCESS_KEY_ID} perm_secret_key=${PERMANENT_SECRET_KEY} role_arn=${STS_TEMP_BUCKET_ROLE_ARN} + ${output} = Execute aws s3api --endpoint-url ${S3G_ENDPOINT_URL} delete-bucket --bucket ${bucket} --profile sts + Should Not Contain ${output} AccessDenied + +STS session policy PutBucketAcl must require bucket WRITE_ACL + ${session_policy} = Set Variable {"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:PutBucketAcl","Resource":"arn:aws:s3:::${ICEBERG_BUCKET_OBS}"}]} + + # Positive control + Assume Role And Configure STS Profile policy_json=${session_policy} perm_access_key_id=${PERMANENT_ACCESS_KEY_ID} perm_secret_key=${PERMANENT_SECRET_KEY} role_arn=${ICEBERG_ALL_ACCESS_ROLE_OBS_ARN} + ${output} = Execute aws s3api --endpoint-url ${S3G_ENDPOINT_URL} put-bucket-acl --bucket ${ICEBERG_BUCKET_OBS} --grant-read "" --profile sts + Should Not Contain ${output} AccessDenied + + # Negative: missing WRITE_ACL + Assume Role And Configure STS Profile policy_json=${session_policy} perm_access_key_id=${PERMANENT_ACCESS_KEY_ID} perm_secret_key=${PERMANENT_SECRET_KEY} role_arn=${PARTIAL_BUCKET_READ_ROLE_ARN} + ${output} = Execute And Ignore Error aws s3api --endpoint-url ${S3G_ENDPOINT_URL} put-bucket-acl --bucket ${ICEBERG_BUCKET_OBS} --grant-read "" --profile sts + Should Contain ${output} AccessDenied + +STS session policy GetObjectTagging must require key READ + ${key_suffix} = Generate Random String 8 [LOWER] + ${key} = Set Variable sts-object-${key_suffix}.txt + ${local_path} = Set Variable ${TEMP_DIR}/${key} + Create File ${local_path} get-object-tagging content + ${put_policy} = Set Variable {"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:PutObject","Resource":"arn:aws:s3:::${ICEBERG_BUCKET_OBS}/${key}"}]} + ${get_tag_policy} = Set Variable {"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:GetObjectTagging","Resource":"arn:aws:s3:::${ICEBERG_BUCKET_OBS}/${key}"}]} + + Assume Role And Configure STS Profile policy_json=${put_policy} perm_access_key_id=${PERMANENT_ACCESS_KEY_ID} perm_secret_key=${PERMANENT_SECRET_KEY} role_arn=${ICEBERG_ALL_ACCESS_ROLE_OBS_ARN} + Put Object Should Succeed ${ICEBERG_BUCKET_OBS} ${key} ${local_path} + + # Positive control + Assume Role And Configure STS Profile policy_json=${get_tag_policy} perm_access_key_id=${PERMANENT_ACCESS_KEY_ID} perm_secret_key=${PERMANENT_SECRET_KEY} role_arn=${ICEBERG_ALL_ACCESS_ROLE_OBS_ARN} + ${output} = Execute aws s3api --endpoint-url ${S3G_ENDPOINT_URL} get-object-tagging --bucket ${ICEBERG_BUCKET_OBS} --key ${key} --profile sts + Should Contain ${output} TagSet + ${tag_count} = Execute echo '${output}' | jq -r '.TagSet | length' + Should Be Equal As Strings ${tag_count} 0 + + # Negative: missing READ - ${PARTIAL_BUCKET_READ_ROLE_ARN} has LIST permission on the key + Assume Role And Get Temporary Credentials policy_json=${get_tag_policy} perm_access_key_id=${PERMANENT_ACCESS_KEY_ID} perm_secret_key=${PERMANENT_SECRET_KEY} role_arn=${PARTIAL_BUCKET_READ_ROLE_ARN} + Configure STS Profile ${STS_ACCESS_KEY_ID} ${STS_SECRET_KEY} ${STS_SESSION_TOKEN} + ${output} = Execute And Ignore Error aws s3api --endpoint-url ${S3G_ENDPOINT_URL} get-object-tagging --bucket ${ICEBERG_BUCKET_OBS} --key ${key} --profile sts + Should Contain ${output} AccessDenied + +STS session policy PutObjectTagging must require key WRITE + ${key_suffix} = Generate Random String 8 [LOWER] + ${key} = Set Variable sts-object-${key_suffix}.txt + ${local_path} = Set Variable ${TEMP_DIR}/${key} + Create File ${local_path} put-object-tagging content + ${put_policy} = Set Variable {"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:PutObject","Resource":"arn:aws:s3:::${ICEBERG_BUCKET_OBS}/${key}"}]} + ${get_tag_policy} = Set Variable {"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:GetObjectTagging","Resource":"arn:aws:s3:::${ICEBERG_BUCKET_OBS}/${key}"}]} + ${put_tag_policy} = Set Variable {"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:PutObjectTagging","Resource":"arn:aws:s3:::${ICEBERG_BUCKET_OBS}/${key}"}]} + + Assume Role And Configure STS Profile policy_json=${put_policy} perm_access_key_id=${PERMANENT_ACCESS_KEY_ID} perm_secret_key=${PERMANENT_SECRET_KEY} role_arn=${ICEBERG_ALL_ACCESS_ROLE_OBS_ARN} + Put Object Should Succeed ${ICEBERG_BUCKET_OBS} ${key} ${local_path} + + # Positive control + Assume Role And Configure STS Profile policy_json=${put_tag_policy} perm_access_key_id=${PERMANENT_ACCESS_KEY_ID} perm_secret_key=${PERMANENT_SECRET_KEY} role_arn=${ICEBERG_ALL_ACCESS_ROLE_OBS_ARN} + ${output} = Execute aws s3api --endpoint-url ${S3G_ENDPOINT_URL} put-object-tagging --bucket ${ICEBERG_BUCKET_OBS} --key ${key} --tagging '{"TagSet":[{"Key":"tag-key1","Value":"tag-value1"}]}' --profile sts + Should Not Contain ${output} AccessDenied + + # Read tags with a session that allows GetObjectTagging + Assume Role And Configure STS Profile policy_json=${get_tag_policy} perm_access_key_id=${PERMANENT_ACCESS_KEY_ID} perm_secret_key=${PERMANENT_SECRET_KEY} role_arn=${ICEBERG_ALL_ACCESS_ROLE_OBS_ARN} + ${output} = Execute aws s3api --endpoint-url ${S3G_ENDPOINT_URL} get-object-tagging --bucket ${ICEBERG_BUCKET_OBS} --key ${key} --profile sts + Should Contain ${output} TagSet + ${tag_count} = Execute echo '${output}' | jq -r '.TagSet | length' + Should Be Equal As Strings ${tag_count} 1 + + # Negative: missing WRITE + Assume Role And Get Temporary Credentials policy_json=${put_tag_policy} perm_access_key_id=${PERMANENT_ACCESS_KEY_ID} perm_secret_key=${PERMANENT_SECRET_KEY} role_arn=${PARTIAL_BUCKET_READ_ROLE_ARN} + Configure STS Profile ${STS_ACCESS_KEY_ID} ${STS_SECRET_KEY} ${STS_SESSION_TOKEN} + ${output} = Execute And Ignore Error aws s3api --endpoint-url ${S3G_ENDPOINT_URL} put-object-tagging --bucket ${ICEBERG_BUCKET_OBS} --key ${key} --tagging '{"TagSet":[{"Key":"tag-key2","Value":"tag-value2"}]}' --profile sts + Should Contain ${output} AccessDenied + +STS session policy DeleteObjectTagging must require key WRITE + ${key_suffix} = Generate Random String 8 [LOWER] + ${key} = Set Variable sts-object-${key_suffix}.txt + ${local_path} = Set Variable ${TEMP_DIR}/${key} + Create File ${local_path} delete-object-tagging content + ${put_policy} = Set Variable {"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:PutObject","Resource":"arn:aws:s3:::${ICEBERG_BUCKET_OBS}/${key}"}]} + ${put_tag_policy} = Set Variable {"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:PutObjectTagging","Resource":"arn:aws:s3:::${ICEBERG_BUCKET_OBS}/${key}"}]} + ${delete_tag_policy} = Set Variable {"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:DeleteObjectTagging","Resource":"arn:aws:s3:::${ICEBERG_BUCKET_OBS}/${key}"}]} + + Assume Role And Configure STS Profile policy_json=${put_policy} perm_access_key_id=${PERMANENT_ACCESS_KEY_ID} perm_secret_key=${PERMANENT_SECRET_KEY} role_arn=${ICEBERG_ALL_ACCESS_ROLE_OBS_ARN} + Put Object Should Succeed ${ICEBERG_BUCKET_OBS} ${key} ${local_path} + + # Seed a tag so the delete actually has something to remove. + Assume Role And Configure STS Profile policy_json=${put_tag_policy} perm_access_key_id=${PERMANENT_ACCESS_KEY_ID} perm_secret_key=${PERMANENT_SECRET_KEY} role_arn=${ICEBERG_ALL_ACCESS_ROLE_OBS_ARN} + ${output} = Execute aws s3api --endpoint-url ${S3G_ENDPOINT_URL} put-object-tagging --bucket ${ICEBERG_BUCKET_OBS} --key ${key} --tagging '{"TagSet":[{"Key":"tag-key1","Value":"tag-value1"}]}' --profile sts + Should Not Contain ${output} AccessDenied + + # Negative: missing WRITE + Assume Role And Configure STS Profile policy_json=${delete_tag_policy} perm_access_key_id=${PERMANENT_ACCESS_KEY_ID} perm_secret_key=${PERMANENT_SECRET_KEY} role_arn=${PARTIAL_BUCKET_READ_ROLE_ARN} + ${output} = Execute And Ignore Error aws s3api --endpoint-url ${S3G_ENDPOINT_URL} delete-object-tagging --bucket ${ICEBERG_BUCKET_OBS} --key ${key} --profile sts + Should Contain ${output} AccessDenied + + # Positive control + Assume Role And Configure STS Profile policy_json=${delete_tag_policy} perm_access_key_id=${PERMANENT_ACCESS_KEY_ID} perm_secret_key=${PERMANENT_SECRET_KEY} role_arn=${ICEBERG_ALL_ACCESS_ROLE_OBS_ARN} + ${output} = Execute aws s3api --endpoint-url ${S3G_ENDPOINT_URL} delete-object-tagging --bucket ${ICEBERG_BUCKET_OBS} --key ${key} --profile sts + Should Not Contain ${output} AccessDenied + + # Read tags with a session that allows GetObjectTagging + ${get_tag_policy} = Set Variable {"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:GetObjectTagging","Resource":"arn:aws:s3:::${ICEBERG_BUCKET_OBS}/${key}"}]} + Assume Role And Get Temporary Credentials policy_json=${get_tag_policy} perm_access_key_id=${PERMANENT_ACCESS_KEY_ID} perm_secret_key=${PERMANENT_SECRET_KEY} role_arn=${ICEBERG_ALL_ACCESS_ROLE_OBS_ARN} + Configure STS Profile ${STS_ACCESS_KEY_ID} ${STS_SECRET_KEY} ${STS_SESSION_TOKEN} + ${output} = Execute aws s3api --endpoint-url ${S3G_ENDPOINT_URL} get-object-tagging --bucket ${ICEBERG_BUCKET_OBS} --key ${key} --profile sts + Should Contain ${output} TagSet + ${tag_count} = Execute echo '${output}' | jq -r '.TagSet | length' + Should Be Equal As Strings ${tag_count} 0 + +STS session policy DeleteObject must require key DELETE + ${key_suffix} = Generate Random String 8 [LOWER] + ${key} = Set Variable sts-object-${key_suffix}.txt + ${local_path} = Set Variable ${TEMP_DIR}/${key} + Create File ${local_path} delete-object content + ${put_policy} = Set Variable {"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:PutObject","Resource":"arn:aws:s3:::${ICEBERG_BUCKET_OBS}/${key}"}]} + ${delete_object_policy} = Set Variable {"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:DeleteObject","Resource":"arn:aws:s3:::${ICEBERG_BUCKET_OBS}/${key}"}]} + + Assume Role And Configure STS Profile policy_json=${put_policy} perm_access_key_id=${PERMANENT_ACCESS_KEY_ID} perm_secret_key=${PERMANENT_SECRET_KEY} role_arn=${ICEBERG_ALL_ACCESS_ROLE_OBS_ARN} + Put Object Should Succeed ${ICEBERG_BUCKET_OBS} ${key} ${local_path} + + # Negative: missing DELETE + Assume Role And Configure STS Profile policy_json=${delete_object_policy} perm_access_key_id=${PERMANENT_ACCESS_KEY_ID} perm_secret_key=${PERMANENT_SECRET_KEY} role_arn=${PARTIAL_BUCKET_READ_ROLE_ARN} + ${output} = Execute And Ignore Error aws s3api --endpoint-url ${S3G_ENDPOINT_URL} delete-object --bucket ${ICEBERG_BUCKET_OBS} --key ${key} --profile sts + Should Contain ${output} AccessDenied + + # Positive control + Assume Role And Configure STS Profile policy_json=${delete_object_policy} perm_access_key_id=${PERMANENT_ACCESS_KEY_ID} perm_secret_key=${PERMANENT_SECRET_KEY} role_arn=${ICEBERG_ALL_ACCESS_ROLE_OBS_ARN} + ${output} = Execute aws s3api --endpoint-url ${S3G_ENDPOINT_URL} delete-object --bucket ${ICEBERG_BUCKET_OBS} --key ${key} --profile sts + Should Not Contain ${output} AccessDenied + +STS session policy ListMultipartUploadParts must require key READ + ${key_suffix} = Generate Random String 8 [LOWER] + ${key} = Set Variable sts-mpu-${key_suffix}.txt + ${local_path} = Set Variable ${TEMP_DIR}/${key} + Create File ${local_path} list multipart upload content + ${put_policy} = Set Variable {"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:PutObject","Resource":"arn:aws:s3:::${ICEBERG_BUCKET_OBS}/${key}"}]} + ${list_parts_policy} = Set Variable {"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:ListMultipartUploadParts","Resource":"arn:aws:s3:::${ICEBERG_BUCKET_OBS}/${key}"}]} + + Assume Role And Configure STS Profile policy_json=${put_policy} perm_access_key_id=${PERMANENT_ACCESS_KEY_ID} perm_secret_key=${PERMANENT_SECRET_KEY} role_arn=${ICEBERG_ALL_ACCESS_ROLE_OBS_ARN} + ${output} = Execute aws s3api --endpoint-url ${S3G_ENDPOINT_URL} create-multipart-upload --bucket ${ICEBERG_BUCKET_OBS} --key ${key} --profile sts + ${upload_id} = Execute echo '${output}' | jq -r '.UploadId' + ${output} = Execute aws s3api --endpoint-url ${S3G_ENDPOINT_URL} upload-part --bucket ${ICEBERG_BUCKET_OBS} --key ${key} --part-number 1 --body ${local_path} --upload-id ${upload_id} --profile sts + Should Contain ${output} ETag + + # Positive control + Assume Role And Configure STS Profile policy_json=${list_parts_policy} perm_access_key_id=${PERMANENT_ACCESS_KEY_ID} perm_secret_key=${PERMANENT_SECRET_KEY} role_arn=${ICEBERG_ALL_ACCESS_ROLE_OBS_ARN} + ${output} = Execute aws s3api --endpoint-url ${S3G_ENDPOINT_URL} list-parts --bucket ${ICEBERG_BUCKET_OBS} --key ${key} --upload-id ${upload_id} --profile sts + Should Contain ${output} PartNumber + + # Negative: missing READ + Assume Role And Configure STS Profile policy_json=${list_parts_policy} perm_access_key_id=${PERMANENT_ACCESS_KEY_ID} perm_secret_key=${PERMANENT_SECRET_KEY} role_arn=${PARTIAL_BUCKET_LIST_ROLE_ARN} + ${output} = Execute And Ignore Error aws s3api --endpoint-url ${S3G_ENDPOINT_URL} list-parts --bucket ${ICEBERG_BUCKET_OBS} --key ${key} --upload-id ${upload_id} --profile sts + Should Contain ${output} AccessDenied + +STS session policy AbortMultipartUpload must require key WRITE + ${key_suffix} = Generate Random String 8 [LOWER] + ${key} = Set Variable sts-mpu-${key_suffix}.txt + ${local_path} = Set Variable ${TEMP_DIR}/${key} + Create File ${local_path} abort multipart upload content + ${put_policy} = Set Variable {"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:PutObject","Resource":"arn:aws:s3:::${ICEBERG_BUCKET_OBS}/${key}"}]} + ${abort_policy} = Set Variable {"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:AbortMultipartUpload","Resource":"arn:aws:s3:::${ICEBERG_BUCKET_OBS}/${key}"}]} + + Assume Role And Configure STS Profile policy_json=${put_policy} perm_access_key_id=${PERMANENT_ACCESS_KEY_ID} perm_secret_key=${PERMANENT_SECRET_KEY} role_arn=${ICEBERG_ALL_ACCESS_ROLE_OBS_ARN} + ${output} = Execute aws s3api --endpoint-url ${S3G_ENDPOINT_URL} create-multipart-upload --bucket ${ICEBERG_BUCKET_OBS} --key ${key} --profile sts + ${upload_id} = Execute echo '${output}' | jq -r '.UploadId' + ${output} = Execute aws s3api --endpoint-url ${S3G_ENDPOINT_URL} upload-part --bucket ${ICEBERG_BUCKET_OBS} --key ${key} --part-number 1 --body ${local_path} --upload-id ${upload_id} --profile sts + Should Contain ${output} ETag + + # Negative: missing WRITE + Assume Role And Configure STS Profile policy_json=${abort_policy} perm_access_key_id=${PERMANENT_ACCESS_KEY_ID} perm_secret_key=${PERMANENT_SECRET_KEY} role_arn=${PARTIAL_BUCKET_READ_ROLE_ARN} + ${output} = Execute And Ignore Error aws s3api --endpoint-url ${S3G_ENDPOINT_URL} abort-multipart-upload --bucket ${ICEBERG_BUCKET_OBS} --key ${key} --upload-id ${upload_id} --profile sts + Should Contain ${output} AccessDenied + + # Positive control + Assume Role And Configure STS Profile policy_json=${abort_policy} perm_access_key_id=${PERMANENT_ACCESS_KEY_ID} perm_secret_key=${PERMANENT_SECRET_KEY} role_arn=${ICEBERG_ALL_ACCESS_ROLE_OBS_ARN} + ${output} = Execute aws s3api --endpoint-url ${S3G_ENDPOINT_URL} abort-multipart-upload --bucket ${ICEBERG_BUCKET_OBS} --key ${key} --upload-id ${upload_id} --profile sts + Should Not Contain ${output} AccessDenied + +STS session policy containing only GetObject must deny GetObjectTagging + ${session_policy} = Set Variable {"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:GetObject","Resource":"arn:aws:s3:::${ICEBERG_BUCKET_OBS}/${ICEBERG_BUCKET_TESTFILE}"}]} + Assume Role And Configure STS Profile policy_json=${session_policy} perm_access_key_id=${PERMANENT_ACCESS_KEY_ID} perm_secret_key=${PERMANENT_SECRET_KEY} role_arn=${ICEBERG_ALL_ACCESS_ROLE_OBS_ARN} + Get Object Should Succeed ${ICEBERG_BUCKET_OBS} ${ICEBERG_BUCKET_TESTFILE} + ${output} = Execute And Ignore Error aws s3api --endpoint-url ${S3G_ENDPOINT_URL} get-object-tagging --bucket ${ICEBERG_BUCKET_OBS} --key ${ICEBERG_BUCKET_TESTFILE} --profile sts + Should Contain ${output} AccessDenied + +STS session policy containing only PutObject must deny PutObjectTagging and DeleteObjectTagging + ${key_suffix} = Generate Random String 8 [LOWER] + ${key} = Set Variable sts-object-${key_suffix}.txt + ${local_path} = Set Variable ${TEMP_DIR}/${key} + Create File ${local_path} put-object-only policy content + ${put_only_policy} = Set Variable {"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:PutObject","Resource":"arn:aws:s3:::${ICEBERG_BUCKET_OBS}/${key}"}]} + + Assume Role And Configure STS Profile policy_json=${put_only_policy} perm_access_key_id=${PERMANENT_ACCESS_KEY_ID} perm_secret_key=${PERMANENT_SECRET_KEY} role_arn=${ICEBERG_ALL_ACCESS_ROLE_OBS_ARN} + Put Object Should Succeed ${ICEBERG_BUCKET_OBS} ${key} ${local_path} + + ${output} = Execute And Ignore Error aws s3api --endpoint-url ${S3G_ENDPOINT_URL} put-object-tagging --bucket ${ICEBERG_BUCKET_OBS} --key ${key} --tagging '{"TagSet":[{"Key":"tag-key1","Value":"tag-value1"}]}' --profile sts + Should Contain ${output} AccessDenied + ${output} = Execute And Ignore Error aws s3api --endpoint-url ${S3G_ENDPOINT_URL} delete-object-tagging --bucket ${ICEBERG_BUCKET_OBS} --key ${key} --profile sts + Should Contain ${output} AccessDenied + +STS session policy CopyObject must require source GetObject and destination PutObject + ${src_key} = Set Variable ${ICEBERG_BUCKET_TESTFILE} + ${key_suffix} = Generate Random String 8 [LOWER] + ${dest_key} = Set Variable sts-copy-dest-${key_suffix}.txt + + # Positive: GetObject on source + PutObject on destination should allow CopyObject + ${copy_allow_policy} = Set Variable {"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:GetObject","Resource":"arn:aws:s3:::${ICEBERG_BUCKET_OBS}/${src_key}"},{"Effect":"Allow","Action":"s3:PutObject","Resource":"arn:aws:s3:::${ICEBERG_BUCKET_OBS}/${dest_key}"}]} + Assume Role And Configure STS Profile policy_json=${copy_allow_policy} perm_access_key_id=${PERMANENT_ACCESS_KEY_ID} perm_secret_key=${PERMANENT_SECRET_KEY} role_arn=${ICEBERG_ALL_ACCESS_ROLE_OBS_ARN} + ${output} = Execute aws s3api --endpoint-url ${S3G_ENDPOINT_URL} copy-object --bucket ${ICEBERG_BUCKET_OBS} --key ${dest_key} --copy-source ${ICEBERG_BUCKET_OBS}/${src_key} --profile sts + Should Contain ${output} CopyObjectResult + + # Negative A: missing source GetObject should deny CopyObject even with destination PutObject + ${copy_missing_source_get} = Set Variable {"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:PutObject","Resource":"arn:aws:s3:::${ICEBERG_BUCKET_OBS}/${dest_key}"}]} + Assume Role And Configure STS Profile policy_json=${copy_missing_source_get} perm_access_key_id=${PERMANENT_ACCESS_KEY_ID} perm_secret_key=${PERMANENT_SECRET_KEY} role_arn=${ICEBERG_ALL_ACCESS_ROLE_OBS_ARN} + ${output} = Execute And Ignore Error aws s3api --endpoint-url ${S3G_ENDPOINT_URL} copy-object --bucket ${ICEBERG_BUCKET_OBS} --key ${dest_key} --copy-source ${ICEBERG_BUCKET_OBS}/${src_key} --profile sts + Should Contain ${output} AccessDenied + + # Negative B: missing destination PutObject should deny CopyObject even with source GetObject + ${copy_missing_dest_put} = Set Variable {"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:GetObject","Resource":"arn:aws:s3:::${ICEBERG_BUCKET_OBS}/${src_key}"}]} + Assume Role And Configure STS Profile policy_json=${copy_missing_dest_put} perm_access_key_id=${PERMANENT_ACCESS_KEY_ID} perm_secret_key=${PERMANENT_SECRET_KEY} role_arn=${ICEBERG_ALL_ACCESS_ROLE_OBS_ARN} + ${output} = Execute And Ignore Error aws s3api --endpoint-url ${S3G_ENDPOINT_URL} copy-object --bucket ${ICEBERG_BUCKET_OBS} --key ${dest_key} --copy-source ${ICEBERG_BUCKET_OBS}/${src_key} --profile sts + Should Contain ${output} AccessDenied + +STS session policy UploadPartCopy must require source GetObject and destination PutObject + ${src_key} = Set Variable ${ICEBERG_BUCKET_TESTFILE} + ${key_suffix} = Generate Random String 8 [LOWER] + ${dest_key} = Set Variable sts-mpu-copy-${key_suffix}.txt + + # Positive: GetObject on source + PutObject on destination should allow UploadPartCopy + ${allow_policy} = Set Variable {"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:GetObject","Resource":"arn:aws:s3:::${ICEBERG_BUCKET_OBS}/${src_key}"},{"Effect":"Allow","Action":"s3:PutObject","Resource":"arn:aws:s3:::${ICEBERG_BUCKET_OBS}/${dest_key}"}]} + Assume Role And Configure STS Profile policy_json=${allow_policy} perm_access_key_id=${PERMANENT_ACCESS_KEY_ID} perm_secret_key=${PERMANENT_SECRET_KEY} role_arn=${ICEBERG_ALL_ACCESS_ROLE_OBS_ARN} + ${output} = Execute aws s3api --endpoint-url ${S3G_ENDPOINT_URL} create-multipart-upload --bucket ${ICEBERG_BUCKET_OBS} --key ${dest_key} --profile sts + ${upload_id} = Execute echo '${output}' | jq -r '.UploadId' + Should Not Be Empty ${upload_id} + ${output} = Execute aws s3api --endpoint-url ${S3G_ENDPOINT_URL} upload-part-copy --bucket ${ICEBERG_BUCKET_OBS} --key ${dest_key} --part-number 1 --upload-id ${upload_id} --copy-source ${ICEBERG_BUCKET_OBS}/${src_key} --profile sts + Should Contain ${output} CopyPartResult + + # Negative A: missing source GetObject should deny UploadPartCopy even with destination PutObject + ${missing_source_get} = Set Variable {"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:PutObject","Resource":"arn:aws:s3:::${ICEBERG_BUCKET_OBS}/${dest_key}"}]} + Assume Role And Configure STS Profile policy_json=${missing_source_get} perm_access_key_id=${PERMANENT_ACCESS_KEY_ID} perm_secret_key=${PERMANENT_SECRET_KEY} role_arn=${ICEBERG_ALL_ACCESS_ROLE_OBS_ARN} + ${output} = Execute And Ignore Error aws s3api --endpoint-url ${S3G_ENDPOINT_URL} create-multipart-upload --bucket ${ICEBERG_BUCKET_OBS} --key ${dest_key} --profile sts + ${upload_id} = Execute echo '${output}' | jq -r '.UploadId' + Should Not Be Empty ${upload_id} + ${output} = Execute And Ignore Error aws s3api --endpoint-url ${S3G_ENDPOINT_URL} upload-part-copy --bucket ${ICEBERG_BUCKET_OBS} --key ${dest_key} --part-number 1 --upload-id ${upload_id} --copy-source ${ICEBERG_BUCKET_OBS}/${src_key} --profile sts + Should Contain ${output} AccessDenied + + # Negative B: missing destination PutObject should deny UploadPartCopy even with source GetObject + # Create MPU under PutObject so we can get an upload ID first. + ${put_only_policy} = Set Variable {"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:PutObject","Resource":"arn:aws:s3:::${ICEBERG_BUCKET_OBS}/${dest_key}"}]} + Assume Role And Configure STS Profile policy_json=${put_only_policy} perm_access_key_id=${PERMANENT_ACCESS_KEY_ID} perm_secret_key=${PERMANENT_SECRET_KEY} role_arn=${ICEBERG_ALL_ACCESS_ROLE_OBS_ARN} + ${output} = Execute aws s3api --endpoint-url ${S3G_ENDPOINT_URL} create-multipart-upload --bucket ${ICEBERG_BUCKET_OBS} --key ${dest_key} --profile sts + ${upload_id} = Execute echo '${output}' | jq -r '.UploadId' + Should Not Be Empty ${upload_id} + # Switch to GetObject-only policy for the actual UploadPartCopy call. + ${missing_dest_put} = Set Variable {"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:GetObject","Resource":"arn:aws:s3:::${ICEBERG_BUCKET_OBS}/${src_key}"}]} + Assume Role And Configure STS Profile policy_json=${missing_dest_put} perm_access_key_id=${PERMANENT_ACCESS_KEY_ID} perm_secret_key=${PERMANENT_SECRET_KEY} role_arn=${ICEBERG_ALL_ACCESS_ROLE_OBS_ARN} + ${output} = Execute And Ignore Error aws s3api --endpoint-url ${S3G_ENDPOINT_URL} upload-part-copy --bucket ${ICEBERG_BUCKET_OBS} --key ${dest_key} --part-number 1 --upload-id ${upload_id} --copy-source ${ICEBERG_BUCKET_OBS}/${src_key} --profile sts + Should Contain ${output} AccessDenied + +Ranger action-matches UploadPartCopy expected-owner should allow + ${src_bucket} = Set Variable ${ICEBERG_BUCKET_OBS} + ${dest_bucket} = Set Variable ${ICEBERG_BUCKET_OBS} + ${src_key} = Set Variable ${ICEBERG_BUCKET_TESTFILE} + ${key_suffix} = Generate Random String 8 [LOWER] + ${dest_key} = Set Variable sts-mpu-expected-owner-${key_suffix}.txt + + # Discover source/destination bucket owners (needed to avoid BucketOwnerMismatch). + ${acl_policy} = Set Variable {"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:GetBucketAcl","Resource":"arn:aws:s3:::${dest_bucket}"}]} + Assume Role And Configure STS Profile policy_json=${acl_policy} perm_access_key_id=${PERMANENT_ACCESS_KEY_ID} perm_secret_key=${PERMANENT_SECRET_KEY} role_arn=${ICEBERG_ALL_ACCESS_ROLE_OBS_ARN} + ${src_acl} = Execute aws s3api --endpoint-url ${S3G_ENDPOINT_URL} get-bucket-acl --bucket ${src_bucket} --output json --profile sts + ${dst_acl} = Execute aws s3api --endpoint-url ${S3G_ENDPOINT_URL} get-bucket-acl --bucket ${dest_bucket} --output json --profile sts + ${src_owner} = Execute echo '${src_acl}' | jq -r '.Owner.DisplayName // .Owner.ID' + ${dst_owner} = Execute echo '${dst_acl}' | jq -r '.Owner.DisplayName // .Owner.ID' + + ${allow_policy} = Set Variable {"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:GetObject","Resource":"arn:aws:s3:::${src_bucket}/${src_key}"},{"Effect":"Allow","Action":"s3:PutObject","Resource":"arn:aws:s3:::${dest_bucket}/${dest_key}"}]} + Assume Role And Configure STS Profile policy_json=${allow_policy} perm_access_key_id=${PERMANENT_ACCESS_KEY_ID} perm_secret_key=${PERMANENT_SECRET_KEY} role_arn=${ACTION_MATCHES_UPLOADPARTCOPY_EXPECTED_OWNER_ROLE_ARN} + + ${output} = Execute aws s3api --endpoint-url ${S3G_ENDPOINT_URL} create-multipart-upload --bucket ${dest_bucket} --key ${dest_key} --profile sts + ${upload_id} = Execute echo '${output}' | jq -r '.UploadId' + Should Not Be Empty ${upload_id} + + ${output} = Execute aws s3api --endpoint-url ${S3G_ENDPOINT_URL} upload-part-copy --bucket ${dest_bucket} --key ${dest_key} --part-number 1 --upload-id ${upload_id} --copy-source ${src_bucket}/${src_key} --expected-source-bucket-owner ${src_owner} --expected-bucket-owner ${dst_owner} --profile sts + Should Contain ${output} CopyPartResult + +Ranger action-matches CopyObject expected-owner should allow + ${src_bucket} = Set Variable ${ICEBERG_BUCKET_OBS} + ${dest_bucket} = Set Variable ${ICEBERG_BUCKET_OBS} + ${src_key} = Set Variable ${ICEBERG_BUCKET_TESTFILE} + ${key_suffix} = Generate Random String 8 [LOWER] + ${dest_key} = Set Variable sts-copy-expected-owner-${key_suffix}.txt + + # Discover source/destination bucket owners (needed to avoid BucketOwnerMismatch). + ${acl_policy} = Set Variable {"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:GetBucketAcl","Resource":"arn:aws:s3:::${dest_bucket}"}]} + Assume Role And Configure STS Profile policy_json=${acl_policy} perm_access_key_id=${PERMANENT_ACCESS_KEY_ID} perm_secret_key=${PERMANENT_SECRET_KEY} role_arn=${ICEBERG_ALL_ACCESS_ROLE_OBS_ARN} + ${src_acl} = Execute aws s3api --endpoint-url ${S3G_ENDPOINT_URL} get-bucket-acl --bucket ${src_bucket} --output json --profile sts + ${dst_acl} = Execute aws s3api --endpoint-url ${S3G_ENDPOINT_URL} get-bucket-acl --bucket ${dest_bucket} --output json --profile sts + ${src_owner} = Execute echo '${src_acl}' | jq -r '.Owner.DisplayName // .Owner.ID' + ${dst_owner} = Execute echo '${dst_acl}' | jq -r '.Owner.DisplayName // .Owner.ID' + + ${allow_policy} = Set Variable {"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:GetObject","Resource":"arn:aws:s3:::${src_bucket}/${src_key}"},{"Effect":"Allow","Action":"s3:PutObject","Resource":"arn:aws:s3:::${dest_bucket}/${dest_key}"}]} + Assume Role And Configure STS Profile policy_json=${allow_policy} perm_access_key_id=${PERMANENT_ACCESS_KEY_ID} perm_secret_key=${PERMANENT_SECRET_KEY} role_arn=${ACTION_MATCHES_UPLOADPARTCOPY_EXPECTED_OWNER_ROLE_ARN} + + ${output} = Execute aws s3api --endpoint-url ${S3G_ENDPOINT_URL} copy-object --bucket ${dest_bucket} --key ${dest_key} --copy-source ${src_bucket}/${src_key} --expected-source-bucket-owner ${src_owner} --expected-bucket-owner ${dst_owner} --profile sts + Should Contain ${output} CopyObjectResult + +STS session policy s3:* on bucket resource must allow bucket APIs but deny ListAllMyBuckets and object APIs + ${bucket_star_policy} = Set Variable {"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:*","Resource":"arn:aws:s3:::${ICEBERG_BUCKET_OBS}"}]} + Assume Role And Configure STS Profile policy_json=${bucket_star_policy} perm_access_key_id=${PERMANENT_ACCESS_KEY_ID} perm_secret_key=${PERMANENT_SECRET_KEY} role_arn=${ICEBERG_ALL_ACCESS_ROLE_OBS_ARN} + + # Bucket APIs should work + List Object Keys Should Succeed ${ICEBERG_BUCKET_OBS} + + ${output} = Execute aws s3api --endpoint-url ${S3G_ENDPOINT_URL} get-bucket-acl --bucket ${ICEBERG_BUCKET_OBS} --profile sts + Should Contain ${output} Owner + + # ListAllMyBuckets should NOT work (needs Resource="*" or Resource="arn:aws:s3:::*") + ${output} = Execute And Ignore Error aws s3api --endpoint-url ${S3G_ENDPOINT_URL} list-buckets --profile sts + Should Contain ${output} AccessDenied + + # Object APIs should NOT work + Get Object Should Fail ${ICEBERG_BUCKET_OBS} ${ICEBERG_BUCKET_TESTFILE} AccessDenied + + ${key_suffix} = Generate Random String 8 [LOWER] + ${key} = Set Variable sts-bucket-star-${key_suffix}.txt + ${local_path} = Set Variable ${TEMP_DIR}/${key} + Create File ${local_path} bucket-star policy should deny PutObject + Put Object Should Fail ${ICEBERG_BUCKET_OBS} ${key} AccessDenied ${local_path} + +STS session policy s3:* on object resource must allow object APIs but deny ListAllMyBuckets and bucket APIs + ${key_suffix} = Generate Random String 8 [LOWER] + ${local_path} = Set Variable ${TEMP_DIR}/object-star-${key_suffix}.txt + Create File ${local_path} object-star policy content + ${object_star_policy} = Set Variable {"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:*","Resource":"arn:aws:s3:::${ICEBERG_BUCKET_OBS}/${ICEBERG_BUCKET_TESTFILE}"}]} + Assume Role And Configure STS Profile policy_json=${object_star_policy} perm_access_key_id=${PERMANENT_ACCESS_KEY_ID} perm_secret_key=${PERMANENT_SECRET_KEY} role_arn=${ICEBERG_ALL_ACCESS_ROLE_OBS_ARN} + + # Object APIs should work (on that single object ARN) + Get Object Should Succeed ${ICEBERG_BUCKET_OBS} ${ICEBERG_BUCKET_TESTFILE} + Put Object Should Succeed ${ICEBERG_BUCKET_OBS} ${ICEBERG_BUCKET_TESTFILE} ${local_path} + Get Object Should Fail ${ICEBERG_BUCKET_OBS} file1again.txt AccessDenied + + # ListAllMyBuckets should NOT work (needs Resource="*" or Resource="arn:aws:s3:::*") + ${output} = Execute And Ignore Error aws s3api --endpoint-url ${S3G_ENDPOINT_URL} list-buckets --profile sts + Should Contain ${output} AccessDenied + + # Bucket APIs should NOT work + List Object Keys Should Fail ${ICEBERG_BUCKET_OBS} list-objects AccessDenied + + ${output} = Execute And Ignore Error aws s3api --endpoint-url ${S3G_ENDPOINT_URL} get-bucket-acl --bucket ${ICEBERG_BUCKET_OBS} --profile sts + Should Contain ${output} AccessDenied + +STS session policy s3:* on * must allow ListAllMyBuckets, Create/ListBucket, and GetObject/PutObject + ${bucket_suffix} = Generate Random String 8 [LOWER] + ${bucket} = Set Variable sts-bucket-${bucket_suffix} + ${key_suffix} = Generate Random String 8 [LOWER] + ${key} = Set Variable sts-object-${key_suffix}.txt + ${local_path} = Set Variable ${TEMP_DIR}/${key} + ${download_path} = Set Variable ${TEMP_DIR}/${key}.download + Create File ${local_path} star-star policy content + + ${star_policy} = Set Variable {"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:*","Resource":"*"}]} + Assume Role And Configure STS Profile policy_json=${star_policy} perm_access_key_id=${PERMANENT_ACCESS_KEY_ID} perm_secret_key=${PERMANENT_SECRET_KEY} role_arn=${STS_TEMP_BUCKET_ROLE_ARN} + + # ListAllMyBuckets should work + ${output} = Execute aws s3api --endpoint-url ${S3G_ENDPOINT_URL} list-buckets --profile sts + Should Contain ${output} ${ICEBERG_BUCKET_OBS} + + # Create/ListBucket should work + ${output} = Execute aws s3api --endpoint-url ${S3G_ENDPOINT_URL} create-bucket --bucket ${bucket} --profile sts + Should Contain ${output} Location + + List Object Keys Should Succeed ${bucket} + + # Object APIs should work + Put Object Should Succeed ${bucket} ${key} ${local_path} + Get Object Should Succeed ${bucket} ${key} ${download_path} + + # Cleanup + ${output} = Execute aws s3api --endpoint-url ${S3G_ENDPOINT_URL} delete-object --bucket ${bucket} --key ${key} --profile sts + Should Not Contain ${output} AccessDenied + ${output} = Execute aws s3api --endpoint-url ${S3G_ENDPOINT_URL} delete-bucket --bucket ${bucket} --profile sts + Should Not Contain ${output} AccessDenied + +Revoking Permanent User Must Revoke Existing Session Token + # Create session tokens for both buckets, verify they work, then revoke permanent user secret and verify both fail. + Assume Role And Get Temporary Credentials perm_access_key_id=${PERMANENT_ACCESS_KEY_ID} perm_secret_key=${PERMANENT_SECRET_KEY} role_arn=${ICEBERG_ALL_ACCESS_ROLE_OBS_ARN} + Set Test Variable ${OBS_STS_ACCESS_KEY_ID} ${STS_ACCESS_KEY_ID} + Set Test Variable ${OBS_STS_SECRET_KEY} ${STS_SECRET_KEY} + Set Test Variable ${OBS_STS_SESSION_TOKEN} ${STS_SESSION_TOKEN} + Configure STS Profile ${OBS_STS_ACCESS_KEY_ID} ${OBS_STS_SECRET_KEY} ${OBS_STS_SESSION_TOKEN} + Get Object Should Succeed ${ICEBERG_BUCKET_OBS} ${ICEBERG_BUCKET_TESTFILE} + + Assume Role And Get Temporary Credentials perm_access_key_id=${PERMANENT_ACCESS_KEY_ID} perm_secret_key=${PERMANENT_SECRET_KEY} role_arn=${ICEBERG_ALL_ACCESS_ROLE_FSO_ARN} + Set Test Variable ${FSO_STS_ACCESS_KEY_ID} ${STS_ACCESS_KEY_ID} + Set Test Variable ${FSO_STS_SECRET_KEY} ${STS_SECRET_KEY} + Set Test Variable ${FSO_STS_SESSION_TOKEN} ${STS_SESSION_TOKEN} + Configure STS Profile ${FSO_STS_ACCESS_KEY_ID} ${FSO_STS_SECRET_KEY} ${FSO_STS_SESSION_TOKEN} + Get Object Should Succeed ${ICEBERG_BUCKET_FSO} ${ICEBERG_BUCKET_TESTFILE} + + # Log in again as user who owns the ${PERMANENT_ACCESS_KEY_ID} so we can issue revokesecret command. + Kinit test user ${ICEBERG_SVC_CATALOG_USER} ${ICEBERG_SVC_CATALOG_USER}.keytab + Execute ozone s3 revokesecret -y -u ${PERMANENT_ACCESS_KEY_ID} ${OM_HA_PARAM} + + # Session tokens must no longer work. + Configure STS Profile ${OBS_STS_ACCESS_KEY_ID} ${OBS_STS_SECRET_KEY} ${OBS_STS_SESSION_TOKEN} + Get Object Should Fail ${ICEBERG_BUCKET_OBS} ${ICEBERG_BUCKET_TESTFILE} AccessDenied + Configure STS Profile ${FSO_STS_ACCESS_KEY_ID} ${FSO_STS_SECRET_KEY} ${FSO_STS_SESSION_TOKEN} + Get Object Should Fail ${ICEBERG_BUCKET_FSO} ${ICEBERG_BUCKET_TESTFILE} AccessDenied From a48cf12131f972eb89afe0b14d3d51e2282177ec Mon Sep 17 00:00:00 2001 From: fmorg-git Date: Mon, 17 Aug 2026 00:40:59 -0700 Subject: [PATCH 45/54] HDDS-14811. [STS] Part 3 - STS Ranger Smoke Tests (#9903) --- .../compose/ozonesecure-ha/test-ranger.sh | 1 + .../ozone-secure-sts-multitenant.robot | 340 ++++++++++++++++++ 2 files changed, 341 insertions(+) create mode 100644 hadoop-ozone/dist/src/main/smoketest/security/ozone-secure-sts-multitenant.robot diff --git a/hadoop-ozone/dist/src/main/compose/ozonesecure-ha/test-ranger.sh b/hadoop-ozone/dist/src/main/compose/ozonesecure-ha/test-ranger.sh index 9ffefe58a113..24cc21778f23 100755 --- a/hadoop-ozone/dist/src/main/compose/ozonesecure-ha/test-ranger.sh +++ b/hadoop-ozone/dist/src/main/compose/ozonesecure-ha/test-ranger.sh @@ -114,3 +114,4 @@ execute_robot_test s3g freon/validate.robot execute_robot_test s3g -v RANGER_ENDPOINT_URL:"http://ranger:6080" -v USER:hdfs security/ozone-secure-tenant.robot execute_robot_test s3g -v RANGER_ENDPOINT_URL:"http://ranger:6080" -v USER:hdfs security/ozone-secure-sts.robot +execute_robot_test s3g -v RANGER_ENDPOINT_URL:"http://ranger:6080" -v USER:hdfs security/ozone-secure-sts-multitenant.robot diff --git a/hadoop-ozone/dist/src/main/smoketest/security/ozone-secure-sts-multitenant.robot b/hadoop-ozone/dist/src/main/smoketest/security/ozone-secure-sts-multitenant.robot new file mode 100644 index 000000000000..c7b367d74cf5 --- /dev/null +++ b/hadoop-ozone/dist/src/main/smoketest/security/ozone-secure-sts-multitenant.robot @@ -0,0 +1,340 @@ +# 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. + +*** Settings *** +Suite Setup Skip If '${RANGER_ENDPOINT_URL}' == '' No Ranger +Documentation Smoke test for S3 STS AssumeRole + Temp Creds (Multi-Tenant Scenario) +Resource ./ozone-secure-sts.resource +Resource ../admincli/lib.resource +Test Timeout 10 minutes + +*** Variables *** +${TENANT_ONE} sts-tenant-one +${TENANT_TWO} sts-tenant-two +${TENANT_THREE} sts-tenant-three +${USER_A} svc-iceberg-userA +${USER_B} svc-iceberg-userB +${TENANT_ONE_ROLE} sts-tenant-one-role +${TENANT_TWO_ROLE} sts-tenant-two-role +${TENANT_THREE_ROLE} sts-tenant-three-role +${TENANT_ONE_ICEBERG_BUCKET} iceberg-tenant-one-bucket +${TENANT_TWO_ICEBERG_BUCKET} iceberg-tenant-two-bucket +${TENANT_THREE_ICEBERG_BUCKET} iceberg-tenant-three-bucket +${TENANT_TWO_ANOTHER_BUCKET} tenant-two-another-bucket +${TENANT_ONE_LINKED_BUCKET} tenant-one-a-linked-bucket +${ICEBERG_BUCKET_TESTFILE} testfile23 +${ANOTHER_BUCKET_TESTFILE} testfile00 +${OM_ADMIN_USER} hdfs + +*** Test Cases *** +Create Users in Ranger + ${user_json} = Set Variable { "loginId": "${USER_A}", "name": "${USER_A}", "password": "Password123", "firstName": "User A Iceberg REST", "lastName": "Catalog", "emailAddress": "${USER_A}@example.com", "userRoleList": ["ROLE_USER"], "userPermList": [ { "moduleId": 1, "isAllowed": 1 }, { "moduleId": 3, "isAllowed": 1 }, { "moduleId": 7, "isAllowed": 1 } ] } + Create Ranger User ${user_json} + ${user_json} = Set Variable { "loginId": "${USER_B}", "name": "${USER_B}", "password": "Password123", "firstName": "User B Iceberg REST", "lastName": "Catalog", "emailAddress": "${USER_B}@example.com", "userRoleList": ["ROLE_USER"], "userPermList": [ { "moduleId": 1, "isAllowed": 1 }, { "moduleId": 3, "isAllowed": 1 }, { "moduleId": 7, "isAllowed": 1 } ] } + Create Ranger User ${user_json} + +Create Tenants + Kinit test user ${OM_ADMIN_USER} ${OM_ADMIN_USER}.keytab + ${output} = Execute ozone tenant --verbose create ${TENANT_ONE} + Should contain ${output} "tenantId" : "${TENANT_ONE}" + ${output} = Execute ozone tenant --verbose create ${TENANT_TWO} + Should contain ${output} "tenantId" : "${TENANT_TWO}" + ${output} = Execute ozone tenant --verbose create ${TENANT_THREE} + Should contain ${output} "tenantId" : "${TENANT_THREE}" + +Assign Users to Tenants + ${accessKeyId} ${secretKey} = Assign User To Tenant And Get Credentials ${USER_A} ${TENANT_ONE} + Set Global Variable ${USER_A_T1_PERM_ACCESS_KEY_ID} ${accessKeyId} + Set Global Variable ${USER_A_T1_PERM_SECRET_KEY} ${secretKey} + + ${accessKeyId} ${secretKey} = Assign User To Tenant And Get Credentials ${USER_A} ${TENANT_TWO} + Set Global Variable ${USER_A_T2_PERM_ACCESS_KEY_ID} ${accessKeyId} + Set Global Variable ${USER_A_T2_PERM_SECRET_KEY} ${secretKey} + + ${accessKeyId} ${secretKey} = Assign User To Tenant And Get Credentials ${USER_B} ${TENANT_THREE} + Set Global Variable ${USER_B_T3_PERM_ACCESS_KEY_ID} ${accessKeyId} + Set Global Variable ${USER_B_T3_PERM_SECRET_KEY} ${secretKey} + +Create Roles in Ranger + ${role_json} = Set Variable { "name": "${TENANT_ONE_ROLE}", "description": "Tenant One Role" } + Create Ranger Role ${role_json} + ${role_json} = Set Variable { "name": "${TENANT_TWO_ROLE}", "description": "Tenant Two Role" } + Create Ranger Role ${role_json} + ${role_json} = Set Variable { "name": "${TENANT_THREE_ROLE}", "description": "Tenant Three Role" } + Create Ranger Role ${role_json} + +Create Assume Role Policies + Create Ranger Assume Role Policy ${TENANT_ONE_ROLE} ${USER_A} + Create Ranger Assume Role Policy ${TENANT_TWO_ROLE} ${USER_A} + Create Ranger Assume Role Policy ${TENANT_THREE_ROLE} ${USER_B} + +Update Tenant Volume Access policies + # This policy gives '${TENANT_ONE_ROLE}' role READ,LIST permission on volume ${TENANT_ONE}. + # It also gives '${USER_A}' user READ permission on volume ${TENANT_ONE}. + ${policy_item_json} = Set Variable [ { "accesses": [ { "type": "read", "isAllowed": true }, { "type": "list", "isAllowed": true } ], "roles": [ "${TENANT_ONE_ROLE}" ], "delegateAdmin": false }, { "accesses": [ { "type": "read", "isAllowed": true } ], "users": [ "${USER_A}" ], "delegateAdmin": false } ] + Update Ranger Policy Items ${TENANT_ONE}-VolumeAccess ${policy_item_json} + + # This policy gives '${TENANT_TWO_ROLE}' role READ,LIST permission on volume ${TENANT_TWO}. + # It also gives '${USER_A}' user READ permission on volume ${TENANT_TWO}. + # It also gives '${TENANT_ONE_ROLE}' role READ permission on volume ${TENANT_TWO} so it can access ${TENANT_ONE}/${TENANT_ONE_LINKED_BUCKET}. + ${policy_item_json} = Set Variable [ { "accesses": [ { "type": "read", "isAllowed": true }, { "type": "list", "isAllowed": true } ], "roles": [ "${TENANT_TWO_ROLE}" ], "delegateAdmin": false }, { "accesses": [ { "type": "read", "isAllowed": true } ], "users": [ "${USER_A}" ], "delegateAdmin": false }, { "accesses": [ { "type": "read", "isAllowed": true } ], "roles": [ "${TENANT_ONE_ROLE}" ], "delegateAdmin": false } ] + Update Ranger Policy Items ${TENANT_TWO}-VolumeAccess ${policy_item_json} + + # This policy gives '${TENANT_THREE_ROLE}' role READ,LIST permission on volume ${TENANT_THREE}. + # It also gives '${USER_B}' user READ permission on volume ${TENANT_THREE}. + ${policy_item_json} = Set Variable [ { "accesses": [ { "type": "read", "isAllowed": true }, { "type": "list", "isAllowed": true } ], "roles": [ "${TENANT_THREE_ROLE}" ], "delegateAdmin": false }, { "accesses": [ { "type": "read", "isAllowed": true } ], "users": [ "${USER_B}" ], "delegateAdmin": false } ] + Update Ranger Policy Items ${TENANT_THREE}-VolumeAccess ${policy_item_json} + +Create Bucket Access policies + # This policy gives '${TENANT_ONE_ROLE}' role ALL permission on buckets ${TENANT_ONE}/${TENANT_ONE_ICEBERG_BUCKET} + # and ${TENANT_ONE}/${TENANT_ONE_LINKED_BUCKET}. + # It also gives '${USER_A}' user READ, CREATE permissions on bucket ${TENANT_ONE}/${TENANT_ONE_ICEBERG_BUCKET}. + ${policy_json} = Set Variable { "isEnabled": true, "service": "dev_ozone", "name": "${TENANT_ONE} ${TENANT_ONE_ICEBERG_BUCKET} and ${TENANT_ONE_LINKED_BUCKET} access", "policyType": 0, "policyPriority": 0, "isAuditEnabled": true, "resources": { "volume": { "values": [ "${TENANT_ONE}" ], "isExcludes": false, "isRecursive": false }, "bucket": { "values": [ "${TENANT_ONE_ICEBERG_BUCKET}", "${TENANT_ONE_LINKED_BUCKET}" ], "isExcludes": false, "isRecursive": false } }, "policyItems": [ { "accesses": [ { "type": "all", "isAllowed": true } ], "roles": [ "${TENANT_ONE_ROLE}" ], "delegateAdmin": false }, { "accesses": [ { "type": "read", "isAllowed": true }, { "type": "create", "isAllowed": true } ], "users": [ "${USER_A}" ], "delegateAdmin": false } ], "serviceType": "ozone", "isDenyAllElse": false } + Create Ranger Policy ${policy_json} + + # This policy gives '${TENANT_TWO_ROLE}' role ALL permission on bucket ${TENANT_TWO}/${TENANT_TWO_ICEBERG_BUCKET}. + # It also gives '${USER_A}' user READ, CREATE permissions on bucket ${TENANT_TWO}/${TENANT_TWO_ICEBERG_BUCKET}. + ${policy_json} = Set Variable { "isEnabled": true, "service": "dev_ozone", "name": "${TENANT_TWO} ${TENANT_TWO_ICEBERG_BUCKET} access", "policyType": 0, "policyPriority": 0, "isAuditEnabled": true, "resources": { "volume": { "values": [ "${TENANT_TWO}" ], "isExcludes": false, "isRecursive": false }, "bucket": { "values": [ "${TENANT_TWO_ICEBERG_BUCKET}" ], "isExcludes": false, "isRecursive": false } }, "policyItems": [ { "accesses": [ { "type": "all", "isAllowed": true } ], "roles": [ "${TENANT_TWO_ROLE}" ], "delegateAdmin": false }, { "accesses": [ { "type": "read", "isAllowed": true }, { "type": "create", "isAllowed": true } ], "users": [ "${USER_A}" ], "delegateAdmin": false } ], "serviceType": "ozone", "isDenyAllElse": false } + Create Ranger Policy ${policy_json} + + # This policy gives '${TENANT_THREE_ROLE}' role ALL permission on bucket ${TENANT_THREE}/${TENANT_THREE_ICEBERG_BUCKET}. + # It also gives '${USER_B}' user READ, CREATE permissions on bucket ${TENANT_THREE}/${TENANT_THREE_ICEBERG_BUCKET}. + ${policy_json} = Set Variable { "isEnabled": true, "service": "dev_ozone", "name": "${TENANT_THREE} ${TENANT_THREE_ICEBERG_BUCKET} access", "policyType": 0, "policyPriority": 0, "isAuditEnabled": true, "resources": { "volume": { "values": [ "${TENANT_THREE}" ], "isExcludes": false, "isRecursive": false }, "bucket": { "values": [ "${TENANT_THREE_ICEBERG_BUCKET}" ], "isExcludes": false, "isRecursive": false } }, "policyItems": [ { "accesses": [ { "type": "all", "isAllowed": true } ], "roles": [ "${TENANT_THREE_ROLE}" ], "delegateAdmin": false }, { "accesses": [ { "type": "read", "isAllowed": true }, { "type": "create", "isAllowed": true } ], "users": [ "${USER_B}" ], "delegateAdmin": false } ], "serviceType": "ozone", "isDenyAllElse": false } + Create Ranger Policy ${policy_json} + + # This policy gives '${TENANT_ONE_ROLE}' role ALL permission on bucket ${TENANT_TWO}/${TENANT_TWO_ANOTHER_BUCKET}. + # It also gives '${USER_A}' user READ, CREATE permissions on bucket ${TENANT_TWO}/${TENANT_TWO_ANOTHER_BUCKET}. + ${policy_json} = Set Variable { "isEnabled": true, "service": "dev_ozone", "name": "${TENANT_TWO} ${TENANT_TWO_ANOTHER_BUCKET} access", "policyType": 0, "policyPriority": 0, "isAuditEnabled": true, "resources": { "volume": { "values": [ "${TENANT_TWO}" ], "isExcludes": false, "isRecursive": false }, "bucket": { "values": [ "${TENANT_TWO_ANOTHER_BUCKET}" ], "isExcludes": false, "isRecursive": false } }, "policyItems": [ { "accesses": [ { "type": "all", "isAllowed": true } ], "roles": [ "${TENANT_ONE_ROLE}" ], "delegateAdmin": false }, { "accesses": [ { "type": "read", "isAllowed": true }, { "type": "create", "isAllowed": true } ], "users": [ "${USER_A}" ], "delegateAdmin": false } ], "serviceType": "ozone", "isDenyAllElse": false } + Create Ranger Policy ${policy_json} + +Create Iceberg Bucket and Another Bucket Table Access policies + # This policy gives '${TENANT_ONE_ROLE}' role ALL permission on keys ${TENANT_ONE}/${TENANT_ONE_ICEBERG_BUCKET}/* + # and keys ${TENANT_ONE}/${TENANT_ONE_LINKED_BUCKET}/*. + ${policy_json} = Set Variable { "isEnabled": true, "service": "dev_ozone", "name": "${TENANT_ONE} ${TENANT_ONE_ICEBERG_BUCKET} and ${TENANT_ONE_LINKED_BUCKET} table access", "policyType": 0, "policyPriority": 0, "isAuditEnabled": true, "resources": { "volume": { "values": [ "${TENANT_ONE}" ], "isExcludes": false, "isRecursive": false }, "bucket": { "values": [ "${TENANT_ONE_ICEBERG_BUCKET}", "${TENANT_ONE_LINKED_BUCKET}" ], "isExcludes": false, "isRecursive": false }, "key": { "values": [ "*" ], "isExcludes": false, "isRecursive": true } }, "policyItems": [ { "accesses": [ { "type": "all", "isAllowed": true } ], "roles": [ "${TENANT_ONE_ROLE}" ], "delegateAdmin": false } ], "serviceType": "ozone", "isDenyAllElse": false } + Create Ranger Policy ${policy_json} + + # This policy gives '${TENANT_TWO_ROLE}' role ALL permission on keys ${TENANT_TWO}/${TENANT_TWO_ICEBERG_BUCKET}/*. + ${policy_json} = Set Variable { "isEnabled": true, "service": "dev_ozone", "name": "${TENANT_TWO} ${TENANT_TWO_ICEBERG_BUCKET} table access", "policyType": 0, "policyPriority": 0, "isAuditEnabled": true, "resources": { "volume": { "values": [ "${TENANT_TWO}" ], "isExcludes": false, "isRecursive": false }, "bucket": { "values": [ "${TENANT_TWO_ICEBERG_BUCKET}" ], "isExcludes": false, "isRecursive": false }, "key": { "values": [ "*" ], "isExcludes": false, "isRecursive": true } }, "policyItems": [ { "accesses": [ { "type": "all", "isAllowed": true } ], "roles": [ "${TENANT_TWO_ROLE}" ], "delegateAdmin": false } ], "serviceType": "ozone", "isDenyAllElse": false } + Create Ranger Policy ${policy_json} + + # This policy gives '${TENANT_THREE_ROLE}' role ALL permission on keys ${TENANT_THREE}/${TENANT_THREE_ICEBERG_BUCKET}/*. + ${policy_json} = Set Variable { "isEnabled": true, "service": "dev_ozone", "name": "${TENANT_THREE} ${TENANT_THREE_ICEBERG_BUCKET} table access", "policyType": 0, "policyPriority": 0, "isAuditEnabled": true, "resources": { "volume": { "values": [ "${TENANT_THREE}" ], "isExcludes": false, "isRecursive": false }, "bucket": { "values": [ "${TENANT_THREE_ICEBERG_BUCKET}" ], "isExcludes": false, "isRecursive": false }, "key": { "values": [ "*" ], "isExcludes": false, "isRecursive": true } }, "policyItems": [ { "accesses": [ { "type": "all", "isAllowed": true } ], "roles": [ "${TENANT_THREE_ROLE}" ], "delegateAdmin": false } ], "serviceType": "ozone", "isDenyAllElse": false } + Create Ranger Policy ${policy_json} + + # This policy gives '${TENANT_ONE_ROLE}' role ALL permission on keys ${TENANT_TWO}/${TENANT_TWO_ANOTHER_BUCKET}/*. + ${policy_json} = Set Variable { "isEnabled": true, "service": "dev_ozone", "name": "${TENANT_TWO} ${TENANT_TWO_ANOTHER_BUCKET} table access", "policyType": 0, "policyPriority": 0, "isAuditEnabled": true, "resources": { "volume": { "values": [ "${TENANT_TWO}" ], "isExcludes": false, "isRecursive": false }, "bucket": { "values": [ "${TENANT_TWO_ANOTHER_BUCKET}" ], "isExcludes": false, "isRecursive": false }, "key": { "values": [ "*" ], "isExcludes": false, "isRecursive": true } }, "policyItems": [ { "accesses": [ { "type": "all", "isAllowed": true } ], "roles": [ "${TENANT_ONE_ROLE}" ], "delegateAdmin": false } ], "serviceType": "ozone", "isDenyAllElse": false } + Create Ranger Policy ${policy_json} + + # Update Ranger policy cache + Kinit test user ${OM_ADMIN_USER} ${OM_ADMIN_USER}.keytab + ${om_param} = Get OM Service Param + ${output} = Execute ozone admin om updateranger ${om_param} + Should contain ${output} Operation completed successfully + +Get S3 Credentials for Principals, Create Buckets, and Upload Files to Buckets + Kinit test user ${USER_A} ${USER_A}.keytab + + # Kinit OM_ADMIN_USER that has access to all volumes/buckets per Ranger default policies + Kinit test user ${OM_ADMIN_USER} ${OM_ADMIN_USER}.keytab + + # Tenant 1 data + Execute ozone sh bucket create /${TENANT_ONE}/${TENANT_ONE_ICEBERG_BUCKET} + Create File ${TEMP_DIR}/${ICEBERG_BUCKET_TESTFILE} + Execute ozone sh key put /${TENANT_ONE}/${TENANT_ONE_ICEBERG_BUCKET}/${ICEBERG_BUCKET_TESTFILE} ${TEMP_DIR}/${ICEBERG_BUCKET_TESTFILE} + + # Tenant 2 data and linked bucket + Execute ozone sh bucket create /${TENANT_TWO}/${TENANT_TWO_ICEBERG_BUCKET} + Execute ozone sh key put /${TENANT_TWO}/${TENANT_TWO_ICEBERG_BUCKET}/${ICEBERG_BUCKET_TESTFILE} ${TEMP_DIR}/${ICEBERG_BUCKET_TESTFILE} + Execute ozone sh bucket create /${TENANT_TWO}/${TENANT_TWO_ANOTHER_BUCKET} + Create File ${TEMP_DIR}/${ANOTHER_BUCKET_TESTFILE} + Execute ozone sh key put /${TENANT_TWO}/${TENANT_TWO_ANOTHER_BUCKET}/${ANOTHER_BUCKET_TESTFILE} ${TEMP_DIR}/${ANOTHER_BUCKET_TESTFILE} + # Link ${TENANT_ONE}/${TENANT_ONE_LINKED_BUCKET} to ${TENANT_TWO}/${TENANT_TWO_ANOTHER_BUCKET} + Execute ozone sh bucket link /${TENANT_TWO}/${TENANT_TWO_ANOTHER_BUCKET} /${TENANT_ONE}/${TENANT_ONE_LINKED_BUCKET} + Execute ozone sh key put /${TENANT_ONE}/${TENANT_ONE_LINKED_BUCKET}/${ANOTHER_BUCKET_TESTFILE} ${TEMP_DIR}/${ANOTHER_BUCKET_TESTFILE} + + # Tenant 3 data + Execute ozone sh bucket create /${TENANT_THREE}/${TENANT_THREE_ICEBERG_BUCKET} + Execute ozone sh key put /${TENANT_THREE}/${TENANT_THREE_ICEBERG_BUCKET}/${ICEBERG_BUCKET_TESTFILE} ${TEMP_DIR}/${ICEBERG_BUCKET_TESTFILE} + +Create Role-Scoped Tokens for All Three Tenants + Set Global Variable ${ROLE_ARN} arn:aws:iam::123456789012:role/${TENANT_ONE_ROLE} + # Waiting for Ranger policy cache refresh - ${USER_A} needs to be able to assume role + Wait Until Keyword Succeeds 60s 5s Assume Role And Get Temporary Credentials perm_access_key_id=${USER_A_T1_PERM_ACCESS_KEY_ID} perm_secret_key=${USER_A_T1_PERM_SECRET_KEY} role_arn=${ROLE_ARN} + Set Global Variable ${USER_A_T1_STS_ACCESS_KEY_ID} ${STS_ACCESS_KEY_ID} + Set Global Variable ${USER_A_T1_STS_SECRET_KEY} ${STS_SECRET_KEY} + Set Global Variable ${USER_A_T1_STS_SESSION_TOKEN} ${STS_SESSION_TOKEN} + + Set Global Variable ${ROLE_ARN} arn:aws:iam::123456789012:role/${TENANT_TWO_ROLE} + Assume Role And Get Temporary Credentials perm_access_key_id=${USER_A_T2_PERM_ACCESS_KEY_ID} perm_secret_key=${USER_A_T2_PERM_SECRET_KEY} role_arn=${ROLE_ARN} + Set Global Variable ${USER_A_T2_STS_ACCESS_KEY_ID} ${STS_ACCESS_KEY_ID} + Set Global Variable ${USER_A_T2_STS_SECRET_KEY} ${STS_SECRET_KEY} + Set Global Variable ${USER_A_T2_STS_SESSION_TOKEN} ${STS_SESSION_TOKEN} + + Set Global Variable ${ROLE_ARN} arn:aws:iam::123456789012:role/${TENANT_THREE_ROLE} + Assume Role And Get Temporary Credentials perm_access_key_id=${USER_B_T3_PERM_ACCESS_KEY_ID} perm_secret_key=${USER_B_T3_PERM_SECRET_KEY} role_arn=${ROLE_ARN} + Set Global Variable ${USER_B_T3_STS_ACCESS_KEY_ID} ${STS_ACCESS_KEY_ID} + Set Global Variable ${USER_B_T3_STS_SECRET_KEY} ${STS_SECRET_KEY} + Set Global Variable ${USER_B_T3_STS_SESSION_TOKEN} ${STS_SESSION_TOKEN} + +Verify Role-Scoped Token Accesses + Configure STS Profile ${USER_A_T1_STS_ACCESS_KEY_ID} ${USER_A_T1_STS_SECRET_KEY} ${USER_A_T1_STS_SESSION_TOKEN} + + # This token should be able to read from ${TENANT_ONE}/${TENANT_ONE_ICEBERG_BUCKET} but not ${TENANT_TWO}/${TENANT_TWO_ICEBERG_BUCKET} + # nor ${TENANT_THREE}/${TENANT_THREE_ICEBERG_BUCKET}. Also verify that it can write to ${TENANT_ONE}/${TENANT_ONE_ICEBERG_BUCKET}. + Get Object Should Succeed ${TENANT_ONE_ICEBERG_BUCKET} ${ICEBERG_BUCKET_TESTFILE} + Get Object Should Fail ${TENANT_TWO_ICEBERG_BUCKET} ${ICEBERG_BUCKET_TESTFILE} NoSuchBucket + Get Object Should Fail ${TENANT_THREE_ICEBERG_BUCKET} ${ICEBERG_BUCKET_TESTFILE} NoSuchBucket + Put Object Should Succeed ${TENANT_ONE_ICEBERG_BUCKET} ${ICEBERG_BUCKET_TESTFILE} + + # This token should be able to read from/write to ${TENANT_ONE}/${TENANT_ONE_LINKED_BUCKET} which is linked to + # ${TENANT_TWO}/${TENANT_TWO_ANOTHER_BUCKET}. It should NOT be able to read from/write to ${TENANT_TWO}/${TENANT_TWO_ANOTHER_BUCKET} + # even though the ${TENANT_ONE_ROLE} has access because the permanent credential only has access to ${TENANT_ONE} volume. + Get Object Should Succeed ${TENANT_ONE_LINKED_BUCKET} ${ANOTHER_BUCKET_TESTFILE} + Put Object Should Succeed ${TENANT_ONE_LINKED_BUCKET} ${ANOTHER_BUCKET_TESTFILE} + Get Object Should Fail ${TENANT_TWO_ANOTHER_BUCKET} ${ANOTHER_BUCKET_TESTFILE} NoSuchBucket + Put Object Should Fail ${TENANT_TWO_ANOTHER_BUCKET} ${ANOTHER_BUCKET_TESTFILE} NoSuchBucket + + Configure STS Profile ${USER_A_T2_STS_ACCESS_KEY_ID} ${USER_A_T2_STS_SECRET_KEY} ${USER_A_T2_STS_SESSION_TOKEN} + + # This token should be able to read from ${TENANT_TWO}/${TENANT_TWO_ICEBERG_BUCKET} but not ${TENANT_ONE}/${TENANT_ONE_ICEBERG_BUCKET} + # nor ${TENANT_TWO}/${TENANT_TWO_ANOTHER_BUCKET} nor ${TENANT_THREE}/${TENANT_THREE_ICEBERG_BUCKET} + # nor ${TENANT_ONE}/${TENANT_ONE_LINKED_BUCKET}. + # Also verify that it can write to ${TENANT_TWO}/${TENANT_TWO_ICEBERG_BUCKET}. + Get Object Should Succeed ${TENANT_TWO_ICEBERG_BUCKET} ${ICEBERG_BUCKET_TESTFILE} + Get Object Should Fail ${TENANT_ONE_ICEBERG_BUCKET} ${ICEBERG_BUCKET_TESTFILE} NoSuchBucket + Get Object Should Fail ${TENANT_TWO_ANOTHER_BUCKET} ${ANOTHER_BUCKET_TESTFILE} AccessDenied + Get Object Should Fail ${TENANT_THREE_ICEBERG_BUCKET} ${ICEBERG_BUCKET_TESTFILE} NoSuchBucket + Get Object Should Fail ${TENANT_ONE_LINKED_BUCKET} ${ANOTHER_BUCKET_TESTFILE} NoSuchBucket + Put Object Should Succeed ${TENANT_TWO_ICEBERG_BUCKET} ${ICEBERG_BUCKET_TESTFILE} + + Configure STS Profile ${USER_B_T3_STS_ACCESS_KEY_ID} ${USER_B_T3_STS_SECRET_KEY} ${USER_B_T3_STS_SESSION_TOKEN} + + # This token should be able to read from ${TENANT_THREE}/${TENANT_THREE_ICEBERG_BUCKET} but not ${TENANT_ONE}/${TENANT_ONE_ICEBERG_BUCKET} + # nor ${TENANT_TWO}/${TENANT_TWO_ICEBERG_BUCKET} nor ${TENANT_TWO}/${TENANT_TWO_ANOTHER_BUCKET} + # nor ${TENANT_ONE}/${TENANT_ONE_LINKED_BUCKET}. + # Also verify that it can write to ${TENANT_THREE}/${TENANT_THREE_ICEBERG_BUCKET}. + Get Object Should Succeed ${TENANT_THREE_ICEBERG_BUCKET} ${ICEBERG_BUCKET_TESTFILE} + Get Object Should Fail ${TENANT_ONE_ICEBERG_BUCKET} ${ICEBERG_BUCKET_TESTFILE} NoSuchBucket + Get Object Should Fail ${TENANT_TWO_ICEBERG_BUCKET} ${ICEBERG_BUCKET_TESTFILE} NoSuchBucket + Get Object Should Fail ${TENANT_TWO_ANOTHER_BUCKET} ${ANOTHER_BUCKET_TESTFILE} NoSuchBucket + Get Object Should Fail ${TENANT_ONE_LINKED_BUCKET} ${ANOTHER_BUCKET_TESTFILE} NoSuchBucket + Put Object Should Succeed ${TENANT_THREE_ICEBERG_BUCKET} ${ICEBERG_BUCKET_TESTFILE} + +Create Limited-Scoped Tokens for All Three Tenants + # Limit scope to read-only for keys in ${TENANT_ONE_ICEBERG_BUCKET} (note ${TENANT_ONE_LINKED_BUCKET} is excluded) + ${session_policy} = Set Variable {"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:GetObject","Resource": "arn:aws:s3:::${TENANT_ONE_ICEBERG_BUCKET}/*" }]} + Set Global Variable ${ROLE_ARN} arn:aws:iam::123456789012:role/${TENANT_ONE_ROLE} + Assume Role And Get Temporary Credentials policy_json=${session_policy} perm_access_key_id=${USER_A_T1_PERM_ACCESS_KEY_ID} perm_secret_key=${USER_A_T1_PERM_SECRET_KEY} role_arn=${ROLE_ARN} + Set Global Variable ${USER_A_T1_STS_ACCESS_KEY_ID} ${STS_ACCESS_KEY_ID} + Set Global Variable ${USER_A_T1_STS_SECRET_KEY} ${STS_SECRET_KEY} + Set Global Variable ${USER_A_T1_STS_SESSION_TOKEN} ${STS_SESSION_TOKEN} + + # Limit scope to read-only for keys in ${TENANT_TWO_ICEBERG_BUCKET} + ${session_policy} = Set Variable {"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:GetObject","Resource":"arn:aws:s3:::${TENANT_TWO_ICEBERG_BUCKET}/*"}]} + Set Global Variable ${ROLE_ARN} arn:aws:iam::123456789012:role/${TENANT_TWO_ROLE} + Assume Role And Get Temporary Credentials policy_json=${session_policy} perm_access_key_id=${USER_A_T2_PERM_ACCESS_KEY_ID} perm_secret_key=${USER_A_T2_PERM_SECRET_KEY} role_arn=${ROLE_ARN} + Set Global Variable ${USER_A_T2_STS_ACCESS_KEY_ID} ${STS_ACCESS_KEY_ID} + Set Global Variable ${USER_A_T2_STS_SECRET_KEY} ${STS_SECRET_KEY} + Set Global Variable ${USER_A_T2_STS_SESSION_TOKEN} ${STS_SESSION_TOKEN} + + # Limit scope to read-only for ${TENANT_THREE_ICEBERG_BUCKET} keys + ${session_policy} = Set Variable {"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:GetObject","Resource":"arn:aws:s3:::${TENANT_THREE_ICEBERG_BUCKET}/*"}]} + Set Global Variable ${ROLE_ARN} arn:aws:iam::123456789012:role/${TENANT_THREE_ROLE} + Assume Role And Get Temporary Credentials policy_json=${session_policy} perm_access_key_id=${USER_B_T3_PERM_ACCESS_KEY_ID} perm_secret_key=${USER_B_T3_PERM_SECRET_KEY} role_arn=${ROLE_ARN} + Set Global Variable ${USER_B_T3_STS_ACCESS_KEY_ID} ${STS_ACCESS_KEY_ID} + Set Global Variable ${USER_B_T3_STS_SECRET_KEY} ${STS_SECRET_KEY} + Set Global Variable ${USER_B_T3_STS_SESSION_TOKEN} ${STS_SESSION_TOKEN} + +Verify Limited-Scoped Token Accesses + Configure STS Profile ${USER_A_T1_STS_ACCESS_KEY_ID} ${USER_A_T1_STS_SECRET_KEY} ${USER_A_T1_STS_SESSION_TOKEN} + + # This token should be able to read from ${TENANT_ONE}/${TENANT_ONE_ICEBERG_BUCKET} but not ${TENANT_TWO}/${TENANT_TWO_ICEBERG_BUCKET} + # nor ${TENANT_THREE}/${TENANT_THREE_ICEBERG_BUCKET}. Also verify that it CANNOT write to ${TENANT_ONE}/${TENANT_ONE_ICEBERG_BUCKET}. + Get Object Should Succeed ${TENANT_ONE_ICEBERG_BUCKET} ${ICEBERG_BUCKET_TESTFILE} + Get Object Should Fail ${TENANT_TWO_ICEBERG_BUCKET} ${ICEBERG_BUCKET_TESTFILE} NoSuchBucket + Get Object Should Fail ${TENANT_THREE_ICEBERG_BUCKET} ${ICEBERG_BUCKET_TESTFILE} NoSuchBucket + Put Object Should Fail ${TENANT_ONE_ICEBERG_BUCKET} ${ICEBERG_BUCKET_TESTFILE} AccessDenied + + # This token should NOT be able to read from/write to ${TENANT_ONE}/${TENANT_ONE_LINKED_BUCKET} (because of the session policy) which is linked to + # ${TENANT_TWO}/${TENANT_TWO_ANOTHER_BUCKET}. It should not be able to read from/write to ${TENANT_TWO}/${TENANT_TWO_ANOTHER_BUCKET} + # even though the ${TENANT_ONE_ROLE} has access because the permanent credential only has access to ${TENANT_ONE}. + Get Object Should Fail ${TENANT_ONE_LINKED_BUCKET} ${ANOTHER_BUCKET_TESTFILE} AccessDenied + Put Object Should Fail ${TENANT_ONE_LINKED_BUCKET} ${ANOTHER_BUCKET_TESTFILE} AccessDenied + Get Object Should Fail ${TENANT_TWO_ANOTHER_BUCKET} ${ANOTHER_BUCKET_TESTFILE} NoSuchBucket + Put Object Should Fail ${TENANT_TWO_ANOTHER_BUCKET} ${ANOTHER_BUCKET_TESTFILE} AccessDenied + + Configure STS Profile ${USER_A_T2_STS_ACCESS_KEY_ID} ${USER_A_T2_STS_SECRET_KEY} ${USER_A_T2_STS_SESSION_TOKEN} + + # This token should be able to read from ${TENANT_TWO}/${TENANT_TWO_ICEBERG_BUCKET} but not ${TENANT_ONE}/${TENANT_ONE_ICEBERG_BUCKET} + # nor ${TENANT_TWO}/${TENANT_TWO_ANOTHER_BUCKET} nor ${TENANT_THREE}/${TENANT_THREE_ICEBERG_BUCKET} + # nor ${TENANT_ONE}/${TENANT_ONE_LINKED_BUCKET}. + # Also verify that it CANNOT write to ${TENANT_TWO}/${TENANT_TWO_ICEBERG_BUCKET}. + Get Object Should Succeed ${TENANT_TWO_ICEBERG_BUCKET} ${ICEBERG_BUCKET_TESTFILE} + Get Object Should Fail ${TENANT_ONE_ICEBERG_BUCKET} ${ICEBERG_BUCKET_TESTFILE} NoSuchBucket + Get Object Should Fail ${TENANT_TWO_ANOTHER_BUCKET} ${ANOTHER_BUCKET_TESTFILE} AccessDenied + Get Object Should Fail ${TENANT_THREE_ICEBERG_BUCKET} ${ICEBERG_BUCKET_TESTFILE} NoSuchBucket + Get Object Should Fail ${TENANT_ONE_LINKED_BUCKET} ${ANOTHER_BUCKET_TESTFILE} NoSuchBucket + Put Object Should Fail ${TENANT_TWO_ICEBERG_BUCKET} ${ICEBERG_BUCKET_TESTFILE} AccessDenied + + Configure STS Profile ${USER_B_T3_STS_ACCESS_KEY_ID} ${USER_B_T3_STS_SECRET_KEY} ${USER_B_T3_STS_SESSION_TOKEN} + + # This token should be able to read from ${TENANT_THREE}/${TENANT_THREE_ICEBERG_BUCKET} but not ${TENANT_ONE}/${TENANT_ONE_ICEBERG_BUCKET} + # nor ${TENANT_TWO}/${TENANT_TWO_ICEBERG_BUCKET} nor ${TENANT_TWO}/${TENANT_TWO_ANOTHER_BUCKET} + # nor ${TENANT_ONE}/${TENANT_ONE_LINKED_BUCKET}. + # Also verify that it CANNOT write to ${TENANT_THREE}/${TENANT_THREE_ICEBERG_BUCKET}. + Get Object Should Succeed ${TENANT_THREE_ICEBERG_BUCKET} ${ICEBERG_BUCKET_TESTFILE} + Get Object Should Fail ${TENANT_ONE_ICEBERG_BUCKET} ${ICEBERG_BUCKET_TESTFILE} NoSuchBucket + Get Object Should Fail ${TENANT_TWO_ICEBERG_BUCKET} ${ICEBERG_BUCKET_TESTFILE} NoSuchBucket + Get Object Should Fail ${TENANT_TWO_ANOTHER_BUCKET} ${ANOTHER_BUCKET_TESTFILE} NoSuchBucket + Get Object Should Fail ${TENANT_ONE_LINKED_BUCKET} ${ANOTHER_BUCKET_TESTFILE} NoSuchBucket + Put Object Should Fail ${TENANT_THREE_ICEBERG_BUCKET} ${ICEBERG_BUCKET_TESTFILE} AccessDenied + +Tenant One Role Session Policy Must Not Access Tenant Three Bucket + ${session_policy} = Set Variable {"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:GetObject","Resource":"arn:aws:s3:::${TENANT_THREE_ICEBERG_BUCKET}/*"}]} + Set Global Variable ${ROLE_ARN} arn:aws:iam::123456789012:role/${TENANT_ONE_ROLE} + Assume Role And Get Temporary Credentials policy_json=${session_policy} perm_access_key_id=${USER_A_T1_PERM_ACCESS_KEY_ID} perm_secret_key=${USER_A_T1_PERM_SECRET_KEY} role_arn=${ROLE_ARN} + Configure STS Profile ${STS_ACCESS_KEY_ID} ${STS_SECRET_KEY} ${STS_SESSION_TOKEN} + Get Object Should Fail ${TENANT_THREE_ICEBERG_BUCKET} ${ICEBERG_BUCKET_TESTFILE} NoSuchBucket + +Tenant One User Must Not Assume Tenant Three Role + Assume Role Should Fail perm_access_key_id=${USER_A_T1_PERM_ACCESS_KEY_ID} perm_secret_key=${USER_A_T1_PERM_SECRET_KEY} expected_error=AccessDenied role_arn=arn:aws:iam::123456789012:role/${TENANT_THREE_ROLE} + +Multitenant Revocation Scenarios + # Create another session token for ${USER_A} in ${TENANT_ONE}, verify it works, then revoke that permanent secret and verify the token no longer works. + # Also verify the token for ${USER_A} in ${TENANT_TWO} still works + Set Global Variable ${ROLE_ARN} arn:aws:iam::123456789012:role/${TENANT_ONE_ROLE} + Assume Role And Get Temporary Credentials perm_access_key_id=${USER_A_T1_PERM_ACCESS_KEY_ID} perm_secret_key=${USER_A_T1_PERM_SECRET_KEY} role_arn=${ROLE_ARN} + Set Global Variable ${USER_A_T1_STS_ACCESS_KEY_ID} ${STS_ACCESS_KEY_ID} + Set Global Variable ${USER_A_T1_STS_SECRET_KEY} ${STS_SECRET_KEY} + Set Global Variable ${USER_A_T1_STS_SESSION_TOKEN} ${STS_SESSION_TOKEN} + Configure STS Profile ${USER_A_T1_STS_ACCESS_KEY_ID} ${USER_A_T1_STS_SECRET_KEY} ${USER_A_T1_STS_SESSION_TOKEN} + Get Object Should Succeed ${TENANT_ONE_ICEBERG_BUCKET} ${ICEBERG_BUCKET_TESTFILE} + + Kinit test user ${OM_ADMIN_USER} ${OM_ADMIN_USER}.keytab + ${output} = Execute ozone tenant --verbose user revoke '${TENANT_ONE}$${USER_A}' + Should contain ${output} Revoked accessId '${TENANT_ONE}$${USER_A}'. + Get Object Should Fail ${TENANT_ONE_ICEBERG_BUCKET} ${ICEBERG_BUCKET_TESTFILE} AccessDenied + + Configure STS Profile ${USER_A_T2_STS_ACCESS_KEY_ID} ${USER_A_T2_STS_SECRET_KEY} ${USER_A_T2_STS_SESSION_TOKEN} + Get Object Should Succeed ${TENANT_TWO_ICEBERG_BUCKET} ${ICEBERG_BUCKET_TESTFILE} + + # Revoking the secret for ${USER_B} should NOT affect the session token, as it is based on ${TENANT_THREE}$${USER_B} combination. + Kinit test user ${USER_B} ${USER_B}.keytab + Execute ozone s3 getsecret -u ${TEST_USER} ${OM_HA_PARAM} + Execute ozone s3 revokesecret -y -u ${TEST_USER} ${OM_HA_PARAM} + Configure STS Profile ${USER_B_T3_STS_ACCESS_KEY_ID} ${USER_B_T3_STS_SECRET_KEY} ${USER_B_T3_STS_SESSION_TOKEN} + Get Object Should Succeed ${TENANT_THREE_ICEBERG_BUCKET} ${ICEBERG_BUCKET_TESTFILE} + + From 113e3d0f100e71fa347f20e380f205759cd01606 Mon Sep 17 00:00:00 2001 From: fmorg-git Date: Mon, 17 Aug 2026 01:50:09 -0700 Subject: [PATCH 46/54] HDDS-16187. [STS] Fix Latent S3 DeleteObjects Issue (#11019) --- .../smoketest/security/ozone-secure-sts.robot | 28 ++++++++ .../ozone/s3/endpoint/BucketEndpoint.java | 32 +++++++-- .../s3/endpoint/TestPermissionCheck.java | 70 +++++++++++++++---- 3 files changed, 108 insertions(+), 22 deletions(-) diff --git a/hadoop-ozone/dist/src/main/smoketest/security/ozone-secure-sts.robot b/hadoop-ozone/dist/src/main/smoketest/security/ozone-secure-sts.robot index 68e12e40b5e0..a1f34e818b76 100644 --- a/hadoop-ozone/dist/src/main/smoketest/security/ozone-secure-sts.robot +++ b/hadoop-ozone/dist/src/main/smoketest/security/ozone-secure-sts.robot @@ -1209,6 +1209,34 @@ STS session policy s3:* on * must allow ListAllMyBuckets, Create/ListBucket, and ${output} = Execute aws s3api --endpoint-url ${S3G_ENDPOINT_URL} delete-bucket --bucket ${bucket} --profile sts Should Not Contain ${output} AccessDenied +STS session policy containing only GetObject must deny DeleteObjects + ${bucket_suffix} = Generate Random String 8 [LOWER] + ${bucket} = Set Variable sts-bucket-deleteobjects-${bucket_suffix} + ${key_suffix} = Generate Random String 8 [LOWER] + ${key} = Set Variable sts-deny-deleteobjects-${key_suffix}.txt + ${local_path} = Set Variable ${TEMP_DIR}/${key} + Create File ${local_path} deleteobjects deny test content + + # Create bucket and object with full STS temp-bucket role permissions. + Assume Role And Configure STS Profile perm_access_key_id=${PERMANENT_ACCESS_KEY_ID} perm_secret_key=${PERMANENT_SECRET_KEY} role_arn=${STS_TEMP_BUCKET_ROLE_ARN} + ${output} = Execute aws s3api --endpoint-url ${S3G_ENDPOINT_URL} create-bucket --bucket ${bucket} --profile sts + Should Contain ${output} Location + ${output} = Execute aws s3api --endpoint-url ${S3G_ENDPOINT_URL} put-object --bucket ${bucket} --key ${key} --body ${local_path} --profile sts + Should Contain ${output} "ETag" + + # Restrict token to GetObject-only via session policy. DeleteObjects must return AccessDenied. + ${session_policy} = Set Variable {"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:GetObject","Resource":"arn:aws:s3:::${bucket}/*"}]} + Assume Role And Configure STS Profile policy_json=${session_policy} perm_access_key_id=${PERMANENT_ACCESS_KEY_ID} perm_secret_key=${PERMANENT_SECRET_KEY} role_arn=${STS_TEMP_BUCKET_ROLE_ARN} + ${output} = Execute And Ignore Error aws s3api --endpoint-url ${S3G_ENDPOINT_URL} delete-objects --bucket ${bucket} --delete 'Objects=[{Key=${key}}],Quiet=false' --profile sts + Run Keyword And Continue On Failure Should Contain ${output} AccessDenied + + # Cleanup using a full-permission token. + Assume Role And Configure STS Profile perm_access_key_id=${PERMANENT_ACCESS_KEY_ID} perm_secret_key=${PERMANENT_SECRET_KEY} role_arn=${STS_TEMP_BUCKET_ROLE_ARN} + ${output} = Execute aws s3api --endpoint-url ${S3G_ENDPOINT_URL} delete-object --bucket ${bucket} --key ${key} --profile sts + Should Not Contain ${output} AccessDenied + ${output} = Execute aws s3api --endpoint-url ${S3G_ENDPOINT_URL} delete-bucket --bucket ${bucket} --profile sts + Should Not Contain ${output} AccessDenied + Revoking Permanent User Must Revoke Existing Session Token # Create session tokens for both buckets, verify they work, then revoke permanent user secret and verify both fail. Assume Role And Get Temporary Credentials perm_access_key_id=${PERMANENT_ACCESS_KEY_ID} perm_secret_key=${PERMANENT_SECRET_KEY} role_arn=${ICEBERG_ALL_ACCESS_ROLE_OBS_ARN} diff --git a/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/endpoint/BucketEndpoint.java b/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/endpoint/BucketEndpoint.java index 8830e59921be..c833d5f22f43 100644 --- a/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/endpoint/BucketEndpoint.java +++ b/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/endpoint/BucketEndpoint.java @@ -46,6 +46,7 @@ import javax.ws.rs.core.MediaType; import javax.ws.rs.core.Response; import org.apache.commons.lang3.StringUtils; +import org.apache.hadoop.hdds.scm.client.HddsClientUtils; import org.apache.hadoop.ozone.audit.AuditEventStatus; import org.apache.hadoop.ozone.audit.AuditMessage; import org.apache.hadoop.ozone.audit.S3GAction; @@ -351,7 +352,14 @@ public MultiDeleteResponse multiDelete( throw newError(S3ErrorTable.MALFORMED_XML, bucketName); } - OzoneBucket bucket = getVolume().getBucket(bucketName); + final OzoneBucket bucket; + try { + bucket = getVolume().getBucket(bucketName); + } catch (OMException ex) { + throw newError(bucketName, ex); + } catch (IOException ex) { + throw newError(S3ErrorTable.INTERNAL_ERROR, bucketName, ex); + } MultiDeleteResponse result = new MultiDeleteResponse(); List deleteKeys = new ArrayList<>(); @@ -380,27 +388,37 @@ public MultiDeleteResponse multiDelete( } getMetrics().updateDeleteKeySuccessStats(startNanos); } catch (IOException ex) { - LOG.error("Delete key failed: {}", ex.getMessage()); getMetrics().updateDeleteKeyFailureStats(startNanos); + final OMException omEx = (OMException) HddsClientUtils.containsException(ex, OMException.class); + if (omEx != null) { + auditMultiDeleteFailure(context, deleteKeys, omEx); + throw newError(bucketName, omEx); + } + LOG.error("Delete key failed: {}", ex.getMessage()); result.addError( new Error("ALL", "InternalError", ex.getMessage())); } } - AuditMessage.Builder message = auditMessageFor(context.getAction()); - message.getParams().put("failedDeletes", deleteKeys.toString()); - if (!result.getErrors().isEmpty()) { - AUDIT.logWriteFailure(message.withResult(AuditEventStatus.FAILURE) - .withException(new Exception("MultiDelete Exception")).build()); + auditMultiDeleteFailure(context, deleteKeys, new Exception("MultiDelete Exception")); } else { + AuditMessage.Builder message = auditMessageFor(context.getAction()); + message.getParams().put("failedDeletes", deleteKeys.toString()); AUDIT.logWriteSuccess(message.withResult(AuditEventStatus.SUCCESS).build()); } return result; } + void auditMultiDeleteFailure(S3RequestContext context, List deleteKeys, Throwable ex) { + final AuditMessage.Builder message = auditMessageFor(context.getAction()); + message.getParams().put("failedDeletes", deleteKeys.toString()); + AUDIT.logWriteFailure(message.withResult(AuditEventStatus.FAILURE) + .withException(ex).build()); + } + private void addKey(ListObjectResponse response, OzoneKey next, boolean includeOwner) { KeyMetadata keyMetadata = new KeyMetadata(); keyMetadata.setKey(EncodingTypeObject.createNullable(next.getName(), diff --git a/hadoop-ozone/s3gateway/src/test/java/org/apache/hadoop/ozone/s3/endpoint/TestPermissionCheck.java b/hadoop-ozone/s3gateway/src/test/java/org/apache/hadoop/ozone/s3/endpoint/TestPermissionCheck.java index bc9e19db1b6d..5229ca0e8983 100644 --- a/hadoop-ozone/s3gateway/src/test/java/org/apache/hadoop/ozone/s3/endpoint/TestPermissionCheck.java +++ b/hadoop-ozone/s3gateway/src/test/java/org/apache/hadoop/ozone/s3/endpoint/TestPermissionCheck.java @@ -35,15 +35,17 @@ import static org.mockito.Mockito.anyMap; import static org.mockito.Mockito.anyString; import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.eq; import static org.mockito.Mockito.isNull; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import java.io.IOException; -import java.util.ArrayList; -import java.util.HashMap; -import java.util.List; +import java.util.Collections; import java.util.Map; +import java.util.stream.Stream; import javax.ws.rs.core.HttpHeaders; import org.apache.hadoop.hdds.conf.OzoneConfiguration; import org.apache.hadoop.ozone.OzoneConfigKeys; @@ -53,6 +55,7 @@ import org.apache.hadoop.ozone.client.OzoneVolume; import org.apache.hadoop.ozone.client.protocol.ClientProtocol; import org.apache.hadoop.ozone.om.exceptions.OMException; +import org.apache.hadoop.ozone.om.exceptions.OMException.ResultCodes; import org.apache.hadoop.ozone.om.helpers.ErrorInfo; import org.apache.hadoop.ozone.s3.exception.OS3Exception; import org.apache.hadoop.ozone.s3.exception.S3ErrorTable; @@ -61,6 +64,9 @@ import org.apache.hadoop.ozone.s3.util.S3Consts.QueryParams; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; /** * Test operation permission check result. @@ -87,8 +93,7 @@ public void setup() { bucket = mock(OzoneBucket.class); volume = mock(OzoneVolume.class); when(volume.getName()).thenReturn("s3Volume"); - exception = new OMException("Permission Denied", - OMException.ResultCodes.PERMISSION_DENIED); + exception = new OMException("Permission Denied", ResultCodes.PERMISSION_DENIED); when(client.getObjectStore()).thenReturn(objectStore); when(client.getConfiguration()).thenReturn(conf); headers = mock(HttpHeaders.class); @@ -186,25 +191,42 @@ public void testDeleteKeys() throws IOException, OS3Exception { when(objectStore.getVolume(anyString())).thenReturn(volume); when(objectStore.getS3Volume()).thenReturn(volume); when(volume.getBucket(anyString())).thenReturn(bucket); - Map deleteErrors = new HashMap<>(); - deleteErrors.put("deleteKeyName", new ErrorInfo("ACCESS_DENIED", "ACL check failed")); + final Map deleteErrors = Collections.singletonMap( + "deleteKeyName", new ErrorInfo("ACCESS_DENIED", "ACL check failed")); when(bucket.deleteKeys(any(), anyBoolean())).thenReturn(deleteErrors); - BucketEndpoint bucketEndpoint = EndpointBuilder.newBucketEndpointBuilder() + final BucketEndpoint bucketEndpoint = EndpointBuilder.newBucketEndpointBuilder() .setClient(client) .build(); - MultiDeleteRequest request = new MultiDeleteRequest(); - List objectList = new ArrayList<>(); - objectList.add(new MultiDeleteRequest.DeleteObject("deleteKeyName")); - request.setQuiet(false); - request.setObjects(objectList); + final MultiDeleteRequest request = createMultiDeleteRequest(); - MultiDeleteResponse response = - bucketEndpoint.multiDelete("BucketName", "keyName", request); + final MultiDeleteResponse response = bucketEndpoint.multiDelete("BucketName", "keyName", request); assertEquals(1, response.getErrors().size()); assertEquals("ACCESS_DENIED", response.getErrors().get(0).getCode()); } + @ParameterizedTest + @MethodSource("deleteKeysTranslatedOMFailures") + public void testDeleteKeysTranslatesContainedOMFailures(ResultCodes resultCode, S3ErrorTable expectedError) + throws IOException { + when(objectStore.getVolume(anyString())).thenReturn(volume); + when(objectStore.getS3Volume()).thenReturn(volume); + when(volume.getBucket(anyString())).thenReturn(bucket); + doThrow(new IOException("DeleteObjects failed", new OMException("OM failure", resultCode))) + .when(bucket).deleteKeys(any(), anyBoolean()); + + final BucketEndpoint bucketEndpoint = spy(new BucketEndpoint()); + EndpointBuilder.newBucketEndpointBuilder() + .setBase(bucketEndpoint) + .setClient(client) + .build(); + final MultiDeleteRequest request = createMultiDeleteRequest(); + + assertErrorResponse(expectedError, () -> bucketEndpoint.multiDelete("BucketName", "keyName", request)); + verify(bucketEndpoint).auditMultiDeleteFailure( + any(), eq(Collections.singletonList("deleteKeyName")), any(OMException.class)); + } + @Test public void testGetAcl() throws Exception { when(objectStore.getS3Volume()).thenReturn(volume); @@ -333,4 +355,22 @@ public void testObjectTagging() throws Exception { assertErrorResponse(S3ErrorTable.ACCESS_DENIED, () -> getTagging(objectEndpoint, "bucketName", "keyPath")); } + + private static MultiDeleteRequest createMultiDeleteRequest() { + final MultiDeleteRequest request = new MultiDeleteRequest(); + request.setQuiet(false); + request.setObjects(Collections.singletonList(new MultiDeleteRequest.DeleteObject("deleteKeyName"))); + return request; + } + + private static Stream deleteKeysTranslatedOMFailures() { + return Stream.of( + Arguments.of(ResultCodes.ACCESS_DENIED, S3ErrorTable.ACCESS_DENIED), + Arguments.of(ResultCodes.PERMISSION_DENIED, S3ErrorTable.ACCESS_DENIED), + Arguments.of(ResultCodes.INVALID_TOKEN, S3ErrorTable.ACCESS_DENIED), + Arguments.of(ResultCodes.REVOKED_TOKEN, S3ErrorTable.ACCESS_DENIED), + Arguments.of(ResultCodes.TOKEN_EXPIRED, S3ErrorTable.EXPIRED_TOKEN), + Arguments.of(ResultCodes.BUCKET_NOT_FOUND, S3ErrorTable.NO_SUCH_BUCKET) + ); + } } From 3a56b7faa3591d36b5922ff2eff2b4436670eee6 Mon Sep 17 00:00:00 2001 From: fmorg-git Date: Mon, 17 Aug 2026 22:30:47 -0700 Subject: [PATCH 47/54] HDDS-15325. [STS] Polaris Smoke Test (#10315) --- .../compose/ozonesecure-ha/polaris-setup.sh | 123 ++++++++++++++++++ .../ozonesecure-ha/polaris-smoketest.sh | 113 ++++++++++++++++ .../main/compose/ozonesecure-ha/polaris.yaml | 77 +++++++++++ .../compose/ozonesecure-ha/test-ranger.sh | 1 + .../security/ozone-secure-sts-polaris.sql | 27 ++++ 5 files changed, 341 insertions(+) create mode 100755 hadoop-ozone/dist/src/main/compose/ozonesecure-ha/polaris-setup.sh create mode 100755 hadoop-ozone/dist/src/main/compose/ozonesecure-ha/polaris-smoketest.sh create mode 100644 hadoop-ozone/dist/src/main/compose/ozonesecure-ha/polaris.yaml create mode 100644 hadoop-ozone/dist/src/main/smoketest/security/ozone-secure-sts-polaris.sql diff --git a/hadoop-ozone/dist/src/main/compose/ozonesecure-ha/polaris-setup.sh b/hadoop-ozone/dist/src/main/compose/ozonesecure-ha/polaris-setup.sh new file mode 100755 index 000000000000..aab766f270e2 --- /dev/null +++ b/hadoop-ozone/dist/src/main/compose/ozonesecure-ha/polaris-setup.sh @@ -0,0 +1,123 @@ +#!/usr/bin/env sh +# 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. + +set -eu + +apk add --no-cache jq >/dev/null + +realm="${POLARIS_REALM:-POLARIS}" +catalog_name="${POLARIS_CATALOG_NAME:-quickstart_catalog}" +storage_location="${POLARIS_STORAGE_LOCATION:-s3://iceberg-obs/polaris-smoke}" +s3_endpoint="${POLARIS_S3_ENDPOINT:-http://s3g:9878}" +sts_endpoint="${POLARIS_STS_ENDPOINT:-http://s3g:9880/sts}" +role_arn="${POLARIS_ROLE_ARN:-arn:aws:iam::123456789012:role/iceberg-data-all-access-obs}" + +if [ -z "${POLARIS_AWS_ACCESS_KEY_ID:-}" ] || [ -z "${POLARIS_AWS_SECRET_ACCESS_KEY:-}" ]; then + echo "POLARIS_AWS_ACCESS_KEY_ID and POLARIS_AWS_SECRET_ACCESS_KEY must be set" + exit 1 +fi + +echo "Waiting for S3 gateway at ${s3_endpoint}..." +attempt=0 +while [ "${attempt}" -lt 30 ]; do + if curl --silent --show-error --include \ + --user "${POLARIS_AWS_ACCESS_KEY_ID}:${POLARIS_AWS_SECRET_ACCESS_KEY}" \ + --aws-sigv4 "aws:amz:us-west-2:s3" \ + "${s3_endpoint}/" >/dev/null 2>&1; then + echo "${s3_endpoint} is available" + break + fi + attempt=$((attempt + 1)) + sleep 2 +done +if [ "${attempt}" -ge 30 ]; then + echo "Timed out waiting for S3 gateway at ${s3_endpoint}" + exit 1 +fi + +echo "Obtaining Polaris OAuth token..." +token="$( + curl --fail-with-body --silent \ + --user "${CLIENT_ID}:${CLIENT_SECRET}" \ + -H "Polaris-Realm: ${realm}" \ + -d grant_type=client_credentials \ + -d scope=PRINCIPAL_ROLE:ALL \ + "http://polaris:8181/api/catalog/v1/oauth/tokens" \ + | jq -r .access_token +)" +if [ -z "${token}" ] || [ "${token}" = "null" ]; then + echo "Failed to obtain access token." + exit 1 +fi + +storage_config_info="$( + jq -n \ + --arg endpoint "${s3_endpoint}" \ + --arg endpointInternal "${s3_endpoint}" \ + --arg stsEndpoint "${sts_endpoint}" \ + --arg roleArn "${role_arn}" \ + '{ + storageType: "S3", + endpoint: $endpoint, + endpointInternal: $endpointInternal, + stsEndpoint: $stsEndpoint, + roleArn: $roleArn, + stsUnavailable: false, + pathStyleAccess: true, + region: "us-west-2" + }' +)" + +payload="$( + jq -n \ + --arg name "${catalog_name}" \ + --arg location "${storage_location}" \ + --argjson storageConfigInfo "${storage_config_info}" \ + '{ + catalog: { + name: $name, + type: "INTERNAL", + readOnly: false, + properties: { + "default-base-location": $location + }, + storageConfigInfo: $storageConfigInfo + } + }' +)" + +echo "Creating catalog ${catalog_name} in realm ${realm}..." +curl --fail-with-body --silent \ + -H "Authorization: Bearer ${token}" \ + -H "Accept: application/json" \ + -H "Content-Type: application/json" \ + -H "Polaris-Realm: ${realm}" \ + "http://polaris:8181/api/management/v1/catalogs" \ + -d "${payload}" + +echo +echo "Granting catalog_admin CATALOG_MANAGE_CONTENT on ${catalog_name}..." +curl --fail-with-body --silent \ + -H "Authorization: Bearer ${token}" \ + -H "Content-Type: application/json" \ + -H "Polaris-Realm: ${realm}" \ + -X PUT \ + "http://polaris:8181/api/management/v1/catalogs/${catalog_name}/catalog-roles/catalog_admin/grants" \ + -d '{"type":"catalog", "privilege":"CATALOG_MANAGE_CONTENT"}' + +echo +echo "Polaris catalog setup complete." diff --git a/hadoop-ozone/dist/src/main/compose/ozonesecure-ha/polaris-smoketest.sh b/hadoop-ozone/dist/src/main/compose/ozonesecure-ha/polaris-smoketest.sh new file mode 100755 index 000000000000..a53bb9e9a47f --- /dev/null +++ b/hadoop-ozone/dist/src/main/compose/ozonesecure-ha/polaris-smoketest.sh @@ -0,0 +1,113 @@ +#!/usr/bin/env bash +# 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. + +set -e -u -o pipefail + +COMPOSE_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" >/dev/null 2>&1 && pwd )" +export COMPOSE_DIR + +if [[ -z "${RANGER_VERSION:-}" ]]; then + # shellcheck source=/dev/null + source "${COMPOSE_DIR}/.env" +fi + +# shellcheck source=/dev/null +source "${COMPOSE_DIR}/../testlib.sh" + +: "${POLARIS_IMAGE:=apache/polaris:1.4.1}" +: "${SPARK_SQL_IMAGE:=apache/spark:3.5.7-scala2.12-java17-ubuntu}" +: "${POLARIS_CATALOG_NAME:=quickstart_catalog}" +: "${POLARIS_STORAGE_LOCATION:=s3://iceberg-obs/polaris-smoke}" +: "${POLARIS_ICEBERG_SPARK_RUNTIME_VERSION:=1.10.1}" +: "${ICEBERG_SVC_CATALOG_USER:=svc-iceberg-rest-catalog}" +: "${ICEBERG_SVC_CATALOG_PRINCIPAL:=${ICEBERG_SVC_CATALOG_USER}/s3g@EXAMPLE.COM}" +: "${ICEBERG_SVC_CATALOG_KEYTAB:=/etc/security/keytabs/${ICEBERG_SVC_CATALOG_USER}.keytab}" + +export POLARIS_IMAGE SPARK_SQL_IMAGE POLARIS_CATALOG_NAME POLARIS_STORAGE_LOCATION + +if [[ "${COMPOSE_FILE:-}" != *polaris.yaml* ]]; then + export COMPOSE_FILE="${COMPOSE_FILE:-docker-compose.yaml:ranger.yaml:../common/ranger.yaml}:polaris.yaml" +fi + +echo "Fetching permanent S3 credentials for ${ICEBERG_SVC_CATALOG_USER}..." +s3_secret_output="$( + docker-compose exec -T s3g bash -lc \ + "kinit -kt ${ICEBERG_SVC_CATALOG_KEYTAB} ${ICEBERG_SVC_CATALOG_PRINCIPAL} && ozone sh volume info s3v && ozone s3 getsecret" +)" + +POLARIS_AWS_ACCESS_KEY_ID="$( + echo "${s3_secret_output}" | grep -o 'awsAccessKey=[^[:space:]]*' | head -1 | cut -d= -f2 +)" +POLARIS_AWS_SECRET_ACCESS_KEY="$( + echo "${s3_secret_output}" | grep -o 'awsSecret=[^[:space:]]*' | head -1 | cut -d= -f2 +)" + +if [[ -z "${POLARIS_AWS_ACCESS_KEY_ID}" || -z "${POLARIS_AWS_SECRET_ACCESS_KEY}" ]]; then + echo "ERROR: Failed to parse S3 credentials from ozone s3 getsecret output:" + echo "${s3_secret_output}" + exit 1 +fi + +export POLARIS_AWS_ACCESS_KEY_ID POLARIS_AWS_SECRET_ACCESS_KEY + +echo "Starting Polaris (${POLARIS_IMAGE})..." +docker-compose --ansi never up -d polaris + +wait_for_port polaris 8181 120 + +echo "Provisioning Polaris catalog (${POLARIS_CATALOG_NAME})..." +docker-compose --ansi never run --rm polaris-setup + +spark_packages="org.apache.iceberg:iceberg-spark-runtime-3.5_2.12:${POLARIS_ICEBERG_SPARK_RUNTIME_VERSION},org.apache.iceberg:iceberg-aws-bundle:${POLARIS_ICEBERG_SPARK_RUNTIME_VERSION}" +sql_file="/opt/hadoop/smoketest/security/ozone-secure-sts-polaris.sql" + +echo "Running Spark SQL against Polaris with STS vended credentials..." +set +e +spark_output="$( + docker-compose --ansi never run --rm spark-sql \ + /opt/spark/bin/spark-sql \ + --packages "${spark_packages}" \ + --conf spark.sql.extensions=org.apache.iceberg.spark.extensions.IcebergSparkSessionExtensions \ + --conf spark.sql.catalog.polaris=org.apache.iceberg.spark.SparkCatalog \ + --conf spark.sql.catalog.polaris.type=rest \ + --conf spark.sql.catalog.polaris.uri=http://polaris:8181/api/catalog \ + --conf spark.sql.catalog.polaris.rest.auth.type=oauth2 \ + --conf spark.sql.catalog.polaris.oauth2-server-uri=http://polaris:8181/api/catalog/v1/oauth/tokens \ + --conf spark.sql.catalog.polaris.token-refresh-enabled=false \ + --conf spark.sql.catalog.polaris.warehouse="${POLARIS_CATALOG_NAME}" \ + --conf spark.sql.catalog.polaris.scope=PRINCIPAL_ROLE:ALL \ + --conf spark.sql.catalog.polaris.credential=root:s3cr3t \ + --conf spark.sql.catalog.polaris.client.region=us-west-2 \ + --conf spark.sql.catalog.polaris.header.X-Iceberg-Access-Delegation=vended-credentials \ + -f "${sql_file}" 2>&1 +)" +spark_exit_code=$? +set -e + +echo "${spark_output}" + +if [[ "${spark_exit_code}" -ne 0 ]]; then + echo "ERROR: spark-sql exited with status ${spark_exit_code}" + exit "${spark_exit_code}" +fi + +if ! echo "${spark_output}" | grep -Fq "testing STS"; then + echo "ERROR: Expected Spark output to contain inserted row value 'testing STS'" + exit 1 +fi + +echo "Polaris STS smoke test passed." diff --git a/hadoop-ozone/dist/src/main/compose/ozonesecure-ha/polaris.yaml b/hadoop-ozone/dist/src/main/compose/ozonesecure-ha/polaris.yaml new file mode 100644 index 000000000000..cfd6d127c0c7 --- /dev/null +++ b/hadoop-ozone/dist/src/main/compose/ozonesecure-ha/polaris.yaml @@ -0,0 +1,77 @@ +# 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. + +# Apache Polaris + Spark SQL overlay for ozonesecure-ha STS smoketests. +# Requires POLARIS_AWS_ACCESS_KEY_ID / POLARIS_AWS_SECRET_ACCESS_KEY at runtime +# (fetched from ozone s3 getsecret by polaris-smoketest.sh). + +services: + polaris: + image: ${POLARIS_IMAGE} + hostname: polaris + dns_search: . + ports: + - 8181:8181 + environment: + AWS_REGION: us-west-2 + AWS_ACCESS_KEY_ID: ${POLARIS_AWS_ACCESS_KEY_ID} + AWS_SECRET_ACCESS_KEY: ${POLARIS_AWS_SECRET_ACCESS_KEY} + POLARIS_BOOTSTRAP_CREDENTIALS: POLARIS,root,s3cr3t + polaris.realm-context.realms: POLARIS + polaris.features."ALLOW_SETTING_S3_ENDPOINTS": "true" + quarkus.otel.sdk.disabled: "true" + healthcheck: + test: ["CMD", "curl", "--fail", "http://localhost:8182/q/health"] + interval: 2s + timeout: 10s + retries: 60 + start_period: 10s + networks: + ozone_net: + ipv4_address: 172.25.0.124 + + polaris-setup: + image: alpine/curl:8.19.0 + dns_search: . + depends_on: + polaris: + condition: service_healthy + environment: + CLIENT_ID: root + CLIENT_SECRET: s3cr3t + POLARIS_REALM: POLARIS + POLARIS_CATALOG_NAME: ${POLARIS_CATALOG_NAME:-quickstart_catalog} + POLARIS_STORAGE_LOCATION: ${POLARIS_STORAGE_LOCATION:-s3://iceberg-obs/polaris-smoke} + POLARIS_S3_ENDPOINT: http://s3g:9878 + POLARIS_STS_ENDPOINT: http://s3g:9880/sts + POLARIS_ROLE_ARN: arn:aws:iam::123456789012:role/iceberg-data-all-access-obs + POLARIS_AWS_ACCESS_KEY_ID: ${POLARIS_AWS_ACCESS_KEY_ID} + POLARIS_AWS_SECRET_ACCESS_KEY: ${POLARIS_AWS_SECRET_ACCESS_KEY} + volumes: + - ./polaris-setup.sh:/polaris-setup.sh:ro + entrypoint: ["/bin/sh", "/polaris-setup.sh"] + networks: + ozone_net: {} + + spark-sql: + image: ${SPARK_SQL_IMAGE} + user: "0:0" + dns_search: . + volumes: + - ../..:/opt/hadoop + - ${RANGER_M2_DIR:-${HOME}/.m2}:/root/.m2 + networks: + ozone_net: {} diff --git a/hadoop-ozone/dist/src/main/compose/ozonesecure-ha/test-ranger.sh b/hadoop-ozone/dist/src/main/compose/ozonesecure-ha/test-ranger.sh index 24cc21778f23..4940e782da59 100755 --- a/hadoop-ozone/dist/src/main/compose/ozonesecure-ha/test-ranger.sh +++ b/hadoop-ozone/dist/src/main/compose/ozonesecure-ha/test-ranger.sh @@ -115,3 +115,4 @@ execute_robot_test s3g freon/validate.robot execute_robot_test s3g -v RANGER_ENDPOINT_URL:"http://ranger:6080" -v USER:hdfs security/ozone-secure-tenant.robot execute_robot_test s3g -v RANGER_ENDPOINT_URL:"http://ranger:6080" -v USER:hdfs security/ozone-secure-sts.robot execute_robot_test s3g -v RANGER_ENDPOINT_URL:"http://ranger:6080" -v USER:hdfs security/ozone-secure-sts-multitenant.robot +"${COMPOSE_DIR}/polaris-smoketest.sh" diff --git a/hadoop-ozone/dist/src/main/smoketest/security/ozone-secure-sts-polaris.sql b/hadoop-ozone/dist/src/main/smoketest/security/ozone-secure-sts-polaris.sql new file mode 100644 index 000000000000..85ae802273be --- /dev/null +++ b/hadoop-ozone/dist/src/main/smoketest/security/ozone-secure-sts-polaris.sql @@ -0,0 +1,27 @@ +-- 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. + +USE polaris; + +CREATE NAMESPACE IF NOT EXISTS ozone_sts_smoke; + +DROP TABLE IF EXISTS ozone_sts_smoke.my_table; + +CREATE TABLE ozone_sts_smoke.my_table (id INT, name STRING); + +INSERT INTO ozone_sts_smoke.my_table VALUES (1, 'testing STS'); + +SELECT * FROM ozone_sts_smoke.my_table; From d5a76c5204e25cb8fd18a049672ef18311d54501 Mon Sep 17 00:00:00 2001 From: fmorg-git Date: Thu, 20 Aug 2026 17:16:15 -0700 Subject: [PATCH 48/54] HDDS-16240. [STS] Remove /sts from endpoint to enhance compatibility (#11073) Co-authored-by: Fabian Morgan --- hadoop-hdds/docs/content/design/ozone-sts.md | 2 +- hadoop-ozone/dist/src/main/compose/ozonesecure-ha/docker-config | 2 +- .../dist/src/main/compose/ozonesecure-ha/polaris-setup.sh | 2 +- hadoop-ozone/dist/src/main/compose/ozonesecure-ha/polaris.yaml | 2 +- .../dist/src/main/smoketest/security/ozone-secure-sts.resource | 2 +- .../main/java/org/apache/hadoop/ozone/s3sts/S3STSEndpoint.java | 2 +- .../src/main/resources/webapps/s3g-sts/WEB-INF/web.xml | 2 +- .../org/apache/hadoop/ozone/s3/TestAuthorizationFilter.java | 2 +- 8 files changed, 8 insertions(+), 8 deletions(-) diff --git a/hadoop-hdds/docs/content/design/ozone-sts.md b/hadoop-hdds/docs/content/design/ozone-sts.md index 6cc94eadd4bc..8c7d5cd3b44b 100644 --- a/hadoop-hdds/docs/content/design/ozone-sts.md +++ b/hadoop-hdds/docs/content/design/ozone-sts.md @@ -42,7 +42,7 @@ solutions that want to aggregate data across multiple cloud providers. # 3. How Ozone STS Works The initial implementation of Ozone STS supports only the [AssumeRole](https://docs.aws.amazon.com/STS/latest/APIReference/API_AssumeRole.html) -API from the AWS specification. A new STS endpoint `/sts` on port `9880` (port `9881` for https) will be created to service STS requests in the S3 Gateway. +API from the AWS specification. A new STS endpoint on port `9880` (port `9881` for https) will be created to service STS requests in the S3 Gateway at the root path (`/`). We use a separate port for STS to align with AWS so we don't have conflicts at a later time. This means we have: - Admin port for Ozone specific S3 admin operations - STS port for STS APIs, analogous to AWS' separate STS endpoint diff --git a/hadoop-ozone/dist/src/main/compose/ozonesecure-ha/docker-config b/hadoop-ozone/dist/src/main/compose/ozonesecure-ha/docker-config index a2f4208c01af..4dc737f56d59 100644 --- a/hadoop-ozone/dist/src/main/compose/ozonesecure-ha/docker-config +++ b/hadoop-ozone/dist/src/main/compose/ozonesecure-ha/docker-config @@ -104,7 +104,7 @@ OZONE-SITE.XML_ozone.security.http.kerberos.enabled=true OZONE-SITE.XML_ozone.s3g.secret.http.enabled=true OZONE-SITE.XML_ozone.http.filter.initializers=org.apache.hadoop.security.AuthenticationFilterInitializer -# Enable S3 Gateway STS (AWS STS compatible) endpoint on s3g (http://s3g:9880/sts) +# Enable S3 Gateway STS (AWS STS compatible) endpoint on s3g (http://s3g:9880) OZONE-SITE.XML_ozone.s3g.sts.http.enabled=true OZONE-SITE.XML_ozone.om.http.auth.type=kerberos diff --git a/hadoop-ozone/dist/src/main/compose/ozonesecure-ha/polaris-setup.sh b/hadoop-ozone/dist/src/main/compose/ozonesecure-ha/polaris-setup.sh index aab766f270e2..951d9576ce10 100755 --- a/hadoop-ozone/dist/src/main/compose/ozonesecure-ha/polaris-setup.sh +++ b/hadoop-ozone/dist/src/main/compose/ozonesecure-ha/polaris-setup.sh @@ -23,7 +23,7 @@ realm="${POLARIS_REALM:-POLARIS}" catalog_name="${POLARIS_CATALOG_NAME:-quickstart_catalog}" storage_location="${POLARIS_STORAGE_LOCATION:-s3://iceberg-obs/polaris-smoke}" s3_endpoint="${POLARIS_S3_ENDPOINT:-http://s3g:9878}" -sts_endpoint="${POLARIS_STS_ENDPOINT:-http://s3g:9880/sts}" +sts_endpoint="${POLARIS_STS_ENDPOINT:-http://s3g:9880}" role_arn="${POLARIS_ROLE_ARN:-arn:aws:iam::123456789012:role/iceberg-data-all-access-obs}" if [ -z "${POLARIS_AWS_ACCESS_KEY_ID:-}" ] || [ -z "${POLARIS_AWS_SECRET_ACCESS_KEY:-}" ]; then diff --git a/hadoop-ozone/dist/src/main/compose/ozonesecure-ha/polaris.yaml b/hadoop-ozone/dist/src/main/compose/ozonesecure-ha/polaris.yaml index cfd6d127c0c7..c22db2380258 100644 --- a/hadoop-ozone/dist/src/main/compose/ozonesecure-ha/polaris.yaml +++ b/hadoop-ozone/dist/src/main/compose/ozonesecure-ha/polaris.yaml @@ -56,7 +56,7 @@ services: POLARIS_CATALOG_NAME: ${POLARIS_CATALOG_NAME:-quickstart_catalog} POLARIS_STORAGE_LOCATION: ${POLARIS_STORAGE_LOCATION:-s3://iceberg-obs/polaris-smoke} POLARIS_S3_ENDPOINT: http://s3g:9878 - POLARIS_STS_ENDPOINT: http://s3g:9880/sts + POLARIS_STS_ENDPOINT: http://s3g:9880 POLARIS_ROLE_ARN: arn:aws:iam::123456789012:role/iceberg-data-all-access-obs POLARIS_AWS_ACCESS_KEY_ID: ${POLARIS_AWS_ACCESS_KEY_ID} POLARIS_AWS_SECRET_ACCESS_KEY: ${POLARIS_AWS_SECRET_ACCESS_KEY} diff --git a/hadoop-ozone/dist/src/main/smoketest/security/ozone-secure-sts.resource b/hadoop-ozone/dist/src/main/smoketest/security/ozone-secure-sts.resource index e5f19b5205e1..dd697b1598e2 100644 --- a/hadoop-ozone/dist/src/main/smoketest/security/ozone-secure-sts.resource +++ b/hadoop-ozone/dist/src/main/smoketest/security/ozone-secure-sts.resource @@ -24,7 +24,7 @@ Resource ../s3/commonawslib.robot *** Variables *** ${RANGER_ENDPOINT_URL} ${EMPTY} -${STS_ENDPOINT_URL} http://s3g:9880/sts +${STS_ENDPOINT_URL} http://s3g:9880 ${S3G_ENDPOINT_URL} http://s3g:9878 ${ROLE_SESSION_NAME} sts-session-name 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 365115a4aa93..d6ed5339a448 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 @@ -71,7 +71,7 @@ * AWS STS (Security Token Service) compatible endpoint for Ozone S3 Gateway. *

* This endpoint provides temporary security credentials compatible with - * AWS STS API, exposed on the port 9880 or 9881. + * AWS STS API, exposed on port 9880 or 9881 at the root path ({@code /}). *

* Currently supports only AssumeRole operation. Other STS operations will * return appropriate error responses. diff --git a/hadoop-ozone/s3gateway/src/main/resources/webapps/s3g-sts/WEB-INF/web.xml b/hadoop-ozone/s3gateway/src/main/resources/webapps/s3g-sts/WEB-INF/web.xml index d6dcf626dcce..eff9f149355c 100644 --- a/hadoop-ozone/s3gateway/src/main/resources/webapps/s3g-sts/WEB-INF/web.xml +++ b/hadoop-ozone/s3gateway/src/main/resources/webapps/s3g-sts/WEB-INF/web.xml @@ -25,7 +25,7 @@ sts-jaxrs - /sts/* + /* org.jboss.weld.environment.servlet.Listener diff --git a/hadoop-ozone/s3gateway/src/test/java/org/apache/hadoop/ozone/s3/TestAuthorizationFilter.java b/hadoop-ozone/s3gateway/src/test/java/org/apache/hadoop/ozone/s3/TestAuthorizationFilter.java index 5171138710e0..cc9dd813fb9a 100644 --- a/hadoop-ozone/s3gateway/src/test/java/org/apache/hadoop/ozone/s3/TestAuthorizationFilter.java +++ b/hadoop-ozone/s3gateway/src/test/java/org/apache/hadoop/ozone/s3/TestAuthorizationFilter.java @@ -137,7 +137,7 @@ public class TestAuthorizationFilter { "Content-SHA", DATETIME, "application/x-www-form-urlencoded; charset=utf-8", - "/sts", + "/", PAYLOAD_TOO_LARGE.getErrorMessage() ) ); From 5000f2835c5940a82b8135c9db16b82cc3a4f1ce Mon Sep 17 00:00:00 2001 From: fmorg-git Date: Sun, 23 Aug 2026 21:02:10 -0700 Subject: [PATCH 49/54] HDDS-16186. Move sts robot tests out of misc suite (#11042) --- .../compose/ozonesecure-ha/ranger-testlib.sh | 111 ++++++++++++++++++ .../main/compose/ozonesecure-ha/ranger.yaml | 2 +- .../compose/ozonesecure-ha/test-ranger.sh | 90 +------------- .../main/compose/ozonesecure-ha/test-sts.sh | 30 +++++ .../security/ozone-secure-sts.resource | 57 +++++++++ .../smoketest/security/ozone-secure-sts.robot | 43 +++++++ 6 files changed, 244 insertions(+), 89 deletions(-) create mode 100644 hadoop-ozone/dist/src/main/compose/ozonesecure-ha/ranger-testlib.sh create mode 100755 hadoop-ozone/dist/src/main/compose/ozonesecure-ha/test-sts.sh diff --git a/hadoop-ozone/dist/src/main/compose/ozonesecure-ha/ranger-testlib.sh b/hadoop-ozone/dist/src/main/compose/ozonesecure-ha/ranger-testlib.sh new file mode 100644 index 000000000000..1046a412b24e --- /dev/null +++ b/hadoop-ozone/dist/src/main/compose/ozonesecure-ha/ranger-testlib.sh @@ -0,0 +1,111 @@ +#!/usr/bin/env bash +# 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. + +_ranger_testlib_dir="$( cd "$( dirname "${BASH_SOURCE[0]}" )" >/dev/null 2>&1 && pwd )" +: "${COMPOSE_DIR:=$_ranger_testlib_dir}" +export COMPOSE_DIR + +# shellcheck source=/dev/null +source "$COMPOSE_DIR/../testlib.sh" + +setup_ranger_acceptance_env() { + # Load FF_ENABLE_OZONE_ACTION_MATCHES_CONDITION from .env without overriding other env. + # Ranger reads this value from install.properties (not process env), but we allow + # controlling the mounted install.properties via .env. + if [[ -z "${FF_ENABLE_OZONE_ACTION_MATCHES_CONDITION:-}" ]] && [[ -f "${COMPOSE_DIR}/.env" ]]; then + local ff_from_dotenv + ff_from_dotenv="$( + ( + set -a + # shellcheck source=/dev/null + source "${COMPOSE_DIR}/.env" || true + echo "${FF_ENABLE_OZONE_ACTION_MATCHES_CONDITION:-}" + ) 2>/dev/null + )" + if [[ -n "${ff_from_dotenv}" ]]; then + export FF_ENABLE_OZONE_ACTION_MATCHES_CONDITION="${ff_from_dotenv}" + fi + fi + + if [[ -z "${RANGER_VERSION:-}" ]]; then + export RANGER_VERSION="${ranger.version}" + fi + + : "${DOWNLOAD_DIR:=${TEMP_DIR:-/tmp}}" + + export COMPOSE_FILE=docker-compose.yaml:ranger.yaml:../common/ranger.yaml + export OM_SERVICE_ID="omservice" + export SCM=scm1.org + export SECURITY_ENABLED=true + + if [[ "${SKIP_APACHE_VERIFY_DOWNLOAD}" != "true" ]]; then + curl -LO https://downloads.apache.org/ranger/KEYS + gpg --import KEYS + fi + + download_and_verify_apache_release "ranger/${RANGER_VERSION}/apache-ranger-${RANGER_VERSION}.tar.gz" + tar -C "${DOWNLOAD_DIR}" -x -z -f "${DOWNLOAD_DIR}/apache-ranger-${RANGER_VERSION}.tar.gz" + export RANGER_SOURCE_DIR="${DOWNLOAD_DIR}/apache-ranger-${RANGER_VERSION}" + chmod -R a+rX "${RANGER_SOURCE_DIR}" + export RANGER_INIT_POSTGRES_SH="${RANGER_SOURCE_DIR}/dev-support/ranger-docker/scripts/rdbms/init_postgres.sh" + + # Create a temp install.properties so we can override feature flags from .env. + local ranger_admin_install_properties_src + ranger_admin_install_properties_src="${RANGER_SOURCE_DIR}/dev-support/ranger-docker/scripts/admin/ranger-admin-install-postgres.properties" + RANGER_ADMIN_INSTALL_PROPERTIES="$(mktemp "${DOWNLOAD_DIR%/}/ranger-admin-install-postgres.XXXXXX")" + cp -f "${ranger_admin_install_properties_src}" "${RANGER_ADMIN_INSTALL_PROPERTIES}" + chmod a+r "${RANGER_ADMIN_INSTALL_PROPERTIES}" + + local ff + ff="$(echo "${FF_ENABLE_OZONE_ACTION_MATCHES_CONDITION:-false}" | tr '[:upper:]' '[:lower:]')" + if [[ "${ff}" != "true" ]]; then + ff="false" + fi + if grep -Eq '^[[:space:]]*#?[[:space:]]*FF_ENABLE_OZONE_ACTION_MATCHES_CONDITION=' "${RANGER_ADMIN_INSTALL_PROPERTIES}"; then + perl -pi -e "s@^[[:space:]]*#?[[:space:]]*FF_ENABLE_OZONE_ACTION_MATCHES_CONDITION=.*@FF_ENABLE_OZONE_ACTION_MATCHES_CONDITION=${ff}@g" \ + "${RANGER_ADMIN_INSTALL_PROPERTIES}" + else + printf '\nFF_ENABLE_OZONE_ACTION_MATCHES_CONDITION=%s\n' "${ff}" >> "${RANGER_ADMIN_INSTALL_PROPERTIES}" + fi + + export RANGER_ADMIN_INSTALL_PROPERTIES + + # Ranger docker support scripts moved between releases (eg: from config/*.sh to scripts/**). + # Ensure we don't fail if a glob doesn't match, but still make init scripts executable when present. + if [[ -d "${RANGER_SOURCE_DIR}/dev-support/ranger-docker" ]]; then + find "${RANGER_SOURCE_DIR}/dev-support/ranger-docker" -type f -name '*.sh' -exec chmod a+x {} + + fi + download_and_verify_apache_release "ranger/${RANGER_VERSION}/plugins/ozone/ranger-${RANGER_VERSION}-ozone-plugin.tar.gz" + tar -C "${DOWNLOAD_DIR}" -x -z -f "${DOWNLOAD_DIR}/ranger-${RANGER_VERSION}-ozone-plugin.tar.gz" + export RANGER_OZONE_PLUGIN_DIR="${DOWNLOAD_DIR}/ranger-${RANGER_VERSION}-ozone-plugin" + chmod -R a+rX "${RANGER_OZONE_PLUGIN_DIR}" + chmod a+x "${RANGER_OZONE_PLUGIN_DIR}"/*.sh + + # customizations before install + perl -wpl -i \ + -e 's@^POLICY_MGR_URL=.*@POLICY_MGR_URL=http://ranger:6080@;' \ + -e 's@^REPOSITORY_NAME=.*@REPOSITORY_NAME=dev_ozone@;' \ + -e 's@^CUSTOM_USER=ozone@CUSTOM_USER=hadoop@;' \ + -e 's@^XAAUDIT.LOG4J.ENABLE=true@XAAUDIT.LOG4J.ENABLE=false@;' \ + -e 's@^XAAUDIT.LOG4J.DESTINATION.LOG4J=true@XAAUDIT.LOG4J.DESTINATION.LOG4J=false@;' \ + "${RANGER_OZONE_PLUGIN_DIR}/install.properties" + + echo 'machine ranger login admin password rangerR0cks!' > "${COMPOSE_DIR}/../../.netrc" + + start_docker_env + wait_for_port ranger 6080 120 +} diff --git a/hadoop-ozone/dist/src/main/compose/ozonesecure-ha/ranger.yaml b/hadoop-ozone/dist/src/main/compose/ozonesecure-ha/ranger.yaml index 579dbfc0cc12..2dad84dbb5f4 100644 --- a/hadoop-ozone/dist/src/main/compose/ozonesecure-ha/ranger.yaml +++ b/hadoop-ozone/dist/src/main/compose/ozonesecure-ha/ranger.yaml @@ -42,7 +42,7 @@ x-om-ranger-config: # JDK17+ no longer ships javax.annotation, but the Ranger Ozone plugin's Jersey2 # client needs javax.annotation.Priority. Copy the jar into the plugin impl dir # so the isolated RangerPluginClassLoader can find it. - command: bash -c 'sudo mkdir -p /opt/ranger/ozone-plugin/lib/libext/ranger-ozone-plugin-impl && for j in /opt/hadoop/share/ozone/lib/javax.annotation-api-*.jar; do [ -e "$$j" ] || continue; sudo cp -n "$$j" /opt/ranger/ozone-plugin/lib/libext/ranger-ozone-plugin-impl/; done && sudo --preserve-env /opt/ranger/ozone-plugin/enable-ozone-plugin.sh && exec /opt/hadoop/bin/ozone om' + command: bash -c 'sudo mkdir -p /opt/ranger/ozone-plugin/lib/libext/ranger-ozone-plugin-impl && for j in /opt/hadoop/share/ozone/lib/javax.annotation-api-*.jar; do [ -e "$$j" ] || continue; dest="/opt/ranger/ozone-plugin/lib/libext/ranger-ozone-plugin-impl/$$(basename "$$j")"; sudo test -e "$$dest" || sudo cp -n "$$j" "$$dest" || sudo test -e "$$dest"; done && sudo --preserve-env /opt/ranger/ozone-plugin/enable-ozone-plugin.sh && exec /opt/hadoop/bin/ozone om' services: om1: diff --git a/hadoop-ozone/dist/src/main/compose/ozonesecure-ha/test-ranger.sh b/hadoop-ozone/dist/src/main/compose/ozonesecure-ha/test-ranger.sh index 4940e782da59..7d6950ccaa70 100755 --- a/hadoop-ozone/dist/src/main/compose/ozonesecure-ha/test-ranger.sh +++ b/hadoop-ozone/dist/src/main/compose/ozonesecure-ha/test-ranger.sh @@ -20,99 +20,13 @@ COMPOSE_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" >/dev/null 2>&1 && pwd )" export COMPOSE_DIR -# Load FF_ENABLE_OZONE_ACTION_MATCHES_CONDITION from .env without overriding other env. -# Ranger reads this value from install.properties (not process env), but we allow -# controlling the mounted install.properties via .env. -if [[ -z "${FF_ENABLE_OZONE_ACTION_MATCHES_CONDITION:-}" ]] && [[ -f "${COMPOSE_DIR}/.env" ]]; then - _ff_from_dotenv="$( - ( - set -a - # shellcheck source=/dev/null - source "${COMPOSE_DIR}/.env" - echo "${FF_ENABLE_OZONE_ACTION_MATCHES_CONDITION:-}" - ) 2>/dev/null - )" - if [[ -n "${_ff_from_dotenv}" ]]; then - export FF_ENABLE_OZONE_ACTION_MATCHES_CONDITION="${_ff_from_dotenv}" - fi - unset _ff_from_dotenv -fi - -if [[ -z "${RANGER_VERSION:-}" ]]; then - export RANGER_VERSION="${ranger.version}" -fi - -: "${DOWNLOAD_DIR:=${TEMP_DIR:-/tmp}}" - # shellcheck source=/dev/null -source "$COMPOSE_DIR/../testlib.sh" - -export COMPOSE_FILE=docker-compose.yaml:ranger.yaml:../common/ranger.yaml -export OM_SERVICE_ID="omservice" -export SCM=scm1.org -export SECURITY_ENABLED=true - -if [[ "${SKIP_APACHE_VERIFY_DOWNLOAD}" != "true" ]]; then - curl -LO https://downloads.apache.org/ranger/KEYS - gpg --import KEYS -fi - -download_and_verify_apache_release "ranger/${RANGER_VERSION}/apache-ranger-${RANGER_VERSION}.tar.gz" -tar -C "${DOWNLOAD_DIR}" -x -z -f "${DOWNLOAD_DIR}/apache-ranger-${RANGER_VERSION}.tar.gz" -export RANGER_SOURCE_DIR="${DOWNLOAD_DIR}/apache-ranger-${RANGER_VERSION}" -chmod -R a+rX "${RANGER_SOURCE_DIR}" -export RANGER_INIT_POSTGRES_SH="${RANGER_SOURCE_DIR}/dev-support/ranger-docker/scripts/rdbms/init_postgres.sh" - -# Create a temp install.properties so we can override feature flags from .env. -RANGER_ADMIN_INSTALL_PROPERTIES_SRC="${RANGER_SOURCE_DIR}/dev-support/ranger-docker/scripts/admin/ranger-admin-install-postgres.properties" -RANGER_ADMIN_INSTALL_PROPERTIES="$(mktemp "${DOWNLOAD_DIR%/}/ranger-admin-install-postgres.XXXXXX")" -cp -f "${RANGER_ADMIN_INSTALL_PROPERTIES_SRC}" "${RANGER_ADMIN_INSTALL_PROPERTIES}" -chmod a+r "${RANGER_ADMIN_INSTALL_PROPERTIES}" - -_ff="$(echo "${FF_ENABLE_OZONE_ACTION_MATCHES_CONDITION:-false}" | tr '[:upper:]' '[:lower:]')" -if [[ "${_ff}" != "true" ]]; then - _ff="false" -fi -if grep -Eq '^[[:space:]]*#?[[:space:]]*FF_ENABLE_OZONE_ACTION_MATCHES_CONDITION=' "${RANGER_ADMIN_INSTALL_PROPERTIES}"; then - perl -pi -e "s@^[[:space:]]*#?[[:space:]]*FF_ENABLE_OZONE_ACTION_MATCHES_CONDITION=.*@FF_ENABLE_OZONE_ACTION_MATCHES_CONDITION=${_ff}@g" \ - "${RANGER_ADMIN_INSTALL_PROPERTIES}" -else - printf '\nFF_ENABLE_OZONE_ACTION_MATCHES_CONDITION=%s\n' "${_ff}" >> "${RANGER_ADMIN_INSTALL_PROPERTIES}" -fi -unset _ff - -export RANGER_ADMIN_INSTALL_PROPERTIES - -# Ranger docker support scripts moved between releases (eg: from config/*.sh to scripts/**). -# Ensure we don't fail if a glob doesn't match, but still make init scripts executable when present. -if [[ -d "${RANGER_SOURCE_DIR}/dev-support/ranger-docker" ]]; then - find "${RANGER_SOURCE_DIR}/dev-support/ranger-docker" -type f -name '*.sh' -exec chmod a+x {} + -fi -download_and_verify_apache_release "ranger/${RANGER_VERSION}/plugins/ozone/ranger-${RANGER_VERSION}-ozone-plugin.tar.gz" -tar -C "${DOWNLOAD_DIR}" -x -z -f "${DOWNLOAD_DIR}/ranger-${RANGER_VERSION}-ozone-plugin.tar.gz" -export RANGER_OZONE_PLUGIN_DIR="${DOWNLOAD_DIR}/ranger-${RANGER_VERSION}-ozone-plugin" -chmod -R a+rX "${RANGER_OZONE_PLUGIN_DIR}" -chmod a+x "${RANGER_OZONE_PLUGIN_DIR}"/*.sh - -# customizations before install -perl -wpl -i \ - -e 's@^POLICY_MGR_URL=.*@POLICY_MGR_URL=http://ranger:6080@;' \ - -e 's@^REPOSITORY_NAME=.*@REPOSITORY_NAME=dev_ozone@;' \ - -e 's@^CUSTOM_USER=ozone@CUSTOM_USER=hadoop@;' \ - -e 's@^XAAUDIT.LOG4J.ENABLE=true@XAAUDIT.LOG4J.ENABLE=false@;' \ - -e 's@^XAAUDIT.LOG4J.DESTINATION.LOG4J=true@XAAUDIT.LOG4J.DESTINATION.LOG4J=false@;' \ - "${RANGER_OZONE_PLUGIN_DIR}/install.properties" - -echo 'machine ranger login admin password rangerR0cks!' > ../../.netrc +source "$COMPOSE_DIR/ranger-testlib.sh" -start_docker_env -wait_for_port ranger 6080 120 +setup_ranger_acceptance_env execute_robot_test s3g -v USER:hdfs kinit.robot execute_robot_test s3g freon/generate.robot execute_robot_test s3g freon/validate.robot execute_robot_test s3g -v RANGER_ENDPOINT_URL:"http://ranger:6080" -v USER:hdfs security/ozone-secure-tenant.robot -execute_robot_test s3g -v RANGER_ENDPOINT_URL:"http://ranger:6080" -v USER:hdfs security/ozone-secure-sts.robot -execute_robot_test s3g -v RANGER_ENDPOINT_URL:"http://ranger:6080" -v USER:hdfs security/ozone-secure-sts-multitenant.robot -"${COMPOSE_DIR}/polaris-smoketest.sh" diff --git a/hadoop-ozone/dist/src/main/compose/ozonesecure-ha/test-sts.sh b/hadoop-ozone/dist/src/main/compose/ozonesecure-ha/test-sts.sh new file mode 100755 index 000000000000..879dd1b58519 --- /dev/null +++ b/hadoop-ozone/dist/src/main/compose/ozonesecure-ha/test-sts.sh @@ -0,0 +1,30 @@ +#!/usr/bin/env bash +# 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. + +#suite:sts + +COMPOSE_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" >/dev/null 2>&1 && pwd )" +export COMPOSE_DIR + +# shellcheck source=/dev/null +source "$COMPOSE_DIR/ranger-testlib.sh" + +setup_ranger_acceptance_env + +execute_robot_test s3g -v RANGER_ENDPOINT_URL:"http://ranger:6080" -v USER:hdfs security/ozone-secure-sts.robot +execute_robot_test s3g -v RANGER_ENDPOINT_URL:"http://ranger:6080" -v USER:hdfs security/ozone-secure-sts-multitenant.robot +"${COMPOSE_DIR}/polaris-smoketest.sh" diff --git a/hadoop-ozone/dist/src/main/smoketest/security/ozone-secure-sts.resource b/hadoop-ozone/dist/src/main/smoketest/security/ozone-secure-sts.resource index dd697b1598e2..19cb6f4e2022 100644 --- a/hadoop-ozone/dist/src/main/smoketest/security/ozone-secure-sts.resource +++ b/hadoop-ozone/dist/src/main/smoketest/security/ozone-secure-sts.resource @@ -27,6 +27,11 @@ ${RANGER_ENDPOINT_URL} ${EMPTY} ${STS_ENDPOINT_URL} http://s3g:9880 ${S3G_ENDPOINT_URL} http://s3g:9878 ${ROLE_SESSION_NAME} sts-session-name +${EXPIRED_STS_TOKEN_PROFILE} expired_sts_token +${EXPIRED_STS_TOKEN_ACCESS_KEY_ID} ${EMPTY} +${EXPIRED_STS_TOKEN_SECRET_ACCESS_KEY} ${EMPTY} +${EXPIRED_STS_TOKEN_SESSION_TOKEN} ${EMPTY} +${EXPIRED_STS_TOKEN_EXPIRATION} ${EMPTY} *** Keywords *** Configure AWS Profile @@ -115,6 +120,58 @@ Assume Role And Get Temporary Credentials Should Be True ${time_diff} >= ${minimum_expected} Expected expiration to be at least ${minimum_expected}s in the future, but was ${time_diff}s Should Be True ${time_diff} <= ${maximum_expected} Expected expiration to be at most ${maximum_expected}s in the future, but was ${time_diff}s +Assume Role And Store Expired STS Token Credentials + # Issue a 900s STS credential and store it in EXPIRED_STS_TOKEN_* globals only (it should never be used/modified until the "Expired STS temporary credentials return ExpiredToken on S3 APIs" test). + [Arguments] ${perm_access_key_id} ${perm_secret_key} ${role_arn} ${duration_seconds}=900 + Configure AWS Profile permanent ${perm_access_key_id} ${perm_secret_key} + ${suffix} = Generate Random String 8 [LOWER] + ${role_session_name} = Set Variable expired-sts-token-${suffix} + + ${cmd} = Set Variable aws sts assume-role --endpoint-url ${STS_ENDPOINT_URL} --role-arn ${role_arn} --role-session-name ${role_session_name} --duration-seconds ${duration_seconds} --output json --profile permanent + + ${json} = Execute ${cmd} + Should Contain ${json} Credentials + + ${expiredStsAccessKeyId} = Execute printf '%s' '${json}' | jq -r '.Credentials.AccessKeyId' + ${expiredStsSecretKey} = Execute printf '%s' '${json}' | jq -r '.Credentials.SecretAccessKey' + ${expiredStsSessionToken} = Execute printf '%s' '${json}' | jq -r '.Credentials.SessionToken' + ${expiration} = Execute printf '%s' '${json}' | jq -r '.Credentials.Expiration' + Should Start With ${expiredStsAccessKeyId} ASIA + Set Global Variable ${EXPIRED_STS_TOKEN_ACCESS_KEY_ID} ${expiredStsAccessKeyId} + Set Global Variable ${EXPIRED_STS_TOKEN_SECRET_ACCESS_KEY} ${expiredStsSecretKey} + Set Global Variable ${EXPIRED_STS_TOKEN_SESSION_TOKEN} ${expiredStsSessionToken} + Set Global Variable ${EXPIRED_STS_TOKEN_EXPIRATION} ${expiration} + +Expired STS Token Credentials Should Be Available + Skip If '${EXPIRED_STS_TOKEN_EXPIRATION}' == '${EMPTY}' Expired STS token credentials are unavailable. Run the prerequisite AssumeRole test before expired-token checks. + Should Not Be Empty ${EXPIRED_STS_TOKEN_ACCESS_KEY_ID} Expired STS token access key ID is unavailable. + Should Not Be Empty ${EXPIRED_STS_TOKEN_SECRET_ACCESS_KEY} Expired STS token secret key is unavailable. + Should Not Be Empty ${EXPIRED_STS_TOKEN_SESSION_TOKEN} Expired STS token session token is unavailable. + +Wait Until Expired STS Token Expiration Elapsed + # Matches the +/-2s skew tolerance used for AssumeRole duration checks. + Expired STS Token Credentials Should Be Available + ${now} = Get Current Date time_zone=UTC + ${secondsUntilExpiration} = Subtract Date From Date ${EXPIRED_STS_TOKEN_EXPIRATION} ${now} + ${remaining} = Evaluate ${secondsUntilExpiration} + 2 + Run Keyword If ${remaining} > 0 Sleep ${remaining} + +Configure Expired STS Token S3 Profile + Expired STS Token Credentials Should Be Available + Configure AWS Profile ${EXPIRED_STS_TOKEN_PROFILE} ${EXPIRED_STS_TOKEN_ACCESS_KEY_ID} ${EXPIRED_STS_TOKEN_SECRET_ACCESS_KEY} ${EXPIRED_STS_TOKEN_SESSION_TOKEN} + +Execute S3api Expect Expired Token + [Arguments] ${command_tail} + ${output} = Execute And Ignore Error aws s3api --endpoint-url ${S3G_ENDPOINT_URL} ${command_tail} --profile ${EXPIRED_STS_TOKEN_PROFILE} + Should Contain ${output} ExpiredToken + +Execute S3api Expect Expired Token Head Operation + [Arguments] ${command_tail} ${expected_aws_operation_name} + [Documentation] head-bucket and head-object omit S3 XML error bodies so we can't check for ExpiredToken in the body. awscli surfaces HTTP 400 and the operation label (HeadBucket / HeadObject) instead of ExpiredToken. Assert status and operation together to avoid matching unrelated 400s. + ${output} = Execute And Ignore Error aws s3api --endpoint-url ${S3G_ENDPOINT_URL} ${command_tail} --profile ${EXPIRED_STS_TOKEN_PROFILE} + Should Contain ${output} (400) + Should Contain ${output} ${expected_aws_operation_name} + Assume Role And Configure STS Profile [Arguments] ${perm_access_key_id} ${perm_secret_key} ${policy_json}=${EMPTY} ${role_arn}=${ROLE_ARN_OBS} ${role_session_name}=${ROLE_SESSION_NAME} ${duration_seconds}=900 Assume Role And Get Temporary Credentials perm_access_key_id=${perm_access_key_id} perm_secret_key=${perm_secret_key} policy_json=${policy_json} role_arn=${role_arn} role_session_name=${role_session_name} duration_seconds=${duration_seconds} diff --git a/hadoop-ozone/dist/src/main/smoketest/security/ozone-secure-sts.robot b/hadoop-ozone/dist/src/main/smoketest/security/ozone-secure-sts.robot index a1f34e818b76..c0362d747048 100644 --- a/hadoop-ozone/dist/src/main/smoketest/security/ozone-secure-sts.robot +++ b/hadoop-ozone/dist/src/main/smoketest/security/ozone-secure-sts.robot @@ -408,6 +408,9 @@ Get S3 Credentials for Service Catalog Principal, Create Iceberg Buckets, and Up # Switch back to the service catalog principal for running S3/STS requests. Kinit test user ${ICEBERG_SVC_CATALOG_USER} ${ICEBERG_SVC_CATALOG_USER}.keytab + # Long-lived 15-minute STS credential to test STS token expiration and proper error message + Assume Role And Store Expired STS Token Credentials perm_access_key_id=${PERMANENT_ACCESS_KEY_ID} perm_secret_key=${PERMANENT_SECRET_KEY} role_arn=${ICEBERG_ALL_ACCESS_ROLE_OBS_ARN} + Assume Role for Limited-Scope Token # All access role is limited to read-only via session policy FOR ${bucket} ${role_arn} IN @@ -1237,6 +1240,46 @@ STS session policy containing only GetObject must deny DeleteObjects ${output} = Execute aws s3api --endpoint-url ${S3G_ENDPOINT_URL} delete-bucket --bucket ${bucket} --profile sts Should Not Contain ${output} AccessDenied +Expired STS temporary credentials must return ExpiredToken on S3 APIs + # Increase timeout to account for 15 minute STS token expiration plus the time to execute the api calls + [Timeout] 25 minutes + ${dummy_mpu_upload_id} = Set Variable dummyExpiredStsMpuUploadId01 + + Wait Until Expired STS Token Expiration Elapsed + Configure Expired STS Token S3 Profile + + Execute S3api Expect Expired Token list-buckets --output json + Execute S3api Expect Expired Token Head Operation head-bucket --bucket ${ICEBERG_BUCKET_OBS} HeadBucket + Execute S3api Expect Expired Token list-objects-v2 --bucket ${ICEBERG_BUCKET_OBS} --output json + Execute S3api Expect Expired Token list-objects --bucket ${ICEBERG_BUCKET_OBS} --output json + Execute S3api Expect Expired Token get-object --bucket ${ICEBERG_BUCKET_OBS} --key ${ICEBERG_BUCKET_TESTFILE} ${TEMP_DIR}/expired-sts-token-get-object.out + + Create File ${TEMP_DIR}/expired-sts-token-put-object-body.txt expired sts token put body + + Execute S3api Expect Expired Token put-object --bucket ${ICEBERG_BUCKET_OBS} --key sts-expired-sts-token-put.txt --body ${TEMP_DIR}/expired-sts-token-put-object-body.txt + Execute S3api Expect Expired Token Head Operation head-object --bucket ${ICEBERG_BUCKET_OBS} --key ${ICEBERG_BUCKET_TESTFILE} HeadObject + Execute S3api Expect Expired Token delete-object --bucket ${ICEBERG_BUCKET_OBS} --key sts-expired-sts-token-delete-marker.txt + Execute S3api Expect Expired Token delete-objects --bucket ${ICEBERG_BUCKET_OBS} --delete 'Objects=[{Key=sts-expired-sts-token-delete-objects-marker.txt}],Quiet=false' + + ${bucket_suffix} = Generate Random String 8 [LOWER] + ${exp_bucket} = Set Variable sts-bucket-expired-sts-token-${bucket_suffix} + + Execute S3api Expect Expired Token create-bucket --bucket ${exp_bucket} + Execute S3api Expect Expired Token delete-bucket --bucket ${exp_bucket} + Execute S3api Expect Expired Token get-bucket-acl --bucket ${ICEBERG_BUCKET_OBS} + Execute S3api Expect Expired Token put-bucket-acl --bucket ${ICEBERG_BUCKET_OBS} --grant-read '' + Execute S3api Expect Expired Token list-multipart-uploads --bucket ${ICEBERG_BUCKET_OBS} + Execute S3api Expect Expired Token create-multipart-upload --bucket ${ICEBERG_BUCKET_OBS} --key sts-expired-sts-token-mpu.txt + Execute S3api Expect Expired Token upload-part --bucket ${ICEBERG_BUCKET_OBS} --key sts-expired-sts-token-mpu.txt --part-number 1 --body ${TEMP_DIR}/expired-sts-token-put-object-body.txt --upload-id ${dummy_mpu_upload_id} + Execute S3api Expect Expired Token upload-part-copy --bucket ${ICEBERG_BUCKET_OBS} --key sts-expired-sts-token-mpu-copy.txt --part-number 1 --upload-id ${dummy_mpu_upload_id} --copy-source ${ICEBERG_BUCKET_OBS}/${ICEBERG_BUCKET_TESTFILE} + Execute S3api Expect Expired Token list-parts --bucket ${ICEBERG_BUCKET_OBS} --key sts-expired-sts-token-mpu.txt --upload-id ${dummy_mpu_upload_id} + Execute S3api Expect Expired Token abort-multipart-upload --bucket ${ICEBERG_BUCKET_OBS} --key sts-expired-sts-token-mpu.txt --upload-id ${dummy_mpu_upload_id} + Execute S3api Expect Expired Token complete-multipart-upload --bucket ${ICEBERG_BUCKET_OBS} --key sts-expired-sts-token-mpu.txt --upload-id ${dummy_mpu_upload_id} --multipart-upload '{"Parts":[{"ETag":"d41d8cd98f00b204e9800998ecf8427e","PartNumber":1}]}' + Execute S3api Expect Expired Token copy-object --bucket ${ICEBERG_BUCKET_OBS} --copy-source ${ICEBERG_BUCKET_OBS}/${ICEBERG_BUCKET_TESTFILE} --key sts-expired-sts-token-copy-dest.txt + Execute S3api Expect Expired Token get-object-tagging --bucket ${ICEBERG_BUCKET_OBS} --key ${ICEBERG_BUCKET_TESTFILE} + Execute S3api Expect Expired Token put-object-tagging --bucket ${ICEBERG_BUCKET_OBS} --key ${ICEBERG_BUCKET_TESTFILE} --tagging '{"TagSet":[{"Key":"tag-key-expired-sts-token","Value":"tag-value-expired-sts-token"}]}' + Execute S3api Expect Expired Token delete-object-tagging --bucket ${ICEBERG_BUCKET_OBS} --key ${ICEBERG_BUCKET_TESTFILE} + Revoking Permanent User Must Revoke Existing Session Token # Create session tokens for both buckets, verify they work, then revoke permanent user secret and verify both fail. Assume Role And Get Temporary Credentials perm_access_key_id=${PERMANENT_ACCESS_KEY_ID} perm_secret_key=${PERMANENT_SECRET_KEY} role_arn=${ICEBERG_ALL_ACCESS_ROLE_OBS_ARN} From dc169f6e87cf23f848988d30cafb804ec4342f3a Mon Sep 17 00:00:00 2001 From: fmorg-git Date: Wed, 26 Aug 2026 06:17:09 -0700 Subject: [PATCH 50/54] HDDS-16110. [STS] Update key in sts revocation table (#11095) --- .../org/apache/hadoop/ozone/OzoneConsts.java | 1 + .../src/main/resources/ozone-default.xml | 7 +- hadoop-hdds/docs/content/design/ozone-sts.md | 20 +- .../ozone/shell/s3/RevokeSTSTokenHandler.java | 22 +- .../hadoop/ozone/client/ObjectStore.java | 8 +- .../ozone/client/protocol/ClientProtocol.java | 6 +- .../hadoop/ozone/client/rpc/RpcClient.java | 4 +- .../hadoop/ozone/om/helpers/S3STSUtils.java | 6 + .../om/protocol/OzoneManagerProtocol.java | 6 +- ...ManagerProtocolClientSideTranslatorPB.java | 4 +- .../smoketest/security/ozone-secure-sts.robot | 38 +- .../src/main/proto/OmClientProtocol.proto | 8 +- .../ozone/om/OmMetadataManagerImpl.java | 2 +- .../hadoop/ozone/om/codec/OMDBDefinition.java | 7 +- .../om/ratis/OzoneManagerStateMachine.java | 24 +- .../ozone/om/request/OMClientRequest.java | 4 +- .../s3/security/S3AssumeRoleRequest.java | 19 +- .../S3DeleteRevokedSTSTokensRequest.java | 5 +- .../s3/security/S3RevokeSTSTokenRequest.java | 111 +++-- .../S3DeleteRevokedSTSTokensResponse.java | 12 +- .../s3/security/S3RevokeSTSTokenResponse.java | 19 +- .../RevokedSTSTokenCleanupService.java | 50 +- .../hadoop/ozone/security/S3SecurityUtil.java | 15 +- .../ozone/security/STSSecurityUtil.java | 13 +- .../ozone/security/STSTokenIdentifier.java | 246 ++++++++-- .../ozone/security/STSTokenSecretManager.java | 33 +- .../ozone/om/TestOmMetadataManager.java | 28 +- .../security/TestS3RevokeSTSTokenRequest.java | 299 ++++++------ .../TestRevokedSTSTokenCleanupService.java | 65 +-- .../ozone/security/TestS3SecurityUtil.java | 77 +++- .../ozone/security/TestSTSSecurityUtil.java | 58 ++- .../security/TestSTSTokenEncryption.java | 31 +- .../security/TestSTSTokenIdentifier.java | 433 ++++++++++++------ .../security/TestSTSTokenSecretManager.java | 83 +++- .../ozone/client/ClientProtocolStub.java | 2 +- 35 files changed, 1194 insertions(+), 572 deletions(-) diff --git a/hadoop-hdds/common/src/main/java/org/apache/hadoop/ozone/OzoneConsts.java b/hadoop-hdds/common/src/main/java/org/apache/hadoop/ozone/OzoneConsts.java index e7e78b812063..188bd65559ff 100644 --- a/hadoop-hdds/common/src/main/java/org/apache/hadoop/ozone/OzoneConsts.java +++ b/hadoop-hdds/common/src/main/java/org/apache/hadoop/ozone/OzoneConsts.java @@ -314,6 +314,7 @@ public final class OzoneConsts { public static final String S3_SETSECRET_USER = "S3SetSecretUser"; public static final String S3_REVOKESECRET_USER = "S3RevokeSecretUser"; public static final String S3_REVOKESTSTOKEN_USER = "S3RevokeSTSTokenUser"; + public static final String S3_STS_TEMP_ACCESS_KEY_ID = "tempAccessKeyId"; public static final String RENAMED_KEYS_MAP = "renamedKeysMap"; public static final String UNRENAMED_KEYS_MAP = "unRenamedKeysMap"; public static final String MULTIPART_UPLOAD_PART_NUMBER = "partNumber"; diff --git a/hadoop-hdds/common/src/main/resources/ozone-default.xml b/hadoop-hdds/common/src/main/resources/ozone-default.xml index c72c351402e3..db79aa505f01 100644 --- a/hadoop-hdds/common/src/main/resources/ozone-default.xml +++ b/hadoop-hdds/common/src/main/resources/ozone-default.xml @@ -5254,9 +5254,10 @@ 3h OZONE, OM, PERFORMANCE, SECURITY - A background job that periodically checks revoked STS token entries and - deletes ones that have existed for 12 hours. This entry controls the interval of this - cleanup check. Unit could be defined with postfix (ns,ms,s,m,h,d). + A background service that periodically scans the s3RevokedStsTokenTable and deletes + revocation entries whose cutoff is older than the maximum STS token lifetime (12 hours). + This property controls how often the cleanup service runs. Unit could be defined with + postfix (ns,ms,s,m,h,d). diff --git a/hadoop-hdds/docs/content/design/ozone-sts.md b/hadoop-hdds/docs/content/design/ozone-sts.md index 8c7d5cd3b44b..3acbafdcf9a1 100644 --- a/hadoop-hdds/docs/content/design/ozone-sts.md +++ b/hadoop-hdds/docs/content/design/ozone-sts.md @@ -139,17 +139,24 @@ was included with the AssumeRole request, the String return value will also incl would further limit the scope of the permissions, resources and actions granted by the role in Ranger, such that the temporary credential will have the permissions and actions comprising the intersection of the role permissions and actions and the sessionPolicy permissions and actions. - HMAC-SHA256 signature - used to ensure the sessionToken was created by Ozone and was not altered since it was created. +- creation time of the token (via `OMTokenProto#issueDate`, exposed as `STSTokenIdentifier#getCreationTime()`) - expiration time of the token (via `ShortLivedTokenIdentifier#getExpiry()`) - UUID of the OzoneManager secret key used to sign the sessionToken and encrypt the secretAccessKey (via `ShortLivedTokenIdentifier#getSecretKeyId()`) ## 3.5 STS Token Revocation In the rare event temporary credentials need to be revoked (ex. for security reasons), a table in the OzoneManager RocksDB will be created -to store revoked tokens, and a command-line utility will be created to add tokens to the table. A background cleaner service -will be created to run every 3 hours to delete revoked tokens that have been in the table for more than 12 hours. The -input parameter for the command-line utility will be the sessionToken - this value is returned in plain text as a result -of the AssumeRole call (mentioned above). In this way, specific STS tokens can be revoked as opposed to all tokens. Furthermore, -AWS doesn't have a standard API to revoke tokens therefore we are creating our own system. +to store revocation cutoffs per originalAccessKeyId, and a command-line utility will be created to add entries to the table. +A background cleaner service will be created to run every 3 hours to delete revocation entries whose cutoff is more than 12 hours old. + +The command-line utility accepts only `originalAccessKeyId`. The OM stores revocations by keying the table on +`originalAccessKeyId` and storing the revocation cutoff time in milliseconds as the value. When the command is issued, +all STS tokens created by that `originalAccessKeyId` whose signed `creationTime` is strictly before the cutoff are +revoked. Tokens created at or after the cutoff remain valid. + +Before writing a revocation entry, the OM verifies that `originalAccessKeyId` corresponds to a real Kerberos identity by +checking that an S3 secret exists for it. This prevents bogus entries from filling the table. Non-admins may only +revoke their own `originalAccessKeyId`; S3 and tenant admins may revoke other principals. Additionally, if the Kerberos identity of the user that created the STS token is revoked via the `ozone s3 revokesecret` command, then all the existing and unexpired STS tokens that user created will be revoked. @@ -221,7 +228,8 @@ created in Ranger as per the Prerequisites above. originalAccessKeyId in the session token and perform the following checks: - Ensure that if the accessKeyId starts with "ASIA", that a sessionToken was included in the `x-amz-security-token` header - Ensure the sessionToken is not expired - - Ensure the sessionToken is not revoked via a `keyMayExist` check in OzoneManager RocksDB + - 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 - 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 diff --git a/hadoop-ozone/cli-shell/src/main/java/org/apache/hadoop/ozone/shell/s3/RevokeSTSTokenHandler.java b/hadoop-ozone/cli-shell/src/main/java/org/apache/hadoop/ozone/shell/s3/RevokeSTSTokenHandler.java index 274304217f86..9cd715d581a1 100644 --- a/hadoop-ozone/cli-shell/src/main/java/org/apache/hadoop/ozone/shell/s3/RevokeSTSTokenHandler.java +++ b/hadoop-ozone/cli-shell/src/main/java/org/apache/hadoop/ozone/shell/s3/RevokeSTSTokenHandler.java @@ -29,18 +29,18 @@ /** * Executes revocation of STS tokens. * - *

This command marks the specified STS token as revoked by adding it to the OM's revoked STS token table. - * Subsequent S3 requests using the same session token will be rejected once the revocation - * state has propagated.

+ *

This command records a revocation cutoff for the given original access key ID in the OM's + * revoked STS token table. Subsequent S3 requests using STS tokens created before that cutoff + * will be rejected once the revocation state has propagated.

*/ @Command(name = "revokeststoken", - description = "Revoke S3 STS token for the given session token") + description = "Revoke S3 STS tokens for the given original access key ID") public class RevokeSTSTokenHandler extends S3Handler { - @Option(names = "-t", + @Option(names = {"-o", "--original-access-key-id"}, required = true, - description = "STS session token") - private String sessionToken; + description = "Original long-lived access key ID whose STS tokens should be revoked") + private String originalAccessKeyId; @Option(names = "-y", description = "Continue without interactive user confirmation") @@ -56,8 +56,8 @@ protected void execute(OzoneClient client, OzoneAddress address) throws IOException { if (!yes) { - out().print("Enter 'y' to confirm STS token revocation for sessionToken '" + - sessionToken + "': "); + out().print( + "Enter 'y' to confirm STS token revocation for originalAccessKeyId '" + originalAccessKeyId + "': "); out().flush(); final Scanner scanner = new Scanner(new InputStreamReader(System.in, StandardCharsets.UTF_8)); final String confirmation = scanner.next().trim().toLowerCase(); @@ -67,7 +67,7 @@ protected void execute(OzoneClient client, OzoneAddress address) } } - client.getObjectStore().revokeSTSToken(sessionToken); - out().println("STS token revoked for sessionToken '" + sessionToken + "'."); + client.getObjectStore().revokeSTSToken(originalAccessKeyId); + out().println("STS tokens revoked for originalAccessKeyId '" + originalAccessKeyId + "'."); } } diff --git a/hadoop-ozone/client/src/main/java/org/apache/hadoop/ozone/client/ObjectStore.java b/hadoop-ozone/client/src/main/java/org/apache/hadoop/ozone/client/ObjectStore.java index bd045ef04e03..ce0f780b72de 100644 --- a/hadoop-ozone/client/src/main/java/org/apache/hadoop/ozone/client/ObjectStore.java +++ b/hadoop-ozone/client/src/main/java/org/apache/hadoop/ozone/client/ObjectStore.java @@ -813,12 +813,12 @@ public AssumeRoleResponseInfo assumeRole(String roleArn, String roleSessionName, } /** - * Revokes an STS token. - * @param sessionToken The STS sessionToken + * Revokes STS tokens for the given original access key ID. + * @param originalAccessKeyId The original long-lived access key ID whose STS tokens to revoke * @throws IOException if an error occurs while revoking the STS token */ - public void revokeSTSToken(String sessionToken) throws IOException { - proxy.revokeSTSToken(sessionToken); + public void revokeSTSToken(String originalAccessKeyId) throws IOException { + proxy.revokeSTSToken(originalAccessKeyId); } /** diff --git a/hadoop-ozone/client/src/main/java/org/apache/hadoop/ozone/client/protocol/ClientProtocol.java b/hadoop-ozone/client/src/main/java/org/apache/hadoop/ozone/client/protocol/ClientProtocol.java index 807cd2757cfc..b5f7baa0ef2c 100644 --- a/hadoop-ozone/client/src/main/java/org/apache/hadoop/ozone/client/protocol/ClientProtocol.java +++ b/hadoop-ozone/client/src/main/java/org/apache/hadoop/ozone/client/protocol/ClientProtocol.java @@ -1648,11 +1648,11 @@ AssumeRoleResponseInfo assumeRole(String roleArn, String roleSessionName, int du String awsIamSessionPolicy, String requestId) throws IOException; /** - * Revokes an STS token. - * @param sessionToken The STS sessionToken + * Revokes STS tokens for the given original access key ID. + * @param originalAccessKeyId The original long-lived access key ID whose STS tokens to revoke * @throws IOException if an error occurs while revoking the STS token */ - void revokeSTSToken(String sessionToken) throws IOException; + void revokeSTSToken(String originalAccessKeyId) throws IOException; /** * Gets the lifecycle configuration information. diff --git a/hadoop-ozone/client/src/main/java/org/apache/hadoop/ozone/client/rpc/RpcClient.java b/hadoop-ozone/client/src/main/java/org/apache/hadoop/ozone/client/rpc/RpcClient.java index 733c915dcd03..9ca47013462d 100644 --- a/hadoop-ozone/client/src/main/java/org/apache/hadoop/ozone/client/rpc/RpcClient.java +++ b/hadoop-ozone/client/src/main/java/org/apache/hadoop/ozone/client/rpc/RpcClient.java @@ -3022,8 +3022,8 @@ public AssumeRoleResponseInfo assumeRole(String roleArn, String roleSessionName, } @Override - public void revokeSTSToken(String sessionToken) throws IOException { - ozoneManagerClient.revokeSTSToken(sessionToken); + public void revokeSTSToken(String originalAccessKeyId) throws IOException { + ozoneManagerClient.revokeSTSToken(originalAccessKeyId); } @Override 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 index 763c8fe9bfa4..642210896559 100644 --- 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 @@ -40,6 +40,12 @@ public final class S3STSUtils { // AWS limit for session policy is 2048 characters public static final int MAX_SESSION_POLICY_LENGTH = 2048; + public static final String STS_TOKEN_PREFIX = "ASIA"; + public static final String STS_ACCESS_KEY_ID_ALLOWED_CHARS = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ"; + public static final int STS_ACCESS_KEY_ID_ALLOWED_CHARS_LENGTH = STS_ACCESS_KEY_ID_ALLOWED_CHARS.length(); + public static final int STS_ACCESS_KEY_ID_RANDOM_LENGTH = 20; + public static final int STS_ACCESS_KEY_ID_LENGTH = STS_TOKEN_PREFIX.length() + STS_ACCESS_KEY_ID_RANDOM_LENGTH; + private S3STSUtils() { } diff --git a/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/om/protocol/OzoneManagerProtocol.java b/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/om/protocol/OzoneManagerProtocol.java index 604669487c68..46254e3d6f63 100644 --- a/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/om/protocol/OzoneManagerProtocol.java +++ b/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/om/protocol/OzoneManagerProtocol.java @@ -1336,11 +1336,11 @@ default AssumeRoleResponseInfo assumeRole(String roleArn, String roleSessionName } /** - * Revokes an STS token. - * @param sessionToken The STS sessionToken + * Revokes STS tokens for the given original access key ID. + * @param originalAccessKeyId The original long-lived access key ID whose STS tokens to revoke * @throws IOException if an error occurs while revoking the STS token */ - default void revokeSTSToken(String sessionToken) throws IOException { + default void revokeSTSToken(String originalAccessKeyId) throws IOException { throw new UnsupportedOperationException("OzoneManager does not require this to be implemented"); } } diff --git a/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/om/protocolPB/OzoneManagerProtocolClientSideTranslatorPB.java b/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/om/protocolPB/OzoneManagerProtocolClientSideTranslatorPB.java index c60bc60db700..7077fd1b02c7 100644 --- a/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/om/protocolPB/OzoneManagerProtocolClientSideTranslatorPB.java +++ b/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/om/protocolPB/OzoneManagerProtocolClientSideTranslatorPB.java @@ -2981,10 +2981,10 @@ public AssumeRoleResponseInfo assumeRole(String roleArn, String roleSessionName, } @Override - public void revokeSTSToken(String sessionToken) throws IOException { + public void revokeSTSToken(String originalAccessKeyId) throws IOException { final OzoneManagerProtocolProtos.RevokeSTSTokenRequest request = OzoneManagerProtocolProtos.RevokeSTSTokenRequest.newBuilder() - .setSessionToken(sessionToken) + .setOriginalAccessKeyId(originalAccessKeyId) .build(); final OMRequest omRequest = createOMRequest(Type.RevokeSTSToken) diff --git a/hadoop-ozone/dist/src/main/smoketest/security/ozone-secure-sts.robot b/hadoop-ozone/dist/src/main/smoketest/security/ozone-secure-sts.robot index c0362d747048..3ee4e19a5aef 100644 --- a/hadoop-ozone/dist/src/main/smoketest/security/ozone-secure-sts.robot +++ b/hadoop-ozone/dist/src/main/smoketest/security/ozone-secure-sts.robot @@ -66,6 +66,7 @@ ${ACTION_MATCHES_PUTOBJECT_CREATE_WRITE_ROLE_ARN} arn:aws:iam::123456789012:rol ${ACTION_MATCHES_GETOBJECT_PUTOBJECT_ROLE_ARN} arn:aws:iam::123456789012:role/${ACTION_MATCHES_GETOBJECT_PUTOBJECT_ROLE} ${ACTION_MATCHES_UPLOADPARTCOPY_EXPECTED_OWNER_ROLE_ARN} arn:aws:iam::123456789012:role/${ACTION_MATCHES_UPLOADPARTCOPY_EXPECTED_OWNER_ROLE} ${ACTION_MATCHES_GET_STAR_READ_ROLE_ARN} arn:aws:iam::123456789012:role/${ACTION_MATCHES_GET_STAR_READ_ROLE} +${TEST_USER_ADMIN} testuser ${TEST_USER_NON_ADMIN} testuser2 @{ICEBERG_OBJECT_KEYS} file1.txt file1again.txt folder/pepper.txt folder/salt.txt userA/userA.txt userB/userB.txt userAfile.txt @{ICEBERG_LISTABLE_OBJECT_KEYS_OBS} file1.txt file1again.txt folder/pepper.txt folder/salt.txt userA/userA.txt userB/userB.txt userAfile.txt zeroByteFile zeroByteFolder/ @@ -254,6 +255,17 @@ Configure STS Profile With Bogus Credential Part Configure STS Profile ${STS_ACCESS_KEY_ID} ${STS_SECRET_KEY} bogusSessionToken END +Verify STS Token Revocation And Post Revocation Assume Role + [Arguments] ${bucket} ${role_arn} ${revoker_user} ${revoker_keytab} + Assume Role And Configure STS Profile perm_access_key_id=${PERMANENT_ACCESS_KEY_ID} perm_secret_key=${PERMANENT_SECRET_KEY} role_arn=${role_arn} + Get Object Should Succeed ${bucket} ${ICEBERG_BUCKET_TESTFILE} + Kinit test user ${revoker_user} ${revoker_keytab} + ${output} = Execute ozone s3 revokeststoken -o ${PERMANENT_ACCESS_KEY_ID} -y ${OM_HA_PARAM} + Should Contain ${output} STS tokens revoked for originalAccessKeyId + Get Object Should Fail ${bucket} ${ICEBERG_BUCKET_TESTFILE} AccessDenied + Assume Role And Configure STS Profile perm_access_key_id=${PERMANENT_ACCESS_KEY_ID} perm_secret_key=${PERMANENT_SECRET_KEY} role_arn=${role_arn} + Get Object Should Succeed ${bucket} ${ICEBERG_BUCKET_TESTFILE} + *** Test Cases *** Create User in Ranger ${user_json} = Set Variable { "loginId": "${ICEBERG_SVC_CATALOG_USER}", "name": "${ICEBERG_SVC_CATALOG_USER}", "password": "Password123", "firstName": "Iceberg REST", "lastName": "Catalog", "emailAddress": "${ICEBERG_SVC_CATALOG_USER}@example.com", "userRoleList": ["ROLE_USER"], "userPermList": [ { "moduleId": 1, "isAllowed": 1 }, { "moduleId": 3, "isAllowed": 1 }, { "moduleId": 7, "isAllowed": 1 } ] } @@ -558,27 +570,32 @@ Verify Token Revocation via CLI FOR ${bucket} ${role_arn} IN ... ${ICEBERG_BUCKET_OBS} ${ICEBERG_ALL_ACCESS_ROLE_OBS_ARN} ... ${ICEBERG_BUCKET_FSO} ${ICEBERG_ALL_ACCESS_ROLE_FSO_ARN} - Assume Role And Configure STS Profile perm_access_key_id=${PERMANENT_ACCESS_KEY_ID} perm_secret_key=${PERMANENT_SECRET_KEY} role_arn=${role_arn} - ${output} = Execute ozone s3 revokeststoken -t ${STS_SESSION_TOKEN} -y ${OM_HA_PARAM} - Should Contain ${output} STS token revoked for sessionToken - # Trying to use the token for even get-object should now fail. - Get Object Should Fail ${bucket} ${ICEBERG_BUCKET_TESTFILE} AccessDenied + # Owner of the original access key can revoke the STS token. + Verify STS Token Revocation And Post Revocation Assume Role ${bucket} ${role_arn} ${ICEBERG_SVC_CATALOG_USER} ${ICEBERG_SVC_CATALOG_USER}.keytab + # S3 admin can also revoke an STS token owned by another user. + Verify STS Token Revocation And Post Revocation Assume Role ${bucket} ${role_arn} ${TEST_USER_ADMIN} ${TEST_USER_ADMIN}.keytab END Non-Admin Cannot Revoke STS Token FOR ${role_arn} IN ${ICEBERG_ALL_ACCESS_ROLE_OBS_ARN} ${ICEBERG_ALL_ACCESS_ROLE_FSO_ARN} # Create a token first. Assume Role And Get Temporary Credentials perm_access_key_id=${PERMANENT_ACCESS_KEY_ID} perm_secret_key=${PERMANENT_SECRET_KEY} role_arn=${role_arn} - ${token_to_revoke} = Set Variable ${STS_SESSION_TOKEN} # Kinit as non-admin user. Kinit test user ${TEST_USER_NON_ADMIN} ${TEST_USER_NON_ADMIN}.keytab # Try to revoke - should give USER_MISMATCH error. - ${output} = Execute And Ignore Error ozone s3 revokeststoken -t ${token_to_revoke} -y ${OM_HA_PARAM} + ${output} = Execute And Ignore Error ozone s3 revokeststoken -o ${PERMANENT_ACCESS_KEY_ID} -y ${OM_HA_PARAM} Should Contain ${output} USER_MISMATCH END +Revoke STS Token Should Fail For Unknown Original Access Key Id + # Revoking a bogus originalAccessKeyId must fail before writing to the revocation table. + Kinit test user ${TEST_USER_ADMIN} ${TEST_USER_ADMIN}.keytab + ${output} = Execute And Ignore Error ozone s3 revokeststoken -o bogus-original-access-key-id -y ${OM_HA_PARAM} + Should Contain ${output} ACCESS_ID_NOT_FOUND + Should Contain ${output} does not exist + List Objects V1 and V2 IAM Session Policy Matrix for OBS and FSO Kinit test user ${ICEBERG_SVC_CATALOG_USER} ${ICEBERG_SVC_CATALOG_USER}.keytab @@ -613,6 +630,13 @@ Tampered STS Token Service, Policy, or Signature Must Fail Get Object Should Fail ${ICEBERG_BUCKET_OBS} ${ICEBERG_BUCKET_TESTFILE} AccessDenied Put Object Should Fail ${ICEBERG_BUCKET_OBS} ${ICEBERG_BUCKET_TESTFILE} AccessDenied + # Exercise malformed token decoding with incorrect token structure. Unlike the earlier + # bogusSessionToken credential-part check, this literal decodes to a negative Writable + # length and covers unchecked decoder failures such as NegativeArraySizeException. + Configure STS Profile ${STS_ACCESS_KEY_ID} ${STS_SECRET_KEY} not-a-valid-token + Get Object Should Fail ${ICEBERG_BUCKET_OBS} ${ICEBERG_BUCKET_TESTFILE} AccessDenied + Put Object Should Fail ${ICEBERG_BUCKET_OBS} ${ICEBERG_BUCKET_TESTFILE} AccessDenied + Assume Role Session Policy With Multiple Buckets Should Access All Buckets ${session_policy} = Set Variable {"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:GetObject","Resource":"arn:aws:s3:::${ICEBERG_BUCKET_OBS}/*"},{"Effect":"Allow","Action":"s3:GetObject","Resource":"arn:aws:s3:::${ICEBERG_BUCKET_FSO}/*"}]} Assume Role And Get Temporary Credentials policy_json=${session_policy} perm_access_key_id=${PERMANENT_ACCESS_KEY_ID} perm_secret_key=${PERMANENT_SECRET_KEY} role_arn=${ICEBERG_MULTI_BUCKET_ROLE_ARN} diff --git a/hadoop-ozone/interface-client/src/main/proto/OmClientProtocol.proto b/hadoop-ozone/interface-client/src/main/proto/OmClientProtocol.proto index c06d54209a0b..638cf99bd1ce 100644 --- a/hadoop-ozone/interface-client/src/main/proto/OmClientProtocol.proto +++ b/hadoop-ozone/interface-client/src/main/proto/OmClientProtocol.proto @@ -2534,18 +2534,20 @@ message UpdateAssumeRoleRequest { } message RevokeSTSTokenRequest { - required string sessionToken = 1; + required string originalAccessKeyId = 1; + // Leader-generated revocation cutoff, replicated across OMs in HA mode. + optional uint64 revocationTimeMillis = 2; } message RevokeSTSTokenResponse { } /** - This will contain a list of revoked STS session tokens whose entries should be removed from + This will contain a list of originalAccessKeyIds whose revocation entries should be removed from the s3RevokedStsTokenTable. */ message DeleteRevokedSTSTokensRequest { - repeated string sessionToken = 1; + repeated string originalAccessKeyId = 1; } message DeleteRevokedSTSTokensResponse { diff --git a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/OmMetadataManagerImpl.java b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/OmMetadataManagerImpl.java index 97f6cf920365..f1a7a51d3e26 100644 --- a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/OmMetadataManagerImpl.java +++ b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/OmMetadataManagerImpl.java @@ -540,7 +540,7 @@ protected void initializeOmTables(CacheType cacheType, compactionLogTable = initializer.get(OMDBDefinition.COMPACTION_LOG_TABLE_DEF); - // sessionToken -> insertionTimeMillis + // originalAccessKeyId -> revocationTimeMillis // FULL_CACHE keeps revocations in memory as there are not expected to be many s3RevokedStsTokenTable = initializer.get( OMDBDefinition.S3_REVOKED_STS_TOKEN_TABLE_DEF, cacheType); diff --git a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/codec/OMDBDefinition.java b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/codec/OMDBDefinition.java index 2e99871e17c4..08600bb99504 100644 --- a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/codec/OMDBDefinition.java +++ b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/codec/OMDBDefinition.java @@ -60,7 +60,7 @@ * | userTable | /user :- UserVolumeInfo | * | dTokenTable | OzoneTokenID :- renew_time | * | s3SecretTable | s3g_access_key_id :- s3Secret | - * | s3RevokedStsTokenTable | sts_session_token :- insertionTimeMillis | + * | s3RevokedStsTokenTable | originalAccessKeyId :- revocationTimeMillis | * |------------------------------------------------------------------------| * } * @@ -169,7 +169,10 @@ public final class OMDBDefinition extends DBDefinition.WithMap { S3SecretValue.getCodec()); public static final String S3_REVOKED_STS_TOKEN_TABLE = "s3RevokedStsTokenTable"; - /** s3RevokedStsTokenTable: sts_session_token :- insertionTimeMillis.*/ + /** + * s3RevokedStsTokenTable: originalAccessKeyId :- revocationTimeMillis. + * The value is the revocation cutoff in milliseconds. + */ public static final DBColumnFamilyDefinition S3_REVOKED_STS_TOKEN_TABLE_DEF = new DBColumnFamilyDefinition<>(S3_REVOKED_STS_TOKEN_TABLE, StringCodec.get(), diff --git a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/ratis/OzoneManagerStateMachine.java b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/ratis/OzoneManagerStateMachine.java index 3db85f508051..5b9c453d8936 100644 --- a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/ratis/OzoneManagerStateMachine.java +++ b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/ratis/OzoneManagerStateMachine.java @@ -25,6 +25,7 @@ import com.google.common.base.Preconditions; import com.google.common.util.concurrent.ThreadFactoryBuilder; import java.io.IOException; +import java.time.Instant; import java.util.ArrayList; import java.util.Collection; import java.util.LinkedHashMap; @@ -683,15 +684,22 @@ OMResponse runCommand(OMRequest request, TermIndex termIndex) { if (s3Auth.hasSessionToken() && !s3Auth.getSessionToken().isEmpty()) { // ThreadLocal carries session policy for OmMetadataReader + // Use Instant.MAX for creationTime so a future revocation check on this ThreadLocal + // identifier never treats the token as issued before a stored cutoff. final STSTokenIdentifier rehydratedTokenIdentifier = new STSTokenIdentifier( - s3Auth.hasResolvedStsTempAccessKeyId() ? s3Auth.getResolvedStsTempAccessKeyId() : "", - s3Auth.hasResolvedStsOriginalAccessKeyId() ? s3Auth.getResolvedStsOriginalAccessKeyId() : "", - s3Auth.hasResolvedStsRoleArn() ? s3Auth.getResolvedStsRoleArn() : "", - java.time.Instant.MAX, // ensure it deterministically is not expired - "", // no secretAccessKey needed - s3Auth.hasResolvedStsSessionPolicy() ? s3Auth.getResolvedStsSessionPolicy() : "", - null // no encryption key needed - ); + STSTokenIdentifier.Params.newBuilder() + .setTempAccessKeyId( + s3Auth.hasResolvedStsTempAccessKeyId() ? s3Auth.getResolvedStsTempAccessKeyId() : "") + .setOriginalAccessKeyId( + s3Auth.hasResolvedStsOriginalAccessKeyId() ? s3Auth.getResolvedStsOriginalAccessKeyId() : "") + .setRoleArn(s3Auth.hasResolvedStsRoleArn() ? s3Auth.getResolvedStsRoleArn() : "") + .setCreationTime(Instant.MAX) + .setExpiry(Instant.MAX) // ensure it deterministically is not expired + .setSecretAccessKey(null) // no secretAccessKey needed + .setSessionPolicy( + s3Auth.hasResolvedStsSessionPolicy() ? s3Auth.getResolvedStsSessionPolicy() : "") + .setManagedSecretKey(null) // no ManagedSecretKey needed + .build()); OzoneManager.setStsTokenIdentifier(rehydratedTokenIdentifier); isStsThreadLocalSet = true; } diff --git a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/OMClientRequest.java b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/OMClientRequest.java index 6b9c6698cf9f..29abe0eb8eec 100644 --- a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/OMClientRequest.java +++ b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/OMClientRequest.java @@ -46,10 +46,10 @@ import org.apache.hadoop.ozone.om.helpers.BucketLayout; import org.apache.hadoop.ozone.om.helpers.OMAuditLogger; import org.apache.hadoop.ozone.om.helpers.OzoneFSUtils; +import org.apache.hadoop.ozone.om.helpers.S3STSUtils; import org.apache.hadoop.ozone.om.lock.OMLockDetails; import org.apache.hadoop.ozone.om.protocolPB.grpc.GrpcClientConstants; import org.apache.hadoop.ozone.om.ratis.utils.OzoneManagerRatisUtils; -import org.apache.hadoop.ozone.om.request.s3.security.S3AssumeRoleRequest; import org.apache.hadoop.ozone.om.response.OMClientResponse; import org.apache.hadoop.ozone.om.upgrade.OMLayoutVersionManager; import org.apache.hadoop.ozone.protocol.proto.OzoneManagerProtocolProtos; @@ -226,7 +226,7 @@ public OzoneManagerProtocolProtos.UserInfo getUserInfo() throws IOException { // falling back to accessId if session token not present. if (omRequest.hasS3Authentication()) { final String accessKeyId = omRequest.getS3Authentication().getAccessId(); - if (accessKeyId.startsWith(S3AssumeRoleRequest.STS_TOKEN_PREFIX) && + if (accessKeyId.startsWith(S3STSUtils.STS_TOKEN_PREFIX) && !omRequest.getS3Authentication().hasSessionToken()) { throw new IOException("Error with STS token", new AuthenticationException( "Missing session token for accessKeyId: " + accessKeyId)); 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 4efd18b4b327..b6d650cc4393 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 @@ -17,6 +17,10 @@ package org.apache.hadoop.ozone.om.request.s3.security; +import static org.apache.hadoop.ozone.om.helpers.S3STSUtils.STS_ACCESS_KEY_ID_ALLOWED_CHARS; +import static org.apache.hadoop.ozone.om.helpers.S3STSUtils.STS_ACCESS_KEY_ID_ALLOWED_CHARS_LENGTH; +import static org.apache.hadoop.ozone.om.helpers.S3STSUtils.STS_ACCESS_KEY_ID_RANDOM_LENGTH; +import static org.apache.hadoop.ozone.om.helpers.S3STSUtils.STS_TOKEN_PREFIX; import static org.apache.hadoop.ozone.security.acl.AssumeRoleRequest.OzoneGrant; import com.google.common.annotations.VisibleForTesting; @@ -31,6 +35,7 @@ import java.util.Set; import org.apache.hadoop.hdds.scm.client.HddsClientUtils; import org.apache.hadoop.ipc_.ProtobufRpcEngine; +import org.apache.hadoop.ozone.OzoneConsts; import org.apache.hadoop.ozone.audit.AuditLogger; import org.apache.hadoop.ozone.audit.OMAction; import org.apache.hadoop.ozone.om.OzoneAclUtils; @@ -70,16 +75,12 @@ public class S3AssumeRoleRequest extends OMClientRequest { SECURE_RANDOM = secureRandom; } - 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 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 + + private static final String CHARS_FOR_SECRET_ACCESS_KEYS = STS_ACCESS_KEY_ID_ALLOWED_CHARS + "abcdefghijklmnopqrstuvwxyz/+"; private static final int CHARS_FOR_SECRET_ACCESS_KEYS_LENGTH = CHARS_FOR_SECRET_ACCESS_KEYS.length(); - public static final String STS_TOKEN_PREFIX = "ASIA"; private final Clock clock; @@ -103,11 +104,13 @@ public OMRequest preExecute(OzoneManager ozoneManager) throws IOException { // 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); + STS_ACCESS_KEY_ID_ALLOWED_CHARS, STS_ACCESS_KEY_ID_ALLOWED_CHARS_LENGTH, + STS_ACCESS_KEY_ID_RANDOM_LENGTH); final String secretAccessKey = generateSecureRandomStringUsingChars( CHARS_FOR_SECRET_ACCESS_KEYS, CHARS_FOR_SECRET_ACCESS_KEYS_LENGTH, STS_SECRET_ACCESS_KEY_LENGTH); final String roleId = ASSUME_ROLE_ID_PREFIX + generateSecureRandomStringUsingChars( - CHARS_FOR_ACCESS_KEY_IDS, CHARS_FOR_ACCESS_KEY_IDS_LENGTH, STS_ROLE_ID_LENGTH); + STS_ACCESS_KEY_ID_ALLOWED_CHARS, STS_ACCESS_KEY_ID_ALLOWED_CHARS_LENGTH, + STS_ROLE_ID_LENGTH); // Build UpdateAssumeRoleRequest with leader-generated credentials final UpdateAssumeRoleRequest.Builder updateAssumeRoleRequestBuilder = @@ -182,7 +185,7 @@ public OMClientResponse validateAndUpdateCache(OzoneManager ozoneManager, Execut final long expirationEpochSeconds = clock.instant().plusSeconds(durationSeconds).getEpochSecond(); // Add tempAccessKeyId to the log so it can be determined which permanent user created the tempAccessKeyId - auditMap.put("tempAccessKeyId", tempAccessKeyId); + auditMap.put(OzoneConsts.S3_STS_TEMP_ACCESS_KEY_ID, tempAccessKeyId); final AssumeRoleResponse.Builder responseBuilder = AssumeRoleResponse.newBuilder() .setAccessKeyId(tempAccessKeyId) diff --git a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/s3/security/S3DeleteRevokedSTSTokensRequest.java b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/s3/security/S3DeleteRevokedSTSTokensRequest.java index f41b20353a83..81558ec58504 100644 --- a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/s3/security/S3DeleteRevokedSTSTokensRequest.java +++ b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/s3/security/S3DeleteRevokedSTSTokensRequest.java @@ -35,6 +35,7 @@ /** * Handles DeleteRevokedSTSTokens requests submitted by {@link RevokedSTSTokenCleanupService}. + * Each request contains originalAccessKeyIds to remove from the revocation table. */ public class S3DeleteRevokedSTSTokensRequest extends OMClientRequest { @@ -62,8 +63,8 @@ public OMClientResponse validateAndUpdateCache(OzoneManager ozoneManager, Execut final DeleteRevokedSTSTokensRequest request = getOmRequest().getDeleteRevokedSTSTokensRequest(); final OMResponse.Builder omResponse = OmResponseUtil.getOMResponseBuilder(getOmRequest()); - final List sessionTokens = request.getSessionTokenList(); - return new S3DeleteRevokedSTSTokensResponse(sessionTokens, omResponse.build()); + final List originalAccessKeyIds = request.getOriginalAccessKeyIdList(); + return new S3DeleteRevokedSTSTokensResponse(originalAccessKeyIds, omResponse.build()); } } diff --git a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/s3/security/S3RevokeSTSTokenRequest.java b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/s3/security/S3RevokeSTSTokenRequest.java index 52a92d8a5560..02e6cac1b3d4 100644 --- a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/s3/security/S3RevokeSTSTokenRequest.java +++ b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/s3/security/S3RevokeSTSTokenRequest.java @@ -17,26 +17,30 @@ package org.apache.hadoop.ozone.om.request.s3.security; +import static org.apache.hadoop.ozone.om.exceptions.OMException.ResultCodes.ACCESS_ID_NOT_FOUND; +import static org.apache.hadoop.ozone.om.exceptions.OMException.ResultCodes.INTERNAL_ERROR; +import static org.apache.hadoop.ozone.om.exceptions.OMException.ResultCodes.INVALID_REQUEST; + import java.io.IOException; import java.time.Clock; import java.time.ZoneOffset; import java.util.HashMap; import java.util.Map; +import org.apache.commons.lang3.StringUtils; import org.apache.hadoop.hdds.utils.db.cache.CacheKey; import org.apache.hadoop.hdds.utils.db.cache.CacheValue; import org.apache.hadoop.ozone.OzoneConsts; import org.apache.hadoop.ozone.audit.OMAction; 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.S3RevokeSTSTokenResponse; -import org.apache.hadoop.ozone.protocol.proto.OzoneManagerProtocolProtos; import org.apache.hadoop.ozone.protocol.proto.OzoneManagerProtocolProtos.OMRequest; import org.apache.hadoop.ozone.protocol.proto.OzoneManagerProtocolProtos.OMResponse; -import org.apache.hadoop.ozone.security.STSSecurityUtil; -import org.apache.hadoop.ozone.security.STSTokenIdentifier; +import org.apache.hadoop.ozone.protocol.proto.OzoneManagerProtocolProtos.RevokeSTSTokenRequest; import org.apache.hadoop.security.UserGroupInformation; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -44,10 +48,14 @@ /** * Handles S3RevokeSTSTokenRequest request. * - *

This request marks an STS session token as revoked by inserting - * it into the {@code s3RevokedStsTokenTable}. Subsequent S3 requests - * authenticated with the same STS session token will be rejected when the - * revocation state has propagated.

+ *

The client submits {@link RevokeSTSTokenRequest} with {@code originalAccessKeyId} only. On the + * leader, {@code preExecute} captures the revocation cutoff in {@code revocationTimeMillis} and + * replicates the updated request through Ratis so every OM applies the same cutoff.

+ * + *

This request records a revocation cutoff for the given {@code originalAccessKeyId} in the + * {@code s3RevokedStsTokenTable}. Subsequent S3 requests authenticated with STS tokens whose + * {@code creationTime} is strictly before the cutoff will be rejected when the revocation state + * has propagated.

*/ public class S3RevokeSTSTokenRequest extends OMClientRequest { @@ -61,48 +69,89 @@ public S3RevokeSTSTokenRequest(OMRequest omRequest) { @Override public OMRequest preExecute(OzoneManager ozoneManager) throws IOException { final OMRequest omRequest = super.preExecute(ozoneManager); - final OzoneManagerProtocolProtos.RevokeSTSTokenRequest revokeReq = - omRequest.getRevokeSTSTokenRequest(); + final RevokeSTSTokenRequest revokeReq = omRequest.getRevokeSTSTokenRequest(); + validateRevokeRequestFields(revokeReq); - // Get the original (long-lived) access key id from the session token - // and enforce the same permission model that is used for S3 secret + // Use the original (long-lived) access key ID from the request and enforce + // the same permission model that is used for S3 secret // operations (get/set/revoke). Only the owner of the original access // key (i.e. the creator of the STS token) or an S3 / tenant admin is allowed // to revoke its temporary STS credentials. - final String sessionToken = revokeReq.getSessionToken(); - final STSTokenIdentifier stsTokenIdentifier = STSSecurityUtil.constructValidateAndDecryptSTSToken( - sessionToken, ozoneManager.getSecretKeyClient(), CLOCK); - final String originalAccessKeyId = stsTokenIdentifier.getOriginalAccessKeyId(); + final String originalAccessKeyId = revokeReq.getOriginalAccessKeyId(); final UserGroupInformation ugi = S3SecretRequestHelper.getOrCreateUgi(originalAccessKeyId); S3SecretRequestHelper.checkAccessIdSecretOpPermission(ozoneManager, ugi, originalAccessKeyId); - return omRequest; + if (!ozoneManager.getS3SecretManager().hasS3Secret(originalAccessKeyId)) { + throw new OMException("originalAccessKeyId does not exist: " + originalAccessKeyId, ACCESS_ID_NOT_FOUND); + } + + final long revocationTimeMillis = CLOCK.millis(); + final RevokeSTSTokenRequest updatedRevokeReq = revokeReq.toBuilder() + .setRevocationTimeMillis(revocationTimeMillis) + .build(); + + return omRequest.toBuilder() + .setRevokeSTSTokenRequest(updatedRevokeReq) + .build(); } @Override public OMClientResponse validateAndUpdateCache(OzoneManager ozoneManager, ExecutionContext context) { final OMResponse.Builder omResponse = OmResponseUtil.getOMResponseBuilder(getOmRequest()); + IOException exception = null; + OMClientResponse omClientResponse; + final Map auditMap = new HashMap<>(); - final OzoneManagerProtocolProtos.RevokeSTSTokenRequest revokeReq = getOmRequest().getRevokeSTSTokenRequest(); - final String sessionToken = revokeReq.getSessionToken(); + try { + final RevokeSTSTokenRequest revokeReq = validateReplicatedRevokeRequestFields(getOmRequest()); + final String originalAccessKeyId = revokeReq.getOriginalAccessKeyId(); + auditMap.put(OzoneConsts.S3_REVOKESTSTOKEN_USER, originalAccessKeyId); + final long revocationTimeMillis = revokeReq.getRevocationTimeMillis(); - // All actual DB mutations are done in the response's addToDBBatch(). - final OMClientResponse omClientResponse = new S3RevokeSTSTokenResponse( - sessionToken, omResponse.build()); + // All actual DB mutations are done in the response's addToDBBatch(). + omClientResponse = new S3RevokeSTSTokenResponse(originalAccessKeyId, revocationTimeMillis, omResponse.build()); - // Audit log - final Map auditMap = new HashMap<>(); - final OzoneManagerProtocolProtos.UserInfo userInfo = getOmRequest().getUserInfo(); - auditMap.put(OzoneConsts.S3_REVOKESTSTOKEN_USER, userInfo.getUserName()); - markForAudit(ozoneManager.getAuditLogger(), buildAuditMessage( - OMAction.REVOKE_STS_TOKEN, auditMap, null, userInfo)); + // Update the cache immediately so subsequent validation checks see the revocation + ozoneManager.getMetadataManager().getS3RevokedStsTokenTable().addCacheEntry( + new CacheKey<>(originalAccessKeyId), CacheValue.get(context.getIndex(), revocationTimeMillis)); - // Update the cache immediately so subsequent validation checks see the revocation - ozoneManager.getMetadataManager().getS3RevokedStsTokenTable().addCacheEntry( - new CacheKey<>(sessionToken), CacheValue.get(context.getIndex(), CLOCK.millis())); + LOG.info( + "Marked STS tokens as revoked for originalAccessKeyId={} with cutoff time {}.", + originalAccessKeyId, revocationTimeMillis); + } catch (IOException ex) { + exception = ex; + omClientResponse = new S3RevokeSTSTokenResponse(null, 0L, createErrorOMResponse(omResponse, ex)); + } - LOG.info("Marked STS session token '{}' as revoked.", sessionToken); + // Audit log + markForAudit( + ozoneManager.getAuditLogger(), buildAuditMessage( + OMAction.REVOKE_STS_TOKEN, auditMap, exception, getOmRequest().getUserInfo())); return omClientResponse; } + + private static void validateRevokeRequestFields(RevokeSTSTokenRequest revokeReq) throws OMException { + final String originalAccessKeyId = revokeReq.getOriginalAccessKeyId(); + if (StringUtils.isEmpty(originalAccessKeyId)) { + throw new OMException("originalAccessKeyId is required for STS token revocation", INVALID_REQUEST); + } + if (revokeReq.hasRevocationTimeMillis()) { + throw new OMException("revocationTimeMillis must not be set by client", INVALID_REQUEST); + } + } + + private static RevokeSTSTokenRequest validateReplicatedRevokeRequestFields(OMRequest omRequest) throws OMException { + if (!omRequest.hasRevokeSTSTokenRequest()) { + throw new OMException("revokeSTSTokenRequest is required for STS token revocation", INTERNAL_ERROR); + } + final RevokeSTSTokenRequest revokeReq = omRequest.getRevokeSTSTokenRequest(); + if (StringUtils.isEmpty(revokeReq.getOriginalAccessKeyId())) { + throw new OMException("originalAccessKeyId is required for STS token revocation", INTERNAL_ERROR); + } + if (!revokeReq.hasRevocationTimeMillis()) { + throw new OMException("revocationTimeMillis is required for STS token revocation", INTERNAL_ERROR); + } + return revokeReq; + } } diff --git a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/response/s3/security/S3DeleteRevokedSTSTokensResponse.java b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/response/s3/security/S3DeleteRevokedSTSTokensResponse.java index cb44e7f466d9..a1b255689de5 100644 --- a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/response/s3/security/S3DeleteRevokedSTSTokensResponse.java +++ b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/response/s3/security/S3DeleteRevokedSTSTokensResponse.java @@ -36,16 +36,16 @@ @CleanupTableInfo(cleanupTables = {S3_REVOKED_STS_TOKEN_TABLE}) public class S3DeleteRevokedSTSTokensResponse extends OMClientResponse { - private final List sessionTokens; + private final List originalAccessKeyIds; - public S3DeleteRevokedSTSTokensResponse(List sessionTokens, @Nonnull OMResponse omResponse) { + public S3DeleteRevokedSTSTokensResponse(List originalAccessKeyIds, @Nonnull OMResponse omResponse) { super(omResponse); - this.sessionTokens = sessionTokens; + this.originalAccessKeyIds = originalAccessKeyIds; } @Override public void addToDBBatch(OMMetadataManager omMetadataManager, BatchOperation batchOperation) throws IOException { - if (sessionTokens == null || sessionTokens.isEmpty()) { + if (originalAccessKeyIds == null || originalAccessKeyIds.isEmpty()) { return; } if (!getOMResponse().hasStatus() || getOMResponse().getStatus() != OK) { @@ -57,8 +57,8 @@ public void addToDBBatch(OMMetadataManager omMetadataManager, BatchOperation bat return; } - for (String sessionToken : sessionTokens) { - table.deleteWithBatch(batchOperation, sessionToken); + for (String originalAccessKeyId : originalAccessKeyIds) { + table.deleteWithBatch(batchOperation, originalAccessKeyId); } } } diff --git a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/response/s3/security/S3RevokeSTSTokenResponse.java b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/response/s3/security/S3RevokeSTSTokenResponse.java index 5b1a8cf3b019..db9233357ed2 100644 --- a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/response/s3/security/S3RevokeSTSTokenResponse.java +++ b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/response/s3/security/S3RevokeSTSTokenResponse.java @@ -22,8 +22,6 @@ import jakarta.annotation.Nonnull; import java.io.IOException; -import java.time.Clock; -import java.time.ZoneOffset; import org.apache.hadoop.hdds.utils.db.BatchOperation; import org.apache.hadoop.hdds.utils.db.Table; import org.apache.hadoop.ozone.om.OMMetadataManager; @@ -37,22 +35,23 @@ @CleanupTableInfo(cleanupTables = {S3_REVOKED_STS_TOKEN_TABLE}) public class S3RevokeSTSTokenResponse extends OMClientResponse { - private static final Clock CLOCK = Clock.system(ZoneOffset.UTC); + private final String originalAccessKeyId; + private final long revocationTimeMillis; - private final String sessionToken; - - public S3RevokeSTSTokenResponse(String sessionToken, @Nonnull OMResponse omResponse) { + public S3RevokeSTSTokenResponse(String originalAccessKeyId, long revocationTimeMillis, + @Nonnull OMResponse omResponse) { super(omResponse); - this.sessionToken = sessionToken; + this.originalAccessKeyId = originalAccessKeyId; + this.revocationTimeMillis = revocationTimeMillis; } @Override public void addToDBBatch(OMMetadataManager omMetadataManager, BatchOperation batchOperation) throws IOException { - if (sessionToken != null && getOMResponse().hasStatus() && getOMResponse().getStatus() == OK) { + if (originalAccessKeyId != null && getOMResponse().hasStatus() && getOMResponse().getStatus() == OK) { final Table table = omMetadataManager.getS3RevokedStsTokenTable(); if (table != null) { - // Store insertionTimeMillis as value - table.putWithBatch(batchOperation, sessionToken, CLOCK.millis()); + // Store revocationTimeMillis as value + table.putWithBatch(batchOperation, originalAccessKeyId, revocationTimeMillis); } } } diff --git a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/service/RevokedSTSTokenCleanupService.java b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/service/RevokedSTSTokenCleanupService.java index 3d9668d6469c..c627f6a21cb7 100644 --- a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/service/RevokedSTSTokenCleanupService.java +++ b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/service/RevokedSTSTokenCleanupService.java @@ -37,6 +37,7 @@ import org.apache.hadoop.ozone.om.OMConfigKeys; import org.apache.hadoop.ozone.om.OMMetadataManager; import org.apache.hadoop.ozone.om.OzoneManager; +import org.apache.hadoop.ozone.om.helpers.S3STSUtils; import org.apache.hadoop.ozone.om.ratis.utils.OzoneManagerRatisUtils; import org.apache.hadoop.ozone.protocol.proto.OzoneManagerProtocolProtos.DeleteRevokedSTSTokensRequest; import org.apache.hadoop.ozone.protocol.proto.OzoneManagerProtocolProtos.OMRequest; @@ -57,7 +58,8 @@ public class RevokedSTSTokenCleanupService extends BackgroundService { // Use a single thread private static final int REVOKED_STS_TOKEN_CLEANER_CORE_POOL_SIZE = 1; private static final Clock CLOCK = Clock.system(ZoneOffset.UTC); - private static final long CLEANUP_THRESHOLD = 12 * 60 * 60 * 1000L; // 12 hours in milliseconds + // Keep revocation entries until max STS token lifetime after the cutoff was captured. + private static final long CLEANUP_THRESHOLD = TimeUnit.SECONDS.toMillis(S3STSUtils.MAX_DURATION_SECONDS); // 12 hours private final OzoneManager ozoneManager; private final OMMetadataManager metadataManager; @@ -124,7 +126,7 @@ private boolean shouldRun() { return !suspended.get() && ozoneManager.isLeaderReady(); } - private class RevokedSTSTokenCleanupTask implements BackgroundTask { + private final class RevokedSTSTokenCleanupTask implements BackgroundTask { @Override public BackgroundTaskResult call() throws Exception { @@ -143,17 +145,17 @@ public BackgroundTaskResult call() throws Exception { iterator.seekToFirst(); while (iterator.hasNext()) { final Table.KeyValue entry = iterator.next(); - final String sessionToken = entry.getKey(); - final Long initialCreationTimeMillis = entry.getValue(); + final String originalAccessKeyId = entry.getKey(); + final Long revocationTimeMillis = entry.getValue(); - if (shouldCleanup(initialCreationTimeMillis)) { - // Calculate the size this token would add to the protobuf message. + if (shouldCleanup(revocationTimeMillis)) { + // Calculate the size this originalAccessKeyId would add to the protobuf message. // Make a copy of the batch to do the size check final List batchCopyWithCandidate = new ArrayList<>(batch); - batchCopyWithCandidate.add(sessionToken); + batchCopyWithCandidate.add(originalAccessKeyId); int batchWithCandidateSize = getBatchSerializedSize(batchCopyWithCandidate); - // If adding this token would exceed the limit, submit the current batch + // If adding this originalAccessKeyId would exceed the limit, submit the current batch if (batchWithCandidateSize > ratisByteLimit) { if (!batch.isEmpty()) { if (submitCleanupRequest(batch)) { @@ -163,22 +165,22 @@ public BackgroundTaskResult call() throws Exception { } batch.clear(); - // Re-calculate the size of the candidate token alone in an empty batch + // Re-calculate the size of the candidate key alone in an empty batch // to check if it exceeds the limit by itself. final List singleCandidateBatch = new ArrayList<>(); - singleCandidateBatch.add(sessionToken); + singleCandidateBatch.add(originalAccessKeyId); batchWithCandidateSize = getBatchSerializedSize(singleCandidateBatch); } - // Check if the single token exceeds the limit (either strictly single or after flush) + // Check if the single key exceeds the limit (either strictly single or after flush) if (batchWithCandidateSize > ratisByteLimit) { LOG.error( - "Single revoked STS Token size ({}) would exceed the ratisByteLimit ({}). SessionToken " + - "initialCreationTimeMillis: {}", batchWithCandidateSize, ratisByteLimit, initialCreationTimeMillis); + "Single originalAccessKeyId entry size ({}) would exceed the ratisByteLimit ({}). " + + "revocationTimeMillis: {}", batchWithCandidateSize, ratisByteLimit, revocationTimeMillis); continue; } } - batch.add(sessionToken); + batch.add(originalAccessKeyId); } } } catch (IOException e) { @@ -213,16 +215,16 @@ public BackgroundTaskResult call() throws Exception { } /** - * Returns true if the given STS session token has been in the table past the cleanup threshold. + * Returns true if the revocation cutoff is older than the cleanup threshold. */ - private boolean shouldCleanup(long initialCreationTimeMillis) { + private boolean shouldCleanup(long revocationTimeMillis) { final long now = CLOCK.millis(); - if (now - initialCreationTimeMillis > CLEANUP_THRESHOLD) { + if (now - revocationTimeMillis > CLEANUP_THRESHOLD) { if (LOG.isDebugEnabled()) { LOG.debug( - "Revoked STS token entry created at {} is older than 12 hours, will clean up. Current time: {}", - initialCreationTimeMillis, now); + "Revoked STS token cutoff at {} is older than {} ms, will clean up. Current time: {}", + revocationTimeMillis, CLEANUP_THRESHOLD, now); } return true; } @@ -230,11 +232,11 @@ private boolean shouldCleanup(long initialCreationTimeMillis) { } /** - * Builds and submits an OMRequest to delete the provided revoked STS token(s). + * Builds and submits an OMRequest to delete the provided originalAccessKeyId revocation entries. */ - private boolean submitCleanupRequest(List sessionTokens) { + private boolean submitCleanupRequest(List originalAccessKeyIds) { final DeleteRevokedSTSTokensRequest request = DeleteRevokedSTSTokensRequest.newBuilder() - .addAllSessionToken(sessionTokens) + .addAllOriginalAccessKeyId(originalAccessKeyIds) .build(); final OMRequest omRequest = OMRequest.newBuilder() @@ -254,9 +256,9 @@ private boolean submitCleanupRequest(List sessionTokens) { } } - private int getBatchSerializedSize(List sessionTokenBatch) { + private int getBatchSerializedSize(List originalAccessKeyIdBatch) { final DeleteRevokedSTSTokensRequest request = DeleteRevokedSTSTokensRequest.newBuilder() - .addAllSessionToken(sessionTokenBatch) + .addAllOriginalAccessKeyId(originalAccessKeyIdBatch) .build(); return request.getSerializedSize(); diff --git a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/security/S3SecurityUtil.java b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/security/S3SecurityUtil.java index 08ac1f2bee11..6612fff2bad8 100644 --- a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/security/S3SecurityUtil.java +++ b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/security/S3SecurityUtil.java @@ -75,8 +75,10 @@ public static void validateS3Credential(OMRequest omRequest, token, ozoneManager.getSecretKeyClient(), CLOCK); // Ensure the token is not revoked - if (isRevokedStsToken(token, ozoneManager)) { - LOG.info("Session token has been revoked: {}, {}", stsTokenIdentifier.getTempAccessKeyId(), token); + if (isRevokedStsToken(stsTokenIdentifier, ozoneManager)) { + LOG.info( + "STS token has been revoked for originalAccessKeyId={}, tempAccessKeyId={}", + stsTokenIdentifier.getOriginalAccessKeyId(), stsTokenIdentifier.getTempAccessKeyId()); throw new OMException("STS token has been revoked", REVOKED_TOKEN); } @@ -157,11 +159,12 @@ private static void validateSTSTokenAwsSignature(STSTokenIdentifier stsTokenIden } /** - * Returns true if the STS session token is present in the revoked STS token table. + * Returns true if the STS token was created before the revocation cutoff for its originalAccessKeyId. */ - private static boolean isRevokedStsToken(String sessionToken, OzoneManager ozoneManager) + private static boolean isRevokedStsToken(STSTokenIdentifier stsTokenIdentifier, OzoneManager ozoneManager) throws OMException { try { + final String originalAccessKeyId = stsTokenIdentifier.getOriginalAccessKeyId(); final OMMetadataManager metadataManager = ozoneManager.getMetadataManager(); if (metadataManager == null) { final String msg = "Could not determine STS revocation: metadataManager is null"; @@ -176,7 +179,9 @@ private static boolean isRevokedStsToken(String sessionToken, OzoneManager ozone throw new OMException(msg, INTERNAL_ERROR); } - return revokedStsTokenTable.getIfExist(sessionToken) != null; + final Long revocationTimeMillis = revokedStsTokenTable.getIfExist(originalAccessKeyId); + return revocationTimeMillis != null + && stsTokenIdentifier.getCreationTime().toEpochMilli() < revocationTimeMillis; } catch (Exception e) { final String msg = "Could not determine STS revocation because of Exception: " + e.getMessage(); LOG.warn(msg, e); diff --git a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/security/STSSecurityUtil.java b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/security/STSSecurityUtil.java index 2212ad6db797..ead735f12eac 100644 --- a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/security/STSSecurityUtil.java +++ b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/security/STSSecurityUtil.java @@ -101,7 +101,7 @@ private static STSTokenIdentifier verifyAndDecryptToken(Token decodeTokenFromString(String encodedToken) throws SecretManager.InvalidToken { final Token token = new Token<>(); + // token.decodeFromUrlString() only declares IOException, but deserialization can throw + // unchecked exceptions (e.g. NegativeArraySizeException) when malformed input decodes to a + // negative byte-array length. Map those to InvalidToken (via catching RuntimeException) + // instead of failing the OM request. try { token.decodeFromUrlString(encodedToken); return token; - } catch (IOException e) { + } catch (IOException | RuntimeException e) { throw new SecretManager.InvalidToken("Failed to decode STS token string: " + e); } } @@ -180,6 +184,9 @@ static void ensureEssentialFieldsArePresentInToken(STSTokenIdentifier stsTokenId if (StringUtils.isEmpty(stsTokenIdentifier.getSecretAccessKey())) { throw new SecretManager.InvalidToken("Invalid STS token - secretAccessKey is null/empty"); } + if (stsTokenIdentifier.getCreationTime() == null) { + throw new SecretManager.InvalidToken("Invalid STS token - creationTime is null"); + } } /** diff --git a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/security/STSTokenIdentifier.java b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/security/STSTokenIdentifier.java index 8c13aac51905..e5629073c5ad 100644 --- a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/security/STSTokenIdentifier.java +++ b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/security/STSTokenIdentifier.java @@ -17,6 +17,7 @@ package org.apache.hadoop.ozone.security; +import com.google.common.annotations.VisibleForTesting; import com.google.common.base.Preconditions; import java.io.ByteArrayInputStream; import java.io.DataInput; @@ -29,6 +30,7 @@ import java.util.UUID; import org.apache.hadoop.hdds.annotation.InterfaceAudience; import org.apache.hadoop.hdds.annotation.InterfaceStability; +import org.apache.hadoop.hdds.security.symmetric.ManagedSecretKey; import org.apache.hadoop.hdds.security.token.ShortLivedTokenIdentifier; import org.apache.hadoop.io.Text; import org.apache.hadoop.ozone.protocol.proto.OzoneManagerProtocolProtos.OMTokenProto; @@ -46,9 +48,11 @@ public class STSTokenIdentifier extends ShortLivedTokenIdentifier { private String originalAccessKeyId; private String secretAccessKey; private String sessionPolicy; + private Instant creationTime; - // Encryption key derived from ManagedSecretKey for this token - private transient byte[] encryptionKey; + // SCM secret key used for encrypting sensitive fields and signing this token + // It will NOT be encoded in the token. + private transient ManagedSecretKey managedSecretKey; // Service name for STS tokens public static final String STS_SERVICE = "STS"; @@ -63,23 +67,143 @@ public STSTokenIdentifier() { /** * Create a new STS token identifier with encryption support. * - * @param tempAccessKeyId the temporary access key ID (owner) - * @param originalAccessKeyId the original long-lived access key ID that created this token - * @param roleArn the ARN of the assumed role - * @param expiry the token expiration time - * @param secretAccessKey the secret access key associated with the temporary access key ID - * @param sessionPolicy an optional opaque identifier that further limits the scope of - * the permissions granted by the role - * @param encryptionKey the key bytes for encrypting sensitive fields + * @param params the STS token creation parameters */ - public STSTokenIdentifier(String tempAccessKeyId, String originalAccessKeyId, String roleArn, Instant expiry, - String secretAccessKey, String sessionPolicy, byte[] encryptionKey) { - super(tempAccessKeyId, expiry); - this.originalAccessKeyId = originalAccessKeyId; - this.roleArn = roleArn; - this.secretAccessKey = secretAccessKey; - this.sessionPolicy = sessionPolicy; - this.encryptionKey = encryptionKey != null ? encryptionKey.clone() : null; + public STSTokenIdentifier(Params params) { + super(params.getTempAccessKeyId(), params.getExpiry()); + this.originalAccessKeyId = params.getOriginalAccessKeyId(); + this.roleArn = params.getRoleArn(); + this.creationTime = params.getCreationTime(); + this.secretAccessKey = params.getSecretAccessKey(); + this.sessionPolicy = params.getSessionPolicy(); + this.managedSecretKey = params.getManagedSecretKey(); + // In the OzoneManagerStateMachine case, both secretAccessKey and managedSecretKey are set to null + if (this.secretAccessKey != null) { + if (this.managedSecretKey != null) { + setSecretKeyId(managedSecretKey.getId()); + } else { + throw new IllegalArgumentException("ManagedSecretKey is not set"); + } + } + } + + /** + * Parameters for constructing an {@link STSTokenIdentifier}. + */ + public static final class Params { + private final String tempAccessKeyId; + private final String originalAccessKeyId; + private final String roleArn; + private final Instant creationTime; + private final Instant expiry; + private final String secretAccessKey; + private final String sessionPolicy; + private final ManagedSecretKey managedSecretKey; + + private Params(Builder builder) { + this.tempAccessKeyId = builder.tempAccessKeyId; + this.originalAccessKeyId = builder.originalAccessKeyId; + this.roleArn = builder.roleArn; + this.creationTime = builder.creationTime; + this.expiry = builder.expiry; + this.secretAccessKey = builder.secretAccessKey; + this.sessionPolicy = builder.sessionPolicy; + this.managedSecretKey = builder.managedSecretKey; + } + + public static Builder newBuilder() { + return new Builder(); + } + + public String getTempAccessKeyId() { + return tempAccessKeyId; + } + + public String getOriginalAccessKeyId() { + return originalAccessKeyId; + } + + public String getRoleArn() { + return roleArn; + } + + public Instant getCreationTime() { + return creationTime; + } + + public Instant getExpiry() { + return expiry; + } + + public String getSecretAccessKey() { + return secretAccessKey; + } + + public String getSessionPolicy() { + return sessionPolicy; + } + + public ManagedSecretKey getManagedSecretKey() { + return managedSecretKey; + } + + /** + * Builder for {@link Params}. + */ + public static final class Builder { + private String tempAccessKeyId; + private String originalAccessKeyId; + private String roleArn; + private Instant creationTime; + private Instant expiry; + private String secretAccessKey; + private String sessionPolicy; + private ManagedSecretKey managedSecretKey; + + public Builder setTempAccessKeyId(String value) { + this.tempAccessKeyId = value; + return this; + } + + public Builder setOriginalAccessKeyId(String value) { + this.originalAccessKeyId = value; + return this; + } + + public Builder setRoleArn(String value) { + this.roleArn = value; + return this; + } + + public Builder setCreationTime(Instant value) { + this.creationTime = value; + return this; + } + + public Builder setExpiry(Instant value) { + this.expiry = value; + return this; + } + + public Builder setSecretAccessKey(String value) { + this.secretAccessKey = value; + return this; + } + + public Builder setSessionPolicy(String value) { + this.sessionPolicy = value; + return this; + } + + public Builder setManagedSecretKey(ManagedSecretKey value) { + this.managedSecretKey = value; + return this; + } + + public Params build() { + return new Params(this); + } + } } @Override @@ -112,23 +236,19 @@ public void readFields(DataInput in) throws IOException { /** * Convert this identifier to protobuf format. */ - public OMTokenProto toProtoBuf() { - Preconditions.checkArgument(this.encryptionKey != null, "The encryption key must not be null"); - - final OMTokenProto.Builder builder = OMTokenProto.newBuilder(); - // Note: secretKeyId must be set before attempting to decrypt secretAccessKey - if (getSecretKeyId() != null) { - builder.setSecretKeyId(getSecretKeyId().toString()); - } + public OMTokenProto toProtoBuf() throws IOException { + Preconditions.checkArgument(this.managedSecretKey != null, "The ManagedSecretKey must not be null"); - builder + final OMTokenProto.Builder builder = OMTokenProto.newBuilder() .setType(OMTokenProto.Type.S3_STS_TOKEN) + .setIssueDate(creationTime.toEpochMilli()) .setMaxDate(getExpiry().toEpochMilli()) .setOwner(getOwnerId() != null ? getOwnerId() : "") .setAccessKeyId(getOwnerId() != null ? getOwnerId() : "") .setOriginalAccessKeyId(originalAccessKeyId != null ? originalAccessKeyId : "") .setRoleArn(roleArn != null ? roleArn : "") .setSecretAccessKey(secretAccessKey != null ? encryptSensitiveField(secretAccessKey) : "") + .setSecretKeyId(managedSecretKey.getId().toString()) .setSessionPolicy(sessionPolicy != null ? sessionPolicy : ""); return builder.build(); @@ -141,11 +261,14 @@ public void fromProtoBuf(OMTokenProto token) throws IOException { Preconditions.checkArgument( token.getType() == OMTokenProto.Type.S3_STS_TOKEN, "Invalid token type for STSTokenIdentifier: " + token.getType()); - Preconditions.checkArgument(this.encryptionKey != null, "The encryption key must not be null"); + Preconditions.checkArgument(this.managedSecretKey != null, "The ManagedSecretKey must not be null"); setOwnerId(token.getOwner()); setExpiry(Instant.ofEpochMilli(token.getMaxDate())); + if (token.hasIssueDate()) { + this.creationTime = Instant.ofEpochMilli(token.getIssueDate()); + } if (token.hasOriginalAccessKeyId()) { this.originalAccessKeyId = token.getOriginalAccessKeyId(); } @@ -174,32 +297,24 @@ public void fromProtoBuf(OMTokenProto token) throws IOException { /** * Encrypt a sensitive field using the configured encryption key. */ - private String encryptSensitiveField(String value) { - if (encryptionKey == null) { - throw new IllegalStateException("Encryption key must be set before encrypting sensitive fields"); - } - + private String encryptSensitiveField(String value) throws IOException { try { final byte[] aad = computeAadBytes(); - return STSTokenEncryption.encrypt(value, encryptionKey, aad); + return STSTokenEncryption.encrypt(value, getSecretKeyBytes(), aad); } catch (STSTokenEncryption.STSTokenEncryptionException e) { - throw new RuntimeException("Token encryption failed", e); + throw new IOException("Token encryption failed", e); } } /** * Decrypt a sensitive field using the configured encryption key. */ - private String decryptSensitiveField(String encryptedValue) { - if (encryptionKey == null) { - throw new IllegalStateException("Encryption key must be set before decrypting sensitive fields"); - } - + private String decryptSensitiveField(String encryptedValue) throws IOException { try { final byte[] aad = computeAadBytes(); - return STSTokenEncryption.decrypt(encryptedValue, encryptionKey, aad); + return STSTokenEncryption.decrypt(encryptedValue, getSecretKeyBytes(), aad); } catch (STSTokenEncryption.STSTokenEncryptionException e) { - throw new RuntimeException("Token decryption failed", e); + throw new IOException("Token decryption failed", e); } } @@ -244,8 +359,43 @@ public String getSessionPolicy() { return sessionPolicy; } - public void setEncryptionKey(byte[] encryptionKey) { - this.encryptionKey = encryptionKey.clone(); + public Instant getCreationTime() { + return creationTime; + } + + // For test only + @VisibleForTesting + public void setManagedSecretKey(ManagedSecretKey secretKey) { + this.managedSecretKey = secretKey; + } + + public ManagedSecretKey getManagedSecretKey() { + return managedSecretKey; + } + + /** + * Sign serialized identifier bytes using the configured {@link ManagedSecretKey}. + */ + public byte[] sign(byte[] identifierBytes) { + Objects.requireNonNull(managedSecretKey, "ManagedSecretKey must be set before signing"); + return managedSecretKey.sign(identifierBytes); + } + + /** + * Verify a signature against serialized identifier bytes using the configured + * {@link ManagedSecretKey}. + */ + public boolean isValidSignature(byte[] identifierBytes, byte[] signature) { + Objects.requireNonNull(managedSecretKey, "ManagedSecretKey must be set before signature verification"); + return managedSecretKey.isValidSignature(identifierBytes, signature); + } + + private byte[] getSecretKeyBytes() { + if (managedSecretKey == null) { + throw new IllegalStateException("ManagedSecretKey is not set"); + } + + return managedSecretKey.getSecretKey().getEncoded(); } @Override @@ -265,13 +415,13 @@ public boolean equals(Object o) { final STSTokenIdentifier that = (STSTokenIdentifier) o; return Objects.equals(roleArn, that.roleArn) && Objects.equals(secretAccessKey, that.secretAccessKey) && Objects.equals(originalAccessKeyId, that.originalAccessKeyId) && - Objects.equals(sessionPolicy, that.sessionPolicy); + Objects.equals(sessionPolicy, that.sessionPolicy) && Objects.equals(creationTime, that.creationTime); } @Override public int hashCode() { return Objects.hash( - super.hashCode(), roleArn, secretAccessKey, originalAccessKeyId, sessionPolicy); + super.hashCode(), roleArn, secretAccessKey, originalAccessKeyId, sessionPolicy, creationTime); } @Override @@ -279,7 +429,7 @@ public String toString() { // Intentionally left off secretAccessKey return "STSTokenIdentifier{" + "tempAccessKeyId='" + getOwnerId() + "'" + ", originalAccessKeyId='" + originalAccessKeyId + "', roleArn='" + roleArn + "'" + - ", expiry='" + getExpiry() + "', secretKeyId='" + getSecretKeyId() + "'" + - ", sessionPolicy='" + sessionPolicy + "'}"; + ", creationTime='" + creationTime + "', expiry='" + getExpiry() + "', secretKeyId='" + getSecretKeyId() + + "', sessionPolicy='" + sessionPolicy + "'}"; } } diff --git a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/security/STSTokenSecretManager.java b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/security/STSTokenSecretManager.java index f72b1892de85..63c4d8121edf 100644 --- a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/security/STSTokenSecretManager.java +++ b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/security/STSTokenSecretManager.java @@ -20,9 +20,9 @@ import java.io.IOException; import java.time.Clock; import java.time.Instant; +import java.util.Objects; import org.apache.hadoop.hdds.annotation.InterfaceAudience; import org.apache.hadoop.hdds.annotation.InterfaceStability; -import org.apache.hadoop.hdds.security.symmetric.ManagedSecretKey; import org.apache.hadoop.hdds.security.symmetric.SecretKeySignerClient; import org.apache.hadoop.hdds.security.token.ShortLivedTokenSecretManager; import org.apache.hadoop.io.Text; @@ -63,10 +63,13 @@ public STSTokenSecretManager(SecretKeySignerClient secretKeyClient) { */ @Override public Token generateToken(STSTokenIdentifier tokenIdentifier) { - final ManagedSecretKey secretKey = secretKeyClient.getCurrentSecretKey(); - tokenIdentifier.setSecretKeyId(secretKey.getId()); + // Note - the ManagedSecretKey will NOT be encoded in the token. When generateToken() is called, + // it eventually calls the write() method in STSTokenIdentifier which calls toProtoBuf(), and the + // ManagedSecretKey is not serialized there. + Objects.requireNonNull( + tokenIdentifier.getManagedSecretKey(), "ManagedSecretKey must be set on the token identifier before signing"); final byte[] identifierBytes = tokenIdentifier.getBytes(); - final byte[] password = secretKey.sign(identifierBytes); + final byte[] password = tokenIdentifier.sign(identifierBytes); return new Token<>(identifierBytes, password, tokenIdentifier.getKind(), new Text(tokenIdentifier.getService())); } @@ -85,17 +88,19 @@ public Token generateToken(STSTokenIdentifier tokenIdentifie */ public String createSTSTokenString(String tempAccessKeyId, String originalAccessKeyId, String roleArn, int durationSeconds, String secretAccessKey, String sessionPolicy, Clock clock) throws IOException { - final Instant expiration = clock.instant().plusSeconds(durationSeconds); + final Instant creationTime = clock.instant(); + final Instant expiration = creationTime.plusSeconds(durationSeconds); - // Get the current secret key for encryption - final ManagedSecretKey currentSecretKey = secretKeyClient.getCurrentSecretKey(); - final byte[] encryptionKey = currentSecretKey.getSecretKey().getEncoded(); - - // Note - the encryptionKey will NOT be encoded in the token. When generateToken() is called, it eventually calls - // the write() method in STSTokenIdentifier which calls toProtoBuf(), and the encryptionKey is not - // serialized there. - final STSTokenIdentifier identifier = new STSTokenIdentifier( - tempAccessKeyId, originalAccessKeyId, roleArn, expiration, secretAccessKey, sessionPolicy, encryptionKey); + final STSTokenIdentifier identifier = new STSTokenIdentifier(STSTokenIdentifier.Params.newBuilder() + .setTempAccessKeyId(tempAccessKeyId) + .setOriginalAccessKeyId(originalAccessKeyId) + .setRoleArn(roleArn) + .setCreationTime(creationTime) + .setExpiry(expiration) + .setSecretAccessKey(secretAccessKey) + .setSessionPolicy(sessionPolicy) + .setManagedSecretKey(secretKeyClient.getCurrentSecretKey()) + .build()); final Token token = generateToken(identifier); return token.encodeToUrlString(); diff --git a/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/om/TestOmMetadataManager.java b/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/om/TestOmMetadataManager.java index d0d11c5ba94f..c284b460dd68 100644 --- a/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/om/TestOmMetadataManager.java +++ b/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/om/TestOmMetadataManager.java @@ -1535,9 +1535,9 @@ public void testS3RevokedStsTokenTablePutAndGet() throws Exception { assertNotNull(omMetadataManager.getS3RevokedStsTokenTable(), "s3RevokedStsTokenTable should be initialized"); final MockClock clock = MockClock.newInstance(); - final String sessionToken1 = "test-session-token-1"; + final String originalAccessKeyId1 = "orig-1"; final long insertionTime1 = clock.millis(); - final String sessionToken2 = "test-session-token-2"; + final String originalAccessKeyId2 = "orig-2"; final long insertionTime2 = insertionTime1 + 1234L; // This table is configured as FULL_CACHE in OmMetadataManagerImpl. @@ -1546,25 +1546,25 @@ public void testS3RevokedStsTokenTablePutAndGet() throws Exception { final TypedTable revokedTable = (TypedTable) omMetadataManager.getS3RevokedStsTokenTable(); - revokedTable.put(sessionToken1, insertionTime1); - revokedTable.put(sessionToken2, insertionTime2); + revokedTable.put(originalAccessKeyId1, insertionTime1); + revokedTable.put(originalAccessKeyId2, insertionTime2); // Verify the values are persisted in RocksDB. - assertEquals(insertionTime1, revokedTable.getSkipCache(sessionToken1)); - assertEquals(insertionTime2, revokedTable.getSkipCache(sessionToken2)); + assertEquals(insertionTime1, revokedTable.getSkipCache(originalAccessKeyId1)); + assertEquals(insertionTime2, revokedTable.getSkipCache(originalAccessKeyId2)); // Update cache to make get/getIfExist reflect the write for FULL_CACHE tables. - revokedTable.addCacheEntry(sessionToken1, insertionTime1, 1L); - revokedTable.addCacheEntry(sessionToken2, insertionTime2, 1L); + revokedTable.addCacheEntry(originalAccessKeyId1, insertionTime1, 1L); + revokedTable.addCacheEntry(originalAccessKeyId2, insertionTime2, 1L); // Verify get and getIfExist return the stored value - assertEquals(insertionTime1, revokedTable.get(sessionToken1)); - assertEquals(insertionTime1, revokedTable.getIfExist(sessionToken1)); - assertEquals(insertionTime2, revokedTable.get(sessionToken2)); - assertEquals(insertionTime2, revokedTable.getIfExist(sessionToken2)); + assertEquals(insertionTime1, revokedTable.get(originalAccessKeyId1)); + assertEquals(insertionTime1, revokedTable.getIfExist(originalAccessKeyId1)); + assertEquals(insertionTime2, revokedTable.get(originalAccessKeyId2)); + assertEquals(insertionTime2, revokedTable.getIfExist(originalAccessKeyId2)); - // Invalid sessionToken should return null for getIfExist - assertNull(revokedTable.getIfExist("INVALID_SESSION_TOKEN")); + // Invalid originalAccessKeyId should return null for getIfExist. + assertNull(revokedTable.getIfExist("INVALID_ORIGINAL_ACCESS_KEY_ID")); } @Test diff --git a/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/om/request/s3/security/TestS3RevokeSTSTokenRequest.java b/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/om/request/s3/security/TestS3RevokeSTSTokenRequest.java index 0f3d2519b30c..1b6caed9cb40 100644 --- a/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/om/request/s3/security/TestS3RevokeSTSTokenRequest.java +++ b/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/om/request/s3/security/TestS3RevokeSTSTokenRequest.java @@ -19,7 +19,9 @@ import static org.apache.hadoop.security.authentication.util.KerberosName.DEFAULT_MECHANISM; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.eq; import static org.mockito.Mockito.mock; @@ -29,16 +31,16 @@ import java.io.IOException; import java.util.Optional; import java.util.UUID; -import org.apache.hadoop.hdds.security.symmetric.SecretKeyClient; import org.apache.hadoop.hdds.utils.db.Table; import org.apache.hadoop.hdds.utils.db.cache.CacheKey; -import org.apache.hadoop.hdds.utils.db.cache.CacheValue; import org.apache.hadoop.ipc_.ExternalCall; import org.apache.hadoop.ipc_.Server; +import org.apache.hadoop.ozone.OzoneConsts; import org.apache.hadoop.ozone.audit.AuditLogger; import org.apache.hadoop.ozone.om.OMMetadataManager; import org.apache.hadoop.ozone.om.OMMultiTenantManager; import org.apache.hadoop.ozone.om.OzoneManager; +import org.apache.hadoop.ozone.om.S3SecretManager; 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; @@ -46,11 +48,8 @@ import org.apache.hadoop.ozone.protocol.proto.OzoneManagerProtocolProtos; import org.apache.hadoop.ozone.protocol.proto.OzoneManagerProtocolProtos.OMRequest; import org.apache.hadoop.ozone.protocol.proto.OzoneManagerProtocolProtos.Type; -import org.apache.hadoop.ozone.security.STSTokenSecretManager; -import org.apache.hadoop.ozone.security.SecretKeyTestClient; import org.apache.hadoop.security.UserGroupInformation; import org.apache.hadoop.security.authentication.util.KerberosName; -import org.apache.ozone.test.MockClock; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -60,22 +59,22 @@ */ public class TestS3RevokeSTSTokenRequest { - private static final MockClock CLOCK = MockClock.newInstance(); + private static final String TEST_KERBEROS_RULES = + "RULE:[2:$1@$0](.*@EXAMPLE.COM)s/@.*//\n" + "RULE:[1:$1@$0](.*@EXAMPLE.COM)s/@.*//\n" + "DEFAULT"; - private STSTokenSecretManager stsTokenSecretManager; - private SecretKeyClient secretKeyClient; private OMMultiTenantManager omMultiTenantManager; + private String kerberosMechanismBeforeTest; + private String kerberosRulesBeforeTest; @BeforeEach public void setUp() throws Exception { + kerberosMechanismBeforeTest = KerberosName.getRuleMechanism(); + kerberosRulesBeforeTest = KerberosName.getRules(); + KerberosName.setRuleMechanism(DEFAULT_MECHANISM); // Initialize KerberosName rules so that UGI short names derived from // principals like "alice@EXAMPLE.COM" are computed correctly. - KerberosName.setRuleMechanism(DEFAULT_MECHANISM); - KerberosName.setRules( - "RULE:[2:$1@$0](.*@EXAMPLE.COM)s/@.*//\n" + "RULE:[1:$1@$0](.*@EXAMPLE.COM)s/@.*//\n" + "DEFAULT"); + KerberosName.setRules(TEST_KERBEROS_RULES); - secretKeyClient = new SecretKeyTestClient(); - stsTokenSecretManager = new STSTokenSecretManager(secretKeyClient); // Multi-tenant manager mock used for tests that exercise the S3 multi-tenancy permission branch. omMultiTenantManager = mock(OMMultiTenantManager.class); } @@ -83,15 +82,15 @@ public void setUp() throws Exception { @AfterEach public void tearDown() { Server.getCurCall().remove(); + KerberosName.setRuleMechanism(kerberosMechanismBeforeTest); + KerberosName.setRules(kerberosRulesBeforeTest); } @Test public void testPreExecuteFailsForNonOwnerOfOriginalAccessKey() throws Exception { - // Verify that preExecute enforces permissions based on the original access key id encoded in the STS token + // Verify that preExecute enforces permissions based on the request's original access key ID // and rejects revocation attempts from non-owners. - final String tempAccessKeyId = "ASIA12345678"; final String originalAccessKeyId = "original-access-key-id"; - final String sessionToken = createSessionToken(tempAccessKeyId, originalAccessKeyId); // An RPC call running another Kerberos identity should NOT be allowed to revoke the token whose original // access key id is different. @@ -100,24 +99,10 @@ public void testPreExecuteFailsForNonOwnerOfOriginalAccessKey() throws Exception OMException ex; try (OzoneManager ozoneManager = mock(OzoneManager.class)) { - when(ozoneManager.isS3MultiTenancyEnabled()).thenReturn(false); - when(ozoneManager.isS3Admin(any(UserGroupInformation.class))) - .thenReturn(false); - when(ozoneManager.getSecretKeyClient()).thenReturn(secretKeyClient); - - final OzoneManagerProtocolProtos.RevokeSTSTokenRequest revokeRequest = - OzoneManagerProtocolProtos.RevokeSTSTokenRequest.newBuilder() - .setSessionToken(sessionToken) - .build(); - - final OMRequest omRequest = OMRequest.newBuilder() - .setClientId(UUID.randomUUID().toString()) - .setCmdType(Type.RevokeSTSToken) - .setRevokeSTSTokenRequest(revokeRequest) - .build(); - - final OMClientRequest omClientRequest = new S3RevokeSTSTokenRequest(omRequest); + configureOzoneManagerForPreExecute(ozoneManager, originalAccessKeyId, true); + when(ozoneManager.isS3Admin(any(UserGroupInformation.class))).thenReturn(false); + final OMClientRequest omClientRequest = new S3RevokeSTSTokenRequest(buildRevokeOmRequest(originalAccessKeyId)); ex = assertThrows(OMException.class, () -> omClientRequest.preExecute(ozoneManager)); } assertEquals(OMException.ResultCodes.USER_MISMATCH, ex.getResult()); @@ -125,36 +110,25 @@ public void testPreExecuteFailsForNonOwnerOfOriginalAccessKey() throws Exception @Test public void testPreExecuteSucceedsForOriginalAccessKeyOwner() throws Exception { - // Verify that preExecute allows the owner of the original access key id (as encoded in the STS token) + // Verify that preExecute allows the owner of the original access key ID from the revoke request // to revoke the temporary credentials. - final String tempAccessKeyId = "ASIA4567891230"; final String originalAccessKeyId = "original-access-key-id"; - final String sessionToken = createSessionToken(tempAccessKeyId, originalAccessKeyId); // Simulate RPC call running as originalAccessKeyId final UserGroupInformation originalUgi = UserGroupInformation.createRemoteUser(originalAccessKeyId); Server.getCurCall().set(new StubCall(originalUgi)); final OzoneManager ozoneManager = mock(OzoneManager.class); - when(ozoneManager.isS3MultiTenancyEnabled()).thenReturn(false); - when(ozoneManager.isS3Admin(any(UserGroupInformation.class))) - .thenReturn(false); - when(ozoneManager.getSecretKeyClient()).thenReturn(secretKeyClient); - - final OzoneManagerProtocolProtos.RevokeSTSTokenRequest revokeRequest = - OzoneManagerProtocolProtos.RevokeSTSTokenRequest.newBuilder() - .setSessionToken(sessionToken) - .build(); - - final OMRequest omRequest = OMRequest.newBuilder() - .setClientId(UUID.randomUUID().toString()) - .setCmdType(Type.RevokeSTSToken) - .setRevokeSTSTokenRequest(revokeRequest) - .build(); + configureOzoneManagerForPreExecute(ozoneManager, originalAccessKeyId, true); + when(ozoneManager.isS3Admin(any(UserGroupInformation.class))).thenReturn(false); - final OMClientRequest omClientRequest = new S3RevokeSTSTokenRequest(omRequest); + final OMClientRequest omClientRequest = new S3RevokeSTSTokenRequest(buildRevokeOmRequest(originalAccessKeyId)); final OMRequest result = omClientRequest.preExecute(ozoneManager); + assertEquals(Type.RevokeSTSToken, result.getCmdType()); + assertTrue(result.getRevokeSTSTokenRequest().hasRevocationTimeMillis()); + assertEquals(originalAccessKeyId, result.getRevokeSTSTokenRequest().getOriginalAccessKeyId()); + assertTrue(result.getRevokeSTSTokenRequest().getRevocationTimeMillis() > 0L); } @Test @@ -163,40 +137,23 @@ public void testPreExecuteSucceedsForTenantAccessIdOwner() throws Exception { // the tenant access ID owner is allowed to revoke the temporary credentials. final String tenantId = "finance"; final String originalAccessKeyId = "alice@EXAMPLE.COM"; - final String tempAccessKeyId = "ASIA123456789"; - final String sessionToken = createSessionToken(tempAccessKeyId, originalAccessKeyId); // Caller short name "alice" should match the owner username returned from the multi-tenant manager. final UserGroupInformation callerUgi = UserGroupInformation.createRemoteUser(originalAccessKeyId); Server.getCurCall().set(new StubCall(callerUgi)); final OzoneManager ozoneManager = mock(OzoneManager.class); + configureOzoneManagerForPreExecute(ozoneManager, originalAccessKeyId, true); when(ozoneManager.isS3MultiTenancyEnabled()).thenReturn(true); when(ozoneManager.getMultiTenantManager()).thenReturn(omMultiTenantManager); - when(ozoneManager.getSecretKeyClient()).thenReturn(secretKeyClient); // Original access key id is assigned to a tenant and owned by "alice". - when(omMultiTenantManager.getTenantForAccessID(originalAccessKeyId)) - .thenReturn(Optional.of(tenantId)); - when(omMultiTenantManager.getUserNameGivenAccessId(originalAccessKeyId)) - .thenReturn("alice"); + when(omMultiTenantManager.getTenantForAccessID(originalAccessKeyId)).thenReturn(Optional.of(tenantId)); + when(omMultiTenantManager.getUserNameGivenAccessId(originalAccessKeyId)).thenReturn("alice"); // Not a tenant admin; ownership should be sufficient. - when(omMultiTenantManager.isTenantAdmin(callerUgi, tenantId, false)) - .thenReturn(false); - - final OzoneManagerProtocolProtos.RevokeSTSTokenRequest revokeRequest = - OzoneManagerProtocolProtos.RevokeSTSTokenRequest.newBuilder() - .setSessionToken(sessionToken) - .build(); - - final OMRequest omRequest = OMRequest.newBuilder() - .setClientId(UUID.randomUUID().toString()) - .setCmdType(Type.RevokeSTSToken) - .setRevokeSTSTokenRequest(revokeRequest) - .build(); - - final OMClientRequest omClientRequest = new S3RevokeSTSTokenRequest(omRequest); + when(omMultiTenantManager.isTenantAdmin(callerUgi, tenantId, false)).thenReturn(false); + final OMClientRequest omClientRequest = new S3RevokeSTSTokenRequest(buildRevokeOmRequest(originalAccessKeyId)); final OMRequest result = omClientRequest.preExecute(ozoneManager); assertEquals(Type.RevokeSTSToken, result.getCmdType()); } @@ -207,40 +164,23 @@ public void testPreExecuteSucceedsForTenantAdmin() throws Exception { // tenant admin (who is not the owner) is allowed to revoke the temporary credentials. final String tenantId = "finance"; final String originalAccessKeyId = "alice@EXAMPLE.COM"; - final String tempAccessKeyId = "ASIA4567890123"; - final String sessionToken = createSessionToken(tempAccessKeyId, originalAccessKeyId); // Caller short name "bob" does not own the access ID but will be configured as tenant admin. final UserGroupInformation callerUgi = UserGroupInformation.createRemoteUser("bob@EXAMPLE.COM"); Server.getCurCall().set(new StubCall(callerUgi)); final OzoneManager ozoneManager = mock(OzoneManager.class); + configureOzoneManagerForPreExecute(ozoneManager, originalAccessKeyId, true); when(ozoneManager.isS3MultiTenancyEnabled()).thenReturn(true); when(ozoneManager.getMultiTenantManager()).thenReturn(omMultiTenantManager); - when(ozoneManager.getSecretKeyClient()).thenReturn(secretKeyClient); // Original access key id is assigned to a tenant and owned by "alice". - when(omMultiTenantManager.getTenantForAccessID(originalAccessKeyId)) - .thenReturn(Optional.of(tenantId)); - when(omMultiTenantManager.getUserNameGivenAccessId(originalAccessKeyId)) - .thenReturn("alice"); + when(omMultiTenantManager.getTenantForAccessID(originalAccessKeyId)).thenReturn(Optional.of(tenantId)); + when(omMultiTenantManager.getUserNameGivenAccessId(originalAccessKeyId)).thenReturn("alice"); // Caller is configured as tenant admin so the check should pass. - when(omMultiTenantManager.isTenantAdmin(callerUgi, tenantId, false)) - .thenReturn(true); - - final OzoneManagerProtocolProtos.RevokeSTSTokenRequest revokeRequest = - OzoneManagerProtocolProtos.RevokeSTSTokenRequest.newBuilder() - .setSessionToken(sessionToken) - .build(); - - final OMRequest omRequest = OMRequest.newBuilder() - .setClientId(UUID.randomUUID().toString()) - .setCmdType(Type.RevokeSTSToken) - .setRevokeSTSTokenRequest(revokeRequest) - .build(); - - final OMClientRequest omClientRequest = new S3RevokeSTSTokenRequest(omRequest); + when(omMultiTenantManager.isTenantAdmin(callerUgi, tenantId, false)).thenReturn(true); + final OMClientRequest omClientRequest = new S3RevokeSTSTokenRequest(buildRevokeOmRequest(originalAccessKeyId)); final OMRequest result = omClientRequest.preExecute(ozoneManager); assertEquals(Type.RevokeSTSToken, result.getCmdType()); } @@ -251,8 +191,6 @@ public void testPreExecuteFailsForNonOwnerNonAdminInTenant() throws Exception { // non-owner, non-admin caller is rejected. final String tenantId = "finance"; final String originalAccessKeyId = "alice@EXAMPLE.COM"; - final String tempAccessKeyId = "ASIA123456789"; - final String sessionToken = createSessionToken(tempAccessKeyId, originalAccessKeyId); // Caller short name "carol" does not own the access ID and is not // configured as tenant admin. @@ -261,42 +199,65 @@ public void testPreExecuteFailsForNonOwnerNonAdminInTenant() throws Exception { final OMException ex; try (OzoneManager ozoneManager = mock(OzoneManager.class)) { + configureOzoneManagerForPreExecute(ozoneManager, originalAccessKeyId, true); when(ozoneManager.isS3MultiTenancyEnabled()).thenReturn(true); when(ozoneManager.getMultiTenantManager()).thenReturn(omMultiTenantManager); - when(ozoneManager.getSecretKeyClient()).thenReturn(secretKeyClient); - // Original access key id is assigned to a tenant and owned by "alice". - when(omMultiTenantManager.getTenantForAccessID(originalAccessKeyId)) - .thenReturn(Optional.of(tenantId)); - when(omMultiTenantManager.getUserNameGivenAccessId(originalAccessKeyId)) - .thenReturn("alice"); + when(omMultiTenantManager.getTenantForAccessID(originalAccessKeyId)).thenReturn(Optional.of(tenantId)); + when(omMultiTenantManager.getUserNameGivenAccessId(originalAccessKeyId)).thenReturn("alice"); // Caller is not a tenant admin. - when(omMultiTenantManager.isTenantAdmin(callerUgi, tenantId, false)) - .thenReturn(false); + when(omMultiTenantManager.isTenantAdmin(callerUgi, tenantId, false)).thenReturn(false); + + final OMClientRequest omClientRequest = new S3RevokeSTSTokenRequest(buildRevokeOmRequest(originalAccessKeyId)); + ex = assertThrows(OMException.class, () -> omClientRequest.preExecute(ozoneManager)); + } + assertEquals(OMException.ResultCodes.USER_MISMATCH, ex.getResult()); + } - final OzoneManagerProtocolProtos.RevokeSTSTokenRequest revokeRequest = - OzoneManagerProtocolProtos.RevokeSTSTokenRequest.newBuilder() - .setSessionToken(sessionToken) - .build(); + @Test + public void testPreExecuteRejectsUnknownOriginalAccessKeyId() throws Exception { + // Reject revocation when originalAccessKeyId has no S3 secret in RocksDB. + final String originalAccessKeyId = "unknown-access-key-id"; + final UserGroupInformation callerUgi = UserGroupInformation.createRemoteUser(originalAccessKeyId); + Server.getCurCall().set(new StubCall(callerUgi)); - final OMRequest omRequest = OMRequest.newBuilder() - .setClientId(UUID.randomUUID().toString()) - .setCmdType(Type.RevokeSTSToken) - .setRevokeSTSTokenRequest(revokeRequest) - .build(); + try (OzoneManager ozoneManager = mock(OzoneManager.class)) { + final S3SecretManager s3SecretManager = configureOzoneManagerForPreExecute( + ozoneManager, originalAccessKeyId, false); + final OMClientRequest omClientRequest = new S3RevokeSTSTokenRequest(buildRevokeOmRequest(originalAccessKeyId)); + final OMException ex = assertThrows(OMException.class, () -> omClientRequest.preExecute(ozoneManager)); + assertEquals(OMException.ResultCodes.ACCESS_ID_NOT_FOUND, ex.getResult()); + assertTrue(ex.getMessage().contains("does not exist")); + assertTrue(ex.getMessage().contains(originalAccessKeyId)); + verify(s3SecretManager).hasS3Secret(originalAccessKeyId); + } + } - final OMClientRequest omClientRequest = new S3RevokeSTSTokenRequest(omRequest); + @Test + public void testPreExecuteRejectsUnknownOriginalAccessKeyIdForS3Admin() throws Exception { + // S3 admins may revoke other principals' tokens, but not for unknown access key IDs. + final String originalAccessKeyId = "unknown-access-key-id"; + final UserGroupInformation adminUgi = UserGroupInformation.createRemoteUser("om-admin"); + Server.getCurCall().set(new StubCall(adminUgi)); - ex = assertThrows(OMException.class, () -> omClientRequest.preExecute(ozoneManager)); + try (OzoneManager ozoneManager = mock(OzoneManager.class)) { + final S3SecretManager s3SecretManager = configureOzoneManagerForPreExecute( + ozoneManager, originalAccessKeyId, false); + when(ozoneManager.isS3Admin(adminUgi)).thenReturn(true); + + final OMClientRequest omClientRequest = new S3RevokeSTSTokenRequest(buildRevokeOmRequest(originalAccessKeyId)); + final OMException ex = assertThrows(OMException.class, () -> omClientRequest.preExecute(ozoneManager)); + assertEquals(OMException.ResultCodes.ACCESS_ID_NOT_FOUND, ex.getResult()); + assertTrue(ex.getMessage().contains("does not exist")); + assertTrue(ex.getMessage().contains(originalAccessKeyId)); + verify(s3SecretManager).hasS3Secret(originalAccessKeyId); } - assertEquals(OMException.ResultCodes.USER_MISMATCH, ex.getResult()); } @Test - public void testValidateAndUpdateCacheUpdatesCacheImmediately() throws Exception { - final String tempAccessKeyId = "ASIA4567891230"; + public void testValidateAndUpdateCacheUpdatesCacheImmediately() { final String originalAccessKeyId = "original-access-key-id"; - final String sessionToken = createSessionToken(tempAccessKeyId, originalAccessKeyId); + final long revocationTimeMillis = 1_700_000_000_000L; final OzoneManager ozoneManager = mock(OzoneManager.class); final OMMetadataManager omMetadataManager = mock(OMMetadataManager.class); @@ -311,7 +272,8 @@ public void testValidateAndUpdateCacheUpdatesCacheImmediately() throws Exception final OzoneManagerProtocolProtos.RevokeSTSTokenRequest revokeRequest = OzoneManagerProtocolProtos.RevokeSTSTokenRequest.newBuilder() - .setSessionToken(sessionToken) + .setOriginalAccessKeyId(originalAccessKeyId) + .setRevocationTimeMillis(revocationTimeMillis) .build(); final OMRequest omRequest = OMRequest.newBuilder() @@ -324,12 +286,91 @@ public void testValidateAndUpdateCacheUpdatesCacheImmediately() throws Exception final OMClientResponse omClientResponse = s3RevokeSTSTokenRequest.validateAndUpdateCache(ozoneManager, context); assertEquals(OzoneManagerProtocolProtos.Status.OK, omClientResponse.getOMResponse().getStatus()); - verify(s3RevokedStsTokenTable).addCacheEntry(eq(new CacheKey<>(sessionToken)), any(CacheValue.class)); + verify(s3RevokedStsTokenTable).addCacheEntry( + eq(new CacheKey<>(originalAccessKeyId)), any()); + assertNotNull(s3RevokeSTSTokenRequest.getAuditBuilder().getAuditMap()); + assertEquals( + originalAccessKeyId, s3RevokeSTSTokenRequest.getAuditBuilder().getAuditMap().get( + OzoneConsts.S3_REVOKESTSTOKEN_USER)); + } + + @Test + public void testValidateAndUpdateCacheRejectsMissingRevocationTimeMillis() { + final String originalAccessKeyId = "original-access-key-id"; + + final OzoneManager ozoneManager = mock(OzoneManager.class); + final OMMetadataManager omMetadataManager = mock(OMMetadataManager.class); + @SuppressWarnings("unchecked") + final Table s3RevokedStsTokenTable = mock(Table.class); + final ExecutionContext context = mock(ExecutionContext.class); + + when(ozoneManager.getMetadataManager()).thenReturn(omMetadataManager); + when(omMetadataManager.getS3RevokedStsTokenTable()).thenReturn(s3RevokedStsTokenTable); + + final OzoneManagerProtocolProtos.RevokeSTSTokenRequest revokeRequest = + OzoneManagerProtocolProtos.RevokeSTSTokenRequest.newBuilder() + .setOriginalAccessKeyId(originalAccessKeyId) + .build(); + + final OMRequest omRequest = OMRequest.newBuilder() + .setClientId(UUID.randomUUID().toString()) + .setCmdType(Type.RevokeSTSToken) + .setRevokeSTSTokenRequest(revokeRequest) + .build(); + + final S3RevokeSTSTokenRequest s3RevokeSTSTokenRequest = new S3RevokeSTSTokenRequest(omRequest); + final OMClientResponse omClientResponse = + s3RevokeSTSTokenRequest.validateAndUpdateCache(ozoneManager, context); + assertEquals(OzoneManagerProtocolProtos.Status.INTERNAL_ERROR, omClientResponse.getOMResponse().getStatus()); + } + + @Test + public void testPreExecuteRejectsClientSuppliedRevocationTimeMillis() throws Exception { + final String originalAccessKeyId = "original-access-key-id"; + final UserGroupInformation callerUgi = UserGroupInformation.createRemoteUser(originalAccessKeyId); + Server.getCurCall().set(new StubCall(callerUgi)); + + final OzoneManagerProtocolProtos.RevokeSTSTokenRequest revokeRequest = + OzoneManagerProtocolProtos.RevokeSTSTokenRequest.newBuilder() + .setOriginalAccessKeyId(originalAccessKeyId) + .setRevocationTimeMillis(1_700_000_000_000L) + .build(); + final OMRequest omRequest = OMRequest.newBuilder() + .setClientId(UUID.randomUUID().toString()) + .setCmdType(Type.RevokeSTSToken) + .setRevokeSTSTokenRequest(revokeRequest) + .build(); + + try (OzoneManager ozoneManager = mock(OzoneManager.class)) { + configureOzoneManagerForPreExecute(ozoneManager, originalAccessKeyId, true); + final OMClientRequest omClientRequest = new S3RevokeSTSTokenRequest(omRequest); + final OMException ex = assertThrows(OMException.class, () -> omClientRequest.preExecute(ozoneManager)); + assertEquals(OMException.ResultCodes.INVALID_REQUEST, ex.getResult()); + } + } + + private static OMRequest buildRevokeOmRequest(String originalAccessKeyId) { + final OzoneManagerProtocolProtos.RevokeSTSTokenRequest revokeRequest = + OzoneManagerProtocolProtos.RevokeSTSTokenRequest.newBuilder() + .setOriginalAccessKeyId(originalAccessKeyId) + .build(); + + return OMRequest.newBuilder() + .setClientId(UUID.randomUUID().toString()) + .setCmdType(Type.RevokeSTSToken) + .setRevokeSTSTokenRequest(revokeRequest) + .build(); + } + + private static S3SecretManager configureOzoneManagerForPreExecute(OzoneManager ozoneManager, + String originalAccessKeyId, boolean hasSecret) throws IOException { + when(ozoneManager.isS3MultiTenancyEnabled()).thenReturn(false); + final S3SecretManager s3SecretManager = mock(S3SecretManager.class); + when(ozoneManager.getS3SecretManager()).thenReturn(s3SecretManager); + when(s3SecretManager.hasS3Secret(originalAccessKeyId)).thenReturn(hasSecret); + return s3SecretManager; } - /** - * Stub used to inject a remote user into the ProtobufRpcEngine.Server.getRemoteUser() thread-local. - */ private static final class StubCall extends ExternalCall { private final UserGroupInformation ugi; @@ -343,10 +384,4 @@ public UserGroupInformation getRemoteUser() { return ugi; } } - - private String createSessionToken(String tempAccessKeyId, String originalAccessKeyId) throws IOException { - return stsTokenSecretManager.createSTSTokenString( - tempAccessKeyId, originalAccessKeyId, "arn:aws:iam::123456789012:role/test-role", 3600, - "test-secret-access-key", "test-session-policy", CLOCK); - } } diff --git a/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/om/service/TestRevokedSTSTokenCleanupService.java b/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/om/service/TestRevokedSTSTokenCleanupService.java index d7cf3630b955..2b734cea2456 100644 --- a/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/om/service/TestRevokedSTSTokenCleanupService.java +++ b/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/om/service/TestRevokedSTSTokenCleanupService.java @@ -75,13 +75,13 @@ public void setUp() { @Test public void submitsCleanupRequestForOnlyExpiredTokens() throws Exception { - // If there are two revoked entries, one expired and one not expired, only the expired session token should be - // submitted for cleanup. + // If there are two revoked entries, one expired and one not expired, only the expired + // originalAccessKeyId should be submitted for cleanup. final long nowMillis = testClock.millis(); final long expiredCreationTimeMillis = nowMillis - TimeUnit.HOURS.toMillis(13); // older than 12h threshold final long validCreationTimeMillis = nowMillis - TimeUnit.HOURS.toMillis(1); - revokedStsTokenTable.put("session-token-a", expiredCreationTimeMillis); - revokedStsTokenTable.put("session-token-b", validCreationTimeMillis); + revokedStsTokenTable.put("original-access-key-a", expiredCreationTimeMillis); + revokedStsTokenTable.put("original-access-key-b", validCreationTimeMillis); final AtomicReference capturedRequest = new AtomicReference<>(); @@ -100,7 +100,7 @@ public void submitsCleanupRequestForOnlyExpiredTokens() throws Exception { final DeleteRevokedSTSTokensRequest deleteRevokedSTSTokensRequest = omRequest.getDeleteRevokedSTSTokensRequest(); - assertThat(deleteRevokedSTSTokensRequest.getSessionTokenList()).containsExactly("session-token-a"); + assertThat(deleteRevokedSTSTokensRequest.getOriginalAccessKeyIdList()).containsExactly("original-access-key-a"); } } @@ -109,8 +109,8 @@ public void doesNotSubmitRequestWhenThereAreNoExpiredTokens() throws Exception { // If only non-expired entries exist in the revoked sts token table, no cleanup request should be submitted and // no metrics should be updated. final long nowMillis = testClock.millis(); - revokedStsTokenTable.put("session-token-c", nowMillis - TimeUnit.HOURS.toMillis(1)); - revokedStsTokenTable.put("session-token-d", nowMillis - TimeUnit.HOURS.toMillis(2)); + revokedStsTokenTable.put("original-access-key-c", nowMillis - TimeUnit.HOURS.toMillis(1)); + revokedStsTokenTable.put("original-access-key-d", nowMillis - TimeUnit.HOURS.toMillis(2)); final AtomicReference capturedRequest = new AtomicReference<>(); @@ -149,8 +149,8 @@ public void doesNotUpdateMetricsOnRatisSubmissionServiceExceptionFailure() throw // If there are expired tokens in the table but the OM request submission to clean up the entries fails with a // service exception, the metrics should not be updated final long nowMillis = testClock.millis(); - revokedStsTokenTable.put("session-token-e", nowMillis - TimeUnit.HOURS.toMillis(13)); - revokedStsTokenTable.put("session-token-f", nowMillis - TimeUnit.HOURS.toMillis(14)); + revokedStsTokenTable.put("original-access-key-e", nowMillis - TimeUnit.HOURS.toMillis(13)); + revokedStsTokenTable.put("original-access-key-f", nowMillis - TimeUnit.HOURS.toMillis(14)); final AtomicInteger submitAttempts = new AtomicInteger(0); @@ -172,7 +172,7 @@ public void doesNotUpdateMetricsOnNonSuccessfulResponse() throws Exception { // If there is an expired token in the table but the OM request submission to clean up the entries gets a // non-successful response, the metrics should not be updated final long nowMillis = testClock.millis(); - revokedStsTokenTable.put("session-token-f", nowMillis - TimeUnit.HOURS.toMillis(20)); + revokedStsTokenTable.put("original-access-key-f", nowMillis - TimeUnit.HOURS.toMillis(20)); try (MockedStatic ozoneManagerRatisUtilsMock = mockStatic(OzoneManagerRatisUtils.class)) { // Return a non-successful response @@ -190,9 +190,9 @@ public void doesNotUpdateMetricsOnNonSuccessfulResponse() throws Exception { public void handlesAllExpiredTokens() throws Exception { // If all the tokens in the table are expired on a particular run, ensure the metrics are updated appropriately final long nowMillis = testClock.millis(); - revokedStsTokenTable.put("session-token-g", nowMillis - TimeUnit.HOURS.toMillis(13)); - revokedStsTokenTable.put("session-token-h", nowMillis - TimeUnit.HOURS.toMillis(14)); - revokedStsTokenTable.put("session-token-i", nowMillis - TimeUnit.HOURS.toMillis(15)); + revokedStsTokenTable.put("original-access-key-g", nowMillis - TimeUnit.HOURS.toMillis(13)); + revokedStsTokenTable.put("original-access-key-h", nowMillis - TimeUnit.HOURS.toMillis(14)); + revokedStsTokenTable.put("original-access-key-i", nowMillis - TimeUnit.HOURS.toMillis(15)); final AtomicReference capturedRequest = new AtomicReference<>(); @@ -211,8 +211,8 @@ public void handlesAllExpiredTokens() throws Exception { final DeleteRevokedSTSTokensRequest deleteRevokedSTSTokensRequest = omRequest.getDeleteRevokedSTSTokensRequest(); - assertThat(deleteRevokedSTSTokensRequest.getSessionTokenList()) - .containsExactlyInAnyOrder("session-token-g", "session-token-h", "session-token-i"); + assertThat(deleteRevokedSTSTokensRequest.getOriginalAccessKeyIdList()) + .containsExactlyInAnyOrder("original-access-key-g", "original-access-key-h", "original-access-key-i"); } } @@ -221,9 +221,9 @@ public void submitsMultipleRequestsWhenBatchSizeIsExceeded() throws Exception { // If the tokens exceed the configured batch size, multiple requests should be submitted final long nowMillis = testClock.millis(); - // Create 10 expired tokens + // Create 10 expired originalAccessKeyIds for (int i = 0; i < 10; i++) { - revokedStsTokenTable.put("session-token-" + i, nowMillis - TimeUnit.HOURS.toMillis(13)); + revokedStsTokenTable.put(String.format("AKIA%07d", i), nowMillis - TimeUnit.HOURS.toMillis(13)); } // Set a very small ratisByteLimit (100 bytes) to force batching. A single token request will be small, but 10 @@ -245,7 +245,7 @@ public void submitsMultipleRequestsWhenBatchSizeIsExceeded() throws Exception { // Verify all tokens were included across the requests final int totalTokens = capturedRequests.stream() - .mapToInt(r -> r.getDeleteRevokedSTSTokensRequest().getSessionTokenList().size()) + .mapToInt(r -> r.getDeleteRevokedSTSTokensRequest().getOriginalAccessKeyIdList().size()) .sum(); assertThat(totalTokens).isEqualTo(10); assertThat(revokedSTSTokenCleanupService.getSubmittedDeletedEntryCount()).isEqualTo(10); @@ -254,7 +254,7 @@ public void submitsMultipleRequestsWhenBatchSizeIsExceeded() throws Exception { @Test public void testSingleOversizedExpiredTokenAndItIsTheOnlyExpiredToken() throws Exception { - // One sessionToken is larger than the ratisByteLimit, and it is the only expired token + // One originalAccessKeyId is larger than the ratisByteLimit, and it is the only expired entry final long nowMillis = testClock.millis(); // Serialized size for largeToken is 102 > 90 (the effective ratisByteLimit) . final String largeToken = new String(new char[100]).replace('\0', 'a'); @@ -279,10 +279,10 @@ public void testSingleOversizedExpiredTokenAndItIsTheOnlyExpiredToken() throws E @Test public void testSingleOversizedExpiredTokenAndThereAreMultipleExpiredTokens() throws Exception { - // One sessionToken is larger than the ratisByteLimit, and it is not the only expired token + // One originalAccessKeyId is larger than the ratisByteLimit, and it is not the only expired entry final long nowMillis = testClock.millis(); - final String smallToken = "session-token-j"; - final String largeToken = "session-token-k-" + new String(new char[90]).replace('\0', 'a'); // > 90 bytes + final String smallToken = "AKIASMALL01"; + final String largeToken = "AKIALARGE-" + new String(new char[90]).replace('\0', 'a'); // > 90 bytes revokedStsTokenTable.put(smallToken, nowMillis - TimeUnit.HOURS.toMillis(13)); revokedStsTokenTable.put(largeToken, nowMillis - TimeUnit.HOURS.toMillis(13)); @@ -308,9 +308,9 @@ public void testExpiredAndNonExpiredTokensWithSmallRatisByteLimit() throws Excep // Expired and non-expired entries with ratisByteLimit of 100 final long nowMillis = testClock.millis(); - revokedStsTokenTable.put("session-token-l", nowMillis - TimeUnit.HOURS.toMillis(13)); - revokedStsTokenTable.put("session-token-m", nowMillis - TimeUnit.HOURS.toMillis(1)); // Should be skipped - revokedStsTokenTable.put("session-token-n", nowMillis - TimeUnit.HOURS.toMillis(13)); + revokedStsTokenTable.put("original-access-key-l", nowMillis - TimeUnit.HOURS.toMillis(13)); + revokedStsTokenTable.put("original-access-key-m", nowMillis - TimeUnit.HOURS.toMillis(1)); // Should be skipped + revokedStsTokenTable.put("original-access-key-n", nowMillis - TimeUnit.HOURS.toMillis(13)); ozoneConfiguration.setStorageSize( OMConfigKeys.OZONE_OM_RATIS_LOG_APPENDER_QUEUE_BYTE_LIMIT, 100, StorageUnit.BYTES); @@ -323,10 +323,11 @@ public void testExpiredAndNonExpiredTokensWithSmallRatisByteLimit() throws Excep final RevokedSTSTokenCleanupService revokedSTSTokenCleanupService = createAndRunCleanupService(); assertThat(revokedSTSTokenCleanupService.getRunCount()).isEqualTo(1); - // session-token-l and session-token-n fit in one batch. session-token-m is ignored because it is not expired. + // original-access-key-l and original-access-key-n fit in one batch. + // original-access-key-m is ignored because it is not expired. assertThat(capturedRequests).hasSize(1); - assertThat(capturedRequests.get(0).getDeleteRevokedSTSTokensRequest().getSessionTokenList()) - .containsExactly("session-token-l", "session-token-n"); + assertThat(capturedRequests.get(0).getDeleteRevokedSTSTokensRequest().getOriginalAccessKeyIdList()) + .containsExactly("original-access-key-l", "original-access-key-n"); assertThat(revokedSTSTokenCleanupService.getSubmittedDeletedEntryCount()).isEqualTo(2); } } @@ -359,12 +360,12 @@ public void testExpiredTokenMatchesRatisByteLimitExactly() throws Exception { public void testCallIdCountIncreasesAcrossBatches() throws Exception { // Force small batch of 40 bytes (which should trigger multiple calls to OzoneManagerRatisUtils.submitRequest) // and ensure the callIdCount increases across each batch - // session-token-1 and session-token-2 are in first batch, and session-token-3 is in second batch. + // AKIA0000001 and AKIA0000002 are in first batch, and AKIA0000003 is in second batch. final long nowMillis = testClock.millis(); - revokedStsTokenTable.put("session-token-1", nowMillis - TimeUnit.HOURS.toMillis(13)); - revokedStsTokenTable.put("session-token-2", nowMillis - TimeUnit.HOURS.toMillis(13)); - revokedStsTokenTable.put("session-token-3", nowMillis - TimeUnit.HOURS.toMillis(13)); + revokedStsTokenTable.put("AKIA0000001", nowMillis - TimeUnit.HOURS.toMillis(13)); + revokedStsTokenTable.put("AKIA0000002", nowMillis - TimeUnit.HOURS.toMillis(13)); + revokedStsTokenTable.put("AKIA0000003", nowMillis - TimeUnit.HOURS.toMillis(13)); ozoneConfiguration.setStorageSize(OMConfigKeys.OZONE_OM_RATIS_LOG_APPENDER_QUEUE_BYTE_LIMIT, 40, StorageUnit.BYTES); diff --git a/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/security/TestS3SecurityUtil.java b/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/security/TestS3SecurityUtil.java index 99c358b929d2..f9d641f60b75 100644 --- a/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/security/TestS3SecurityUtil.java +++ b/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/security/TestS3SecurityUtil.java @@ -36,8 +36,11 @@ import java.io.IOException; import java.time.Clock; +import java.time.Duration; +import java.time.Instant; import java.util.UUID; -import java.util.concurrent.ThreadLocalRandom; +import javax.crypto.spec.SecretKeySpec; +import org.apache.hadoop.hdds.security.symmetric.ManagedSecretKey; import org.apache.hadoop.hdds.security.symmetric.SecretKeyClient; import org.apache.hadoop.hdds.utils.db.InMemoryTestTable; import org.apache.hadoop.hdds.utils.db.Table; @@ -57,21 +60,15 @@ * Tests for STS revocation handling in {@link S3SecurityUtil}. */ public class TestS3SecurityUtil { - private static final byte[] ENCRYPTION_KEY = new byte[5]; + private static final ManagedSecretKey MANAGED_SECRET_KEY = createManagedSecretKey(); private static final MockClock CLOCK = MockClock.newInstance(); private static final String TEMP_ACCESS_KEY_ID = "temp-access-key-id"; - { - ThreadLocalRandom.current().nextBytes(ENCRYPTION_KEY); - } - @Test - public void testValidateS3CredentialFailsWhenTokenRevoked() throws Exception { - // If the revoked STS token table contains an entry for the session token, the request should be rejected with - // REVOKED_TOKEN + public void testValidateS3CredentialFailsWhenTokenCreatedBeforeRevocationCutoff() throws Exception { validateS3CredentialHelper( new TestConfig() - .setTokenRevoked(true) + .setRevocationCutoffOffsetMs(1) .setExpectedResult(REVOKED_TOKEN) .setExpectedMessage("STS token has been revoked")); } @@ -162,6 +159,22 @@ public void testValidateS3CredentialFailsWhenRequestAccessIdEmpty() throws Excep .setExpectedMessage("STS token validation failed - accessKeyId is invalid for session token")); } + @Test + public void testValidateS3CredentialSuccessWhenTokenCreatedAfterRevocationCutoff() throws Exception { + validateS3CredentialHelper( + new TestConfig() + .setRevocationCutoffOffsetMs(-1) + .setExpectedResult(null)); + } + + @Test + public void testValidateS3CredentialSuccessWhenTokenCreatedAtRevocationCutoff() throws Exception { + validateS3CredentialHelper( + new TestConfig() + .setRevocationCutoffOffsetMs(0) + .setExpectedResult(null)); + } + private void validateS3CredentialHelper(TestConfig config) throws Exception { try (OzoneManager ozoneManager = mock(OzoneManager.class)) { when(ozoneManager.isSecurityEnabled()).thenReturn(true); @@ -188,12 +201,13 @@ private void validateS3CredentialHelper(TestConfig config) throws Exception { } final String sessionToken = "session-token"; - if (config.isTokenRevoked && config.revokedSTSTokenTable != null) { - final long insertionTimeMillis = CLOCK.millis(); - config.revokedSTSTokenTable.put(sessionToken, insertionTimeMillis); - } - final STSTokenIdentifier stsTokenIdentifier = createSTSTokenIdentifier(); + final String originalAccessKeyId = stsTokenIdentifier.getOriginalAccessKeyId(); + if (config.revocationCutoffOffsetMs != null && config.revokedSTSTokenTable != null) { + final long revocationTimeMillis = stsTokenIdentifier.getCreationTime().toEpochMilli() + + config.revocationCutoffOffsetMs; + config.revokedSTSTokenTable.put(originalAccessKeyId, revocationTimeMillis); + } try (MockedStatic stsSecurityUtilMock = mockStatic(STSSecurityUtil.class, CALLS_REAL_METHODS); MockedStatic awsV4AuthValidatorMock = mockStatic( @@ -229,10 +243,28 @@ private void validateS3CredentialHelper(TestConfig config) throws Exception { } private STSTokenIdentifier createSTSTokenIdentifier() { - return new STSTokenIdentifier( - TEMP_ACCESS_KEY_ID, "original-access-key-id", "arn:aws:iam::123456789012:role/test-role", - CLOCK.instant().plusSeconds(3600), "secret-access-key", "session-policy", - ENCRYPTION_KEY); + return new STSTokenIdentifier(STSTokenIdentifier.Params.newBuilder() + .setTempAccessKeyId(TEMP_ACCESS_KEY_ID) + .setOriginalAccessKeyId("original-access-key-id") + .setRoleArn("arn:aws:iam::123456789012:role/test-role") + .setCreationTime(CLOCK.instant()) + .setExpiry(CLOCK.instant().plusSeconds(3600)) + .setSecretAccessKey("secret-access-key") + .setSessionPolicy("session-policy") + .setManagedSecretKey(MANAGED_SECRET_KEY) + .build()); + } + + private static ManagedSecretKey createManagedSecretKey() { + final byte[] keyBytes = new byte[32]; + for (int i = 0; i < keyBytes.length; i++) { + keyBytes[i] = (byte) i; + } + return new ManagedSecretKey( + UUID.randomUUID(), + Instant.EPOCH, + Instant.EPOCH.plus(Duration.ofDays(1)), + new SecretKeySpec(keyBytes, "HmacSHA256")); } private static OMRequest createRequestWithSessionToken(String accessId, boolean includeAccessId) { @@ -258,7 +290,7 @@ private static OMRequest createRequestWithSessionToken(String accessId, boolean private static final class TestConfig { private OMMetadataManager metadataManager = mock(OMMetadataManager.class); private Table revokedSTSTokenTable = new InMemoryTestTable<>(); - private boolean isTokenRevoked = false; + private Long revocationCutoffOffsetMs = null; private boolean isOriginalAccessKeyIdRevoked = false; private boolean shouldOriginalAccessKeyIdCheckThrowError = false; private String requestAccessId = TEMP_ACCESS_KEY_ID; @@ -277,9 +309,8 @@ TestConfig setRevokedSTSTokenTable(Table table) { return this; } - @SuppressWarnings("SameParameterValue") - TestConfig setTokenRevoked(boolean isRevoked) { - this.isTokenRevoked = isRevoked; + TestConfig setRevocationCutoffOffsetMs(long offsetMs) { + this.revocationCutoffOffsetMs = offsetMs; return this; } diff --git a/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/security/TestSTSSecurityUtil.java b/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/security/TestSTSSecurityUtil.java index d2033deabec1..290d848535a8 100644 --- a/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/security/TestSTSSecurityUtil.java +++ b/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/security/TestSTSSecurityUtil.java @@ -17,6 +17,7 @@ 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.TOKEN_EXPIRED; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; @@ -29,7 +30,6 @@ import java.time.Instant; import java.time.ZoneOffset; import java.util.UUID; -import java.util.concurrent.ThreadLocalRandom; import org.apache.hadoop.hdds.security.exception.SCMSecurityException; import org.apache.hadoop.hdds.security.symmetric.ManagedSecretKey; import org.apache.hadoop.hdds.security.symmetric.SecretKeyClient; @@ -54,17 +54,12 @@ public class TestSTSSecurityUtil { private static final String SECRET_ACCESS_KEY = "test-secret-access-key"; private static final String SESSION_POLICY = "test-session-policy"; private static final int DURATION_SECONDS = 3600; - private static final byte[] ENCRYPTION_KEY = new byte[5]; - + private static final ManagedSecretKey MANAGED_SECRET_KEY = new SecretKeyTestClient().getCurrentSecretKey(); private final SecretKeyTestClient secretKeyClient = new SecretKeyTestClient(); private final STSTokenSecretManager tokenSecretManager = new STSTokenSecretManager(secretKeyClient); private final UUID secretKeyId = secretKeyClient.getCurrentSecretKey().getId(); private final MockClock clock = new MockClock(Instant.ofEpochMilli(1764819000), ZoneOffset.UTC); - { - ThreadLocalRandom.current().nextBytes(ENCRYPTION_KEY); - } - @Test public void testConstructValidateAndDecryptSTSTokenInvalidProtobuf() throws IOException { // Create a token whose identifier bytes are not a valid OMTokenProto @@ -98,6 +93,7 @@ public void testConstructValidateAndDecryptSTSTokenSuccess() throws IOException assertThat(result.getRoleArn()).isEqualTo(ROLE_ARN); assertThat(result.getSecretAccessKey()).isEqualTo(SECRET_ACCESS_KEY); assertThat(result.getSessionPolicy()).isEqualTo(SESSION_POLICY); + assertThat(result.getCreationTime()).isEqualTo(clock.instant()); assertThat(result.isExpired(clock.instant())).isFalse(); final long expirationEpochMillis = result.getExpiry().toEpochMilli(); assertThat(expirationEpochMillis).isEqualTo(clock.millis() + (DURATION_SECONDS * 1000)); @@ -126,6 +122,16 @@ public void testConstructValidateAndDecryptSTSTokenInvalidFormat() { .hasMessageContaining("Invalid STS token format: Failed to decode STS token string"); } + @Test + public void testConstructValidateAndDecryptSTSTokenRuntimeDecodeFailure() { + assertThatThrownBy(() -> + STSSecurityUtil.constructValidateAndDecryptSTSToken("not-a-valid-token", secretKeyClient, clock)) + .isInstanceOf(OMException.class) + .satisfies(exception -> assertThat(((OMException) exception).getResult()).isEqualTo(INVALID_TOKEN)) + .hasMessageContaining("Invalid STS token format: Failed to decode STS token string") + .hasMessageContaining("NegativeArraySizeException"); + } + @Test public void testConstructValidateAndDecryptSTSTokenInvalidKind() throws Exception { // Create a valid identifier to use as base @@ -303,7 +309,8 @@ public void testConstructValidateAndDecryptSTSTokenEmptyString() { assertThatThrownBy(() -> STSSecurityUtil.constructValidateAndDecryptSTSToken("", secretKeyClient, clock)) .isInstanceOf(OMException.class) - .hasMessage("Invalid STS token format: Failed to decode STS token string: java.io.EOFException"); + .hasMessage( + "Invalid STS token format: Failed to decode STS token string: java.io.EOFException"); } @Test @@ -330,8 +337,7 @@ public void testConstructValidateAndDecryptMultipleTokens() throws Exception { @Test public void testEnsureEssentialFieldsArePresentInTokenMissingExpiry() { - final STSTokenIdentifier tokenIdentifier = new STSTokenIdentifier( - TEMP_ACCESS_KEY, ORIGINAL_ACCESS_KEY, ROLE_ARN, null, SECRET_ACCESS_KEY, SESSION_POLICY, ENCRYPTION_KEY); + final STSTokenIdentifier tokenIdentifier = new STSTokenIdentifier(paramsBuilder().setExpiry(null).build()); assertThatThrownBy(() -> STSSecurityUtil.ensureEssentialFieldsArePresentInToken(tokenIdentifier)) .isInstanceOf(SecretManager.InvalidToken.class) @@ -340,8 +346,7 @@ public void testEnsureEssentialFieldsArePresentInTokenMissingExpiry() { @Test public void testEnsureEssentialFieldsArePresentInTokenMissingTempAccessKeyId() { - final STSTokenIdentifier tokenIdentifier = new STSTokenIdentifier( - null, ORIGINAL_ACCESS_KEY, ROLE_ARN, clock.instant(), SECRET_ACCESS_KEY, SESSION_POLICY, ENCRYPTION_KEY); + final STSTokenIdentifier tokenIdentifier = new STSTokenIdentifier(paramsBuilder().setTempAccessKeyId(null).build()); assertThatThrownBy(() -> STSSecurityUtil.ensureEssentialFieldsArePresentInToken(tokenIdentifier)) .isInstanceOf(SecretManager.InvalidToken.class) @@ -350,8 +355,7 @@ public void testEnsureEssentialFieldsArePresentInTokenMissingTempAccessKeyId() { @Test public void testEnsureEssentialFieldsArePresentInTokenMissingRoleArn() { - final STSTokenIdentifier tokenIdentifier = new STSTokenIdentifier( - TEMP_ACCESS_KEY, ORIGINAL_ACCESS_KEY, null, clock.instant(), SECRET_ACCESS_KEY, SESSION_POLICY, ENCRYPTION_KEY); + final STSTokenIdentifier tokenIdentifier = new STSTokenIdentifier(paramsBuilder().setRoleArn(null).build()); assertThatThrownBy(() -> STSSecurityUtil.ensureEssentialFieldsArePresentInToken(tokenIdentifier)) .isInstanceOf(SecretManager.InvalidToken.class) @@ -361,7 +365,7 @@ public void testEnsureEssentialFieldsArePresentInTokenMissingRoleArn() { @Test public void testEnsureEssentialFieldsArePresentInTokenMissingOriginalAccessKeyId() { final STSTokenIdentifier tokenIdentifier = new STSTokenIdentifier( - TEMP_ACCESS_KEY, null, ROLE_ARN, clock.instant(), SECRET_ACCESS_KEY, SESSION_POLICY, ENCRYPTION_KEY); + paramsBuilder().setOriginalAccessKeyId(null).build()); assertThatThrownBy(() -> STSSecurityUtil.ensureEssentialFieldsArePresentInToken(tokenIdentifier)) .isInstanceOf(SecretManager.InvalidToken.class) @@ -370,14 +374,22 @@ public void testEnsureEssentialFieldsArePresentInTokenMissingOriginalAccessKeyId @Test public void testEnsureEssentialFieldsArePresentInTokenMissingSecretAccessKey() { - final STSTokenIdentifier tokenIdentifier = new STSTokenIdentifier( - TEMP_ACCESS_KEY, ORIGINAL_ACCESS_KEY, ROLE_ARN, clock.instant(), null, SESSION_POLICY, ENCRYPTION_KEY); + final STSTokenIdentifier tokenIdentifier = new STSTokenIdentifier(paramsBuilder().setSecretAccessKey(null).build()); assertThatThrownBy(() -> STSSecurityUtil.ensureEssentialFieldsArePresentInToken(tokenIdentifier)) .isInstanceOf(SecretManager.InvalidToken.class) .hasMessage("Invalid STS token - secretAccessKey is null/empty"); } + @Test + public void testEnsureEssentialFieldsArePresentInTokenMissingCreationTime() { + final STSTokenIdentifier tokenIdentifier = new STSTokenIdentifier(paramsBuilder().setCreationTime(null).build()); + + assertThatThrownBy(() -> STSSecurityUtil.ensureEssentialFieldsArePresentInToken(tokenIdentifier)) + .isInstanceOf(SecretManager.InvalidToken.class) + .hasMessage("Invalid STS token - creationTime is null"); + } + @Test public void testEnsureResolvedStsFieldsInvariantsSuccess() throws Exception { final String tokenString = tokenSecretManager.createSTSTokenString( @@ -449,4 +461,16 @@ public void testEnsureResolvedStsFieldsInvariantsNoS3Auth() throws Exception { // Should not throw STSSecurityUtil.ensureResolvedStsFieldsInvariants(request); } + + private STSTokenIdentifier.Params.Builder paramsBuilder() { + return STSTokenIdentifier.Params.newBuilder() + .setTempAccessKeyId(TEMP_ACCESS_KEY) + .setOriginalAccessKeyId(ORIGINAL_ACCESS_KEY) + .setRoleArn(ROLE_ARN) + .setCreationTime(clock.instant()) + .setExpiry(clock.instant().plusSeconds(DURATION_SECONDS)) + .setSecretAccessKey(SECRET_ACCESS_KEY) + .setSessionPolicy(SESSION_POLICY) + .setManagedSecretKey(MANAGED_SECRET_KEY); + } } diff --git a/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/security/TestSTSTokenEncryption.java b/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/security/TestSTSTokenEncryption.java index 1eb880f9dd03..268e672a38fb 100644 --- a/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/security/TestSTSTokenEncryption.java +++ b/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/security/TestSTSTokenEncryption.java @@ -23,12 +23,14 @@ import static org.junit.jupiter.api.Assertions.assertThrows; import java.nio.charset.StandardCharsets; +import java.time.Duration; import java.time.Instant; import java.util.Base64; import java.util.UUID; import javax.crypto.KeyGenerator; import javax.crypto.SecretKey; import javax.crypto.spec.SecretKeySpec; +import org.apache.hadoop.hdds.security.symmetric.ManagedSecretKey; import org.apache.hadoop.ozone.protocol.proto.OzoneManagerProtocolProtos; import org.apache.hadoop.ozone.security.STSTokenEncryption.STSTokenEncryptionException; import org.junit.jupiter.api.BeforeAll; @@ -43,11 +45,17 @@ public class TestSTSTokenEncryption { private static final int HKDF_SALT_LENGTH = 16; // 128 bits private static SecretKey sharedSecretKey; + private static ManagedSecretKey managedSecretKey; @BeforeAll public static void setUpClass() { final byte[] keyBytes = "01234567890123456789012345678901".getBytes(StandardCharsets.US_ASCII); sharedSecretKey = new SecretKeySpec(keyBytes, "HmacSHA256"); + managedSecretKey = new ManagedSecretKey( + UUID.randomUUID(), + Instant.EPOCH, + Instant.EPOCH.plus(Duration.ofDays(1)), + sharedSecretKey); } @Test @@ -69,20 +77,26 @@ public void testEncryptDecryptRoundTrip() throws Exception { @Test public void testSTSTokenIdentifierEncryption() throws Exception { - final byte[] keyBytes = sharedSecretKey.getEncoded(); - final String tempAccessKeyId = "ASIA123TEMPKEY"; final String originalAccessKeyId = "AKIA123ORIGINAL"; final String roleArn = "arn:aws:iam::123456789012:role/TestRole"; final String secretAccessKey = "mySecretAccessKey123456"; // Use millisecond precision to match serialization format - final Instant expiry = Instant.ofEpochMilli(Instant.now().plusSeconds(3600).toEpochMilli()); + final Instant creationTime = Instant.ofEpochMilli(1_700_000_000_000L); + final Instant expiry = creationTime.plusSeconds(3600); final String sessionPolicy = "test-session-policy"; - + // Create token identifier with encryption - final STSTokenIdentifier tokenId = new STSTokenIdentifier( - tempAccessKeyId, originalAccessKeyId, roleArn, expiry, secretAccessKey, sessionPolicy, keyBytes); - tokenId.setSecretKeyId(UUID.randomUUID()); + final STSTokenIdentifier tokenId = new STSTokenIdentifier(STSTokenIdentifier.Params.newBuilder() + .setTempAccessKeyId(tempAccessKeyId) + .setOriginalAccessKeyId(originalAccessKeyId) + .setRoleArn(roleArn) + .setCreationTime(creationTime) + .setExpiry(expiry) + .setSecretAccessKey(secretAccessKey) + .setSessionPolicy(sessionPolicy) + .setManagedSecretKey(managedSecretKey) + .build()); // Convert to protobuf final OzoneManagerProtocolProtos.OMTokenProto omTokenProto = tokenId.toProtoBuf(); @@ -91,7 +105,7 @@ public void testSTSTokenIdentifierEncryption() throws Exception { // Create new token identifier from protobuf with decryption key final STSTokenIdentifier decodedTokenId = new STSTokenIdentifier(); - decodedTokenId.setEncryptionKey(keyBytes); + decodedTokenId.setManagedSecretKey(managedSecretKey); decodedTokenId.readFromByteArray(protobufBytes); // Verify all fields are correctly decrypted @@ -100,6 +114,7 @@ public void testSTSTokenIdentifierEncryption() throws Exception { assertEquals(roleArn, decodedTokenId.getRoleArn()); assertEquals(secretAccessKey, decodedTokenId.getSecretAccessKey()); assertEquals(expiry, decodedTokenId.getExpiry()); + assertEquals(creationTime, decodedTokenId.getCreationTime()); } @Test diff --git a/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/security/TestSTSTokenIdentifier.java b/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/security/TestSTSTokenIdentifier.java index 09a786faaea3..c2136388e2a0 100644 --- a/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/security/TestSTSTokenIdentifier.java +++ b/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/security/TestSTSTokenIdentifier.java @@ -24,11 +24,13 @@ import java.io.ByteArrayOutputStream; import java.io.DataOutputStream; import java.io.IOException; -import java.security.SecureRandom; +import java.time.Duration; import java.time.Instant; import java.time.temporal.ChronoUnit; import java.util.UUID; import java.util.concurrent.ThreadLocalRandom; +import javax.crypto.spec.SecretKeySpec; +import org.apache.hadoop.hdds.security.symmetric.ManagedSecretKey; import org.apache.hadoop.ozone.protocol.proto.OzoneManagerProtocolProtos.OMTokenProto; import org.junit.jupiter.api.Test; @@ -37,17 +39,25 @@ */ public class TestSTSTokenIdentifier { - private static final byte[] ENCRYPTION_KEY = new byte[5]; + private static final byte[] SECRET_KEY_BYTES = new byte[5]; + private static final ManagedSecretKey MANAGED_SECRET_KEY; + private static final Instant CREATION_TIME = Instant.ofEpochMilli(1_700_000_000_000L); - { - ThreadLocalRandom.current().nextBytes(ENCRYPTION_KEY); + static { + ThreadLocalRandom.current().nextBytes(SECRET_KEY_BYTES); + MANAGED_SECRET_KEY = createManagedSecretKey(SECRET_KEY_BYTES); } @Test public void testKindAndService() { - final STSTokenIdentifier stsTokenIdentifier = new STSTokenIdentifier( - "tempAccessKeyId", "originalAccessKeyId", "roleArn", - Instant.now().plusSeconds(3600), "secretAccessKey", "sessionPolicy", ENCRYPTION_KEY); + final STSTokenIdentifier stsTokenIdentifier = new STSTokenIdentifier(paramsBuilder() + .setTempAccessKeyId("tempAccessKeyId") + .setOriginalAccessKeyId("originalAccessKeyId") + .setRoleArn("roleArn") + .setExpiry(Instant.now().plusSeconds(3600)) + .setSecretAccessKey("secretAccessKey") + .setSessionPolicy("sessionPolicy") + .build()); assertEquals("STSToken", stsTokenIdentifier.getKind().toString()); assertEquals("STS", stsTokenIdentifier.getService()); @@ -59,16 +69,22 @@ public void testProtoBufRoundTrip() throws IOException { // so use a millisecond-precision Instant to avoid nanos-only differences across // platforms/JDKs during round-trips. final Instant expiry = Instant.now().plusSeconds(7200).truncatedTo(ChronoUnit.MILLIS); - final STSTokenIdentifier originalTokenIdentifier = new STSTokenIdentifier( - "tempAccess", "origAccess", "arn:aws:iam::123456789012:role/RoleY", - expiry, "secretKey", "sessionPolicy", ENCRYPTION_KEY); - final UUID secretKeyId = UUID.randomUUID(); - originalTokenIdentifier.setSecretKeyId(secretKeyId); + final STSTokenIdentifier originalTokenIdentifier = new STSTokenIdentifier(paramsBuilder() + .setTempAccessKeyId("tempAccess") + .setOriginalAccessKeyId("origAccess") + .setRoleArn("arn:aws:iam::123456789012:role/RoleY") + .setExpiry(expiry) + .setSecretAccessKey("secretKey") + .setSessionPolicy("sessionPolicy") + .setManagedSecretKey(MANAGED_SECRET_KEY) + .build()); + final UUID secretKeyId = MANAGED_SECRET_KEY.getId(); final OMTokenProto proto = originalTokenIdentifier.toProtoBuf(); assertThat(proto.getType()).isEqualTo(OMTokenProto.Type.S3_STS_TOKEN); assertThat(proto.getOwner()).isEqualTo("tempAccess"); assertThat(proto.getMaxDate()).isEqualTo(expiry.toEpochMilli()); + assertThat(proto.getIssueDate()).isEqualTo(CREATION_TIME.toEpochMilli()); assertThat(proto.getOriginalAccessKeyId()).isEqualTo("origAccess"); assertThat(proto.getRoleArn()).isEqualTo("arn:aws:iam::123456789012:role/RoleY"); assertThat(proto.getSecretAccessKey()).isNotEqualTo("secretKey"); // must be encrypted @@ -76,11 +92,12 @@ public void testProtoBufRoundTrip() throws IOException { assertThat(proto.getSecretKeyId()).isEqualTo(secretKeyId.toString()); final STSTokenIdentifier parsedTokenIdentifier = new STSTokenIdentifier(); - parsedTokenIdentifier.setEncryptionKey(ENCRYPTION_KEY); + parsedTokenIdentifier.setManagedSecretKey(MANAGED_SECRET_KEY); parsedTokenIdentifier.fromProtoBuf(proto); assertThat(parsedTokenIdentifier.getOwnerId()).isEqualTo("tempAccess"); assertThat(parsedTokenIdentifier.getExpiry()).isEqualTo(expiry); + assertThat(parsedTokenIdentifier.getCreationTime()).isEqualTo(CREATION_TIME); assertThat(parsedTokenIdentifier.getOriginalAccessKeyId()).isEqualTo("origAccess"); assertThat(parsedTokenIdentifier.getRoleArn()).isEqualTo("arn:aws:iam::123456789012:role/RoleY"); assertThat(parsedTokenIdentifier.getSecretAccessKey()).isEqualTo("secretKey"); @@ -99,9 +116,14 @@ public void testFromProtoBufInvalidSecretKeyId() { .setSecretKeyId("not-a-uuid") .build(); - final STSTokenIdentifier stsTokenIdentifier = new STSTokenIdentifier( - "tempAccessKeyId", "originalAccessKeyId", "roleArn", Instant.now(), - "secretAccessKey", "sessionPolicy", ENCRYPTION_KEY); + final STSTokenIdentifier stsTokenIdentifier = new STSTokenIdentifier(paramsBuilder() + .setTempAccessKeyId("tempAccessKeyId") + .setOriginalAccessKeyId("originalAccessKeyId") + .setRoleArn("roleArn") + .setExpiry(Instant.now()) + .setSecretAccessKey("secretAccessKey") + .setSessionPolicy("sessionPolicy") + .build()); final IOException ex = assertThrows(IOException.class, () -> stsTokenIdentifier.fromProtoBuf(invalid)); assertThat(ex.getMessage()).isEqualTo("Invalid secretKeyId format in STS token: not-a-uuid"); @@ -110,17 +132,20 @@ public void testFromProtoBufInvalidSecretKeyId() { @Test public void testProtobufRoundTripWithNullSessionPolicy() throws IOException { final Instant expiry = Instant.now().plusSeconds(7200); - final STSTokenIdentifier stsTokenIdentifier = new STSTokenIdentifier( - "tempAccess", "origAccess", "arn:aws:iam::123456789012:role/RoleX", - expiry, "secretKey", null, ENCRYPTION_KEY); - final UUID secretKeyId = UUID.randomUUID(); - stsTokenIdentifier.setSecretKeyId(secretKeyId); + final STSTokenIdentifier stsTokenIdentifier = new STSTokenIdentifier(paramsBuilder() + .setTempAccessKeyId("tempAccess") + .setOriginalAccessKeyId("origAccess") + .setRoleArn("arn:aws:iam::123456789012:role/RoleX") + .setExpiry(expiry) + .setSecretAccessKey("secretKey") + .setManagedSecretKey(MANAGED_SECRET_KEY) + .build()); final OMTokenProto proto = stsTokenIdentifier.toProtoBuf(); assertThat(proto.getSessionPolicy()).isEmpty(); final STSTokenIdentifier parsedTokenIdentifier = new STSTokenIdentifier(); - parsedTokenIdentifier.setEncryptionKey(ENCRYPTION_KEY); + parsedTokenIdentifier.setManagedSecretKey(MANAGED_SECRET_KEY); parsedTokenIdentifier.fromProtoBuf(proto); assertThat(parsedTokenIdentifier.getSessionPolicy()).isEmpty(); @@ -129,17 +154,21 @@ public void testProtobufRoundTripWithNullSessionPolicy() throws IOException { @Test public void testProtobufRoundTripWithEmptySessionPolicy() throws IOException { final Instant expiry = Instant.now().plusSeconds(4000); - final STSTokenIdentifier stsTokenIdentifier = new STSTokenIdentifier( - "tempAccess", "origAccess", "arn:aws:iam::123456789012:role/RoleZ", - expiry, "secretKey", "", ENCRYPTION_KEY); - final UUID secretKeyId = UUID.randomUUID(); - stsTokenIdentifier.setSecretKeyId(secretKeyId); + final STSTokenIdentifier stsTokenIdentifier = new STSTokenIdentifier(paramsBuilder() + .setTempAccessKeyId("tempAccess") + .setOriginalAccessKeyId("origAccess") + .setRoleArn("arn:aws:iam::123456789012:role/RoleZ") + .setExpiry(expiry) + .setSecretAccessKey("secretKey") + .setSessionPolicy("") + .setManagedSecretKey(MANAGED_SECRET_KEY) + .build()); final OMTokenProto proto = stsTokenIdentifier.toProtoBuf(); assertThat(proto.getSessionPolicy()).isEmpty(); final STSTokenIdentifier parsedTokenIdentifier = new STSTokenIdentifier(); - parsedTokenIdentifier.setEncryptionKey(ENCRYPTION_KEY); + parsedTokenIdentifier.setManagedSecretKey(MANAGED_SECRET_KEY); parsedTokenIdentifier.fromProtoBuf(proto); assertThat(parsedTokenIdentifier.getSessionPolicy()).isEmpty(); @@ -153,9 +182,14 @@ public void testFromProtoBufInvalidTokenType() { .setMaxDate(Instant.now().toEpochMilli()) .build(); - final STSTokenIdentifier stsTokenIdentifier = new STSTokenIdentifier( - "tempAccessKeyId", "origAccessKeyId", "roleArn", Instant.now(), - "secretAccessKey", "sessionPolicy", ENCRYPTION_KEY); + final STSTokenIdentifier stsTokenIdentifier = new STSTokenIdentifier(paramsBuilder() + .setTempAccessKeyId("tempAccessKeyId") + .setOriginalAccessKeyId("origAccessKeyId") + .setRoleArn("roleArn") + .setExpiry(Instant.now()) + .setSecretAccessKey("secretAccessKey") + .setSessionPolicy("sessionPolicy") + .build()); final IllegalArgumentException ex = assertThrows( IllegalArgumentException.class, () -> stsTokenIdentifier.fromProtoBuf(invalidType)); @@ -169,10 +203,15 @@ public void testWriteToAndReadFromByteArray() throws Exception { // compared to the original object, which is compared using equals(). final Instant expiry = Instant.now().plusSeconds(1000).truncatedTo(ChronoUnit.MILLIS); - final STSTokenIdentifier originalTokenIdentifier = new STSTokenIdentifier( - "tempAccessKeyId", "originalAccessKeyId", "roleArn", expiry, - "secretAccessKey", "sessionPolicy", ENCRYPTION_KEY); - originalTokenIdentifier.setSecretKeyId(UUID.randomUUID()); + final STSTokenIdentifier originalTokenIdentifier = new STSTokenIdentifier(paramsBuilder() + .setTempAccessKeyId("tempAccessKeyId") + .setOriginalAccessKeyId("originalAccessKeyId") + .setRoleArn("roleArn") + .setExpiry(expiry) + .setSecretAccessKey("secretAccessKey") + .setSessionPolicy("sessionPolicy") + .setManagedSecretKey(MANAGED_SECRET_KEY) + .build()); final ByteArrayOutputStream baos = new ByteArrayOutputStream(); try (DataOutputStream out = new DataOutputStream(baos)) { @@ -181,35 +220,41 @@ public void testWriteToAndReadFromByteArray() throws Exception { final byte[] bytes = baos.toByteArray(); final STSTokenIdentifier parsedTokenIdentifier = new STSTokenIdentifier(); - parsedTokenIdentifier.setEncryptionKey(ENCRYPTION_KEY); + parsedTokenIdentifier.setManagedSecretKey(MANAGED_SECRET_KEY); parsedTokenIdentifier.readFromByteArray(bytes); assertThat(parsedTokenIdentifier).isEqualTo(originalTokenIdentifier); } @Test - public void testWriteToAndReadFromByteArrayWithDifferentSecretKeyIds() throws Exception { - final UUID uuid1 = UUID.randomUUID(); - UUID uuid2 = UUID.randomUUID(); - if (uuid2.equals(uuid1)) { - uuid2 = UUID.randomUUID(); - } - + public void testWriteToAndReadFromByteArrayWithDifferentSecretKeys() throws Exception { final Instant expiry = Instant.now().plusSeconds(1500); - final STSTokenIdentifier originalTokenIdentifier = new STSTokenIdentifier( - "tempAccessKeyId", "originalAccessKeyId", "roleArn", expiry, - "secretAccessKey", "sessionPolicy", ENCRYPTION_KEY); - originalTokenIdentifier.setSecretKeyId(uuid1); + final STSTokenIdentifier originalTokenIdentifier = new STSTokenIdentifier(paramsBuilder() + .setTempAccessKeyId("tempAccessKeyId") + .setOriginalAccessKeyId("originalAccessKeyId") + .setRoleArn("roleArn") + .setExpiry(expiry) + .setSecretAccessKey("secretAccessKey") + .setSessionPolicy("sessionPolicy") + .setManagedSecretKey(MANAGED_SECRET_KEY) + .build()); final ByteArrayOutputStream baos1 = new ByteArrayOutputStream(); try (DataOutputStream out = new DataOutputStream(baos1)) { originalTokenIdentifier.write(out); } - final STSTokenIdentifier anotherTokenIdentifier = new STSTokenIdentifier( - "tempAccessKeyId", "originalAccessKeyId", "roleArn", expiry, - "secretAccessKey", "sessionPolicy", ENCRYPTION_KEY); - anotherTokenIdentifier.setSecretKeyId(uuid2); + byte[] rawBytes = new byte[5]; + ManagedSecretKey managedSecretKey2 = createManagedSecretKey(rawBytes); + final STSTokenIdentifier anotherTokenIdentifier = new STSTokenIdentifier(paramsBuilder() + .setTempAccessKeyId("tempAccessKeyId") + .setOriginalAccessKeyId("originalAccessKeyId") + .setRoleArn("roleArn") + .setExpiry(expiry) + .setSecretAccessKey("secretAccessKey") + .setSessionPolicy("sessionPolicy") + .setManagedSecretKey(managedSecretKey2) + .build()); final ByteArrayOutputStream baos2 = new ByteArrayOutputStream(); try (DataOutputStream out = new DataOutputStream(baos2)) { @@ -223,33 +268,42 @@ public void testWriteToAndReadFromByteArrayWithDifferentSecretKeyIds() throws Ex final byte[] byteArr2 = baos2.toByteArray(); assertThat(byteArr1).isNotEqualTo(byteArr2); final STSTokenIdentifier tokenFromByteArr1 = new STSTokenIdentifier(); - tokenFromByteArr1.setEncryptionKey(ENCRYPTION_KEY); + tokenFromByteArr1.setManagedSecretKey(MANAGED_SECRET_KEY); tokenFromByteArr1.readFromByteArray(byteArr1); final STSTokenIdentifier tokenFromByteArr2 = new STSTokenIdentifier(); - tokenFromByteArr2.setEncryptionKey(ENCRYPTION_KEY); + tokenFromByteArr2.setManagedSecretKey(managedSecretKey2); tokenFromByteArr2.readFromByteArray(byteArr2); assertThat(tokenFromByteArr1).isNotEqualTo(tokenFromByteArr2); } @Test public void testWriteToAndReadFromByteArrayWithSameSecretKeyIds() throws Exception { - final UUID uuid = UUID.randomUUID(); final Instant expiry = Instant.now().plusSeconds(1700); - final STSTokenIdentifier originalTokenIdentifier = new STSTokenIdentifier( - "tempAccessKeyId", "originalAccessKeyId", "roleArn", expiry, - "secretAccessKey", "sessionPolicy", ENCRYPTION_KEY); - originalTokenIdentifier.setSecretKeyId(uuid); + final STSTokenIdentifier originalTokenIdentifier = new STSTokenIdentifier(paramsBuilder() + .setTempAccessKeyId("tempAccessKeyId") + .setOriginalAccessKeyId("originalAccessKeyId") + .setRoleArn("roleArn") + .setExpiry(expiry) + .setSecretAccessKey("secretAccessKey") + .setSessionPolicy("sessionPolicy") + .setManagedSecretKey(MANAGED_SECRET_KEY) + .build()); final ByteArrayOutputStream baos1 = new ByteArrayOutputStream(); try (DataOutputStream out = new DataOutputStream(baos1)) { originalTokenIdentifier.write(out); } - final STSTokenIdentifier anotherTokenIdentifier = new STSTokenIdentifier( - "tempAccessKeyId", "originalAccessKeyId", "roleArn", expiry, - "secretAccessKey", "sessionPolicy", ENCRYPTION_KEY); - anotherTokenIdentifier.setSecretKeyId(uuid); + final STSTokenIdentifier anotherTokenIdentifier = new STSTokenIdentifier(paramsBuilder() + .setTempAccessKeyId("tempAccessKeyId") + .setOriginalAccessKeyId("originalAccessKeyId") + .setRoleArn("roleArn") + .setExpiry(expiry) + .setSecretAccessKey("secretAccessKey") + .setSessionPolicy("sessionPolicy") + .setManagedSecretKey(MANAGED_SECRET_KEY) + .build()); final ByteArrayOutputStream baos2 = new ByteArrayOutputStream(); try (DataOutputStream out = new DataOutputStream(baos2)) { @@ -262,10 +316,10 @@ public void testWriteToAndReadFromByteArrayWithSameSecretKeyIds() throws Excepti final byte[] byteArr2 = baos2.toByteArray(); assertThat(byteArr1).isNotEqualTo(byteArr2); final STSTokenIdentifier tokenFromByteArr1 = new STSTokenIdentifier(); - tokenFromByteArr1.setEncryptionKey(ENCRYPTION_KEY); + tokenFromByteArr1.setManagedSecretKey(MANAGED_SECRET_KEY); tokenFromByteArr1.readFromByteArray(byteArr1); final STSTokenIdentifier tokenFromByteArr2 = new STSTokenIdentifier(); - tokenFromByteArr2.setEncryptionKey(ENCRYPTION_KEY); + tokenFromByteArr2.setManagedSecretKey(MANAGED_SECRET_KEY); tokenFromByteArr2.readFromByteArray(byteArr2); assertThat(tokenFromByteArr1).isEqualTo(tokenFromByteArr2); } @@ -279,13 +333,20 @@ public void testGettersReturnCorrectValues() { final String secretAccessKey = "mySecretKey"; final String sessionPolicy = "myPolicy"; - final STSTokenIdentifier stsTokenIdentifier = new STSTokenIdentifier( - tempAccessKeyId, originalAccessKeyId, roleArn, expiry, secretAccessKey, sessionPolicy, ENCRYPTION_KEY); + final STSTokenIdentifier stsTokenIdentifier = new STSTokenIdentifier(paramsBuilder() + .setTempAccessKeyId(tempAccessKeyId) + .setOriginalAccessKeyId(originalAccessKeyId) + .setRoleArn(roleArn) + .setExpiry(expiry) + .setSecretAccessKey(secretAccessKey) + .setSessionPolicy(sessionPolicy) + .build()); assertThat(stsTokenIdentifier.getOwnerId()).isEqualTo(tempAccessKeyId); assertThat(stsTokenIdentifier.getTempAccessKeyId()).isEqualTo(tempAccessKeyId); assertThat(stsTokenIdentifier.getOriginalAccessKeyId()).isEqualTo(originalAccessKeyId); assertThat(stsTokenIdentifier.getRoleArn()).isEqualTo(roleArn); + assertThat(stsTokenIdentifier.getCreationTime()).isEqualTo(CREATION_TIME); assertThat(stsTokenIdentifier.getExpiry()).isEqualTo(expiry); assertThat(stsTokenIdentifier.getSecretAccessKey()).isEqualTo(secretAccessKey); assertThat(stsTokenIdentifier.getSessionPolicy()).isEqualTo(sessionPolicy); @@ -296,14 +357,24 @@ public void testEqualsAndHashCode() { final Instant expiry = Instant.now().plusSeconds(3600); final UUID uuid = UUID.randomUUID(); - final STSTokenIdentifier stsTokenIdentifier = new STSTokenIdentifier( - "tempAccessKeyId", "originalAccessKeyId", "roleArn", expiry, - "secretAccessKey", "sessionPolicy", ENCRYPTION_KEY); + final STSTokenIdentifier stsTokenIdentifier = new STSTokenIdentifier(paramsBuilder() + .setTempAccessKeyId("tempAccessKeyId") + .setOriginalAccessKeyId("originalAccessKeyId") + .setRoleArn("roleArn") + .setExpiry(expiry) + .setSecretAccessKey("secretAccessKey") + .setSessionPolicy("sessionPolicy") + .build()); stsTokenIdentifier.setSecretKeyId(uuid); - final STSTokenIdentifier stsTokenIdentifier2 = new STSTokenIdentifier( - "tempAccessKeyId", "originalAccessKeyId", "roleArn", expiry, - "secretAccessKey", "sessionPolicy", ENCRYPTION_KEY); + final STSTokenIdentifier stsTokenIdentifier2 = new STSTokenIdentifier(paramsBuilder() + .setTempAccessKeyId("tempAccessKeyId") + .setOriginalAccessKeyId("originalAccessKeyId") + .setRoleArn("roleArn") + .setExpiry(expiry) + .setSecretAccessKey("secretAccessKey") + .setSessionPolicy("sessionPolicy") + .build()); stsTokenIdentifier2.setSecretKeyId(uuid); assertThat(stsTokenIdentifier).isEqualTo(stsTokenIdentifier2); @@ -314,13 +385,23 @@ public void testEqualsAndHashCode() { public void testNotEqualsWhenTempAccessKeyIdDiffers() { final Instant expiry = Instant.now().plusSeconds(3600); - final STSTokenIdentifier stsTokenIdentifier = new STSTokenIdentifier( - "tempAccessKeyId1", "originalAccessKeyId", "roleArn", - expiry, "secretAccessKey", "sessionPolicy", ENCRYPTION_KEY); - - final STSTokenIdentifier stsTokenIdentifier2 = new STSTokenIdentifier( - "tempAccessKeyId2", "originalAccessKeyId", "roleArn", expiry, - "secretAccessKey", "sessionPolicy", ENCRYPTION_KEY); + final STSTokenIdentifier stsTokenIdentifier = new STSTokenIdentifier(paramsBuilder() + .setTempAccessKeyId("tempAccessKeyId1") + .setOriginalAccessKeyId("originalAccessKeyId") + .setRoleArn("roleArn") + .setExpiry(expiry) + .setSecretAccessKey("secretAccessKey") + .setSessionPolicy("sessionPolicy") + .build()); + + final STSTokenIdentifier stsTokenIdentifier2 = new STSTokenIdentifier(paramsBuilder() + .setTempAccessKeyId("tempAccessKeyId2") + .setOriginalAccessKeyId("originalAccessKeyId") + .setRoleArn("roleArn") + .setExpiry(expiry) + .setSecretAccessKey("secretAccessKey") + .setSessionPolicy("sessionPolicy") + .build()); assertThat(stsTokenIdentifier).isNotEqualTo(stsTokenIdentifier2); } @@ -329,13 +410,23 @@ public void testNotEqualsWhenTempAccessKeyIdDiffers() { public void testNotEqualsWhenOriginalAccessKeyIdDiffers() { final Instant expiry = Instant.now().plusSeconds(3600); - final STSTokenIdentifier stsTokenIdentifier = new STSTokenIdentifier( - "tempAccessKeyId", "originalAccessKeyId1", "roleArn", expiry, - "secretAccessKey", "sessionPolicy", ENCRYPTION_KEY); - - final STSTokenIdentifier stsTokenIdentifier2 = new STSTokenIdentifier( - "tempAccessKeyId", "originalAccessKeyId2", "roleArn", expiry, - "secretAccessKey", "sessionPolicy", ENCRYPTION_KEY); + final STSTokenIdentifier stsTokenIdentifier = new STSTokenIdentifier(paramsBuilder() + .setTempAccessKeyId("tempAccessKeyId") + .setOriginalAccessKeyId("originalAccessKeyId1") + .setRoleArn("roleArn") + .setExpiry(expiry) + .setSecretAccessKey("secretAccessKey") + .setSessionPolicy("sessionPolicy") + .build()); + + final STSTokenIdentifier stsTokenIdentifier2 = new STSTokenIdentifier(paramsBuilder() + .setTempAccessKeyId("tempAccessKeyId") + .setOriginalAccessKeyId("originalAccessKeyId2") + .setRoleArn("roleArn") + .setExpiry(expiry) + .setSecretAccessKey("secretAccessKey") + .setSessionPolicy("sessionPolicy") + .build()); assertThat(stsTokenIdentifier).isNotEqualTo(stsTokenIdentifier2); } @@ -344,26 +435,46 @@ public void testNotEqualsWhenOriginalAccessKeyIdDiffers() { public void testNotEqualsWhenRoleArnDiffers() { final Instant expiry = Instant.now().plusSeconds(3600); - final STSTokenIdentifier stsTokenIdentifier = new STSTokenIdentifier( - "tempAccessKeyId", "originalAccessKeyId", "roleArn1", expiry, - "secretAccessKey", "sessionPolicy", ENCRYPTION_KEY); - - final STSTokenIdentifier stsTokenIdentifier2 = new STSTokenIdentifier( - "tempAccessKeyId", "originalAccessKeyId", "roleArn2", expiry, - "secretAccessKey", "sessionPolicy", ENCRYPTION_KEY); + final STSTokenIdentifier stsTokenIdentifier = new STSTokenIdentifier(paramsBuilder() + .setTempAccessKeyId("tempAccessKeyId") + .setOriginalAccessKeyId("originalAccessKeyId") + .setRoleArn("roleArn1") + .setExpiry(expiry) + .setSecretAccessKey("secretAccessKey") + .setSessionPolicy("sessionPolicy") + .build()); + + final STSTokenIdentifier stsTokenIdentifier2 = new STSTokenIdentifier(paramsBuilder() + .setTempAccessKeyId("tempAccessKeyId") + .setOriginalAccessKeyId("originalAccessKeyId") + .setRoleArn("roleArn2") + .setExpiry(expiry) + .setSecretAccessKey("secretAccessKey") + .setSessionPolicy("sessionPolicy") + .build()); assertThat(stsTokenIdentifier).isNotEqualTo(stsTokenIdentifier2); } @Test public void testNotEqualsWhenExpirationDiffers() { - final STSTokenIdentifier stsTokenIdentifier = new STSTokenIdentifier( - "tempAccessKeyId", "originalAccessKeyId", "roleArn", - Instant.now().plusSeconds(3600), "secretAccessKey", "sessionPolicy", ENCRYPTION_KEY); - - final STSTokenIdentifier stsTokenIdentifier2 = new STSTokenIdentifier( - "tempAccessKeyId", "originalAccessKeyId", "roleArn", - Instant.now().plusSeconds(7600), "secretAccessKey", "sessionPolicy", ENCRYPTION_KEY); + final STSTokenIdentifier stsTokenIdentifier = new STSTokenIdentifier(paramsBuilder() + .setTempAccessKeyId("tempAccessKeyId") + .setOriginalAccessKeyId("originalAccessKeyId") + .setRoleArn("roleArn") + .setExpiry(Instant.now().plusSeconds(3600)) + .setSecretAccessKey("secretAccessKey") + .setSessionPolicy("sessionPolicy") + .build()); + + final STSTokenIdentifier stsTokenIdentifier2 = new STSTokenIdentifier(paramsBuilder() + .setTempAccessKeyId("tempAccessKeyId") + .setOriginalAccessKeyId("originalAccessKeyId") + .setRoleArn("roleArn") + .setExpiry(Instant.now().plusSeconds(7600)) + .setSecretAccessKey("secretAccessKey") + .setSessionPolicy("sessionPolicy") + .build()); assertThat(stsTokenIdentifier).isNotEqualTo(stsTokenIdentifier2); } @@ -372,13 +483,23 @@ public void testNotEqualsWhenExpirationDiffers() { public void testNotEqualsWhenSecretAccessKeyDiffers() { final Instant expiry = Instant.now().plusSeconds(3600); - final STSTokenIdentifier stsTokenIdentifier = new STSTokenIdentifier( - "tempAccessKeyId", "originalAccessKeyId", "roleArn", expiry, - "secretAccessKey1", "sessionPolicy", ENCRYPTION_KEY); - - final STSTokenIdentifier stsTokenIdentifier2 = new STSTokenIdentifier( - "tempAccessKeyId", "originalAccessKeyId", "roleArn", expiry, - "secretAccessKey2", "sessionPolicy", ENCRYPTION_KEY); + final STSTokenIdentifier stsTokenIdentifier = new STSTokenIdentifier(paramsBuilder() + .setTempAccessKeyId("tempAccessKeyId") + .setOriginalAccessKeyId("originalAccessKeyId") + .setRoleArn("roleArn") + .setExpiry(expiry) + .setSecretAccessKey("secretAccessKey1") + .setSessionPolicy("sessionPolicy") + .build()); + + final STSTokenIdentifier stsTokenIdentifier2 = new STSTokenIdentifier(paramsBuilder() + .setTempAccessKeyId("tempAccessKeyId") + .setOriginalAccessKeyId("originalAccessKeyId") + .setRoleArn("roleArn") + .setExpiry(expiry) + .setSecretAccessKey("secretAccessKey2") + .setSessionPolicy("sessionPolicy") + .build()); assertThat(stsTokenIdentifier).isNotEqualTo(stsTokenIdentifier2); } @@ -387,13 +508,23 @@ public void testNotEqualsWhenSecretAccessKeyDiffers() { public void testNotEqualsWhenSessionPolicyDiffers() { final Instant expiry = Instant.now().plusSeconds(3600); - final STSTokenIdentifier stsTokenIdentifier = new STSTokenIdentifier( - "tempAccessKeyId", "originalAccessKeyId", "roleArn", expiry, - "secretAccessKey", "sessionPolicy1", ENCRYPTION_KEY); - - final STSTokenIdentifier stsTokenIdentifier2 = new STSTokenIdentifier( - "tempAccessKeyId", "originalAccessKeyId", "roleArn", expiry, - "secretAccessKey", "sessionPolicy2", ENCRYPTION_KEY); + final STSTokenIdentifier stsTokenIdentifier = new STSTokenIdentifier(paramsBuilder() + .setTempAccessKeyId("tempAccessKeyId") + .setOriginalAccessKeyId("originalAccessKeyId") + .setRoleArn("roleArn") + .setExpiry(expiry) + .setSecretAccessKey("secretAccessKey") + .setSessionPolicy("sessionPolicy1") + .build()); + + final STSTokenIdentifier stsTokenIdentifier2 = new STSTokenIdentifier(paramsBuilder() + .setTempAccessKeyId("tempAccessKeyId") + .setOriginalAccessKeyId("originalAccessKeyId") + .setRoleArn("roleArn") + .setExpiry(expiry) + .setSecretAccessKey("secretAccessKey") + .setSessionPolicy("sessionPolicy2") + .build()); assertThat(stsTokenIdentifier).isNotEqualTo(stsTokenIdentifier2); } @@ -403,14 +534,20 @@ public void testToString() { final Instant expiry = Instant.now().plusSeconds(3600); final UUID uuid = UUID.randomUUID(); - final STSTokenIdentifier stsTokenIdentifier = new STSTokenIdentifier( - "tempAccessKeyId", "originalAccessKeyId", "roleArn", expiry, - "secretAccessKey", "sessionPolicy", ENCRYPTION_KEY); + final STSTokenIdentifier stsTokenIdentifier = new STSTokenIdentifier(paramsBuilder() + .setTempAccessKeyId("tempAccessKeyId") + .setOriginalAccessKeyId("originalAccessKeyId") + .setRoleArn("roleArn") + .setExpiry(expiry) + .setSecretAccessKey("secretAccessKey") + .setSessionPolicy("sessionPolicy") + .build()); stsTokenIdentifier.setSecretKeyId(uuid); final String stsTokenIdentifierStr = stsTokenIdentifier.toString(); final String expectedString = "STSTokenIdentifier{" + "tempAccessKeyId='tempAccessKeyId'" + - ", originalAccessKeyId='originalAccessKeyId'" + ", roleArn='roleArn'" + ", expiry='" + expiry + + ", originalAccessKeyId='originalAccessKeyId'" + ", roleArn='roleArn'" + + ", creationTime='" + CREATION_TIME + "', expiry='" + expiry + "', secretKeyId='" + uuid + "', sessionPolicy='sessionPolicy'" + '}'; assertEquals(expectedString, stsTokenIdentifierStr); @@ -418,37 +555,65 @@ public void testToString() { @Test public void testNotEqualsWithNull() { - final STSTokenIdentifier stsTokenIdentifier = new STSTokenIdentifier( - "tempAccessKeyId", "originalAccessKeyId", "roleArn", Instant.now(), - "secretAccessKey", "sessionPolicy", ENCRYPTION_KEY); + final STSTokenIdentifier stsTokenIdentifier = new STSTokenIdentifier(paramsBuilder() + .setTempAccessKeyId("tempAccessKeyId") + .setOriginalAccessKeyId("originalAccessKeyId") + .setRoleArn("roleArn") + .setExpiry(Instant.now()) + .setSecretAccessKey("secretAccessKey") + .setSessionPolicy("sessionPolicy") + .build()); assertThat(stsTokenIdentifier).isNotEqualTo(null); } @Test - public void testEqualsWithDifferentEncryptionKeys() { + public void testEqualsWithDifferentManagedSecretKeys() { final Instant expiry = Instant.now().plusSeconds(3600).truncatedTo(ChronoUnit.MILLIS); final UUID uuid = UUID.randomUUID(); // Create first identifier with the default key - final STSTokenIdentifier stsTokenIdentifier = new STSTokenIdentifier( - "tempAccessKeyId", "originalAccessKeyId", "roleArn", expiry, - "secretAccessKey", "sessionPolicy", ENCRYPTION_KEY); + final STSTokenIdentifier stsTokenIdentifier = new STSTokenIdentifier(paramsBuilder() + .setTempAccessKeyId("tempAccessKeyId") + .setOriginalAccessKeyId("originalAccessKeyId") + .setRoleArn("roleArn") + .setExpiry(expiry) + .setSecretAccessKey("secretAccessKey") + .setSessionPolicy("sessionPolicy") + .build()); stsTokenIdentifier.setSecretKeyId(uuid); - // Create second identifier with a different encryption key but otherwise same parameters - byte[] differentKey = new byte[5]; - new SecureRandom().nextBytes(differentKey); - - final STSTokenIdentifier stsTokenIdentifier2 = new STSTokenIdentifier( - "tempAccessKeyId", "originalAccessKeyId", "roleArn", expiry, - "secretAccessKey", "sessionPolicy", differentKey); + // Create second identifier with a different ManagedSecretKey but otherwise same parameters + byte[] differentKeyBytes = new byte[5]; + ThreadLocalRandom.current().nextBytes(differentKeyBytes); + + final STSTokenIdentifier stsTokenIdentifier2 = new STSTokenIdentifier(paramsBuilder() + .setTempAccessKeyId("tempAccessKeyId") + .setOriginalAccessKeyId("originalAccessKeyId") + .setRoleArn("roleArn") + .setExpiry(expiry) + .setSecretAccessKey("secretAccessKey") + .setSessionPolicy("sessionPolicy") + .setManagedSecretKey(createManagedSecretKey(differentKeyBytes)) + .build()); stsTokenIdentifier2.setSecretKeyId(uuid); - // They should still be equal because encryptionKey is transient/ignored for identity + // They should still be equal because managedSecretKey is transient/ignored for identity assertThat(stsTokenIdentifier).isEqualTo(stsTokenIdentifier2); assertThat(stsTokenIdentifier.hashCode()).isEqualTo(stsTokenIdentifier2.hashCode()); } -} + private static ManagedSecretKey createManagedSecretKey(byte[] keyBytes) { + return new ManagedSecretKey( + UUID.randomUUID(), + CREATION_TIME, + CREATION_TIME.plus(Duration.ofDays(1)), + new SecretKeySpec(keyBytes, "HmacSHA256")); + } + private static STSTokenIdentifier.Params.Builder paramsBuilder() { + return STSTokenIdentifier.Params.newBuilder() + .setCreationTime(CREATION_TIME) + .setManagedSecretKey(MANAGED_SECRET_KEY); + } +} diff --git a/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/security/TestSTSTokenSecretManager.java b/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/security/TestSTSTokenSecretManager.java index 800aeabe97c5..4408652dbb9d 100644 --- a/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/security/TestSTSTokenSecretManager.java +++ b/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/security/TestSTSTokenSecretManager.java @@ -26,12 +26,16 @@ import java.io.IOException; import java.nio.charset.StandardCharsets; +import java.time.Duration; import java.time.Instant; import java.time.ZoneOffset; +import java.util.HashMap; +import java.util.Map; import java.util.UUID; import javax.crypto.SecretKey; import javax.crypto.spec.SecretKeySpec; import org.apache.hadoop.hdds.security.symmetric.ManagedSecretKey; +import org.apache.hadoop.hdds.security.symmetric.SecretKeyClient; import org.apache.hadoop.hdds.security.symmetric.SecretKeySignerClient; import org.apache.hadoop.io.Text; import org.apache.hadoop.security.token.Token; @@ -70,7 +74,7 @@ public void setUp() throws Exception { final UUID keyId = UUID.fromString("00000000-0000-0000-0000-000000000000"); when(mockSecretKey.getId()).thenReturn(keyId); when(mockSecretKey.getSecretKey()).thenReturn(sharedSecretKey); - when(mockSecretKey.sign(any(STSTokenIdentifier.class))) + when(mockSecretKey.sign(any(byte[].class))) .thenReturn("mock-signature".getBytes(StandardCharsets.UTF_8)); when(mockSecretKeyClient.getCurrentSecretKey()).thenReturn(mockSecretKey); @@ -89,7 +93,9 @@ public void testCreateSTSTokenStringContainsCorrectFields() throws IOException { // Verify the token identifier fields final STSTokenIdentifier identifier = new STSTokenIdentifier(); - identifier.setEncryptionKey(sharedSecretKey.getEncoded()); + identifier.setManagedSecretKey(createManagedSecretKey( + UUID.fromString("00000000-0000-0000-0000-000000000000"), + sharedSecretKey.getEncoded(), Instant.now())); identifier.readFromByteArray(token.getIdentifier()); final Instant expiration = identifier.getExpiry(); @@ -98,6 +104,7 @@ public void testCreateSTSTokenStringContainsCorrectFields() throws IOException { assertEquals(ROLE_ARN, identifier.getRoleArn()); assertEquals(SECRET_ACCESS_KEY, identifier.getSecretAccessKey()); assertEquals(SESSION_POLICY, identifier.getSessionPolicy()); + assertEquals(clock.instant(), identifier.getCreationTime()); assertNotNull(identifier.getSecretKeyId()); assertEquals(new Text("STSToken"), identifier.getKind()); assertEquals("STS", identifier.getService()); @@ -114,8 +121,78 @@ public void testCreateSTSTokenStringWithNullSessionPolicy() throws IOException { token.decodeFromUrlString(tokenString); final STSTokenIdentifier identifier = new STSTokenIdentifier(); - identifier.setEncryptionKey(sharedSecretKey.getEncoded()); + identifier.setManagedSecretKey(createManagedSecretKey( + UUID.fromString("00000000-0000-0000-0000-000000000000"), + sharedSecretKey.getEncoded(), Instant.now())); identifier.readFromByteArray(token.getIdentifier()); assertTrue(identifier.getSessionPolicy().isEmpty()); } + + /** + * createSTSTokenString() must use a single getCurrentSecretKey() for encryption, secretKeyId, and signing. If a + * second fetch happened during signing, a key rotation between calls would encrypt with the old key but stamp the + * token with the new key id. + */ + @Test + public void testCreateSTSTokenStringValidatesWhenSecretKeyRotatesDuringCreation() throws Exception { + // ManagedSecretKey.isExpired() uses Instant.now(), not the test clock. + final Instant keyCreationTime = Instant.now(); + final ManagedSecretKey encryptionKey = createManagedSecretKey( + UUID.fromString("11111111-1111-1111-1111-111111111111"), + "encryption-key-material-012345678901".getBytes(StandardCharsets.US_ASCII), + keyCreationTime); + final ManagedSecretKey signingKey = createManagedSecretKey( + UUID.fromString("22222222-2222-2222-2222-222222222222"), + "signing-key-material-01234567890123".getBytes(StandardCharsets.US_ASCII), + keyCreationTime); + + final RotatingSecretKeyTestClient rotatingSecretKeyClient = new RotatingSecretKeyTestClient( + encryptionKey, signingKey); + final STSTokenSecretManager rotatingSecretManager = new STSTokenSecretManager(rotatingSecretKeyClient); + + final String tokenString = rotatingSecretManager.createSTSTokenString( + TEMP_ACCESS_KEY, ORIGINAL_ACCESS_KEY, ROLE_ARN, DURATION_SECONDS, SECRET_ACCESS_KEY, SESSION_POLICY, clock); + + final STSTokenIdentifier result = STSSecurityUtil.constructValidateAndDecryptSTSToken( + tokenString, rotatingSecretKeyClient, clock); + assertEquals(SECRET_ACCESS_KEY, result.getSecretAccessKey()); + assertEquals(encryptionKey.getId(), result.getSecretKeyId()); + assertEquals(1, rotatingSecretKeyClient.getCurrentSecretKeyCallCount()); + } + + private static ManagedSecretKey createManagedSecretKey(UUID id, byte[] keyBytes, Instant creationTime) { + final SecretKey secretKey = new SecretKeySpec(keyBytes, "HmacSHA256"); + return new ManagedSecretKey(id, creationTime, creationTime.plus(Duration.ofHours(1)), secretKey); + } + + /** + * Returns different current keys on consecutive getCurrentSecretKey() calls to simulate rotation. + */ + private static final class RotatingSecretKeyTestClient implements SecretKeyClient { + private final ManagedSecretKey firstKey; + private final ManagedSecretKey secondKey; + private final Map keysById = new HashMap<>(); + private int getCurrentSecretKeyCallCount; + + private RotatingSecretKeyTestClient(ManagedSecretKey firstKey, ManagedSecretKey secondKey) { + this.firstKey = firstKey; + this.secondKey = secondKey; + keysById.put(firstKey.getId(), firstKey); + keysById.put(secondKey.getId(), secondKey); + } + + @Override + public synchronized ManagedSecretKey getCurrentSecretKey() { + return getCurrentSecretKeyCallCount++ == 0 ? firstKey : secondKey; + } + + @Override + public ManagedSecretKey getSecretKey(UUID id) { + return keysById.get(id); + } + + private int getCurrentSecretKeyCallCount() { + return getCurrentSecretKeyCallCount; + } + } } diff --git a/hadoop-ozone/s3gateway/src/test/java/org/apache/hadoop/ozone/client/ClientProtocolStub.java b/hadoop-ozone/s3gateway/src/test/java/org/apache/hadoop/ozone/client/ClientProtocolStub.java index b79984fa93e7..abd80cbc1fcf 100644 --- a/hadoop-ozone/s3gateway/src/test/java/org/apache/hadoop/ozone/client/ClientProtocolStub.java +++ b/hadoop-ozone/s3gateway/src/test/java/org/apache/hadoop/ozone/client/ClientProtocolStub.java @@ -899,7 +899,7 @@ public AssumeRoleResponseInfo assumeRole(String roleArn, String roleSessionName, } @Override - public void revokeSTSToken(String sessionToken) throws IOException { + public void revokeSTSToken(String originalAccessKeyId) throws IOException { } @Override From 5a0707f4baac2bc19093d5560f30a140316ba243 Mon Sep 17 00:00:00 2001 From: fmorg-git Date: Fri, 28 Aug 2026 20:09:00 -0700 Subject: [PATCH 51/54] HDDS-16472. [STS] Tighten session policy validation (#11103) --- hadoop-ozone/common/pom.xml | 4 + .../acl/iam/IamSessionPolicyResolver.java | 202 +++++++-- .../acl/iam/TestIamSessionPolicyResolver.java | 422 ++++++++++++++++++ .../s3/security/TestS3AssumeRoleRequest.java | 37 +- 4 files changed, 638 insertions(+), 27 deletions(-) diff --git a/hadoop-ozone/common/pom.xml b/hadoop-ozone/common/pom.xml index 79039d218d72..75740bd1380e 100644 --- a/hadoop-ozone/common/pom.xml +++ b/hadoop-ozone/common/pom.xml @@ -31,6 +31,10 @@ com.fasterxml.jackson.core jackson-annotations + + com.fasterxml.jackson.core + jackson-core + com.fasterxml.jackson.core jackson-databind diff --git a/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/security/acl/iam/IamSessionPolicyResolver.java b/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/security/acl/iam/IamSessionPolicyResolver.java index 415ae5e8af75..6dcf468f6b7e 100644 --- a/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/security/acl/iam/IamSessionPolicyResolver.java +++ b/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/security/acl/iam/IamSessionPolicyResolver.java @@ -29,13 +29,17 @@ import static org.apache.hadoop.ozone.security.acl.IAccessAuthorizer.ACLType.WRITE; import static org.apache.hadoop.ozone.security.acl.IAccessAuthorizer.ACLType.WRITE_ACL; +import com.fasterxml.jackson.core.JsonParser; +import com.fasterxml.jackson.core.JsonToken; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import com.google.common.annotations.VisibleForTesting; import com.google.common.base.Preconditions; +import java.io.IOException; import java.util.Collections; import java.util.EnumSet; import java.util.HashSet; +import java.util.Iterator; import java.util.LinkedHashMap; import java.util.LinkedHashSet; import java.util.Map; @@ -78,6 +82,9 @@ * value is case-sensitive per the * AWS spec. *

+ * The only supported Statement elements are Sid, Effect, Action, Resource, and Condition. Duplicate JSON object keys + * and unsupported Statement elements will throw OMException with MALFORMED_POLICY_DOCUMENT. + *

* If a (currently) unsupported S3 action is requested, such as s3:GetAccelerateConfiguration, * it will be silently ignored. Similarly, if an invalid S3 action is requested, it will be silently ignored. *

@@ -141,14 +148,21 @@ public static Set resolve(String policyJson, Strin final Set statements = parseJsonAndRetrieveStatements(policyJson); for (JsonNode stmt : statements) { + validateSupportedStatementFields(stmt); validateEffectInJsonStatement(stmt); - final Set actions = readStringOrArray(stmt.get("Action")); - final Set resources = readStringOrArray(stmt.get("Resource")); + final Set actions = readRequiredStringOrArray(stmt.get("Action"), "Action"); + final Set resources = readRequiredStringOrArray(stmt.get("Resource"), "Resource"); // Parse prefixes from conditions, if any final Condition condition = parsePrefixesFromConditions(stmt); + // An empty s3:prefix array matches no prefixes and therefore grants no access (AWS behavior). + if (condition != null && condition.prefixes != null + && condition.prefixes.isEmpty()) { + continue; + } + // Map actions to S3Action enum if possible final Set mappedS3Actions = mapPolicyActionsToS3Actions(actions); if (mappedS3Actions.isEmpty()) { @@ -203,6 +217,10 @@ private static void validateInputParameters(String policyJson, String volumeName * Parses IAM session policy and retrieve the statement(s). */ private static Set parseJsonAndRetrieveStatements(String policyJson) throws OMException { + // Jackson's tree model silently collapses duplicate keys (last value wins), which could let a caller smuggle + // broader permissions than intended. Detect them up front so we can reject them with the exact field name. + checkForDuplicateFields(policyJson); + final JsonNode root; try { root = MAPPER.readTree(policyJson); @@ -223,9 +241,84 @@ private static Set parseJsonAndRetrieveStatements(String policyJson) t } else { statements.add(statementsNode); } + if (statements.isEmpty()) { + throw new OMException(ERROR_PREFIX + "No Statement(s) found in policy", MALFORMED_POLICY_DOCUMENT); + } return statements; } + /** + * Detects duplicate JSON object keys at any nesting level in a single streaming pass, reporting the offending + * field name directly. Structural JSON problems are ignored here and surfaced by the subsequent tree parse. + */ + private static void checkForDuplicateFields(String policyJson) throws OMException { + try (JsonParser parser = MAPPER.getFactory().createParser(policyJson)) { + checkForDuplicateFields(parser); + } catch (OMException e) { + throw e; + } catch (IOException e) { + // Structural JSON problems are surfaced by the subsequent tree parse with a clearer message. + } + } + + private static void checkForDuplicateFields(JsonParser parser) throws IOException { + JsonToken token = parser.currentToken(); + if (token == null) { + token = parser.nextToken(); + } + + if (token == JsonToken.START_OBJECT) { + final Set fieldNames = new HashSet<>(); + while (parser.nextToken() == JsonToken.FIELD_NAME) { + final String fieldName = parser.currentName(); + if (!fieldNames.add(fieldName)) { + throw new OMException( + ERROR_PREFIX + "Duplicate field '" + fieldName + "' in session policy", MALFORMED_POLICY_DOCUMENT); + } + parser.nextToken(); + checkForDuplicateFields(parser); + } + } else if (token == JsonToken.START_ARRAY) { + JsonToken element; + while ((element = parser.nextToken()) != null && element != JsonToken.END_ARRAY) { + checkForDuplicateFields(parser); + } + } + } + + /** + * Ensures statements contain only the IAM policy elements supported by the STS session policy subset. + */ + private static void validateSupportedStatementFields(JsonNode statement) throws OMException { + if (!statement.isObject()) { + throw new OMException( + ERROR_PREFIX + "Invalid Statement in JSON policy (must be an Object) - " + statement, + MALFORMED_POLICY_DOCUMENT); + } + + final Iterator fieldNames = statement.fieldNames(); + while (fieldNames.hasNext()) { + final String fieldName = fieldNames.next(); + if (!isSupportedStatementField(fieldName)) { + throw new OMException( + ERROR_PREFIX + "Unsupported statement element - " + fieldName, MALFORMED_POLICY_DOCUMENT); + } + } + } + + private static boolean isSupportedStatementField(String fieldName) { + switch (fieldName) { + case "Sid": + case "Effect": + case "Action": + case "Resource": + case "Condition": + return true; + default: + return false; + } + } + /** * Parses Effect from IAM session policy and ensures it is valid and supported. */ @@ -248,28 +341,87 @@ private static void validateEffectInJsonStatement(JsonNode statement) throws OME } /** - * Reads a JsonNode and converts to a Set of String, if the node represents - * a textual value or an array of textual values. Otherwise, returns - * an empty List. + * Reads a required String or String array JSON policy element. */ - private static Set readStringOrArray(JsonNode node) { + private static Set readRequiredStringOrArray(JsonNode node, String fieldName) throws OMException { if (node == null || node.isMissingNode() || node.isNull()) { - return Collections.emptySet(); + throw new OMException(ERROR_PREFIX + "No " + fieldName + "(s) found in policy", MALFORMED_POLICY_DOCUMENT); } if (node.isTextual()) { return Collections.singleton(node.asText()); } if (node.isArray()) { final Set set = new HashSet<>(); - node.forEach(n -> { - if (n.isTextual()) { - set.add(n.asText()); + for (JsonNode n : node) { + if (!n.isTextual()) { + throw invalidStringOrArray(fieldName, node); } - }); + set.add(n.asText()); + } + if (set.isEmpty()) { + throw new OMException(ERROR_PREFIX + "No " + fieldName + "(s) found in policy", MALFORMED_POLICY_DOCUMENT); + } return set; } - return Collections.emptySet(); + throw invalidStringOrArray(fieldName, node); + } + + private static OMException invalidStringOrArray(String fieldName, JsonNode node) { + return new OMException( + ERROR_PREFIX + "Invalid " + fieldName + " in JSON policy (must be a String or Array of Strings) - " + node, + MALFORMED_POLICY_DOCUMENT); + } + + /** + * Reads and validates an s3:prefix condition value per AWS IAM session policy behavior. + *

+ * Rejects {@code null}, objects (such as {@code {}}), and arrays whose elements are not all + * strings or not all numbers/booleans. Scalar numbers and booleans are coerced to strings + * (for example {@code 123} becomes {@code "123"}). An empty array matches no prefixes and + * causes the statement to grant no access. + */ + private static Set readConditionPrefixValue(JsonNode node) throws OMException { + if (node == null || node.isMissingNode() || node.isNull()) { + throw invalidConditionPrefixValue(node); + } + if (node.isTextual() || node.isNumber() || node.isBoolean()) { + return Collections.singleton(node.asText()); + } + if (node.isArray()) { + if (node.isEmpty()) { + return Collections.emptySet(); + } + boolean allTextual = true; + boolean allNumber = true; + boolean allBoolean = true; + for (final JsonNode element : node) { + if (!element.isTextual()) { + allTextual = false; + } + if (!element.isNumber()) { + allNumber = false; + } + if (!element.isBoolean()) { + allBoolean = false; + } + } + if (!allTextual && !allNumber && !allBoolean) { + throw invalidConditionPrefixValue(node); + } + final Set prefixes = new HashSet<>(); + node.forEach(n -> prefixes.add(n.asText())); + return prefixes; + } + + throw invalidConditionPrefixValue(node); + } + + private static OMException invalidConditionPrefixValue(JsonNode node) { + return new OMException( + ERROR_PREFIX + "Invalid s3:prefix in Condition (must be a String, Number, Boolean, or homogeneous " + + "Array of Strings, Numbers, or Booleans) - " + node, + MALFORMED_POLICY_DOCUMENT); } /** @@ -319,7 +471,7 @@ private static Condition parsePrefixesFromConditions(JsonNode stmt) throws OMExc throw new OMException(ERROR_PREFIX + "Unsupported Condition key name - " + keyName, NOT_SUPPORTED_OPERATION); } - final Set prefixes = readStringOrArray(operatorValue.get(keyName)); + final Set prefixes = readConditionPrefixValue(operatorValue.get(keyName)); condition = new Condition(operator, prefixes); } @@ -596,23 +748,23 @@ private static void processResourceTypeAny(String volumeName, AuthorizerType aut addAclsForObj(objToAclsMap, volumeObj, action.volumePerms); addAclsForObj(objToAclsMap, bucketObj, action.bucketPerms); - if (condition != null && condition.prefixes != null && !condition.prefixes.isEmpty() && - action == S3Action.LIST_BUCKET) { - + if (condition != null && action == S3Action.LIST_BUCKET) { // Ensure the volume and bucket get the action addActionForKind(objToActionsMap, action, volumeObj, bucketObj, null); - for (String prefix : condition.prefixes) { - // If operator is StringEquals, ignore wildcard prefixes - this is AWS behavior - if (STRING_EQUALS.equals(condition.operator) && hasWildcard(prefix)) { - continue; - } + if (condition.prefixes != null && !condition.prefixes.isEmpty()) { + for (String prefix : condition.prefixes) { + // If operator is StringEquals, ignore wildcard prefixes - this is AWS behavior + if (STRING_EQUALS.equals(condition.operator) && hasWildcard(prefix)) { + continue; + } - final IOzoneObj listObj = createObjectResourcesFromConditionPrefix( - volumeName, authorizerType, ResourceSpec.any(), prefix, objToAclsMap, EnumSet.of(READ)); - addActionForKind(objToActionsMap, action, null, null, listObj); + final IOzoneObj listObj = createObjectResourcesFromConditionPrefix( + volumeName, authorizerType, ResourceSpec.any(), prefix, objToAclsMap, EnumSet.of(READ)); + addActionForKind(objToActionsMap, action, null, null, listObj); + } } - } else { + } else if (condition == null) { addAclsForObj(objToAclsMap, keyObj, action.objectPerms); addActionForKind(objToActionsMap, action, volumeObj, bucketObj, keyObj); } diff --git a/hadoop-ozone/common/src/test/java/org/apache/hadoop/ozone/security/acl/iam/TestIamSessionPolicyResolver.java b/hadoop-ozone/common/src/test/java/org/apache/hadoop/ozone/security/acl/iam/TestIamSessionPolicyResolver.java index 2902aa4fba09..54e3be080eaf 100644 --- a/hadoop-ozone/common/src/test/java/org/apache/hadoop/ozone/security/acl/iam/TestIamSessionPolicyResolver.java +++ b/hadoop-ozone/common/src/test/java/org/apache/hadoop/ozone/security/acl/iam/TestIamSessionPolicyResolver.java @@ -197,6 +197,17 @@ public void testInvalidJsonWithoutStatementThrows() { json, "IAM session policy: Invalid policy JSON - missing Statement", MALFORMED_POLICY_DOCUMENT); } + @Test + public void testInvalidJsonWithEmptyStatementArrayThrows() { + final String json = "{\n" + + " \"Version\": \"2012-10-17\",\n" + + " \"Statement\": []\n" + + "}"; + + expectResolveThrowsForBothAuthorizers( + json, "IAM session policy: No Statement(s) found in policy", MALFORMED_POLICY_DOCUMENT); + } + @Test public void testInvalidEffectThrows() { final String json = "{\n" + @@ -210,6 +221,18 @@ public void testInvalidEffectThrows() { expectResolveThrowsForBothAuthorizers( json, "IAM session policy: Invalid Effect in JSON policy (must be a String) - [\"Allow\"]", MALFORMED_POLICY_DOCUMENT); + + final String jsonWithNull = "{\n" + + " \"Statement\": [{\n" + + " \"Effect\": null,\n" + + " \"Action\": \"s3:ListBucket\",\n" + + " \"Resource\": \"arn:aws:s3:::bucket1\"\n" + + " }]\n" + + "}"; + + expectResolveThrowsForBothAuthorizers( + jsonWithNull, "IAM session policy: Invalid Effect in JSON policy (must be a String) - null", + MALFORMED_POLICY_DOCUMENT); } @Test @@ -225,6 +248,265 @@ public void testMissingEffectInStatementThrows() { json, "IAM session policy: Effect is missing from JSON policy", MALFORMED_POLICY_DOCUMENT); } + @Test + public void testDuplicateStatementKeysThrow() { + final String duplicateActionGetThenStar = "{\n" + + " \"Statement\": [{\n" + + " \"Effect\": \"Allow\",\n" + + " \"Action\": \"s3:GetObject\",\n" + + " \"Action\": \"s3:*\",\n" + + " \"Resource\": \"arn:aws:s3:::bucket1/*\"\n" + + " }]\n" + + "}"; + final String duplicateActionStarThenGet = "{\n" + + " \"Statement\": [{\n" + + " \"Effect\": \"Allow\",\n" + + " \"Action\": \"s3:*\",\n" + + " \"Action\": \"s3:GetObject\",\n" + + " \"Resource\": \"arn:aws:s3:::bucket1/*\"\n" + + " }]\n" + + "}"; + final String duplicateEffect = "{\n" + + " \"Statement\": [{\n" + + " \"Effect\": \"Allow\",\n" + + " \"Effect\": \"Deny\",\n" + + " \"Action\": \"s3:GetObject\",\n" + + " \"Resource\": \"arn:aws:s3:::bucket1/*\"\n" + + " }]\n" + + "}"; + final String duplicateResource = "{\n" + + " \"Statement\": [{\n" + + " \"Effect\": \"Allow\",\n" + + " \"Action\": \"s3:GetObject\",\n" + + " \"Resource\": \"arn:aws:s3:::bucket1/*\",\n" + + " \"Resource\": \"arn:aws:s3:::bucket2/*\"\n" + + " }]\n" + + "}"; + + expectDuplicateFieldThrowsForBothAuthorizers(duplicateActionGetThenStar, "Action"); + expectDuplicateFieldThrowsForBothAuthorizers(duplicateActionStarThenGet, "Action"); + expectDuplicateFieldThrowsForBothAuthorizers(duplicateEffect, "Effect"); + expectDuplicateFieldThrowsForBothAuthorizers(duplicateResource, "Resource"); + } + + @Test + public void testDuplicateNestedConditionKeysThrow() { + final String duplicateS3Prefix = "{\n" + + " \"Statement\": [{\n" + + " \"Effect\": \"Allow\",\n" + + " \"Action\": \"s3:ListBucket\",\n" + + " \"Resource\": \"arn:aws:s3:::bucket1\",\n" + + " \"Condition\": { \"StringEquals\": { \"s3:prefix\": \"team/*\", \"s3:prefix\": \"other/*\" } }\n" + + " }]\n" + + "}"; + + expectDuplicateFieldThrowsForBothAuthorizers(duplicateS3Prefix, "s3:prefix"); + } + + @Test + public void testDuplicateConditionAtStatementLevelThrows() { + final String duplicateConditionStringEqualsThenStringLike = "{\n" + + " \"Statement\": [{\n" + + " \"Effect\": \"Allow\",\n" + + " \"Action\": \"s3:ListBucket\",\n" + + " \"Resource\": \"arn:aws:s3:::bucket1\",\n" + + " \"Condition\": { \"StringEquals\": { \"s3:prefix\": \"team/*\" } },\n" + + " \"Condition\": { \"StringLike\": { \"s3:prefix\": \"other/*\" } }\n" + + " }]\n" + + "}"; + final String duplicateConditionStringLikeThenStringEquals = "{\n" + + " \"Statement\": [{\n" + + " \"Effect\": \"Allow\",\n" + + " \"Action\": \"s3:ListBucket\",\n" + + " \"Resource\": \"arn:aws:s3:::bucket1\",\n" + + " \"Condition\": { \"StringLike\": { \"s3:prefix\": \"other/*\" } },\n" + + " \"Condition\": { \"StringEquals\": { \"s3:prefix\": \"team/*\" } }\n" + + " }]\n" + + "}"; + + expectDuplicateFieldThrowsForBothAuthorizers(duplicateConditionStringEqualsThenStringLike, "Condition"); + expectDuplicateFieldThrowsForBothAuthorizers(duplicateConditionStringLikeThenStringEquals, "Condition"); + } + + @Test + public void testInvalidStatementElementThrows() { + final String statementScalar = "{\n" + + " \"Statement\": \"not-an-object\"\n" + + "}"; + final String statementArrayWithNonObject = "{\n" + + " \"Statement\": [\"not-an-object\"]\n" + + "}"; + + expectResolveThrowsForBothAuthorizers( + statementScalar, "IAM session policy: Invalid Statement in JSON policy (must be an Object) - \"not-an-object\"", + MALFORMED_POLICY_DOCUMENT); + expectResolveThrowsForBothAuthorizers( + statementArrayWithNonObject, + "IAM session policy: Invalid Statement in JSON policy (must be an Object) - \"not-an-object\"", + MALFORMED_POLICY_DOCUMENT); + } + + @Test + public void testMissingActionInStatementThrows() { + final String json = "{\n" + + " \"Statement\": [{\n" + + " \"Effect\": \"Allow\",\n" + + " \"Resource\": \"arn:aws:s3:::bucket1/*\"\n" + + " }]\n" + + "}"; + + expectResolveThrowsForBothAuthorizers( + json, "IAM session policy: No Action(s) found in policy", MALFORMED_POLICY_DOCUMENT); + } + + @Test + public void testMissingResourceInStatementThrows() { + final String json = "{\n" + + " \"Statement\": [{\n" + + " \"Effect\": \"Allow\",\n" + + " \"Action\": \"s3:GetObject\"\n" + + " }]\n" + + "}"; + + expectResolveThrowsForBothAuthorizers( + json, "IAM session policy: No Resource(s) found in policy", MALFORMED_POLICY_DOCUMENT); + } + + @Test + public void testNullResourceInStatementThrows() { + final String json = "{\n" + + " \"Statement\": [{\n" + + " \"Effect\": \"Allow\",\n" + + " \"Action\": \"s3:GetObject\",\n" + + " \"Resource\": null\n" + + " }]\n" + + "}"; + + expectResolveThrowsForBothAuthorizers( + json, "IAM session policy: No Resource(s) found in policy", MALFORMED_POLICY_DOCUMENT); + } + + @Test + public void testInvalidResourceInStatementThrows() { + final String json = "{\n" + + " \"Statement\": [{\n" + + " \"Effect\": \"Allow\",\n" + + " \"Action\": \"s3:GetObject\",\n" + + " \"Resource\": \"INVALID\"\n" + + " }]\n" + + "}"; + + expectResolveThrowsForBothAuthorizers( + json, "IAM session policy: Unsupported Resource Arn - INVALID", NOT_SUPPORTED_OPERATION); + } + + @Test + public void testUnsupportedStatementElementsThrow() { + final Set unsupportedStatementElements = strSet("NotAction", "NotResource", "Principal"); + for (String unsupportedStatementElement : unsupportedStatementElements) { + final String json = "{\n" + + " \"Statement\": [{\n" + + " \"Effect\": \"Allow\",\n" + + " \"Action\": \"s3:GetObject\",\n" + + " \"Resource\": \"arn:aws:s3:::bucket1/*\",\n" + + " \"" + unsupportedStatementElement + "\": \"ignored\"\n" + + " }]\n" + + "}"; + + expectResolveThrowsForBothAuthorizers( + json, "IAM session policy: Unsupported statement element - " + unsupportedStatementElement, + MALFORMED_POLICY_DOCUMENT); + } + } + + @Test + public void testInvalidActionShapeThrows() { + final String actionObject = "{\n" + + " \"Statement\": [{\n" + + " \"Effect\": \"Allow\",\n" + + " \"Action\": {\"Name\":\"s3:GetObject\"},\n" + + " \"Resource\": \"arn:aws:s3:::bucket1/*\"\n" + + " }]\n" + + "}"; + final String actionArrayWithNonString = "{\n" + + " \"Statement\": [{\n" + + " \"Effect\": \"Allow\",\n" + + " \"Action\": [\"s3:GetObject\", 1],\n" + + " \"Resource\": \"arn:aws:s3:::bucket1/*\"\n" + + " }]\n" + + "}"; + final String emptyActionArray = "{\n" + + " \"Statement\": [{\n" + + " \"Effect\": \"Allow\",\n" + + " \"Action\": [],\n" + + " \"Resource\": \"arn:aws:s3:::bucket1/*\"\n" + + " }]\n" + + "}"; + final String nullAction = "{\n" + + " \"Statement\": [{\n" + + " \"Effect\": \"Allow\",\n" + + " \"Action\": null,\n" + + " \"Resource\": \"arn:aws:s3:::bucket1/*\"\n" + + " }]\n" + + "}"; + + expectResolveThrowsForBothAuthorizers( + actionObject, "IAM session policy: Invalid Action in JSON policy (must be a String or Array of Strings) - " + + "{\"Name\":\"s3:GetObject\"}", MALFORMED_POLICY_DOCUMENT); + expectResolveThrowsForBothAuthorizers( + actionArrayWithNonString, + "IAM session policy: Invalid Action in JSON policy (must be a String or Array of Strings) - " + + "[\"s3:GetObject\",1]", MALFORMED_POLICY_DOCUMENT); + expectResolveThrowsForBothAuthorizers( + emptyActionArray, "IAM session policy: No Action(s) found in policy", MALFORMED_POLICY_DOCUMENT); + expectResolveThrowsForBothAuthorizers( + nullAction, "IAM session policy: No Action(s) found in policy", MALFORMED_POLICY_DOCUMENT); + } + + @Test + public void testInvalidResourceShapeThrows() { + final String resourceObject = "{\n" + + " \"Statement\": [{\n" + + " \"Effect\": \"Allow\",\n" + + " \"Action\": \"s3:GetObject\",\n" + + " \"Resource\": {\"Arn\":\"arn:aws:s3:::bucket1/*\"}\n" + + " }]\n" + + "}"; + final String resourceArrayWithNonString = "{\n" + + " \"Statement\": [{\n" + + " \"Effect\": \"Allow\",\n" + + " \"Action\": \"s3:GetObject\",\n" + + " \"Resource\": [\"arn:aws:s3:::bucket1/*\", 1]\n" + + " }]\n" + + "}"; + final String emptyResourceArray = "{\n" + + " \"Statement\": [{\n" + + " \"Effect\": \"Allow\",\n" + + " \"Action\": \"s3:GetObject\",\n" + + " \"Resource\": []\n" + + " }]\n" + + "}"; + final String nullResource = "{\n" + + " \"Statement\": [{\n" + + " \"Effect\": \"Allow\",\n" + + " \"Action\": \"s3:GetObject\",\n" + + " \"Resource\": null\n" + + " }]\n" + + "}"; + + expectResolveThrowsForBothAuthorizers( + resourceObject, "IAM session policy: Invalid Resource in JSON policy (must be a String or " + + "Array of Strings) - {\"Arn\":\"arn:aws:s3:::bucket1/*\"}", MALFORMED_POLICY_DOCUMENT); + expectResolveThrowsForBothAuthorizers( + resourceArrayWithNonString, + "IAM session policy: Invalid Resource in JSON policy (must be a String or Array of Strings) - " + + "[\"arn:aws:s3:::bucket1/*\",1]", MALFORMED_POLICY_DOCUMENT); + expectResolveThrowsForBothAuthorizers( + emptyResourceArray, "IAM session policy: No Resource(s) found in policy", MALFORMED_POLICY_DOCUMENT); + expectResolveThrowsForBothAuthorizers( + nullResource, "IAM session policy: No Resource(s) found in policy", MALFORMED_POLICY_DOCUMENT); + } + @Test public void testInvalidNumberOfConditionsThrows() { final String json = "{\n" + @@ -303,6 +585,140 @@ public void testInvalidConditionAttributeStructureThrows() { MALFORMED_POLICY_DOCUMENT); } + @Test + public void testInvalidS3PrefixConditionValueThrows() { + final String nullPrefix = "{\n" + + " \"Statement\": [{\n" + + " \"Effect\": \"Allow\",\n" + + " \"Action\": \"s3:ListBucket\",\n" + + " \"Resource\": \"arn:aws:s3:::b\",\n" + + " \"Condition\": { \"StringEquals\": { \"s3:prefix\": null } }\n" + + " }]\n" + + "}"; + final String objectPrefix = "{\n" + + " \"Statement\": [{\n" + + " \"Effect\": \"Allow\",\n" + + " \"Action\": \"s3:ListBucket\",\n" + + " \"Resource\": \"arn:aws:s3:::b\",\n" + + " \"Condition\": { \"StringEquals\": { \"s3:prefix\": {} } }\n" + + " }]\n" + + "}"; + final String mixedStringAndNumberArray = "{\n" + + " \"Statement\": [{\n" + + " \"Effect\": \"Allow\",\n" + + " \"Action\": \"s3:ListBucket\",\n" + + " \"Resource\": \"arn:aws:s3:::b\",\n" + + " \"Condition\": { \"StringEquals\": { \"s3:prefix\": [\"team/*\", 1] } }\n" + + " }]\n" + + "}"; + + final String invalidPrefixMessagePrefix = "IAM session policy: Invalid s3:prefix in Condition (must be a " + + "String, Number, Boolean, or homogeneous Array of Strings, Numbers, or Booleans) - "; + + expectResolveThrowsForBothAuthorizers(nullPrefix, invalidPrefixMessagePrefix + "null", MALFORMED_POLICY_DOCUMENT); + expectResolveThrowsForBothAuthorizers(objectPrefix, invalidPrefixMessagePrefix + "{}", MALFORMED_POLICY_DOCUMENT); + expectResolveThrowsForBothAuthorizers( + mixedStringAndNumberArray, invalidPrefixMessagePrefix + "[\"team/*\",1]", MALFORMED_POLICY_DOCUMENT); + } + + @Test + public void testAcceptedS3PrefixConditionValueCoercion() throws OMException { + final String numericScalar = "{\n" + + " \"Statement\": [{\n" + + " \"Effect\": \"Allow\",\n" + + " \"Action\": \"s3:ListBucket\",\n" + + " \"Resource\": \"arn:aws:s3:::my-bucket\",\n" + + " \"Condition\": { \"StringEquals\": { \"s3:prefix\": 123 } }\n" + + " }]\n" + + "}"; + final String booleanScalar = "{\n" + + " \"Statement\": [{\n" + + " \"Effect\": \"Allow\",\n" + + " \"Action\": \"s3:ListBucket\",\n" + + " \"Resource\": \"arn:aws:s3:::my-bucket\",\n" + + " \"Condition\": { \"StringEquals\": { \"s3:prefix\": true } }\n" + + " }]\n" + + "}"; + final String numericArray = "{\n" + + " \"Statement\": [{\n" + + " \"Effect\": \"Allow\",\n" + + " \"Action\": \"s3:ListBucket\",\n" + + " \"Resource\": \"arn:aws:s3:::my-bucket\",\n" + + " \"Condition\": { \"StringEquals\": { \"s3:prefix\": [123] } }\n" + + " }]\n" + + "}"; + + final Set numericScalarNative = resolve(numericScalar, VOLUME, NATIVE); + final Set numericScalarRanger = resolve(numericScalar, VOLUME, RANGER); + assertThat(numericScalarNative).containsExactlyInAnyOrder( + new OzoneGrant(objSet(volume(), prefix("my-bucket", "123")), acls(READ), strSet("ListBucket")), + new OzoneGrant(objSet(bucket("my-bucket")), acls(READ, LIST), strSet("ListBucket"))); + assertThat(numericScalarRanger).containsExactlyInAnyOrder( + new OzoneGrant(objSet(volume(), key("my-bucket", "123")), acls(READ), strSet("ListBucket")), + new OzoneGrant(objSet(bucket("my-bucket")), acls(READ, LIST), strSet("ListBucket"))); + + final Set booleanScalarNative = resolve(booleanScalar, VOLUME, NATIVE); + final Set booleanScalarRanger = resolve(booleanScalar, VOLUME, RANGER); + assertThat(booleanScalarNative).containsExactlyInAnyOrder( + new OzoneGrant(objSet(volume(), prefix("my-bucket", "true")), acls(READ), strSet("ListBucket")), + new OzoneGrant(objSet(bucket("my-bucket")), acls(READ, LIST), strSet("ListBucket"))); + assertThat(booleanScalarRanger).containsExactlyInAnyOrder( + new OzoneGrant(objSet(volume(), key("my-bucket", "true")), acls(READ), strSet("ListBucket")), + new OzoneGrant(objSet(bucket("my-bucket")), acls(READ, LIST), strSet("ListBucket"))); + + final Set numericArrayNative = resolve(numericArray, VOLUME, NATIVE); + final Set numericArrayRanger = resolve(numericArray, VOLUME, RANGER); + assertThat(numericArrayNative).containsExactlyInAnyOrder( + new OzoneGrant(objSet(volume(), prefix("my-bucket", "123")), acls(READ), strSet("ListBucket")), + new OzoneGrant(objSet(bucket("my-bucket")), acls(READ, LIST), strSet("ListBucket"))); + assertThat(numericArrayRanger).containsExactlyInAnyOrder( + new OzoneGrant(objSet(volume(), key("my-bucket", "123")), acls(READ), strSet("ListBucket")), + new OzoneGrant(objSet(bucket("my-bucket")), acls(READ, LIST), strSet("ListBucket"))); + } + + @Test + public void testEmptyS3PrefixConditionArrayDoesNotGrantAccess() throws OMException { + final String emptyPrefixArrayOnAnyResource = "{\n" + + " \"Statement\": [{\n" + + " \"Effect\": \"Allow\",\n" + + " \"Action\": \"s3:ListBucket\",\n" + + " \"Resource\": \"*\",\n" + + " \"Condition\": { \"StringEquals\": { \"s3:prefix\": [] } }\n" + + " }]\n" + + "}"; + final String emptyPrefixArrayOnBucket = "{\n" + + " \"Statement\": [{\n" + + " \"Effect\": \"Allow\",\n" + + " \"Action\": \"s3:ListBucket\",\n" + + " \"Resource\": \"arn:aws:s3:::my-bucket\",\n" + + " \"Condition\": { \"StringEquals\": { \"s3:prefix\": [] } }\n" + + " }]\n" + + "}"; + + assertThat(resolve(emptyPrefixArrayOnAnyResource, VOLUME, NATIVE)).isEmpty(); + assertThat(resolve(emptyPrefixArrayOnAnyResource, VOLUME, RANGER)).isEmpty(); + assertThat(resolve(emptyPrefixArrayOnBucket, VOLUME, NATIVE)).isEmpty(); + assertThat(resolve(emptyPrefixArrayOnBucket, VOLUME, RANGER)).isEmpty(); + } + + @Test + public void testEmptyS3PrefixConditionArrayWithMultipleActionsAndResourcesDoesNotGrantAccess() throws OMException { + final String json = "{\n" + + " \"Statement\": [{\n" + + " \"Effect\": \"Allow\",\n" + + " \"Action\": [\"s3:ListBucket\", \"s3:PutObject\", \"s3:DeleteObject\"],\n" + + " \"Resource\": [\n" + + " \"arn:aws:s3:::my-bucket\",\n" + + " \"arn:aws:s3:::my-bucket/*\"\n" + + " ],\n" + + " \"Condition\": { \"StringEquals\": { \"s3:prefix\": [] } }\n" + + " }]\n" + + "}"; + + assertThat(resolve(json, VOLUME, NATIVE)).isEmpty(); + assertThat(resolve(json, VOLUME, RANGER)).isEmpty(); + } + @Test public void testInvalidJsonThrows() { final String invalidJson = "{[{{}]\"\""; @@ -2454,6 +2870,12 @@ private static void expectResolveThrowsForBothAuthorizers(String json, String ex expectResolveThrows(json, RANGER, expectedMessage, expectedCode); } + private static void expectDuplicateFieldThrowsForBothAuthorizers(String json, String duplicateFieldName) { + expectResolveThrowsForBothAuthorizers( + json, "IAM session policy: Duplicate field '" + duplicateFieldName + "' in session policy", + MALFORMED_POLICY_DOCUMENT); + } + /** * Ensure resources containing wildcards in buckets throw an Exception * when the OzoneNativeAuthorizer is used. 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 f465dcaaf795..3ae775b716b3 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 @@ -471,14 +471,13 @@ public void testValidRoleSessionNameMinLengthBoundary() throws IOException { @Test public void testAssumeRoleWithSessionPolicyPresent() throws IOException { - 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) + .setAwsIamSessionPolicy(AWS_IAM_POLICY) .setRequestId(REQUEST_ID) ).build(); @@ -491,6 +490,40 @@ public void testAssumeRoleWithSessionPolicyPresent() throws IOException { assertMarkForAuditCalled(requestWithCredentials); } + @Test + public void testMalformedSessionPolicyDoesNotIssueCredentials() throws IOException { + final String sessionPolicy = "{\n" + + " \"Statement\": [{\n" + + " \"Effect\": \"Allow\",\n" + + " \"Action\": \"s3:GetObject\",\n" + + " \"Action\": \"s3:*\",\n" + + " \"Resource\": \"arn:aws:s3:::bucket1/*\"\n" + + " }]\n" + + "}"; + final OMRequest omRequest = baseOmRequestBuilder() + .setAssumeRoleRequest( + AssumeRoleRequest.newBuilder() + .setRoleArn(ROLE_ARN_1) + .setRoleSessionName(SESSION_NAME) + .setDurationSeconds(3600) + .setAwsIamSessionPolicy(sessionPolicy) + .setRequestId(REQUEST_ID) + ).build(); + + final S3AssumeRoleRequest request = new S3AssumeRoleRequest(omRequest, CLOCK); + final OMRequest preExecutedRequest = request.preExecute(ozoneManager); + final S3AssumeRoleRequest requestWithCredentials = new S3AssumeRoleRequest(preExecutedRequest, CLOCK); + final OMClientResponse response = requestWithCredentials.validateAndUpdateCache(ozoneManager, context); + final OMResponse omResponse = response.getOMResponse(); + + assertThat(omResponse.getStatus()).isEqualTo(Status.MALFORMED_POLICY_DOCUMENT); + assertThat(omResponse.getMessage()).isEqualTo("IAM session policy: Duplicate field 'Action' in session policy"); + assertThat(omResponse.hasAssumeRoleResponse()).isFalse(); + verify(accessAuthorizer, never()).generateAssumeRoleSessionPolicy( + any(org.apache.hadoop.ozone.security.acl.AssumeRoleRequest.class)); + assertMarkForAuditCalled(requestWithCredentials); + } + @Test public void testGetSessionPolicyUsesDefaultVolumeWhenMultiTenantDisabled() throws Exception { when(ozoneManager.isS3MultiTenancyEnabled()).thenReturn(false); From 30a353d780bff4deb0fd1d024921ddf965bd55a4 Mon Sep 17 00:00:00 2001 From: fmorg-git Date: Wed, 2 Sep 2026 20:52:17 -0700 Subject: [PATCH 52/54] HDDS-16371. [STS] Better messaging for invalid endpoint path and prevent non-canonical token use (#11186) --- .../security/ozone-secure-sts.resource | 6 +- .../smoketest/security/ozone-secure-sts.robot | 12 ++ .../ozone/security/STSSecurityUtil.java | 4 + .../ozone/security/TestSTSSecurityUtil.java | 26 ++++ .../OSTSNotFoundExceptionMapper.java | 93 ++++++++++++++ .../hadoop/ozone/s3sts/Application.java | 2 + .../TestOSTSNotFoundExceptionMapper.java | 120 ++++++++++++++++++ 7 files changed, 260 insertions(+), 3 deletions(-) create mode 100644 hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/exception/OSTSNotFoundExceptionMapper.java create mode 100644 hadoop-ozone/s3gateway/src/test/java/org/apache/hadoop/ozone/s3/exception/TestOSTSNotFoundExceptionMapper.java diff --git a/hadoop-ozone/dist/src/main/smoketest/security/ozone-secure-sts.resource b/hadoop-ozone/dist/src/main/smoketest/security/ozone-secure-sts.resource index 19cb6f4e2022..8e63f622545c 100644 --- a/hadoop-ozone/dist/src/main/smoketest/security/ozone-secure-sts.resource +++ b/hadoop-ozone/dist/src/main/smoketest/security/ozone-secure-sts.resource @@ -178,13 +178,13 @@ Assume Role And Configure STS Profile Configure STS Profile ${STS_ACCESS_KEY_ID} ${STS_SECRET_KEY} ${STS_SESSION_TOKEN} Assume Role Should Fail - [Arguments] ${perm_access_key_id} ${perm_secret_key} ${policy_json}=${EMPTY} ${expected_error}=AccessDenied ${expected_http_code}=${EMPTY} ${role_arn}=${ROLE_ARN_OBS} ${role_session_name}=${ROLE_SESSION_NAME} ${duration_seconds}=900 ${extra_cli_args}=${EMPTY} + [Arguments] ${perm_access_key_id} ${perm_secret_key} ${policy_json}=${EMPTY} ${expected_error}=AccessDenied ${expected_http_code}=${EMPTY} ${role_arn}=${ROLE_ARN_OBS} ${role_session_name}=${ROLE_SESSION_NAME} ${duration_seconds}=900 ${extra_cli_args}=${EMPTY} ${sts_endpoint_url}=${STS_ENDPOINT_URL} Configure AWS Profile permanent ${perm_access_key_id} ${perm_secret_key} IF '${expected_http_code}' != '${EMPTY}' # Note: curl in the s3g container doesn't reliably support --aws-sigv4, # so use awscli debug output to capture the HTTP response code. - ${cmd} = Set Variable aws sts assume-role --endpoint-url ${STS_ENDPOINT_URL} --role-arn ${role_arn} --role-session-name ${role_session_name} --profile permanent --debug 2>&1 + ${cmd} = Set Variable aws sts assume-role --endpoint-url ${sts_endpoint_url} --role-arn ${role_arn} --role-session-name ${role_session_name} --profile permanent --debug 2>&1 ${cmd} = Set Variable If '${duration_seconds}' != '${EMPTY}' ${cmd} --duration-seconds ${duration_seconds} ${cmd} ${cmd} = Set Variable If '${policy_json}' != '${EMPTY}' ${cmd} --policy '${policy_json}' ${cmd} ${cmd} = Set Variable If '${extra_cli_args}' != '${EMPTY}' ${cmd} ${extra_cli_args} ${cmd} @@ -198,7 +198,7 @@ Assume Role Should Fail ${http_code} = Get From List ${http_codes} -1 Should Be Equal As Strings ${http_code} ${expected_http_code} ELSE - ${cmd} = Set Variable aws sts assume-role --endpoint-url ${STS_ENDPOINT_URL} --role-arn ${role_arn} --role-session-name ${role_session_name} --output json --profile permanent + ${cmd} = Set Variable aws sts assume-role --endpoint-url ${sts_endpoint_url} --role-arn ${role_arn} --role-session-name ${role_session_name} --output json --profile permanent ${cmd} = Set Variable If '${duration_seconds}' != '${EMPTY}' ${cmd} --duration-seconds ${duration_seconds} ${cmd} ${cmd} = Set Variable If '${policy_json}' != '${EMPTY}' ${cmd} --policy '${policy_json}' ${cmd} diff --git a/hadoop-ozone/dist/src/main/smoketest/security/ozone-secure-sts.robot b/hadoop-ozone/dist/src/main/smoketest/security/ozone-secure-sts.robot index 3ee4e19a5aef..c07ee6afc988 100644 --- a/hadoop-ozone/dist/src/main/smoketest/security/ozone-secure-sts.robot +++ b/hadoop-ozone/dist/src/main/smoketest/security/ozone-secure-sts.robot @@ -611,6 +611,14 @@ Assume Role Request With Oversized Payload Should Fail ${large_policy} = Generate Oversized Session Policy Assume Role Should Fail perm_access_key_id=${PERMANENT_ACCESS_KEY_ID} perm_secret_key=${PERMANENT_SECRET_KEY} policy_json=${large_policy} expected_error=PayloadTooLarge role_arn=${ICEBERG_ALL_ACCESS_ROLE_OBS_ARN} +Doubled STS Session Token Must Fail + # Concatenating a valid session token with itself must be rejected as non-canonical. + Assume Role And Get Temporary Credentials perm_access_key_id=${PERMANENT_ACCESS_KEY_ID} perm_secret_key=${PERMANENT_SECRET_KEY} role_arn=${ICEBERG_ALL_ACCESS_ROLE_OBS_ARN} + ${doubled_token} = Catenate SEPARATOR= ${STS_SESSION_TOKEN} ${STS_SESSION_TOKEN} + Configure STS Profile ${STS_ACCESS_KEY_ID} ${STS_SECRET_KEY} ${doubled_token} + Get Object Should Fail ${ICEBERG_BUCKET_OBS} ${ICEBERG_BUCKET_TESTFILE} AccessDenied + Put Object Should Fail ${ICEBERG_BUCKET_OBS} ${ICEBERG_BUCKET_TESTFILE} AccessDenied + Tampered STS Token Service, Policy, or Signature Must Fail # Taking valid STS session token and mutating different parts of it must render it unusable ${session_policy} = Set Variable {"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:GetObject","Resource":"arn:aws:s3:::${ICEBERG_BUCKET_OBS}/*"}]} @@ -660,6 +668,10 @@ Assume Role Without Duration Should Default To One Hour Configure STS Profile ${STS_ACCESS_KEY_ID} ${STS_SECRET_KEY} ${STS_SESSION_TOKEN} Get Object Should Succeed ${ICEBERG_BUCKET_OBS} ${ICEBERG_BUCKET_TESTFILE} +Assume Role Should Fail For Invalid Sts Endpoint Path + Assume Role Should Fail 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} sts_endpoint_url=${STS_ENDPOINT_URL}/sts + Assume Role Should Fail 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} sts_endpoint_url=${STS_ENDPOINT_URL}/invalidEndpoint + Assume Role Should Fail For Too Short Role Arn 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=a diff --git a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/security/STSSecurityUtil.java b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/security/STSSecurityUtil.java index ead735f12eac..8862f16d235e 100644 --- a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/security/STSSecurityUtil.java +++ b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/security/STSSecurityUtil.java @@ -160,6 +160,10 @@ private static Token decodeTokenFromString(String encodedTok // instead of failing the OM request. try { token.decodeFromUrlString(encodedToken); + final String canonical = token.encodeToUrlString(); + if (!canonical.equals(encodedToken)) { + throw new SecretManager.InvalidToken("Failed to decode STS token string: non-canonical token encoding"); + } return token; } catch (IOException | RuntimeException e) { throw new SecretManager.InvalidToken("Failed to decode STS token string: " + e); diff --git a/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/security/TestSTSSecurityUtil.java b/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/security/TestSTSSecurityUtil.java index 290d848535a8..608be8f6603a 100644 --- a/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/security/TestSTSSecurityUtil.java +++ b/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/security/TestSTSSecurityUtil.java @@ -303,6 +303,32 @@ public void testConstructValidateAndDecryptSTSTokenInvalidSignature() throws Exc .hasMessageContaining("Invalid STS token format: Invalid STS token - signature is not correct for token"); } + @Test + public void testConstructValidateAndDecryptSTSTokenRejectsDoubledToken() throws Exception { + final String tokenString = tokenSecretManager.createSTSTokenString( + TEMP_ACCESS_KEY, ORIGINAL_ACCESS_KEY, ROLE_ARN, DURATION_SECONDS, + SECRET_ACCESS_KEY, SESSION_POLICY, clock); + + assertThatThrownBy(() -> + STSSecurityUtil.constructValidateAndDecryptSTSToken(tokenString + tokenString, secretKeyClient, clock)) + .isInstanceOf(OMException.class) + .satisfies(exception -> assertThat(((OMException) exception).getResult()).isEqualTo(INVALID_TOKEN)) + .hasMessageContaining("non-canonical token encoding"); + } + + @Test + public void testConstructValidateAndDecryptSTSTokenRejectsTokenWithSuffix() throws Exception { + final String tokenString = tokenSecretManager.createSTSTokenString( + TEMP_ACCESS_KEY, ORIGINAL_ACCESS_KEY, ROLE_ARN, DURATION_SECONDS, + SECRET_ACCESS_KEY, SESSION_POLICY, clock); + + assertThatThrownBy(() -> + STSSecurityUtil.constructValidateAndDecryptSTSToken(tokenString + "garbage", secretKeyClient, clock)) + .isInstanceOf(OMException.class) + .satisfies(exception -> assertThat(((OMException) exception).getResult()).isEqualTo(INVALID_TOKEN)) + .hasMessageContaining("non-canonical token encoding"); + } + @Test public void testConstructValidateAndDecryptSTSTokenEmptyString() { // Try to decrypt an empty token string diff --git a/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/exception/OSTSNotFoundExceptionMapper.java b/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/exception/OSTSNotFoundExceptionMapper.java new file mode 100644 index 000000000000..511ea26547bf --- /dev/null +++ b/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/exception/OSTSNotFoundExceptionMapper.java @@ -0,0 +1,93 @@ +/* + * 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.s3.exception; + +import static org.apache.hadoop.ozone.s3.exception.S3ErrorTable.STS_VALIDATION_ERROR; + +import com.google.common.annotations.VisibleForTesting; +import javax.inject.Inject; +import javax.ws.rs.NotFoundException; +import javax.ws.rs.core.Context; +import javax.ws.rs.core.MediaType; +import javax.ws.rs.core.Response; +import javax.ws.rs.core.UriInfo; +import javax.ws.rs.ext.ExceptionMapper; +import javax.ws.rs.ext.Provider; +import org.apache.commons.lang3.StringUtils; +import org.apache.hadoop.ozone.s3.RequestIdentifier; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Maps unmatched STS endpoint paths to AWS STS compatible XML errors. + *

+ * Without this mapper, Jersey returns Jetty HTML 404 pages for paths such as {@code /sts}, + * which AWS clients report as {@code Unknown}. + */ +@Provider +public class OSTSNotFoundExceptionMapper implements ExceptionMapper { + + private static final Logger LOG = LoggerFactory.getLogger(OSTSNotFoundExceptionMapper.class); + + @Inject + private RequestIdentifier requestIdentifier; + + @Context + private UriInfo uriInfo; + + @Override + public Response toResponse(NotFoundException exception) { + final String requestPath = getRequestPath(); + final String validationMessage = buildValidationMessage(requestPath); + if (LOG.isDebugEnabled()) { + LOG.debug("Returning STS validation error for unmatched path: {}", requestPath); + } + final OSTSException stsException = new OSTSException(STS_VALIDATION_ERROR).withMessage(validationMessage); + stsException.setRequestId(requestIdentifier.getRequestId()); + return Response.status(stsException.getHttpCode()) + .entity(stsException.toXml()) + .type(MediaType.APPLICATION_XML) + .build(); + } + + private String getRequestPath() { + if (uriInfo == null) { + return "/"; + } + final String path = uriInfo.getPath(); + if (StringUtils.isBlank(path)) { + return "/"; + } + return path.startsWith("/") ? path : "/" + path; + } + + private static String buildValidationMessage(String requestPath) { + return "1 validation error detected: Invalid STS endpoint path '" + requestPath + "'. " + + "Ozone STS is served at the root path /."; + } + + @VisibleForTesting + public void setRequestIdentifier(RequestIdentifier requestIdentifier) { + this.requestIdentifier = requestIdentifier; + } + + @VisibleForTesting + public void setUriInfo(UriInfo uriInfo) { + this.uriInfo = uriInfo; + } +} diff --git a/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3sts/Application.java b/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3sts/Application.java index b4db14dfa611..0d6e4b4c4c21 100644 --- a/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3sts/Application.java +++ b/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3sts/Application.java @@ -19,6 +19,7 @@ import org.apache.hadoop.ozone.s3.S3STSHeadersResponseFilter; import org.apache.hadoop.ozone.s3.exception.OSTSExceptionMapper; +import org.apache.hadoop.ozone.s3.exception.OSTSNotFoundExceptionMapper; import org.glassfish.jersey.server.ResourceConfig; /** @@ -30,6 +31,7 @@ public Application() { register(org.apache.hadoop.ozone.s3.AuthorizationFilter.class); register(org.apache.hadoop.ozone.s3.ClientIpFilter.class); register(OSTSExceptionMapper.class); + register(OSTSNotFoundExceptionMapper.class); register(S3STSHeadersResponseFilter.class); } } diff --git a/hadoop-ozone/s3gateway/src/test/java/org/apache/hadoop/ozone/s3/exception/TestOSTSNotFoundExceptionMapper.java b/hadoop-ozone/s3gateway/src/test/java/org/apache/hadoop/ozone/s3/exception/TestOSTSNotFoundExceptionMapper.java new file mode 100644 index 000000000000..c023d7b2c9c9 --- /dev/null +++ b/hadoop-ozone/s3gateway/src/test/java/org/apache/hadoop/ozone/s3/exception/TestOSTSNotFoundExceptionMapper.java @@ -0,0 +1,120 @@ +/* + * 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.s3.exception; + +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.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.io.StringReader; +import javax.ws.rs.NotFoundException; +import javax.ws.rs.core.MediaType; +import javax.ws.rs.core.Response; +import javax.ws.rs.core.UriInfo; +import javax.xml.parsers.DocumentBuilder; +import javax.xml.parsers.DocumentBuilderFactory; +import org.apache.hadoop.ozone.s3.RequestIdentifier; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.w3c.dom.Document; +import org.w3c.dom.Element; +import org.xml.sax.InputSource; + +/** + * Unit tests for {@link OSTSNotFoundExceptionMapper}. + */ +public class TestOSTSNotFoundExceptionMapper { + private static final String REQUEST_ID = "test-request-id"; + private static final String STS_NS = "https://sts.amazonaws.com/doc/2011-06-15/"; + + private OSTSNotFoundExceptionMapper mapper; + + @BeforeEach + public void setup() { + mapper = new OSTSNotFoundExceptionMapper(); + final RequestIdentifier requestIdentifier = mock(RequestIdentifier.class); + when(requestIdentifier.getRequestId()).thenReturn(REQUEST_ID); + mapper.setRequestIdentifier(requestIdentifier); + } + + @Test + public void testMapsNotFoundToStsValidationErrorForStsPath() throws Exception { + mapper.setUriInfo(createUriInfo("sts")); + + try (Response response = mapper.toResponse(new NotFoundException())) { + + assertEquals(Response.Status.BAD_REQUEST.getStatusCode(), response.getStatus()); + assertEquals(MediaType.APPLICATION_XML_TYPE, response.getMediaType()); + assertStsValidationErrorXml((String) response.getEntity(), "/sts"); + } + } + + @Test + public void testMapsNotFoundToStsValidationErrorForUnknownPath() throws Exception { + mapper.setUriInfo(createUriInfo("foo/bar")); + + try (Response response = mapper.toResponse(new NotFoundException())) { + + assertEquals(Response.Status.BAD_REQUEST.getStatusCode(), response.getStatus()); + assertEquals(MediaType.APPLICATION_XML_TYPE, response.getMediaType()); + assertStsValidationErrorXml((String) response.getEntity(), "/foo/bar"); + } + } + + @Test + public void testMapsNotFoundWhenRequestContextIsUnavailable() throws Exception { + try (Response response = mapper.toResponse(new NotFoundException())) { + + assertEquals(Response.Status.BAD_REQUEST.getStatusCode(), response.getStatus()); + assertEquals(MediaType.APPLICATION_XML_TYPE, response.getMediaType()); + assertStsValidationErrorXml((String) response.getEntity(), "/"); + } + } + + private static UriInfo createUriInfo(String path) { + final UriInfo uriInfo = mock(UriInfo.class); + when(uriInfo.getPath()).thenReturn(path); + return uriInfo; + } + + private static void assertStsValidationErrorXml(String xml, String expectedPathInMessage) throws Exception { + final Document doc = parseXml(xml); + final Element root = doc.getDocumentElement(); + assertEquals("ErrorResponse", root.getLocalName()); + assertEquals(STS_NS, root.getNamespaceURI()); + assertEquals("Sender", doc.getElementsByTagName("Type").item(0).getTextContent()); + assertEquals("ValidationError", doc.getElementsByTagName("Code").item(0).getTextContent()); + assertEquals(REQUEST_ID, doc.getElementsByTagName("RequestId").item(0).getTextContent()); + + final String message = doc.getElementsByTagName("Message").item(0).getTextContent(); + assertTrue( + message.contains("Invalid STS endpoint path '" + expectedPathInMessage + "'"), + "Expected message to mention path: " + expectedPathInMessage); + assertTrue(message.contains("root path /"), "Expected message to mention root path"); + } + + private static Document parseXml(String xml) throws Exception { + assertNotNull(xml); + final DocumentBuilderFactory documentBuilderFactory = DocumentBuilderFactory.newInstance(); + documentBuilderFactory.setNamespaceAware(true); + final DocumentBuilder documentBuilder = documentBuilderFactory.newDocumentBuilder(); + return documentBuilder.parse(new InputSource(new StringReader(xml))); + } +} From 0ab01a4777f3287203054b5ddaa9e4085056ad19 Mon Sep 17 00:00:00 2001 From: fmorg-git Date: Thu, 3 Sep 2026 03:11:17 -0700 Subject: [PATCH 53/54] HDDS-16313. [STS] Handle linked bucket and session policies (#11196) --- hadoop-hdds/docs/content/design/ozone-sts.md | 19 +- .../ozone-secure-sts-multitenant.robot | 7 +- .../security/ozone-secure-sts.resource | 8 + .../smoketest/security/ozone-secure-sts.robot | 91 ++++ .../src/main/proto/OmClientProtocol.proto | 2 + .../apache/hadoop/ozone/om/OzoneManager.java | 31 +- .../hadoop/ozone/om/ResolvedBucket.java | 34 ++ .../s3/security/S3AssumeRoleRequest.java | 281 ++++++++++--- .../ozone/security/STSTokenSecretManager.java | 6 +- .../s3/security/TestS3AssumeRoleRequest.java | 387 ++++++++++++++++-- .../ozone/security/TestSTSSecurityUtil.java | 50 +-- .../security/TestSTSTokenSecretManager.java | 10 +- 12 files changed, 760 insertions(+), 166 deletions(-) diff --git a/hadoop-hdds/docs/content/design/ozone-sts.md b/hadoop-hdds/docs/content/design/ozone-sts.md index 3acbafdcf9a1..9963534d8482 100644 --- a/hadoop-hdds/docs/content/design/ozone-sts.md +++ b/hadoop-hdds/docs/content/design/ozone-sts.md @@ -117,6 +117,15 @@ team agreed that behavior is fine for actions, but does not work for Conditions, restrict calls by sourceIp, and if we silently ignore this, the client may incorrectly think the temporary credentials are restricted for use by that IP address, so the consensus was to reject the request for that scenario. +### 3.3.2 Additional Context on Linked Buckets + +In Ozone, one may configure a chain of bucket links. In the scenario where one desires to call the AssumeRole API where the resource +is a linked bucket, ensure the Ranger policies for the role have the proper permissions for each link in the chain as well +as the source bucket. For example, if there is a source bucket S, that is linked to bucket A, which is linked to bucket B, +and you want the token to be able to issue operations against linked bucket B, ensure that the role has read access to bucket B, +read access to bucket A, and the requisite access for bucket S (such as read on keys for GetObject, create/write on keys for PutObject, etc.). +The role must have at least read access to the volume(s) where these buckets live as well. + ## 3.4 SessionToken Format As mentioned above, one of the return values from the AssumeRole call will be the sessionToken. To support not @@ -203,14 +212,14 @@ The format of this String is entirely up to the Ranger team. What is required f subsequent S3 API calls are made that use STS tokens. In order to achieve this, the sessionPolicy String from Ranger will be included in the sessionToken response to the AssumeRole API call (as mentioned above), and Ozone will supply this String to Ranger whenever STS tokens are used on S3 API calls via a new `RequestContext.sessionPolicy` field in the -`IAccessAuthorizer#checkAccess(IOzoneObj, RequestContext)` call. Another requirement from the Ozone side is to pass the action (without the s3: prefix) corresponding to the S3 api call into the `RequestContext.s3Action` field. +`IAccessAuthorizer#checkAccess(IOzoneObj, RequestContext)` call. Another requirement from the Ozone side is to pass the action (without the s3: prefix) corresponding to the S3 API call into the `RequestContext.s3Action` field. ### 3.6.2 Additional Context on Permissions and Actions In a prior iteration of this design, only permissions corresponding to Ozone `ACLType` (i.e. read, write, create, read_acl, etc.) were included in Ranger roles and session policies. -However, after testing against AWS, it was found that ACLs used by Ozone and Ranger are not granular enough. For example, read on volume, read on bucket, and write on key can be used by either the S3 PutObjectTagging api (requiring `s3:PutObjectTagging` action) or the S3 DeleteObjectTagging api (requiring `s3:DeleteObjectTagging` action). -Similarly, because the S3 PutObject api (`s3:PutObject` action) requires read on volume, read on bucket, and create and write on key, someone with `s3:PutObject` access could previously also call the S3 PutObjectTagging api, even though they did not have access to the `s3:PutObjectTagging` action (as an example). -AWS does not allow an STS token that is restricted for one action to issue calls to an api that is associated with a different action. To prevent having more access than requested (or different access than requested), ACL permissions can be constrained further by S3 actions. +However, after testing against AWS, it was found that ACLs used by Ozone and Ranger are not granular enough. For example, read on volume, read on bucket, and write on key can be used by either the S3 PutObjectTagging API (requiring `s3:PutObjectTagging` action) or the S3 DeleteObjectTagging API (requiring `s3:DeleteObjectTagging` action). +Similarly, because the S3 PutObject API (`s3:PutObject` action) requires read on volume, read on bucket, and create and write on key, someone with `s3:PutObject` access could previously also call the S3 PutObjectTagging API, even though they did not have access to the `s3:PutObjectTagging` action (as an example). +AWS does not allow an STS token that is restricted for one action to issue calls to an API that is associated with a different action. To prevent having more access than requested (or different access than requested), ACL permissions can be constrained further by S3 actions. To do this constraining, the `RequestContext.s3Action` field is introduced so that if populated, the RangerOzoneAuthorizer would further restrict the permissions according to the action. Additionally, the OzoneGrant would contain a Set representing the S3 actions that are allowed for an inline policy. If all actions are allowed, then the Set would be empty or null. @@ -224,7 +233,7 @@ created in Ranger as per the Prerequisites above. - This authorized user (having permanent S3 credentials) makes the AssumeRole STS call to Ozone. - If successful, Ozone responds with the temporary credentials. - A client makes S3 API calls with the temporary credentials for up to as long as the credentials last. -- When Ozone receives an S3 api call using temporary credentials, it will use the Kerberos identity associated with the +- When Ozone receives an S3 API call using temporary credentials, it will use the Kerberos identity associated with the originalAccessKeyId in the session token and perform the following checks: - Ensure that if the accessKeyId starts with "ASIA", that a sessionToken was included in the `x-amz-security-token` header - Ensure the sessionToken is not expired diff --git a/hadoop-ozone/dist/src/main/smoketest/security/ozone-secure-sts-multitenant.robot b/hadoop-ozone/dist/src/main/smoketest/security/ozone-secure-sts-multitenant.robot index c7b367d74cf5..f4c56839dcae 100644 --- a/hadoop-ozone/dist/src/main/smoketest/security/ozone-secure-sts-multitenant.robot +++ b/hadoop-ozone/dist/src/main/smoketest/security/ozone-secure-sts-multitenant.robot @@ -17,7 +17,6 @@ Suite Setup Skip If '${RANGER_ENDPOINT_URL}' == '' No Ranger Documentation Smoke test for S3 STS AssumeRole + Temp Creds (Multi-Tenant Scenario) Resource ./ozone-secure-sts.resource -Resource ../admincli/lib.resource Test Timeout 10 minutes *** Variables *** @@ -137,11 +136,7 @@ Create Iceberg Bucket and Another Bucket Table Access policies ${policy_json} = Set Variable { "isEnabled": true, "service": "dev_ozone", "name": "${TENANT_TWO} ${TENANT_TWO_ANOTHER_BUCKET} table access", "policyType": 0, "policyPriority": 0, "isAuditEnabled": true, "resources": { "volume": { "values": [ "${TENANT_TWO}" ], "isExcludes": false, "isRecursive": false }, "bucket": { "values": [ "${TENANT_TWO_ANOTHER_BUCKET}" ], "isExcludes": false, "isRecursive": false }, "key": { "values": [ "*" ], "isExcludes": false, "isRecursive": true } }, "policyItems": [ { "accesses": [ { "type": "all", "isAllowed": true } ], "roles": [ "${TENANT_ONE_ROLE}" ], "delegateAdmin": false } ], "serviceType": "ozone", "isDenyAllElse": false } Create Ranger Policy ${policy_json} - # Update Ranger policy cache - Kinit test user ${OM_ADMIN_USER} ${OM_ADMIN_USER}.keytab - ${om_param} = Get OM Service Param - ${output} = Execute ozone admin om updateranger ${om_param} - Should contain ${output} Operation completed successfully + Refresh Ranger Policy Cache Get S3 Credentials for Principals, Create Buckets, and Upload Files to Buckets Kinit test user ${USER_A} ${USER_A}.keytab diff --git a/hadoop-ozone/dist/src/main/smoketest/security/ozone-secure-sts.resource b/hadoop-ozone/dist/src/main/smoketest/security/ozone-secure-sts.resource index 8e63f622545c..ebfbe55a6532 100644 --- a/hadoop-ozone/dist/src/main/smoketest/security/ozone-secure-sts.resource +++ b/hadoop-ozone/dist/src/main/smoketest/security/ozone-secure-sts.resource @@ -19,6 +19,7 @@ Library String Library BuiltIn Library DateTime Library Collections +Resource ../admincli/lib.resource Resource ../commonlib.robot Resource ../s3/commonawslib.robot @@ -85,6 +86,13 @@ Update Ranger Policy Items ${result} = Execute curl --silent --show-error --include --location --netrc -X PUT -H "Content-Type: application/json" -H "accept: application/json" --data '${updated}' "${RANGER_ENDPOINT_URL}/service/public/v2/api/policy/${policy_id}" Should Contain ${result} HTTP/1.1 200 +Refresh Ranger Policy Cache + [Arguments] ${admin_user}=hdfs ${admin_keytab}=hdfs.keytab + Kinit test user ${admin_user} ${admin_keytab} + ${om_param} = Get OM Service Param + ${output} = Execute ozone admin om updateranger ${om_param} + Should Contain ${output} Operation completed successfully + Assume Role And Get Temporary Credentials [Arguments] ${perm_access_key_id} ${perm_secret_key} ${policy_json}=${EMPTY} ${role_arn}=${ROLE_ARN_OBS} ${role_session_name}=${ROLE_SESSION_NAME} ${duration_seconds}=900 Configure AWS Profile permanent ${perm_access_key_id} ${perm_secret_key} diff --git a/hadoop-ozone/dist/src/main/smoketest/security/ozone-secure-sts.robot b/hadoop-ozone/dist/src/main/smoketest/security/ozone-secure-sts.robot index c07ee6afc988..b07a38e1a231 100644 --- a/hadoop-ozone/dist/src/main/smoketest/security/ozone-secure-sts.robot +++ b/hadoop-ozone/dist/src/main/smoketest/security/ozone-secure-sts.robot @@ -66,6 +66,15 @@ ${ACTION_MATCHES_PUTOBJECT_CREATE_WRITE_ROLE_ARN} arn:aws:iam::123456789012:rol ${ACTION_MATCHES_GETOBJECT_PUTOBJECT_ROLE_ARN} arn:aws:iam::123456789012:role/${ACTION_MATCHES_GETOBJECT_PUTOBJECT_ROLE} ${ACTION_MATCHES_UPLOADPARTCOPY_EXPECTED_OWNER_ROLE_ARN} arn:aws:iam::123456789012:role/${ACTION_MATCHES_UPLOADPARTCOPY_EXPECTED_OWNER_ROLE} ${ACTION_MATCHES_GET_STAR_READ_ROLE_ARN} arn:aws:iam::123456789012:role/${ACTION_MATCHES_GET_STAR_READ_ROLE} +${STS_LINK_BUCKET_ROLE} sts-link-bucket-role +${STS_LINK_BUCKET_ROLE_ARN} arn:aws:iam::123456789012:role/${STS_LINK_BUCKET_ROLE} +${STS_LINK_BUCKET_SOURCE} sts-link-bucket-source +${STS_LINK_BUCKET_LINKED} s3v-sts-link-bucket-source +${STS_LINK_BUCKET_TESTFILE} link-bucket-testfile.txt +${STS_LINK_BUCKET_CHAIN_SOURCE} sts-chain-source +${STS_LINK_BUCKET_CHAIN_INTERMEDIATE} s3v-sts-chain-intermediate +${STS_LINK_BUCKET_CHAIN_FINAL} s3v-sts-chain-final +${STS_LINK_BUCKET_CHAIN_TESTFILE} chain-link-testfile.txt ${TEST_USER_ADMIN} testuser ${TEST_USER_NON_ADMIN} testuser2 @{ICEBERG_OBJECT_KEYS} file1.txt file1again.txt folder/pepper.txt folder/salt.txt userA/userA.txt userB/userB.txt userAfile.txt @@ -94,6 +103,27 @@ Populate Iceberg Bucket Create File ${TEMP_DIR}/zero-byte-marker Execute ozone sh key put /s3v/${bucket}/zeroByteFolder/ ${TEMP_DIR}/zero-byte-marker +Setup Same Volume Link Bucket + # Create a source bucket and a same-volume linked bucket (s3v/source -> s3v/s3v-source). + Kinit test user hdfs hdfs.keytab + Execute ozone sh bucket create --layout ${ICEBERG_LAYOUT_OBS} /s3v/${STS_LINK_BUCKET_SOURCE} + Create File ${TEMP_DIR}/${STS_LINK_BUCKET_TESTFILE} link bucket test content + Execute ozone sh key put /s3v/${STS_LINK_BUCKET_SOURCE}/${STS_LINK_BUCKET_TESTFILE} ${TEMP_DIR}/${STS_LINK_BUCKET_TESTFILE} + Execute ozone sh bucket link /s3v/${STS_LINK_BUCKET_SOURCE} /s3v/${STS_LINK_BUCKET_LINKED} + +Setup Chained Same Volume Link Buckets + # Create source <- intermediate <- final chained links in s3v. + Kinit test user hdfs hdfs.keytab + Execute ozone sh bucket create --layout ${ICEBERG_LAYOUT_OBS} /s3v/${STS_LINK_BUCKET_CHAIN_SOURCE} + Create File ${TEMP_DIR}/${STS_LINK_BUCKET_CHAIN_TESTFILE} chained link test content + Execute ozone sh key put /s3v/${STS_LINK_BUCKET_CHAIN_SOURCE}/${STS_LINK_BUCKET_CHAIN_TESTFILE} ${TEMP_DIR}/${STS_LINK_BUCKET_CHAIN_TESTFILE} + Execute ozone sh bucket link /s3v/${STS_LINK_BUCKET_CHAIN_SOURCE} /s3v/${STS_LINK_BUCKET_CHAIN_INTERMEDIATE} + Execute ozone sh bucket link /s3v/${STS_LINK_BUCKET_CHAIN_INTERMEDIATE} /s3v/${STS_LINK_BUCKET_CHAIN_FINAL} + +Head Bucket Should Succeed + [Arguments] ${bucket} ${profile}=sts + Execute and checkrc aws s3api --endpoint-url ${S3G_ENDPOINT_URL} head-bucket --bucket ${bucket} --profile ${profile} 0 + Run List Prefix And Delimiter Policy Matrix For Bucket And Api [Arguments] ${bucket} ${role_arn} ${api} # Capture baseline (non-STS) behavior using the permanent credentials. When using STS, it will behave just like @@ -337,6 +367,31 @@ Create Iceberg Multi-Bucket Role Policies ${key_policy} = Set Variable { "isEnabled": true, "service": "dev_ozone", "name": "iceberg multi table access", "policyType": 0, "policyPriority": 0, "isAuditEnabled": true, "resources": { "volume": { "values": [ "s3v" ], "isExcludes": false, "isRecursive": false }, "bucket": { "values": [ "${ICEBERG_BUCKET_OBS}", "${ICEBERG_BUCKET_FSO}" ], "isExcludes": false, "isRecursive": false }, "key": { "values": [ "*" ], "isExcludes": false, "isRecursive": true } }, "policyItems": [ { "accesses": [ { "type": "all", "isAllowed": true } ], "roles": [ "${ICEBERG_MULTI_BUCKET_ROLE}" ], "delegateAdmin": false } ], "serviceType": "ozone", "isDenyAllElse": false } Create Ranger Policy ${key_policy} +Create STS Link Bucket Role in Ranger + ${role_json} = Set Variable { "name": "${STS_LINK_BUCKET_ROLE}", "description": "STS linked bucket authorization regression role" } + Create Ranger Role ${role_json} + Create Ranger Assume Role Policy ${STS_LINK_BUCKET_ROLE} ${ICEBERG_SVC_CATALOG_USER} + +Create STS Link Bucket Access Policies + # Grant volume access and linked-bucket bucket/key policies for session-policy scoping. + # Also grant source-bucket key access on the role so Ranger allows the resolved key path; the STS + # session policy (linked bucket only) is what this regression exercises. + ${policy_items} = Set Variable [ { "accesses": [ { "type": "read", "isAllowed": true }, { "type": "list", "isAllowed": true } ], "roles": [ "${STS_LINK_BUCKET_ROLE}" ], "delegateAdmin": false } ] + Update Ranger Policy Items iceberg volume access ${policy_items} + ${bucket_policy} = Set Variable { "isEnabled": true, "service": "dev_ozone", "name": "sts linked bucket access", "policyType": 0, "policyPriority": 0, "isAuditEnabled": true, "resources": { "volume": { "values": [ "s3v" ], "isExcludes": false, "isRecursive": false }, "bucket": { "values": [ "${STS_LINK_BUCKET_LINKED}" ], "isExcludes": false, "isRecursive": false } }, "policyItems": [ { "accesses": [ { "type": "all", "isAllowed": true } ], "roles": [ "${STS_LINK_BUCKET_ROLE}" ], "delegateAdmin": false } ], "serviceType": "ozone", "isDenyAllElse": false } + Create Ranger Policy ${bucket_policy} + ${key_policy} = Set Variable { "isEnabled": true, "service": "dev_ozone", "name": "sts linked bucket table access", "policyType": 0, "policyPriority": 0, "isAuditEnabled": true, "resources": { "volume": { "values": [ "s3v" ], "isExcludes": false, "isRecursive": false }, "bucket": { "values": [ "${STS_LINK_BUCKET_LINKED}" ], "isExcludes": false, "isRecursive": false }, "key": { "values": [ "*" ], "isExcludes": false, "isRecursive": true } }, "policyItems": [ { "accesses": [ { "type": "all", "isAllowed": true } ], "roles": [ "${STS_LINK_BUCKET_ROLE}" ], "delegateAdmin": false } ], "serviceType": "ozone", "isDenyAllElse": false } + Create Ranger Policy ${key_policy} + ${source_key_policy} = Set Variable { "isEnabled": true, "service": "dev_ozone", "name": "sts linked bucket source table access", "policyType": 0, "policyPriority": 0, "isAuditEnabled": true, "resources": { "volume": { "values": [ "s3v" ], "isExcludes": false, "isRecursive": false }, "bucket": { "values": [ "${STS_LINK_BUCKET_SOURCE}" ], "isExcludes": false, "isRecursive": false }, "key": { "values": [ "*" ], "isExcludes": false, "isRecursive": true } }, "policyItems": [ { "accesses": [ { "type": "all", "isAllowed": true } ], "roles": [ "${STS_LINK_BUCKET_ROLE}" ], "delegateAdmin": false } ], "serviceType": "ozone", "isDenyAllElse": false } + Create Ranger Policy ${source_key_policy} + ${chain_bucket_policy} = Set Variable { "isEnabled": true, "service": "dev_ozone", "name": "sts chained link bucket access", "policyType": 0, "policyPriority": 0, "isAuditEnabled": true, "resources": { "volume": { "values": [ "s3v" ], "isExcludes": false, "isRecursive": false }, "bucket": { "values": [ "${STS_LINK_BUCKET_CHAIN_FINAL}", "${STS_LINK_BUCKET_CHAIN_INTERMEDIATE}" ], "isExcludes": false, "isRecursive": false } }, "policyItems": [ { "accesses": [ { "type": "all", "isAllowed": true } ], "roles": [ "${STS_LINK_BUCKET_ROLE}" ], "delegateAdmin": false } ], "serviceType": "ozone", "isDenyAllElse": false } + Create Ranger Policy ${chain_bucket_policy} + ${chain_key_policy} = Set Variable { "isEnabled": true, "service": "dev_ozone", "name": "sts chained link bucket table access", "policyType": 0, "policyPriority": 0, "isAuditEnabled": true, "resources": { "volume": { "values": [ "s3v" ], "isExcludes": false, "isRecursive": false }, "bucket": { "values": [ "${STS_LINK_BUCKET_CHAIN_FINAL}" ], "isExcludes": false, "isRecursive": false }, "key": { "values": [ "*" ], "isExcludes": false, "isRecursive": true } }, "policyItems": [ { "accesses": [ { "type": "all", "isAllowed": true } ], "roles": [ "${STS_LINK_BUCKET_ROLE}" ], "delegateAdmin": false } ], "serviceType": "ozone", "isDenyAllElse": false } + Create Ranger Policy ${chain_key_policy} + ${chain_source_key_policy} = Set Variable { "isEnabled": true, "service": "dev_ozone", "name": "sts chained link bucket source table access", "policyType": 0, "policyPriority": 0, "isAuditEnabled": true, "resources": { "volume": { "values": [ "s3v" ], "isExcludes": false, "isRecursive": false }, "bucket": { "values": [ "${STS_LINK_BUCKET_CHAIN_SOURCE}" ], "isExcludes": false, "isRecursive": false }, "key": { "values": [ "*" ], "isExcludes": false, "isRecursive": true } }, "policyItems": [ { "accesses": [ { "type": "all", "isAllowed": true } ], "roles": [ "${STS_LINK_BUCKET_ROLE}" ], "delegateAdmin": false } ], "serviceType": "ozone", "isDenyAllElse": false } + Create Ranger Policy ${chain_source_key_policy} + Refresh Ranger Policy Cache + Create Partial Access Roles in Ranger FOR ${role} IN ${PARTIAL_LIST_ALL_BUCKETS_VOL_READ_ROLE} ${PARTIAL_LIST_ALL_BUCKETS_VOL_LIST_ROLE} ${PARTIAL_BUCKET_READ_ROLE} ${PARTIAL_BUCKET_READ_UPLOAD_PREFIX_ROLE} ${PARTIAL_BUCKET_LIST_ROLE} ${PARTIAL_BUCKET_READ_ACL_ROLE} ${PARTIAL_PUT_OBJECT_KEY_CREATE_ROLE} ${PARTIAL_PUT_OBJECT_KEY_WRITE_ROLE} ${role_json} = Set Variable { "name": "${role}", "description": "Partial access role" } @@ -416,6 +471,8 @@ Get S3 Credentials for Service Catalog Principal, Create Iceberg Buckets, and Up Execute ozone sh bucket create --layout ${ICEBERG_LAYOUT_FSO} /s3v/${ICEBERG_BUCKET_FSO} Populate Iceberg Bucket ${ICEBERG_BUCKET_OBS} Populate Iceberg Bucket ${ICEBERG_BUCKET_FSO} + Setup Same Volume Link Bucket + Setup Chained Same Volume Link Buckets # Switch back to the service catalog principal for running S3/STS requests. Kinit test user ${ICEBERG_SVC_CATALOG_USER} ${ICEBERG_SVC_CATALOG_USER}.keytab @@ -1276,6 +1333,40 @@ STS session policy containing only GetObject must deny DeleteObjects ${output} = Execute aws s3api --endpoint-url ${S3G_ENDPOINT_URL} delete-bucket --bucket ${bucket} --profile sts Should Not Contain ${output} AccessDenied +STS Session Policy On Linked Bucket Grants GetObject On Source Bucket + # Same-volume linked buckets (e.g. s3v/s3v-iceberg -> s3v/iceberg): session policy scoped to the linked + # bucket must also authorize ListBucket and GetObject on the source bucket. + Kinit test user ${ICEBERG_SVC_CATALOG_USER} ${ICEBERG_SVC_CATALOG_USER}.keytab + ${session_policy} = Set Variable {"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":["s3:ListBucket","s3:GetObject"],"Resource":["arn:aws:s3:::${STS_LINK_BUCKET_LINKED}","arn:aws:s3:::${STS_LINK_BUCKET_LINKED}/*"]}]} + Assume Role And Configure STS Profile policy_json=${session_policy} perm_access_key_id=${PERMANENT_ACCESS_KEY_ID} perm_secret_key=${PERMANENT_SECRET_KEY} role_arn=${STS_LINK_BUCKET_ROLE_ARN} + Head Bucket Should Succeed ${STS_LINK_BUCKET_LINKED} + Get Object Should Succeed ${STS_LINK_BUCKET_LINKED} ${STS_LINK_BUCKET_TESTFILE} + +STS Role On Linked Bucket Grants GetObject On Source Bucket + # Same-volume linked buckets: role permissions on the linked bucket must authorize + # key access on the source bucket when no inline session policy is supplied. + Kinit test user ${ICEBERG_SVC_CATALOG_USER} ${ICEBERG_SVC_CATALOG_USER}.keytab + Assume Role And Configure STS Profile perm_access_key_id=${PERMANENT_ACCESS_KEY_ID} perm_secret_key=${PERMANENT_SECRET_KEY} role_arn=${STS_LINK_BUCKET_ROLE_ARN} + Head Bucket Should Succeed ${STS_LINK_BUCKET_LINKED} + Get Object Should Succeed ${STS_LINK_BUCKET_LINKED} ${STS_LINK_BUCKET_TESTFILE} + +STS Session Policy On Chained Linked Bucket Grants GetObject On Source Bucket + # Chained linked buckets (source <- intermediate <- final): session policy scoped to the final + # linked bucket must also authorize ListBucket and GetObject on the source bucket. + Kinit test user ${ICEBERG_SVC_CATALOG_USER} ${ICEBERG_SVC_CATALOG_USER}.keytab + ${session_policy} = Set Variable {"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":["s3:ListBucket","s3:GetObject"],"Resource":["arn:aws:s3:::${STS_LINK_BUCKET_CHAIN_FINAL}","arn:aws:s3:::${STS_LINK_BUCKET_CHAIN_FINAL}/*"]}]} + Assume Role And Configure STS Profile policy_json=${session_policy} perm_access_key_id=${PERMANENT_ACCESS_KEY_ID} perm_secret_key=${PERMANENT_SECRET_KEY} role_arn=${STS_LINK_BUCKET_ROLE_ARN} + Head Bucket Should Succeed ${STS_LINK_BUCKET_CHAIN_FINAL} + Get Object Should Succeed ${STS_LINK_BUCKET_CHAIN_FINAL} ${STS_LINK_BUCKET_CHAIN_TESTFILE} + +STS Role On Chained Linked Bucket Grants GetObject On Source Bucket + # Chained linked buckets: role permissions on the final linked bucket must authorize + # key access on the source bucket when no inline session policy is supplied. + Kinit test user ${ICEBERG_SVC_CATALOG_USER} ${ICEBERG_SVC_CATALOG_USER}.keytab + Assume Role And Configure STS Profile perm_access_key_id=${PERMANENT_ACCESS_KEY_ID} perm_secret_key=${PERMANENT_SECRET_KEY} role_arn=${STS_LINK_BUCKET_ROLE_ARN} + Head Bucket Should Succeed ${STS_LINK_BUCKET_CHAIN_FINAL} + Get Object Should Succeed ${STS_LINK_BUCKET_CHAIN_FINAL} ${STS_LINK_BUCKET_CHAIN_TESTFILE} + Expired STS temporary credentials must return ExpiredToken on S3 APIs # Increase timeout to account for 15 minute STS token expiration plus the time to execute the api calls [Timeout] 25 minutes diff --git a/hadoop-ozone/interface-client/src/main/proto/OmClientProtocol.proto b/hadoop-ozone/interface-client/src/main/proto/OmClientProtocol.proto index 638cf99bd1ce..53f7c20e5d9a 100644 --- a/hadoop-ozone/interface-client/src/main/proto/OmClientProtocol.proto +++ b/hadoop-ozone/interface-client/src/main/proto/OmClientProtocol.proto @@ -2531,6 +2531,8 @@ message UpdateAssumeRoleRequest { required string tempAccessKeyId = 6; required string secretAccessKey = 7; required string roleId = 8; + optional string sessionToken = 9; + optional uint64 expirationEpochSeconds = 10; } message RevokeSTSTokenRequest { 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 04455a525a99..f8c4d0a244a1 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 @@ -145,6 +145,7 @@ import java.util.HashMap; import java.util.HashSet; import java.util.LinkedHashMap; +import java.util.LinkedHashSet; import java.util.List; import java.util.Map; import java.util.Objects; @@ -5119,19 +5120,16 @@ public ResolvedBucket resolveBucketLink(OmKeyArgs args) public ResolvedBucket resolveBucketLink(Pair requested, OMClientRequest omClientRequest) throws IOException { + final Set> linkChain = new LinkedHashSet<>(); OmBucketInfo resolved; if (getAclsEnabled()) { - resolved = resolveBucketLink(requested, new HashSet<>(), - omClientRequest.createUGIForApi(), - omClientRequest.getRemoteAddress(), - omClientRequest.getHostName(), - false); + resolved = resolveBucketLink( + requested, linkChain, omClientRequest.createUGIForApi(), omClientRequest.getRemoteAddress(), + omClientRequest.getHostName(), false); } else { - resolved = resolveBucketLink(requested, new HashSet<>(), - null, null, null, false); + resolved = resolveBucketLink(requested, linkChain, null, null, null, false); } - return new ResolvedBucket(requested.getLeft(), requested.getRight(), - resolved); + return new ResolvedBucket(requested.getLeft(), requested.getRight(), resolved, linkChain); } public ResolvedBucket resolveBucketLink(Pair requested, @@ -5143,6 +5141,7 @@ public ResolvedBucket resolveBucketLink(Pair requested, boolean allowDanglingBuckets, boolean aclEnabled) throws IOException { + final Set> linkChain = new LinkedHashSet<>(); OmBucketInfo resolved; if (aclEnabled) { UserGroupInformation ugi = getRemoteUser(); @@ -5151,17 +5150,13 @@ public ResolvedBucket resolveBucketLink(Pair requested, ugi = UserGroupInformation.createRemoteUser(principal); } InetAddress remoteIp = Server.getRemoteIp(); - resolved = resolveBucketLink(requested, new HashSet<>(), - ugi, - remoteIp != null ? remoteIp : omRpcAddress.getAddress(), - remoteIp != null ? remoteIp.getHostName() : - omRpcAddress.getHostName(), allowDanglingBuckets, aclEnabled); + resolved = resolveBucketLink( + requested, linkChain, ugi, remoteIp != null ? remoteIp : omRpcAddress.getAddress(), + remoteIp != null ? remoteIp.getHostName() : omRpcAddress.getHostName(), allowDanglingBuckets, aclEnabled); } else { - resolved = resolveBucketLink(requested, new HashSet<>(), - null, null, null, allowDanglingBuckets, aclEnabled); + resolved = resolveBucketLink(requested, linkChain, null, null, null, allowDanglingBuckets, aclEnabled); } - return new ResolvedBucket(requested.getLeft(), requested.getRight(), - resolved); + return new ResolvedBucket(requested.getLeft(), requested.getRight(), resolved, linkChain); } private OmBucketInfo resolveBucketLink( diff --git a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/ResolvedBucket.java b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/ResolvedBucket.java index 19b41355d091..6dd50feff615 100644 --- a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/ResolvedBucket.java +++ b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/ResolvedBucket.java @@ -17,6 +17,9 @@ package org.apache.hadoop.ozone.om; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; import java.util.LinkedHashMap; import java.util.Map; import java.util.Objects; @@ -44,11 +47,18 @@ public class ResolvedBucket { private final String realBucket; private final String bucketOwner; private final BucketLayout bucketLayout; + private final Collection> linkChain; public ResolvedBucket(String requestedVolume, String requestedBucket, OmBucketInfo resolved) { + this(requestedVolume, requestedBucket, resolved, Collections.emptyList()); + } + + public ResolvedBucket(String requestedVolume, String requestedBucket, + OmBucketInfo resolved, Collection> linkChain) { this.requestedVolume = requestedVolume; this.requestedBucket = requestedBucket; + this.linkChain = unmodifiableLinkChain(linkChain); if (resolved != null) { this.realVolume = resolved.getVolumeName(); this.realBucket = resolved.getBucketName(); @@ -65,12 +75,28 @@ public ResolvedBucket(String requestedVolume, String requestedBucket, public ResolvedBucket(String requestedVolume, String requestedBucket, String realVolume, String realBucket, String bucketOwner, BucketLayout bucketLayout) { + this(requestedVolume, requestedBucket, realVolume, realBucket, bucketOwner, bucketLayout, + Collections.emptyList()); + } + + public ResolvedBucket(String requestedVolume, String requestedBucket, + String realVolume, String realBucket, String bucketOwner, + BucketLayout bucketLayout, Collection> linkChain) { this.requestedVolume = requestedVolume; this.requestedBucket = requestedBucket; this.realVolume = realVolume; this.realBucket = realBucket; this.bucketOwner = bucketOwner; this.bucketLayout = bucketLayout; + this.linkChain = unmodifiableLinkChain(linkChain); + } + + private static Collection> unmodifiableLinkChain( + Collection> linkChain) { + if (linkChain == null || linkChain.isEmpty()) { + return Collections.emptyList(); + } + return Collections.unmodifiableList(new ArrayList<>(linkChain)); } public ResolvedBucket(Pair requested, @@ -104,6 +130,14 @@ public BucketLayout bucketLayout() { return bucketLayout; } + /** + * Ordered link buckets followed when resolving from the requested bucket to the real bucket. + * Each pair is {@code (volume, bucket)}. Empty when the requested bucket is not a link. + */ + public Collection> linkChain() { + return linkChain; + } + public OmKeyArgs update(OmKeyArgs args) { return isLink() ? args.toBuilder() 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 b6d650cc4393..693d280419b8 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 @@ -29,10 +29,15 @@ import java.net.InetAddress; import java.security.SecureRandom; import java.time.Clock; +import java.time.Instant; +import java.util.EnumSet; import java.util.HashMap; +import java.util.LinkedHashSet; import java.util.Map; import java.util.Optional; import java.util.Set; +import org.apache.commons.lang3.StringUtils; +import org.apache.commons.lang3.tuple.Pair; import org.apache.hadoop.hdds.scm.client.HddsClientUtils; import org.apache.hadoop.ipc_.ProtobufRpcEngine; import org.apache.hadoop.ozone.OzoneConsts; @@ -40,6 +45,7 @@ import org.apache.hadoop.ozone.audit.OMAction; import org.apache.hadoop.ozone.om.OzoneAclUtils; import org.apache.hadoop.ozone.om.OzoneManager; +import org.apache.hadoop.ozone.om.ResolvedBucket; 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; @@ -53,6 +59,10 @@ 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.UpdateAssumeRoleRequest; +import org.apache.hadoop.ozone.security.acl.IAccessAuthorizer.ACLType; +import org.apache.hadoop.ozone.security.acl.IOzoneObj; +import org.apache.hadoop.ozone.security.acl.OzoneObj; +import org.apache.hadoop.ozone.security.acl.OzoneObjInfo; import org.apache.hadoop.ozone.security.acl.iam.IamSessionPolicyResolver; import org.apache.hadoop.security.UserGroupInformation; @@ -94,42 +104,81 @@ public OMRequest preExecute(OzoneManager ozoneManager) throws IOException { final OMRequest omRequest = super.preExecute(ozoneManager); final AssumeRoleRequest assumeRoleRequest = omRequest.getAssumeRoleRequest(); - // Brief overview of flow: - // The STS Endpoint makes the AssumeRole call, which when received by OM leader (via this method), - // it will generate the temporary credentials (tempAccessKeyId, secretAccessKey) and roleId. - // The original AssumeRole request is converted to an UpdateAssumeRoleRequest with the generated - // credentials. This update request will be submitted to Ratis and the credentials - // created by the leader will be replicated across all OMs. All OMs in - // HA mode therefore will have identical audit logs with the same tempAccessKeyId. - - // Generate temporary AWS credentials using cryptographically strong SecureRandom - final String tempAccessKeyId = STS_TOKEN_PREFIX + generateSecureRandomStringUsingChars( - STS_ACCESS_KEY_ID_ALLOWED_CHARS, STS_ACCESS_KEY_ID_ALLOWED_CHARS_LENGTH, - STS_ACCESS_KEY_ID_RANDOM_LENGTH); - final String secretAccessKey = generateSecureRandomStringUsingChars( - CHARS_FOR_SECRET_ACCESS_KEYS, CHARS_FOR_SECRET_ACCESS_KEYS_LENGTH, STS_SECRET_ACCESS_KEY_LENGTH); - final String roleId = ASSUME_ROLE_ID_PREFIX + generateSecureRandomStringUsingChars( - STS_ACCESS_KEY_ID_ALLOWED_CHARS, STS_ACCESS_KEY_ID_ALLOWED_CHARS_LENGTH, - STS_ROLE_ID_LENGTH); - - // Build UpdateAssumeRoleRequest with leader-generated credentials - final UpdateAssumeRoleRequest.Builder updateAssumeRoleRequestBuilder = - UpdateAssumeRoleRequest.newBuilder() - .setRoleArn(assumeRoleRequest.getRoleArn()) - .setRoleSessionName(assumeRoleRequest.getRoleSessionName()) - .setDurationSeconds(assumeRoleRequest.getDurationSeconds()) - .setRequestId(assumeRoleRequest.getRequestId()) - .setTempAccessKeyId(tempAccessKeyId) - .setSecretAccessKey(secretAccessKey) - .setRoleId(roleId); - - if (assumeRoleRequest.hasAwsIamSessionPolicy()) { - updateAssumeRoleRequestBuilder.setAwsIamSessionPolicy(assumeRoleRequest.getAwsIamSessionPolicy()); - } + final int durationSeconds = assumeRoleRequest.getDurationSeconds(); + final String roleSessionName = assumeRoleRequest.getRoleSessionName(); + final String roleArn = assumeRoleRequest.getRoleArn(); + final String awsIamSessionPolicy = assumeRoleRequest.getAwsIamSessionPolicy(); + final String requestId = assumeRoleRequest.getRequestId(); + final OzoneManagerProtocolProtos.UserInfo userInfo = omRequest.getUserInfo(); + final AuditLogger auditLogger = ozoneManager.getAuditLogger(); + final Map auditMap = new HashMap<>(); + S3STSUtils.addAssumeRoleAuditParams( + auditMap, roleArn, roleSessionName, awsIamSessionPolicy, durationSeconds, requestId); - return omRequest.toBuilder() - .setUpdateAssumeRoleRequest(updateAssumeRoleRequestBuilder.build()) - .build(); + try { + if (!omRequest.hasS3Authentication()) { + throw new OMException( + "S3AssumeRoleRequest does not have S3 authentication", OMException.ResultCodes.INVALID_REQUEST); + } + + // Brief overview of flow: + // The STS Endpoint makes the AssumeRole call, which when received by OM leader (via this method), + // it will validate the request, authorize via Ranger, generate temporary credentials + // (tempAccessKeyId, secretAccessKey), roleId, and the signed session token. + // The original AssumeRole request is converted to an UpdateAssumeRoleRequest with the generated + // values. This update request will be submitted to Ratis and replicated across all OMs. + // All OMs in HA mode therefore will have identical audit logs with the same tempAccessKeyId. + S3STSUtils.validateDuration(durationSeconds); + S3STSUtils.validateRoleSessionName(roleSessionName); + final String targetRoleName = AwsRoleArnValidator.validateAndExtractRoleNameFromArn(roleArn); + + // Generate temporary AWS credentials using cryptographically strong SecureRandom + final String tempAccessKeyId = STS_TOKEN_PREFIX + generateSecureRandomStringUsingChars( + STS_ACCESS_KEY_ID_ALLOWED_CHARS, STS_ACCESS_KEY_ID_ALLOWED_CHARS_LENGTH, + STS_ACCESS_KEY_ID_RANDOM_LENGTH); + final String secretAccessKey = generateSecureRandomStringUsingChars( + CHARS_FOR_SECRET_ACCESS_KEYS, CHARS_FOR_SECRET_ACCESS_KEYS_LENGTH, STS_SECRET_ACCESS_KEY_LENGTH); + final String roleId = ASSUME_ROLE_ID_PREFIX + generateSecureRandomStringUsingChars( + STS_ACCESS_KEY_ID_ALLOWED_CHARS, STS_ACCESS_KEY_ID_ALLOWED_CHARS_LENGTH, + STS_ROLE_ID_LENGTH); + + final Instant creationInstant = clock.instant(); + final String sessionToken = generateSessionToken( + targetRoleName, omRequest, ozoneManager, assumeRoleRequest, secretAccessKey, tempAccessKeyId, + creationInstant); + final long expirationEpochSeconds = creationInstant.plusSeconds(durationSeconds).getEpochSecond(); + + auditMap.put(OzoneConsts.S3_STS_TEMP_ACCESS_KEY_ID, tempAccessKeyId); + + // Build UpdateAssumeRoleRequest with leader-generated credentials and session token + final UpdateAssumeRoleRequest.Builder updateAssumeRoleRequestBuilder = + UpdateAssumeRoleRequest.newBuilder() + .setRoleArn(roleArn) + .setRoleSessionName(roleSessionName) + .setDurationSeconds(durationSeconds) + .setRequestId(requestId) + .setTempAccessKeyId(tempAccessKeyId) + .setSecretAccessKey(secretAccessKey) + .setRoleId(roleId) + .setSessionToken(sessionToken) + .setExpirationEpochSeconds(expirationEpochSeconds); + + if (assumeRoleRequest.hasAwsIamSessionPolicy()) { + updateAssumeRoleRequestBuilder.setAwsIamSessionPolicy(awsIamSessionPolicy); + } + + return omRequest.toBuilder() + .setUpdateAssumeRoleRequest(updateAssumeRoleRequestBuilder.build()) + .build(); + } catch (OMException e) { + markForAudit(auditLogger, buildAuditMessage(OMAction.S3_ASSUME_ROLE, auditMap, e, userInfo)); + throw e; + } catch (IOException e) { + final OMException omException = new OMException( + "Failed to generate STS token for role: " + roleArn, e, OMException.ResultCodes.INTERNAL_ERROR); + markForAudit(auditLogger, buildAuditMessage(OMAction.S3_ASSUME_ROLE, auditMap, omException, userInfo)); + throw omException; + } } @Override @@ -148,6 +197,8 @@ public OMClientResponse validateAndUpdateCache(OzoneManager ozoneManager, Execut final String tempAccessKeyId = updateAssumeRoleRequest.getTempAccessKeyId(); final String secretAccessKey = updateAssumeRoleRequest.getSecretAccessKey(); final String roleId = updateAssumeRoleRequest.getRoleId(); + final String sessionToken = updateAssumeRoleRequest.getSessionToken(); + final long expirationEpochSeconds = updateAssumeRoleRequest.getExpirationEpochSeconds(); final Map auditMap = new HashMap<>(); final AuditLogger auditLogger = ozoneManager.getAuditLogger(); @@ -158,33 +209,15 @@ public OMClientResponse validateAndUpdateCache(OzoneManager ozoneManager, Execut Exception exception = null; OMClientResponse omClientResponse; try { - // Validate duration - S3STSUtils.validateDuration(durationSeconds); - - // Validate role session name - S3STSUtils.validateRoleSessionName(roleSessionName); - - // Validate role ARN and extract role - final String targetRoleName = AwsRoleArnValidator.validateAndExtractRoleNameFromArn(roleArn); - - // Note: The IamSessionPolicyResolver validates the awsIamPolicy length internally - - if (!omRequest.hasS3Authentication()) { + if (Strings.isNullOrEmpty(tempAccessKeyId) || Strings.isNullOrEmpty(secretAccessKey) || + Strings.isNullOrEmpty(roleId) || Strings.isNullOrEmpty(sessionToken) || expirationEpochSeconds <= 0) { throw new OMException( - "S3AssumeRoleRequest does not have S3 authentication", OMException.ResultCodes.INVALID_REQUEST); + "UpdateAssumeRoleRequest is missing leader-generated AssumeRole fields", + OMException.ResultCodes.INVALID_REQUEST); } - // Generate session token using leader-generated credentials - final String sessionToken = generateSessionToken( - targetRoleName, omRequest, ozoneManager, assumeRoleRequest, secretAccessKey, tempAccessKeyId); - - // Generate AssumedRoleId for response using leader-generated roleId final String assumedRoleId = roleId + ":" + roleSessionName; - // Calculate expiration of session token - final long expirationEpochSeconds = clock.instant().plusSeconds(durationSeconds).getEpochSecond(); - - // Add tempAccessKeyId to the log so it can be determined which permanent user created the tempAccessKeyId auditMap.put(OzoneConsts.S3_STS_TEMP_ACCESS_KEY_ID, tempAccessKeyId); final AssumeRoleResponse.Builder responseBuilder = AssumeRoleResponse.newBuilder() @@ -202,15 +235,8 @@ public OMClientResponse validateAndUpdateCache(OzoneManager ozoneManager, Execut exception = e; omClientResponse = 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); - exception = omException; - omClientResponse = new S3AssumeRoleResponse( - createErrorOMResponse(OmResponseUtil.getOMResponseBuilder(omRequest), omException)); } - // Audit log markForAudit(auditLogger, buildAuditMessage(OMAction.S3_ASSUME_ROLE, auditMap, exception, userInfo)); return omClientResponse; @@ -221,7 +247,7 @@ public OMClientResponse validateAndUpdateCache(OzoneManager ozoneManager, Execut */ private String generateSessionToken(String targetRoleName, OMRequest omRequest, OzoneManager ozoneManager, AssumeRoleRequest assumeRoleRequest, String secretAccessKey, - String tempAccessKeyId) throws IOException { + String tempAccessKeyId, Instant creationInstant) throws IOException { InetAddress remoteIp = ProtobufRpcEngine.Server.getRemoteIp(); if (remoteIp == null) { @@ -246,7 +272,7 @@ private String generateSessionToken(String targetRoleName, OMRequest omRequest, return ozoneManager.getSTSTokenSecretManager().createSTSTokenString( tempAccessKeyId, originalAccessKeyId, roleArn, assumeRoleRequest.getDurationSeconds(), secretAccessKey, - sessionPolicy, clock); + sessionPolicy, creationInstant); } /** @@ -274,13 +300,134 @@ String getSessionPolicy(OzoneManager ozoneManager, String originalAccessKeyId, S final Set grants = Strings.isNullOrEmpty(awsIamPolicy) ? null : - IamSessionPolicyResolver.resolve(awsIamPolicy, volumeName, IamSessionPolicyResolver.AuthorizerType.RANGER); + resolveGrantsAgainstBucketLinks( + IamSessionPolicyResolver.resolve(awsIamPolicy, volumeName, IamSessionPolicyResolver.AuthorizerType.RANGER), + (linkVolume, linkBucket) -> ozoneManager.resolveBucketLink(Pair.of(linkVolume, linkBucket), true, false)); return ozoneManager.getAccessAuthorizer().generateAssumeRoleSessionPolicy( new org.apache.hadoop.ozone.security.acl.AssumeRoleRequest( hostName, remoteIp, ugi, targetRoleName, grants)); } + /** + * Rewrites the resolved session-policy grants so that any bucket, key, or prefix resource that names a + * bucket link is anchored to the link's source volume and bucket - the resource paths the OM authorizes + * against once the link is resolved at request time. READ on each link bucket in the chain (and, when the + * chain crosses volumes, READ on each distinct volume except the requested one) is retained so OM can follow + * every hop at request time, which keeps the generated token as small as possible. + *

+ * The link target is resolved when the token is generated, so the token grants access to whatever the link + * points to at that moment. If the link is later re-pointed, the token no longer grants access to the new + * target. + * + * @param grants the grants produced by {@link IamSessionPolicyResolver}, possibly {@code null} + * @param linkResolver resolves a (volume, bucket) pair to its link target + * @return the link-aware grants, or the input unchanged when there is nothing to resolve + */ + @VisibleForTesting + static Set resolveGrantsAgainstBucketLinks(Set grants, + BucketLinkResolver linkResolver) throws IOException { + if (grants == null || grants.isEmpty()) { + return grants; + } + + final Map, ResolvedBucket> resolutionCache = new HashMap<>(); + final Set linkFollowObjects = new LinkedHashSet<>(); + final Set resolvedGrants = new LinkedHashSet<>(); + + for (OzoneGrant grant : grants) { + final Set resolvedObjects = new LinkedHashSet<>(); + for (IOzoneObj object : grant.getObjects()) { + resolvedObjects.add( + resolveObjectAgainstBucketLink((OzoneObj) object, linkResolver, resolutionCache, linkFollowObjects)); + } + resolvedGrants.add(new OzoneGrant(resolvedObjects, grant.getPermissions(), grant.getS3Actions())); + } + + // Retain only the READ required to follow each link hop at request time. + if (!linkFollowObjects.isEmpty()) { + resolvedGrants.add(new OzoneGrant(linkFollowObjects, EnumSet.of(ACLType.READ))); + } + + return resolvedGrants; + } + + /** + * Resolves a single grant object against its bucket link. Bucket, key, and prefix objects that name a + * link bucket are rewritten to the link's source volume and bucket, and the READ needed to follow each hop + * in the link chain is collected in {@code linkFollowObjects}. All other objects (volume resources and + * wildcard buckets) are returned unchanged. + */ + private static IOzoneObj resolveObjectAgainstBucketLink(OzoneObj object, BucketLinkResolver linkResolver, + Map, ResolvedBucket> resolutionCache, Set linkFollowObjects) + throws IOException { + final OzoneObj.ResourceType resourceType = object.getResourceType(); + if (resourceType != OzoneObj.ResourceType.BUCKET + && resourceType != OzoneObj.ResourceType.KEY + && resourceType != OzoneObj.ResourceType.PREFIX) { + return object; + } + + final String volumeName = object.getVolumeName(); + final String bucketName = object.getBucketName(); + // Wildcard or unspecified names cannot correspond to a concrete link bucket. + if (StringUtils.isBlank(volumeName) || StringUtils.isBlank(bucketName) || hasWildcard(volumeName) || + hasWildcard(bucketName)) { + return object; + } + + final Pair requested = Pair.of(volumeName, bucketName); + ResolvedBucket resolved = resolutionCache.get(requested); + if (resolved == null) { + resolved = linkResolver.resolve(volumeName, bucketName); + resolutionCache.put(requested, resolved); + } + if (resolved == null || resolved.isDangling() || !resolved.isLink()) { + return object; + } + + final Set chainVolumes = new LinkedHashSet<>(); + for (Pair link : resolved.linkChain()) { + linkFollowObjects.add(newResourceObj(OzoneObj.ResourceType.BUCKET, link.getLeft(), link.getRight())); + chainVolumes.add(link.getLeft()); + } + chainVolumes.add(resolved.realVolume()); + chainVolumes.remove(volumeName); + for (String vol : chainVolumes) { + linkFollowObjects.add(newResourceObj(OzoneObj.ResourceType.VOLUME, vol, null)); + } + + return OzoneObjInfo.Builder.fromOzoneObj(object) + .setVolumeName(resolved.realVolume()) + .setBucketName(resolved.realBucket()) + .build(); + } + + private static IOzoneObj newResourceObj(OzoneObj.ResourceType resourceType, String volumeName, String bucketName) { + final OzoneObjInfo.Builder builder = OzoneObjInfo.Builder.newBuilder() + .setResType(resourceType) + .setStoreType(OzoneObj.StoreType.OZONE) + .setVolumeName(volumeName); + if (bucketName != null) { + builder.setBucketName(bucketName); + } + return builder.build(); + } + + private static boolean hasWildcard(String name) { + return name.indexOf('*') >= 0 || name.indexOf('?') >= 0; + } + + /** + * Resolves a (volume, bucket) pair to its link target, following bucket links. Implementations must not + * enforce ACLs, so that session-policy generation stays deterministic across OMs and does not depend on + * the external authorizer. + */ + @FunctionalInterface + interface BucketLinkResolver { + ResolvedBucket resolve(String volumeName, String bucketName) throws IOException; + } + /** * Generates a cryptographically strong String of the supplied stringLength using supplied chars. */ diff --git a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/security/STSTokenSecretManager.java b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/security/STSTokenSecretManager.java index 63c4d8121edf..d9e5c4caf767 100644 --- a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/security/STSTokenSecretManager.java +++ b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/security/STSTokenSecretManager.java @@ -18,7 +18,6 @@ package org.apache.hadoop.ozone.security; import java.io.IOException; -import java.time.Clock; import java.time.Instant; import java.util.Objects; import org.apache.hadoop.hdds.annotation.InterfaceAudience; @@ -83,12 +82,11 @@ public Token generateToken(STSTokenIdentifier tokenIdentifie * @param secretAccessKey the secret access key associated with the temporary access key ID * @param sessionPolicy an optional opaque identifier that further limits the scope of * the permissions granted by the role - * @param clock the system clock + * @param creationTime token creation time * @return base64 encoded token string */ public String createSTSTokenString(String tempAccessKeyId, String originalAccessKeyId, String roleArn, - int durationSeconds, String secretAccessKey, String sessionPolicy, Clock clock) throws IOException { - final Instant creationTime = clock.instant(); + int durationSeconds, String secretAccessKey, String sessionPolicy, Instant creationTime) throws IOException { final Instant expiration = creationTime.plusSeconds(durationSeconds); final STSTokenIdentifier identifier = new STSTokenIdentifier(STSTokenIdentifier.Params.newBuilder() 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 3ae775b716b3..57068d1a169a 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 @@ -19,6 +19,7 @@ import static java.util.Collections.emptySet; import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.mockStatic; @@ -33,13 +34,17 @@ import java.nio.charset.StandardCharsets; import java.time.Instant; import java.time.ZoneOffset; +import java.util.Arrays; import java.util.Collections; +import java.util.EnumSet; +import java.util.LinkedHashSet; import java.util.Optional; import java.util.Set; import java.util.UUID; import java.util.regex.Pattern; import javax.crypto.SecretKey; import javax.crypto.spec.SecretKeySpec; +import org.apache.commons.lang3.tuple.Pair; import org.apache.hadoop.hdds.conf.OzoneConfiguration; import org.apache.hadoop.hdds.security.symmetric.ManagedSecretKey; import org.apache.hadoop.hdds.security.symmetric.SecretKeySignerClient; @@ -47,7 +52,10 @@ import org.apache.hadoop.ozone.audit.AuditMessage; import org.apache.hadoop.ozone.om.OMMultiTenantManager; import org.apache.hadoop.ozone.om.OzoneManager; +import org.apache.hadoop.ozone.om.ResolvedBucket; +import org.apache.hadoop.ozone.om.exceptions.OMException; import org.apache.hadoop.ozone.om.execution.flowcontrol.ExecutionContext; +import org.apache.hadoop.ozone.om.helpers.BucketLayout; import org.apache.hadoop.ozone.om.helpers.OMAuditLogger; import org.apache.hadoop.ozone.om.response.OMClientResponse; import org.apache.hadoop.ozone.protocol.proto.OzoneManagerProtocolProtos.AssumeRoleRequest; @@ -57,9 +65,14 @@ 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.apache.hadoop.ozone.protocol.proto.OzoneManagerProtocolProtos.UpdateAssumeRoleRequest; import org.apache.hadoop.ozone.security.STSTokenSecretManager; import org.apache.hadoop.ozone.security.acl.AssumeRoleRequest.OzoneGrant; import org.apache.hadoop.ozone.security.acl.IAccessAuthorizer; +import org.apache.hadoop.ozone.security.acl.IAccessAuthorizer.ACLType; +import org.apache.hadoop.ozone.security.acl.IOzoneObj; +import org.apache.hadoop.ozone.security.acl.OzoneObj; +import org.apache.hadoop.ozone.security.acl.OzoneObjInfo; import org.apache.hadoop.ozone.security.acl.iam.IamSessionPolicyResolver; import org.apache.hadoop.security.UserGroupInformation; import org.apache.hadoop.security.token.TokenIdentifier; @@ -150,13 +163,11 @@ public void testInvalidDurationTooShort() { ).build(); final S3AssumeRoleRequest request = new S3AssumeRoleRequest(omRequest, CLOCK); - final OMClientResponse response = request.validateAndUpdateCache(ozoneManager, context); - final OMResponse omResponse = response.getOMResponse(); + final OMException exception = assertThrows(OMException.class, () -> request.preExecute(ozoneManager)); - assertThat(omResponse.getStatus()).isEqualTo(Status.INVALID_REQUEST); - assertThat(omResponse.getMessage()).isEqualTo( + assertThat(exception.getResult()).isEqualTo(OMException.ResultCodes.INVALID_REQUEST); + assertThat(exception.getMessage()).isEqualTo( "Invalid Value: DurationSeconds must be between 900 and 43200 seconds"); - assertThat(omResponse.hasAssumeRoleResponse()).isFalse(); assertMarkForAuditCalled(request); } @@ -172,13 +183,11 @@ public void testInvalidDurationTooLong() { ).build(); final S3AssumeRoleRequest request = new S3AssumeRoleRequest(omRequest, CLOCK); - final OMClientResponse response = request.validateAndUpdateCache(ozoneManager, context); - final OMResponse omResponse = response.getOMResponse(); + final OMException exception = assertThrows(OMException.class, () -> request.preExecute(ozoneManager)); - assertThat(omResponse.getStatus()).isEqualTo(Status.INVALID_REQUEST); - assertThat(omResponse.getMessage()).isEqualTo( + assertThat(exception.getResult()).isEqualTo(OMException.ResultCodes.INVALID_REQUEST); + assertThat(exception.getMessage()).isEqualTo( "Invalid Value: DurationSeconds must be between 900 and 43200 seconds"); - assertThat(omResponse.hasAssumeRoleResponse()).isFalse(); assertMarkForAuditCalled(request); } @@ -196,12 +205,17 @@ public void testValidDurationMaxBoundary() throws IOException { // Call preExecute first to generate credentials final S3AssumeRoleRequest request = new S3AssumeRoleRequest(omRequest, CLOCK); final OMRequest preExecutedRequest = request.preExecute(ozoneManager); + + assertLeaderGeneratedAssumeRoleFields(preExecutedRequest, 43200); + final S3AssumeRoleRequest requestWithCredentials = new S3AssumeRoleRequest(preExecutedRequest, CLOCK); final OMClientResponse response = requestWithCredentials.validateAndUpdateCache(ozoneManager, context); final OMResponse omResponse = response.getOMResponse(); assertThat(omResponse.getStatus()).isEqualTo(Status.OK); assertThat(omResponse.hasAssumeRoleResponse()).isTrue(); + verify(accessAuthorizer).generateAssumeRoleSessionPolicy( + any(org.apache.hadoop.ozone.security.acl.AssumeRoleRequest.class)); assertMarkForAuditCalled(requestWithCredentials); } @@ -219,12 +233,17 @@ public void testValidDurationMinBoundary() throws IOException { // Call preExecute first to generate credentials final S3AssumeRoleRequest request = new S3AssumeRoleRequest(omRequest, CLOCK); final OMRequest preExecutedRequest = request.preExecute(ozoneManager); + + assertLeaderGeneratedAssumeRoleFields(preExecutedRequest, 900); + final S3AssumeRoleRequest requestWithCredentials = new S3AssumeRoleRequest(preExecutedRequest, CLOCK); final OMClientResponse response = requestWithCredentials.validateAndUpdateCache(ozoneManager, context); final OMResponse omResponse = response.getOMResponse(); assertThat(omResponse.getStatus()).isEqualTo(Status.OK); assertThat(omResponse.hasAssumeRoleResponse()).isTrue(); + verify(accessAuthorizer).generateAssumeRoleSessionPolicy( + any(org.apache.hadoop.ozone.security.acl.AssumeRoleRequest.class)); assertMarkForAuditCalled(requestWithCredentials); } @@ -242,12 +261,10 @@ public void testMissingS3Authentication() { ).build(); final S3AssumeRoleRequest request = new S3AssumeRoleRequest(omRequest, CLOCK); - final OMClientResponse response = request.validateAndUpdateCache(ozoneManager, context); - final OMResponse omResponse = response.getOMResponse(); + final OMException exception = assertThrows(OMException.class, () -> request.preExecute(ozoneManager)); - assertThat(omResponse.getStatus()).isEqualTo(Status.INVALID_REQUEST); - assertThat(omResponse.getMessage()).isEqualTo("S3AssumeRoleRequest does not have S3 authentication"); - assertThat(omResponse.hasAssumeRoleResponse()).isFalse(); + assertThat(exception.getResult()).isEqualTo(OMException.ResultCodes.INVALID_REQUEST); + assertThat(exception.getMessage()).isEqualTo("S3AssumeRoleRequest does not have S3 authentication"); assertMarkForAuditCalled(request); } @@ -266,6 +283,9 @@ public void testSuccessfulAssumeRoleGeneratesCredentials() throws IOException { final S3AssumeRoleRequest request = new S3AssumeRoleRequest(omRequest, CLOCK); // Call preExecute first to generate credentials final OMRequest preExecutedRequest = request.preExecute(ozoneManager); + + assertLeaderGeneratedAssumeRoleFields(preExecutedRequest, durationSeconds); + final S3AssumeRoleRequest requestWithCredentials = new S3AssumeRoleRequest(preExecutedRequest, CLOCK); final OMClientResponse clientResponse = requestWithCredentials.validateAndUpdateCache(ozoneManager, context); final OMResponse omResponse = clientResponse.getOMResponse(); @@ -275,6 +295,8 @@ public void testSuccessfulAssumeRoleGeneratesCredentials() throws IOException { assertThat(omResponse.getCmdType()).isEqualTo(Type.AssumeRole); final AssumeRoleResponse assumeRoleResponse = omResponse.getAssumeRoleResponse(); + assertThat(assumeRoleResponse.getSessionToken()).isEqualTo( + preExecutedRequest.getUpdateAssumeRoleRequest().getSessionToken()); // AccessKeyId: prefix ASIA + 20 chars assertThat(assumeRoleResponse.getAccessKeyId()).startsWith("ASIA"); @@ -359,6 +381,29 @@ public void testAssumeRoleCredentialsAreUnique() throws IOException { verify(auditLogger, times(2)).logWrite(any(AuditMessage.class)); } + @Test + public void testValidateAndUpdateCacheDoesNotCallAuthorizer() throws IOException { + final OMRequest omRequest = baseOmRequestBuilder() + .setAssumeRoleRequest( + AssumeRoleRequest.newBuilder() + .setRoleArn(ROLE_ARN_1) + .setRoleSessionName(SESSION_NAME) + .setDurationSeconds(3600) + .setRequestId(REQUEST_ID) + ).build(); + + final S3AssumeRoleRequest request = new S3AssumeRoleRequest(omRequest, CLOCK); + final OMRequest preExecutedRequest = request.preExecute(ozoneManager); + verify(accessAuthorizer).generateAssumeRoleSessionPolicy( + any(org.apache.hadoop.ozone.security.acl.AssumeRoleRequest.class)); + + final S3AssumeRoleRequest requestWithCredentials = new S3AssumeRoleRequest(preExecutedRequest, CLOCK); + requestWithCredentials.validateAndUpdateCache(ozoneManager, context); + + verify(accessAuthorizer, times(1)).generateAssumeRoleSessionPolicy( + any(org.apache.hadoop.ozone.security.acl.AssumeRoleRequest.class)); + } + @Test public void testAssumeRoleWithEmptySessionName() { final OMRequest omRequest = baseOmRequestBuilder() @@ -371,9 +416,9 @@ public void testAssumeRoleWithEmptySessionName() { ).build(); final S3AssumeRoleRequest request = new S3AssumeRoleRequest(omRequest, CLOCK); - final OMClientResponse response = request.validateAndUpdateCache(ozoneManager, context); - assertThat(response.getOMResponse().getStatus()).isEqualTo(Status.INVALID_REQUEST); - assertThat(response.getOMResponse().getMessage()).isEqualTo( + final OMException exception = assertThrows(OMException.class, () -> request.preExecute(ozoneManager)); + assertThat(exception.getResult()).isEqualTo(OMException.ResultCodes.INVALID_REQUEST); + assertThat(exception.getMessage()).isEqualTo( "Value null at 'roleSessionName' failed to satisfy constraint: Member must not be null"); assertMarkForAuditCalled(request); } @@ -389,14 +434,12 @@ public void testInvalidAssumeRoleSessionNameTooShort() { ).build(); final S3AssumeRoleRequest request = new S3AssumeRoleRequest(omRequest, CLOCK); - final OMClientResponse response = request.validateAndUpdateCache(ozoneManager, context); - final OMResponse omResponse = response.getOMResponse(); + final OMException exception = assertThrows(OMException.class, () -> request.preExecute(ozoneManager)); - assertThat(omResponse.getStatus()).isEqualTo(Status.INVALID_REQUEST); - assertThat(omResponse.getMessage()).isEqualTo( + assertThat(exception.getResult()).isEqualTo(OMException.ResultCodes.INVALID_REQUEST); + assertThat(exception.getMessage()).isEqualTo( "Invalid RoleSessionName length 1: it must be 2-64 characters long and contain only alphanumeric " + "characters and +, =, ,, ., @, -"); - assertThat(omResponse.hasAssumeRoleResponse()).isFalse(); assertMarkForAuditCalled(request); } @@ -412,15 +455,13 @@ public void testInvalidRoleSessionNameTooLong() { ).build(); final S3AssumeRoleRequest request = new S3AssumeRoleRequest(omRequest, CLOCK); - final OMClientResponse response = request.validateAndUpdateCache(ozoneManager, context); - final OMResponse omResponse = response.getOMResponse(); + final OMException exception = assertThrows(OMException.class, () -> request.preExecute(ozoneManager)); - assertThat(omResponse.getStatus()).isEqualTo(Status.INVALID_REQUEST); - assertThat(omResponse.getMessage()).isEqualTo( + assertThat(exception.getResult()).isEqualTo(OMException.ResultCodes.INVALID_REQUEST); + assertThat(exception.getMessage()).isEqualTo( "Invalid RoleSessionName length 70: it must be 2-64 characters long and contain only alphanumeric " + "characters and +, =, ,, ., @, -" ); - assertThat(omResponse.hasAssumeRoleResponse()).isFalse(); assertMarkForAuditCalled(request); } @@ -511,17 +552,13 @@ public void testMalformedSessionPolicyDoesNotIssueCredentials() throws IOExcepti ).build(); final S3AssumeRoleRequest request = new S3AssumeRoleRequest(omRequest, CLOCK); - final OMRequest preExecutedRequest = request.preExecute(ozoneManager); - final S3AssumeRoleRequest requestWithCredentials = new S3AssumeRoleRequest(preExecutedRequest, CLOCK); - final OMClientResponse response = requestWithCredentials.validateAndUpdateCache(ozoneManager, context); - final OMResponse omResponse = response.getOMResponse(); + final OMException exception = assertThrows(OMException.class, () -> request.preExecute(ozoneManager)); - assertThat(omResponse.getStatus()).isEqualTo(Status.MALFORMED_POLICY_DOCUMENT); - assertThat(omResponse.getMessage()).isEqualTo("IAM session policy: Duplicate field 'Action' in session policy"); - assertThat(omResponse.hasAssumeRoleResponse()).isFalse(); + assertThat(exception.getResult()).isEqualTo(OMException.ResultCodes.MALFORMED_POLICY_DOCUMENT); + assertThat(exception.getMessage()).isEqualTo("IAM session policy: Duplicate field 'Action' in session policy"); verify(accessAuthorizer, never()).generateAssumeRoleSessionPolicy( any(org.apache.hadoop.ozone.security.acl.AssumeRoleRequest.class)); - assertMarkForAuditCalled(requestWithCredentials); + assertMarkForAuditCalled(request); } @Test @@ -604,6 +641,273 @@ public void testGetSessionPolicyWithBlankAwsPolicyCapturesNullGrants() throws Ex assertThat(capturedAssumeRoleRequest.getGrants()).isNull(); } + @Test + public void testResolveGrantsAgainstBucketLinksLeavesNonLinkGrantsUnchanged() throws IOException { + final Set grants = Collections.singleton( + new OzoneGrant( + objectsOf( + obj(OzoneObj.ResourceType.VOLUME, "s3v", null, null), + obj(OzoneObj.ResourceType.BUCKET, "s3v", "mybucket", null), + obj(OzoneObj.ResourceType.KEY, "s3v", "mybucket", "*")), + EnumSet.of(ACLType.READ), Collections.singleton("GetObject"))); + + final Set result = S3AssumeRoleRequest.resolveGrantsAgainstBucketLinks( + grants, (volume, bucket) -> new ResolvedBucket( + volume, bucket, volume, bucket, "owner", BucketLayout.OBJECT_STORE)); + + assertThat(result).isEqualTo(grants); + } + + @Test + public void testResolveGrantsAgainstBucketLinksRewritesToSourceForSameVolumeLink() throws IOException { + final Set grants = Collections.singleton( + new OzoneGrant( + objectsOf( + obj(OzoneObj.ResourceType.VOLUME, "s3v", null, null), + obj(OzoneObj.ResourceType.BUCKET, "s3v", "s3v-iceberg", null), + obj(OzoneObj.ResourceType.KEY, "s3v", "s3v-iceberg", "*")), + EnumSet.of(ACLType.READ), Collections.singleton("GetObject"))); + + final Set result = S3AssumeRoleRequest.resolveGrantsAgainstBucketLinks( + grants, (volume, bucket) -> resolved( + volume, bucket, "s3v", "iceberg", Pair.of("s3v", "s3v-iceberg"))); + + final IOzoneObj linkBucket = obj(OzoneObj.ResourceType.BUCKET, "s3v", "s3v-iceberg", null); + final Set allObjects = allObjectsIn(result); + // Key and bucket are anchored to the link's source. + assertThat(allObjects).contains(obj(OzoneObj.ResourceType.KEY, "s3v", "iceberg", "*")); + assertThat(allObjects).contains(obj(OzoneObj.ResourceType.BUCKET, "s3v", "iceberg", null)); + // The link key is gone; only a READ on the link bucket remains, for following the link. + assertThat(allObjects).doesNotContain(obj(OzoneObj.ResourceType.KEY, "s3v", "s3v-iceberg", "*")); + assertThat(allObjects).contains(linkBucket); + // Same volume, so no extra source-volume grant is added. + assertThat(allObjects).doesNotContain(obj(OzoneObj.ResourceType.VOLUME, "iceberg", null, null)); + + final OzoneGrant followGrant = grantContaining(result, linkBucket); + assertThat(followGrant.getPermissions()).containsExactly(ACLType.READ); + assertThat(followGrant.getS3Actions()).isEmpty(); + } + + @Test + public void testResolveGrantsAgainstBucketLinksRewritesPrefixOnSameVolumeLink() throws IOException { + final Set grants = Collections.singleton( + new OzoneGrant( + objectsOf( + prefixObj("s3v", "s3v-iceberg", "folder/")), + EnumSet.of(ACLType.READ, ACLType.LIST), Collections.singleton("ListBucket"))); + + final Set result = S3AssumeRoleRequest.resolveGrantsAgainstBucketLinks( + grants, (volume, bucket) -> resolved( + volume, bucket, "s3v", "iceberg", Pair.of("s3v", "s3v-iceberg"))); + + final IOzoneObj linkBucket = obj(OzoneObj.ResourceType.BUCKET, "s3v", "s3v-iceberg", null); + final Set allObjects = allObjectsIn(result); + assertThat(allObjects).contains(prefixObj("s3v", "iceberg", "folder/")); + assertThat(allObjects).doesNotContain(prefixObj("s3v", "s3v-iceberg", "folder/")); + assertThat(allObjects).contains(linkBucket); + + final OzoneGrant followGrant = grantContaining(result, linkBucket); + assertThat(followGrant.getPermissions()).containsExactly(ACLType.READ); + assertThat(followGrant.getS3Actions()).isEmpty(); + } + + @Test + public void testResolveGrantsAgainstBucketLinksAddsSourceVolumeReadForCrossVolumeLink() throws IOException { + final Set grants = Collections.singleton( + new OzoneGrant( + objectsOf( + obj(OzoneObj.ResourceType.VOLUME, "s3v", null, null), + obj(OzoneObj.ResourceType.BUCKET, "s3v", "s3v-iceberg", null), + obj(OzoneObj.ResourceType.KEY, "s3v", "s3v-iceberg", "*")), + EnumSet.of(ACLType.READ), Collections.singleton("GetObject"))); + + final Set result = S3AssumeRoleRequest.resolveGrantsAgainstBucketLinks( + grants, (volume, bucket) -> resolved( + volume, bucket, "tenantvol", "iceberg", Pair.of("s3v", "s3v-iceberg"))); + + final IOzoneObj linkBucket = obj(OzoneObj.ResourceType.BUCKET, "s3v", "s3v-iceberg", null); + final IOzoneObj sourceVolume = obj(OzoneObj.ResourceType.VOLUME, "tenantvol", null, null); + final Set allObjects = allObjectsIn(result); + assertThat(allObjects).contains(obj(OzoneObj.ResourceType.KEY, "tenantvol", "iceberg", "*")); + assertThat(allObjects).contains(linkBucket); + assertThat(allObjects).contains(sourceVolume); + + final OzoneGrant followGrant = grantContaining(result, sourceVolume); + assertThat(followGrant.getPermissions()).containsExactly(ACLType.READ); + assertThat(followGrant.getObjects()).contains(linkBucket); + } + + @Test + public void testResolveGrantsAgainstBucketLinksSkipsWildcardBuckets() throws IOException { + final Set grants = Collections.singleton( + new OzoneGrant( + objectsOf( + obj(OzoneObj.ResourceType.VOLUME, "s3v", null, null), + obj(OzoneObj.ResourceType.BUCKET, "s3v", "*", null), + obj(OzoneObj.ResourceType.KEY, "s3v", "*", "*")), + EnumSet.of(ACLType.READ), Collections.singleton("GetObject"))); + + final Set result = S3AssumeRoleRequest.resolveGrantsAgainstBucketLinks( + grants, (volume, bucket) -> { + throw new AssertionError("link resolver must not be called for wildcard buckets"); + }); + + assertThat(result).isEqualTo(grants); + } + + @Test + public void testResolveGrantsAgainstBucketLinksLeavesDanglingBucketsUnchanged() throws IOException { + final Set grants = Collections.singleton( + new OzoneGrant( + objectsOf( + obj(OzoneObj.ResourceType.KEY, "s3v", "danglingBucket", "*")), + EnumSet.of(ACLType.READ), Collections.singleton("GetObject"))); + + final Set result = S3AssumeRoleRequest.resolveGrantsAgainstBucketLinks( + grants, (volume, bucket) -> new ResolvedBucket(volume, bucket, null, null, null, null)); + + assertThat(result).isEqualTo(grants); + } + + @Test + public void testResolveGrantsAgainstBucketLinksRewritesChainedSameVolumeLink() throws IOException { + final Set grants = Collections.singleton( + new OzoneGrant( + objectsOf( + obj(OzoneObj.ResourceType.BUCKET, "s3v", "linkA", null), + obj(OzoneObj.ResourceType.KEY, "s3v", "linkA", "*")), + EnumSet.of(ACLType.READ), Collections.singleton("GetObject"))); + + final Set result = S3AssumeRoleRequest.resolveGrantsAgainstBucketLinks( + grants, (volume, bucket) -> resolved( + volume, bucket, "s3v", "source", Pair.of("s3v", "linkA"), Pair.of("s3v", "linkB"))); + + final IOzoneObj linkA = obj(OzoneObj.ResourceType.BUCKET, "s3v", "linkA", null); + final IOzoneObj linkB = obj(OzoneObj.ResourceType.BUCKET, "s3v", "linkB", null); + final Set allObjects = allObjectsIn(result); + assertThat(allObjects).contains(obj(OzoneObj.ResourceType.KEY, "s3v", "source", "*")); + assertThat(allObjects).contains(linkA); + assertThat(allObjects).contains(linkB); + assertThat(allObjects).doesNotContain(obj(OzoneObj.ResourceType.VOLUME, "s3v", null, null)); + } + + @Test + public void testResolveGrantsAgainstBucketLinksAddsVolumeReadForChainedCrossVolumeHop() throws IOException { + final Set grants = Collections.singleton( + new OzoneGrant( + objectsOf( + obj(OzoneObj.ResourceType.BUCKET, "s3v", "linkA", null), + obj(OzoneObj.ResourceType.KEY, "s3v", "linkA", "*")), + EnumSet.of(ACLType.READ), Collections.singleton("GetObject"))); + + final Set result = S3AssumeRoleRequest.resolveGrantsAgainstBucketLinks( + grants, (volume, bucket) -> resolved( + volume, bucket, "tenant", "source", Pair.of("s3v", "linkA"), Pair.of("tenant", "linkB"))); + + final IOzoneObj linkA = obj(OzoneObj.ResourceType.BUCKET, "s3v", "linkA", null); + final IOzoneObj linkB = obj(OzoneObj.ResourceType.BUCKET, "tenant", "linkB", null); + final IOzoneObj tenantVolume = obj(OzoneObj.ResourceType.VOLUME, "tenant", null, null); + final Set allObjects = allObjectsIn(result); + assertThat(allObjects).contains(obj(OzoneObj.ResourceType.KEY, "tenant", "source", "*")); + assertThat(allObjects).contains(linkA); + assertThat(allObjects).contains(linkB); + assertThat(allObjects).contains(tenantVolume); + assertThat(allObjects).doesNotContain(obj(OzoneObj.ResourceType.VOLUME, "s3v", null, null)); + } + + @Test + public void testGetSessionPolicyRewritesLinkBucketGrantsToSource() throws Exception { + when(ozoneManager.isS3MultiTenancyEnabled()).thenReturn(false); + + final Set resolverGrants = Collections.singleton( + new OzoneGrant( + objectsOf( + obj(OzoneObj.ResourceType.VOLUME, "s3v", null, null), + obj(OzoneObj.ResourceType.BUCKET, "s3v", "s3v-iceberg", null), + obj(OzoneObj.ResourceType.KEY, "s3v", "s3v-iceberg", "*")), + EnumSet.of(ACLType.READ), Collections.singleton("GetObject"))); + + when(ozoneManager.resolveBucketLink(Pair.of("s3v", "s3v-iceberg"), true, false)) + .thenReturn(resolved("s3v", "s3v-iceberg", "s3v", "iceberg", Pair.of("s3v", "s3v-iceberg"))); + + try (MockedStatic resolverMock = mockStatic(IamSessionPolicyResolver.class)) { + resolverMock.when(() -> IamSessionPolicyResolver.resolve( + AWS_IAM_POLICY, "s3v", IamSessionPolicyResolver.AuthorizerType.RANGER)) + .thenReturn(resolverGrants); + + final String result = new S3AssumeRoleRequest(baseOmRequestBuilder().build(), CLOCK) + .getSessionPolicy( + ozoneManager, ORIGINAL_ACCESS_KEY_ID, AWS_IAM_POLICY, OM_HOST, LOOPBACK_IP, + UserGroupInformation.createRemoteUser("userNameLink"), TARGET_ROLE_NAME); + // Ensure no exception was thrown and that the method actually delegated to generateAssumeRoleSessionPolicy + // and returned its value (not null, not something else). + assertThat(result).isEqualTo(SESSION_POLICY_VALUE); + } + + final ArgumentCaptor captor = ArgumentCaptor.forClass( + org.apache.hadoop.ozone.security.acl.AssumeRoleRequest.class); + verify(accessAuthorizer).generateAssumeRoleSessionPolicy(captor.capture()); + + final Set allObjects = allObjectsIn(captor.getValue().getGrants()); + assertThat(allObjects).contains(obj(OzoneObj.ResourceType.KEY, "s3v", "iceberg", "*")); + assertThat(allObjects).contains(obj(OzoneObj.ResourceType.BUCKET, "s3v", "s3v-iceberg", null)); + assertThat(allObjects).doesNotContain(obj(OzoneObj.ResourceType.KEY, "s3v", "s3v-iceberg", "*")); + + verify(ozoneManager).resolveBucketLink(Pair.of("s3v", "s3v-iceberg"), true, false); + } + + @SafeVarargs + private static ResolvedBucket resolved(String requestedVol, String requestedBucket, String realVol, String realBucket, + Pair... linkChain) { + return new ResolvedBucket( + requestedVol, requestedBucket, realVol, realBucket, "owner", + BucketLayout.OBJECT_STORE, Arrays.asList(linkChain)); + } + + private static IOzoneObj obj(OzoneObj.ResourceType type, String volume, String bucket, String key) { + final OzoneObjInfo.Builder builder = OzoneObjInfo.Builder.newBuilder() + .setResType(type) + .setStoreType(OzoneObj.StoreType.OZONE) + .setVolumeName(volume); + if (bucket != null) { + builder.setBucketName(bucket); + } + if (key != null) { + builder.setKeyName(key); + } + return builder.build(); + } + + @SuppressWarnings("SameParameterValue") + private static IOzoneObj prefixObj(String volume, String bucket, String prefix) { + return OzoneObjInfo.Builder.newBuilder() + .setResType(OzoneObj.ResourceType.PREFIX) + .setStoreType(OzoneObj.StoreType.OZONE) + .setVolumeName(volume) + .setBucketName(bucket) + .setPrefixName(prefix) + .build(); + } + + private static Set objectsOf(IOzoneObj... objects) { + return new LinkedHashSet<>(Arrays.asList(objects)); + } + + private static Set allObjectsIn(Set grants) { + final Set all = new LinkedHashSet<>(); + for (OzoneGrant grant : grants) { + all.addAll(grant.getObjects()); + } + return all; + } + + private static OzoneGrant grantContaining(Set grants, IOzoneObj object) { + return grants.stream() + .filter(grant -> grant.getObjects().contains(object)) + .findFirst() + .orElseThrow(() -> new AssertionError("No grant contains " + object)); + } + private org.apache.hadoop.ozone.security.acl.AssumeRoleRequest captureAssumeRoleRequest(String volumeName, String userName) throws Exception { try (MockedStatic resolverMock = mockStatic(IamSessionPolicyResolver.class)) { @@ -637,6 +941,17 @@ private static OMRequest.Builder baseOmRequestBuilder() { ); } + private void assertLeaderGeneratedAssumeRoleFields(OMRequest preExecutedRequest, int durationSeconds) { + assertThat(preExecutedRequest.hasUpdateAssumeRoleRequest()).isTrue(); + final UpdateAssumeRoleRequest updateAssumeRoleRequest = preExecutedRequest.getUpdateAssumeRoleRequest(); + assertThat(updateAssumeRoleRequest.getTempAccessKeyId()).startsWith("ASIA"); + assertThat(updateAssumeRoleRequest.getSecretAccessKey()).isNotEmpty(); + assertThat(updateAssumeRoleRequest.getRoleId()).startsWith("AROA"); + assertThat(updateAssumeRoleRequest.getSessionToken()).isNotEmpty(); + assertThat(updateAssumeRoleRequest.getExpirationEpochSeconds()) + .isEqualTo(CLOCK.instant().getEpochSecond() + durationSeconds); + } + private void assertMarkForAuditCalled(S3AssumeRoleRequest request) { OMAuditLogger.log(request.getAuditBuilder()); verify(auditLogger).logWrite(any(AuditMessage.class)); diff --git a/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/security/TestSTSSecurityUtil.java b/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/security/TestSTSSecurityUtil.java index 608be8f6603a..2f3a0350b578 100644 --- a/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/security/TestSTSSecurityUtil.java +++ b/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/security/TestSTSSecurityUtil.java @@ -80,8 +80,8 @@ public void testConstructValidateAndDecryptSTSTokenInvalidProtobuf() throws IOEx @Test public void testConstructValidateAndDecryptSTSTokenSuccess() throws IOException { // Create a valid token - final String tokenString = tokenSecretManager.createSTSTokenString( - TEMP_ACCESS_KEY, ORIGINAL_ACCESS_KEY, ROLE_ARN, DURATION_SECONDS, SECRET_ACCESS_KEY, SESSION_POLICY, clock); + final String tokenString = tokenSecretManager.createSTSTokenString(TEMP_ACCESS_KEY, ORIGINAL_ACCESS_KEY, + ROLE_ARN, DURATION_SECONDS, SECRET_ACCESS_KEY, SESSION_POLICY, clock.instant()); // Validate and decrypt the token final STSTokenIdentifier result = STSSecurityUtil.constructValidateAndDecryptSTSToken( @@ -103,7 +103,7 @@ public void testConstructValidateAndDecryptSTSTokenSuccess() throws IOException public void testConstructValidateAndDecryptSTSTokenSuccessWithNullSessionPolicy() throws Exception { // Create a valid token with null session policy final String tokenString = tokenSecretManager.createSTSTokenString( - TEMP_ACCESS_KEY, ORIGINAL_ACCESS_KEY, ROLE_ARN, DURATION_SECONDS, SECRET_ACCESS_KEY, null, clock); + TEMP_ACCESS_KEY, ORIGINAL_ACCESS_KEY, ROLE_ARN, DURATION_SECONDS, SECRET_ACCESS_KEY, null, clock.instant()); // Validate and decrypt the token final STSTokenIdentifier result = STSSecurityUtil.constructValidateAndDecryptSTSToken( @@ -135,8 +135,8 @@ public void testConstructValidateAndDecryptSTSTokenRuntimeDecodeFailure() { @Test public void testConstructValidateAndDecryptSTSTokenInvalidKind() throws Exception { // Create a valid identifier to use as base - final String validTokenString = tokenSecretManager.createSTSTokenString( - TEMP_ACCESS_KEY, ORIGINAL_ACCESS_KEY, ROLE_ARN, DURATION_SECONDS, SECRET_ACCESS_KEY, SESSION_POLICY, clock); + final String validTokenString = tokenSecretManager.createSTSTokenString(TEMP_ACCESS_KEY, ORIGINAL_ACCESS_KEY, + ROLE_ARN, DURATION_SECONDS, SECRET_ACCESS_KEY, SESSION_POLICY, clock.instant()); final Token validToken = new Token<>(); validToken.decodeFromUrlString(validTokenString); @@ -158,8 +158,8 @@ public void testConstructValidateAndDecryptSTSTokenInvalidKind() throws Exceptio @Test public void testConstructValidateAndDecryptSTSTokenInvalidService() throws Exception { // Create a token with incorrect service - final String validTokenString = tokenSecretManager.createSTSTokenString( - TEMP_ACCESS_KEY, ORIGINAL_ACCESS_KEY, ROLE_ARN, DURATION_SECONDS, SECRET_ACCESS_KEY, SESSION_POLICY, clock); + final String validTokenString = tokenSecretManager.createSTSTokenString(TEMP_ACCESS_KEY, ORIGINAL_ACCESS_KEY, + ROLE_ARN, DURATION_SECONDS, SECRET_ACCESS_KEY, SESSION_POLICY, clock.instant()); final Token validToken = new Token<>(); validToken.decodeFromUrlString(validTokenString); @@ -180,7 +180,7 @@ public void testConstructValidateAndDecryptSTSTokenInvalidService() throws Excep public void testConstructValidateAndDecryptSTSTokenExpired() throws Exception { // Create a token that expires immediately (durationSeconds of 0) final String tokenString = tokenSecretManager.createSTSTokenString( - TEMP_ACCESS_KEY, ORIGINAL_ACCESS_KEY, ROLE_ARN, 0, SECRET_ACCESS_KEY, SESSION_POLICY, clock); + TEMP_ACCESS_KEY, ORIGINAL_ACCESS_KEY, ROLE_ARN, 0, SECRET_ACCESS_KEY, SESSION_POLICY, clock.instant()); // Fast-forward time to ensure token is expired clock.fastForward(100); @@ -196,8 +196,8 @@ public void testConstructValidateAndDecryptSTSTokenExpired() throws Exception { @Test public void testConstructValidateAndDecryptSTSTokenSecretKeyNotFound() throws Exception { // Create a valid token string - final String validTokenString = tokenSecretManager.createSTSTokenString( - TEMP_ACCESS_KEY, ORIGINAL_ACCESS_KEY, ROLE_ARN, DURATION_SECONDS, SECRET_ACCESS_KEY, SESSION_POLICY, clock); + final String validTokenString = tokenSecretManager.createSTSTokenString(TEMP_ACCESS_KEY, ORIGINAL_ACCESS_KEY, + ROLE_ARN, DURATION_SECONDS, SECRET_ACCESS_KEY, SESSION_POLICY, clock.instant()); // Create a mock secret key client that returns null for the key final SecretKeyClient mockKeyClient = mock(SecretKeyClient.class); @@ -215,8 +215,8 @@ public void testConstructValidateAndDecryptSTSTokenSecretKeyNotFound() throws Ex @Test public void testConstructValidateAndDecryptSTSTokenInvalidSecretKeyId() throws Exception { // Create a valid identifier to use as base - final String validTokenString = tokenSecretManager.createSTSTokenString( - TEMP_ACCESS_KEY, ORIGINAL_ACCESS_KEY, ROLE_ARN, DURATION_SECONDS, SECRET_ACCESS_KEY, SESSION_POLICY, clock); + final String validTokenString = tokenSecretManager.createSTSTokenString(TEMP_ACCESS_KEY, ORIGINAL_ACCESS_KEY, + ROLE_ARN, DURATION_SECONDS, SECRET_ACCESS_KEY, SESSION_POLICY, clock.instant()); final Token validToken = new Token<>(); validToken.decodeFromUrlString(validTokenString); @@ -241,8 +241,8 @@ public void testConstructValidateAndDecryptSTSTokenInvalidSecretKeyId() throws E @Test public void testConstructValidateAndDecryptSTSTokenExpiredSecretKey() throws Exception { // Create a valid token string - final String validTokenString = tokenSecretManager.createSTSTokenString( - TEMP_ACCESS_KEY, ORIGINAL_ACCESS_KEY, ROLE_ARN, DURATION_SECONDS, SECRET_ACCESS_KEY, SESSION_POLICY, clock); + final String validTokenString = tokenSecretManager.createSTSTokenString(TEMP_ACCESS_KEY, ORIGINAL_ACCESS_KEY, + ROLE_ARN, DURATION_SECONDS, SECRET_ACCESS_KEY, SESSION_POLICY, clock.instant()); // Create a mock secret key that is expired final ManagedSecretKey expiredSecretKey = mock(ManagedSecretKey.class); @@ -264,8 +264,8 @@ public void testConstructValidateAndDecryptSTSTokenExpiredSecretKey() throws Exc @Test public void testConstructValidateAndDecryptSTSTokenSecretKeyRetrievalException() throws Exception { // Create a valid token string - final String validTokenString = tokenSecretManager.createSTSTokenString( - TEMP_ACCESS_KEY, ORIGINAL_ACCESS_KEY, ROLE_ARN, DURATION_SECONDS, SECRET_ACCESS_KEY, SESSION_POLICY, clock); + final String validTokenString = tokenSecretManager.createSTSTokenString(TEMP_ACCESS_KEY, ORIGINAL_ACCESS_KEY, + ROLE_ARN, DURATION_SECONDS, SECRET_ACCESS_KEY, SESSION_POLICY, clock.instant()); // Create a mock secret key client that throws an exception final SecretKeyClient mockKeyClient = mock(SecretKeyClient.class); @@ -283,8 +283,8 @@ public void testConstructValidateAndDecryptSTSTokenSecretKeyRetrievalException() @Test public void testConstructValidateAndDecryptSTSTokenInvalidSignature() throws Exception { // Create a valid token string - final String validTokenString = tokenSecretManager.createSTSTokenString( - TEMP_ACCESS_KEY, ORIGINAL_ACCESS_KEY, ROLE_ARN, DURATION_SECONDS, SECRET_ACCESS_KEY, SESSION_POLICY, clock); + final String validTokenString = tokenSecretManager.createSTSTokenString(TEMP_ACCESS_KEY, ORIGINAL_ACCESS_KEY, + ROLE_ARN, DURATION_SECONDS, SECRET_ACCESS_KEY, SESSION_POLICY, clock.instant()); final Token validToken = new Token<>(); validToken.decodeFromUrlString(validTokenString); @@ -307,7 +307,7 @@ public void testConstructValidateAndDecryptSTSTokenInvalidSignature() throws Exc public void testConstructValidateAndDecryptSTSTokenRejectsDoubledToken() throws Exception { final String tokenString = tokenSecretManager.createSTSTokenString( TEMP_ACCESS_KEY, ORIGINAL_ACCESS_KEY, ROLE_ARN, DURATION_SECONDS, - SECRET_ACCESS_KEY, SESSION_POLICY, clock); + SECRET_ACCESS_KEY, SESSION_POLICY, clock.instant()); assertThatThrownBy(() -> STSSecurityUtil.constructValidateAndDecryptSTSToken(tokenString + tokenString, secretKeyClient, clock)) @@ -320,7 +320,7 @@ public void testConstructValidateAndDecryptSTSTokenRejectsDoubledToken() throws public void testConstructValidateAndDecryptSTSTokenRejectsTokenWithSuffix() throws Exception { final String tokenString = tokenSecretManager.createSTSTokenString( TEMP_ACCESS_KEY, ORIGINAL_ACCESS_KEY, ROLE_ARN, DURATION_SECONDS, - SECRET_ACCESS_KEY, SESSION_POLICY, clock); + SECRET_ACCESS_KEY, SESSION_POLICY, clock.instant()); assertThatThrownBy(() -> STSSecurityUtil.constructValidateAndDecryptSTSToken(tokenString + "garbage", secretKeyClient, clock)) @@ -344,11 +344,11 @@ public void testConstructValidateAndDecryptMultipleTokens() throws Exception { // Create multiple tokens and validate them all final String token1 = tokenSecretManager.createSTSTokenString( "temp-key-1", "orig-key-1", "role-arn-1", DURATION_SECONDS, - "secret-key-1", "policy-1", clock); + "secret-key-1", "policy-1", clock.instant()); final String token2 = tokenSecretManager.createSTSTokenString( "temp-key-2", "orig-key-2", "role-arn-2", DURATION_SECONDS, - "secret-key-2", "policy-2", clock); + "secret-key-2", "policy-2", clock.instant()); final STSTokenIdentifier result1 = STSSecurityUtil.constructValidateAndDecryptSTSToken( token1, secretKeyClient, clock); @@ -418,8 +418,8 @@ public void testEnsureEssentialFieldsArePresentInTokenMissingCreationTime() { @Test public void testEnsureResolvedStsFieldsInvariantsSuccess() throws Exception { - final String tokenString = tokenSecretManager.createSTSTokenString( - TEMP_ACCESS_KEY, ORIGINAL_ACCESS_KEY, ROLE_ARN, DURATION_SECONDS, SECRET_ACCESS_KEY, SESSION_POLICY, clock); + final String tokenString = tokenSecretManager.createSTSTokenString(TEMP_ACCESS_KEY, ORIGINAL_ACCESS_KEY, + ROLE_ARN, DURATION_SECONDS, SECRET_ACCESS_KEY, SESSION_POLICY, clock.instant()); final S3Authentication s3Auth = S3Authentication.newBuilder() .setSessionToken(tokenString) @@ -460,7 +460,7 @@ public void testEnsureResolvedStsFieldsInvariantsMissingSessionToken() { public void testEnsureResolvedStsFieldsInvariantsMissingResolvedFields() throws Exception { final String tokenString = tokenSecretManager.createSTSTokenString( TEMP_ACCESS_KEY, ORIGINAL_ACCESS_KEY, ROLE_ARN, DURATION_SECONDS, - SECRET_ACCESS_KEY, SESSION_POLICY, clock); + SECRET_ACCESS_KEY, SESSION_POLICY, clock.instant()); final S3Authentication s3Auth = S3Authentication.newBuilder() .setSessionToken(tokenString) diff --git a/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/security/TestSTSTokenSecretManager.java b/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/security/TestSTSTokenSecretManager.java index 4408652dbb9d..fe20c9dd2a40 100644 --- a/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/security/TestSTSTokenSecretManager.java +++ b/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/security/TestSTSTokenSecretManager.java @@ -84,8 +84,8 @@ public void setUp() throws Exception { @Test public void testCreateSTSTokenStringContainsCorrectFields() throws IOException { - final String tokenString = secretManager.createSTSTokenString( - TEMP_ACCESS_KEY, ORIGINAL_ACCESS_KEY, ROLE_ARN, DURATION_SECONDS, SECRET_ACCESS_KEY, SESSION_POLICY, clock); + final String tokenString = secretManager.createSTSTokenString(TEMP_ACCESS_KEY, ORIGINAL_ACCESS_KEY, + ROLE_ARN, DURATION_SECONDS, SECRET_ACCESS_KEY, SESSION_POLICY, clock.instant()); // Decode the token final Token token = new Token<>(); @@ -114,7 +114,7 @@ public void testCreateSTSTokenStringContainsCorrectFields() throws IOException { @Test public void testCreateSTSTokenStringWithNullSessionPolicy() throws IOException { final String tokenString = secretManager.createSTSTokenString( - TEMP_ACCESS_KEY, ORIGINAL_ACCESS_KEY, ROLE_ARN, DURATION_SECONDS, SECRET_ACCESS_KEY, null, clock); + TEMP_ACCESS_KEY, ORIGINAL_ACCESS_KEY, ROLE_ARN, DURATION_SECONDS, SECRET_ACCESS_KEY, null, clock.instant()); // Decode the token final Token token = new Token<>(); @@ -150,8 +150,8 @@ public void testCreateSTSTokenStringValidatesWhenSecretKeyRotatesDuringCreation( encryptionKey, signingKey); final STSTokenSecretManager rotatingSecretManager = new STSTokenSecretManager(rotatingSecretKeyClient); - final String tokenString = rotatingSecretManager.createSTSTokenString( - TEMP_ACCESS_KEY, ORIGINAL_ACCESS_KEY, ROLE_ARN, DURATION_SECONDS, SECRET_ACCESS_KEY, SESSION_POLICY, clock); + final String tokenString = rotatingSecretManager.createSTSTokenString(TEMP_ACCESS_KEY, ORIGINAL_ACCESS_KEY, + ROLE_ARN, DURATION_SECONDS, SECRET_ACCESS_KEY, SESSION_POLICY, clock.instant()); final STSTokenIdentifier result = STSSecurityUtil.constructValidateAndDecryptSTSToken( tokenString, rotatingSecretKeyClient, clock); From 5bc5a1e362f5143198683b511a32984f6319a8fb Mon Sep 17 00:00:00 2001 From: fmorg-git Date: Thu, 3 Sep 2026 18:10:08 -0700 Subject: [PATCH 54/54] HDDS-16290. [STS] Implement GetCallerIdentity API (#11121) --- hadoop-hdds/docs/content/design/ozone-sts.md | 12 +- .../hadoop/ozone/client/ObjectStore.java | 10 + .../ozone/client/protocol/ClientProtocol.java | 8 + .../hadoop/ozone/client/rpc/RpcClient.java | 6 + .../java/org/apache/hadoop/ozone/OmUtils.java | 2 + .../ozone/om/helpers/CallerIdentityInfo.java | 88 ++++++++ .../hadoop/ozone/om/helpers/S3STSUtils.java | 34 ++- .../om/protocol/OzoneManagerProtocol.java | 10 + ...ManagerProtocolClientSideTranslatorPB.java | 10 + .../om/helpers/TestCallerIdentityInfo.java | 107 ++++++++++ .../helpers/TestS3STSUtilsCallerIdentity.java | 59 ++++++ .../security/ozone-secure-sts.resource | 37 ++++ .../smoketest/security/ozone-secure-sts.robot | 32 +++ .../src/main/proto/OmClientProtocol.proto | 14 ++ .../s3/security/S3AssumeRoleRequest.java | 199 ++++++++++++++++-- .../OzoneManagerRequestHandler.java | 24 +++ .../ozone/security/STSTokenIdentifier.java | 53 ++++- .../ozone/security/STSTokenSecretManager.java | 158 ++++++++++++-- .../s3/security/TestS3AssumeRoleRequest.java | 41 +++- .../ozone/security/TestSTSSecurityUtil.java | 84 ++++---- .../security/TestSTSTokenIdentifier.java | 14 ++ .../security/TestSTSTokenSecretManager.java | 25 ++- .../apache/hadoop/ozone/audit/S3GAction.java | 1 + .../ozone/s3/util/S3GActionIamMapper.java | 1 + .../ozone/s3sts/S3AssumeRoleResponseXml.java | 23 +- .../s3sts/S3GetCallerIdentityResponseXml.java | 92 ++++++++ .../hadoop/ozone/s3sts/S3STSEndpoint.java | 126 ++++++++--- .../ozone/s3sts/S3STSResponseMetadata.java | 42 ++++ .../ozone/client/ClientProtocolStub.java | 6 + .../ozone/s3/util/TestS3GActionIamMapper.java | 1 + .../hadoop/ozone/s3sts/TestS3STSEndpoint.java | 79 +++++++ 31 files changed, 1261 insertions(+), 137 deletions(-) create mode 100644 hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/om/helpers/CallerIdentityInfo.java create mode 100644 hadoop-ozone/common/src/test/java/org/apache/hadoop/ozone/om/helpers/TestCallerIdentityInfo.java create mode 100644 hadoop-ozone/common/src/test/java/org/apache/hadoop/ozone/om/helpers/TestS3STSUtilsCallerIdentity.java create mode 100644 hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3sts/S3GetCallerIdentityResponseXml.java create mode 100644 hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3sts/S3STSResponseMetadata.java diff --git a/hadoop-hdds/docs/content/design/ozone-sts.md b/hadoop-hdds/docs/content/design/ozone-sts.md index 9963534d8482..ccca985ae571 100644 --- a/hadoop-hdds/docs/content/design/ozone-sts.md +++ b/hadoop-hdds/docs/content/design/ozone-sts.md @@ -41,8 +41,9 @@ solutions that want to aggregate data across multiple cloud providers. # 3. How Ozone STS Works -The initial implementation of Ozone STS supports only the [AssumeRole](https://docs.aws.amazon.com/STS/latest/APIReference/API_AssumeRole.html) -API from the AWS specification. A new STS endpoint on port `9880` (port `9881` for https) will be created to service STS requests in the S3 Gateway at the root path (`/`). +The initial implementation of Ozone STS supports the [AssumeRole](https://docs.aws.amazon.com/STS/latest/APIReference/API_AssumeRole.html) +and [GetCallerIdentity](https://docs.aws.amazon.com/STS/latest/APIReference/API_GetCallerIdentity.html) +APIs from the AWS specification. A new STS endpoint on port `9880` (port `9881` for https) will be created to service STS requests in the S3 Gateway at the root path (`/`). We use a separate port for STS to align with AWS so we don't have conflicts at a later time. This means we have: - Admin port for Ozone specific S3 admin operations - STS port for STS APIs, analogous to AWS' separate STS endpoint @@ -66,6 +67,11 @@ return value of the AssumeRole call will be temporary credentials consisting of an IAM policy is specified, the temporary credential will have the permissions comprising the intersection of the role permissions and the IAM policy permissions. **Note:** If the IAM policy is specified and does not grant any permissions, then the generated temporary credentials won't have any permissions and will essentially be useless. +- [GetCallerIdentity](https://docs.aws.amazon.com/STS/latest/APIReference/API_GetCallerIdentity.html) returns the account, +ARN, and user ID for the caller credentials used to sign the request. Ozone uses a static account ID of `123456789012`. +For permanent S3 credentials, `UserId` is the resolved Kerberos principal and `Arn` is `arn:aws:iam::123456789012:user/` +where `` is the short username of the Kerberos principal. For STS temporary credentials, `UserId` is +the `AssumedRoleId` and `Arn` is the assumed-role user ARN from the session token. ## 3.2 Limitations in AssumeRole API Support @@ -151,6 +157,8 @@ credential will have the permissions and actions comprising the intersection of - creation time of the token (via `OMTokenProto#issueDate`, exposed as `STSTokenIdentifier#getCreationTime()`) - expiration time of the token (via `ShortLivedTokenIdentifier#getExpiry()`) - UUID of the OzoneManager secret key used to sign the sessionToken and encrypt the secretAccessKey (via `ShortLivedTokenIdentifier#getSecretKeyId()`) +- assumedRoleId - the generated identifier of the role from the AssumeRole call response (this is used for GetCallerIdentity api) +- assumedRoleUserArn - the arn from the AssumeRole call response (this is used for GetCallerIdentity api) ## 3.5 STS Token Revocation diff --git a/hadoop-ozone/client/src/main/java/org/apache/hadoop/ozone/client/ObjectStore.java b/hadoop-ozone/client/src/main/java/org/apache/hadoop/ozone/client/ObjectStore.java index ce0f780b72de..2cc610d4007f 100644 --- a/hadoop-ozone/client/src/main/java/org/apache/hadoop/ozone/client/ObjectStore.java +++ b/hadoop-ozone/client/src/main/java/org/apache/hadoop/ozone/client/ObjectStore.java @@ -37,6 +37,7 @@ import org.apache.hadoop.ozone.om.exceptions.OMException; import org.apache.hadoop.ozone.om.helpers.AssumeRoleResponseInfo; import org.apache.hadoop.ozone.om.helpers.BucketLayout; +import org.apache.hadoop.ozone.om.helpers.CallerIdentityInfo; import org.apache.hadoop.ozone.om.helpers.DeleteTenantState; import org.apache.hadoop.ozone.om.helpers.OmVolumeArgs; import org.apache.hadoop.ozone.om.helpers.S3SecretValue; @@ -812,6 +813,15 @@ public AssumeRoleResponseInfo assumeRole(String roleArn, String roleSessionName, return proxy.assumeRole(roleArn, roleSessionName, durationSeconds, awsIamSessionPolicy, requestId); } + /** + * Returns the caller identity for the current S3-authenticated request. + * @return CallerIdentityInfo containing account, arn, and userId + * @throws IOException if an error occurs during the GetCallerIdentity operation + */ + public CallerIdentityInfo getCallerIdentity() throws IOException { + return proxy.getCallerIdentity(); + } + /** * Revokes STS tokens for the given original access key ID. * @param originalAccessKeyId The original long-lived access key ID whose STS tokens to revoke diff --git a/hadoop-ozone/client/src/main/java/org/apache/hadoop/ozone/client/protocol/ClientProtocol.java b/hadoop-ozone/client/src/main/java/org/apache/hadoop/ozone/client/protocol/ClientProtocol.java index b5f7baa0ef2c..73f099879bc6 100644 --- a/hadoop-ozone/client/src/main/java/org/apache/hadoop/ozone/client/protocol/ClientProtocol.java +++ b/hadoop-ozone/client/src/main/java/org/apache/hadoop/ozone/client/protocol/ClientProtocol.java @@ -48,6 +48,7 @@ import org.apache.hadoop.ozone.om.OMConfigKeys; import org.apache.hadoop.ozone.om.exceptions.OMException; import org.apache.hadoop.ozone.om.helpers.AssumeRoleResponseInfo; +import org.apache.hadoop.ozone.om.helpers.CallerIdentityInfo; import org.apache.hadoop.ozone.om.helpers.DeleteTenantState; import org.apache.hadoop.ozone.om.helpers.ErrorInfo; import org.apache.hadoop.ozone.om.helpers.LeaseKeyInfo; @@ -1647,6 +1648,13 @@ void deleteObjectTagging(String volumeName, String bucketName, String keyName) AssumeRoleResponseInfo assumeRole(String roleArn, String roleSessionName, int durationSeconds, String awsIamSessionPolicy, String requestId) throws IOException; + /** + * Returns the caller identity for the current S3-authenticated request. + * @return CallerIdentityInfo containing account, arn, and userId + * @throws IOException if an error occurs during the GetCallerIdentity operation + */ + CallerIdentityInfo getCallerIdentity() throws IOException; + /** * Revokes STS tokens for the given original access key ID. * @param originalAccessKeyId The original long-lived access key ID whose STS tokens to revoke diff --git a/hadoop-ozone/client/src/main/java/org/apache/hadoop/ozone/client/rpc/RpcClient.java b/hadoop-ozone/client/src/main/java/org/apache/hadoop/ozone/client/rpc/RpcClient.java index 9ca47013462d..dcba71af525a 100644 --- a/hadoop-ozone/client/src/main/java/org/apache/hadoop/ozone/client/rpc/RpcClient.java +++ b/hadoop-ozone/client/src/main/java/org/apache/hadoop/ozone/client/rpc/RpcClient.java @@ -133,6 +133,7 @@ import org.apache.hadoop.ozone.om.helpers.BasicOmKeyInfo; import org.apache.hadoop.ozone.om.helpers.BucketEncryptionKeyInfo; import org.apache.hadoop.ozone.om.helpers.BucketLayout; +import org.apache.hadoop.ozone.om.helpers.CallerIdentityInfo; import org.apache.hadoop.ozone.om.helpers.DeleteTenantState; import org.apache.hadoop.ozone.om.helpers.ErrorInfo; import org.apache.hadoop.ozone.om.helpers.KeyInfoWithVolumeContext; @@ -3021,6 +3022,11 @@ public AssumeRoleResponseInfo assumeRole(String roleArn, String roleSessionName, return ozoneManagerClient.assumeRole(roleArn, roleSessionName, durationSeconds, awsIamSessionPolicy, requestId); } + @Override + public CallerIdentityInfo getCallerIdentity() throws IOException { + return ozoneManagerClient.getCallerIdentity(); + } + @Override public void revokeSTSToken(String originalAccessKeyId) throws IOException { ozoneManagerClient.revokeSTSToken(originalAccessKeyId); diff --git a/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/OmUtils.java b/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/OmUtils.java index 5da80215fad8..31530900cf2c 100644 --- a/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/OmUtils.java +++ b/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/OmUtils.java @@ -238,6 +238,7 @@ public static boolean isReadOnly(OMRequest omRequest) { case FinalizeUpgradeProgress: case PrepareStatus: case GetS3VolumeContext: + case GetCallerIdentity: case ListTenant: case TenantGetUserInfo: case TenantListUser: @@ -383,6 +384,7 @@ public static boolean shouldSendToFollower(OMRequest omRequest) { case FinalizeUpgradeProgress: case PrepareStatus: case GetS3VolumeContext: + case GetCallerIdentity: case ListTenant: case TenantGetUserInfo: case TenantListUser: diff --git a/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/om/helpers/CallerIdentityInfo.java b/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/om/helpers/CallerIdentityInfo.java new file mode 100644 index 000000000000..5a014f19fcfd --- /dev/null +++ b/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/om/helpers/CallerIdentityInfo.java @@ -0,0 +1,88 @@ +/* + * 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 java.util.Objects; +import net.jcip.annotations.Immutable; +import org.apache.hadoop.ozone.protocol.proto.OzoneManagerProtocolProtos.GetCallerIdentityResponse; + +/** + * Utility class to handle GetCallerIdentityResponse protobuf message. + */ +@Immutable +public class CallerIdentityInfo { + + private final String account; + private final String arn; + private final String userId; + + public CallerIdentityInfo(String account, String arn, String userId) { + this.account = account; + this.arn = arn; + this.userId = userId; + } + + public String getAccount() { + return account; + } + + public String getArn() { + return arn; + } + + public String getUserId() { + return userId; + } + + public static CallerIdentityInfo fromProtobuf(GetCallerIdentityResponse response) { + return new CallerIdentityInfo(response.getAccount(), response.getArn(), response.getUserId()); + } + + public GetCallerIdentityResponse getProtobuf() { + return GetCallerIdentityResponse.newBuilder() + .setAccount(account) + .setArn(arn) + .setUserId(userId) + .build(); + } + + @Override + public String toString() { + return "CallerIdentityInfo{" + "account='" + account + "', arn='" + arn + "', userId='" + userId + "'}"; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + + if (o == null || getClass() != o.getClass()) { + return false; + } + + final CallerIdentityInfo that = (CallerIdentityInfo) o; + return Objects.equals(account, that.account) && Objects.equals(arn, that.arn) && + Objects.equals(userId, that.userId); + } + + @Override + public int hashCode() { + return Objects.hash(account, arn, userId); + } +} 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 index 642210896559..17bf084f9b31 100644 --- 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 @@ -44,11 +44,43 @@ public final class S3STSUtils { public static final String STS_ACCESS_KEY_ID_ALLOWED_CHARS = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ"; public static final int STS_ACCESS_KEY_ID_ALLOWED_CHARS_LENGTH = STS_ACCESS_KEY_ID_ALLOWED_CHARS.length(); public static final int STS_ACCESS_KEY_ID_RANDOM_LENGTH = 20; - public static final int STS_ACCESS_KEY_ID_LENGTH = STS_TOKEN_PREFIX.length() + STS_ACCESS_KEY_ID_RANDOM_LENGTH; + + public static final String OZONE_STATIC_ACCOUNT_ID = "123456789012"; private S3STSUtils() { } + /** + * Builds an IAM user ARN for the given Kerberos short name. + */ + public static String toIamUserArn(String kerberosShortName) { + return "arn:aws:iam::" + OZONE_STATIC_ACCOUNT_ID + ":user/" + kerberosShortName; + } + + /** + * Resolves the caller identity for GetCallerIdentity with permanent S3 credentials. + * + * @param resolvedPrincipal full Kerberos principal of the caller + * @param kerberosShortName short username + * @return caller identity with account, arn, and userId + */ + public static CallerIdentityInfo resolveCallerIdentityForPermanentCredentials(String resolvedPrincipal, + String kerberosShortName) { + return new CallerIdentityInfo(OZONE_STATIC_ACCOUNT_ID, toIamUserArn(kerberosShortName), resolvedPrincipal); + } + + /** + * Resolves the caller identity for GetCallerIdentity with temporary STS credentials. + * + * @param assumedRoleId assumed role ID from the STS token + * @param assumedRoleUserArn assumed role user ARN from the STS token + * @return caller identity with account, arn, and userId + */ + public static CallerIdentityInfo resolveCallerIdentityForStsCredentials(String assumedRoleId, + String assumedRoleUserArn) { + return new CallerIdentityInfo(OZONE_STATIC_ACCOUNT_ID, assumedRoleUserArn, assumedRoleId); + } + /** * Adds standard AssumeRole audit params. */ diff --git a/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/om/protocol/OzoneManagerProtocol.java b/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/om/protocol/OzoneManagerProtocol.java index 46254e3d6f63..7cea19d7a909 100644 --- a/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/om/protocol/OzoneManagerProtocol.java +++ b/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/om/protocol/OzoneManagerProtocol.java @@ -30,6 +30,7 @@ import org.apache.hadoop.ozone.om.OMConfigKeys; import org.apache.hadoop.ozone.om.exceptions.OMException; import org.apache.hadoop.ozone.om.helpers.AssumeRoleResponseInfo; +import org.apache.hadoop.ozone.om.helpers.CallerIdentityInfo; import org.apache.hadoop.ozone.om.helpers.DBUpdates; import org.apache.hadoop.ozone.om.helpers.DeleteTenantState; import org.apache.hadoop.ozone.om.helpers.ErrorInfo; @@ -1335,6 +1336,15 @@ default AssumeRoleResponseInfo assumeRole(String roleArn, String roleSessionName throw new UnsupportedOperationException("OzoneManager does not require this to be implemented"); } + /** + * Returns the caller identity for the current S3-authenticated request. + * @return CallerIdentityInfo containing account, arn, and userId + * @throws IOException if an error occurs during the GetCallerIdentity operation + */ + default CallerIdentityInfo getCallerIdentity() throws IOException { + throw new UnsupportedOperationException("OzoneManager does not require this to be implemented"); + } + /** * Revokes STS tokens for the given original access key ID. * @param originalAccessKeyId The original long-lived access key ID whose STS tokens to revoke diff --git a/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/om/protocolPB/OzoneManagerProtocolClientSideTranslatorPB.java b/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/om/protocolPB/OzoneManagerProtocolClientSideTranslatorPB.java index 7077fd1b02c7..b5a9e3a2bbcf 100644 --- a/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/om/protocolPB/OzoneManagerProtocolClientSideTranslatorPB.java +++ b/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/om/protocolPB/OzoneManagerProtocolClientSideTranslatorPB.java @@ -60,6 +60,7 @@ import org.apache.hadoop.ozone.om.exceptions.OMException; import org.apache.hadoop.ozone.om.helpers.AssumeRoleResponseInfo; import org.apache.hadoop.ozone.om.helpers.BasicOmKeyInfo; +import org.apache.hadoop.ozone.om.helpers.CallerIdentityInfo; import org.apache.hadoop.ozone.om.helpers.DBUpdates; import org.apache.hadoop.ozone.om.helpers.DeleteTenantState; import org.apache.hadoop.ozone.om.helpers.ErrorInfo; @@ -2980,6 +2981,15 @@ public AssumeRoleResponseInfo assumeRole(String roleArn, String roleSessionName, handleError(submitRequest(omRequest)).getAssumeRoleResponse()); } + @Override + public CallerIdentityInfo getCallerIdentity() throws IOException { + final OMRequest omRequest = createOMRequest(Type.GetCallerIdentity) + .setGetCallerIdentityRequest(OzoneManagerProtocolProtos.GetCallerIdentityRequest.newBuilder().build()) + .build(); + + return CallerIdentityInfo.fromProtobuf(handleError(submitRequest(omRequest)).getGetCallerIdentityResponse()); + } + @Override public void revokeSTSToken(String originalAccessKeyId) throws IOException { final OzoneManagerProtocolProtos.RevokeSTSTokenRequest request = diff --git a/hadoop-ozone/common/src/test/java/org/apache/hadoop/ozone/om/helpers/TestCallerIdentityInfo.java b/hadoop-ozone/common/src/test/java/org/apache/hadoop/ozone/om/helpers/TestCallerIdentityInfo.java new file mode 100644 index 000000000000..f6bc0334acfe --- /dev/null +++ b/hadoop-ozone/common/src/test/java/org/apache/hadoop/ozone/om/helpers/TestCallerIdentityInfo.java @@ -0,0 +1,107 @@ +/* + * 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.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; + +import org.apache.hadoop.ozone.protocol.proto.OzoneManagerProtocolProtos.GetCallerIdentityResponse; +import org.junit.jupiter.api.Test; + +/** + * Test CallerIdentityInfo. + */ +public class TestCallerIdentityInfo { + + private static final String ACCOUNT = "123456789012"; + private static final String ARN = "arn:aws:iam::123456789012:user/om"; + private static final String USER_ID = "om/polarisclient@root.comops.site"; + + @Test + public void testConstructor() { + final CallerIdentityInfo identity = new CallerIdentityInfo(ACCOUNT, ARN, USER_ID); + + assertEquals(ACCOUNT, identity.getAccount()); + assertEquals(ARN, identity.getArn()); + assertEquals(USER_ID, identity.getUserId()); + } + + @Test + public void testProtobufConversion() { + final CallerIdentityInfo identity = new CallerIdentityInfo(ACCOUNT, ARN, USER_ID); + + final GetCallerIdentityResponse proto = identity.getProtobuf(); + + assertNotNull(proto); + assertEquals(ACCOUNT, proto.getAccount()); + assertEquals(ARN, proto.getArn()); + assertEquals(USER_ID, proto.getUserId()); + } + + @Test + public void testFromProtobuf() { + final GetCallerIdentityResponse proto = GetCallerIdentityResponse.newBuilder() + .setAccount(ACCOUNT) + .setArn(ARN) + .setUserId(USER_ID) + .build(); + + final CallerIdentityInfo identity = CallerIdentityInfo.fromProtobuf(proto); + + assertEquals(ACCOUNT, identity.getAccount()); + assertEquals(ARN, identity.getArn()); + assertEquals(USER_ID, identity.getUserId()); + } + + @Test + public void testProtobufRoundTrip() { + final CallerIdentityInfo original = new CallerIdentityInfo(ACCOUNT, ARN, USER_ID); + + final CallerIdentityInfo recovered = CallerIdentityInfo.fromProtobuf(original.getProtobuf()); + + assertEquals(original, recovered); + } + + @Test + public void testEqualsAndHashCodeWithIdenticalObjects() { + final CallerIdentityInfo identity1 = new CallerIdentityInfo(ACCOUNT, ARN, USER_ID); + final CallerIdentityInfo identity2 = new CallerIdentityInfo(ACCOUNT, ARN, USER_ID); + + assertEquals(identity1, identity2); + assertEquals(identity1.hashCode(), identity2.hashCode()); + } + + @Test + public void testNotEqualsWithDifferentArn() { + final CallerIdentityInfo identity1 = new CallerIdentityInfo(ACCOUNT, ARN, USER_ID); + final CallerIdentityInfo identity2 = new CallerIdentityInfo( + ACCOUNT, "arn:aws:iam::123456789012:user/other", USER_ID); + + assertNotEquals(identity1, identity2); + assertNotEquals(identity1.hashCode(), identity2.hashCode()); + } + + @Test + public void testToString() { + final CallerIdentityInfo identity = new CallerIdentityInfo(ACCOUNT, ARN, USER_ID); + + assertEquals( + "CallerIdentityInfo{account='123456789012', arn='" + ARN + "', userId='" + USER_ID + "'}", identity.toString()); + } +} diff --git a/hadoop-ozone/common/src/test/java/org/apache/hadoop/ozone/om/helpers/TestS3STSUtilsCallerIdentity.java b/hadoop-ozone/common/src/test/java/org/apache/hadoop/ozone/om/helpers/TestS3STSUtilsCallerIdentity.java new file mode 100644 index 000000000000..f991bd7bffdc --- /dev/null +++ b/hadoop-ozone/common/src/test/java/org/apache/hadoop/ozone/om/helpers/TestS3STSUtilsCallerIdentity.java @@ -0,0 +1,59 @@ +/* + * 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.junit.jupiter.api.Assertions.assertEquals; + +import org.junit.jupiter.api.Test; + +/** + * Test caller identity resolution helpers in S3STSUtils. + */ +public class TestS3STSUtilsCallerIdentity { + + private static final String PRINCIPAL = "om/polarisclient@example.com"; + private static final String KERBEROS_SHORT_NAME = "om"; + private static final String ASSUMED_ROLE_ID = "AROATEST123456789:testsess"; + private static final String ASSUMED_ROLE_USER_ARN = + "arn:aws:sts::123456789012:assumed-role/test-role/testsess"; + + @Test + public void testToIamUserArn() { + assertEquals("arn:aws:iam::123456789012:user/om", S3STSUtils.toIamUserArn(KERBEROS_SHORT_NAME)); + } + + @Test + public void testResolveCallerIdentityForPermanentCredentials() { + final CallerIdentityInfo identity = S3STSUtils.resolveCallerIdentityForPermanentCredentials( + PRINCIPAL, KERBEROS_SHORT_NAME); + + assertEquals(S3STSUtils.OZONE_STATIC_ACCOUNT_ID, identity.getAccount()); + assertEquals("arn:aws:iam::123456789012:user/om", identity.getArn()); + assertEquals(PRINCIPAL, identity.getUserId()); + } + + @Test + public void testResolveCallerIdentityForStsCredentials() { + final CallerIdentityInfo identity = S3STSUtils.resolveCallerIdentityForStsCredentials( + ASSUMED_ROLE_ID, ASSUMED_ROLE_USER_ARN); + + assertEquals(S3STSUtils.OZONE_STATIC_ACCOUNT_ID, identity.getAccount()); + assertEquals(ASSUMED_ROLE_USER_ARN, identity.getArn()); + assertEquals(ASSUMED_ROLE_ID, identity.getUserId()); + } +} diff --git a/hadoop-ozone/dist/src/main/smoketest/security/ozone-secure-sts.resource b/hadoop-ozone/dist/src/main/smoketest/security/ozone-secure-sts.resource index ebfbe55a6532..4ebf2ec1b3bd 100644 --- a/hadoop-ozone/dist/src/main/smoketest/security/ozone-secure-sts.resource +++ b/hadoop-ozone/dist/src/main/smoketest/security/ozone-secure-sts.resource @@ -111,10 +111,14 @@ Assume Role And Get Temporary Credentials ${stsAccessKeyId} = Execute echo '${json}' | jq -r '.Credentials.AccessKeyId' ${stsSecretKey} = Execute echo '${json}' | jq -r '.Credentials.SecretAccessKey' ${stsSessionToken} = Execute echo '${json}' | jq -r '.Credentials.SessionToken' + ${assumedRoleId} = Execute echo '${json}' | jq -r '.AssumedRoleUser.AssumedRoleId' + ${assumedRoleUserArn} = Execute echo '${json}' | jq -r '.AssumedRoleUser.Arn' Should Start With ${stsAccessKeyId} ASIA Set Global Variable ${STS_ACCESS_KEY_ID} ${stsAccessKeyId} Set Global Variable ${STS_SECRET_KEY} ${stsSecretKey} Set Global Variable ${STS_SESSION_TOKEN} ${stsSessionToken} + Set Global Variable ${STS_ASSUMED_ROLE_ID} ${assumedRoleId} + Set Global Variable ${STS_ASSUMED_ROLE_USER_ARN} ${assumedRoleUserArn} ${expected_duration} = Set Variable ${duration_seconds} # Ensure the expected duration defaults to 3600 seconds (1 hour) if not specified @@ -336,3 +340,36 @@ Assert Listed Keys Should Equal Sort List ${expected_sorted} ${actual_list} = Evaluate json.loads($actual_keys_json) modules=json Lists Should Be Equal ${actual_list} ${expected_sorted} + +Get Caller Identity + [Arguments] ${profile} + ${json} = Execute aws sts get-caller-identity --endpoint-url ${STS_ENDPOINT_URL} --output json --profile ${profile} + ${account} = Execute echo '${json}' | jq -r '.Account' + ${arn} = Execute echo '${json}' | jq -r '.Arn' + ${userId} = Execute echo '${json}' | jq -r '.UserId' + [Return] ${json} ${account} ${arn} ${userId} + +Get Caller Identity Using Curl + # AWS CLI always sends Version=2011-06-15 and rejects unknown flags, so use curl to test version validation. + # curl 7.76.1's --aws-sigv4 omits the port from the signed canonical "host" header (it signs "host:s3g" + # while sending "Host: s3g:9880"), so Ozone's SigV4 validation for the non-default STS port rejects it. + # Send an explicit Host header without the port so the sent and signed host values match. + # This should also work for newer versions of curl as well + [Arguments] ${perm_access_key_id} ${perm_secret_key} ${api_version}=2011-06-15 ${extra_curl_params}=${EMPTY} + ${sts_host} = Evaluate urllib.parse.urlparse("${STS_ENDPOINT_URL}").hostname modules=urllib.parse + ${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 "Host: ${sts_host}" --header "Content-Type: application/x-www-form-urlencoded" --data-urlencode "Action=GetCallerIdentity" + ${cmd} = Set Variable If '${api_version}' != '${EMPTY}' ${cmd} --data-urlencode "Version=${api_version}" ${cmd} + ${cmd} = Set Variable If '${extra_curl_params}' != '${EMPTY}' ${cmd} ${extra_curl_params} ${cmd} + ${cmd} = Set Variable ${cmd} ${STS_ENDPOINT_URL} + ${output} = Execute And Ignore Error ${cmd} + [Return] ${output} + +Get Caller Identity Should Fail + [Arguments] ${expected_error_contains} ${api_version}=${EMPTY} + ${output} = Get Caller Identity Using Curl ${PERMANENT_ACCESS_KEY_ID} ${PERMANENT_SECRET_KEY} api_version=${api_version} + Should Contain ${output} ${expected_error_contains} + @{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. + ${http_code} = Get From List ${http_codes} -1 + Should Be Equal As Strings ${http_code} 400 diff --git a/hadoop-ozone/dist/src/main/smoketest/security/ozone-secure-sts.robot b/hadoop-ozone/dist/src/main/smoketest/security/ozone-secure-sts.robot index b07a38e1a231..2368b1da68ec 100644 --- a/hadoop-ozone/dist/src/main/smoketest/security/ozone-secure-sts.robot +++ b/hadoop-ozone/dist/src/main/smoketest/security/ozone-secure-sts.robot @@ -1367,6 +1367,38 @@ STS Role On Chained Linked Bucket Grants GetObject On Source Bucket Head Bucket Should Succeed ${STS_LINK_BUCKET_CHAIN_FINAL} Get Object Should Succeed ${STS_LINK_BUCKET_CHAIN_FINAL} ${STS_LINK_BUCKET_CHAIN_TESTFILE} +Get Caller Identity With Permanent Credentials Should Succeed + Configure AWS Profile permanent ${PERMANENT_ACCESS_KEY_ID} ${PERMANENT_SECRET_KEY} + ${json} ${account} ${arn} ${userId} = Get Caller Identity permanent + Should Be Equal ${account} 123456789012 + ${principal} = Execute klist | awk '/Default principal/ {print $3}' + Should Be Equal ${userId} ${principal} + Should Be Equal ${arn} arn:aws:iam::123456789012:user/${ICEBERG_SVC_CATALOG_USER} + +Get Caller Identity With STS Credentials Should Succeed + Assume Role And Configure STS Profile perm_access_key_id=${PERMANENT_ACCESS_KEY_ID} perm_secret_key=${PERMANENT_SECRET_KEY} role_arn=${ICEBERG_ALL_ACCESS_ROLE_OBS_ARN} + ${json} ${account} ${arn} ${userId} = Get Caller Identity sts + Should Be Equal ${account} 123456789012 + Should Be Equal ${userId} ${STS_ASSUMED_ROLE_ID} + Should Be Equal ${arn} ${STS_ASSUMED_ROLE_USER_ARN} + +Get Caller Identity Ignores Extra Parameters + # curl 7.76.1 produces an invalid SigV4 signature for STS GET requests with --get --data-urlencode. + ${extra_curl_params} = Set Variable --data-urlencode "RoleArn=${ICEBERG_ALL_ACCESS_ROLE_OBS_ARN}" --data-urlencode "RoleSessionName=${ROLE_SESSION_NAME}" --data-urlencode "DurationSeconds=3600" + ${output} = Get Caller Identity Using Curl ${PERMANENT_ACCESS_KEY_ID} ${PERMANENT_SECRET_KEY} extra_curl_params=${extra_curl_params} + Should Contain ${output} 123456789012 + @{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. + ${http_code} = Get From List ${http_codes} -1 + Should Be Equal As Strings ${http_code} 200 + +Get Caller Identity Rejects Missing Version + Get Caller Identity Should Fail InvalidAction + +Get Caller Identity Rejects Invalid Version + Get Caller Identity Should Fail InvalidAction api_version=2020-01-01 + Expired STS temporary credentials must return ExpiredToken on S3 APIs # Increase timeout to account for 15 minute STS token expiration plus the time to execute the api calls [Timeout] 25 minutes diff --git a/hadoop-ozone/interface-client/src/main/proto/OmClientProtocol.proto b/hadoop-ozone/interface-client/src/main/proto/OmClientProtocol.proto index 53f7c20e5d9a..3702b021ac20 100644 --- a/hadoop-ozone/interface-client/src/main/proto/OmClientProtocol.proto +++ b/hadoop-ozone/interface-client/src/main/proto/OmClientProtocol.proto @@ -169,6 +169,7 @@ enum Type { AssumeRole = 153; RevokeSTSToken = 154; DeleteRevokedSTSTokens = 155; + GetCallerIdentity = 156; } enum SafeMode { @@ -333,6 +334,7 @@ message OMRequest { optional RevokeSTSTokenRequest revokeSTSTokenRequest = 155; optional DeleteRevokedSTSTokensRequest deleteRevokedSTSTokensRequest = 156; optional UpdateAssumeRoleRequest updateAssumeRoleRequest = 157; + optional GetCallerIdentityRequest getCallerIdentityRequest = 158; } message OMResponse { @@ -481,6 +483,7 @@ message OMResponse { optional AssumeRoleResponse assumeRoleResponse = 153; optional RevokeSTSTokenResponse revokeSTSTokenResponse = 154; optional DeleteRevokedSTSTokensResponse deleteRevokedSTSTokensResponse = 155; + optional GetCallerIdentityResponse getCallerIdentityResponse = 156; } enum Status { @@ -1600,6 +1603,8 @@ message OMTokenProto { optional string originalAccessKeyId = 18; optional string secretAccessKey = 19; optional string sessionPolicy = 20; + optional string assumedRoleId = 21; + optional string assumedRoleUserArn = 22; } message SecretKeyProto { @@ -2555,6 +2560,15 @@ message DeleteRevokedSTSTokensRequest { message DeleteRevokedSTSTokensResponse { } +message GetCallerIdentityRequest { +} + +message GetCallerIdentityResponse { + optional string account = 1; + optional string arn = 2; + optional string userId = 3; +} + enum ReadConsistencyProto { // Unspecified consistency, the read consistency behavior is decided // by the OM 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 693d280419b8..173ae19461fe 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 @@ -27,6 +27,9 @@ import com.google.common.base.Strings; import java.io.IOException; import java.net.InetAddress; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; import java.security.SecureRandom; import java.time.Clock; import java.time.Instant; @@ -59,6 +62,7 @@ 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.UpdateAssumeRoleRequest; +import org.apache.hadoop.ozone.security.STSTokenSecretManager; import org.apache.hadoop.ozone.security.acl.IAccessAuthorizer.ACLType; import org.apache.hadoop.ozone.security.acl.IOzoneObj; import org.apache.hadoop.ozone.security.acl.OzoneObj; @@ -131,23 +135,31 @@ public OMRequest preExecute(OzoneManager ozoneManager) throws IOException { S3STSUtils.validateDuration(durationSeconds); S3STSUtils.validateRoleSessionName(roleSessionName); final String targetRoleName = AwsRoleArnValidator.validateAndExtractRoleNameFromArn(roleArn); - - // Generate temporary AWS credentials using cryptographically strong SecureRandom + + // Generate temporary AWS credentials using cryptographically strong SecureRandom, and a + // deterministic roleId derived from the role ARN. final String tempAccessKeyId = STS_TOKEN_PREFIX + generateSecureRandomStringUsingChars( STS_ACCESS_KEY_ID_ALLOWED_CHARS, STS_ACCESS_KEY_ID_ALLOWED_CHARS_LENGTH, STS_ACCESS_KEY_ID_RANDOM_LENGTH); final String secretAccessKey = generateSecureRandomStringUsingChars( CHARS_FOR_SECRET_ACCESS_KEYS, CHARS_FOR_SECRET_ACCESS_KEYS_LENGTH, STS_SECRET_ACCESS_KEY_LENGTH); - final String roleId = ASSUME_ROLE_ID_PREFIX + generateSecureRandomStringUsingChars( - STS_ACCESS_KEY_ID_ALLOWED_CHARS, STS_ACCESS_KEY_ID_ALLOWED_CHARS_LENGTH, - STS_ROLE_ID_LENGTH); + final String roleId = generateDeterministicRoleId(roleArn); + final String assumedRoleId = roleId + ":" + roleSessionName; + final String assumedRoleUserArn = S3STSUtils.toAssumedRoleUserArn(roleArn, roleSessionName); final Instant creationInstant = clock.instant(); - final String sessionToken = generateSessionToken( - targetRoleName, omRequest, ozoneManager, assumeRoleRequest, secretAccessKey, tempAccessKeyId, - creationInstant); + final String sessionToken = generateSessionToken(GenerateSessionTokenParams.newBuilder() + .setTargetRoleName(targetRoleName) + .setOmRequest(omRequest) + .setOzoneManager(ozoneManager) + .setAssumeRoleRequest(assumeRoleRequest) + .setSecretAccessKey(secretAccessKey) + .setTempAccessKeyId(tempAccessKeyId) + .setAssumedRoleId(assumedRoleId) + .setAssumedRoleUserArn(assumedRoleUserArn) + .setCreationTime(creationInstant) + .build()); final long expirationEpochSeconds = creationInstant.plusSeconds(durationSeconds).getEpochSecond(); - auditMap.put(OzoneConsts.S3_STS_TEMP_ACCESS_KEY_ID, tempAccessKeyId); // Build UpdateAssumeRoleRequest with leader-generated credentials and session token @@ -245,9 +257,10 @@ public OMClientResponse validateAndUpdateCache(OzoneManager ozoneManager, Execut /** * Generates session token using components from the AssumeRoleRequest. */ - private String generateSessionToken(String targetRoleName, OMRequest omRequest, - OzoneManager ozoneManager, AssumeRoleRequest assumeRoleRequest, String secretAccessKey, - String tempAccessKeyId, Instant creationInstant) throws IOException { + private String generateSessionToken(GenerateSessionTokenParams params) throws IOException { + final OzoneManager ozoneManager = params.getOzoneManager(); + final OMRequest omRequest = params.getOmRequest(); + final AssumeRoleRequest assumeRoleRequest = params.getAssumeRoleRequest(); InetAddress remoteIp = ProtobufRpcEngine.Server.getRemoteIp(); if (remoteIp == null) { @@ -268,11 +281,148 @@ private String generateSessionToken(String targetRoleName, OMRequest omRequest, final String roleArn = assumeRoleRequest.getRoleArn(); final String sessionPolicy = getSessionPolicy( ozoneManager, originalAccessKeyId, assumeRoleRequest.getAwsIamSessionPolicy(), hostName, remoteIp, ugi, - targetRoleName); + params.getTargetRoleName()); return ozoneManager.getSTSTokenSecretManager().createSTSTokenString( - tempAccessKeyId, originalAccessKeyId, roleArn, assumeRoleRequest.getDurationSeconds(), secretAccessKey, - sessionPolicy, creationInstant); + STSTokenSecretManager.CreateSTSTokenParams.newBuilder() + .setTempAccessKeyId(params.getTempAccessKeyId()) + .setOriginalAccessKeyId(originalAccessKeyId) + .setRoleArn(roleArn) + .setDurationSeconds(assumeRoleRequest.getDurationSeconds()) + .setSecretAccessKey(params.getSecretAccessKey()) + .setSessionPolicy(sessionPolicy) + .setAssumedRoleId(params.getAssumedRoleId()) + .setAssumedRoleUserArn(params.getAssumedRoleUserArn()) + .setCreationTime(params.getCreationTime()) + .build()); + } + + /** + * Parameters for {@link #generateSessionToken(GenerateSessionTokenParams)}. + */ + private static final class GenerateSessionTokenParams { + private final String targetRoleName; + private final OMRequest omRequest; + private final OzoneManager ozoneManager; + private final AssumeRoleRequest assumeRoleRequest; + private final String secretAccessKey; + private final String tempAccessKeyId; + private final String assumedRoleId; + private final String assumedRoleUserArn; + private final Instant creationInstant; + + private GenerateSessionTokenParams(Builder builder) { + this.targetRoleName = builder.targetRoleName; + this.omRequest = builder.omRequest; + this.ozoneManager = builder.ozoneManager; + this.assumeRoleRequest = builder.assumeRoleRequest; + this.secretAccessKey = builder.secretAccessKey; + this.tempAccessKeyId = builder.tempAccessKeyId; + this.assumedRoleId = builder.assumedRoleId; + this.assumedRoleUserArn = builder.assumedRoleUserArn; + this.creationInstant = builder.creationInstant; + } + + static Builder newBuilder() { + return new Builder(); + } + + String getTargetRoleName() { + return targetRoleName; + } + + OMRequest getOmRequest() { + return omRequest; + } + + OzoneManager getOzoneManager() { + return ozoneManager; + } + + AssumeRoleRequest getAssumeRoleRequest() { + return assumeRoleRequest; + } + + String getSecretAccessKey() { + return secretAccessKey; + } + + String getTempAccessKeyId() { + return tempAccessKeyId; + } + + String getAssumedRoleId() { + return assumedRoleId; + } + + String getAssumedRoleUserArn() { + return assumedRoleUserArn; + } + + Instant getCreationTime() { + return creationInstant; + } + + private static final class Builder { + private String targetRoleName; + private OMRequest omRequest; + private OzoneManager ozoneManager; + private AssumeRoleRequest assumeRoleRequest; + private String secretAccessKey; + private String tempAccessKeyId; + private String assumedRoleId; + private String assumedRoleUserArn; + private Instant creationInstant; + + Builder setTargetRoleName(String value) { + this.targetRoleName = value; + return this; + } + + Builder setOmRequest(OMRequest value) { + this.omRequest = value; + return this; + } + + Builder setOzoneManager(OzoneManager value) { + this.ozoneManager = value; + return this; + } + + Builder setAssumeRoleRequest(AssumeRoleRequest value) { + this.assumeRoleRequest = value; + return this; + } + + Builder setSecretAccessKey(String value) { + this.secretAccessKey = value; + return this; + } + + Builder setTempAccessKeyId(String value) { + this.tempAccessKeyId = value; + return this; + } + + Builder setAssumedRoleId(String value) { + this.assumedRoleId = value; + return this; + } + + Builder setAssumedRoleUserArn(String value) { + this.assumedRoleUserArn = value; + return this; + } + + Builder setCreationTime(Instant instant) { + this.creationInstant = instant; + return this; + } + + GenerateSessionTokenParams build() { + return new GenerateSessionTokenParams(this); + } + } } /** @@ -428,6 +578,25 @@ interface BucketLinkResolver { ResolvedBucket resolve(String volumeName, String bucketName) throws IOException; } + /** + * Generates a deterministic role ID from the role ARN so the same role returns the same ID on every + * AssumeRole invocation, matching AWS behavior where RoleId is stable for a given role. + */ + @VisibleForTesting + static String generateDeterministicRoleId(String roleArn) { + try { + final MessageDigest digest = MessageDigest.getInstance("SHA-256"); + final byte[] hash = digest.digest(roleArn.getBytes(StandardCharsets.UTF_8)); + final StringBuilder sb = new StringBuilder(STS_ROLE_ID_LENGTH); + for (int i = 0; i < STS_ROLE_ID_LENGTH; i++) { + sb.append(STS_ACCESS_KEY_ID_ALLOWED_CHARS.charAt((hash[i] & 0xFF) % STS_ACCESS_KEY_ID_ALLOWED_CHARS_LENGTH)); + } + return ASSUME_ROLE_ID_PREFIX + sb; + } catch (NoSuchAlgorithmException e) { + throw new IllegalStateException("SHA-256 not available", e); + } + } + /** * Generates a cryptographically strong String of the supplied stringLength using supplied chars. */ diff --git a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/protocolPB/OzoneManagerRequestHandler.java b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/protocolPB/OzoneManagerRequestHandler.java index 21afb1e42f70..0ff432318565 100644 --- a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/protocolPB/OzoneManagerRequestHandler.java +++ b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/protocolPB/OzoneManagerRequestHandler.java @@ -61,6 +61,7 @@ import org.apache.hadoop.hdds.scm.protocolPB.OzonePBHelper; import org.apache.hadoop.hdds.utils.FaultInjector; import org.apache.hadoop.ozone.OzoneAcl; +import org.apache.hadoop.ozone.om.OzoneAclUtils; import org.apache.hadoop.ozone.om.OzoneManager; import org.apache.hadoop.ozone.om.OzoneManagerPrepareState; import org.apache.hadoop.ozone.om.exceptions.OMException; @@ -86,6 +87,7 @@ import org.apache.hadoop.ozone.om.helpers.OpenKeySession; import org.apache.hadoop.ozone.om.helpers.OzoneFileStatus; import org.apache.hadoop.ozone.om.helpers.OzoneFileStatusLight; +import org.apache.hadoop.ozone.om.helpers.S3STSUtils; import org.apache.hadoop.ozone.om.helpers.ServiceInfo; import org.apache.hadoop.ozone.om.helpers.ServiceInfoEx; import org.apache.hadoop.ozone.om.helpers.SnapshotDiffJob; @@ -113,6 +115,7 @@ import org.apache.hadoop.ozone.protocol.proto.OzoneManagerProtocolProtos.FinalizeUpgradeProgressResponse; import org.apache.hadoop.ozone.protocol.proto.OzoneManagerProtocolProtos.GetBucketTaggingRequest; import org.apache.hadoop.ozone.protocol.proto.OzoneManagerProtocolProtos.GetBucketTaggingResponse; +import org.apache.hadoop.ozone.protocol.proto.OzoneManagerProtocolProtos.GetCallerIdentityResponse; import org.apache.hadoop.ozone.protocol.proto.OzoneManagerProtocolProtos.GetFileStatusRequest; import org.apache.hadoop.ozone.protocol.proto.OzoneManagerProtocolProtos.GetFileStatusResponse; import org.apache.hadoop.ozone.protocol.proto.OzoneManagerProtocolProtos.GetKeyInfoRequest; @@ -171,11 +174,13 @@ import org.apache.hadoop.ozone.protocol.proto.OzoneManagerProtocolProtos.TenantListUserResponse; import org.apache.hadoop.ozone.protocol.proto.OzoneManagerProtocolProtos.Type; import org.apache.hadoop.ozone.request.validation.RequestProcessingPhase; +import org.apache.hadoop.ozone.security.STSTokenIdentifier; import org.apache.hadoop.ozone.security.acl.OzoneObjInfo; import org.apache.hadoop.ozone.snapshot.ListSnapshotResponse; import org.apache.hadoop.ozone.upgrade.UpgradeFinalization.StatusAndMessages; import org.apache.hadoop.ozone.util.PayloadUtils; import org.apache.hadoop.ozone.util.ProtobufUtils; +import org.apache.hadoop.security.UserGroupInformation; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -316,6 +321,9 @@ public OMResponse handleReadRequest(OMRequest request) { getS3VolumeContext(); responseBuilder.setGetS3VolumeContextResponse(s3VolumeContextResponse); break; + case GetCallerIdentity: + responseBuilder.setGetCallerIdentityResponse(getCallerIdentity()); + break; case TenantGetUserInfo: impl.checkS3MultiTenancyEnabled(); TenantGetUserInfoResponse getUserInfoResponse = tenantGetUserInfo( @@ -1448,6 +1456,22 @@ private GetS3VolumeContextResponse getS3VolumeContext() return impl.getS3VolumeContext().getProtobuf(); } + private GetCallerIdentityResponse getCallerIdentity() throws OMException { + impl.checkS3STSEnabled(); + if (OzoneManager.getS3Auth() == null) { + throw new OMException( + "GetCallerIdentity does not have S3 authentication", OMException.ResultCodes.INVALID_REQUEST); + } + final STSTokenIdentifier stsTokenIdentifier = OzoneManager.getStsTokenIdentifier(); + if (stsTokenIdentifier != null) { + return S3STSUtils.resolveCallerIdentityForStsCredentials( + stsTokenIdentifier.getAssumedRoleId(), stsTokenIdentifier.getAssumedRoleUserArn()).getProtobuf(); + } + final String resolvedPrincipal = OzoneAclUtils.accessIdToUserPrincipal(OzoneManager.getS3AuthEffectiveAccessId()); + final String kerberosShortName = UserGroupInformation.createRemoteUser(resolvedPrincipal).getShortUserName(); + return S3STSUtils.resolveCallerIdentityForPermanentCredentials(resolvedPrincipal, kerberosShortName).getProtobuf(); + } + @DisallowedUntilLayoutVersion(FILESYSTEM_SNAPSHOT) private SnapshotDiffResponse snapshotDiff( SnapshotDiffRequest snapshotDiffRequest) throws IOException { diff --git a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/security/STSTokenIdentifier.java b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/security/STSTokenIdentifier.java index e5629073c5ad..c0928d93a2c7 100644 --- a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/security/STSTokenIdentifier.java +++ b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/security/STSTokenIdentifier.java @@ -48,6 +48,8 @@ public class STSTokenIdentifier extends ShortLivedTokenIdentifier { private String originalAccessKeyId; private String secretAccessKey; private String sessionPolicy; + private String assumedRoleId; + private String assumedRoleUserArn; private Instant creationTime; // SCM secret key used for encrypting sensitive fields and signing this token @@ -85,6 +87,8 @@ public STSTokenIdentifier(Params params) { throw new IllegalArgumentException("ManagedSecretKey is not set"); } } + this.assumedRoleId = params.getAssumedRoleId(); + this.assumedRoleUserArn = params.getAssumedRoleUserArn(); } /** @@ -99,6 +103,8 @@ public static final class Params { private final String secretAccessKey; private final String sessionPolicy; private final ManagedSecretKey managedSecretKey; + private final String assumedRoleId; + private final String assumedRoleUserArn; private Params(Builder builder) { this.tempAccessKeyId = builder.tempAccessKeyId; @@ -109,6 +115,8 @@ private Params(Builder builder) { this.secretAccessKey = builder.secretAccessKey; this.sessionPolicy = builder.sessionPolicy; this.managedSecretKey = builder.managedSecretKey; + this.assumedRoleId = builder.assumedRoleId; + this.assumedRoleUserArn = builder.assumedRoleUserArn; } public static Builder newBuilder() { @@ -147,6 +155,14 @@ public ManagedSecretKey getManagedSecretKey() { return managedSecretKey; } + public String getAssumedRoleId() { + return assumedRoleId; + } + + public String getAssumedRoleUserArn() { + return assumedRoleUserArn; + } + /** * Builder for {@link Params}. */ @@ -159,6 +175,8 @@ public static final class Builder { private String secretAccessKey; private String sessionPolicy; private ManagedSecretKey managedSecretKey; + private String assumedRoleId; + private String assumedRoleUserArn; public Builder setTempAccessKeyId(String value) { this.tempAccessKeyId = value; @@ -200,6 +218,16 @@ public Builder setManagedSecretKey(ManagedSecretKey value) { return this; } + public Builder setAssumedRoleId(String value) { + this.assumedRoleId = value; + return this; + } + + public Builder setAssumedRoleUserArn(String value) { + this.assumedRoleUserArn = value; + return this; + } + public Params build() { return new Params(this); } @@ -249,7 +277,9 @@ public OMTokenProto toProtoBuf() throws IOException { .setRoleArn(roleArn != null ? roleArn : "") .setSecretAccessKey(secretAccessKey != null ? encryptSensitiveField(secretAccessKey) : "") .setSecretKeyId(managedSecretKey.getId().toString()) - .setSessionPolicy(sessionPolicy != null ? sessionPolicy : ""); + .setSessionPolicy(sessionPolicy != null ? sessionPolicy : "") + .setAssumedRoleId(assumedRoleId != null ? assumedRoleId : "") + .setAssumedRoleUserArn(assumedRoleUserArn != null ? assumedRoleUserArn : ""); return builder.build(); } @@ -292,6 +322,12 @@ public void fromProtoBuf(OMTokenProto token) throws IOException { if (token.hasSessionPolicy()) { this.sessionPolicy = token.getSessionPolicy(); } + if (token.hasAssumedRoleId()) { + this.assumedRoleId = token.getAssumedRoleId(); + } + if (token.hasAssumedRoleUserArn()) { + this.assumedRoleUserArn = token.getAssumedRoleUserArn(); + } } /** @@ -359,6 +395,14 @@ public String getSessionPolicy() { return sessionPolicy; } + public String getAssumedRoleId() { + return assumedRoleId; + } + + public String getAssumedRoleUserArn() { + return assumedRoleUserArn; + } + public Instant getCreationTime() { return creationTime; } @@ -415,13 +459,15 @@ public boolean equals(Object o) { final STSTokenIdentifier that = (STSTokenIdentifier) o; return Objects.equals(roleArn, that.roleArn) && Objects.equals(secretAccessKey, that.secretAccessKey) && Objects.equals(originalAccessKeyId, that.originalAccessKeyId) && - Objects.equals(sessionPolicy, that.sessionPolicy) && Objects.equals(creationTime, that.creationTime); + Objects.equals(sessionPolicy, that.sessionPolicy) && Objects.equals(assumedRoleId, that.assumedRoleId) && + Objects.equals(assumedRoleUserArn, that.assumedRoleUserArn) && Objects.equals(creationTime, that.creationTime); } @Override public int hashCode() { return Objects.hash( - super.hashCode(), roleArn, secretAccessKey, originalAccessKeyId, sessionPolicy, creationTime); + super.hashCode(), roleArn, secretAccessKey, originalAccessKeyId, sessionPolicy, assumedRoleId, + assumedRoleUserArn, creationTime); } @Override @@ -429,6 +475,7 @@ public String toString() { // Intentionally left off secretAccessKey return "STSTokenIdentifier{" + "tempAccessKeyId='" + getOwnerId() + "'" + ", originalAccessKeyId='" + originalAccessKeyId + "', roleArn='" + roleArn + "'" + + ", assumedRoleId='" + assumedRoleId + "', assumedRoleUserArn='" + assumedRoleUserArn + "'" + ", creationTime='" + creationTime + "', expiry='" + getExpiry() + "', secretKeyId='" + getSecretKeyId() + "', sessionPolicy='" + sessionPolicy + "'}"; } diff --git a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/security/STSTokenSecretManager.java b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/security/STSTokenSecretManager.java index d9e5c4caf767..2a7b7b1feb29 100644 --- a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/security/STSTokenSecretManager.java +++ b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/security/STSTokenSecretManager.java @@ -75,34 +75,160 @@ public Token generateToken(STSTokenIdentifier tokenIdentifie /** * Create an STS token and return it as an encoded string. * - * @param tempAccessKeyId the temporary access key ID - * @param originalAccessKeyId the original long-lived access key ID - * @param roleArn the ARN of the assumed role - * @param durationSeconds how long the token should be valid for - * @param secretAccessKey the secret access key associated with the temporary access key ID - * @param sessionPolicy an optional opaque identifier that further limits the scope of - * the permissions granted by the role - * @param creationTime token creation time + * @param params the STS token creation parameters * @return base64 encoded token string */ - public String createSTSTokenString(String tempAccessKeyId, String originalAccessKeyId, String roleArn, - int durationSeconds, String secretAccessKey, String sessionPolicy, Instant creationTime) throws IOException { - final Instant expiration = creationTime.plusSeconds(durationSeconds); + public String createSTSTokenString(CreateSTSTokenParams params) throws IOException { + final Instant creationTime = params.getCreationTime(); + final Instant expiration = creationTime.plusSeconds(params.getDurationSeconds()); final STSTokenIdentifier identifier = new STSTokenIdentifier(STSTokenIdentifier.Params.newBuilder() - .setTempAccessKeyId(tempAccessKeyId) - .setOriginalAccessKeyId(originalAccessKeyId) - .setRoleArn(roleArn) + .setTempAccessKeyId(params.getTempAccessKeyId()) + .setOriginalAccessKeyId(params.getOriginalAccessKeyId()) + .setRoleArn(params.getRoleArn()) .setCreationTime(creationTime) .setExpiry(expiration) - .setSecretAccessKey(secretAccessKey) - .setSessionPolicy(sessionPolicy) + .setSecretAccessKey(params.getSecretAccessKey()) + .setSessionPolicy(params.getSessionPolicy()) .setManagedSecretKey(secretKeyClient.getCurrentSecretKey()) + .setAssumedRoleId(params.getAssumedRoleId()) + .setAssumedRoleUserArn(params.getAssumedRoleUserArn()) .build()); final Token token = generateToken(identifier); return token.encodeToUrlString(); } + + /** + * Parameters for {@link #createSTSTokenString(CreateSTSTokenParams)}. + */ + public static final class CreateSTSTokenParams { + private final String tempAccessKeyId; + private final String originalAccessKeyId; + private final String roleArn; + private final int durationSeconds; + private final String secretAccessKey; + private final String sessionPolicy; + private final String assumedRoleId; + private final String assumedRoleUserArn; + private final Instant creationTime; + + private CreateSTSTokenParams(Builder builder) { + this.tempAccessKeyId = builder.tempAccessKeyId; + this.originalAccessKeyId = builder.originalAccessKeyId; + this.roleArn = builder.roleArn; + this.durationSeconds = builder.durationSeconds; + this.secretAccessKey = builder.secretAccessKey; + this.sessionPolicy = builder.sessionPolicy; + this.assumedRoleId = builder.assumedRoleId; + this.assumedRoleUserArn = builder.assumedRoleUserArn; + this.creationTime = builder.creationTime; + } + + public static Builder newBuilder() { + return new Builder(); + } + + public String getTempAccessKeyId() { + return tempAccessKeyId; + } + + public String getOriginalAccessKeyId() { + return originalAccessKeyId; + } + + public String getRoleArn() { + return roleArn; + } + + public int getDurationSeconds() { + return durationSeconds; + } + + public String getSecretAccessKey() { + return secretAccessKey; + } + + public String getSessionPolicy() { + return sessionPolicy; + } + + public String getAssumedRoleId() { + return assumedRoleId; + } + + public String getAssumedRoleUserArn() { + return assumedRoleUserArn; + } + + public Instant getCreationTime() { + return creationTime; + } + + /** + * Builder for {@link CreateSTSTokenParams}. + */ + public static final class Builder { + private String tempAccessKeyId; + private String originalAccessKeyId; + private String roleArn; + private int durationSeconds; + private String secretAccessKey; + private String sessionPolicy; + private String assumedRoleId; + private String assumedRoleUserArn; + private Instant creationTime; + + public Builder setTempAccessKeyId(String value) { + this.tempAccessKeyId = value; + return this; + } + + public Builder setOriginalAccessKeyId(String value) { + this.originalAccessKeyId = value; + return this; + } + + public Builder setRoleArn(String value) { + this.roleArn = value; + return this; + } + + public Builder setDurationSeconds(int value) { + this.durationSeconds = value; + return this; + } + + public Builder setSecretAccessKey(String value) { + this.secretAccessKey = value; + return this; + } + + public Builder setSessionPolicy(String value) { + this.sessionPolicy = value; + return this; + } + + public Builder setAssumedRoleId(String value) { + this.assumedRoleId = value; + return this; + } + + public Builder setAssumedRoleUserArn(String value) { + this.assumedRoleUserArn = value; + return this; + } + + public Builder setCreationTime(Instant creationTime) { + this.creationTime = creationTime; + return this; + } + + public CreateSTSTokenParams build() { + return new CreateSTSTokenParams(this); + } + } + } } 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 57068d1a169a..f779a349d0fa 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 @@ -88,6 +88,7 @@ public class TestS3AssumeRoleRequest { 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/MyRole2"; private static final String SESSION_NAME = "testSessionName"; private static final String ORIGINAL_ACCESS_KEY_ID = "origAccessKeyId"; private static final String TARGET_ROLE_NAME = "targetRole"; @@ -306,11 +307,9 @@ public void testSuccessfulAssumeRoleGeneratesCredentials() throws IOException { assertThat(assumeRoleResponse.getSecretAccessKey().length()).isEqualTo(40); // AssumedRoleId: prefix AROA + 16 chars, followed by ":" and sessionName + final String expectedRoleId = S3AssumeRoleRequest.generateDeterministicRoleId(ROLE_ARN_1); 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); + .isEqualTo(expectedRoleId + ":" + SESSION_NAME); // Verify expiration added durationSeconds final long expirationEpochSeconds = assumeRoleResponse.getExpirationEpochSeconds(); @@ -318,6 +317,17 @@ public void testSuccessfulAssumeRoleGeneratesCredentials() throws IOException { assertMarkForAuditCalled(requestWithCredentials); } + @Test + public void testGenerateDeterministicRoleId() { + final String roleId1 = S3AssumeRoleRequest.generateDeterministicRoleId(ROLE_ARN_1); + final String roleId2 = S3AssumeRoleRequest.generateDeterministicRoleId(ROLE_ARN_1); + final String roleId3 = S3AssumeRoleRequest.generateDeterministicRoleId(ROLE_ARN_2); + + assertThat(roleId1).startsWith("AROA").hasSize(4 + 16); + assertThat(roleId1).isEqualTo(roleId2); + assertThat(roleId1).isNotEqualTo(roleId3); + } + @Test public void testGenerateSecureRandomStringUsingChars() { final String chars = "ABC"; @@ -373,12 +383,29 @@ public void testAssumeRoleCredentialsAreUnique() throws IOException { // Different session tokens assertThat(assumeRoleResponse1.getSessionToken()).isNotEqualTo(assumeRoleResponse2.getSessionToken()); - // Different assumed role IDs - assertThat(assumeRoleResponse1.getAssumedRoleId()).isNotEqualTo(assumeRoleResponse2.getAssumedRoleId()); + // Same assumed role ID for the same role and session name + assertThat(assumeRoleResponse1.getAssumedRoleId()).isEqualTo(assumeRoleResponse2.getAssumedRoleId()); + + // Different role ARN yields a different assumed role ID + final OMRequest omRequestDifferentRole = baseOmRequestBuilder() + .setAssumeRoleRequest( + AssumeRoleRequest.newBuilder() + .setRoleArn(ROLE_ARN_2) + .setRoleSessionName(SESSION_NAME) + .setDurationSeconds(3600) + .setRequestId(REQUEST_ID) + ).build(); + final S3AssumeRoleRequest request3 = new S3AssumeRoleRequest(omRequestDifferentRole, CLOCK); + final OMRequest preExecutedRequest3 = request3.preExecute(ozoneManager); + final S3AssumeRoleRequest requestWithCredentials3 = new S3AssumeRoleRequest(preExecutedRequest3, CLOCK); + final OMClientResponse response3 = requestWithCredentials3.validateAndUpdateCache(ozoneManager, context); + final AssumeRoleResponse assumeRoleResponse3 = response3.getOMResponse().getAssumeRoleResponse(); + assertThat(assumeRoleResponse1.getAssumedRoleId()).isNotEqualTo(assumeRoleResponse3.getAssumedRoleId()); OMAuditLogger.log(requestWithCredentials1.getAuditBuilder()); OMAuditLogger.log(requestWithCredentials2.getAuditBuilder()); - verify(auditLogger, times(2)).logWrite(any(AuditMessage.class)); + OMAuditLogger.log(requestWithCredentials3.getAuditBuilder()); + verify(auditLogger, times(3)).logWrite(any(AuditMessage.class)); } @Test diff --git a/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/security/TestSTSSecurityUtil.java b/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/security/TestSTSSecurityUtil.java index 2f3a0350b578..769ae0da5f1c 100644 --- a/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/security/TestSTSSecurityUtil.java +++ b/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/security/TestSTSSecurityUtil.java @@ -53,6 +53,9 @@ public class TestSTSSecurityUtil { private static final String ROLE_ARN = "arn:aws:iam::123456789012:role/test-role"; private static final String SECRET_ACCESS_KEY = "test-secret-access-key"; private static final String SESSION_POLICY = "test-session-policy"; + private static final String ASSUMED_ROLE_ID = "AROATEST123456789:testsess"; + private static final String ASSUMED_ROLE_USER_ARN = + "arn:aws:sts::123456789012:assumed-role/test-role/testsess"; private static final int DURATION_SECONDS = 3600; private static final ManagedSecretKey MANAGED_SECRET_KEY = new SecretKeyTestClient().getCurrentSecretKey(); private final SecretKeyTestClient secretKeyClient = new SecretKeyTestClient(); @@ -80,8 +83,7 @@ public void testConstructValidateAndDecryptSTSTokenInvalidProtobuf() throws IOEx @Test public void testConstructValidateAndDecryptSTSTokenSuccess() throws IOException { // Create a valid token - final String tokenString = tokenSecretManager.createSTSTokenString(TEMP_ACCESS_KEY, ORIGINAL_ACCESS_KEY, - ROLE_ARN, DURATION_SECONDS, SECRET_ACCESS_KEY, SESSION_POLICY, clock.instant()); + final String tokenString = createStsTokenString(); // Validate and decrypt the token final STSTokenIdentifier result = STSSecurityUtil.constructValidateAndDecryptSTSToken( @@ -102,8 +104,7 @@ public void testConstructValidateAndDecryptSTSTokenSuccess() throws IOException @Test public void testConstructValidateAndDecryptSTSTokenSuccessWithNullSessionPolicy() throws Exception { // Create a valid token with null session policy - final String tokenString = tokenSecretManager.createSTSTokenString( - TEMP_ACCESS_KEY, ORIGINAL_ACCESS_KEY, ROLE_ARN, DURATION_SECONDS, SECRET_ACCESS_KEY, null, clock.instant()); + final String tokenString = createStsTokenString(DURATION_SECONDS, null); // Validate and decrypt the token final STSTokenIdentifier result = STSSecurityUtil.constructValidateAndDecryptSTSToken( @@ -135,8 +136,7 @@ public void testConstructValidateAndDecryptSTSTokenRuntimeDecodeFailure() { @Test public void testConstructValidateAndDecryptSTSTokenInvalidKind() throws Exception { // Create a valid identifier to use as base - final String validTokenString = tokenSecretManager.createSTSTokenString(TEMP_ACCESS_KEY, ORIGINAL_ACCESS_KEY, - ROLE_ARN, DURATION_SECONDS, SECRET_ACCESS_KEY, SESSION_POLICY, clock.instant()); + final String validTokenString = createStsTokenString(); final Token validToken = new Token<>(); validToken.decodeFromUrlString(validTokenString); @@ -158,8 +158,7 @@ public void testConstructValidateAndDecryptSTSTokenInvalidKind() throws Exceptio @Test public void testConstructValidateAndDecryptSTSTokenInvalidService() throws Exception { // Create a token with incorrect service - final String validTokenString = tokenSecretManager.createSTSTokenString(TEMP_ACCESS_KEY, ORIGINAL_ACCESS_KEY, - ROLE_ARN, DURATION_SECONDS, SECRET_ACCESS_KEY, SESSION_POLICY, clock.instant()); + final String validTokenString = createStsTokenString(); final Token validToken = new Token<>(); validToken.decodeFromUrlString(validTokenString); @@ -179,8 +178,7 @@ public void testConstructValidateAndDecryptSTSTokenInvalidService() throws Excep @Test public void testConstructValidateAndDecryptSTSTokenExpired() throws Exception { // Create a token that expires immediately (durationSeconds of 0) - final String tokenString = tokenSecretManager.createSTSTokenString( - TEMP_ACCESS_KEY, ORIGINAL_ACCESS_KEY, ROLE_ARN, 0, SECRET_ACCESS_KEY, SESSION_POLICY, clock.instant()); + final String tokenString = createStsTokenString(0, SESSION_POLICY); // Fast-forward time to ensure token is expired clock.fastForward(100); @@ -196,8 +194,7 @@ public void testConstructValidateAndDecryptSTSTokenExpired() throws Exception { @Test public void testConstructValidateAndDecryptSTSTokenSecretKeyNotFound() throws Exception { // Create a valid token string - final String validTokenString = tokenSecretManager.createSTSTokenString(TEMP_ACCESS_KEY, ORIGINAL_ACCESS_KEY, - ROLE_ARN, DURATION_SECONDS, SECRET_ACCESS_KEY, SESSION_POLICY, clock.instant()); + final String validTokenString = createStsTokenString(); // Create a mock secret key client that returns null for the key final SecretKeyClient mockKeyClient = mock(SecretKeyClient.class); @@ -215,8 +212,7 @@ public void testConstructValidateAndDecryptSTSTokenSecretKeyNotFound() throws Ex @Test public void testConstructValidateAndDecryptSTSTokenInvalidSecretKeyId() throws Exception { // Create a valid identifier to use as base - final String validTokenString = tokenSecretManager.createSTSTokenString(TEMP_ACCESS_KEY, ORIGINAL_ACCESS_KEY, - ROLE_ARN, DURATION_SECONDS, SECRET_ACCESS_KEY, SESSION_POLICY, clock.instant()); + final String validTokenString = createStsTokenString(); final Token validToken = new Token<>(); validToken.decodeFromUrlString(validTokenString); @@ -241,8 +237,7 @@ public void testConstructValidateAndDecryptSTSTokenInvalidSecretKeyId() throws E @Test public void testConstructValidateAndDecryptSTSTokenExpiredSecretKey() throws Exception { // Create a valid token string - final String validTokenString = tokenSecretManager.createSTSTokenString(TEMP_ACCESS_KEY, ORIGINAL_ACCESS_KEY, - ROLE_ARN, DURATION_SECONDS, SECRET_ACCESS_KEY, SESSION_POLICY, clock.instant()); + final String validTokenString = createStsTokenString(); // Create a mock secret key that is expired final ManagedSecretKey expiredSecretKey = mock(ManagedSecretKey.class); @@ -264,8 +259,7 @@ public void testConstructValidateAndDecryptSTSTokenExpiredSecretKey() throws Exc @Test public void testConstructValidateAndDecryptSTSTokenSecretKeyRetrievalException() throws Exception { // Create a valid token string - final String validTokenString = tokenSecretManager.createSTSTokenString(TEMP_ACCESS_KEY, ORIGINAL_ACCESS_KEY, - ROLE_ARN, DURATION_SECONDS, SECRET_ACCESS_KEY, SESSION_POLICY, clock.instant()); + final String validTokenString = createStsTokenString(); // Create a mock secret key client that throws an exception final SecretKeyClient mockKeyClient = mock(SecretKeyClient.class); @@ -283,8 +277,7 @@ public void testConstructValidateAndDecryptSTSTokenSecretKeyRetrievalException() @Test public void testConstructValidateAndDecryptSTSTokenInvalidSignature() throws Exception { // Create a valid token string - final String validTokenString = tokenSecretManager.createSTSTokenString(TEMP_ACCESS_KEY, ORIGINAL_ACCESS_KEY, - ROLE_ARN, DURATION_SECONDS, SECRET_ACCESS_KEY, SESSION_POLICY, clock.instant()); + final String validTokenString = createStsTokenString(); final Token validToken = new Token<>(); validToken.decodeFromUrlString(validTokenString); @@ -305,9 +298,7 @@ public void testConstructValidateAndDecryptSTSTokenInvalidSignature() throws Exc @Test public void testConstructValidateAndDecryptSTSTokenRejectsDoubledToken() throws Exception { - final String tokenString = tokenSecretManager.createSTSTokenString( - TEMP_ACCESS_KEY, ORIGINAL_ACCESS_KEY, ROLE_ARN, DURATION_SECONDS, - SECRET_ACCESS_KEY, SESSION_POLICY, clock.instant()); + final String tokenString = createStsTokenString(); assertThatThrownBy(() -> STSSecurityUtil.constructValidateAndDecryptSTSToken(tokenString + tokenString, secretKeyClient, clock)) @@ -318,9 +309,7 @@ public void testConstructValidateAndDecryptSTSTokenRejectsDoubledToken() throws @Test public void testConstructValidateAndDecryptSTSTokenRejectsTokenWithSuffix() throws Exception { - final String tokenString = tokenSecretManager.createSTSTokenString( - TEMP_ACCESS_KEY, ORIGINAL_ACCESS_KEY, ROLE_ARN, DURATION_SECONDS, - SECRET_ACCESS_KEY, SESSION_POLICY, clock.instant()); + final String tokenString = createStsTokenString(); assertThatThrownBy(() -> STSSecurityUtil.constructValidateAndDecryptSTSToken(tokenString + "garbage", secretKeyClient, clock)) @@ -342,13 +331,11 @@ public void testConstructValidateAndDecryptSTSTokenEmptyString() { @Test public void testConstructValidateAndDecryptMultipleTokens() throws Exception { // Create multiple tokens and validate them all - final String token1 = tokenSecretManager.createSTSTokenString( - "temp-key-1", "orig-key-1", "role-arn-1", DURATION_SECONDS, - "secret-key-1", "policy-1", clock.instant()); + final String token1 = createStsTokenString(DURATION_SECONDS, "secret-key-1", "policy-1", + "temp-key-1", "orig-key-1", "role-arn-1"); - final String token2 = tokenSecretManager.createSTSTokenString( - "temp-key-2", "orig-key-2", "role-arn-2", DURATION_SECONDS, - "secret-key-2", "policy-2", clock.instant()); + final String token2 = createStsTokenString(DURATION_SECONDS, "secret-key-2", "policy-2", + "temp-key-2", "orig-key-2", "role-arn-2"); final STSTokenIdentifier result1 = STSSecurityUtil.constructValidateAndDecryptSTSToken( token1, secretKeyClient, clock); @@ -418,8 +405,7 @@ public void testEnsureEssentialFieldsArePresentInTokenMissingCreationTime() { @Test public void testEnsureResolvedStsFieldsInvariantsSuccess() throws Exception { - final String tokenString = tokenSecretManager.createSTSTokenString(TEMP_ACCESS_KEY, ORIGINAL_ACCESS_KEY, - ROLE_ARN, DURATION_SECONDS, SECRET_ACCESS_KEY, SESSION_POLICY, clock.instant()); + final String tokenString = createStsTokenString(); final S3Authentication s3Auth = S3Authentication.newBuilder() .setSessionToken(tokenString) @@ -458,9 +444,7 @@ public void testEnsureResolvedStsFieldsInvariantsMissingSessionToken() { @Test public void testEnsureResolvedStsFieldsInvariantsMissingResolvedFields() throws Exception { - final String tokenString = tokenSecretManager.createSTSTokenString( - TEMP_ACCESS_KEY, ORIGINAL_ACCESS_KEY, ROLE_ARN, DURATION_SECONDS, - SECRET_ACCESS_KEY, SESSION_POLICY, clock.instant()); + final String tokenString = createStsTokenString(); final S3Authentication s3Auth = S3Authentication.newBuilder() .setSessionToken(tokenString) @@ -488,6 +472,32 @@ public void testEnsureResolvedStsFieldsInvariantsNoS3Auth() throws Exception { STSSecurityUtil.ensureResolvedStsFieldsInvariants(request); } + private String createStsTokenString() throws IOException { + return createStsTokenString(DURATION_SECONDS, SECRET_ACCESS_KEY, SESSION_POLICY, + TEMP_ACCESS_KEY, ORIGINAL_ACCESS_KEY, ROLE_ARN); + } + + private String createStsTokenString(int durationSeconds, String sessionPolicy) + throws IOException { + return createStsTokenString(durationSeconds, TestSTSSecurityUtil.SECRET_ACCESS_KEY, sessionPolicy, + TEMP_ACCESS_KEY, ORIGINAL_ACCESS_KEY, ROLE_ARN); + } + + private String createStsTokenString(int durationSeconds, String secretAccessKey, String sessionPolicy, + String tempAccessKey, String originalAccessKey, String roleArn) throws IOException { + return tokenSecretManager.createSTSTokenString(STSTokenSecretManager.CreateSTSTokenParams.newBuilder() + .setTempAccessKeyId(tempAccessKey) + .setOriginalAccessKeyId(originalAccessKey) + .setRoleArn(roleArn) + .setDurationSeconds(durationSeconds) + .setSecretAccessKey(secretAccessKey) + .setSessionPolicy(sessionPolicy) + .setAssumedRoleId(ASSUMED_ROLE_ID) + .setAssumedRoleUserArn(ASSUMED_ROLE_USER_ARN) + .setCreationTime(clock.instant()) + .build()); + } + private STSTokenIdentifier.Params.Builder paramsBuilder() { return STSTokenIdentifier.Params.newBuilder() .setTempAccessKeyId(TEMP_ACCESS_KEY) diff --git a/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/security/TestSTSTokenIdentifier.java b/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/security/TestSTSTokenIdentifier.java index c2136388e2a0..ee2863e6259f 100644 --- a/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/security/TestSTSTokenIdentifier.java +++ b/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/security/TestSTSTokenIdentifier.java @@ -77,6 +77,8 @@ public void testProtoBufRoundTrip() throws IOException { .setSecretAccessKey("secretKey") .setSessionPolicy("sessionPolicy") .setManagedSecretKey(MANAGED_SECRET_KEY) + .setAssumedRoleId("AROATEST123456789:testsess") + .setAssumedRoleUserArn("arn:aws:sts::123456789012:assumed-role/RoleY/testsess") .build()); final UUID secretKeyId = MANAGED_SECRET_KEY.getId(); @@ -89,6 +91,9 @@ public void testProtoBufRoundTrip() throws IOException { assertThat(proto.getRoleArn()).isEqualTo("arn:aws:iam::123456789012:role/RoleY"); assertThat(proto.getSecretAccessKey()).isNotEqualTo("secretKey"); // must be encrypted assertThat(proto.getSessionPolicy()).isEqualTo("sessionPolicy"); + assertThat(proto.getAssumedRoleId()).isEqualTo("AROATEST123456789:testsess"); + assertThat(proto.getAssumedRoleUserArn()) + .isEqualTo("arn:aws:sts::123456789012:assumed-role/RoleY/testsess"); assertThat(proto.getSecretKeyId()).isEqualTo(secretKeyId.toString()); final STSTokenIdentifier parsedTokenIdentifier = new STSTokenIdentifier(); @@ -103,6 +108,9 @@ public void testProtoBufRoundTrip() throws IOException { assertThat(parsedTokenIdentifier.getSecretAccessKey()).isEqualTo("secretKey"); assertThat(parsedTokenIdentifier.getSecretKeyId()).isEqualTo(secretKeyId); assertThat(parsedTokenIdentifier.getSessionPolicy()).isEqualTo("sessionPolicy"); + assertThat(parsedTokenIdentifier.getAssumedRoleId()).isEqualTo("AROATEST123456789:testsess"); + assertThat(parsedTokenIdentifier.getAssumedRoleUserArn()) + .isEqualTo("arn:aws:sts::123456789012:assumed-role/RoleY/testsess"); assertThat(parsedTokenIdentifier).isEqualTo(originalTokenIdentifier); assertThat(parsedTokenIdentifier.hashCode()).isEqualTo(originalTokenIdentifier.hashCode()); } @@ -211,6 +219,8 @@ public void testWriteToAndReadFromByteArray() throws Exception { .setSecretAccessKey("secretAccessKey") .setSessionPolicy("sessionPolicy") .setManagedSecretKey(MANAGED_SECRET_KEY) + .setAssumedRoleId("AROATEST123456789:testsess") + .setAssumedRoleUserArn("arn:aws:sts::123456789012:assumed-role/test-role/testsess") .build()); final ByteArrayOutputStream baos = new ByteArrayOutputStream(); @@ -541,12 +551,16 @@ public void testToString() { .setExpiry(expiry) .setSecretAccessKey("secretAccessKey") .setSessionPolicy("sessionPolicy") + .setAssumedRoleId("AROATEST123456789:testsess") + .setAssumedRoleUserArn("arn:aws:sts::123456789012:assumed-role/test-role/testsess") .build()); stsTokenIdentifier.setSecretKeyId(uuid); final String stsTokenIdentifierStr = stsTokenIdentifier.toString(); final String expectedString = "STSTokenIdentifier{" + "tempAccessKeyId='tempAccessKeyId'" + ", originalAccessKeyId='originalAccessKeyId'" + ", roleArn='roleArn'" + + ", assumedRoleId='AROATEST123456789:testsess'" + + ", assumedRoleUserArn='arn:aws:sts::123456789012:assumed-role/test-role/testsess'" + ", creationTime='" + CREATION_TIME + "', expiry='" + expiry + "', secretKeyId='" + uuid + "', sessionPolicy='sessionPolicy'" + '}'; diff --git a/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/security/TestSTSTokenSecretManager.java b/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/security/TestSTSTokenSecretManager.java index fe20c9dd2a40..022dab455080 100644 --- a/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/security/TestSTSTokenSecretManager.java +++ b/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/security/TestSTSTokenSecretManager.java @@ -56,6 +56,8 @@ public class TestSTSTokenSecretManager { private static final String ROLE_ARN = "arn:aws:iam::123456789012:role/test-role"; private static final String SECRET_ACCESS_KEY = "test-secret-access-key"; private static final String SESSION_POLICY = "test-session-policy"; + private static final String ASSUMED_ROLE_ID = "AROATEST123456789:testsess"; + private static final String ASSUMED_ROLE_USER_ARN = "arn:aws:sts::123456789012:assumed-role/test-role/testsess"; private static final int DURATION_SECONDS = 3600; private static SecretKey sharedSecretKey; @@ -84,8 +86,7 @@ public void setUp() throws Exception { @Test public void testCreateSTSTokenStringContainsCorrectFields() throws IOException { - final String tokenString = secretManager.createSTSTokenString(TEMP_ACCESS_KEY, ORIGINAL_ACCESS_KEY, - ROLE_ARN, DURATION_SECONDS, SECRET_ACCESS_KEY, SESSION_POLICY, clock.instant()); + final String tokenString = secretManager.createSTSTokenString(createStsTokenParamsBuilder().build()); // Decode the token final Token token = new Token<>(); @@ -104,6 +105,8 @@ public void testCreateSTSTokenStringContainsCorrectFields() throws IOException { assertEquals(ROLE_ARN, identifier.getRoleArn()); assertEquals(SECRET_ACCESS_KEY, identifier.getSecretAccessKey()); assertEquals(SESSION_POLICY, identifier.getSessionPolicy()); + assertEquals(ASSUMED_ROLE_ID, identifier.getAssumedRoleId()); + assertEquals(ASSUMED_ROLE_USER_ARN, identifier.getAssumedRoleUserArn()); assertEquals(clock.instant(), identifier.getCreationTime()); assertNotNull(identifier.getSecretKeyId()); assertEquals(new Text("STSToken"), identifier.getKind()); @@ -114,7 +117,7 @@ public void testCreateSTSTokenStringContainsCorrectFields() throws IOException { @Test public void testCreateSTSTokenStringWithNullSessionPolicy() throws IOException { final String tokenString = secretManager.createSTSTokenString( - TEMP_ACCESS_KEY, ORIGINAL_ACCESS_KEY, ROLE_ARN, DURATION_SECONDS, SECRET_ACCESS_KEY, null, clock.instant()); + createStsTokenParamsBuilder().setSessionPolicy(null).build()); // Decode the token final Token token = new Token<>(); @@ -150,8 +153,7 @@ public void testCreateSTSTokenStringValidatesWhenSecretKeyRotatesDuringCreation( encryptionKey, signingKey); final STSTokenSecretManager rotatingSecretManager = new STSTokenSecretManager(rotatingSecretKeyClient); - final String tokenString = rotatingSecretManager.createSTSTokenString(TEMP_ACCESS_KEY, ORIGINAL_ACCESS_KEY, - ROLE_ARN, DURATION_SECONDS, SECRET_ACCESS_KEY, SESSION_POLICY, clock.instant()); + final String tokenString = rotatingSecretManager.createSTSTokenString(createStsTokenParamsBuilder().build()); final STSTokenIdentifier result = STSSecurityUtil.constructValidateAndDecryptSTSToken( tokenString, rotatingSecretKeyClient, clock); @@ -160,6 +162,19 @@ public void testCreateSTSTokenStringValidatesWhenSecretKeyRotatesDuringCreation( assertEquals(1, rotatingSecretKeyClient.getCurrentSecretKeyCallCount()); } + private STSTokenSecretManager.CreateSTSTokenParams.Builder createStsTokenParamsBuilder() { + return STSTokenSecretManager.CreateSTSTokenParams.newBuilder() + .setTempAccessKeyId(TEMP_ACCESS_KEY) + .setOriginalAccessKeyId(ORIGINAL_ACCESS_KEY) + .setRoleArn(ROLE_ARN) + .setDurationSeconds(DURATION_SECONDS) + .setSecretAccessKey(SECRET_ACCESS_KEY) + .setSessionPolicy(SESSION_POLICY) + .setAssumedRoleId(ASSUMED_ROLE_ID) + .setAssumedRoleUserArn(ASSUMED_ROLE_USER_ARN) + .setCreationTime(clock.instant()); + } + private static ManagedSecretKey createManagedSecretKey(UUID id, byte[] keyBytes, Instant creationTime) { final SecretKey secretKey = new SecretKeySpec(keyBytes, "HmacSHA256"); return new ManagedSecretKey(id, creationTime, creationTime.plus(Duration.ofHours(1)), secretKey); diff --git a/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/audit/S3GAction.java b/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/audit/S3GAction.java index abdb64e5ff29..f215cd6cb0ff 100644 --- a/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/audit/S3GAction.java +++ b/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/audit/S3GAction.java @@ -66,6 +66,7 @@ public enum S3GAction implements AuditAction { // STS endpoint ASSUME_ROLE, + GET_CALLER_IDENTITY, GET_OBJECT_ATTRIBUTES; diff --git a/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/util/S3GActionIamMapper.java b/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/util/S3GActionIamMapper.java index 9953ebe2020b..223b057b5bb3 100644 --- a/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/util/S3GActionIamMapper.java +++ b/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/util/S3GActionIamMapper.java @@ -85,6 +85,7 @@ private S3GActionIamMapper() { case GENERATE_SECRET: case REVOKE_SECRET: case ASSUME_ROLE: + case GET_CALLER_IDENTITY: default: return null; } 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 index bd4be9a7eafb..6c8b73906a76 100644 --- 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 @@ -33,7 +33,7 @@ public class S3AssumeRoleResponseXml { private AssumeRoleResult assumeRoleResult; @XmlElement(name = "ResponseMetadata") - private ResponseMetadata responseMetadata; + private S3STSResponseMetadata responseMetadata; public AssumeRoleResult getAssumeRoleResult() { return assumeRoleResult; @@ -43,11 +43,11 @@ public void setAssumeRoleResult(AssumeRoleResult assumeRoleResult) { this.assumeRoleResult = assumeRoleResult; } - public ResponseMetadata getResponseMetadata() { + public S3STSResponseMetadata getResponseMetadata() { return responseMetadata; } - public void setResponseMetadata(ResponseMetadata responseMetadata) { + public void setResponseMetadata(S3STSResponseMetadata responseMetadata) { this.responseMetadata = responseMetadata; } @@ -157,23 +157,6 @@ 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/S3GetCallerIdentityResponseXml.java b/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3sts/S3GetCallerIdentityResponseXml.java new file mode 100644 index 000000000000..594ed12e55de --- /dev/null +++ b/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3sts/S3GetCallerIdentityResponseXml.java @@ -0,0 +1,92 @@ +/* + * 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 GetCallerIdentityResponse. + */ +@XmlAccessorType(XmlAccessType.FIELD) +@XmlRootElement(name = "GetCallerIdentityResponse", namespace = "https://sts.amazonaws.com/doc/2011-06-15/") +public class S3GetCallerIdentityResponseXml { + + @XmlElement(name = "GetCallerIdentityResult") + private GetCallerIdentityResult getCallerIdentityResult; + + @XmlElement(name = "ResponseMetadata") + private S3STSResponseMetadata responseMetadata; + + public GetCallerIdentityResult getGetCallerIdentityResult() { + return getCallerIdentityResult; + } + + public void setGetCallerIdentityResult(GetCallerIdentityResult getCallerIdentityResult) { + this.getCallerIdentityResult = getCallerIdentityResult; + } + + public S3STSResponseMetadata getResponseMetadata() { + return responseMetadata; + } + + public void setResponseMetadata(S3STSResponseMetadata responseMetadata) { + this.responseMetadata = responseMetadata; + } + + /** + * GetCallerIdentityResult element. + */ + @XmlAccessorType(XmlAccessType.FIELD) + public static class GetCallerIdentityResult { + @XmlElement(name = "Arn") + private String arn; + + @XmlElement(name = "UserId") + private String userId; + + @XmlElement(name = "Account") + private String account; + + public String getArn() { + return arn; + } + + public void setArn(String arn) { + this.arn = arn; + } + + public String getUserId() { + return userId; + } + + public void setUserId(String userId) { + this.userId = userId; + } + + public String getAccount() { + return account; + } + + public void setAccount(String account) { + this.account = account; + } + } +} 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 d6ed5339a448..2c6200d1e661 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 @@ -60,6 +60,7 @@ 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.CallerIdentityInfo; import org.apache.hadoop.ozone.om.helpers.S3STSUtils; import org.apache.hadoop.ozone.s3.RequestIdentifier; import org.apache.hadoop.ozone.s3.exception.OS3Exception; @@ -73,8 +74,8 @@ * This endpoint provides temporary security credentials compatible with * AWS STS API, exposed on port 9880 or 9881 at the root path ({@code /}). *

- * Currently supports only AssumeRole operation. Other STS operations will - * return appropriate error responses. + * Currently supports AssumeRole and GetCallerIdentity operations. Other STS + * operations will return appropriate error responses. * * @see AWS STS API Reference */ @@ -113,7 +114,8 @@ public class S3STSEndpoint extends S3STSEndpointBase { static { try { - JAXB_CONTEXT = JAXBContext.newInstance(S3AssumeRoleResponseXml.class); + JAXB_CONTEXT = JAXBContext.newInstance( + S3AssumeRoleResponseXml.class, S3GetCallerIdentityResponseXml.class, S3STSResponseMetadata.class); } catch (JAXBException e) { throw new RuntimeException("Failed to initialize JAXBContext: " + e, e); } @@ -193,11 +195,12 @@ private Response handleSTSRequest(Set paramNamesToValidate, String actio case ASSUME_ROLE_ACTION: return handleAssumeRole( paramNamesToValidate, roleArn, roleSessionName, durationSeconds, awsIamSessionPolicy, version, requestId); + case GET_CALLER_IDENTITY_ACTION: + return handleGetCallerIdentity(version, requestId); // These operations are not supported yet case GET_SESSION_TOKEN_ACTION: case ASSUME_ROLE_WITH_SAML_ACTION: case ASSUME_ROLE_WITH_WEB_IDENTITY_ACTION: - case GET_CALLER_IDENTITY_ACTION: case DECODE_AUTHORIZATION_MESSAGE_ACTION: case GET_ACCESS_KEY_INFO_ACTION: throw new OSTSException(STS_INVALID_ACTION_NOT_IMPLEMENTED) @@ -307,39 +310,76 @@ private Response handleAssumeRole(Set paramNamesToValidate, String roleA .header("Content-Type", "text/xml") .build(); } catch (IOException e) { - LOG.error("Error during AssumeRole processing", e); - + throw toStsProcessingException( + S3GAction.ASSUME_ROLE, auditParams, e, action, "User is not authorized to perform: sts:AssumeRole on " + + "resource: " + roleArn); + } catch (Exception e) { getAuditLogger().logWriteFailure(buildAuditMessageForFailure(S3GAction.ASSUME_ROLE, auditParams, e)); + throw e; + } + } - if (e instanceof OMException) { - final OMException omException = (OMException) e; - if (omException.getResult() == OMException.ResultCodes.ACCESS_DENIED || - omException.getResult() == OMException.ResultCodes.PERMISSION_DENIED || - omException.getResult() == OMException.ResultCodes.TOKEN_EXPIRED) { - throw new OSTSException(ACCESS_DENIED) - .withMessage("User is not authorized to perform: sts:AssumeRole on resource: " + roleArn); - } - if (omException.getResult() == OMException.ResultCodes.INVALID_TOKEN) { - throw new OSTSException(STS_INVALID_CLIENT_TOKEN_ID); - } - if (omException.getResult() == OMException.ResultCodes.NOT_SUPPORTED_OPERATION || - omException.getResult() == OMException.ResultCodes.FEATURE_NOT_ENABLED) { - throw new OSTSException(STS_UNSUPPORTED_OPERATION).withMessage(omException.getMessage()); - } - if (omException.getResult() == OMException.ResultCodes.INVALID_REQUEST) { - throw new OSTSException(STS_VALIDATION_ERROR).withMessage(omException.getMessage()); - } - if (omException.getResult() == OMException.ResultCodes.MALFORMED_POLICY_DOCUMENT) { - throw new OSTSException(STS_MALFORMED_POLICY_DOCUMENT).withMessage(omException.getMessage()); - } - } - throw new OSTSException(STS_INTERNAL_FAILURE, e).withType("Receiver"); + private Response handleGetCallerIdentity(String version, String requestId) throws OSTSException { + final String action = GET_CALLER_IDENTITY_ACTION; + final Map auditParams = getAuditParameters(); + auditParams.put("action", action); + auditParams.put("requestId", requestId); + + if (version == null || !version.equals(EXPECTED_VERSION)) { + final OSTSException exception = new OSTSException(STS_INVALID_ACTION) + .withMessage("Could not find operation " + action + " for version " + + (version == null ? "NO_VERSION_SPECIFIED. Expected version is: " + EXPECTED_VERSION : version)); + getAuditLogger().logWriteFailure(buildAuditMessageForFailure( + S3GAction.GET_CALLER_IDENTITY, auditParams, exception)); + throw exception; + } + + try { + final CallerIdentityInfo identityInfo = getClient().getObjectStore().getCallerIdentity(); + final String responseXml = generateGetCallerIdentityResponse(identityInfo, requestId); + getAuditLogger().logWriteSuccess(buildAuditMessageForSuccess(S3GAction.GET_CALLER_IDENTITY, auditParams)); + return Response.ok(responseXml) + .header("Content-Type", "text/xml") + .build(); + } catch (IOException e) { + throw toStsProcessingException( + S3GAction.GET_CALLER_IDENTITY, auditParams, e, action, "User is not authorized to perform: " + + "sts:GetCallerIdentity"); } catch (Exception e) { - getAuditLogger().logWriteFailure(buildAuditMessageForFailure(S3GAction.ASSUME_ROLE, auditParams, e)); + getAuditLogger().logWriteFailure(buildAuditMessageForFailure(S3GAction.GET_CALLER_IDENTITY, auditParams, e)); throw e; } } + private OSTSException toStsProcessingException(S3GAction auditAction, Map auditParams, IOException e, + String operationName, String accessDeniedMessage) { + LOG.error("Error during {} processing", operationName, e); + getAuditLogger().logWriteFailure(buildAuditMessageForFailure(auditAction, auditParams, e)); + + if (e instanceof OMException) { + final OMException omException = (OMException) e; + if (omException.getResult() == OMException.ResultCodes.ACCESS_DENIED || + omException.getResult() == OMException.ResultCodes.PERMISSION_DENIED || + omException.getResult() == OMException.ResultCodes.TOKEN_EXPIRED) { + return new OSTSException(ACCESS_DENIED).withMessage(accessDeniedMessage); + } + if (omException.getResult() == OMException.ResultCodes.INVALID_TOKEN) { + return new OSTSException(STS_INVALID_CLIENT_TOKEN_ID); + } + if (omException.getResult() == OMException.ResultCodes.NOT_SUPPORTED_OPERATION || + omException.getResult() == OMException.ResultCodes.FEATURE_NOT_ENABLED) { + return new OSTSException(STS_UNSUPPORTED_OPERATION).withMessage(omException.getMessage()); + } + if (omException.getResult() == OMException.ResultCodes.INVALID_REQUEST) { + return new OSTSException(STS_VALIDATION_ERROR).withMessage(omException.getMessage()); + } + if (omException.getResult() == OMException.ResultCodes.MALFORMED_POLICY_DOCUMENT) { + return new OSTSException(STS_MALFORMED_POLICY_DOCUMENT).withMessage(omException.getMessage()); + } + } + return new OSTSException(STS_INTERNAL_FAILURE, e).withType("Receiver"); + } + private AssumeRoleParamValidationResult validateAssumeRoleParameters(Set paramNamesToValidate) { if (paramNamesToValidate == null || paramNamesToValidate.isEmpty()) { return AssumeRoleParamValidationResult.empty(); @@ -450,7 +490,7 @@ private String generateAssumeRoleResponse(String assumedRoleUserArn, AssumeRoleR user.setArn(assumedRoleUserArn); result.setAssumedRoleUser(user); response.setAssumeRoleResult(result); - final S3AssumeRoleResponseXml.ResponseMetadata meta = new S3AssumeRoleResponseXml.ResponseMetadata(); + final S3STSResponseMetadata meta = new S3STSResponseMetadata(); meta.setRequestId(requestId); response.setResponseMetadata(meta); @@ -463,5 +503,29 @@ private String generateAssumeRoleResponse(String assumedRoleUserArn, AssumeRoleR throw new IOException("Failed to marshal AssumeRole response", e); } } + + private String generateGetCallerIdentityResponse(CallerIdentityInfo identityInfo, String requestId) + throws IOException { + try { + final S3GetCallerIdentityResponseXml response = new S3GetCallerIdentityResponseXml(); + final S3GetCallerIdentityResponseXml.GetCallerIdentityResult result = + new S3GetCallerIdentityResponseXml.GetCallerIdentityResult(); + result.setAccount(identityInfo.getAccount()); + result.setArn(identityInfo.getArn()); + result.setUserId(identityInfo.getUserId()); + response.setGetCallerIdentityResult(result); + final S3STSResponseMetadata meta = new S3STSResponseMetadata(); + meta.setRequestId(requestId); + response.setResponseMetadata(meta); + + final Marshaller marshaller = JAXB_CONTEXT.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 GetCallerIdentity response", e); + } + } } diff --git a/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3sts/S3STSResponseMetadata.java b/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3sts/S3STSResponseMetadata.java new file mode 100644 index 000000000000..a43ec76b4433 --- /dev/null +++ b/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3sts/S3STSResponseMetadata.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.s3sts; + +import javax.xml.bind.annotation.XmlAccessType; +import javax.xml.bind.annotation.XmlAccessorType; +import javax.xml.bind.annotation.XmlElement; +import javax.xml.bind.annotation.XmlType; + +/** + * JAXB model for AWS STS ResponseMetadata element shared across STS responses. + */ +@XmlAccessorType(XmlAccessType.FIELD) +@XmlType(name = "ResponseMetadata", namespace = "https://sts.amazonaws.com/doc/2011-06-15/") +public class S3STSResponseMetadata { + + @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/test/java/org/apache/hadoop/ozone/client/ClientProtocolStub.java b/hadoop-ozone/s3gateway/src/test/java/org/apache/hadoop/ozone/client/ClientProtocolStub.java index abd80cbc1fcf..6fd318dc9061 100644 --- a/hadoop-ozone/s3gateway/src/test/java/org/apache/hadoop/ozone/client/ClientProtocolStub.java +++ b/hadoop-ozone/s3gateway/src/test/java/org/apache/hadoop/ozone/client/ClientProtocolStub.java @@ -39,6 +39,7 @@ import org.apache.hadoop.ozone.client.protocol.ClientProtocol; import org.apache.hadoop.ozone.client.protocol.ListStatusLightOptions; import org.apache.hadoop.ozone.om.helpers.AssumeRoleResponseInfo; +import org.apache.hadoop.ozone.om.helpers.CallerIdentityInfo; import org.apache.hadoop.ozone.om.helpers.DeleteTenantState; import org.apache.hadoop.ozone.om.helpers.ErrorInfo; import org.apache.hadoop.ozone.om.helpers.LeaseKeyInfo; @@ -898,6 +899,11 @@ public AssumeRoleResponseInfo assumeRole(String roleArn, String roleSessionName, return null; } + @Override + public CallerIdentityInfo getCallerIdentity() throws IOException { + return null; + } + @Override public void revokeSTSToken(String originalAccessKeyId) throws IOException { } diff --git a/hadoop-ozone/s3gateway/src/test/java/org/apache/hadoop/ozone/s3/util/TestS3GActionIamMapper.java b/hadoop-ozone/s3gateway/src/test/java/org/apache/hadoop/ozone/s3/util/TestS3GActionIamMapper.java index c7ae9e4e924c..82f0134cffec 100644 --- a/hadoop-ozone/s3gateway/src/test/java/org/apache/hadoop/ozone/s3/util/TestS3GActionIamMapper.java +++ b/hadoop-ozone/s3gateway/src/test/java/org/apache/hadoop/ozone/s3/util/TestS3GActionIamMapper.java @@ -62,6 +62,7 @@ public void copyActionsReturnNull() { @Test public void nonIamActionsReturnNull() { assertNull(S3GActionIamMapper.toS3ActionString(S3GAction.ASSUME_ROLE)); + assertNull(S3GActionIamMapper.toS3ActionString(S3GAction.GET_CALLER_IDENTITY)); assertNull(S3GActionIamMapper.toS3ActionString(S3GAction.GENERATE_SECRET)); assertNull(S3GActionIamMapper.toS3ActionString(S3GAction.REVOKE_SECRET)); } 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 36adf2359c4a..379bd27eb981 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 @@ -51,6 +51,7 @@ import org.apache.hadoop.ozone.client.OzoneClientStub; import org.apache.hadoop.ozone.om.exceptions.OMException; import org.apache.hadoop.ozone.om.helpers.AssumeRoleResponseInfo; +import org.apache.hadoop.ozone.om.helpers.CallerIdentityInfo; import org.apache.hadoop.ozone.s3.OzoneConfigurationHolder; import org.apache.hadoop.ozone.s3.RequestIdentifier; import org.apache.hadoop.ozone.s3.exception.OSTSException; @@ -103,6 +104,11 @@ public void setup() throws Exception { "session-token", Instant.now().plusSeconds(3600).getEpochSecond(), "AROA1234567890123456:test-session")); + when(objectStore.getCallerIdentity()) + .thenReturn(new CallerIdentityInfo( + "123456789012", + "arn:aws:iam::123456789012:user/test-user", + "test-user")); when(clientStub.getObjectStore()).thenReturn(objectStore); endpoint = new S3STSEndpoint(); @@ -560,6 +566,79 @@ public void testStsWhenActionNotImplemented() throws Exception { "Operation GetSessionToken is not supported yet."); } + @Test + public void testStsGetCallerIdentitySuccessForGetMethod() throws Exception { + final Response response = endpoint.get("GetCallerIdentity", null, null, null, "2011-06-15", null); + + assertEquals(200, response.getStatus()); + verify(objectStore).getCallerIdentity(); + verify(auditLogger).logWriteSuccess(any(AuditMessage.class)); + verify(auditLogger, never()).logWriteFailure(any(AuditMessage.class)); + + final Document doc = parseXml((String) response.getEntity()); + assertEquals("GetCallerIdentityResponse", doc.getDocumentElement().getLocalName()); + assertEquals(STS_NS, doc.getDocumentElement().getNamespaceURI()); + assertEquals( + "123456789012", doc.getElementsByTagNameNS(STS_NS, "Account").item(0).getTextContent()); + assertEquals( + "arn:aws:iam::123456789012:user/test-user", doc.getElementsByTagNameNS(STS_NS, "Arn").item(0).getTextContent()); + assertEquals( + "test-user", doc.getElementsByTagNameNS(STS_NS, "UserId").item(0).getTextContent()); + } + + @Test + public void testStsGetCallerIdentityIgnoresExtraParameters() throws Exception { + final Response response = endpoint.get("GetCallerIdentity", ROLE_ARN, ROLE_SESSION_NAME, 3600, "2011-06-15", null); + + assertEquals(200, response.getStatus()); + verify(objectStore).getCallerIdentity(); + } + + @Test + public void testStsGetCallerIdentityIgnoresExtraParametersForPostMethod() throws Exception { + formParameters = new Form(); + formParameters.param("Action", "GetCallerIdentity"); + formParameters.param("Version", "2011-06-15"); + formParameters.param("RoleArn", ROLE_ARN); + formParameters.param("RoleSessionName", ROLE_SESSION_NAME); + formParameters.param("DurationSeconds", "3600"); + + final Response response = endpoint.post(formParameters); + + assertEquals(200, response.getStatus()); + verify(objectStore).getCallerIdentity(); + } + + @Test + public void testStsGetCallerIdentityRejectsMissingVersion() throws Exception { + final OSTSException ex = assertThrows( + OSTSException.class, () -> endpoint.get("GetCallerIdentity", null, null, null, null, null)); + + assertEquals(400, ex.getHttpCode()); + verify(auditLogger).logWriteFailure(any(AuditMessage.class)); + verify(objectStore, never()).getCallerIdentity(); + + ex.setRequestId(REQUEST_ID); + assertStsErrorXml( + ex.toXml(), AWS_FAULT_NS, "Sender", "InvalidAction", + "Could not find operation GetCallerIdentity for version NO_VERSION_SPECIFIED"); + } + + @Test + public void testStsGetCallerIdentityRejectsInvalidVersion() throws Exception { + final OSTSException ex = assertThrows( + OSTSException.class, () -> endpoint.get("GetCallerIdentity", null, null, null, "2020-01-01", null)); + + assertEquals(400, ex.getHttpCode()); + verify(auditLogger).logWriteFailure(any(AuditMessage.class)); + verify(objectStore, never()).getCallerIdentity(); + + ex.setRequestId(REQUEST_ID); + assertStsErrorXml( + ex.toXml(), AWS_FAULT_NS, "Sender", "InvalidAction", + "Could not find operation GetCallerIdentity for version 2020-01-01"); + } + @Test public void testStsMissingRoleSessionName() throws Exception { final OSTSException ex = assertThrows(OSTSException.class, () ->