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..d2ebc831e4b9 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,8 @@ 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_ORIGINAL_ACCESS_KEY_ID = "originalAccessKeyId"; + 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 6cc94eadd4bc..7ee1d9fcd73e 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..7900fa4a41f1 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 @@ -142,8 +142,9 @@ OzoneVolume getVolumeDetails(String volumeName) throws IOException; /** - * @return Raw GetS3VolumeContextResponse. - * S3Auth won't be updated with actual userPrincipal by this call. + * @return S3 volume context from OM. + * When thread-local {@link S3Auth} is set, implementations update it with OM-returned + * {@code userPrincipal} and, when present, validated STS {@code originalAccessKeyId}. * @throws IOException */ S3VolumeContext getS3VolumeContext() throws IOException; @@ -1648,11 +1649,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..d960cad0bca2 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 @@ -211,6 +211,8 @@ public class RpcClient implements ClientProtocol { private final XceiverClientFactory xceiverClientManager; private final UserGroupInformation ugi; private UserGroupInformation s3gUgi; + // Cached per thread for the current S3 Gateway request - cleared with thread-local S3Auth. + private final ThreadLocal cachedS3VolumeContext = new ThreadLocal<>(); private final ClientId clientId = ClientId.randomId(); private final boolean unsafeByteBufferConversion; private Text dtService; @@ -505,12 +507,31 @@ public OzoneVolume getVolumeDetails(String volumeName) @Override public S3VolumeContext getS3VolumeContext() throws IOException { - S3VolumeContext resp = ozoneManagerClient.getS3VolumeContext(); - String userPrincipal = resp.getUserPrincipal(); - updateS3Principal(userPrincipal); + final S3VolumeContext cached = cachedS3VolumeContext.get(); + if (cached != null) { + return cached; + } + final S3VolumeContext resp = ozoneManagerClient.getS3VolumeContext(); + updateS3Principal(resp.getUserPrincipal()); + updateValidatedStsOriginalAccessKeyId(resp.getStsOriginalAccessKeyId()); + cachedS3VolumeContext.set(resp); return resp; } + private void updateValidatedStsOriginalAccessKeyId(String stsOriginalAccessKeyId) { + final S3Auth s3Auth = this.getThreadLocalS3Auth(); + if (s3Auth != null && StringUtils.isNotEmpty(stsOriginalAccessKeyId)) { + LOG.debug("Updating S3Auth.validatedStsOriginalAccessKeyId to {}", stsOriginalAccessKeyId); + s3Auth.setValidatedStsOriginalAccessKeyId(stsOriginalAccessKeyId); + this.setThreadLocalS3Auth(s3Auth); + } + } + + private void updateS3Context(KeyInfoWithVolumeContext keyInfoWithS3Context) { + keyInfoWithS3Context.getUserPrincipal().ifPresent(this::updateS3Principal); + keyInfoWithS3Context.getStsOriginalAccessKeyId().ifPresent(this::updateValidatedStsOriginalAccessKeyId); + } + private void updateS3Principal(String userPrincipal) { S3Auth s3Auth = this.getThreadLocalS3Auth(); // Update user principal if needed to be used for KMS client @@ -1979,7 +2000,7 @@ private OmKeyInfo getS3KeyInfo( .build(); KeyInfoWithVolumeContext keyInfoWithS3Context = ozoneManagerClient.getKeyInfo(keyArgs, true); - keyInfoWithS3Context.getUserPrincipal().ifPresent(this::updateS3Principal); + updateS3Context(keyInfoWithS3Context); return keyInfoWithS3Context.getKeyInfo(); } @@ -2004,7 +2025,7 @@ private OmKeyInfo getS3PartKeyInfo( .build(); KeyInfoWithVolumeContext keyInfoWithS3Context = ozoneManagerClient.getKeyInfo(keyArgs, true); - keyInfoWithS3Context.getUserPrincipal().ifPresent(this::updateS3Principal); + updateS3Context(keyInfoWithS3Context); return keyInfoWithS3Context.getKeyInfo(); } @@ -2896,6 +2917,7 @@ public OzoneKey headS3Object(String bucketName, String keyName) @Override public void setThreadLocalS3Auth( S3Auth ozoneSharedSecretAuth) { + cachedS3VolumeContext.remove(); ozoneManagerClient.setThreadLocalS3Auth(ozoneSharedSecretAuth); this.s3gUgi = UserGroupInformation.createRemoteUser(getThreadLocalS3Auth().getUserPrincipal()); } @@ -2908,6 +2930,7 @@ public S3Auth getThreadLocalS3Auth() { @Override public void clearThreadLocalS3Auth() { ozoneManagerClient.clearThreadLocalS3Auth(); + cachedS3VolumeContext.remove(); } @Override @@ -3022,8 +3045,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/client/src/test/java/org/apache/hadoop/ozone/client/rpc/TestRpcClient.java b/hadoop-ozone/client/src/test/java/org/apache/hadoop/ozone/client/rpc/TestRpcClient.java index 999b892ff7bb..990a1ee21c73 100644 --- a/hadoop-ozone/client/src/test/java/org/apache/hadoop/ozone/client/rpc/TestRpcClient.java +++ b/hadoop-ozone/client/src/test/java/org/apache/hadoop/ozone/client/rpc/TestRpcClient.java @@ -21,20 +21,30 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertSame; import static org.junit.jupiter.api.Assertions.assertThrows; import java.io.IOException; import java.util.LinkedList; import java.util.List; +import java.util.concurrent.atomic.AtomicInteger; import org.apache.hadoop.hdds.conf.OzoneConfiguration; import org.apache.hadoop.hdds.protocol.proto.HddsProtos; import org.apache.hadoop.hdds.scm.XceiverClientFactory; import org.apache.hadoop.ozone.OzoneManagerVersion; import org.apache.hadoop.ozone.client.MockOmTransport; import org.apache.hadoop.ozone.client.MockXceiverClientFactory; +import org.apache.hadoop.ozone.om.helpers.S3VolumeContext; import org.apache.hadoop.ozone.om.helpers.ServiceInfo; import org.apache.hadoop.ozone.om.helpers.ServiceInfoEx; +import org.apache.hadoop.ozone.om.protocol.S3Auth; import org.apache.hadoop.ozone.om.protocolPB.OmTransport; +import org.apache.hadoop.ozone.protocol.proto.OzoneManagerProtocolProtos.GetS3VolumeContextResponse; +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.hadoop.ozone.protocol.proto.OzoneManagerProtocolProtos.VolumeInfo; import org.apache.ozone.test.GenericTestUtils; import org.apache.ozone.test.GenericTestUtils.LogCapturer; import org.junit.jupiter.api.Test; @@ -228,6 +238,64 @@ public void testFutureVersionShouldNotBeAnExpectedVersion() { () -> validateOmVersion(OzoneManagerVersion.FUTURE_VERSION, null)); } + @Test + public void testGetS3VolumeContextCachesResponseWithinSameS3Auth() throws IOException { + final CountingS3VolumeContextTransport transport = new CountingS3VolumeContextTransport(); + final RpcClient rpcClient = createRpcClient(transport); + try { + final S3Auth s3Auth = new S3Auth("sign", "sig", "ASIAEXAMPLE", "ASIAEXAMPLE"); + rpcClient.setThreadLocalS3Auth(s3Auth); + + final S3VolumeContext first = rpcClient.getS3VolumeContext(); + final S3VolumeContext second = rpcClient.getS3VolumeContext(); + + assertEquals(1, transport.getS3VolumeContextCallCount()); + assertSame(first, second); + assertEquals("AKIAORIGINAL123", s3Auth.getValidatedStsOriginalAccessKeyId()); + assertEquals("alice", s3Auth.getUserPrincipal()); + } finally { + rpcClient.close(); + } + } + + @Test + public void testClearThreadLocalS3AuthClearsS3VolumeContextCache() throws IOException { + final CountingS3VolumeContextTransport transport = new CountingS3VolumeContextTransport(); + final RpcClient rpcClient = createRpcClient(transport); + try { + rpcClient.setThreadLocalS3Auth(new S3Auth("sign", "sig", "ASIAEXAMPLE", "ASIAEXAMPLE")); + rpcClient.getS3VolumeContext(); + rpcClient.getS3VolumeContext(); + assertEquals(1, transport.getS3VolumeContextCallCount()); + + rpcClient.clearThreadLocalS3Auth(); + rpcClient.setThreadLocalS3Auth(new S3Auth("sign", "sig", "ASIAEXAMPLE", "ASIAEXAMPLE")); + rpcClient.getS3VolumeContext(); + + assertEquals(2, transport.getS3VolumeContextCallCount()); + } finally { + rpcClient.close(); + } + } + + @Test + public void testSetThreadLocalS3AuthClearsS3VolumeContextCache() throws IOException { + final CountingS3VolumeContextTransport transport = new CountingS3VolumeContextTransport(); + final RpcClient rpcClient = createRpcClient(transport); + try { + rpcClient.setThreadLocalS3Auth(new S3Auth("sign", "sig", "ASIAEXAMPLE", "ASIAEXAMPLE")); + rpcClient.getS3VolumeContext(); + assertEquals(1, transport.getS3VolumeContextCallCount()); + + rpcClient.setThreadLocalS3Auth(new S3Auth("sign2", "sig2", "ASIAEXAMPLE2", "ASIAEXAMPLE2")); + rpcClient.getS3VolumeContext(); + + assertEquals(2, transport.getS3VolumeContextCallCount()); + } finally { + rpcClient.close(); + } + } + @Test public void testCloseTwiceDoesNotWarn() throws IOException { RpcClient rpcClient = createRpcClient(); @@ -250,11 +318,15 @@ public void testCloseTwiceDoesNotWarn() throws IOException { } private static RpcClient createRpcClient() throws IOException { + return createRpcClient(new MockOmTransport()); + } + + private static RpcClient createRpcClient(MockOmTransport transport) throws IOException { OzoneConfiguration config = new OzoneConfiguration(); return new RpcClient(config, null) { @Override protected OmTransport createOmTransport(String omServiceId) { - return new MockOmTransport(); + return transport; } @Override @@ -264,4 +336,37 @@ protected XceiverClientFactory createXceiverClientFactory( } }; } + + private static final class CountingS3VolumeContextTransport extends MockOmTransport { + private final AtomicInteger getS3VolumeContextCallCount = new AtomicInteger(); + + @Override + public OMResponse submitRequest(OMRequest payload) throws IOException { + if (payload.getCmdType() == Type.GetS3VolumeContext) { + getS3VolumeContextCallCount.incrementAndGet(); + final VolumeInfo volumeInfo = VolumeInfo.newBuilder() + .setVolume("s3v") + .setAdminName("admin") + .setOwnerName("owner") + .build(); + final GetS3VolumeContextResponse getS3VolumeContextResponse = + GetS3VolumeContextResponse.newBuilder() + .setVolumeInfo(volumeInfo) + .setUserPrincipal("alice") + .setStsOriginalAccessKeyId("AKIAORIGINAL123") + .build(); + return OMResponse.newBuilder() + .setCmdType(payload.getCmdType()) + .setSuccess(true) + .setStatus(Status.OK) + .setGetS3VolumeContextResponse(getS3VolumeContextResponse) + .build(); + } + return super.submitRequest(payload); + } + + private int getS3VolumeContextCallCount() { + return getS3VolumeContextCallCount.get(); + } + } } diff --git a/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/om/helpers/KeyInfoWithVolumeContext.java b/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/om/helpers/KeyInfoWithVolumeContext.java index d6d54d3c174d..f8098549b6b9 100644 --- a/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/om/helpers/KeyInfoWithVolumeContext.java +++ b/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/om/helpers/KeyInfoWithVolumeContext.java @@ -19,6 +19,7 @@ import java.io.IOException; import java.util.Optional; +import org.apache.commons.lang3.StringUtils; import org.apache.hadoop.ozone.protocol.proto.OzoneManagerProtocolProtos.GetKeyInfoResponse; /** @@ -35,13 +36,24 @@ public class KeyInfoWithVolumeContext { */ private final Optional userPrincipal; + /** + * OM-validated originalAccessKeyId for the current STS session token, when present. + */ + private final Optional stsOriginalAccessKeyId; + private final OmKeyInfo keyInfo; public KeyInfoWithVolumeContext(OmVolumeArgs volumeArgs, String userPrincipal, OmKeyInfo keyInfo) { + this(volumeArgs, userPrincipal, null, keyInfo); + } + + public KeyInfoWithVolumeContext(OmVolumeArgs volumeArgs, String userPrincipal, String stsOriginalAccessKeyId, + OmKeyInfo keyInfo) { this.volumeArgs = Optional.ofNullable(volumeArgs); this.userPrincipal = Optional.ofNullable(userPrincipal); + this.stsOriginalAccessKeyId = Optional.ofNullable(stsOriginalAccessKeyId); this.keyInfo = keyInfo; } @@ -51,6 +63,7 @@ public static KeyInfoWithVolumeContext fromProtobuf( .setVolumeArgs(proto.hasVolumeInfo() ? OmVolumeArgs.getFromProtobuf(proto.getVolumeInfo()) : null) .setUserPrincipal(proto.getUserPrincipal()) + .setStsOriginalAccessKeyId(proto.hasStsOriginalAccessKeyId() ? proto.getStsOriginalAccessKeyId() : null) .setKeyInfo(OmKeyInfo.getFromProtobuf(proto.getKeyInfo())) .build(); } @@ -59,6 +72,7 @@ public GetKeyInfoResponse toProtobuf(int clientVersion) { GetKeyInfoResponse.Builder builder = GetKeyInfoResponse.newBuilder(); volumeArgs.ifPresent(v -> builder.setVolumeInfo(v.getProtobuf())); userPrincipal.ifPresent(builder::setUserPrincipal); + stsOriginalAccessKeyId.filter(StringUtils::isNotEmpty).ifPresent(builder::setStsOriginalAccessKeyId); builder.setKeyInfo(keyInfo.getProtobuf(clientVersion)); return builder.build(); } @@ -75,6 +89,10 @@ public Optional getUserPrincipal() { return userPrincipal; } + public Optional getStsOriginalAccessKeyId() { + return stsOriginalAccessKeyId; + } + public static Builder newBuilder() { return new Builder(); } @@ -85,6 +103,7 @@ public static Builder newBuilder() { public static class Builder { private OmVolumeArgs volumeArgs; private String userPrincipal; + private String stsOriginalAccessKeyId; private OmKeyInfo keyInfo; public Builder setVolumeArgs(OmVolumeArgs volumeArgs) { @@ -97,13 +116,18 @@ public Builder setUserPrincipal(String userPrincipal) { return this; } + public Builder setStsOriginalAccessKeyId(String stsOriginalAccessKeyId) { + this.stsOriginalAccessKeyId = stsOriginalAccessKeyId; + return this; + } + public Builder setKeyInfo(OmKeyInfo keyInfo) { this.keyInfo = keyInfo; return this; } public KeyInfoWithVolumeContext build() { - return new KeyInfoWithVolumeContext(volumeArgs, userPrincipal, keyInfo); + return new KeyInfoWithVolumeContext(volumeArgs, userPrincipal, stsOriginalAccessKeyId, keyInfo); } } } 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/helpers/S3VolumeContext.java b/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/om/helpers/S3VolumeContext.java index 19d428d0a9e1..673e43ef9081 100644 --- a/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/om/helpers/S3VolumeContext.java +++ b/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/om/helpers/S3VolumeContext.java @@ -17,6 +17,7 @@ package org.apache.hadoop.ozone.om.helpers; +import org.apache.commons.lang3.StringUtils; import org.apache.hadoop.ozone.protocol.proto.OzoneManagerProtocolProtos.GetS3VolumeContextResponse; /** @@ -35,9 +36,19 @@ public class S3VolumeContext { */ private final String userPrincipal; + /** + * OM-validated originalAccessKeyId for the current STS session token, when present. + */ + private final String stsOriginalAccessKeyId; + public S3VolumeContext(OmVolumeArgs omVolumeArgs, String userPrincipal) { + this(omVolumeArgs, userPrincipal, null); + } + + public S3VolumeContext(OmVolumeArgs omVolumeArgs, String userPrincipal, String stsOriginalAccessKeyId) { this.omVolumeArgs = omVolumeArgs; this.userPrincipal = userPrincipal; + this.stsOriginalAccessKeyId = stsOriginalAccessKeyId; } public OmVolumeArgs getOmVolumeArgs() { @@ -48,17 +59,25 @@ public String getUserPrincipal() { return userPrincipal; } + public String getStsOriginalAccessKeyId() { + return stsOriginalAccessKeyId; + } + public static S3VolumeContext fromProtobuf(GetS3VolumeContextResponse resp) { return new S3VolumeContext( OmVolumeArgs.getFromProtobuf(resp.getVolumeInfo()), - resp.getUserPrincipal()); + resp.getUserPrincipal(), + resp.hasStsOriginalAccessKeyId() ? resp.getStsOriginalAccessKeyId() : null); } public GetS3VolumeContextResponse getProtobuf() { - return GetS3VolumeContextResponse.newBuilder() + final GetS3VolumeContextResponse.Builder builder = GetS3VolumeContextResponse.newBuilder() .setVolumeInfo(omVolumeArgs.getProtobuf()) - .setUserPrincipal(userPrincipal) - .build(); + .setUserPrincipal(userPrincipal); + if (StringUtils.isNotEmpty(stsOriginalAccessKeyId)) { + builder.setStsOriginalAccessKeyId(stsOriginalAccessKeyId); + } + return builder.build(); } public static S3VolumeContext.Builder newBuilder() { @@ -71,6 +90,7 @@ public static S3VolumeContext.Builder newBuilder() { public static final class Builder { private OmVolumeArgs omVolumeArgs; private String userPrincipal; + private String stsOriginalAccessKeyId; private Builder() { } @@ -85,8 +105,13 @@ public Builder setUserPrincipal(String userPrincipal) { return this; } + public Builder setStsOriginalAccessKeyId(String stsOriginalAccessKeyId) { + this.stsOriginalAccessKeyId = stsOriginalAccessKeyId; + return this; + } + public S3VolumeContext build() { - return new S3VolumeContext(omVolumeArgs, userPrincipal); + return new S3VolumeContext(omVolumeArgs, userPrincipal, stsOriginalAccessKeyId); } } } 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/protocol/S3Auth.java b/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/om/protocol/S3Auth.java index 577339c96ac3..37c8438836bd 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 @@ -31,6 +31,8 @@ public class S3Auth { 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; + // OM-validated originalAccessKeyId for the current STS session token, when present. + private String validatedStsOriginalAccessKeyId; public S3Auth(final String stringToSign, final String signature, @@ -77,4 +79,12 @@ public String getS3Action() { public void setS3Action(String s3Action) { this.s3Action = s3Action; } + + public String getValidatedStsOriginalAccessKeyId() { + return validatedStsOriginalAccessKeyId; + } + + public void setValidatedStsOriginalAccessKeyId(String validatedStsOriginalAccessKeyId) { + this.validatedStsOriginalAccessKeyId = validatedStsOriginalAccessKeyId; + } } 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/common/src/test/java/org/apache/hadoop/ozone/om/helpers/TestKeyInfoWithVolumeContext.java b/hadoop-ozone/common/src/test/java/org/apache/hadoop/ozone/om/helpers/TestKeyInfoWithVolumeContext.java new file mode 100644 index 000000000000..98c03f9c5ead --- /dev/null +++ b/hadoop-ozone/common/src/test/java/org/apache/hadoop/ozone/om/helpers/TestKeyInfoWithVolumeContext.java @@ -0,0 +1,70 @@ +/* + * 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.assertFalse; + +import org.apache.hadoop.hdds.protocol.proto.HddsProtos; +import org.apache.hadoop.ozone.protocol.proto.OzoneManagerProtocolProtos.GetKeyInfoResponse; +import org.apache.hadoop.ozone.protocol.proto.OzoneManagerProtocolProtos.KeyInfo; +import org.junit.jupiter.api.Test; + +/** Unit tests for {@link KeyInfoWithVolumeContext}. */ +public class TestKeyInfoWithVolumeContext { + + @Test + public void fromProtobufReadsStsOriginalAccessKeyId() throws Exception { + final GetKeyInfoResponse proto = GetKeyInfoResponse.newBuilder() + .setKeyInfo(minimalKeyInfo()) + .setUserPrincipal("alice") + .setStsOriginalAccessKeyId("AKIAORIGINAL123") + .build(); + + final KeyInfoWithVolumeContext decoded = KeyInfoWithVolumeContext.fromProtobuf(proto); + + assertEquals("alice", decoded.getUserPrincipal().orElse(null)); + assertEquals("AKIAORIGINAL123", decoded.getStsOriginalAccessKeyId().orElse(null)); + assertEquals("key", decoded.getKeyInfo().getKeyName()); + } + + @Test + public void omitsStsOriginalAccessKeyIdWhenUnset() throws Exception { + final GetKeyInfoResponse proto = GetKeyInfoResponse.newBuilder() + .setKeyInfo(minimalKeyInfo()) + .setUserPrincipal("alice") + .build(); + + final KeyInfoWithVolumeContext decoded = KeyInfoWithVolumeContext.fromProtobuf(proto); + + assertEquals("alice", decoded.getUserPrincipal().orElse(null)); + assertFalse(decoded.getStsOriginalAccessKeyId().isPresent()); + } + + private static KeyInfo minimalKeyInfo() { + return KeyInfo.newBuilder() + .setVolumeName("s3v") + .setBucketName("bucket") + .setKeyName("key") + .setDataSize(0L) + .setCreationTime(0L) + .setModificationTime(0L) + .setType(HddsProtos.ReplicationType.STAND_ALONE) + .build(); + } +} diff --git a/hadoop-ozone/common/src/test/java/org/apache/hadoop/ozone/om/helpers/TestS3VolumeContext.java b/hadoop-ozone/common/src/test/java/org/apache/hadoop/ozone/om/helpers/TestS3VolumeContext.java new file mode 100644 index 000000000000..30e75e651a3e --- /dev/null +++ b/hadoop-ozone/common/src/test/java/org/apache/hadoop/ozone/om/helpers/TestS3VolumeContext.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.helpers; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + +import org.apache.hadoop.ozone.protocol.proto.OzoneManagerProtocolProtos.GetS3VolumeContextResponse; +import org.junit.jupiter.api.Test; + +/** Unit tests for {@link S3VolumeContext}. */ +public class TestS3VolumeContext { + + @Test + public void roundTripsStsOriginalAccessKeyId() { + final OmVolumeArgs volumeArgs = OmVolumeArgs.newBuilder() + .setVolume("s3v") + .setAdminName("admin") + .setOwnerName("owner") + .build(); + final S3VolumeContext context = S3VolumeContext.newBuilder() + .setOmVolumeArgs(volumeArgs) + .setUserPrincipal("alice") + .setStsOriginalAccessKeyId("AKIAORIGINAL123") + .build(); + + final GetS3VolumeContextResponse proto = context.getProtobuf(); + final S3VolumeContext decoded = S3VolumeContext.fromProtobuf(proto); + + assertEquals("alice", decoded.getUserPrincipal()); + assertEquals("AKIAORIGINAL123", decoded.getStsOriginalAccessKeyId()); + } + + @Test + public void omitsStsOriginalAccessKeyIdWhenUnset() { + final OmVolumeArgs volumeArgs = OmVolumeArgs.newBuilder() + .setVolume("s3v") + .setAdminName("admin") + .setOwnerName("owner") + .build(); + final S3VolumeContext context = S3VolumeContext.newBuilder() + .setOmVolumeArgs(volumeArgs) + .setUserPrincipal("alice") + .build(); + + final S3VolumeContext decoded = S3VolumeContext.fromProtobuf(context.getProtobuf()); + + assertEquals("alice", decoded.getUserPrincipal()); + assertNull(decoded.getStsOriginalAccessKeyId()); + } +} 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..eb3e9ea7d55e 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 } ] } @@ -555,27 +567,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} INVALID_REQUEST + 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 @@ -610,6 +627,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..78f89685f900 100644 --- a/hadoop-ozone/interface-client/src/main/proto/OmClientProtocol.proto +++ b/hadoop-ozone/interface-client/src/main/proto/OmClientProtocol.proto @@ -333,6 +333,7 @@ message OMRequest { optional RevokeSTSTokenRequest revokeSTSTokenRequest = 155; optional DeleteRevokedSTSTokensRequest deleteRevokedSTSTokensRequest = 156; optional UpdateAssumeRoleRequest updateAssumeRoleRequest = 157; + optional UpdateRevokeSTSTokenRequest updateRevokeSTSTokenRequest = 158; } message OMResponse { @@ -1410,6 +1411,8 @@ message GetKeyInfoResponse { optional KeyInfo keyInfo = 1; optional VolumeInfo volumeInfo = 2; optional string UserPrincipal = 3; + // Set only after OM cryptographically validates the STS session token. + optional string stsOriginalAccessKeyId = 4; } message RenameKeysRequest { @@ -2374,6 +2377,8 @@ message GetS3VolumeContextResponse { optional VolumeInfo volumeInfo = 1; // Piggybacked username (principal) response to be used for KMS client operations optional string userPrincipal = 2; + // Set only after OM cryptographically validates the STS session token. + optional string stsOriginalAccessKeyId = 3; } /** @@ -2534,18 +2539,27 @@ message UpdateAssumeRoleRequest { } message RevokeSTSTokenRequest { - required string sessionToken = 1; + required string originalAccessKeyId = 1; +} + +/** + This request will be used internally by OM to replicate the revocation cutoff captured by the leader + across the OMs in HA mode. +*/ +message UpdateRevokeSTSTokenRequest { + required string originalAccessKeyId = 1; + required 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/OmMetadataReader.java b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/OmMetadataReader.java index 434d05132bf5..8e5ea0ed220e 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 @@ -202,6 +202,7 @@ public KeyInfoWithVolumeContext getKeyInfo(final OmKeyArgs args, s3VolumeContext.ifPresent(context -> { builder.setVolumeArgs(context.getOmVolumeArgs()); builder.setUserPrincipal(context.getUserPrincipal()); + builder.setStsOriginalAccessKeyId(context.getStsOriginalAccessKeyId()); }); return builder.build(); } catch (Exception ex) { diff --git a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/OmSnapshot.java b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/OmSnapshot.java index 6d3a56f40ed0..4272014d70e8 100644 --- a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/OmSnapshot.java +++ b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/OmSnapshot.java @@ -315,6 +315,7 @@ private KeyInfoWithVolumeContext denormalizeKeyInfoWithVolumeContext( .setKeyInfo(denormalizeOmKeyInfo(k.getKeyInfo())) .setVolumeArgs(k.getVolumeArgs().orElse(null)) .setUserPrincipal(k.getUserPrincipal().orElse(null)) + .setStsOriginalAccessKeyId(k.getStsOriginalAccessKeyId().orElse(null)) .build(); } 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..b1ef381796f4 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 @@ -4187,6 +4187,10 @@ S3VolumeContext getS3VolumeContext(boolean skipChecks) throws IOException { final S3VolumeContext.Builder s3VolumeContext = S3VolumeContext.newBuilder() .setOmVolumeArgs(volumeInfo) .setUserPrincipal(userPrincipal); + final STSTokenIdentifier stsTokenIdentifier = getStsTokenIdentifier(); + if (stsTokenIdentifier != null) { + s3VolumeContext.setStsOriginalAccessKeyId(stsTokenIdentifier.getOriginalAccessKeyId()); + } perfMetrics.addS3VolumeContextLatencyNs(Time.monotonicNowNanos() - start); return s3VolumeContext.build(); } 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..a5e4154b43d0 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("") // no secretAccessKey needed + .setSessionPolicy( + s3Auth.hasResolvedStsSessionPolicy() ? s3Auth.getResolvedStsSessionPolicy() : "") + .setEncryptionKey(null) // no encryption key 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..5ce42618e2ac 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,16 +17,21 @@ package org.apache.hadoop.ozone.om.request.s3.security; +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; @@ -35,8 +40,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.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.ozone.protocol.proto.OzoneManagerProtocolProtos.UpdateRevokeSTSTokenRequest; import org.apache.hadoop.security.UserGroupInformation; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -44,10 +49,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 and builds an {@link UpdateRevokeSTSTokenRequest} + * that is replicated 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 +70,94 @@ 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, INVALID_REQUEST); + } + + final long revocationTimeMillis = CLOCK.millis(); + final UpdateRevokeSTSTokenRequest updateRevokeSTSTokenRequest = UpdateRevokeSTSTokenRequest.newBuilder() + .setOriginalAccessKeyId(originalAccessKeyId) + .setRevocationTimeMillis(revocationTimeMillis) + .build(); + + return omRequest.toBuilder() + .setUpdateRevokeSTSTokenRequest(updateRevokeSTSTokenRequest) + .build(); } @Override public OMClientResponse validateAndUpdateCache(OzoneManager ozoneManager, ExecutionContext context) { final OMResponse.Builder omResponse = OmResponseUtil.getOMResponseBuilder(getOmRequest()); + IOException exception = null; + OMClientResponse omClientResponse; + String originalAccessKeyId = null; + + try { + validateReplicatedRevokeRequestFields(getOmRequest()); + final UpdateRevokeSTSTokenRequest updateRevokeSTSTokenRequest = getOmRequest().getUpdateRevokeSTSTokenRequest(); + originalAccessKeyId = updateRevokeSTSTokenRequest.getOriginalAccessKeyId(); + final long revocationTimeMillis = updateRevokeSTSTokenRequest.getRevocationTimeMillis(); - final OzoneManagerProtocolProtos.RevokeSTSTokenRequest revokeReq = getOmRequest().getRevokeSTSTokenRequest(); - final String sessionToken = revokeReq.getSessionToken(); + // All actual DB mutations are done in the response's addToDBBatch(). + omClientResponse = new S3RevokeSTSTokenResponse(originalAccessKeyId, revocationTimeMillis, omResponse.build()); - // All actual DB mutations are done in the response's addToDBBatch(). - final OMClientResponse omClientResponse = new S3RevokeSTSTokenResponse( - sessionToken, omResponse.build()); + // Update the cache immediately so subsequent validation checks see the revocation + ozoneManager.getMetadataManager().getS3RevokedStsTokenTable().addCacheEntry( + new CacheKey<>(originalAccessKeyId), CacheValue.get(context.getIndex(), revocationTimeMillis)); + + 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)); + } // 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)); + if (originalAccessKeyId != null) { + auditMap.put(OzoneConsts.S3_STS_ORIGINAL_ACCESS_KEY_ID, originalAccessKeyId); + } + markForAudit( + ozoneManager.getAuditLogger(), buildAuditMessage(OMAction.REVOKE_STS_TOKEN, auditMap, exception, userInfo)); + return omClientResponse; + } - // Update the cache immediately so subsequent validation checks see the revocation - ozoneManager.getMetadataManager().getS3RevokedStsTokenTable().addCacheEntry( - new CacheKey<>(sessionToken), CacheValue.get(context.getIndex(), CLOCK.millis())); + 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 (originalAccessKeyId.length() >= OzoneConsts.OZONE_MAXIMUM_ACCESS_ID_LENGTH) { + throw new OMException("originalAccessKeyId length is invalid: " + originalAccessKeyId.length(), INVALID_REQUEST); + } + } - LOG.info("Marked STS session token '{}' as revoked.", sessionToken); - return omClientResponse; + private static void validateReplicatedRevokeRequestFields(OMRequest omRequest) throws OMException { + if (!omRequest.hasUpdateRevokeSTSTokenRequest()) { + throw new OMException("updateRevokeSTSTokenRequest is required for STS token revocation", INTERNAL_ERROR); + } + final String originalAccessKeyId = omRequest.getRevokeSTSTokenRequest().getOriginalAccessKeyId(); + final UpdateRevokeSTSTokenRequest updateRevokeSTSTokenRequest = omRequest.getUpdateRevokeSTSTokenRequest(); + if (!originalAccessKeyId.equals(updateRevokeSTSTokenRequest.getOriginalAccessKeyId())) { + throw new OMException( + "originalAccessKeyId mismatch between revokeSTSTokenRequest and updateRevokeSTSTokenRequest", + INTERNAL_ERROR); + } } } 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..03a1fdba017d 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 @@ -157,7 +157,7 @@ private static Token decodeTokenFromString(String encodedTok 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 +180,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..cc229083d9eb 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,7 @@ 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; @@ -63,23 +64,135 @@ 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.encryptionKey = params.getEncryptionKey(); // already cloned via Params + } + + /** + * 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 byte[] encryptionKey; + + 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.encryptionKey = builder.encryptionKey; + } + + 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 byte[] getEncryptionKey() { + return encryptionKey != null ? encryptionKey.clone() : null; + } + + /** + * 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 byte[] encryptionKey; + + 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 setEncryptionKey(byte[] value) { + this.encryptionKey = value != null ? value.clone() : null; + return this; + } + + public Params build() { + return new Params(this); + } + } } @Override @@ -123,6 +236,7 @@ public OMTokenProto toProtoBuf() { builder .setType(OMTokenProto.Type.S3_STS_TOKEN) + .setIssueDate(creationTime.toEpochMilli()) .setMaxDate(getExpiry().toEpochMilli()) .setOwner(getOwnerId() != null ? getOwnerId() : "") .setAccessKeyId(getOwnerId() != null ? getOwnerId() : "") @@ -146,6 +260,9 @@ public void fromProtoBuf(OMTokenProto token) throws IOException { setOwnerId(token.getOwner()); setExpiry(Instant.ofEpochMilli(token.getMaxDate())); + if (token.hasIssueDate()) { + this.creationTime = Instant.ofEpochMilli(token.getIssueDate()); + } if (token.hasOriginalAccessKeyId()) { this.originalAccessKeyId = token.getOriginalAccessKeyId(); } @@ -244,6 +361,10 @@ public String getSessionPolicy() { return sessionPolicy; } + public Instant getCreationTime() { + return creationTime; + } + public void setEncryptionKey(byte[] encryptionKey) { this.encryptionKey = encryptionKey.clone(); } @@ -265,13 +386,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 +400,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..8cddc50f18ab 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 @@ -85,7 +85,8 @@ 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(); @@ -94,8 +95,16 @@ 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. - 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) + .setEncryptionKey(encryptionKey) + .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..99b6d6a98e9f 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.hasUpdateRevokeSTSTokenRequest()); + assertEquals(originalAccessKeyId, result.getUpdateRevokeSTSTokenRequest().getOriginalAccessKeyId()); + assertTrue(result.getUpdateRevokeSTSTokenRequest().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 OzoneManagerProtocolProtos.RevokeSTSTokenRequest revokeRequest = - OzoneManagerProtocolProtos.RevokeSTSTokenRequest.newBuilder() - .setSessionToken(sessionToken) - .build(); + final OMClientRequest omClientRequest = new S3RevokeSTSTokenRequest(buildRevokeOmRequest(originalAccessKeyId)); + ex = assertThrows(OMException.class, () -> omClientRequest.preExecute(ozoneManager)); + } + assertEquals(OMException.ResultCodes.USER_MISMATCH, ex.getResult()); + } - final OMRequest omRequest = OMRequest.newBuilder() - .setClientId(UUID.randomUUID().toString()) - .setCmdType(Type.RevokeSTSToken) - .setRevokeSTSTokenRequest(revokeRequest) - .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 OMClientRequest omClientRequest = new S3RevokeSTSTokenRequest(omRequest); + 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.INVALID_REQUEST, ex.getResult()); + assertTrue(ex.getMessage().contains("does not exist")); + assertTrue(ex.getMessage().contains(originalAccessKeyId)); + verify(s3SecretManager).hasS3Secret(originalAccessKeyId); + } + } - ex = assertThrows(OMException.class, () -> omClientRequest.preExecute(ozoneManager)); + @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)); + + 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.INVALID_REQUEST, 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,25 +272,135 @@ public void testValidateAndUpdateCacheUpdatesCacheImmediately() throws Exception final OzoneManagerProtocolProtos.RevokeSTSTokenRequest revokeRequest = OzoneManagerProtocolProtos.RevokeSTSTokenRequest.newBuilder() - .setSessionToken(sessionToken) + .setOriginalAccessKeyId(originalAccessKeyId) + .build(); + final OzoneManagerProtocolProtos.UpdateRevokeSTSTokenRequest updateRevokeRequest = + OzoneManagerProtocolProtos.UpdateRevokeSTSTokenRequest.newBuilder() + .setOriginalAccessKeyId(originalAccessKeyId) + .setRevocationTimeMillis(revocationTimeMillis) .build(); final OMRequest omRequest = OMRequest.newBuilder() .setClientId(UUID.randomUUID().toString()) .setCmdType(Type.RevokeSTSToken) .setRevokeSTSTokenRequest(revokeRequest) + .setUpdateRevokeSTSTokenRequest(updateRevokeRequest) .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)); + verify(s3RevokedStsTokenTable).addCacheEntry( + eq(new CacheKey<>(originalAccessKeyId)), any()); + assertNotNull(s3RevokeSTSTokenRequest.getAuditBuilder().getAuditMap()); + assertEquals( + originalAccessKeyId, s3RevokeSTSTokenRequest.getAuditBuilder().getAuditMap().get( + OzoneConsts.S3_STS_ORIGINAL_ACCESS_KEY_ID)); + } + + @Test + public void testValidateAndUpdateCacheRejectsMissingUpdateRevokeSTSTokenRequest() { + 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 testValidateAndUpdateCacheRejectsMismatchedOriginalAccessKeyId() { + final String originalAccessKeyId = "original-access-key-id"; + final String mismatchedAccessKeyId = "other-access-key-id"; + final long revocationTimeMillis = 1_700_000_000_000L; + + 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 OMRequest omRequest = OMRequest.newBuilder() + .setClientId(UUID.randomUUID().toString()) + .setCmdType(Type.RevokeSTSToken) + .setRevokeSTSTokenRequest(OzoneManagerProtocolProtos.RevokeSTSTokenRequest.newBuilder() + .setOriginalAccessKeyId(originalAccessKeyId) + .build()) + .setUpdateRevokeSTSTokenRequest(OzoneManagerProtocolProtos.UpdateRevokeSTSTokenRequest.newBuilder() + .setOriginalAccessKeyId(mismatchedAccessKeyId) + .setRevocationTimeMillis(revocationTimeMillis) + .build()) + .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 testPreExecuteRejectsOverlongOriginalAccessKeyId() throws Exception { + final StringBuilder sb = new StringBuilder(); + for (int i = 0; i < OzoneConsts.OZONE_MAXIMUM_ACCESS_ID_LENGTH; i++) { + sb.append('a'); + } + final UserGroupInformation callerUgi = UserGroupInformation.createRemoteUser("caller"); + Server.getCurCall().set(new StubCall(callerUgi)); + + try (OzoneManager ozoneManager = mock(OzoneManager.class)) { + configureOzoneManagerForPreExecute(ozoneManager, sb.toString(), false); + final OMClientRequest omClientRequest = new S3RevokeSTSTokenRequest(buildRevokeOmRequest(sb.toString())); + 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 +414,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..24e5b48bb4c8 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 @@ -22,6 +22,7 @@ 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.assertNull; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.any; @@ -50,6 +51,7 @@ import org.apache.hadoop.ozone.protocol.proto.OzoneManagerProtocolProtos.S3Authentication; import org.apache.hadoop.ozone.protocol.proto.OzoneManagerProtocolProtos.Type; import org.apache.ozone.test.MockClock; +import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.Test; import org.mockito.MockedStatic; @@ -65,13 +67,16 @@ public class TestS3SecurityUtil { ThreadLocalRandom.current().nextBytes(ENCRYPTION_KEY); } + @AfterEach + public void tearDown() { + OzoneManager.setStsTokenIdentifier(null); + } + @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 +167,31 @@ public void testValidateS3CredentialFailsWhenRequestAccessIdEmpty() throws Excep .setExpectedMessage("STS token validation failed - accessKeyId is invalid for session token")); } + @Test + public void testValidateS3CredentialFailsWhenAwsSignatureInvalid() throws Exception { + validateS3CredentialHelper( + new TestConfig() + .setAwsSignatureValid(false) + .setExpectedResult(INVALID_TOKEN) + .setExpectedMessage("STS token validation failed for 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 +218,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( @@ -206,7 +237,7 @@ private void validateS3CredentialHelper(TestConfig config) throws Exception { // Mock AWS V4 signature validation awsV4AuthValidatorMock.when(() -> AWSV4AuthValidator.validateRequest(anyString(), anyString(), anyString())) - .thenReturn(true); + .thenReturn(config.awsSignatureValid); final OMRequest omRequest = createRequestWithSessionToken( config.requestAccessId, config.includeAccessId); @@ -221,18 +252,27 @@ private void validateS3CredentialHelper(TestConfig config) throws Exception { "Expected exception message to contain: '" + config.expectedMessage + "' but was: '" + omException.getMessage() + "'"); } + assertNull( + OzoneManager.getStsTokenIdentifier(), "STS token identifier must not be set when validation fails"); } else { assertDoesNotThrow(() -> S3SecurityUtil.validateS3Credential(omRequest, ozoneManager)); + assertEquals(stsTokenIdentifier, OzoneManager.getStsTokenIdentifier()); } } } } 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") + .setEncryptionKey(ENCRYPTION_KEY) + .build()); } private static OMRequest createRequestWithSessionToken(String accessId, boolean includeAccessId) { @@ -258,11 +298,12 @@ 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; private boolean includeAccessId = true; + private boolean awsSignatureValid = true; private OMException.ResultCodes expectedResult = null; private String expectedMessage = null; @@ -277,9 +318,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; } @@ -306,6 +346,11 @@ TestConfig setIncludeAccessId(boolean includeAccessId) { return this; } + TestConfig setAwsSignatureValid(boolean awsSignatureValid) { + this.awsSignatureValid = awsSignatureValid; + return this; + } + TestConfig setExpectedResult(OMException.ResultCodes result) { this.expectedResult = result; 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..c02df40e1a16 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; @@ -98,6 +99,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 +128,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 @@ -274,6 +286,26 @@ public void testConstructValidateAndDecryptSTSTokenSecretKeyRetrievalException() "key: something went wrong"); } + @Test + public void testConstructValidateAndDecryptSTSTokenRejectsForgedOriginalAccessKeyId() throws Exception { + 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 OMTokenProto forgedProto = OMTokenProto.parseFrom(validToken.getIdentifier()).toBuilder() + .setOriginalAccessKeyId("forged-original-access-key") + .build(); + final Token forgedToken = new Token<>( + forgedProto.toByteArray(), validToken.getPassword(), validToken.getKind(), validToken.getService()); + + assertThatThrownBy(() -> + STSSecurityUtil.constructValidateAndDecryptSTSToken( + forgedToken.encodeToUrlString(), secretKeyClient, clock)) + .isInstanceOf(OMException.class) + .hasMessageContaining("Invalid STS token format: Invalid STS token - signature is not correct for token"); + } + @Test public void testConstructValidateAndDecryptSTSTokenInvalidSignature() throws Exception { // Create a valid token string @@ -303,7 +335,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 +363,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 +372,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 +381,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 +391,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 +400,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 +487,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) + .setEncryptionKey(ENCRYPTION_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..870e99c3da18 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 @@ -76,12 +76,21 @@ public void testSTSTokenIdentifierEncryption() throws Exception { 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); + final STSTokenIdentifier tokenId = new STSTokenIdentifier(STSTokenIdentifier.Params.newBuilder() + .setTempAccessKeyId(tempAccessKeyId) + .setOriginalAccessKeyId(originalAccessKeyId) + .setRoleArn(roleArn) + .setCreationTime(creationTime) + .setExpiry(expiry) + .setSecretAccessKey(secretAccessKey) + .setSessionPolicy(sessionPolicy) + .setEncryptionKey(keyBytes) + .build()); tokenId.setSecretKeyId(UUID.randomUUID()); // Convert to protobuf @@ -100,6 +109,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..4ff08087e9fe 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 @@ -38,6 +38,7 @@ public class TestSTSTokenIdentifier { private static final byte[] ENCRYPTION_KEY = new byte[5]; + private static final Instant CREATION_TIME = Instant.ofEpochMilli(1_700_000_000_000L); { ThreadLocalRandom.current().nextBytes(ENCRYPTION_KEY); @@ -45,9 +46,14 @@ public class TestSTSTokenIdentifier { @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,9 +65,14 @@ 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 STSTokenIdentifier originalTokenIdentifier = new STSTokenIdentifier(paramsBuilder() + .setTempAccessKeyId("tempAccess") + .setOriginalAccessKeyId("origAccess") + .setRoleArn("arn:aws:iam::123456789012:role/RoleY") + .setExpiry(expiry) + .setSecretAccessKey("secretKey") + .setSessionPolicy("sessionPolicy") + .build()); final UUID secretKeyId = UUID.randomUUID(); originalTokenIdentifier.setSecretKeyId(secretKeyId); @@ -69,6 +80,7 @@ public void testProtoBufRoundTrip() throws IOException { 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 @@ -81,6 +93,7 @@ public void testProtoBufRoundTrip() throws IOException { 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 +112,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,9 +128,13 @@ 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 STSTokenIdentifier stsTokenIdentifier = new STSTokenIdentifier(paramsBuilder() + .setTempAccessKeyId("tempAccess") + .setOriginalAccessKeyId("origAccess") + .setRoleArn("arn:aws:iam::123456789012:role/RoleX") + .setExpiry(expiry) + .setSecretAccessKey("secretKey") + .build()); final UUID secretKeyId = UUID.randomUUID(); stsTokenIdentifier.setSecretKeyId(secretKeyId); @@ -129,9 +151,14 @@ 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 STSTokenIdentifier stsTokenIdentifier = new STSTokenIdentifier(paramsBuilder() + .setTempAccessKeyId("tempAccess") + .setOriginalAccessKeyId("origAccess") + .setRoleArn("arn:aws:iam::123456789012:role/RoleZ") + .setExpiry(expiry) + .setSecretAccessKey("secretKey") + .setSessionPolicy("") + .build()); final UUID secretKeyId = UUID.randomUUID(); stsTokenIdentifier.setSecretKeyId(secretKeyId); @@ -153,9 +180,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,9 +201,14 @@ 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); + final STSTokenIdentifier originalTokenIdentifier = new STSTokenIdentifier(paramsBuilder() + .setTempAccessKeyId("tempAccessKeyId") + .setOriginalAccessKeyId("originalAccessKeyId") + .setRoleArn("roleArn") + .setExpiry(expiry) + .setSecretAccessKey("secretAccessKey") + .setSessionPolicy("sessionPolicy") + .build()); originalTokenIdentifier.setSecretKeyId(UUID.randomUUID()); final ByteArrayOutputStream baos = new ByteArrayOutputStream(); @@ -196,9 +233,14 @@ public void testWriteToAndReadFromByteArrayWithDifferentSecretKeyIds() throws Ex } final Instant expiry = Instant.now().plusSeconds(1500); - final STSTokenIdentifier originalTokenIdentifier = new STSTokenIdentifier( - "tempAccessKeyId", "originalAccessKeyId", "roleArn", expiry, - "secretAccessKey", "sessionPolicy", ENCRYPTION_KEY); + final STSTokenIdentifier originalTokenIdentifier = new STSTokenIdentifier(paramsBuilder() + .setTempAccessKeyId("tempAccessKeyId") + .setOriginalAccessKeyId("originalAccessKeyId") + .setRoleArn("roleArn") + .setExpiry(expiry) + .setSecretAccessKey("secretAccessKey") + .setSessionPolicy("sessionPolicy") + .build()); originalTokenIdentifier.setSecretKeyId(uuid1); final ByteArrayOutputStream baos1 = new ByteArrayOutputStream(); @@ -206,9 +248,14 @@ public void testWriteToAndReadFromByteArrayWithDifferentSecretKeyIds() throws Ex originalTokenIdentifier.write(out); } - final STSTokenIdentifier anotherTokenIdentifier = new STSTokenIdentifier( - "tempAccessKeyId", "originalAccessKeyId", "roleArn", expiry, - "secretAccessKey", "sessionPolicy", ENCRYPTION_KEY); + final STSTokenIdentifier anotherTokenIdentifier = new STSTokenIdentifier(paramsBuilder() + .setTempAccessKeyId("tempAccessKeyId") + .setOriginalAccessKeyId("originalAccessKeyId") + .setRoleArn("roleArn") + .setExpiry(expiry) + .setSecretAccessKey("secretAccessKey") + .setSessionPolicy("sessionPolicy") + .build()); anotherTokenIdentifier.setSecretKeyId(uuid2); final ByteArrayOutputStream baos2 = new ByteArrayOutputStream(); @@ -236,9 +283,14 @@ public void testWriteToAndReadFromByteArrayWithSameSecretKeyIds() throws Excepti 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); + final STSTokenIdentifier originalTokenIdentifier = new STSTokenIdentifier(paramsBuilder() + .setTempAccessKeyId("tempAccessKeyId") + .setOriginalAccessKeyId("originalAccessKeyId") + .setRoleArn("roleArn") + .setExpiry(expiry) + .setSecretAccessKey("secretAccessKey") + .setSessionPolicy("sessionPolicy") + .build()); originalTokenIdentifier.setSecretKeyId(uuid); final ByteArrayOutputStream baos1 = new ByteArrayOutputStream(); @@ -246,9 +298,14 @@ public void testWriteToAndReadFromByteArrayWithSameSecretKeyIds() throws Excepti originalTokenIdentifier.write(out); } - final STSTokenIdentifier anotherTokenIdentifier = new STSTokenIdentifier( - "tempAccessKeyId", "originalAccessKeyId", "roleArn", expiry, - "secretAccessKey", "sessionPolicy", ENCRYPTION_KEY); + final STSTokenIdentifier anotherTokenIdentifier = new STSTokenIdentifier(paramsBuilder() + .setTempAccessKeyId("tempAccessKeyId") + .setOriginalAccessKeyId("originalAccessKeyId") + .setRoleArn("roleArn") + .setExpiry(expiry) + .setSecretAccessKey("secretAccessKey") + .setSessionPolicy("sessionPolicy") + .build()); anotherTokenIdentifier.setSecretKeyId(uuid); final ByteArrayOutputStream baos2 = new ByteArrayOutputStream(); @@ -279,13 +336,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 +360,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 +388,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 +413,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 +438,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 +486,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 +511,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 +537,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,9 +558,14 @@ 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); } @@ -431,24 +576,39 @@ public void testEqualsWithDifferentEncryptionKeys() { 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); + final STSTokenIdentifier stsTokenIdentifier2 = new STSTokenIdentifier(paramsBuilder() + .setTempAccessKeyId("tempAccessKeyId") + .setOriginalAccessKeyId("originalAccessKeyId") + .setRoleArn("roleArn") + .setExpiry(expiry) + .setSecretAccessKey("secretAccessKey") + .setSessionPolicy("sessionPolicy") + .setEncryptionKey(differentKey) + .build()); 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()); } -} - + private static STSTokenIdentifier.Params.Builder paramsBuilder() { + return STSTokenIdentifier.Params.newBuilder() + .setCreationTime(CREATION_TIME) + .setEncryptionKey(ENCRYPTION_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..525ee5f0da08 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 @@ -98,6 +98,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()); 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 c833d5f22f43..3bf2eb346ea7 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 @@ -404,9 +404,9 @@ public MultiDeleteResponse multiDelete( if (!result.getErrors().isEmpty()) { auditMultiDeleteFailure(context, deleteKeys, new Exception("MultiDelete Exception")); } else { - AuditMessage.Builder message = auditMessageFor(context.getAction()); + AuditMessage.Builder message = auditMessageForSuccess(context.getAction()); message.getParams().put("failedDeletes", deleteKeys.toString()); - AUDIT.logWriteSuccess(message.withResult(AuditEventStatus.SUCCESS).build()); + AUDIT.logWriteSuccess(message.build()); } return result; 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 63e25fca628e..c2047e2f6f3b 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 @@ -312,7 +312,7 @@ protected T runWithS3ActionString(String s3Action, Chec } protected OzoneVolume getVolume() throws IOException { - return client.getObjectStore().getS3Volume(); + return getClient().getObjectStore().getS3Volume(); } /** @@ -605,6 +605,12 @@ protected AuditMessage.Builder auditMessageFor(AuditAction op) { Map auditMap = getAuditParameters(); auditMap.put("x-amz-request-id", requestIdentifier.getRequestId()); auditMap.put("x-amz-id-2", requestIdentifier.getAmzId()); + if (s3Auth != null) { + final String originalAccessKeyId = s3Auth.getValidatedStsOriginalAccessKeyId(); + if (StringUtils.isNotEmpty(originalAccessKeyId)) { + auditMap.put(OzoneConsts.S3_STS_ORIGINAL_ACCESS_KEY_ID, originalAccessKeyId); + } + } AuditMessage.Builder builder = new AuditMessage.Builder() .forOperation(op) @@ -621,6 +627,7 @@ protected AuditMessage.Builder auditMessageFor(AuditAction op) { } protected AuditMessage.Builder auditMessageForSuccess(AuditAction op) { + resolveValidatedStsOriginalAccessKeyIdForAudit(); return auditMessageFor(op) .withResult(AuditEventStatus.SUCCESS); } @@ -631,6 +638,26 @@ protected AuditMessage.Builder auditMessageForFailure(AuditAction op, Throwable .withException(throwable); } + /** + * Populates {@link S3Auth#getValidatedStsOriginalAccessKeyId()} from OM when the request carries + * an STS session token but the validated id is not yet available (e.g. bucket-only paths). + * Called only from the success-audit path; failure audits must not trigger an OM round-trip. + * Never disrupts auditing when OM validation fails. + */ + private void resolveValidatedStsOriginalAccessKeyIdForAudit() { + if (s3Auth == null || StringUtils.isEmpty(s3Auth.getSessionToken())) { + return; + } + if (StringUtils.isNotEmpty(s3Auth.getValidatedStsOriginalAccessKeyId())) { + return; + } + try { + getClient().getObjectStore().getS3VolumeContext(); + } catch (IOException | RuntimeException e) { + LOG.debug("Could not resolve validated STS context for audit", e); + } + } + @VisibleForTesting public void setClient(OzoneClient ozoneClient) { this.client = ozoneClient; @@ -702,6 +729,13 @@ public S3GatewayMetrics getMetrics() { return S3GatewayMetrics.getMetrics(); } + @VisibleForTesting + void setValidatedStsOriginalAccessKeyIdForTest() { + if (s3Auth != null) { + s3Auth.setValidatedStsOriginalAccessKeyId("AKIAORIGINAL123"); + } + } + 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 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 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 9865345a9162..f0d91631cb02 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 @@ -26,29 +26,54 @@ 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.anyString; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.doThrow; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; +import java.io.IOException; import java.nio.charset.StandardCharsets; import java.util.Locale; import java.util.Map; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.BiConsumer; +import java.util.function.Consumer; import java.util.stream.Stream; import javax.ws.rs.core.MultivaluedHashMap; import javax.ws.rs.core.MultivaluedMap; +import org.apache.hadoop.io.Text; import org.apache.hadoop.ozone.OzoneConsts; +import org.apache.hadoop.ozone.audit.AuditMessage; +import org.apache.hadoop.ozone.audit.S3GAction; +import org.apache.hadoop.ozone.client.ObjectStore; +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.helpers.OmVolumeArgs; +import org.apache.hadoop.ozone.om.helpers.S3VolumeContext; +import org.apache.hadoop.ozone.om.protocol.S3Auth; +import org.apache.hadoop.ozone.protocol.proto.OzoneManagerProtocolProtos.OMTokenProto; import org.apache.hadoop.ozone.s3.exception.OS3Exception; +import org.apache.hadoop.ozone.s3.signature.SignatureInfo; +import org.apache.hadoop.security.token.Token; import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.MethodSource; +import org.mockito.stubbing.Answer; /** * Tests the s3 EndpointBase class methods. * Test methods of the EndpointBase. */ public class TestEndpointBase { + private static final String ORIGINAL_ACCESS_KEY_ID_PARAM = "originalAccessKeyId"; + private static final String FORGED_STS_ORIGINAL_ACCESS_KEY_ID = "FORGED-ORIGINAL-ACCESS-KEY"; + private static final String STS_TEMP_ACCESS_KEY_ID = "ASIAEXAMPLE123"; /** * Verify s3 metadata key "gdprEnabled" can't be set up directly @@ -146,6 +171,93 @@ public void init() { } assertFalse(endpointBase.isExpiredToken(new OMException(ResultCodes.INVALID_TOKEN))); } + @Test + public void testAuditMessageIncludesValidatedStsOriginalAccessKeyId() throws Exception { + final String originalAccessKeyId = "AKIAORIGINAL123"; + // Pass the forged token to newAuditEndpoint because we need a session token present (so the request is + // for STS), but deliberately make its embedded originalAccessKeyId wrong, so the test can prove the audit path + // ignores it and only trusts the validated field. + final AuditEndpoint endpointBase = newAuditEndpoint(stsSignatureInfoWithForgedOriginalAccessKeyId()); + endpointBase.setValidatedStsOriginalAccessKeyIdForTest(); + + assertThat(endpointBase.auditMessageForTest().getParams()) + .containsEntry(ORIGINAL_ACCESS_KEY_ID_PARAM, originalAccessKeyId) + .doesNotContainValue(FORGED_STS_ORIGINAL_ACCESS_KEY_ID); + } + + @Test + public void testAuditMessageResolvesValidatedStsOriginalAccessKeyIdFromOm() throws Exception { + final String originalAccessKeyId = "AKIAORIGINAL123"; + final StsAuditEndpointFixture fixture = newStsAuditEndpointFixture( + stsSignatureInfoWithForgedOriginalAccessKeyId(), + (objectStore, s3AuthRef) -> stubGetS3VolumeContext( + objectStore, invocation -> { + final S3Auth auth = s3AuthRef.get(); + if (auth != null) { + auth.setValidatedStsOriginalAccessKeyId(originalAccessKeyId); + } + final OmVolumeArgs volumeArgs = OmVolumeArgs.newBuilder() + .setVolume("s3v") + .setAdminName("admin") + .setOwnerName("owner") + .build(); + return S3VolumeContext.newBuilder() + .setOmVolumeArgs(volumeArgs) + .setUserPrincipal("alice") + .setStsOriginalAccessKeyId(originalAccessKeyId) + .build(); + })); + + assertThat(fixture.getEndpoint().auditMessageForTest().getParams()) + .containsEntry(ORIGINAL_ACCESS_KEY_ID_PARAM, originalAccessKeyId) + .doesNotContainValue(FORGED_STS_ORIGINAL_ACCESS_KEY_ID); + verify(fixture.getObjectStore()).getS3VolumeContext(); + } + + @Test + public void testAuditMessageOmitsStsOriginalAccessKeyIdWhenNotValidated() throws Exception { + final AuditEndpoint endpointBase = newAuditEndpoint(stsSignatureInfoWithForgedOriginalAccessKeyId()); + + assertThat(endpointBase.auditMessageForTest().getParams()) + .doesNotContainKey(ORIGINAL_ACCESS_KEY_ID_PARAM); + } + + @Test + public void testFailureAuditOmitsStsOriginalAccessKeyIdWhenNotValidated() throws Exception { + final StsAuditEndpointFixture fixture = newStsAuditEndpointFixture(stsSignatureInfoWithForgedOriginalAccessKeyId()); + + assertThat(fixture.getEndpoint().auditMessageForFailureTest( + new OMException("STS token validation failed", ResultCodes.INVALID_TOKEN)).getParams()) + .doesNotContainKey(ORIGINAL_ACCESS_KEY_ID_PARAM) + .doesNotContainValue(FORGED_STS_ORIGINAL_ACCESS_KEY_ID); + verify(fixture.getObjectStore(), never()).getS3VolumeContext(); + } + + @Test + public void testAuditMessageSuccessIgnoresRuntimeExceptionFromOmResolution() throws Exception { + final StsAuditEndpointFixture fixture = newStsAuditEndpointFixture( + stsSignatureInfoWithForgedOriginalAccessKeyId(), objectStore -> stubGetS3VolumeContextToThrow( + objectStore, new RuntimeException("OM unavailable"))); + + assertThat(fixture.getEndpoint().auditMessageForTest().getParams()) + .doesNotContainKey(ORIGINAL_ACCESS_KEY_ID_PARAM) + .doesNotContainValue(FORGED_STS_ORIGINAL_ACCESS_KEY_ID); + verify(fixture.getObjectStore()).getS3VolumeContext(); + } + + @Test + public void testAuditMessageOmitsStsOriginalAccessKeyIdForNonStsRequest() { + final SignatureInfo signatureInfo = new SignatureInfo.Builder(SignatureInfo.Version.V4) + .setAwsAccessId("AKIAEXAMPLE123") + .setSignature("signature") + .setStringToSign("string-to-sign") + .build(); + final AuditEndpoint endpointBase = newAuditEndpoint(signatureInfo); + + assertThat(endpointBase.auditMessageForTest().getParams()) + .doesNotContainKey(ORIGINAL_ACCESS_KEY_ID_PARAM); + } + @Test public void testListS3BucketsHandlesRuntimeExceptionWrappingOMException() throws Exception { final EndpointBase endpointBase = new EndpointBase() { @@ -209,4 +321,109 @@ private static Stream reservedInternalMetadataKeyPrefixCases() { RESERVED_USER_METADATA_KEY_PREFIX.toUpperCase(Locale.ROOT) + "cache-control"); } + private static String encodeSessionToken(OMTokenProto proto) throws Exception { + final Token token = new Token<>( + proto.toByteArray(), new byte[0], new Text("OzoneToken"), new Text("sts")); + return token.encodeToUrlString(); + } + + private static SignatureInfo stsSignatureInfoWithForgedOriginalAccessKeyId() throws Exception { + final OMTokenProto proto = OMTokenProto.newBuilder() + .setType(OMTokenProto.Type.S3_STS_TOKEN) + .setOriginalAccessKeyId(FORGED_STS_ORIGINAL_ACCESS_KEY_ID) + .build(); + return new SignatureInfo.Builder(SignatureInfo.Version.V4) + .setAwsAccessId(STS_TEMP_ACCESS_KEY_ID) + .setSignature("signature") + .setStringToSign("string-to-sign") + .setSessionToken(encodeSessionToken(proto)) + .build(); + } + + private static void stubGetS3VolumeContext(ObjectStore objectStore, Answer answer) { + try { + doAnswer(answer).when(objectStore).getS3VolumeContext(); + } catch (IOException e) { + throw new RuntimeException(e); + } + } + + private static void stubGetS3VolumeContextToThrow(ObjectStore objectStore, RuntimeException toThrow) { + try { + doThrow(toThrow).when(objectStore).getS3VolumeContext(); + } catch (IOException e) { + throw new RuntimeException(e); + } + } + + private static StsAuditEndpointFixture newStsAuditEndpointFixture(SignatureInfo signatureInfo) + throws Exception { + return newStsAuditEndpointFixture(signatureInfo, (Consumer) objectStore -> { }); + } + + private static StsAuditEndpointFixture newStsAuditEndpointFixture( + SignatureInfo signatureInfo, + Consumer objectStoreConfigurer) throws Exception { + return newStsAuditEndpointFixture(signatureInfo, (objectStore, s3AuthRef) -> + objectStoreConfigurer.accept(objectStore)); + } + + private static StsAuditEndpointFixture newStsAuditEndpointFixture( + SignatureInfo signatureInfo, + BiConsumer> objectStoreConfigurer) throws Exception { + final OzoneClient client = mock(OzoneClient.class); + final ObjectStore objectStore = mock(ObjectStore.class); + final ClientProtocol clientProtocol = mock(ClientProtocol.class); + final AtomicReference s3AuthRef = new AtomicReference<>(); + + doAnswer(invocation -> { + s3AuthRef.set(invocation.getArgument(0)); + return null; + }).when(clientProtocol).setThreadLocalS3Auth(any(S3Auth.class)); + when(clientProtocol.getThreadLocalS3Auth()).thenAnswer(invocation -> s3AuthRef.get()); + when(client.getObjectStore()).thenReturn(objectStore); + when(objectStore.getClientProxy()).thenReturn(clientProtocol); + objectStoreConfigurer.accept(objectStore, s3AuthRef); + + final AuditEndpoint endpoint = new EndpointBuilder<>(AuditEndpoint::new) + .setClient(client) + .setSignatureInfo(signatureInfo) + .build(); + return new StsAuditEndpointFixture(endpoint, objectStore); + } + + private static AuditEndpoint newAuditEndpoint(SignatureInfo signatureInfo) { + return new EndpointBuilder<>(AuditEndpoint::new) + .setSignatureInfo(signatureInfo) + .build(); + } + + private static final class StsAuditEndpointFixture { + private final AuditEndpoint endpoint; + private final ObjectStore objectStore; + + private StsAuditEndpointFixture(AuditEndpoint endpoint, ObjectStore objectStore) { + this.endpoint = endpoint; + this.objectStore = objectStore; + } + + private AuditEndpoint getEndpoint() { + return endpoint; + } + + private ObjectStore getObjectStore() { + return objectStore; + } + } + + private static final class AuditEndpoint extends EndpointBase { + private AuditMessage.Builder auditMessageForTest() { + return auditMessageForSuccess(S3GAction.GET_KEY); + } + + private AuditMessage.Builder auditMessageForFailureTest(Throwable throwable) { + return auditMessageForFailure(S3GAction.GET_KEY, throwable); + } + } + }