diff --git a/hadoop-hdds/common/src/main/java/org/apache/hadoop/ozone/OzoneConfigKeys.java b/hadoop-hdds/common/src/main/java/org/apache/hadoop/ozone/OzoneConfigKeys.java index 87e6fb86f1ea..ab735c23940c 100644 --- a/hadoop-hdds/common/src/main/java/org/apache/hadoop/ozone/OzoneConfigKeys.java +++ b/hadoop-hdds/common/src/main/java/org/apache/hadoop/ozone/OzoneConfigKeys.java @@ -727,6 +727,10 @@ public final class OzoneConfigKeys { public static final String OZONE_CLIENT_ELASTIC_BYTE_BUFFER_POOL_MAX_SIZE = "ozone.client.elastic.byte.buffer.pool.max.size"; public static final String OZONE_CLIENT_ELASTIC_BYTE_BUFFER_POOL_MAX_SIZE_DEFAULT = "16GB"; + + public static final String OZONE_S3G_STS_HTTP_ENABLED_KEY = + "ozone.s3g.sts.http.enabled"; + public static final boolean OZONE_S3G_STS_HTTP_ENABLED_DEFAULT = false; /** * There is no need to instantiate this class. diff --git a/hadoop-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 3e70a473dc66..188bd65559ff 100644 --- a/hadoop-hdds/common/src/main/java/org/apache/hadoop/ozone/OzoneConsts.java +++ b/hadoop-hdds/common/src/main/java/org/apache/hadoop/ozone/OzoneConsts.java @@ -313,6 +313,8 @@ public final class OzoneConsts { public static final String S3_GETSECRET_USER = "S3GetSecretUser"; public static final String S3_SETSECRET_USER = "S3SetSecretUser"; public static final String S3_REVOKESECRET_USER = "S3RevokeSecretUser"; + public static final String S3_REVOKESTSTOKEN_USER = "S3RevokeSTSTokenUser"; + public static final String 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 248119303b29..2de7451e1d22 100644 --- a/hadoop-hdds/common/src/main/resources/ozone-default.xml +++ b/hadoop-hdds/common/src/main/resources/ozone-default.xml @@ -2116,6 +2116,54 @@ will be used for http authentication. + + + ozone.s3g.sts.http.enabled + false + OZONE, S3GATEWAY + + The boolean which enables the Ozone S3Gateway STS endpoint. + + + + ozone.s3g.sts.http-bind-host + 0.0.0.0 + OZONE, S3GATEWAY + + The bind host for the S3 Gateway STS HTTP server. + If this optional address is set, it overrides only the hostname portion of + ozone.s3g.sts.http-address. + If not set, the value of ozone.s3g.http-bind-host is used. + + + + ozone.s3g.sts.http-address + 0.0.0.0:9880 + OZONE, S3GATEWAY + + The HTTP address for the S3 Gateway STS endpoint. + + + + ozone.s3g.sts.https-bind-host + 0.0.0.0 + OZONE, S3GATEWAY + + The bind host for the S3 Gateway STS HTTPS server. + If this optional address is set, it overrides only the hostname portion of + ozone.s3g.sts.http-address. + If not set, the value of ozone.s3g.https-bind-host is used. + + + + ozone.s3g.sts.https-address + 0.0.0.0:9881 + OZONE, S3GATEWAY + + The HTTPS address for the S3 Gateway STS endpoint. + + + ozone.s3g.metrics.percentiles.intervals.seconds 60 @@ -5211,6 +5259,27 @@ Interval for cleaning up orphan snapshot local data versions corresponding to snapshots + + ozone.om.sts.token.cleanup.service.interval + 3h + OZONE, OM, PERFORMANCE, SECURITY + + A background 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). + + + + ozone.om.sts.token.cleanup.service.timeout + 15m + OZONE, OM, PERFORMANCE, SECURITY + + A timeout value for the revoked STS token cleanup service. If this is set + greater than 0, the service will stop waiting for the deletion + completion after this time. Unit could be defined with postfix (ns,ms,s,m,h,d). + + ozone.scm.ratis.events.max.limit 100 diff --git a/hadoop-hdds/dev-support/checkstyle/checkstyle.xml b/hadoop-hdds/dev-support/checkstyle/checkstyle.xml index 1109bd9983b8..307d99261ff5 100644 --- a/hadoop-hdds/dev-support/checkstyle/checkstyle.xml +++ b/hadoop-hdds/dev-support/checkstyle/checkstyle.xml @@ -83,6 +83,12 @@ + + + + + diff --git a/hadoop-hdds/docs/content/design/ozone-sts.md b/hadoop-hdds/docs/content/design/ozone-sts.md index 6cc94eadd4bc..ccca985ae571 100644 --- a/hadoop-hdds/docs/content/design/ozone-sts.md +++ b/hadoop-hdds/docs/content/design/ozone-sts.md @@ -41,8 +41,9 @@ solutions that want to aggregate data across multiple cloud providers. # 3. How Ozone STS Works -The initial implementation of Ozone STS supports only the [AssumeRole](https://docs.aws.amazon.com/STS/latest/APIReference/API_AssumeRole.html) -API from the AWS specification. A new STS endpoint `/sts` on port `9880` (port `9881` for https) will be created to service STS requests in the S3 Gateway. +The initial implementation of Ozone STS supports the [AssumeRole](https://docs.aws.amazon.com/STS/latest/APIReference/API_AssumeRole.html) +and [GetCallerIdentity](https://docs.aws.amazon.com/STS/latest/APIReference/API_GetCallerIdentity.html) +APIs from the AWS specification. A new STS endpoint on port `9880` (port `9881` for https) will be created to service STS requests in the S3 Gateway at the root path (`/`). We use a separate port for STS to align with AWS so we don't have conflicts at a later time. This means we have: - Admin port for Ozone specific S3 admin operations - STS port for STS APIs, analogous to AWS' separate STS endpoint @@ -66,6 +67,11 @@ return value of the AssumeRole call will be temporary credentials consisting of an IAM policy is specified, the temporary credential will have the permissions comprising the intersection of the role permissions and the IAM policy permissions. **Note:** If the IAM policy is specified and does not grant any permissions, then the generated temporary credentials won't have any permissions and will essentially be useless. +- [GetCallerIdentity](https://docs.aws.amazon.com/STS/latest/APIReference/API_GetCallerIdentity.html) returns the account, +ARN, and user ID for the caller credentials used to sign the request. Ozone uses a static account ID of `123456789012`. +For permanent S3 credentials, `UserId` is the resolved Kerberos principal and `Arn` is `arn:aws:iam::123456789012:user/` +where `` is the short username of the Kerberos principal. For STS temporary credentials, `UserId` is +the `AssumedRoleId` and `Arn` is the assumed-role user ARN from the session token. ## 3.2 Limitations in AssumeRole API Support @@ -117,6 +123,15 @@ team agreed that behavior is fine for actions, but does not work for Conditions, restrict calls by sourceIp, and if we silently ignore this, the client may incorrectly think the temporary credentials are restricted for use by that IP address, so the consensus was to reject the request for that scenario. +### 3.3.2 Additional Context on Linked Buckets + +In Ozone, one may configure a chain of bucket links. In the scenario where one desires to call the AssumeRole API where the resource +is a linked bucket, ensure the Ranger policies for the role have the proper permissions for each link in the chain as well +as the source bucket. For example, if there is a source bucket S, that is linked to bucket A, which is linked to bucket B, +and you want the token to be able to issue operations against linked bucket B, ensure that the role has read access to bucket B, +read access to bucket A, and the requisite access for bucket S (such as read on keys for GetObject, create/write on keys for PutObject, etc.). +The role must have at least read access to the volume(s) where these buckets live as well. + ## 3.4 SessionToken Format As mentioned above, one of the return values from the AssumeRole call will be the sessionToken. To support not @@ -139,17 +154,26 @@ 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()`) +- assumedRoleId - the generated identifier of the role from the AssumeRole call response (this is used for GetCallerIdentity api) +- assumedRoleUserArn - the arn from the AssumeRole call response (this is used for GetCallerIdentity api) ## 3.5 STS Token Revocation 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. @@ -196,14 +220,14 @@ The format of this String is entirely up to the Ranger team. What is required f subsequent S3 API calls are made that use STS tokens. In order to achieve this, the sessionPolicy String from Ranger will be included in the sessionToken response to the AssumeRole API call (as mentioned above), and Ozone will supply this String to Ranger whenever STS tokens are used on S3 API calls via a new `RequestContext.sessionPolicy` field in the -`IAccessAuthorizer#checkAccess(IOzoneObj, RequestContext)` call. Another requirement from the Ozone side is to pass the action (without the s3: prefix) corresponding to the S3 api call into the `RequestContext.s3Action` field. +`IAccessAuthorizer#checkAccess(IOzoneObj, RequestContext)` call. Another requirement from the Ozone side is to pass the action (without the s3: prefix) corresponding to the S3 API call into the `RequestContext.s3Action` field. ### 3.6.2 Additional Context on Permissions and Actions In a prior iteration of this design, only permissions corresponding to Ozone `ACLType` (i.e. read, write, create, read_acl, etc.) were included in Ranger roles and session policies. -However, after testing against AWS, it was found that ACLs used by Ozone and Ranger are not granular enough. For example, read on volume, read on bucket, and write on key can be used by either the S3 PutObjectTagging api (requiring `s3:PutObjectTagging` action) or the S3 DeleteObjectTagging api (requiring `s3:DeleteObjectTagging` action). -Similarly, because the S3 PutObject api (`s3:PutObject` action) requires read on volume, read on bucket, and create and write on key, someone with `s3:PutObject` access could previously also call the S3 PutObjectTagging api, even though they did not have access to the `s3:PutObjectTagging` action (as an example). -AWS does not allow an STS token that is restricted for one action to issue calls to an api that is associated with a different action. To prevent having more access than requested (or different access than requested), ACL permissions can be constrained further by S3 actions. +However, after testing against AWS, it was found that ACLs used by Ozone and Ranger are not granular enough. For example, read on volume, read on bucket, and write on key can be used by either the S3 PutObjectTagging API (requiring `s3:PutObjectTagging` action) or the S3 DeleteObjectTagging API (requiring `s3:DeleteObjectTagging` action). +Similarly, because the S3 PutObject API (`s3:PutObject` action) requires read on volume, read on bucket, and create and write on key, someone with `s3:PutObject` access could previously also call the S3 PutObjectTagging API, even though they did not have access to the `s3:PutObjectTagging` action (as an example). +AWS does not allow an STS token that is restricted for one action to issue calls to an API that is associated with a different action. To prevent having more access than requested (or different access than requested), ACL permissions can be constrained further by S3 actions. To do this constraining, the `RequestContext.s3Action` field is introduced so that if populated, the RangerOzoneAuthorizer would further restrict the permissions according to the action. Additionally, the OzoneGrant would contain a Set representing the S3 actions that are allowed for an inline policy. If all actions are allowed, then the Set would be empty or null. @@ -217,11 +241,12 @@ created in Ranger as per the Prerequisites above. - This authorized user (having permanent S3 credentials) makes the AssumeRole STS call to Ozone. - If successful, Ozone responds with the temporary credentials. - A client makes S3 API calls with the temporary credentials for up to as long as the credentials last. -- When Ozone receives an S3 api call using temporary credentials, it will use the Kerberos identity associated with the +- When Ozone receives an S3 API call using temporary credentials, it will use the Kerberos identity associated with the originalAccessKeyId in the session token and perform the following checks: - Ensure that if the accessKeyId starts with "ASIA", that a sessionToken was included in the `x-amz-security-token` header - Ensure the sessionToken is not expired - - 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 new file mode 100644 index 000000000000..9cd715d581a1 --- /dev/null +++ b/hadoop-ozone/cli-shell/src/main/java/org/apache/hadoop/ozone/shell/s3/RevokeSTSTokenHandler.java @@ -0,0 +1,73 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.hadoop.ozone.shell.s3; + +import java.io.IOException; +import java.io.InputStreamReader; +import java.nio.charset.StandardCharsets; +import java.util.Scanner; +import org.apache.hadoop.ozone.client.OzoneClient; +import org.apache.hadoop.ozone.shell.OzoneAddress; +import picocli.CommandLine.Command; +import picocli.CommandLine.Option; + +/** + * Executes revocation of STS tokens. + * + *

This command 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 tokens for the given original access key ID") +public class RevokeSTSTokenHandler extends S3Handler { + + @Option(names = {"-o", "--original-access-key-id"}, + required = true, + 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") + private boolean yes; + + @Override + protected boolean isApplicable() { + return securityEnabled(); + } + + @Override + protected void execute(OzoneClient client, OzoneAddress address) + throws IOException { + + if (!yes) { + out().print( + "Enter 'y' to confirm STS token revocation for originalAccessKeyId '" + originalAccessKeyId + "': "); + out().flush(); + final Scanner scanner = new Scanner(new InputStreamReader(System.in, StandardCharsets.UTF_8)); + final String confirmation = scanner.next().trim().toLowerCase(); + if (!"y".equals(confirmation)) { + out().println("Revoke STS token operation cancelled."); + return; + } + } + + client.getObjectStore().revokeSTSToken(originalAccessKeyId); + out().println("STS tokens revoked for originalAccessKeyId '" + originalAccessKeyId + "'."); + } +} diff --git a/hadoop-ozone/cli-shell/src/main/java/org/apache/hadoop/ozone/shell/s3/S3Shell.java b/hadoop-ozone/cli-shell/src/main/java/org/apache/hadoop/ozone/shell/s3/S3Shell.java index 19241ebdf453..088b6cef007a 100644 --- a/hadoop-ozone/cli-shell/src/main/java/org/apache/hadoop/ozone/shell/s3/S3Shell.java +++ b/hadoop-ozone/cli-shell/src/main/java/org/apache/hadoop/ozone/shell/s3/S3Shell.java @@ -28,7 +28,8 @@ subcommands = { GetS3SecretHandler.class, SetS3SecretHandler.class, - RevokeS3SecretHandler.class + RevokeS3SecretHandler.class, + RevokeSTSTokenHandler.class }) public class S3Shell extends Shell { diff --git a/hadoop-ozone/client/src/main/java/org/apache/hadoop/ozone/client/ObjectStore.java b/hadoop-ozone/client/src/main/java/org/apache/hadoop/ozone/client/ObjectStore.java index 791032a62058..2cc610d4007f 100644 --- a/hadoop-ozone/client/src/main/java/org/apache/hadoop/ozone/client/ObjectStore.java +++ b/hadoop-ozone/client/src/main/java/org/apache/hadoop/ozone/client/ObjectStore.java @@ -35,7 +35,9 @@ import org.apache.hadoop.ozone.OzoneFsServerDefaults; import org.apache.hadoop.ozone.client.protocol.ClientProtocol; import org.apache.hadoop.ozone.om.exceptions.OMException; +import org.apache.hadoop.ozone.om.helpers.AssumeRoleResponseInfo; import org.apache.hadoop.ozone.om.helpers.BucketLayout; +import org.apache.hadoop.ozone.om.helpers.CallerIdentityInfo; import org.apache.hadoop.ozone.om.helpers.DeleteTenantState; import org.apache.hadoop.ozone.om.helpers.OmVolumeArgs; import org.apache.hadoop.ozone.om.helpers.S3SecretValue; @@ -795,6 +797,40 @@ public Iterator listSnapshotDiffJobs( return new SnapshotDiffJobIterator(volumeName, bucketName, jobStatus, listAllStatus, prevSnapshotDiffJob); } + /** + * Process the AssumeRole operation. + * + * @param roleArn The ARN of the role to assume + * @param roleSessionName The session name (should be unique) for this operation + * @param durationSeconds The duration in seconds for the token validity + * @param awsIamSessionPolicy The AWS IAM JSON session policy + * @param requestId The requestId from the STS endpoint + * @return AssumeRoleResponseInfo The AssumeRole response information containing temporary credentials + * @throws IOException if an error occurs during the AssumeRole operation + */ + public AssumeRoleResponseInfo assumeRole(String roleArn, String roleSessionName, int durationSeconds, + String awsIamSessionPolicy, String requestId) throws IOException { + return proxy.assumeRole(roleArn, roleSessionName, durationSeconds, awsIamSessionPolicy, requestId); + } + + /** + * Returns the caller identity for the current S3-authenticated request. + * @return CallerIdentityInfo containing account, arn, and userId + * @throws IOException if an error occurs during the GetCallerIdentity operation + */ + public CallerIdentityInfo getCallerIdentity() throws IOException { + return proxy.getCallerIdentity(); + } + + /** + * Revokes STS tokens for the given original access key ID. + * @param originalAccessKeyId The original long-lived access key ID whose STS tokens to revoke + * @throws IOException if an error occurs while revoking the STS token + */ + public void revokeSTSToken(String originalAccessKeyId) throws IOException { + proxy.revokeSTSToken(originalAccessKeyId); + } + /** * An Iterator to iterate over {@link SnapshotDiffJobIterator} list. */ diff --git a/hadoop-ozone/client/src/main/java/org/apache/hadoop/ozone/client/OzoneBucket.java b/hadoop-ozone/client/src/main/java/org/apache/hadoop/ozone/client/OzoneBucket.java index 9b5f663b7f09..e3cbcce76f33 100644 --- a/hadoop-ozone/client/src/main/java/org/apache/hadoop/ozone/client/OzoneBucket.java +++ b/hadoop-ozone/client/src/main/java/org/apache/hadoop/ozone/client/OzoneBucket.java @@ -51,6 +51,7 @@ import org.apache.hadoop.ozone.client.io.OzoneInputStream; import org.apache.hadoop.ozone.client.io.OzoneOutputStream; import org.apache.hadoop.ozone.client.protocol.ClientProtocol; +import org.apache.hadoop.ozone.client.protocol.ListStatusLightOptions; import org.apache.hadoop.ozone.om.exceptions.OMException; import org.apache.hadoop.ozone.om.helpers.BasicOmKeyInfo; import org.apache.hadoop.ozone.om.helpers.BucketLayout; @@ -1688,9 +1689,19 @@ List getNextShallowListOfKeys(String prevKey) } // 2. Get immediate children by listStatusLight method - List statuses = - proxy.listStatusLight(volumeName, name, delimiterKeyPrefix, false, - startKey, listCacheSize, false); + // For STS auth, pass the original request prefix (if any) as listPrefix so OM + // checks LIST on that prefix instead of the internal traversal path. + final List statuses = proxy.listStatusLight( + ListStatusLightOptions.builder() + .setVolumeName(volumeName) + .setBucketName(name) + .setKeyName(delimiterKeyPrefix) + .setRecursive(false) + .setStartKey(startKey) + .setNumEntries(listCacheSize) + .setAllowPartialPrefixes(false) + .setListPrefix(getKeyPrefix()) + .build()); if (addedKeyPrefix && !statuses.isEmpty()) { // previous round already include the startKey, so remove it @@ -1929,9 +1940,19 @@ List getNextShallowListOfKeys(String prevKey) } // 2. Get immediate children by listStatus method. - List statuses = - proxy.listStatusLight(volumeName, name, getDelimiterKeyPrefix(), - false, startKey, listCacheSize, false); + // For STS auth, pass the original request prefix (if any) as listPrefix so OM + // checks LIST on that prefix instead of the internal traversal path. + List statuses = proxy.listStatusLight( + ListStatusLightOptions.builder() + .setVolumeName(volumeName) + .setBucketName(name) + .setKeyName(getDelimiterKeyPrefix()) + .setRecursive(false) + .setStartKey(startKey) + .setNumEntries(listCacheSize) + .setAllowPartialPrefixes(false) + .setListPrefix(getKeyPrefix()) + .build()); if (!statuses.isEmpty()) { // If findFirstStartKey is false, indicates that the keyPrefix is an @@ -2089,8 +2110,17 @@ private boolean getChildrenKeys(String keyPrefix, String startKey, startKey = startKey == null ? "" : startKey; // 1. Get immediate children of keyPrefix, starting with startKey - List statuses = proxy.listStatusLight(volumeName, - name, keyPrefix, false, startKey, listCacheSize, true); + List statuses = proxy.listStatusLight( + ListStatusLightOptions.builder() + .setVolumeName(volumeName) + .setBucketName(name) + .setKeyName(keyPrefix) + .setRecursive(false) + .setStartKey(startKey) + .setNumEntries(listCacheSize) + .setAllowPartialPrefixes(true) + .setListPrefix(getKeyPrefix()) + .build()); // 2. Special case: ListKey expects keyPrefix element should present in // the resultList, only if startKey is blank. If startKey is not blank diff --git a/hadoop-ozone/client/src/main/java/org/apache/hadoop/ozone/client/io/BlockDataStreamOutputEntryPool.java b/hadoop-ozone/client/src/main/java/org/apache/hadoop/ozone/client/io/BlockDataStreamOutputEntryPool.java index ff054c350956..be05a294dcfc 100644 --- a/hadoop-ozone/client/src/main/java/org/apache/hadoop/ozone/client/io/BlockDataStreamOutputEntryPool.java +++ b/hadoop-ozone/client/src/main/java/org/apache/hadoop/ozone/client/io/BlockDataStreamOutputEntryPool.java @@ -60,6 +60,7 @@ public class BlockDataStreamOutputEntryPool implements KeyMetadataAware { private final Map metadata = new HashMap<>(); private final XceiverClientFactory xceiverClientFactory; private OmMultipartCommitUploadPartInfo commitUploadPartInfo; + private long modificationTime; private final long openID; private final ExcludeList excludeList; private List bufferList; @@ -254,8 +255,9 @@ void commitKey(long offset) throws IOException { if (keyArgs.getIsMultipartKey()) { commitUploadPartInfo = omClient.commitMultipartUploadPart(buildKeyArgs(), openID); + modificationTime = commitUploadPartInfo.getModificationTime(); } else { - omClient.commitKey(buildKeyArgs(), openID); + modificationTime = omClient.commitKey(buildKeyArgs(), openID); } } else { LOG.warn("Closing KeyDataStreamOutput, but key args is null"); @@ -304,6 +306,10 @@ public OmMultipartCommitUploadPartInfo getCommitUploadPartInfo() { return commitUploadPartInfo; } + public long getModificationTime() { + return modificationTime; + } + public ExcludeList getExcludeList() { return excludeList; } diff --git a/hadoop-ozone/client/src/main/java/org/apache/hadoop/ozone/client/io/BlockOutputStreamEntryPool.java b/hadoop-ozone/client/src/main/java/org/apache/hadoop/ozone/client/io/BlockOutputStreamEntryPool.java index f3b98626f093..cef5a7ed056a 100644 --- a/hadoop-ozone/client/src/main/java/org/apache/hadoop/ozone/client/io/BlockOutputStreamEntryPool.java +++ b/hadoop-ozone/client/src/main/java/org/apache/hadoop/ozone/client/io/BlockOutputStreamEntryPool.java @@ -83,6 +83,7 @@ public class BlockOutputStreamEntryPool implements KeyMetadataAware { */ private final BufferPool bufferPool; private OmMultipartCommitUploadPartInfo commitUploadPartInfo; + private long modificationTime; private final long openID; private final ExcludeList excludeList; private final ContainerClientMetrics clientMetrics; @@ -329,8 +330,9 @@ void commitKey(long offset) throws IOException { if (keyArgs.getIsMultipartKey()) { commitUploadPartInfo = omClient.commitMultipartUploadPart(buildKeyArgs(), openID); + modificationTime = commitUploadPartInfo.getModificationTime(); } else { - omClient.commitKey(buildKeyArgs(), openID); + modificationTime = omClient.commitKey(buildKeyArgs(), openID); } } else { LOG.warn("Closing KeyOutputStream, but key args is null"); @@ -422,6 +424,10 @@ public OmMultipartCommitUploadPartInfo getCommitUploadPartInfo() { return commitUploadPartInfo; } + public long getModificationTime() { + return modificationTime; + } + public ExcludeList getExcludeList() { return excludeList; } diff --git a/hadoop-ozone/client/src/main/java/org/apache/hadoop/ozone/client/io/KeyDataStreamOutput.java b/hadoop-ozone/client/src/main/java/org/apache/hadoop/ozone/client/io/KeyDataStreamOutput.java index 119af2f04e5c..d3267923b48a 100644 --- a/hadoop-ozone/client/src/main/java/org/apache/hadoop/ozone/client/io/KeyDataStreamOutput.java +++ b/hadoop-ozone/client/src/main/java/org/apache/hadoop/ozone/client/io/KeyDataStreamOutput.java @@ -461,6 +461,10 @@ public OmMultipartCommitUploadPartInfo getCommitUploadPartInfo() { return blockDataStreamOutputEntryPool.getCommitUploadPartInfo(); } + public long getModificationTime() { + return blockDataStreamOutputEntryPool.getModificationTime(); + } + @VisibleForTesting public ExcludeList getExcludeList() { return blockDataStreamOutputEntryPool.getExcludeList(); diff --git a/hadoop-ozone/client/src/main/java/org/apache/hadoop/ozone/client/io/KeyOutputStream.java b/hadoop-ozone/client/src/main/java/org/apache/hadoop/ozone/client/io/KeyOutputStream.java index a3a1ca28030b..7ce64f1c2b6c 100644 --- a/hadoop-ozone/client/src/main/java/org/apache/hadoop/ozone/client/io/KeyOutputStream.java +++ b/hadoop-ozone/client/src/main/java/org/apache/hadoop/ozone/client/io/KeyOutputStream.java @@ -664,6 +664,10 @@ private void closeInternal() throws IOException { return blockOutputStreamEntryPool.getCommitUploadPartInfo(); } + public long getModificationTime() { + return blockOutputStreamEntryPool.getModificationTime(); + } + @VisibleForTesting public ExcludeList getExcludeList() { return blockOutputStreamEntryPool.getExcludeList(); diff --git a/hadoop-ozone/client/src/main/java/org/apache/hadoop/ozone/client/io/OzoneDataStreamOutput.java b/hadoop-ozone/client/src/main/java/org/apache/hadoop/ozone/client/io/OzoneDataStreamOutput.java index 9d87150b2b44..6ab706c6b058 100644 --- a/hadoop-ozone/client/src/main/java/org/apache/hadoop/ozone/client/io/OzoneDataStreamOutput.java +++ b/hadoop-ozone/client/src/main/java/org/apache/hadoop/ozone/client/io/OzoneDataStreamOutput.java @@ -103,7 +103,7 @@ public synchronized void close() throws IOException { } public OmMultipartCommitUploadPartInfo getCommitUploadPartInfo() { - KeyCommitOutput keyCommitOutput = getKeyCommitOutput(); + final KeyCommitOutput keyCommitOutput = getKeyCommitOutput(); if (keyCommitOutput != null) { return keyCommitOutput.getCommitUploadPartInfo(); } @@ -111,6 +111,14 @@ public OmMultipartCommitUploadPartInfo getCommitUploadPartInfo() { return null; } + public long getModificationTime() { + final KeyDataStreamOutput keyDataStreamOutput = getKeyDataStreamOutput(); + if (keyDataStreamOutput != null) { + return keyDataStreamOutput.getModificationTime(); + } + throw new IllegalStateException("OutputStream is not a KeyDataStreamOutput: " + byteBufferStreamOutput.getClass()); + } + public KeyDataStreamOutput getKeyDataStreamOutput() { if (byteBufferStreamOutput instanceof KeyDataStreamOutput) { return ((KeyDataStreamOutput) byteBufferStreamOutput); diff --git a/hadoop-ozone/client/src/main/java/org/apache/hadoop/ozone/client/io/OzoneOutputStream.java b/hadoop-ozone/client/src/main/java/org/apache/hadoop/ozone/client/io/OzoneOutputStream.java index 38dbff8c5fdf..3988c850e888 100644 --- a/hadoop-ozone/client/src/main/java/org/apache/hadoop/ozone/client/io/OzoneOutputStream.java +++ b/hadoop-ozone/client/src/main/java/org/apache/hadoop/ozone/client/io/OzoneOutputStream.java @@ -140,6 +140,14 @@ public OmMultipartCommitUploadPartInfo getCommitUploadPartInfo() { return null; } + public long getModificationTime() { + final KeyOutputStream keyOutputStream = getKeyOutputStream(); + if (keyOutputStream != null) { + return keyOutputStream.getModificationTime(); + } + throw new IllegalStateException("OutputStream is not a KeyOutputStream: " + outputStream.getClass()); + } + public OutputStream getOutputStream() { return outputStream; } @@ -150,7 +158,7 @@ public KeyOutputStream getKeyOutputStream() { } public void setPreCommits(List> preCommits) { - KeyCommitOutput keyCommitOutput = getKeyCommitOutput(); + final KeyCommitOutput keyCommitOutput = getKeyCommitOutput(); if (keyCommitOutput != null) { keyCommitOutput.setPreCommits(preCommits); return; 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 0c115fe8f616..84b49e1b57e0 100644 --- a/hadoop-ozone/client/src/main/java/org/apache/hadoop/ozone/client/protocol/ClientProtocol.java +++ b/hadoop-ozone/client/src/main/java/org/apache/hadoop/ozone/client/protocol/ClientProtocol.java @@ -48,6 +48,8 @@ import org.apache.hadoop.ozone.client.io.OzoneOutputStream; import org.apache.hadoop.ozone.om.OMConfigKeys; import org.apache.hadoop.ozone.om.exceptions.OMException; +import org.apache.hadoop.ozone.om.helpers.AssumeRoleResponseInfo; +import org.apache.hadoop.ozone.om.helpers.CallerIdentityInfo; import org.apache.hadoop.ozone.om.helpers.DeleteTenantState; import org.apache.hadoop.ozone.om.helpers.ErrorInfo; import org.apache.hadoop.ozone.om.helpers.LeaseKeyInfo; @@ -1241,6 +1243,16 @@ List listStatus(String volumeName, String bucketName, /** * Lightweight listStatus API. * + * @param options Encapsulates volume, bucket, key, recursive, startKey, + * numEntries, allowPartialPrefixes, and optional listPrefix. + * @return list of file status + */ + List listStatusLight(ListStatusLightOptions options) + throws IOException; + + /** + * Lightweight listStatus API (convenience overload without listPrefix). + * * @param volumeName Volume name * @param bucketName Bucket name * @param keyName Absolute path of the entry to be listed @@ -1253,9 +1265,12 @@ List listStatus(String volumeName, String bucketName, * this is needed in context of ListKeys * @return list of file status */ - List listStatusLight(String volumeName, + default List listStatusLight(String volumeName, String bucketName, String keyName, boolean recursive, String startKey, - long numEntries, boolean allowPartialPrefixes) throws IOException; + long numEntries, boolean allowPartialPrefixes) throws IOException { + return listStatusLight(ListStatusLightOptions.of(volumeName, bucketName, + keyName, recursive, startKey, numEntries, allowPartialPrefixes)); + } /** * Add acl for Ozone object. Return true if acl is added successfully else @@ -1632,6 +1647,34 @@ void putObjectTagging(String volumeName, String bucketName, String keyName, void deleteObjectTagging(String volumeName, String bucketName, String keyName) throws IOException; + /** + * Process the AssumeRole operation. + * + * @param roleArn The ARN of the role to assume + * @param roleSessionName The session name (should be unique) for this operation + * @param durationSeconds The duration in seconds for the token validity + * @param awsIamSessionPolicy The AWS IAM JSON session policy + * @param requestId The requestId from the STS endpoint + * @return AssumeRoleResponseInfo The AssumeRole response information containing temporary credentials + * @throws IOException if an error occurs during the AssumeRole operation + */ + AssumeRoleResponseInfo assumeRole(String roleArn, String roleSessionName, int durationSeconds, + String awsIamSessionPolicy, String requestId) throws IOException; + + /** + * Returns the caller identity for the current S3-authenticated request. + * @return CallerIdentityInfo containing account, arn, and userId + * @throws IOException if an error occurs during the GetCallerIdentity operation + */ + CallerIdentityInfo getCallerIdentity() throws IOException; + + /** + * Revokes STS tokens for the given original access key ID. + * @param originalAccessKeyId The original long-lived access key ID whose STS tokens to revoke + * @throws IOException if an error occurs while revoking the STS token + */ + void revokeSTSToken(String originalAccessKeyId) throws IOException; + /** * Gets the lifecycle configuration information. * @param volumeName - Volume name. diff --git a/hadoop-ozone/client/src/main/java/org/apache/hadoop/ozone/client/protocol/ListStatusLightOptions.java b/hadoop-ozone/client/src/main/java/org/apache/hadoop/ozone/client/protocol/ListStatusLightOptions.java new file mode 100644 index 000000000000..28fff17bbf59 --- /dev/null +++ b/hadoop-ozone/client/src/main/java/org/apache/hadoop/ozone/client/protocol/ListStatusLightOptions.java @@ -0,0 +1,182 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.hadoop.ozone.client.protocol; + +import java.util.Objects; + +/** + * Options for {@link ClientProtocol#listStatusLight(ListStatusLightOptions)}. + * Encapsulates all parameters to allow future extensibility without breaking + * the method signature. + */ +public final class ListStatusLightOptions { + + private final String volumeName; + private final String bucketName; + private final String keyName; + private final boolean recursive; + private final String startKey; + private final long numEntries; + private final boolean allowPartialPrefixes; + // When keyName is empty (root listing), this is the original S3/list + // prefix for STS auth. Enables LIST check on this prefix instead of "*". + private final String listPrefix; + + private ListStatusLightOptions(Builder b) { + this.volumeName = b.volumeName; + this.bucketName = b.bucketName; + this.keyName = b.keyName; + this.recursive = b.recursive; + this.startKey = b.startKey; + this.numEntries = b.numEntries; + this.allowPartialPrefixes = b.allowPartialPrefixes; + this.listPrefix = b.listPrefix; + } + + public String getVolumeName() { + return volumeName; + } + + public String getBucketName() { + return bucketName; + } + + public String getKeyName() { + return keyName; + } + + public boolean isRecursive() { + return recursive; + } + + public String getStartKey() { + return startKey; + } + + public long getNumEntries() { + return numEntries; + } + + public boolean isAllowPartialPrefixes() { + return allowPartialPrefixes; + } + + public String getListPrefix() { + return listPrefix; + } + + public static Builder builder() { + return new Builder(); + } + + /** + * Convenience factory for the common case (no listPrefix). + */ + public static ListStatusLightOptions of(String volumeName, String bucketName, + String keyName, boolean recursive, String startKey, long numEntries, + boolean allowPartialPrefixes) { + return builder() + .setVolumeName(volumeName) + .setBucketName(bucketName) + .setKeyName(keyName) + .setRecursive(recursive) + .setStartKey(startKey) + .setNumEntries(numEntries) + .setAllowPartialPrefixes(allowPartialPrefixes) + .build(); + } + + /** + * Builder for ListStatusLightOptions. + */ + public static final class Builder { + private String volumeName; + private String bucketName; + private String keyName; + private boolean recursive; + private String startKey; + private long numEntries; + private boolean allowPartialPrefixes; + private String listPrefix; + + public Builder setVolumeName(String volumeName) { + this.volumeName = volumeName; + return this; + } + + public Builder setBucketName(String bucketName) { + this.bucketName = bucketName; + return this; + } + + public Builder setKeyName(String keyName) { + this.keyName = keyName; + return this; + } + + public Builder setRecursive(boolean recursive) { + this.recursive = recursive; + return this; + } + + public Builder setStartKey(String startKey) { + this.startKey = startKey; + return this; + } + + public Builder setNumEntries(long numEntries) { + this.numEntries = numEntries; + return this; + } + + public Builder setAllowPartialPrefixes(boolean allowPartialPrefixes) { + this.allowPartialPrefixes = allowPartialPrefixes; + return this; + } + + public Builder setListPrefix(String listPrefix) { + this.listPrefix = listPrefix; + return this; + } + + public ListStatusLightOptions build() { + return new ListStatusLightOptions(this); + } + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + final ListStatusLightOptions that = (ListStatusLightOptions) o; + return recursive == that.recursive && numEntries == that.numEntries && + allowPartialPrefixes == that.allowPartialPrefixes && Objects.equals(volumeName, that.volumeName) && + Objects.equals(bucketName, that.bucketName) && Objects.equals(keyName, that.keyName) && + Objects.equals(startKey, that.startKey) && Objects.equals(listPrefix, that.listPrefix); + } + + @Override + public int hashCode() { + return Objects.hash( + volumeName, bucketName, keyName, recursive, startKey, numEntries, allowPartialPrefixes, listPrefix); + } +} diff --git a/hadoop-ozone/client/src/main/java/org/apache/hadoop/ozone/client/rpc/RpcClient.java b/hadoop-ozone/client/src/main/java/org/apache/hadoop/ozone/client/rpc/RpcClient.java index 9be0128dea6c..9c121bde4c89 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 @@ -129,11 +129,14 @@ import org.apache.hadoop.ozone.client.io.OzoneInputStream; import org.apache.hadoop.ozone.client.io.OzoneOutputStream; import org.apache.hadoop.ozone.client.protocol.ClientProtocol; +import org.apache.hadoop.ozone.client.protocol.ListStatusLightOptions; import org.apache.hadoop.ozone.om.OmConfig; import org.apache.hadoop.ozone.om.exceptions.OMException; +import org.apache.hadoop.ozone.om.helpers.AssumeRoleResponseInfo; import org.apache.hadoop.ozone.om.helpers.BasicOmKeyInfo; import org.apache.hadoop.ozone.om.helpers.BucketEncryptionKeyInfo; import org.apache.hadoop.ozone.om.helpers.BucketLayout; +import org.apache.hadoop.ozone.om.helpers.CallerIdentityInfo; import org.apache.hadoop.ozone.om.helpers.DeleteTenantState; import org.apache.hadoop.ozone.om.helpers.ErrorInfo; import org.apache.hadoop.ozone.om.helpers.KeyInfoWithVolumeContext; @@ -2507,14 +2510,17 @@ public OzoneOutputStream createFile(String volumeName, String bucketName, } private OmKeyArgs prepareOmKeyArgs(String volumeName, String bucketName, - String keyName) { - return new OmKeyArgs.Builder() + String keyName, String listPrefix) { + final OmKeyArgs.Builder builder = new OmKeyArgs.Builder() .setVolumeName(volumeName) .setBucketName(bucketName) .setKeyName(keyName) .setSortDatanodesInPipeline(topologyAwareReadEnabled) - .setLatestVersionLocation(getLatestVersionLocation) - .build(); + .setLatestVersionLocation(getLatestVersionLocation); + if (listPrefix != null && !listPrefix.isEmpty()) { + builder.setListPrefix(listPrefix); + } + return builder.build(); } @Override @@ -2542,7 +2548,8 @@ public OzoneDataStreamOutput createStreamFile(String volumeName, public List listStatus(String volumeName, String bucketName, String keyName, boolean recursive, String startKey, long numEntries) throws IOException { - OmKeyArgs keyArgs = prepareOmKeyArgs(volumeName, bucketName, keyName); + final OmKeyArgs keyArgs = prepareOmKeyArgs(volumeName, bucketName, keyName, + null); return ozoneManagerClient .listStatus(keyArgs, recursive, startKey, numEntries); } @@ -2551,23 +2558,25 @@ public List listStatus(String volumeName, String bucketName, public List listStatus(String volumeName, String bucketName, String keyName, boolean recursive, String startKey, long numEntries, boolean allowPartialPrefixes) throws IOException { - OmKeyArgs keyArgs = prepareOmKeyArgs(volumeName, bucketName, keyName); + final OmKeyArgs keyArgs = prepareOmKeyArgs(volumeName, bucketName, keyName, null); return ozoneManagerClient .listStatus(keyArgs, recursive, startKey, numEntries, allowPartialPrefixes); } @Override - public List listStatusLight(String volumeName, - String bucketName, String keyName, boolean recursive, String startKey, - long numEntries, boolean allowPartialPrefixes) throws IOException { - OmKeyArgs keyArgs = prepareOmKeyArgs(volumeName, bucketName, keyName); + public List listStatusLight(ListStatusLightOptions options) + throws IOException { + final OmKeyArgs keyArgs = prepareOmKeyArgs( + options.getVolumeName(), options.getBucketName(), options.getKeyName(), options.getListPrefix()); if (omVersion.compareTo(OzoneManagerVersion.LIGHTWEIGHT_LIST_STATUS) >= 0) { - return ozoneManagerClient.listStatusLight(keyArgs, recursive, startKey, - numEntries, allowPartialPrefixes); + return ozoneManagerClient.listStatusLight( + keyArgs, options.isRecursive(), options.getStartKey(), options.getNumEntries(), + options.isAllowPartialPrefixes()); } else { - return ozoneManagerClient.listStatus(keyArgs, recursive, startKey, - numEntries, allowPartialPrefixes) + return ozoneManagerClient.listStatus( + keyArgs, options.isRecursive(), options.getStartKey(), options.getNumEntries(), + options.isAllowPartialPrefixes()) .stream() .map(OzoneFileStatusLight::fromOzoneFileStatus) .collect(Collectors.toList()); @@ -3028,6 +3037,22 @@ public void deleteObjectTagging(String volumeName, String bucketName, ozoneManagerClient.deleteObjectTagging(keyArgs); } + @Override + public AssumeRoleResponseInfo assumeRole(String roleArn, String roleSessionName, int durationSeconds, + String awsIamSessionPolicy, String requestId) throws IOException { + return ozoneManagerClient.assumeRole(roleArn, roleSessionName, durationSeconds, awsIamSessionPolicy, requestId); + } + + @Override + public CallerIdentityInfo getCallerIdentity() throws IOException { + return ozoneManagerClient.getCallerIdentity(); + } + + @Override + public void revokeSTSToken(String originalAccessKeyId) throws IOException { + ozoneManagerClient.revokeSTSToken(originalAccessKeyId); + } + @Override public OzoneLifecycleConfiguration getLifecycleConfiguration(String volumeName, String bucketName) throws IOException { diff --git a/hadoop-ozone/common/pom.xml b/hadoop-ozone/common/pom.xml index 79039d218d72..75740bd1380e 100644 --- a/hadoop-ozone/common/pom.xml +++ b/hadoop-ozone/common/pom.xml @@ -31,6 +31,10 @@ com.fasterxml.jackson.core jackson-annotations + + com.fasterxml.jackson.core + jackson-core + com.fasterxml.jackson.core jackson-databind diff --git a/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/OmUtils.java b/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/OmUtils.java index 9e9622c64112..31530900cf2c 100644 --- a/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/OmUtils.java +++ b/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/OmUtils.java @@ -238,6 +238,7 @@ public static boolean isReadOnly(OMRequest omRequest) { case FinalizeUpgradeProgress: case PrepareStatus: case GetS3VolumeContext: + case GetCallerIdentity: case ListTenant: case TenantGetUserInfo: case TenantListUser: @@ -285,6 +286,7 @@ public static boolean isReadOnly(OMRequest omRequest) { case CompleteMultiPartUpload: case AbortMultiPartUpload: case GetS3Secret: + case AssumeRole: case GetDelegationToken: case RenewDelegationToken: case CancelDelegationToken: @@ -305,6 +307,7 @@ public static boolean isReadOnly(OMRequest omRequest) { case DeleteOpenKeys: case SetS3Secret: case RevokeS3Secret: + case RevokeSTSToken: case PurgeDirectories: case PurgePaths: case CreateTenant: @@ -327,6 +330,7 @@ public static boolean isReadOnly(OMRequest omRequest) { case QuotaRepair: case PutObjectTagging: case DeleteObjectTagging: + case DeleteRevokedSTSTokens: case PutBucketTagging: case DeleteBucketTagging: case SetLifecycleConfiguration: @@ -380,6 +384,7 @@ public static boolean shouldSendToFollower(OMRequest omRequest) { case FinalizeUpgradeProgress: case PrepareStatus: case GetS3VolumeContext: + case GetCallerIdentity: case ListTenant: case TenantGetUserInfo: case TenantListUser: @@ -453,6 +458,9 @@ public static boolean shouldSendToFollower(OMRequest omRequest) { case QuotaRepair: case PutObjectTagging: case DeleteObjectTagging: + case AssumeRole: + case RevokeSTSToken: + case DeleteRevokedSTSTokens: case PutBucketTagging: case DeleteBucketTagging: case ServiceList: // OM leader should have the most up-to-date OM service list info @@ -494,7 +502,7 @@ public static byte[] getSHADigest() throws IOException { "This could possibly indicate a faulty JRE"); } } - + /** * Get a collection of all active omNodeIds (excluding decommissioned nodes) * for the given omServiceId. diff --git a/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/om/OMConfigKeys.java b/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/om/OMConfigKeys.java index d1b1dd038ef0..5852e06ea754 100644 --- a/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/om/OMConfigKeys.java +++ b/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/om/OMConfigKeys.java @@ -787,6 +787,16 @@ public final class OMConfigKeys { "ozone.om.snapshot.local.data.manager.service.interval"; public static final String OZONE_OM_SNAPSHOT_LOCAL_DATA_MANAGER_SERVICE_INTERVAL_DEFAULT = "5m"; + public static final String OZONE_OM_STS_TOKEN_CLEANUP_SERVICE_INTERVAL + = "ozone.om.sts.token.cleanup.service.interval"; + public static final String OZONE_OM_STS_TOKEN_CLEANUP_SERVICE_INTERVAL_DEFAULT + = "3h"; + + public static final String OZONE_OM_STS_TOKEN_CLEANUP_SERVICE_TIMEOUT + = "ozone.om.sts.token.cleanup.service.timeout"; + public static final String OZONE_OM_STS_TOKEN_CLEANUP_SERVICE_TIMEOUT_DEFAULT + = "15m"; + public static final String OZONE_OM_RATIS_EVENTS_MAX_LIMIT = "ozone.om.ratis.events.max.limit"; public static final int OZONE_OM_RATIS_EVENTS_MAX_LIMIT_DEFAULT = 100; diff --git a/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/om/exceptions/OMException.java b/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/om/exceptions/OMException.java index b7fcf8c5e355..7a7e1879b5ba 100644 --- a/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/om/exceptions/OMException.java +++ b/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/om/exceptions/OMException.java @@ -283,6 +283,8 @@ public enum ResultCodes { ATOMIC_WRITE_CONFLICT, LIFECYCLE_CONFIGURATION_NOT_FOUND, - UPDATE_ID_NOT_MATCH + UPDATE_ID_NOT_MATCH, + REVOKED_TOKEN, + MALFORMED_POLICY_DOCUMENT } } diff --git a/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/om/helpers/AssumeRoleResponseInfo.java b/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/om/helpers/AssumeRoleResponseInfo.java new file mode 100644 index 000000000000..ae674bfcfb2b --- /dev/null +++ b/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/om/helpers/AssumeRoleResponseInfo.java @@ -0,0 +1,109 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.hadoop.ozone.om.helpers; + +import java.util.Objects; +import net.jcip.annotations.Immutable; +import org.apache.hadoop.ozone.protocol.proto.OzoneManagerProtocolProtos.AssumeRoleResponse; + +/** + * Utility class to handle AssumeRoleResponse protobuf message. + */ +@Immutable +public class AssumeRoleResponseInfo { + + private final String accessKeyId; + private final String secretAccessKey; + private final String sessionToken; + private final long expirationEpochSeconds; + private final String assumedRoleId; + + public String getAccessKeyId() { + return accessKeyId; + } + + public String getSecretAccessKey() { + return secretAccessKey; + } + + public String getSessionToken() { + return sessionToken; + } + + public long getExpirationEpochSeconds() { + return expirationEpochSeconds; + } + + public String getAssumedRoleId() { + return assumedRoleId; + } + + public AssumeRoleResponseInfo(String accessKeyId, String secretAccessKey, String sessionToken, + long expirationEpochSeconds, String assumedRoleId) { + this.accessKeyId = accessKeyId; + this.secretAccessKey = secretAccessKey; + this.sessionToken = sessionToken; + this.expirationEpochSeconds = expirationEpochSeconds; + this.assumedRoleId = assumedRoleId; + } + + public static AssumeRoleResponseInfo fromProtobuf(AssumeRoleResponse response) { + return new AssumeRoleResponseInfo( + response.getAccessKeyId(), response.getSecretAccessKey(), response.getSessionToken(), + response.getExpirationEpochSeconds(), response.getAssumedRoleId()); + } + + public AssumeRoleResponse getProtobuf() { + return AssumeRoleResponse.newBuilder() + .setAccessKeyId(accessKeyId) + .setSecretAccessKey(secretAccessKey) + .setSessionToken(sessionToken) + .setExpirationEpochSeconds(expirationEpochSeconds) + .setAssumedRoleId(assumedRoleId) + .build(); + } + + @Override + public String toString() { + // Intentionally left off secretAccessKey + return "AssumeRoleResponseInfo{" + "accessKeyId='" + accessKeyId + "'" + + ", sessionToken='" + sessionToken + "', expirationEpochSeconds=" + expirationEpochSeconds + + ", assumedRoleId='" + assumedRoleId + "'}"; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + + if (o == null || getClass() != o.getClass()) { + return false; + } + + final AssumeRoleResponseInfo that = (AssumeRoleResponseInfo) o; + return expirationEpochSeconds == that.expirationEpochSeconds && Objects.equals(accessKeyId, that.accessKeyId) && + Objects.equals(secretAccessKey, that.secretAccessKey) && Objects.equals(sessionToken, that.sessionToken) && + Objects.equals(assumedRoleId, that.assumedRoleId); + } + + @Override + public int hashCode() { + return Objects.hash(accessKeyId, secretAccessKey, sessionToken, expirationEpochSeconds, assumedRoleId); + } +} diff --git a/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/om/helpers/AwsRoleArnValidator.java b/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/om/helpers/AwsRoleArnValidator.java new file mode 100644 index 000000000000..a60a514c674d --- /dev/null +++ b/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/om/helpers/AwsRoleArnValidator.java @@ -0,0 +1,146 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.hadoop.ozone.om.helpers; + +import com.google.common.base.Strings; +import org.apache.hadoop.ozone.om.exceptions.OMException; + +/** + * Validator for AWS IAM Role ARNs and extracts the role name from them. + */ +public final class AwsRoleArnValidator { + + private static final int ASSUME_ROLE_NAME_MAX_LENGTH = 64; + private static final int ASSUME_ROLE_ARN_MIN_LENGTH = 20; + private static final int ASSUME_ROLE_ARN_MAX_LENGTH = 2048; + + private AwsRoleArnValidator() { + } + + /** + * Extract the role name from an AWS-style role ARN, falling back to the + * full ARN if parsing is not possible. + * Examples: + *
{@code
+   * arn:aws:iam::123456789012:role/RoleA -> RoleA
+   * arn:aws:iam::123456789012:role/path/RoleB -> RoleB
+   * }
+ * + * @param roleArn the AWS role ARN to validate and extract from + * @return the extracted role name + * @throws OMException if the ARN is invalid + */ + public static String validateAndExtractRoleNameFromArn(String roleArn) throws OMException { + if (Strings.isNullOrEmpty(roleArn)) { + throw new OMException( + "Value null at 'roleArn' failed to satisfy constraint: Member must not be null", + OMException.ResultCodes.INVALID_REQUEST); + } + + final int roleArnLength = roleArn.length(); + if (roleArnLength < ASSUME_ROLE_ARN_MIN_LENGTH || roleArnLength > ASSUME_ROLE_ARN_MAX_LENGTH) { + throw new OMException( + "Role ARN length must be between " + ASSUME_ROLE_ARN_MIN_LENGTH + " and " + + ASSUME_ROLE_ARN_MAX_LENGTH, OMException.ResultCodes.INVALID_REQUEST); + } + + // Expected format: arn:aws:iam::123456789012:role/[optional path segments/]RoleName + if (!roleArn.startsWith("arn:aws:iam::")) { + throw new OMException( + "Invalid role ARN (does not start with arn:aws:iam::): " + roleArn, OMException.ResultCodes.INVALID_REQUEST); + } + + // Split ARN into parts: arn:aws:iam::accountId:role/path/name + final String[] parts = roleArn.split(":", 6); + if (parts.length < 6 || !parts[5].startsWith("role/")) { + throw new OMException( + "Invalid role ARN (unexpected field count): " + roleArn, OMException.ResultCodes.INVALID_REQUEST); + } + + // Validate account ID (12 digits) + final String accountId = parts[4]; + if (accountId.length() != 12 || !isAllDigits(accountId)) { + throw new OMException("Invalid AWS account ID in ARN", OMException.ResultCodes.INVALID_REQUEST); + } + + // Extract role name (last segment after last slash) + final String rolePath = parts[5].substring(5); // Skip "role/" + if (rolePath.isEmpty() || rolePath.endsWith("/")) { + throw new OMException("Invalid role ARN: missing role name", OMException.ResultCodes.INVALID_REQUEST); + } + + final String[] pathSegments = rolePath.split("/"); + final String roleName = pathSegments[pathSegments.length - 1]; + + // Validate role name + if (roleName.isEmpty() || roleName.length() > ASSUME_ROLE_NAME_MAX_LENGTH || + hasCharNotAllowedInIamRoleArn(roleName)) { + throw new OMException("Invalid role name: " + roleName, OMException.ResultCodes.INVALID_REQUEST); + } + + // Validate path segments if present + if (pathSegments.length > 1) { + final String pathPrefix = rolePath.substring(0, rolePath.lastIndexOf('/') + 1); + if (pathPrefix.length() > 511) { + throw new OMException( + "Role path length must be between 1 and 512 characters", OMException.ResultCodes.INVALID_REQUEST); + } + for (String segment : pathSegments) { + if (segment.isEmpty() || hasCharNotAllowedInIamRoleArn(segment)) { + throw new OMException("Invalid role path segment: " + segment, OMException.ResultCodes.INVALID_REQUEST); + } + } + } + + return roleName; + } + + /** + * Checks if all the characters in a String are numbers. + */ + private static boolean isAllDigits(String s) { + for (int i = 0; i < s.length(); i++) { + if (!Character.isDigit(s.charAt(i))) { + return false; + } + } + return true; + } + + /** + * Checks if supplied string contains a char that is not allowed in IAM Role ARN. + */ + private static boolean hasCharNotAllowedInIamRoleArn(String s) { + for (int i = 0; i < s.length(); i++) { + if (!isCharAllowedInIamRoleArn(s.codePointAt(i))) { + return true; + } + } + return false; + } + + /** + * Checks if the supplied char is allowed in IAM Role ARN. + * Pattern: [\u0009\u000A\u000D\u0020-\u007E\u0085\u00A0-\uD7FF\uE000-\uFFFD\u10000-\u10FFFF]+ + */ + private static boolean isCharAllowedInIamRoleArn(int c) { + return c == 0x09 || c == 0x0A || c == 0x0D || (c >= 0x20 && c <= 0x7E) || c == 0x85 || (c >= 0xA0 && c <= 0xD7FF) || + (c >= 0xE000 && c <= 0xFFFD) || (c >= 0x10000 && c <= 0x10FFFF); + } +} + diff --git a/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/om/helpers/CallerIdentityInfo.java b/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/om/helpers/CallerIdentityInfo.java new file mode 100644 index 000000000000..5a014f19fcfd --- /dev/null +++ b/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/om/helpers/CallerIdentityInfo.java @@ -0,0 +1,88 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.hadoop.ozone.om.helpers; + +import java.util.Objects; +import net.jcip.annotations.Immutable; +import org.apache.hadoop.ozone.protocol.proto.OzoneManagerProtocolProtos.GetCallerIdentityResponse; + +/** + * Utility class to handle GetCallerIdentityResponse protobuf message. + */ +@Immutable +public class CallerIdentityInfo { + + private final String account; + private final String arn; + private final String userId; + + public CallerIdentityInfo(String account, String arn, String userId) { + this.account = account; + this.arn = arn; + this.userId = userId; + } + + public String getAccount() { + return account; + } + + public String getArn() { + return arn; + } + + public String getUserId() { + return userId; + } + + public static CallerIdentityInfo fromProtobuf(GetCallerIdentityResponse response) { + return new CallerIdentityInfo(response.getAccount(), response.getArn(), response.getUserId()); + } + + public GetCallerIdentityResponse getProtobuf() { + return GetCallerIdentityResponse.newBuilder() + .setAccount(account) + .setArn(arn) + .setUserId(userId) + .build(); + } + + @Override + public String toString() { + return "CallerIdentityInfo{" + "account='" + account + "', arn='" + arn + "', userId='" + userId + "'}"; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + + if (o == null || getClass() != o.getClass()) { + return false; + } + + final CallerIdentityInfo that = (CallerIdentityInfo) o; + return Objects.equals(account, that.account) && Objects.equals(arn, that.arn) && + Objects.equals(userId, that.userId); + } + + @Override + public int hashCode() { + return Objects.hash(account, arn, userId); + } +} diff --git a/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/om/helpers/OmKeyArgs.java b/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/om/helpers/OmKeyArgs.java index 5c55d7b9b2eb..03ff4bd8f615 100644 --- a/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/om/helpers/OmKeyArgs.java +++ b/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/om/helpers/OmKeyArgs.java @@ -61,6 +61,9 @@ public final class OmKeyArgs extends WithMetadata implements Auditable { // This allows a key to be created an committed atomically if the original has not // been modified. private Long expectedDataGeneration = null; + // Original S3/list prefix when keyName is empty (root listing). Used for STS + // auth to check LIST on this prefix instead of "*". + private final String listPrefix; private final String expectedETag; private final boolean derivedKeyPiggyBacking; @@ -84,6 +87,7 @@ private OmKeyArgs(Builder b) { this.ownerName = b.ownerName; this.tags = b.tags.build(); this.expectedDataGeneration = b.expectedDataGeneration; + this.listPrefix = b.listPrefix; this.expectedETag = b.expectedETag; this.derivedKeyPiggyBacking = b.derivedKeyPiggyBacking; } @@ -168,6 +172,14 @@ public Long getExpectedDataGeneration() { return expectedDataGeneration; } + /** + * Original S3/list prefix when keyName is empty (root listing). + * Used for STS auth to check LIST on this prefix instead of "*". + */ + public String getListPrefix() { + return listPrefix; + } + public String getExpectedETag() { return expectedETag; } @@ -249,6 +261,7 @@ public static class Builder extends WithMetadata.Builder { private boolean forceUpdateContainerCacheFromSCM; private final MapBuilder tags; private Long expectedDataGeneration = null; + private String listPrefix = null; private String expectedETag; private boolean derivedKeyPiggyBacking; @@ -300,6 +313,7 @@ public Builder(OmKeyArgs obj) { this.derivedKeyPiggyBacking = obj.derivedKeyPiggyBacking; this.tags = MapBuilder.of(obj.tags); this.acls = AclListBuilder.of(obj.acls); + this.listPrefix = obj.listPrefix; } public Builder setVolumeName(String volume) { @@ -433,6 +447,11 @@ public Builder setExpectedDataGeneration(long generation) { return this; } + public Builder setListPrefix(String prefix) { + this.listPrefix = prefix; + return this; + } + public Builder setExpectedETag(String eTag) { this.expectedETag = eTag; return this; diff --git a/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/om/helpers/OmMultipartCommitUploadPartInfo.java b/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/om/helpers/OmMultipartCommitUploadPartInfo.java index 93774a82dc5a..0c072b65277f 100644 --- a/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/om/helpers/OmMultipartCommitUploadPartInfo.java +++ b/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/om/helpers/OmMultipartCommitUploadPartInfo.java @@ -27,9 +27,12 @@ public class OmMultipartCommitUploadPartInfo { private final String eTag; - public OmMultipartCommitUploadPartInfo(String partName, String eTag) { + private final long modificationTime; + + public OmMultipartCommitUploadPartInfo(String partName, String eTag, long modificationTime) { this.partName = partName; this.eTag = eTag; + this.modificationTime = modificationTime; } public String getETag() { @@ -39,4 +42,8 @@ public String getETag() { public String getPartName() { return partName; } + + public long getModificationTime() { + return modificationTime; + } } diff --git a/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/om/helpers/S3STSUtils.java b/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/om/helpers/S3STSUtils.java new file mode 100644 index 000000000000..17bf084f9b31 --- /dev/null +++ b/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/om/helpers/S3STSUtils.java @@ -0,0 +1,208 @@ +/* + * 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.apache.hadoop.ozone.om.exceptions.OMException.ResultCodes.INVALID_REQUEST; + +import com.google.common.base.Strings; +import java.util.Map; +import net.jcip.annotations.Immutable; +import org.apache.hadoop.ozone.om.exceptions.OMException; + +/** + * Utility class containing constants and validation methods shared by STS endpoint and OzoneManager processing. + */ +@Immutable +public final class S3STSUtils { + // STS API constants + public static final int DEFAULT_DURATION_SECONDS = 3600; // 1 hour + public static final int MAX_DURATION_SECONDS = 43200; // 12 hours + public static final int MIN_DURATION_SECONDS = 900; // 15 minutes + + public static final int ASSUME_ROLE_SESSION_NAME_MIN_LENGTH = 2; + public static final int ASSUME_ROLE_SESSION_NAME_MAX_LENGTH = 64; + + // AWS limit for session policy is 2048 characters + public static final int MAX_SESSION_POLICY_LENGTH = 2048; + + 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 String OZONE_STATIC_ACCOUNT_ID = "123456789012"; + + private S3STSUtils() { + } + + /** + * Builds an IAM user ARN for the given Kerberos short name. + */ + public static String toIamUserArn(String kerberosShortName) { + return "arn:aws:iam::" + OZONE_STATIC_ACCOUNT_ID + ":user/" + kerberosShortName; + } + + /** + * Resolves the caller identity for GetCallerIdentity with permanent S3 credentials. + * + * @param resolvedPrincipal full Kerberos principal of the caller + * @param kerberosShortName short username + * @return caller identity with account, arn, and userId + */ + public static CallerIdentityInfo resolveCallerIdentityForPermanentCredentials(String resolvedPrincipal, + String kerberosShortName) { + return new CallerIdentityInfo(OZONE_STATIC_ACCOUNT_ID, toIamUserArn(kerberosShortName), resolvedPrincipal); + } + + /** + * Resolves the caller identity for GetCallerIdentity with temporary STS credentials. + * + * @param assumedRoleId assumed role ID from the STS token + * @param assumedRoleUserArn assumed role user ARN from the STS token + * @return caller identity with account, arn, and userId + */ + public static CallerIdentityInfo resolveCallerIdentityForStsCredentials(String assumedRoleId, + String assumedRoleUserArn) { + return new CallerIdentityInfo(OZONE_STATIC_ACCOUNT_ID, assumedRoleUserArn, assumedRoleId); + } + + /** + * Adds standard AssumeRole audit params. + */ + public static void addAssumeRoleAuditParams(Map auditParams, String roleArn, String roleSessionName, + String awsIamSessionPolicy, int duration, String requestId) { + + auditParams.put("action", "AssumeRole"); + auditParams.put("roleArn", roleArn); + auditParams.put("roleSessionName", roleSessionName); + auditParams.put("duration", String.valueOf(duration)); + auditParams.put("isPolicyIncluded", Strings.isNullOrEmpty(awsIamSessionPolicy) ? "N" : "Y"); + auditParams.put("requestId", requestId); + } + + /** + * Validates the duration in seconds. + * @param durationSeconds duration in seconds + * @return validated duration + * @throws OMException if duration is invalid + */ + public static int validateDuration(Integer durationSeconds) throws OMException { + if (durationSeconds == null) { + return DEFAULT_DURATION_SECONDS; + } + + if (durationSeconds < MIN_DURATION_SECONDS || durationSeconds > MAX_DURATION_SECONDS) { + throw new OMException( + "Invalid Value: DurationSeconds must be between " + MIN_DURATION_SECONDS + " and " + MAX_DURATION_SECONDS + + " seconds", INVALID_REQUEST); + } + + return durationSeconds; + } + + /** + * Validates the role session name. + * @param roleSessionName role session name + * @throws OMException if role session name is invalid + */ + public static void validateRoleSessionName(String roleSessionName) throws OMException { + if (Strings.isNullOrEmpty(roleSessionName)) { + throw new OMException( + "Value null at 'roleSessionName' failed to satisfy constraint: Member must not be null", INVALID_REQUEST); + } + + final int roleSessionNameLength = roleSessionName.length(); + if (roleSessionNameLength < ASSUME_ROLE_SESSION_NAME_MIN_LENGTH || + roleSessionNameLength > ASSUME_ROLE_SESSION_NAME_MAX_LENGTH) { + throw new OMException("Invalid RoleSessionName length " + roleSessionNameLength + ": it must be " + + ASSUME_ROLE_SESSION_NAME_MIN_LENGTH + "-" + ASSUME_ROLE_SESSION_NAME_MAX_LENGTH + " characters long and " + + "contain only alphanumeric characters and +, =, ,, ., @, -", INVALID_REQUEST); + } + + // AWS allows: alphanumeric, +, =, ,, ., @, - + // Pattern: [\w+=,.@-]* + // Don't use regex for performance reasons + for (int i = 0; i < roleSessionNameLength; i++) { + final char c = roleSessionName.charAt(i); + if (!isRoleSessionNameChar(c)) { + throw new OMException("Invalid character '" + c + "' in RoleSessionName: it must be " + + ASSUME_ROLE_SESSION_NAME_MIN_LENGTH + "-" + ASSUME_ROLE_SESSION_NAME_MAX_LENGTH + " characters long and " + + "contain only alphanumeric characters and +, =, ,, ., @, -", INVALID_REQUEST); + } + } + } + + private static boolean isRoleSessionNameChar(char c) { + return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || + c == '_' || c == '+' || c == '=' || c == ',' || c == '.' || c == '@' || c == '-'; + } + + /** + * Validates the session policy length. + * @param awsIamSessionPolicy session policy + * @throws OMException if policy length is invalid + */ + public static void validateSessionPolicy(String awsIamSessionPolicy) throws OMException { + if (awsIamSessionPolicy != null && awsIamSessionPolicy.length() > MAX_SESSION_POLICY_LENGTH) { + throw new OMException( + "Value '" + awsIamSessionPolicy + "' at 'policy' failed to satisfy constraint: Member " + + "must have length less than or equal to " + MAX_SESSION_POLICY_LENGTH, INVALID_REQUEST); + } + } + + /** + * Generates the assumed role user ARN. + * @param validRoleArn valid role ARN + * @param roleSessionName role session name + * @return assumed role user ARN + */ + public static String toAssumedRoleUserArn(String validRoleArn, String roleSessionName) { + // We already know the roleArn is valid, so perform the conversion for assumed role user arn format + // RoleArn format: arn:aws:iam:::role/ + // Assumed role user arn format: arn:aws:sts:::assumed-role// + final String[] parts = splitRoleArnWithoutRegex(validRoleArn); + + final String partition = parts[1]; + final String accountId = parts[4]; + final String resource = parts[5]; + final String roleName = resource.substring("role/".length()); + + //noinspection StringBufferReplaceableByString + final StringBuilder stringBuilder = new StringBuilder("arn:") + .append(partition) + .append(":sts::") + .append(accountId) + .append(":assumed-role/") + .append(roleName) + .append('/') + .append(roleSessionName); + return stringBuilder.toString(); + } + + private static String[] splitRoleArnWithoutRegex(String roleArn) { + final String[] parts = new String[6]; + int start = 0; + for (int i = 0; i < 5; i++) { + final int end = roleArn.indexOf(':', start); + parts[i] = roleArn.substring(start, end); + start = end + 1; + } + parts[5] = roleArn.substring(start); + return parts; + } +} diff --git a/hadoop-ozone/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 00ebff2a6e15..7cea19d7a909 100644 --- a/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/om/protocol/OzoneManagerProtocol.java +++ b/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/om/protocol/OzoneManagerProtocol.java @@ -29,6 +29,8 @@ import org.apache.hadoop.ozone.om.IOmMetadataReader; import org.apache.hadoop.ozone.om.OMConfigKeys; import org.apache.hadoop.ozone.om.exceptions.OMException; +import org.apache.hadoop.ozone.om.helpers.AssumeRoleResponseInfo; +import org.apache.hadoop.ozone.om.helpers.CallerIdentityInfo; import org.apache.hadoop.ozone.om.helpers.DBUpdates; import org.apache.hadoop.ozone.om.helpers.DeleteTenantState; import org.apache.hadoop.ozone.om.helpers.ErrorInfo; @@ -245,9 +247,10 @@ default OpenKeySession openKey(OmKeyArgs args) throws IOException { * * @param args the key to commit * @param clientID the client identification + * @return the modification time of the committed key in epoch milliseconds * @throws IOException */ - default void commitKey(OmKeyArgs args, long clientID) + default long commitKey(OmKeyArgs args, long clientID) throws IOException { throw new UnsupportedOperationException("OzoneManager does not require " + "this to be implemented, as write requests use a new approach."); @@ -1316,4 +1319,38 @@ default void resumeLifecycleService() throws IOException { throw new UnsupportedOperationException("OzoneManager does not require " + "this to be implemented, as write requests use a new approach."); } + + /** + * Process the AssumeRole operation. + * + * @param roleArn The ARN of the role to assume + * @param roleSessionName The session name (should be unique) for this operation + * @param durationSeconds The duration in seconds for the token validity + * @param awsIamSessionPolicy The AWS IAM JSON session policy + * @param requestId The requestId from the STS endpoint + * @return AssumeRoleResponseInfo The AssumeRole response information containing temporary credentials + * @throws IOException if an error occurs during the AssumeRole operation + */ + default AssumeRoleResponseInfo assumeRole(String roleArn, String roleSessionName, int durationSeconds, + String awsIamSessionPolicy, String requestId) throws IOException { + throw new UnsupportedOperationException("OzoneManager does not require this to be implemented"); + } + + /** + * Returns the caller identity for the current S3-authenticated request. + * @return CallerIdentityInfo containing account, arn, and userId + * @throws IOException if an error occurs during the GetCallerIdentity operation + */ + default CallerIdentityInfo getCallerIdentity() throws IOException { + throw new UnsupportedOperationException("OzoneManager does not require this to be implemented"); + } + + /** + * Revokes STS tokens for the given original access key ID. + * @param originalAccessKeyId The original long-lived access key ID whose STS tokens to revoke + * @throws IOException if an error occurs while revoking the STS token + */ + 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 84acade8f9a5..577339c96ac3 100644 --- a/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/om/protocol/S3Auth.java +++ b/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/om/protocol/S3Auth.java @@ -27,6 +27,10 @@ public class S3Auth { public static final String S3_AUTH_CHECK = "ozone.s3.auth.check"; // User principal to be used for KMS encryption and decryption private String userPrincipal; + // Optional STS session token when using temporary credentials + private String sessionToken; + // S3 action without s3: prefix (e.g. PutObject), set by S3 Gateway for use in finer-grained STS permissions. + private String s3Action; public S3Auth(final String stringToSign, final String signature, @@ -57,4 +61,20 @@ public String getUserPrincipal() { public void setUserPrincipal(String userPrincipal) { this.userPrincipal = userPrincipal; } + + public String getSessionToken() { + return sessionToken; + } + + public void setSessionToken(String sessionToken) { + this.sessionToken = sessionToken; + } + + public String getS3Action() { + return s3Action; + } + + public void setS3Action(String s3Action) { + this.s3Action = s3Action; + } } diff --git a/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/om/protocolPB/OzoneManagerProtocolClientSideTranslatorPB.java b/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/om/protocolPB/OzoneManagerProtocolClientSideTranslatorPB.java index 30f32ba2e9d5..b5a9e3a2bbcf 100644 --- a/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/om/protocolPB/OzoneManagerProtocolClientSideTranslatorPB.java +++ b/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/om/protocolPB/OzoneManagerProtocolClientSideTranslatorPB.java @@ -58,7 +58,9 @@ import org.apache.hadoop.ozone.ClientVersion; import org.apache.hadoop.ozone.OzoneAcl; import org.apache.hadoop.ozone.om.exceptions.OMException; +import org.apache.hadoop.ozone.om.helpers.AssumeRoleResponseInfo; import org.apache.hadoop.ozone.om.helpers.BasicOmKeyInfo; +import org.apache.hadoop.ozone.om.helpers.CallerIdentityInfo; import org.apache.hadoop.ozone.om.helpers.DBUpdates; import org.apache.hadoop.ozone.om.helpers.DeleteTenantState; import org.apache.hadoop.ozone.om.helpers.ErrorInfo; @@ -264,6 +266,7 @@ import org.apache.hadoop.ozone.upgrade.UpgradeFinalization.StatusAndMessages; import org.apache.hadoop.ozone.util.ProtobufUtils; import org.apache.hadoop.security.token.Token; +import org.apache.hadoop.util.Time; /** * The client side implementation of OzoneManagerProtocol. @@ -331,15 +334,24 @@ private OMResponse submitRequest(OMRequest omRequest) OMRequest.Builder builder = OMRequest.newBuilder(omRequest); // Insert S3 Authentication information for each request. if (getThreadLocalS3Auth() != null) { - builder.setS3Authentication( + final S3Authentication.Builder s3AuthBuilder = S3Authentication.newBuilder() .setSignature( threadLocalS3Auth.get().getSignature()) .setStringToSign( threadLocalS3Auth.get().getStringTosSign()) .setAccessId( - threadLocalS3Auth.get().getAccessID()) - .build()); + threadLocalS3Auth.get().getAccessID()); + + // Include STS session token if present so OM can validate it + if (threadLocalS3Auth.get().getSessionToken() != null) { + s3AuthBuilder.setSessionToken(threadLocalS3Auth.get().getSessionToken()); + } + if (threadLocalS3Auth.get().getS3Action() != null) { + s3AuthBuilder.setS3Action(threadLocalS3Auth.get().getS3Action()); + } + + builder.setS3Authentication(s3AuthBuilder.build()); } if (s3AuthCheck && getThreadLocalS3Auth() == null) { throw new IllegalArgumentException("S3 Auth expected to " + @@ -831,9 +843,9 @@ public void hsyncKey(OmKeyArgs args, long clientId) } @Override - public void commitKey(OmKeyArgs args, long clientId) + public long commitKey(OmKeyArgs args, long clientId) throws IOException { - updateKey(args, clientId, false, false); + return updateKeyAndGetModificationTime(args, clientId, false, false); } @Override @@ -857,6 +869,12 @@ public static void setReplicationConfig(ReplicationConfig replication, private void updateKey(OmKeyArgs args, long clientId, boolean hsync, boolean recovery) throws IOException { + // Preserve legacy behavior (ignore response payload). + updateKeyAndGetModificationTime(args, clientId, hsync, recovery); + } + + private long updateKeyAndGetModificationTime(OmKeyArgs args, long clientId, boolean hsync, boolean recovery) + throws IOException { CommitKeyRequest.Builder req = CommitKeyRequest.newBuilder(); List locationInfoList = args.getLocationInfoList(); Objects.requireNonNull(locationInfoList, "locationInfoList == null"); @@ -882,7 +900,11 @@ private void updateKey(OmKeyArgs args, long clientId, boolean hsync, boolean rec .setCommitKeyRequest(req) .build(); - handleError(submitRequest(omRequest)); + final OMResponse resp = handleError(submitRequest(omRequest)); + if (resp.hasCommitKeyResponse() && resp.getCommitKeyResponse().hasModificationTime()) { + return resp.getCommitKeyResponse().getModificationTime(); + } + return Time.now(); } @Override @@ -1809,10 +1831,8 @@ public OmMultipartCommitUploadPartInfo commitMultipartUploadPart( handleError(submitRequest(omRequest)) .getCommitMultiPartUploadResponse(); - OmMultipartCommitUploadPartInfo info = new - OmMultipartCommitUploadPartInfo(response.getPartName(), - response.getETag()); - return info; + final long modificationTime = response.hasModificationTime() ? response.getModificationTime() : Time.now(); + return new OmMultipartCommitUploadPartInfo(response.getPartName(), response.getETag(), modificationTime); } @Override @@ -2487,8 +2507,8 @@ public List listStatus(OmKeyArgs args, boolean recursive, .setLatestVersionLocation(args.getLatestVersionLocation()) .build(); - ListStatusRequest.Builder listStatusRequestBuilder = createListStatusRequestBuilder(keyArgs, recursive, startKey, - numEntries, allowPartialPrefixes); + final ListStatusRequest.Builder listStatusRequestBuilder = createListStatusRequestBuilder( + keyArgs, recursive, startKey, numEntries, allowPartialPrefixes); OMRequest omRequest = createOMRequest(Type.ListStatus) .setListStatusRequest(listStatusRequestBuilder.build()) @@ -2516,8 +2536,12 @@ public List listStatusLight(OmKeyArgs args, .setLatestVersionLocation(true) .build(); - ListStatusRequest.Builder listStatusRequestBuilder = createListStatusRequestBuilder(keyArgs, recursive, startKey, - numEntries, allowPartialPrefixes); + final ListStatusRequest.Builder listStatusRequestBuilder = createListStatusRequestBuilder( + keyArgs, recursive, startKey, numEntries, allowPartialPrefixes); + final String listPrefix = args.getListPrefix(); + if (listPrefix != null && !listPrefix.isEmpty()) { + listStatusRequestBuilder.setListPrefix(listPrefix); + } OMRequest omRequest = createOMRequest(Type.ListStatusLight) .setListStatusRequest(listStatusRequestBuilder.build()) @@ -2938,6 +2962,48 @@ public void deleteBucketTagging(OmBucketArgs args) throws IOException { handleError(submitRequest(omRequest)); } + @Override + public AssumeRoleResponseInfo assumeRole(String roleArn, String roleSessionName, int durationSeconds, + String awsIamSessionPolicy, String requestId) throws IOException { + final OzoneManagerProtocolProtos.AssumeRoleRequest.Builder request = + OzoneManagerProtocolProtos.AssumeRoleRequest.newBuilder() + .setRoleArn(roleArn) + .setRoleSessionName(roleSessionName) + .setDurationSeconds(durationSeconds) + .setAwsIamSessionPolicy(awsIamSessionPolicy != null ? awsIamSessionPolicy : "") + .setRequestId(requestId); + + final OMRequest omRequest = createOMRequest(Type.AssumeRole) + .setAssumeRoleRequest(request) + .build(); + + return AssumeRoleResponseInfo.fromProtobuf( + handleError(submitRequest(omRequest)).getAssumeRoleResponse()); + } + + @Override + public CallerIdentityInfo getCallerIdentity() throws IOException { + final OMRequest omRequest = createOMRequest(Type.GetCallerIdentity) + .setGetCallerIdentityRequest(OzoneManagerProtocolProtos.GetCallerIdentityRequest.newBuilder().build()) + .build(); + + return CallerIdentityInfo.fromProtobuf(handleError(submitRequest(omRequest)).getGetCallerIdentityResponse()); + } + + @Override + public void revokeSTSToken(String originalAccessKeyId) throws IOException { + final OzoneManagerProtocolProtos.RevokeSTSTokenRequest request = + OzoneManagerProtocolProtos.RevokeSTSTokenRequest.newBuilder() + .setOriginalAccessKeyId(originalAccessKeyId) + .build(); + + final OMRequest omRequest = createOMRequest(Type.RevokeSTSToken) + .setRevokeSTSTokenRequest(request) + .build(); + + handleError(submitRequest(omRequest)); + } + private SafeMode toProtoBuf(SafeModeAction action) { switch (action) { case ENTER: diff --git a/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/security/acl/iam/IamSessionPolicyResolver.java b/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/security/acl/iam/IamSessionPolicyResolver.java new file mode 100644 index 000000000000..6dcf468f6b7e --- /dev/null +++ b/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/security/acl/iam/IamSessionPolicyResolver.java @@ -0,0 +1,1231 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.hadoop.ozone.security.acl.iam; + +import static java.util.Collections.singleton; +import static org.apache.hadoop.ozone.om.exceptions.OMException.ResultCodes.INTERNAL_ERROR; +import static org.apache.hadoop.ozone.om.exceptions.OMException.ResultCodes.MALFORMED_POLICY_DOCUMENT; +import static org.apache.hadoop.ozone.om.exceptions.OMException.ResultCodes.NOT_SUPPORTED_OPERATION; +import static org.apache.hadoop.ozone.security.acl.IAccessAuthorizer.ACLType.CREATE; +import static org.apache.hadoop.ozone.security.acl.IAccessAuthorizer.ACLType.DELETE; +import static org.apache.hadoop.ozone.security.acl.IAccessAuthorizer.ACLType.LIST; +import static org.apache.hadoop.ozone.security.acl.IAccessAuthorizer.ACLType.READ; +import static org.apache.hadoop.ozone.security.acl.IAccessAuthorizer.ACLType.READ_ACL; +import static org.apache.hadoop.ozone.security.acl.IAccessAuthorizer.ACLType.WRITE; +import static org.apache.hadoop.ozone.security.acl.IAccessAuthorizer.ACLType.WRITE_ACL; + +import com.fasterxml.jackson.core.JsonParser; +import com.fasterxml.jackson.core.JsonToken; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.google.common.annotations.VisibleForTesting; +import com.google.common.base.Preconditions; +import java.io.IOException; +import java.util.Collections; +import java.util.EnumSet; +import java.util.HashSet; +import java.util.Iterator; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import org.apache.commons.lang3.StringUtils; +import org.apache.hadoop.ozone.om.exceptions.OMException; +import org.apache.hadoop.ozone.security.acl.AssumeRoleRequest; +import org.apache.hadoop.ozone.security.acl.IAccessAuthorizer.ACLType; +import org.apache.hadoop.ozone.security.acl.IOzoneObj; +import org.apache.hadoop.ozone.security.acl.OzoneObj; +import org.apache.hadoop.ozone.security.acl.OzoneObjInfo; + +/** + * Resolves a limited subset of AWS IAM session policies into Ozone ACL grants, + * according to either the RangerOzoneAuthorizer or OzoneNativeAuthorizer constructs. + *

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

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

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

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

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

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

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

+ * Supported wildcard expansions in Actions are: s3:*, s3:Get*, s3:Put*, s3:List*, + * s3:Create*, and s3:Delete*. + */ +public final class IamSessionPolicyResolver { + + private static final ObjectMapper MAPPER = new ObjectMapper(); + + private static final String AWS_S3_ARN_PREFIX = "arn:aws:s3:::"; + + // JSON length is limited per AWS policy. See https://docs.aws.amazon.com/STS/latest/APIReference/API_AssumeRole.html + // under Policy section. + private static final int MAX_JSON_LENGTH = 2048; + + // Used to group actions into s3:Get*, s3:Put*, s3:List*, s3:Delete*, s3:Create* + private static final String[] S3_ACTION_PREFIXES = {"s3:Get", "s3:Put", "s3:List", "s3:Delete", "s3:Create"}; + + private static final String ERROR_PREFIX = "IAM session policy: "; + private static final String STRING_EQUALS = "StringEquals"; + private static final String STRING_LIKE = "StringLike"; + + @VisibleForTesting + static final Map> S3_ACTION_MAP_CI = buildCaseInsensitiveS3ActionMap(); + + private IamSessionPolicyResolver() { + } + + /** + * Resolves an S3 IAM session policy in the form of a JSON String to a data structure comprising + * the IOzoneObjs and permissions that IAM policy grants (if any). + *

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

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

+ * + * @param policyJson the IAM session policy + * @param volumeName the volume under which the resource(s) live. This may not be s3v in + * multi-tenant scenarios + * @param authorizerType whether the IOzoneObjs should be generated for use by the + * RangerOzoneAuthorizer or the OzoneNativeAuthorizer + * @return the data structure comprising the paths and permission pairings that + * the session policy grants (if any) + * @throws OMException if the policy JSON is invalid, malformed, or contains unsupported features + */ + public static Set resolve(String policyJson, String volumeName, + AuthorizerType authorizerType) throws OMException { + + validateInputParameters(policyJson, volumeName, authorizerType); + + // Accumulate ACLs across ALL statements using a single map to allow + // cross-statement deduplication and ALL-permission collapsing. + final Map> objToAclsMap = new LinkedHashMap<>(); + final Map> objToActionsMap = new LinkedHashMap<>(); + + // Parse JSON into set of statements + final Set statements = parseJsonAndRetrieveStatements(policyJson); + + for (JsonNode stmt : statements) { + validateSupportedStatementFields(stmt); + validateEffectInJsonStatement(stmt); + + final Set actions = readRequiredStringOrArray(stmt.get("Action"), "Action"); + final Set resources = readRequiredStringOrArray(stmt.get("Resource"), "Resource"); + + // Parse prefixes from conditions, if any + final Condition condition = parsePrefixesFromConditions(stmt); + + // An empty s3:prefix array matches no prefixes and therefore grants no access (AWS behavior). + if (condition != null && condition.prefixes != null + && condition.prefixes.isEmpty()) { + continue; + } + + // Map actions to S3Action enum if possible + final Set mappedS3Actions = mapPolicyActionsToS3Actions(actions); + if (mappedS3Actions.isEmpty()) { + // No actions recognized - no need to look at Resources for this Statement + continue; + } + + // s3:prefix is only applicable to the ListBucket action because we don't support ListBucketVersions + // (see https://docs.aws.amazon.com/service-authorization/latest/reference/list_amazons3.html and search for + // s3:prefix). If a statement carries a Condition, non-ListBucket actions (ex GetObject, PutObject, + // ListBucketMultipartUploads, etc.) in that statement do not apply. + final Set filteredS3Actions = filterActionsWhenConditionPresent(mappedS3Actions, condition); + if (filteredS3Actions.isEmpty()) { + continue; + } + + // Categorize resources according to bucket resource, object resource, etc + final Set resourceSpecs = validateAndCategorizeResources(authorizerType, resources); + + // For each action, map to Ozone objects (paths) and acls based on resource specs and prefixes + createPathsAndPermissions( + volumeName, authorizerType, filteredS3Actions, resourceSpecs, condition, objToAclsMap, objToActionsMap); + } + + // Group accumulated objects by their ACL sets and S3 actions to create final result + return groupObjectsByAclsAndActions(objToAclsMap, objToActionsMap); + } + + /** + * Ensures required input parameters are supplied. + */ + private static void validateInputParameters(String policyJson, String volumeName, + AuthorizerType authorizerType) throws OMException { + if (StringUtils.isBlank(policyJson)) { + throw new OMException(ERROR_PREFIX + "The IAM session policy JSON is required", INTERNAL_ERROR); + } + + if (StringUtils.isBlank(volumeName)) { + throw new OMException(ERROR_PREFIX + "The volume name is required", INTERNAL_ERROR); + } + + Objects.requireNonNull(authorizerType, "The authorizer type is required"); + + if (policyJson.length() > MAX_JSON_LENGTH) { + throw new OMException( + ERROR_PREFIX + "Invalid policy JSON - exceeds maximum length of " + MAX_JSON_LENGTH + " characters", + MALFORMED_POLICY_DOCUMENT); + } + } + + /** + * Parses IAM session policy and retrieve the statement(s). + */ + private static Set parseJsonAndRetrieveStatements(String policyJson) throws OMException { + // Jackson's tree model silently collapses duplicate keys (last value wins), which could let a caller smuggle + // broader permissions than intended. Detect them up front so we can reject them with the exact field name. + checkForDuplicateFields(policyJson); + + final JsonNode root; + try { + root = MAPPER.readTree(policyJson); + } catch (Exception e) { + throw new OMException( + ERROR_PREFIX + "Invalid policy JSON (most likely JSON structure is incorrect)", e, MALFORMED_POLICY_DOCUMENT); + } + + final JsonNode statementsNode = root.path("Statement"); + if (statementsNode.isMissingNode()) { + throw new OMException(ERROR_PREFIX + "Invalid policy JSON - missing Statement", MALFORMED_POLICY_DOCUMENT); + } + + final Set statements = new HashSet<>(); + + if (statementsNode.isArray()) { + statementsNode.forEach(statements::add); + } else { + statements.add(statementsNode); + } + if (statements.isEmpty()) { + throw new OMException(ERROR_PREFIX + "No Statement(s) found in policy", MALFORMED_POLICY_DOCUMENT); + } + return statements; + } + + /** + * Detects duplicate JSON object keys at any nesting level in a single streaming pass, reporting the offending + * field name directly. Structural JSON problems are ignored here and surfaced by the subsequent tree parse. + */ + private static void checkForDuplicateFields(String policyJson) throws OMException { + try (JsonParser parser = MAPPER.getFactory().createParser(policyJson)) { + checkForDuplicateFields(parser); + } catch (OMException e) { + throw e; + } catch (IOException e) { + // Structural JSON problems are surfaced by the subsequent tree parse with a clearer message. + } + } + + private static void checkForDuplicateFields(JsonParser parser) throws IOException { + JsonToken token = parser.currentToken(); + if (token == null) { + token = parser.nextToken(); + } + + if (token == JsonToken.START_OBJECT) { + final Set fieldNames = new HashSet<>(); + while (parser.nextToken() == JsonToken.FIELD_NAME) { + final String fieldName = parser.currentName(); + if (!fieldNames.add(fieldName)) { + throw new OMException( + ERROR_PREFIX + "Duplicate field '" + fieldName + "' in session policy", MALFORMED_POLICY_DOCUMENT); + } + parser.nextToken(); + checkForDuplicateFields(parser); + } + } else if (token == JsonToken.START_ARRAY) { + JsonToken element; + while ((element = parser.nextToken()) != null && element != JsonToken.END_ARRAY) { + checkForDuplicateFields(parser); + } + } + } + + /** + * Ensures statements contain only the IAM policy elements supported by the STS session policy subset. + */ + private static void validateSupportedStatementFields(JsonNode statement) throws OMException { + if (!statement.isObject()) { + throw new OMException( + ERROR_PREFIX + "Invalid Statement in JSON policy (must be an Object) - " + statement, + MALFORMED_POLICY_DOCUMENT); + } + + final Iterator fieldNames = statement.fieldNames(); + while (fieldNames.hasNext()) { + final String fieldName = fieldNames.next(); + if (!isSupportedStatementField(fieldName)) { + throw new OMException( + ERROR_PREFIX + "Unsupported statement element - " + fieldName, MALFORMED_POLICY_DOCUMENT); + } + } + } + + private static boolean isSupportedStatementField(String fieldName) { + switch (fieldName) { + case "Sid": + case "Effect": + case "Action": + case "Resource": + case "Condition": + return true; + default: + return false; + } + } + + /** + * Parses Effect from IAM session policy and ensures it is valid and supported. + */ + private static void validateEffectInJsonStatement(JsonNode statement) throws OMException { + final JsonNode effectNode = statement.get("Effect"); + if (effectNode != null) { + if (effectNode.isTextual()) { + final String effect = effectNode.asText(); + if (!"Allow".equals(effect)) { + throw new OMException(ERROR_PREFIX + "Unsupported Effect - " + effect, NOT_SUPPORTED_OPERATION); + } + return; + } + + throw new OMException( + ERROR_PREFIX + "Invalid Effect in JSON policy (must be a String) - " + effectNode, MALFORMED_POLICY_DOCUMENT); + } + + throw new OMException(ERROR_PREFIX + "Effect is missing from JSON policy", MALFORMED_POLICY_DOCUMENT); + } + + /** + * Reads a required String or String array JSON policy element. + */ + private static Set readRequiredStringOrArray(JsonNode node, String fieldName) throws OMException { + if (node == null || node.isMissingNode() || node.isNull()) { + throw new OMException(ERROR_PREFIX + "No " + fieldName + "(s) found in policy", MALFORMED_POLICY_DOCUMENT); + } + if (node.isTextual()) { + return Collections.singleton(node.asText()); + } + if (node.isArray()) { + final Set set = new HashSet<>(); + for (JsonNode n : node) { + if (!n.isTextual()) { + throw invalidStringOrArray(fieldName, node); + } + set.add(n.asText()); + } + if (set.isEmpty()) { + throw new OMException(ERROR_PREFIX + "No " + fieldName + "(s) found in policy", MALFORMED_POLICY_DOCUMENT); + } + return set; + } + + throw invalidStringOrArray(fieldName, node); + } + + private static OMException invalidStringOrArray(String fieldName, JsonNode node) { + return new OMException( + ERROR_PREFIX + "Invalid " + fieldName + " in JSON policy (must be a String or Array of Strings) - " + node, + MALFORMED_POLICY_DOCUMENT); + } + + /** + * Reads and validates an s3:prefix condition value per AWS IAM session policy behavior. + *

+ * Rejects {@code null}, objects (such as {@code {}}), and arrays whose elements are not all + * strings or not all numbers/booleans. Scalar numbers and booleans are coerced to strings + * (for example {@code 123} becomes {@code "123"}). An empty array matches no prefixes and + * causes the statement to grant no access. + */ + private static Set readConditionPrefixValue(JsonNode node) throws OMException { + if (node == null || node.isMissingNode() || node.isNull()) { + throw invalidConditionPrefixValue(node); + } + if (node.isTextual() || node.isNumber() || node.isBoolean()) { + return Collections.singleton(node.asText()); + } + if (node.isArray()) { + if (node.isEmpty()) { + return Collections.emptySet(); + } + boolean allTextual = true; + boolean allNumber = true; + boolean allBoolean = true; + for (final JsonNode element : node) { + if (!element.isTextual()) { + allTextual = false; + } + if (!element.isNumber()) { + allNumber = false; + } + if (!element.isBoolean()) { + allBoolean = false; + } + } + if (!allTextual && !allNumber && !allBoolean) { + throw invalidConditionPrefixValue(node); + } + final Set prefixes = new HashSet<>(); + node.forEach(n -> prefixes.add(n.asText())); + return prefixes; + } + + throw invalidConditionPrefixValue(node); + } + + private static OMException invalidConditionPrefixValue(JsonNode node) { + return new OMException( + ERROR_PREFIX + "Invalid s3:prefix in Condition (must be a String, Number, Boolean, or homogeneous " + + "Array of Strings, Numbers, or Booleans) - " + node, + MALFORMED_POLICY_DOCUMENT); + } + + /** + * Parses and returns prefixes from Conditions (if any). Also validates + * that if there is a Condition, there is only one and that the Condition + * operator and key name are supported. + *

+ * Only the StringEquals and StringLike operators and s3:prefix key name are supported. + */ + private static Condition parsePrefixesFromConditions(JsonNode stmt) throws OMException { + Condition condition = null; + final JsonNode cond = stmt.get("Condition"); + if (cond != null && !cond.isMissingNode() && !cond.isNull()) { + if (cond.size() != 1) { + throw new OMException(ERROR_PREFIX + "Only one Condition is supported", NOT_SUPPORTED_OPERATION); + } + + if (!cond.isObject()) { + throw new OMException( + ERROR_PREFIX + "Invalid Condition (must have operator " + STRING_EQUALS + " or " + STRING_LIKE + + " and key name s3:prefix) - " + cond, MALFORMED_POLICY_DOCUMENT); + } + + final String operator = cond.fieldNames().next(); + if (!STRING_EQUALS.equals(operator) && !STRING_LIKE.equals(operator)) { + throw new OMException(ERROR_PREFIX + "Unsupported Condition operator - " + operator, NOT_SUPPORTED_OPERATION); + } + + final JsonNode operatorValue = cond.get(operator); + if ("null".equals(operatorValue.asText())) { + throw new OMException( + ERROR_PREFIX + "Missing Condition operator value for " + operator, MALFORMED_POLICY_DOCUMENT); + } + + if (!operatorValue.isObject()) { + throw new OMException( + ERROR_PREFIX + "Invalid Condition operator value structure - " + operatorValue, MALFORMED_POLICY_DOCUMENT); + } + + if (operatorValue.size() != 1) { + throw new OMException( + ERROR_PREFIX + "Only one Condition key is supported per operator", NOT_SUPPORTED_OPERATION); + } + + final String keyName = operatorValue.fieldNames().next(); + if (!"s3:prefix".equalsIgnoreCase(keyName)) { + throw new OMException(ERROR_PREFIX + "Unsupported Condition key name - " + keyName, NOT_SUPPORTED_OPERATION); + } + + final Set prefixes = readConditionPrefixValue(operatorValue.get(keyName)); + condition = new Condition(operator, prefixes); + } + + return condition; + } + + /** + * Builds a case-insensitive S3Action map by lowercasing keys. This map is used for mapping policy actions to + * S3Action enum values. This map is built once and cached statically. + */ + @VisibleForTesting + static Map> buildCaseInsensitiveS3ActionMap() { + final Map> ciMap = new LinkedHashMap<>(); + for (S3Action sa : S3Action.values()) { + // Exact action mapping + ciMap.put(sa.name.toLowerCase(), singleton(sa)); + + // Group into s3:Get*, s3:Put*, s3:List*, s3:Delete*, s3:Create* based on action name prefix + for (String prefix : S3_ACTION_PREFIXES) { + if (sa.name.startsWith(prefix)) { + final String wildcardKey = (prefix + "*").toLowerCase(); + ciMap.computeIfAbsent(wildcardKey, k -> new LinkedHashSet<>()).add(sa); + break; + } + } + } + return Collections.unmodifiableMap(ciMap); + } + + /** + * Maps actions from JSON IAM policy to S3Action enum in order to determine what the + * permissions should be. + */ + @VisibleForTesting + static Set mapPolicyActionsToS3Actions(Set actions) { + if (actions == null || actions.isEmpty()) { + return Collections.emptySet(); + } + + // Map the actions from the IAM policy to S3Action + final Set mappedActions = new LinkedHashSet<>(); + for (String action : actions) { + if ("s3:*".equalsIgnoreCase(action)) { + // Expand into all supported concrete actions + return EnumSet.allOf(S3Action.class); + } + + // Unsupported actions are silently ignored + final Set s3Actions = S3_ACTION_MAP_CI.get(action.toLowerCase()); + if (s3Actions != null) { + mappedActions.addAll(s3Actions); + } + } + + return mappedActions; + } + + /** + * Filters out actions when a Condition is present if the action is not ListBucket. + */ + private static Set filterActionsWhenConditionPresent(Set mappedS3Actions, Condition condition) { + if (condition == null) { + return mappedS3Actions; + } + + if (mappedS3Actions.contains(S3Action.LIST_BUCKET)) { + final Set filteredActions = new HashSet<>(); + filteredActions.add(S3Action.LIST_BUCKET); + return filteredActions; + } + + return Collections.emptySet(); + } + + /** + * Validates that wildcard bucket patterns are not used with native authorizer. + */ + private static void validateNativeAuthorizerBucketPattern(AuthorizerType authorizerType, String bucket) + throws OMException { + if (authorizerType == AuthorizerType.NATIVE && bucket.contains("*")) { + throw new OMException( + ERROR_PREFIX + "Wildcard bucket patterns are not supported for Ozone native authorizer", + NOT_SUPPORTED_OPERATION); + } + } + + /** + * Iterates over resources in IAM policy and determines whether it is a bucket resource, + * an object resource, a prefix or a wildcard. The categorization can be different + * depending on whether the AuthorizerType is Ranger (for RangerOzoneAuthorizer) or + * native (for OzoneNativeAuthorizer). See main Javadoc at top of file for more + * examples of these differences. + *

+ * It also validates that the Resource Arn(s) are valid and supported. + */ + @VisibleForTesting + static Set validateAndCategorizeResources(AuthorizerType authorizerType, + Set resources) throws OMException { + final Set resourceSpecs = new HashSet<>(); + if (resources.isEmpty()) { + throw new OMException(ERROR_PREFIX + "No Resource(s) found in policy", MALFORMED_POLICY_DOCUMENT); + } + for (String resource : resources) { + if ("*".equals(resource)) { + validateNativeAuthorizerBucketPattern(authorizerType, "*"); + resourceSpecs.add(ResourceSpec.any()); + continue; + } + + if (!resource.startsWith(AWS_S3_ARN_PREFIX)) { + throw new OMException(ERROR_PREFIX + "Unsupported Resource Arn - " + resource, NOT_SUPPORTED_OPERATION); + } + + final String suffix = resource.substring(AWS_S3_ARN_PREFIX.length()); + if (suffix.isEmpty()) { + throw new OMException(ERROR_PREFIX + "Invalid Resource Arn - " + resource, MALFORMED_POLICY_DOCUMENT); + } + + ResourceSpec spec = parseResourceSpec(suffix); + + // This scenario can happen in the case of arn:aws:s3:::*/* or arn:aws:s3:::*/test.txt for + // examples + validateNativeAuthorizerBucketPattern(authorizerType, spec.bucket); + + if (authorizerType == AuthorizerType.NATIVE && spec.type == S3ResourceType.OBJECT_PREFIX_WILDCARD) { + final String specPrefixExceptLastChar = spec.prefix.substring(0, spec.prefix.length() - 1); + if (spec.prefix.endsWith("*") && !specPrefixExceptLastChar.contains("*")) { + spec = ResourceSpec.objectPrefix(spec.bucket, specPrefixExceptLastChar); + } else { + throw new OMException( + ERROR_PREFIX + "Wildcard prefix patterns are not supported for Ozone native authorizer if " + + "wildcard is not at the end", NOT_SUPPORTED_OPERATION); + } + } + resourceSpecs.add(spec); + } + return resourceSpecs; + } + + /** + * Iterates over all resources, finds applicable actions (if any) and constructs + * entries pairing sets of IOzoneObjs with the requisite permissions granted (if any). + */ + @VisibleForTesting + static void createPathsAndPermissions(String volumeName, AuthorizerType authorizerType, Set mappedS3Actions, + Set resourceSpecs, Condition condition, Map> objToAclsMap, + Map> objToActionsMap) { + // Process each resource spec with the given actions + for (ResourceSpec resourceSpec : resourceSpecs) { + processResourceSpecWithActions( + volumeName, authorizerType, mappedS3Actions, resourceSpec, condition, objToAclsMap, objToActionsMap); + } + } + + /** + * Groups objects by their ACL sets and S3 actions. + */ + @VisibleForTesting + static Set groupObjectsByAclsAndActions(Map> objToAclsMap, + Map> objToActionsMap) { + + // Composite key to group by both ACLs and S3 actions + class GrantKey { + private final Set acls; + private final Set actions; + + GrantKey(Set acls, Set actions) { + this.acls = acls; + this.actions = actions; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + GrantKey grantKey = (GrantKey) o; + return Objects.equals(acls, grantKey.acls) && Objects.equals(actions, grantKey.actions); + } + + @Override + public int hashCode() { + return Objects.hash(acls, actions); + } + } + + final Map> groupMap = new LinkedHashMap<>(); + + // Group objects by their ACL sets and S3 actions + objToAclsMap.forEach((obj, acls) -> { + Set actions = objToActionsMap.getOrDefault(obj, Collections.emptySet()); + if (actions.size() == S3Action.values().length) { + // An empty set of actions means all actions are allowed + actions = Collections.emptySet(); + } + final GrantKey key = new GrantKey(acls, actions); + groupMap.computeIfAbsent(key, k -> new LinkedHashSet<>()).add(obj); + }); + + // Convert to result format, filtering out entries with empty ACLs + final Set result = new LinkedHashSet<>(); + groupMap.forEach((key, objs) -> { + if (!key.acls.isEmpty()) { + result.add(new AssumeRoleRequest.OzoneGrant(objs, key.acls, key.actions)); + } + }); + + return result; + } + + /** + * Processes a single ResourceSpec with given actions and adds resulting + * IOzoneObj to ACLType mappings to the provided map. + */ + private static void processResourceSpecWithActions(String volumeName, AuthorizerType authorizerType, + Set mappedS3Actions, ResourceSpec resourceSpec, Condition condition, + Map> objToAclsMap, Map> objToActionsMap) { + + // Process based on ResourceSpec type + switch (resourceSpec.type) { + case ANY: + Preconditions.checkArgument( + authorizerType != AuthorizerType.NATIVE, + "ResourceSpec type ANY not supported for OzoneNativeAuthorizer"); + processResourceTypeAny(volumeName, authorizerType, mappedS3Actions, condition, objToAclsMap, objToActionsMap); + break; + case BUCKET: + processBucketResource( + volumeName, mappedS3Actions, resourceSpec, condition, authorizerType, objToAclsMap, objToActionsMap); + break; + case BUCKET_WILDCARD: + Preconditions.checkArgument( + authorizerType != AuthorizerType.NATIVE, + "ResourceSpec type BUCKET_WILDCARD not supported for OzoneNativeAuthorizer"); + processBucketResource( + volumeName, mappedS3Actions, resourceSpec, condition, authorizerType, objToAclsMap, objToActionsMap); + break; + case OBJECT_EXACT: + processObjectExactResource(volumeName, mappedS3Actions, resourceSpec, objToAclsMap, objToActionsMap); + break; + case OBJECT_PREFIX: + Preconditions.checkArgument( + authorizerType != AuthorizerType.RANGER, + "ResourceSpec type OBJECT_PREFIX not supported for RangerOzoneAuthorizer"); + processObjectPrefixResource( + volumeName, authorizerType, mappedS3Actions, resourceSpec, objToAclsMap, objToActionsMap); + break; + case OBJECT_PREFIX_WILDCARD: + Preconditions.checkArgument( + authorizerType != AuthorizerType.NATIVE, + "ResourceSpec type OBJECT_PREFIX_WILDCARD not supported for OzoneNativeAuthorizer"); + processObjectPrefixResource( + volumeName, authorizerType, mappedS3Actions, resourceSpec, objToAclsMap, objToActionsMap); + break; + default: + throw new IllegalStateException("Unexpected resourceSpec type found: " + resourceSpec.type); + } + } + + /** + * Handles ResourceType.ANY (*). + * Example: "Resource": "*" + */ + private static void processResourceTypeAny(String volumeName, AuthorizerType authorizerType, + Set mappedS3Actions, Condition condition, Map> objToAclsMap, + Map> objToActionsMap) { + final IOzoneObj volumeObj = volumeObj(volumeName); + final IOzoneObj bucketObj = bucketObj(volumeName, "*"); + final IOzoneObj keyObj = keyObj(volumeName, "*", "*"); + for (S3Action action : mappedS3Actions) { + addAclsForObj(objToAclsMap, volumeObj, action.volumePerms); + addAclsForObj(objToAclsMap, bucketObj, action.bucketPerms); + + if (condition != null && action == S3Action.LIST_BUCKET) { + // Ensure the volume and bucket get the action + addActionForKind(objToActionsMap, action, volumeObj, bucketObj, null); + + if (condition.prefixes != null && !condition.prefixes.isEmpty()) { + for (String prefix : condition.prefixes) { + // If operator is StringEquals, ignore wildcard prefixes - this is AWS behavior + if (STRING_EQUALS.equals(condition.operator) && hasWildcard(prefix)) { + continue; + } + + final IOzoneObj listObj = createObjectResourcesFromConditionPrefix( + volumeName, authorizerType, ResourceSpec.any(), prefix, objToAclsMap, EnumSet.of(READ)); + addActionForKind(objToActionsMap, action, null, null, listObj); + } + } + } else if (condition == null) { + addAclsForObj(objToAclsMap, keyObj, action.objectPerms); + addActionForKind(objToActionsMap, action, volumeObj, bucketObj, keyObj); + } + } + } + + /** + * Handles BUCKET and BUCKET_WILDCARD resource types. + * Example: "Resource": "arn:aws:s3:::my-bucket" or "Resource": "arn:aws:s3:::my-bucket*" or + * "Resource": "arn:aws:s3:::*" + */ + private static void processBucketResource(String volumeName, Set mappedS3Actions, + ResourceSpec resourceSpec, Condition condition, AuthorizerType authorizerType, + Map> objToAclsMap, Map> objToActionsMap) { + final IOzoneObj volumeObj = volumeObj(volumeName); + final IOzoneObj bucketObj = bucketObj(volumeName, resourceSpec.bucket); + for (S3Action action : mappedS3Actions) { + // The s3:ListAllMyBuckets action can use either "*" or + // "arn:aws:s3:::*" as its Resource. The former is already handled via the + // ResourceSpec.ANY path. The latter is parsed as a BUCKET_WILDCARD with a + // bucket name of "*". To align with AWS, make sure that in this + // specific case we also grant the volume-level permissions for volume-scoped + // actions (currently s3:ListAllMyBuckets). + if (action.kind == ActionKind.BUCKET || + (action.kind == ActionKind.VOLUME && "*".equals(resourceSpec.bucket))) { // this handles s3:ListAllMyBuckets + addAclsForObj(objToAclsMap, volumeObj, action.volumePerms); + addAclsForObj(objToAclsMap, bucketObj, action.bucketPerms); + addActionForKind(objToActionsMap, action, volumeObj, bucketObj, null); + } + + if (action == S3Action.LIST_BUCKET) { + // If condition prefixes are present, these would constrain the object permissions if the action + // is s3:ListBucket + if (condition != null && condition.prefixes != null && !condition.prefixes.isEmpty()) { + for (String prefix : condition.prefixes) { + // If operator is StringEquals, we should ignore any prefix containing wildcards - this is AWS behavior + if (STRING_EQUALS.equals(condition.operator) && hasWildcard(prefix)) { + continue; + } + final IOzoneObj listObj = createObjectResourcesFromConditionPrefix( + volumeName, authorizerType, resourceSpec, prefix, objToAclsMap, EnumSet.of(READ)); + // Add action for the key/prefix + addActionForKind(objToActionsMap, action, null, null, listObj); + } + } else if (condition == null) { + // No condition prefixes, but we need READ access to all objects, so use "*" as the prefix + final IOzoneObj readObj = createObjectResourcesFromConditionPrefix( + volumeName, authorizerType, resourceSpec, "*", objToAclsMap, EnumSet.of(READ)); + // Add action for the key/prefix + addActionForKind(objToActionsMap, action, null, null, readObj); + } + } + } + } + + /** + * Handles OBJECT_EXACT resource type. + * Example: "Resource": "arn:aws:s3:::my-bucket/file.txt" + */ + private static void processObjectExactResource(String volumeName, Set mappedS3Actions, + ResourceSpec resourceSpec, Map> objToAclsMap, + Map> objToActionsMap) { + final IOzoneObj volumeObj = volumeObj(volumeName); + final IOzoneObj bucketObj = bucketObj(volumeName, resourceSpec.bucket); + final IOzoneObj keyObj = keyObj(volumeName, resourceSpec.bucket, resourceSpec.key); + for (S3Action action : mappedS3Actions) { + if (action.kind == ActionKind.OBJECT) { + addAclsForObj(objToAclsMap, volumeObj, action.volumePerms); + addAclsForObj(objToAclsMap, bucketObj, action.bucketPerms); + addAclsForObj(objToAclsMap, keyObj, action.objectPerms); + addActionForKind(objToActionsMap, action, volumeObj, bucketObj, keyObj); + } + } + } + + /** + * Handles OBJECT_PREFIX and OBJECT_PREFIX_WILDCARD resource types. + * Prefixes can be specified in the Resource itself as in the example below, or via an s3:prefix Condition. + * Example: "Resource": "arn:aws:s3:::my-bucket/path/folder" + */ + private static void processObjectPrefixResource(String volumeName, AuthorizerType authorizerType, + Set mappedS3Actions, ResourceSpec resourceSpec, Map> objToAclsMap, + Map> objToActionsMap) { + final IOzoneObj volumeObj = volumeObj(volumeName); + final IOzoneObj bucketObj = bucketObj(volumeName, resourceSpec.bucket); + for (S3Action action : mappedS3Actions) { + // Object actions apply to prefix/key resources - ensure to add the acls only for the appropriate action type + if (action.kind == ActionKind.OBJECT) { + addAclsForObj(objToAclsMap, volumeObj, action.volumePerms); + addAclsForObj(objToAclsMap, bucketObj, action.bucketPerms); + // Handle the resource prefix itself (e.g., my-bucket/*) + createObjectResourcesFromResourcePrefix( + volumeName, authorizerType, resourceSpec, objToAclsMap, objToActionsMap, action.objectPerms, action.name); + // Object-level action was already applied inside createObjectResourcesFromResourcePrefix. + addActionForKind(objToActionsMap, action, volumeObj, bucketObj, null); + } + } + } + + /** + * Creates object resources from resource prefix (e.g., my-bucket/*). + */ + private static void createObjectResourcesFromResourcePrefix(String volumeName, AuthorizerType authorizerType, + ResourceSpec resourceSpec, Map> objToAclsMap, + Map> objToActionsMap, Set acls, String actionName) { + if (authorizerType == AuthorizerType.NATIVE) { + final IOzoneObj prefixObj = prefixObj(volumeName, resourceSpec.bucket, resourceSpec.prefix); + addAclsForObj(objToAclsMap, prefixObj, acls); + addActionForObj(objToActionsMap, prefixObj, actionName); + } else { + final IOzoneObj keyObj = keyObj(volumeName, resourceSpec.bucket, resourceSpec.prefix); + addAclsForObj(objToAclsMap, keyObj, acls); + addActionForObj(objToActionsMap, keyObj, actionName); + } + } + + /** + * Creates object resources from condition prefixes (i.e. the s3:prefix conditions). + */ + private static IOzoneObj createObjectResourcesFromConditionPrefix(String volumeName, AuthorizerType authorizerType, + ResourceSpec resourceSpec, String conditionPrefix, Map> objToAclsMap, Set acls) { + if (authorizerType == AuthorizerType.NATIVE) { + // For native authorizer, use PREFIX resource type with normalized prefix. + // Map "x" in condition list prefix to "x". Map "x/*" in condition list prefix to "x/". + // Map "*" in condition list prefix to "". + final String normalizedPrefix; + if (conditionPrefix != null && conditionPrefix.endsWith("*")) { + normalizedPrefix = conditionPrefix.substring(0, conditionPrefix.length() - 1); + } else { + normalizedPrefix = conditionPrefix; + } + final IOzoneObj prefixObj = prefixObj(volumeName, resourceSpec.bucket, normalizedPrefix); + addAclsForObj(objToAclsMap, prefixObj, acls); + return prefixObj; + } else { + // For Ranger authorizer, use KEY resource type with original prefix + // Map "x" in condition list prefix to "x". Map "x/*" in condition list prefix to "x/*". + // Map "* in condition list prefix to "*". + final IOzoneObj keyObj = keyObj(volumeName, resourceSpec.bucket, conditionPrefix); + addAclsForObj(objToAclsMap, keyObj, acls); + return keyObj; + } + } + + private static void addActionForKind(Map> objToActionsMap, S3Action action, + IOzoneObj volumeObj, IOzoneObj bucketObj, IOzoneObj objectObj) { + if (action == null) { + return; + } + final ActionKind kind = action.kind; + if (kind == ActionKind.VOLUME) { + if (volumeObj != null) { + addActionForObj(objToActionsMap, volumeObj, action.name); + } + return; + } + + if (volumeObj != null) { + addActionForObj(objToActionsMap, volumeObj, action.name); + } + if (bucketObj != null) { + addActionForObj(objToActionsMap, bucketObj, action.name); + } + if (objectObj != null) { + if (kind == ActionKind.OBJECT || action == S3Action.LIST_BUCKET) { + addActionForObj(objToActionsMap, objectObj, action.name); + } + } + } + + /** + * Helper method to add ACLs for an IOzoneObj, merging with existing ACLs if present. + * If ALL permission is present, no other permissions are added. + */ + private static void addAclsForObj(Map> objToAclsMap, IOzoneObj obj, Set acls) { + if (acls != null && !acls.isEmpty()) { + final OzoneObj ozoneObj = (OzoneObj) obj; + final Set existingAcls = objToAclsMap.computeIfAbsent(ozoneObj, k -> EnumSet.noneOf(ACLType.class)); + + // If ALL is already present, don't add other permissions + if (existingAcls.contains(ACLType.ALL)) { + return; + } + + // If we're about to add ALL, remove all other permissions first + if (acls.contains(ACLType.ALL)) { + existingAcls.clear(); + existingAcls.add(ACLType.ALL); + } else { + // Only add permissions if ALL is not already present + existingAcls.addAll(acls); + } + } + } + + /** + * Helper method to add an S3 action for an IOzoneObj. It basically strips off + * the s3: prefix, such that s3:GetObject becomes GetObject. + */ + private static String normalizeS3ActionForGrant(String action) { + if (action == null) { + return null; + } + if (action.isEmpty()) { + return action; + } + if (action.regionMatches(true, 0, "s3:", 0, 3)) { + return action.substring(3); + } + return action; + } + + private static void addActionForObj(Map> objToActionsMap, IOzoneObj obj, String action) { + final String normalizedAction = normalizeS3ActionForGrant(action); + if (normalizedAction != null && !normalizedAction.isEmpty()) { + final OzoneObj ozoneObj = (OzoneObj) obj; + objToActionsMap.computeIfAbsent(ozoneObj, k -> new LinkedHashSet<>()).add(normalizedAction); + } + } + + /** + * The authorizer type, whether for OzoneNativeAuthorizer or RangerOzoneAuthorizer. + * The IOzoneObjs generated differ in certain cases depending on the type. + * See main Javadoc at top of file for differences. + */ + public enum AuthorizerType { + NATIVE, + RANGER + } + + /** + * The type of resource the S3 action applies to. + */ + private enum ActionKind { + VOLUME, + BUCKET, + OBJECT + } + + /** + * The categorization possibilities of Resources in the IAM policy. + */ + @VisibleForTesting + enum S3ResourceType { + ANY, // Ranger authorizer solely uses this + BUCKET, + BUCKET_WILDCARD, // Ranger authorizer solely uses this + OBJECT_PREFIX, // Native authorizer solely uses this + OBJECT_PREFIX_WILDCARD, // Ranger authorizer solely uses this. We initially categorize all resources with + // wildcard (*) as OBJECT_PREFIX_WILDCARD, but if the wildcard is not at the end, and + // Native authorizer is being used, an error is thrown. If the wildcard is at the end, + // then the categorization will use OBJECT_PREFIX for native authorizer instead and remove + // the wildcard. + OBJECT_EXACT + } + + /** + * Encapsulates the Condition operator and values. + */ + @VisibleForTesting + public static final class Condition { + private final String operator; + private final Set prefixes; + + public Condition(String operator, Set prefixes) { + this.operator = operator; + this.prefixes = prefixes; + } + } + + /** + * Utility to help categorize IAM policy resources, whether for bucket, key, wildcards, etc. + */ + @VisibleForTesting + static final class ResourceSpec { + private final S3ResourceType type; + private final String bucket; + private final String prefix; // for OBJECT_PREFIX or OBJECT_PREFIX_WILDCARD only, otherwise null + private final String key; // for OBJECT_EXACT only, otherwise null + + @VisibleForTesting + ResourceSpec(S3ResourceType type, String bucket, String prefix, String key) { + this.type = type; + this.bucket = bucket; + this.prefix = prefix; + this.key = key; + } + + static ResourceSpec any() { + return new ResourceSpec(S3ResourceType.ANY, "*", null, null); + } + + static ResourceSpec bucket(String bucket) { + return new ResourceSpec( + bucket.contains("*") ? S3ResourceType.BUCKET_WILDCARD : S3ResourceType.BUCKET, bucket, null, null); + } + + static ResourceSpec objectExact(String bucket, String key) { + return new ResourceSpec(S3ResourceType.OBJECT_EXACT, bucket, null, key); + } + + static ResourceSpec objectPrefix(String bucket, String prefix) { + return new ResourceSpec( + prefix.contains("*") ? S3ResourceType.OBJECT_PREFIX_WILDCARD : S3ResourceType.OBJECT_PREFIX, bucket, + prefix, null); + } + + @Override + public boolean equals(Object o) { + if (o == null || getClass() != o.getClass()) { + return false; + } + final ResourceSpec that = (ResourceSpec) o; + return type == that.type && Objects.equals(bucket, that.bucket) && Objects.equals(prefix, that.prefix) && + Objects.equals(key, that.key); + } + + @Override + public int hashCode() { + return Objects.hash(type, bucket, prefix, key); + } + + @Override + public String toString() { + return "ResourceSpec{" + "type=" + type + ", bucket='" + bucket + '\'' + ", prefix='" + prefix + '\'' + + ", key='" + key + '\'' + '}'; + } + } + + /** + * Parses and categorizes the ResourceArn. + *

+ * Suffix parameter can be: + * -> bucket + * -> bucket/* (prefix in OzoneNativeAuthorizer or wildcard key in RangerOzoneAuthorizer) + * -> bucket/deep/path/* (prefix in OzoneNativeAuthorizer or wildcard key in RangerOzoneAuthorizer) + * -> bucket/key or bucket/prefix/key (exact key) + */ + private static ResourceSpec parseResourceSpec(String suffix) { + + final int slashIndex = suffix.indexOf('/'); + if (slashIndex < 0) { + return ResourceSpec.bucket(suffix); + } + + final String bucket = suffix.substring(0, slashIndex); + final String rest = suffix.substring(slashIndex + 1); + if (rest.contains("*")) { + return ResourceSpec.objectPrefix(bucket, rest); + } + + return ResourceSpec.objectExact(bucket, rest); + } + + @VisibleForTesting + enum S3Action { + // Volume-scope + // Used for ListBuckets api + LIST_ALL_MY_BUCKETS("s3:ListAllMyBuckets", ActionKind.VOLUME, EnumSet.of(READ, LIST), + EnumSet.noneOf(ACLType.class), EnumSet.noneOf(ACLType.class)), + + // Bucket-scope + CREATE_BUCKET("s3:CreateBucket", ActionKind.BUCKET, EnumSet.of(READ), EnumSet.of(CREATE), + EnumSet.noneOf(ACLType.class)), + DELETE_BUCKET("s3:DeleteBucket", ActionKind.BUCKET, EnumSet.of(READ), EnumSet.of(DELETE), + EnumSet.noneOf(ACLType.class)), + GET_BUCKET_ACL("s3:GetBucketAcl", ActionKind.BUCKET, EnumSet.of(READ), EnumSet.of(READ, READ_ACL), + EnumSet.noneOf(ACLType.class)), + // Used for HeadBucket, ListObjects and ListObjectsV2 apis + LIST_BUCKET("s3:ListBucket", ActionKind.BUCKET, EnumSet.of(READ), EnumSet.of(READ, LIST), EnumSet.of(READ)), + // Used for ListMultipartUploads API + LIST_BUCKET_MULTIPART_UPLOADS("s3:ListBucketMultipartUploads", ActionKind.BUCKET, EnumSet.of(READ), + EnumSet.of(READ, LIST), EnumSet.noneOf(ACLType.class)), + PUT_BUCKET_ACL("s3:PutBucketAcl", ActionKind.BUCKET, EnumSet.of(READ), EnumSet.of(READ, READ_ACL, WRITE_ACL), + EnumSet.noneOf(ACLType.class)), + + // Object-scope + ABORT_MULTIPART_UPLOAD("s3:AbortMultipartUpload", ActionKind.OBJECT, EnumSet.of(READ), EnumSet.of(READ), + EnumSet.of(WRITE)), + // Used for DeleteObject (when versionId parameter is not supplied), + // DeleteObjects (when versionId parameter is not supplied) APIs + DELETE_OBJECT("s3:DeleteObject", ActionKind.OBJECT, EnumSet.of(READ), EnumSet.of(READ), EnumSet.of(DELETE)), + DELETE_OBJECT_TAGGING("s3:DeleteObjectTagging", ActionKind.OBJECT, EnumSet.of(READ), EnumSet.of(READ), + EnumSet.of(WRITE)), + // Used for HeadObject, CopyObject (for source bucket), GetObject (without versionId parameter) APIs + GET_OBJECT("s3:GetObject", ActionKind.OBJECT, EnumSet.of(READ), EnumSet.of(READ), EnumSet.of(READ)), + GET_OBJECT_TAGGING("s3:GetObjectTagging", ActionKind.OBJECT, EnumSet.of(READ), EnumSet.of(READ), EnumSet.of(READ)), + // Used for ListParts API + LIST_MULTIPART_UPLOAD_PARTS("s3:ListMultipartUploadParts", ActionKind.OBJECT, EnumSet.of(READ), EnumSet.of(READ), + EnumSet.of(READ)), + // Used for CreateMultipartUpload, UploadPart, CompleteMultipartUpload, + // CopyObject (for destination bucket), PutObject APIs + PUT_OBJECT("s3:PutObject", ActionKind.OBJECT, EnumSet.of(READ), EnumSet.of(READ), + EnumSet.of(CREATE, ACLType.WRITE)), + PUT_OBJECT_TAGGING("s3:PutObjectTagging", ActionKind.OBJECT, EnumSet.of(READ), EnumSet.of(READ), + EnumSet.of(ACLType.WRITE)); + + private final String name; + private final ActionKind kind; + private final Set volumePerms; + private final Set bucketPerms; + private final Set objectPerms; + + S3Action(String name, ActionKind kind, Set volumePerms, Set bucketPerms, + Set objectPerms) { + this.name = name; + this.kind = kind; + this.volumePerms = volumePerms; + this.bucketPerms = bucketPerms; + this.objectPerms = objectPerms; + } + } + + /** + * Creates an OzoneObjInfo.Builder based on supplied parameters. + */ + private static OzoneObjInfo.Builder obj(OzoneObj.ResourceType type, String volumeName, String bucketName) { + return OzoneObjInfo.Builder.newBuilder() + .setResType(type) + .setStoreType(OzoneObj.StoreType.OZONE) + .setVolumeName(volumeName) + .setBucketName(bucketName); + } + + /** + * Creates IOzoneObj with ResourceType BUCKET. + */ + private static IOzoneObj bucketObj(String volumeName, String bucketName) { + return obj(OzoneObj.ResourceType.BUCKET, volumeName, bucketName).build(); + } + + /** + * Creates IOzoneObj with ResourceType KEY. + */ + private static IOzoneObj keyObj(String volumeName, String bucketName, String keyName) { + return obj(OzoneObj.ResourceType.KEY, volumeName, bucketName) + .setKeyName(keyName) + .build(); + } + + /** + * Creates IOzoneObj with ResourceType PREFIX. + */ + private static IOzoneObj prefixObj(String volumeName, String bucketName, String prefixName) { + return obj(OzoneObj.ResourceType.PREFIX, volumeName, bucketName) + .setPrefixName(prefixName) + .build(); + } + + /** + * Creates IOzoneObj with ResourceType VOLUME. + */ + private static IOzoneObj volumeObj(String volumeName) { + return OzoneObjInfo.Builder.newBuilder() + .setResType(OzoneObj.ResourceType.VOLUME) + .setStoreType(OzoneObj.StoreType.OZONE) + .setVolumeName(volumeName) + .build(); + } + + private static boolean hasWildcard(String prefix) { + return ((prefix.contains("*") || prefix.contains("?"))); + } +} diff --git a/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/security/acl/iam/package-info.java b/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/security/acl/iam/package-info.java new file mode 100644 index 000000000000..dee8fe2ea0cd --- /dev/null +++ b/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/security/acl/iam/package-info.java @@ -0,0 +1,21 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * Classes related to ozone REST interface. + */ +package org.apache.hadoop.ozone.security.acl.iam; diff --git a/hadoop-ozone/common/src/test/java/org/apache/hadoop/ozone/om/helpers/TestAssumeRoleResponseInfo.java b/hadoop-ozone/common/src/test/java/org/apache/hadoop/ozone/om/helpers/TestAssumeRoleResponseInfo.java new file mode 100644 index 000000000000..ce42b815470a --- /dev/null +++ b/hadoop-ozone/common/src/test/java/org/apache/hadoop/ozone/om/helpers/TestAssumeRoleResponseInfo.java @@ -0,0 +1,197 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.hadoop.ozone.om.helpers; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; + +import org.apache.hadoop.ozone.protocol.proto.OzoneManagerProtocolProtos.AssumeRoleResponse; +import org.junit.jupiter.api.Test; + +/** + * Test AssumeRoleResponseInfo. + */ +public class TestAssumeRoleResponseInfo { + + private static final String ACCESS_KEY_ID = "ASIA7O1AJD8VV4KCEAX5"; + private static final String SECRET_ACCESS_KEY = "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"; + private static final String SESSION_TOKEN = "jgIDCAMaI2lkYnJva2VyL2lkYnJva2VyY2xpZW50QEVYQU1QTEUuQ09" + + "NOLDJ8bClM2IjaWRicm9rZXIvaWRicm9rZXJjbGllbnRARVhBTVBMRS5DT02CASRjMGM5YTk2NS00YTU1LTRmMjQtYTUxMi0" + + "3MGQ3M2JiMzg0ZDSKARRBU0lBN08xQUpEOFdWNEtDRUFYNZIBKGFybjphd3M6aWFtOjoxMjM0NTY3ODkwMTI6cm9sZS9mbS1" + + "kd3JvbGWaAXAvdmNOSjNqRW5zc3UyWklKYWxJbGRXZSswV1VmYkRvSmwxdXV1eDBPVExQalVzZ0VqOVE5T0FZVUZTd2JtUGo" + + "zZHNhaXpjMytacEJiVXJDNWRSV1FOTE4xcWJsVkhSdEZiZFBPTXp4NU5YY1pXdz09ogHQAVt7InJvbGVOYW1lIjoiZm0tZHd" + + "yb2xlIiwiZ3JhbnRzIjpbeyJvYmplY3RzIjpbImtleTogL3Mzdi9idWNrZXQxLyoiXSwicGVybWlzc2lvbnMiOlsicmVhZCJ" + + "dfSx7Im9iamVjdHMiOlsidm9sdW1lOiAvczN2Il0sInBlcm1pc3Npb25zIjpbInJlYWQiXX0seyJvYmplY3RzIjpbImJ1Y2t" + + "ldDogL3Mzdi9idWNrZXQxIl0sInBlcm1pc3Npb25zIjpbInJlYWQiXX1dfV0gCil_LhVjpP4hfMez4L5wNZDeqEubSeBfEow" + + "VoRnSQ-wIU1RTVG9rZW4DU1RT"; + private static final long EXPIRATION_EPOCH_SECONDS = 1577836800L; + private static final String ASSUMED_ROLE_ID = "arn:aws:iam::123456789012:role/MyRole"; + + @Test + public void testConstructor() { + final AssumeRoleResponseInfo response = new AssumeRoleResponseInfo( + ACCESS_KEY_ID, SECRET_ACCESS_KEY, SESSION_TOKEN, EXPIRATION_EPOCH_SECONDS, ASSUMED_ROLE_ID); + + assertEquals(ACCESS_KEY_ID, response.getAccessKeyId()); + assertEquals(SECRET_ACCESS_KEY, response.getSecretAccessKey()); + assertEquals(SESSION_TOKEN, response.getSessionToken()); + assertEquals(EXPIRATION_EPOCH_SECONDS, response.getExpirationEpochSeconds()); + assertEquals(ASSUMED_ROLE_ID, response.getAssumedRoleId()); + } + + @Test + public void testProtobufConversion() { + final AssumeRoleResponseInfo response = new AssumeRoleResponseInfo( + ACCESS_KEY_ID, SECRET_ACCESS_KEY, SESSION_TOKEN, EXPIRATION_EPOCH_SECONDS, ASSUMED_ROLE_ID); + + final AssumeRoleResponse proto = response.getProtobuf(); + + assertNotNull(proto); + assertEquals(ACCESS_KEY_ID, proto.getAccessKeyId()); + assertEquals(SECRET_ACCESS_KEY, proto.getSecretAccessKey()); + assertEquals(SESSION_TOKEN, proto.getSessionToken()); + assertEquals(EXPIRATION_EPOCH_SECONDS, proto.getExpirationEpochSeconds()); + assertEquals(ASSUMED_ROLE_ID, proto.getAssumedRoleId()); + } + + @Test + public void testFromProtobuf() { + final AssumeRoleResponse proto = AssumeRoleResponse.newBuilder() + .setAccessKeyId(ACCESS_KEY_ID) + .setSecretAccessKey(SECRET_ACCESS_KEY) + .setSessionToken(SESSION_TOKEN) + .setExpirationEpochSeconds(EXPIRATION_EPOCH_SECONDS) + .setAssumedRoleId(ASSUMED_ROLE_ID) + .build(); + + final AssumeRoleResponseInfo response = AssumeRoleResponseInfo.fromProtobuf(proto); + + assertEquals(ACCESS_KEY_ID, response.getAccessKeyId()); + assertEquals(SECRET_ACCESS_KEY, response.getSecretAccessKey()); + assertEquals(SESSION_TOKEN, response.getSessionToken()); + assertEquals(EXPIRATION_EPOCH_SECONDS, response.getExpirationEpochSeconds()); + assertEquals(ASSUMED_ROLE_ID, response.getAssumedRoleId()); + } + + @Test + public void testProtobufRoundTrip() { + final AssumeRoleResponseInfo originalResponse = new AssumeRoleResponseInfo( + ACCESS_KEY_ID, SECRET_ACCESS_KEY, SESSION_TOKEN, EXPIRATION_EPOCH_SECONDS, ASSUMED_ROLE_ID); + + final AssumeRoleResponse proto = originalResponse.getProtobuf(); + final AssumeRoleResponseInfo recoveredResponse = AssumeRoleResponseInfo.fromProtobuf(proto); + + assertEquals(originalResponse, recoveredResponse); + } + + @Test + public void testEqualsAndHashCodeWithIdenticalObjects() { + final AssumeRoleResponseInfo response1 = new AssumeRoleResponseInfo( + ACCESS_KEY_ID, SECRET_ACCESS_KEY, SESSION_TOKEN, EXPIRATION_EPOCH_SECONDS, ASSUMED_ROLE_ID); + + final AssumeRoleResponseInfo response2 = new AssumeRoleResponseInfo( + ACCESS_KEY_ID, SECRET_ACCESS_KEY, SESSION_TOKEN, EXPIRATION_EPOCH_SECONDS, ASSUMED_ROLE_ID); + + assertEquals(response1, response2); + assertEquals(response1.hashCode(), response2.hashCode()); + } + + @Test + public void testNotEqualsAndHashCodeWithDifferentAccessKeyId() { + final AssumeRoleResponseInfo response1 = new AssumeRoleResponseInfo( + ACCESS_KEY_ID, SECRET_ACCESS_KEY, SESSION_TOKEN, EXPIRATION_EPOCH_SECONDS, ASSUMED_ROLE_ID); + + final AssumeRoleResponseInfo response2 = new AssumeRoleResponseInfo( + "DIFFERENT_KEY_ID", SECRET_ACCESS_KEY, SESSION_TOKEN, EXPIRATION_EPOCH_SECONDS, ASSUMED_ROLE_ID); + + assertNotEquals(response1, response2); + assertNotEquals(response1.hashCode(), response2.hashCode()); + } + + @Test + public void testNotEqualsAndHashCodeWithDifferentSecretAccessKey() { + final AssumeRoleResponseInfo response1 = new AssumeRoleResponseInfo( + ACCESS_KEY_ID, SECRET_ACCESS_KEY, SESSION_TOKEN, EXPIRATION_EPOCH_SECONDS, ASSUMED_ROLE_ID); + + final AssumeRoleResponseInfo response2 = new AssumeRoleResponseInfo( + ACCESS_KEY_ID, "DIFFERENT_SECRET_KEY", SESSION_TOKEN, EXPIRATION_EPOCH_SECONDS, ASSUMED_ROLE_ID); + + assertNotEquals(response1, response2); + assertNotEquals(response1.hashCode(), response2.hashCode()); + } + + @Test + public void testNotEqualsAndHashCodeWithDifferentSessionToken() { + final AssumeRoleResponseInfo response1 = new AssumeRoleResponseInfo( + ACCESS_KEY_ID, SECRET_ACCESS_KEY, SESSION_TOKEN, EXPIRATION_EPOCH_SECONDS, ASSUMED_ROLE_ID); + + final AssumeRoleResponseInfo response2 = new AssumeRoleResponseInfo( + ACCESS_KEY_ID, SECRET_ACCESS_KEY, "DIFFERENT_TOKEN", EXPIRATION_EPOCH_SECONDS, ASSUMED_ROLE_ID); + + assertNotEquals(response1, response2); + assertNotEquals(response1.hashCode(), response2.hashCode()); + } + + @Test + public void testNotEqualsAndHashCodeWithDifferentExpirationEpochSeconds() { + final AssumeRoleResponseInfo response1 = new AssumeRoleResponseInfo( + ACCESS_KEY_ID, SECRET_ACCESS_KEY, SESSION_TOKEN, EXPIRATION_EPOCH_SECONDS, ASSUMED_ROLE_ID); + + final AssumeRoleResponseInfo response2 = new AssumeRoleResponseInfo( + ACCESS_KEY_ID, SECRET_ACCESS_KEY, SESSION_TOKEN, 9999999999L, ASSUMED_ROLE_ID); + + assertNotEquals(response1, response2); + assertNotEquals(response1.hashCode(), response2.hashCode()); + } + + @Test + public void testNotEqualsAndHashCodeWithDifferentAssumedRoleId() { + final AssumeRoleResponseInfo response1 = new AssumeRoleResponseInfo( + ACCESS_KEY_ID, SECRET_ACCESS_KEY, SESSION_TOKEN, EXPIRATION_EPOCH_SECONDS, ASSUMED_ROLE_ID); + + final AssumeRoleResponseInfo response2 = new AssumeRoleResponseInfo( + ACCESS_KEY_ID, SECRET_ACCESS_KEY, SESSION_TOKEN, EXPIRATION_EPOCH_SECONDS, "DIFFERENT_ROLE_ID"); + + assertNotEquals(response1, response2); + assertNotEquals(response1.hashCode(), response2.hashCode()); + } + + @Test + public void testNotEqualsWithNull() { + final AssumeRoleResponseInfo response = new AssumeRoleResponseInfo( + ACCESS_KEY_ID, SECRET_ACCESS_KEY, SESSION_TOKEN, EXPIRATION_EPOCH_SECONDS, ASSUMED_ROLE_ID); + + assertNotEquals(null, response); + } + + @Test + public void testToString() { + final AssumeRoleResponseInfo response = new AssumeRoleResponseInfo( + ACCESS_KEY_ID, SECRET_ACCESS_KEY, SESSION_TOKEN, EXPIRATION_EPOCH_SECONDS, ASSUMED_ROLE_ID); + + final String toString = response.toString(); + final String expectedString = "AssumeRoleResponseInfo{" + "accessKeyId='" + ACCESS_KEY_ID + + "', sessionToken='" + SESSION_TOKEN + "', expirationEpochSeconds=" + EXPIRATION_EPOCH_SECONDS + + ", assumedRoleId='" + ASSUMED_ROLE_ID + "'}"; + + assertNotNull(toString); + assertEquals(expectedString, toString); + } +} + diff --git a/hadoop-ozone/common/src/test/java/org/apache/hadoop/ozone/om/helpers/TestAwsRoleArnValidator.java b/hadoop-ozone/common/src/test/java/org/apache/hadoop/ozone/om/helpers/TestAwsRoleArnValidator.java new file mode 100644 index 000000000000..ea6db63c5557 --- /dev/null +++ b/hadoop-ozone/common/src/test/java/org/apache/hadoop/ozone/om/helpers/TestAwsRoleArnValidator.java @@ -0,0 +1,133 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.hadoop.ozone.om.helpers; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import org.apache.commons.lang3.StringUtils; +import org.apache.hadoop.ozone.om.exceptions.OMException; +import org.junit.jupiter.api.Test; + +/** + * Unit tests for AwsRoleArnValidator. + */ +public class TestAwsRoleArnValidator { + + private static final String ROLE_ARN_1 = "arn:aws:iam::123456789012:role/MyRole1"; + private static final String ROLE_ARN_2 = "arn:aws:iam::123456789012:role/path/anotherLevel/Role2"; + + @Test + public void testValidateAndExtractRoleNameFromArnSuccessCases() throws OMException { + assertThat(AwsRoleArnValidator.validateAndExtractRoleNameFromArn(ROLE_ARN_1)).isEqualTo("MyRole1"); + + assertThat(AwsRoleArnValidator.validateAndExtractRoleNameFromArn(ROLE_ARN_2)).isEqualTo("Role2"); + + // Path name right at 511-char max boundary + final String arnPrefixLen511 = StringUtils.repeat('p', 510) + "/"; // 510 chars + '/' = 511 + final String arnMaxPath = "arn:aws:iam::123456789012:role/" + arnPrefixLen511 + "RoleB"; + assertThat(AwsRoleArnValidator.validateAndExtractRoleNameFromArn(arnMaxPath)).isEqualTo("RoleB"); + + // Role name right at 64-char max boundary + final String roleName64 = StringUtils.repeat('A', 64); + final String arn64 = "arn:aws:iam::123456789012:role/" + roleName64; + assertThat(AwsRoleArnValidator.validateAndExtractRoleNameFromArn(arn64)).isEqualTo(roleName64); + } + + @Test + public void testValidateAndExtractRoleNameFromArnFailureCases() { + // Improper structure + final OMException e1 = assertThrows( + OMException.class, () -> AwsRoleArnValidator.validateAndExtractRoleNameFromArn("roleNoSlashNorColons")); + assertThat(e1.getResult()).isEqualTo(OMException.ResultCodes.INVALID_REQUEST); + assertThat(e1.getMessage()).isEqualTo( + "Invalid role ARN (does not start with arn:aws:iam::): roleNoSlashNorColons"); + + // Null + final OMException e2 = assertThrows( + OMException.class, () -> AwsRoleArnValidator.validateAndExtractRoleNameFromArn(null)); + assertThat(e2.getResult()).isEqualTo(OMException.ResultCodes.INVALID_REQUEST); + assertThat(e2.getMessage()).isEqualTo( + "Value null at 'roleArn' failed to satisfy constraint: Member must not be null"); + + // String without role name + final OMException e3 = assertThrows( + OMException.class, + () -> AwsRoleArnValidator.validateAndExtractRoleNameFromArn("arn:aws:iam::123456789012:role/")); + assertThat(e3.getResult()).isEqualTo(OMException.ResultCodes.INVALID_REQUEST); + assertThat(e3.getMessage()).isEqualTo("Invalid role ARN: missing role name"); + + // No role resource and no role name + final OMException e4 = assertThrows( + OMException.class, + () -> AwsRoleArnValidator.validateAndExtractRoleNameFromArn("arn:aws:iam::123456789012")); + assertThat(e4.getResult()).isEqualTo(OMException.ResultCodes.INVALID_REQUEST); + assertThat(e4.getMessage()).isEqualTo( + "Invalid role ARN (unexpected field count): arn:aws:iam::123456789012"); + + // No role resource but contains role name + final OMException e5 = assertThrows( + OMException.class, + () -> AwsRoleArnValidator.validateAndExtractRoleNameFromArn("arn:aws:iam::123456789012:WebRole")); + assertThat(e5.getResult()).isEqualTo(OMException.ResultCodes.INVALID_REQUEST); + assertThat(e5.getMessage()).isEqualTo( + "Invalid role ARN (unexpected field count): arn:aws:iam::123456789012:WebRole"); + + // Empty string + final OMException e6 = assertThrows( + OMException.class, () -> AwsRoleArnValidator.validateAndExtractRoleNameFromArn("")); + assertThat(e6.getResult()).isEqualTo(OMException.ResultCodes.INVALID_REQUEST); + assertThat(e6.getMessage()).isEqualTo( + "Value null at 'roleArn' failed to satisfy constraint: Member must not be null"); + + // String with only slash + final OMException e7 = assertThrows( + OMException.class, () -> AwsRoleArnValidator.validateAndExtractRoleNameFromArn("/")); + assertThat(e7.getResult()).isEqualTo(OMException.ResultCodes.INVALID_REQUEST); + assertThat(e7.getMessage()).isEqualTo("Role ARN length must be between 20 and 2048"); + + // String with only whitespace + final OMException e8 = assertThrows( + OMException.class, () -> AwsRoleArnValidator.validateAndExtractRoleNameFromArn(" ")); + assertThat(e8.getResult()).isEqualTo(OMException.ResultCodes.INVALID_REQUEST); + assertThat(e8.getMessage()).isEqualTo("Role ARN length must be between 20 and 2048"); + + // Path name too long (> 511 characters) + final String arnPrefixLen512 = StringUtils.repeat('q', 511) + "/"; // 511 chars + '/' = 512 + final String arnTooLongPath = "arn:aws:iam::123456789012:role/" + arnPrefixLen512 + "RoleA"; + final OMException e9 = assertThrows( + OMException.class, () -> AwsRoleArnValidator.validateAndExtractRoleNameFromArn(arnTooLongPath)); + assertThat(e9.getResult()).isEqualTo(OMException.ResultCodes.INVALID_REQUEST); + assertThat(e9.getMessage()).isEqualTo("Role path length must be between 1 and 512 characters"); + + // Otherwise valid role ending in / + final OMException e10 = assertThrows( + OMException.class, + () -> AwsRoleArnValidator.validateAndExtractRoleNameFromArn("arn:aws:iam::123456789012:role/MyRole/")); + assertThat(e10.getResult()).isEqualTo(OMException.ResultCodes.INVALID_REQUEST); + assertThat(e10.getMessage()).isEqualTo("Invalid role ARN: missing role name"); // MyRole/ is considered a path + + // 65-char role name + final String roleName65 = StringUtils.repeat('B', 65); + final String roleArn65 = "arn:aws:iam::123456789012:role/" + roleName65; + final OMException e11 = assertThrows( + OMException.class, () -> AwsRoleArnValidator.validateAndExtractRoleNameFromArn(roleArn65)); + assertThat(e11.getResult()).isEqualTo(OMException.ResultCodes.INVALID_REQUEST); + assertThat(e11.getMessage()).isEqualTo("Invalid role name: " + roleName65); + } +} diff --git a/hadoop-ozone/common/src/test/java/org/apache/hadoop/ozone/om/helpers/TestCallerIdentityInfo.java b/hadoop-ozone/common/src/test/java/org/apache/hadoop/ozone/om/helpers/TestCallerIdentityInfo.java new file mode 100644 index 000000000000..f6bc0334acfe --- /dev/null +++ b/hadoop-ozone/common/src/test/java/org/apache/hadoop/ozone/om/helpers/TestCallerIdentityInfo.java @@ -0,0 +1,107 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.hadoop.ozone.om.helpers; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; + +import org.apache.hadoop.ozone.protocol.proto.OzoneManagerProtocolProtos.GetCallerIdentityResponse; +import org.junit.jupiter.api.Test; + +/** + * Test CallerIdentityInfo. + */ +public class TestCallerIdentityInfo { + + private static final String ACCOUNT = "123456789012"; + private static final String ARN = "arn:aws:iam::123456789012:user/om"; + private static final String USER_ID = "om/polarisclient@root.comops.site"; + + @Test + public void testConstructor() { + final CallerIdentityInfo identity = new CallerIdentityInfo(ACCOUNT, ARN, USER_ID); + + assertEquals(ACCOUNT, identity.getAccount()); + assertEquals(ARN, identity.getArn()); + assertEquals(USER_ID, identity.getUserId()); + } + + @Test + public void testProtobufConversion() { + final CallerIdentityInfo identity = new CallerIdentityInfo(ACCOUNT, ARN, USER_ID); + + final GetCallerIdentityResponse proto = identity.getProtobuf(); + + assertNotNull(proto); + assertEquals(ACCOUNT, proto.getAccount()); + assertEquals(ARN, proto.getArn()); + assertEquals(USER_ID, proto.getUserId()); + } + + @Test + public void testFromProtobuf() { + final GetCallerIdentityResponse proto = GetCallerIdentityResponse.newBuilder() + .setAccount(ACCOUNT) + .setArn(ARN) + .setUserId(USER_ID) + .build(); + + final CallerIdentityInfo identity = CallerIdentityInfo.fromProtobuf(proto); + + assertEquals(ACCOUNT, identity.getAccount()); + assertEquals(ARN, identity.getArn()); + assertEquals(USER_ID, identity.getUserId()); + } + + @Test + public void testProtobufRoundTrip() { + final CallerIdentityInfo original = new CallerIdentityInfo(ACCOUNT, ARN, USER_ID); + + final CallerIdentityInfo recovered = CallerIdentityInfo.fromProtobuf(original.getProtobuf()); + + assertEquals(original, recovered); + } + + @Test + public void testEqualsAndHashCodeWithIdenticalObjects() { + final CallerIdentityInfo identity1 = new CallerIdentityInfo(ACCOUNT, ARN, USER_ID); + final CallerIdentityInfo identity2 = new CallerIdentityInfo(ACCOUNT, ARN, USER_ID); + + assertEquals(identity1, identity2); + assertEquals(identity1.hashCode(), identity2.hashCode()); + } + + @Test + public void testNotEqualsWithDifferentArn() { + final CallerIdentityInfo identity1 = new CallerIdentityInfo(ACCOUNT, ARN, USER_ID); + final CallerIdentityInfo identity2 = new CallerIdentityInfo( + ACCOUNT, "arn:aws:iam::123456789012:user/other", USER_ID); + + assertNotEquals(identity1, identity2); + assertNotEquals(identity1.hashCode(), identity2.hashCode()); + } + + @Test + public void testToString() { + final CallerIdentityInfo identity = new CallerIdentityInfo(ACCOUNT, ARN, USER_ID); + + assertEquals( + "CallerIdentityInfo{account='123456789012', arn='" + ARN + "', userId='" + USER_ID + "'}", identity.toString()); + } +} diff --git a/hadoop-ozone/common/src/test/java/org/apache/hadoop/ozone/om/helpers/TestS3STSUtilsCallerIdentity.java b/hadoop-ozone/common/src/test/java/org/apache/hadoop/ozone/om/helpers/TestS3STSUtilsCallerIdentity.java new file mode 100644 index 000000000000..f991bd7bffdc --- /dev/null +++ b/hadoop-ozone/common/src/test/java/org/apache/hadoop/ozone/om/helpers/TestS3STSUtilsCallerIdentity.java @@ -0,0 +1,59 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.hadoop.ozone.om.helpers; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import org.junit.jupiter.api.Test; + +/** + * Test caller identity resolution helpers in S3STSUtils. + */ +public class TestS3STSUtilsCallerIdentity { + + private static final String PRINCIPAL = "om/polarisclient@example.com"; + private static final String KERBEROS_SHORT_NAME = "om"; + private static final String ASSUMED_ROLE_ID = "AROATEST123456789:testsess"; + private static final String ASSUMED_ROLE_USER_ARN = + "arn:aws:sts::123456789012:assumed-role/test-role/testsess"; + + @Test + public void testToIamUserArn() { + assertEquals("arn:aws:iam::123456789012:user/om", S3STSUtils.toIamUserArn(KERBEROS_SHORT_NAME)); + } + + @Test + public void testResolveCallerIdentityForPermanentCredentials() { + final CallerIdentityInfo identity = S3STSUtils.resolveCallerIdentityForPermanentCredentials( + PRINCIPAL, KERBEROS_SHORT_NAME); + + assertEquals(S3STSUtils.OZONE_STATIC_ACCOUNT_ID, identity.getAccount()); + assertEquals("arn:aws:iam::123456789012:user/om", identity.getArn()); + assertEquals(PRINCIPAL, identity.getUserId()); + } + + @Test + public void testResolveCallerIdentityForStsCredentials() { + final CallerIdentityInfo identity = S3STSUtils.resolveCallerIdentityForStsCredentials( + ASSUMED_ROLE_ID, ASSUMED_ROLE_USER_ARN); + + assertEquals(S3STSUtils.OZONE_STATIC_ACCOUNT_ID, identity.getAccount()); + assertEquals(ASSUMED_ROLE_USER_ARN, identity.getArn()); + assertEquals(ASSUMED_ROLE_ID, identity.getUserId()); + } +} diff --git a/hadoop-ozone/common/src/test/java/org/apache/hadoop/ozone/security/acl/iam/TestIamSessionPolicyResolver.java b/hadoop-ozone/common/src/test/java/org/apache/hadoop/ozone/security/acl/iam/TestIamSessionPolicyResolver.java new file mode 100644 index 000000000000..54e3be080eaf --- /dev/null +++ b/hadoop-ozone/common/src/test/java/org/apache/hadoop/ozone/security/acl/iam/TestIamSessionPolicyResolver.java @@ -0,0 +1,2927 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.hadoop.ozone.security.acl.iam; + +import static java.util.Collections.emptySet; +import static org.apache.hadoop.ozone.om.exceptions.OMException.ResultCodes.MALFORMED_POLICY_DOCUMENT; +import static org.apache.hadoop.ozone.om.exceptions.OMException.ResultCodes.NOT_SUPPORTED_OPERATION; +import static org.apache.hadoop.ozone.security.acl.AssumeRoleRequest.OzoneGrant; +import static org.apache.hadoop.ozone.security.acl.IAccessAuthorizer.ACLType; +import static org.apache.hadoop.ozone.security.acl.IAccessAuthorizer.ACLType.CREATE; +import static org.apache.hadoop.ozone.security.acl.IAccessAuthorizer.ACLType.DELETE; +import static org.apache.hadoop.ozone.security.acl.IAccessAuthorizer.ACLType.LIST; +import static org.apache.hadoop.ozone.security.acl.IAccessAuthorizer.ACLType.READ; +import static org.apache.hadoop.ozone.security.acl.IAccessAuthorizer.ACLType.READ_ACL; +import static org.apache.hadoop.ozone.security.acl.IAccessAuthorizer.ACLType.WRITE; +import static org.apache.hadoop.ozone.security.acl.IAccessAuthorizer.ACLType.WRITE_ACL; +import static org.apache.hadoop.ozone.security.acl.iam.IamSessionPolicyResolver.AuthorizerType.NATIVE; +import static org.apache.hadoop.ozone.security.acl.iam.IamSessionPolicyResolver.AuthorizerType.RANGER; +import static org.apache.hadoop.ozone.security.acl.iam.IamSessionPolicyResolver.S3Action.ABORT_MULTIPART_UPLOAD; +import static org.apache.hadoop.ozone.security.acl.iam.IamSessionPolicyResolver.S3Action.CREATE_BUCKET; +import static org.apache.hadoop.ozone.security.acl.iam.IamSessionPolicyResolver.S3Action.DELETE_BUCKET; +import static org.apache.hadoop.ozone.security.acl.iam.IamSessionPolicyResolver.S3Action.DELETE_OBJECT; +import static org.apache.hadoop.ozone.security.acl.iam.IamSessionPolicyResolver.S3Action.DELETE_OBJECT_TAGGING; +import static org.apache.hadoop.ozone.security.acl.iam.IamSessionPolicyResolver.S3Action.GET_BUCKET_ACL; +import static org.apache.hadoop.ozone.security.acl.iam.IamSessionPolicyResolver.S3Action.GET_OBJECT; +import static org.apache.hadoop.ozone.security.acl.iam.IamSessionPolicyResolver.S3Action.GET_OBJECT_TAGGING; +import static org.apache.hadoop.ozone.security.acl.iam.IamSessionPolicyResolver.S3Action.LIST_ALL_MY_BUCKETS; +import static org.apache.hadoop.ozone.security.acl.iam.IamSessionPolicyResolver.S3Action.LIST_BUCKET; +import static org.apache.hadoop.ozone.security.acl.iam.IamSessionPolicyResolver.S3Action.LIST_BUCKET_MULTIPART_UPLOADS; +import static org.apache.hadoop.ozone.security.acl.iam.IamSessionPolicyResolver.S3Action.LIST_MULTIPART_UPLOAD_PARTS; +import static org.apache.hadoop.ozone.security.acl.iam.IamSessionPolicyResolver.S3Action.PUT_BUCKET_ACL; +import static org.apache.hadoop.ozone.security.acl.iam.IamSessionPolicyResolver.S3Action.PUT_OBJECT; +import static org.apache.hadoop.ozone.security.acl.iam.IamSessionPolicyResolver.S3Action.PUT_OBJECT_TAGGING; +import static org.apache.hadoop.ozone.security.acl.iam.IamSessionPolicyResolver.S3ResourceType; +import static org.apache.hadoop.ozone.security.acl.iam.IamSessionPolicyResolver.buildCaseInsensitiveS3ActionMap; +import static org.apache.hadoop.ozone.security.acl.iam.IamSessionPolicyResolver.createPathsAndPermissions; +import static org.apache.hadoop.ozone.security.acl.iam.IamSessionPolicyResolver.groupObjectsByAclsAndActions; +import static org.apache.hadoop.ozone.security.acl.iam.IamSessionPolicyResolver.mapPolicyActionsToS3Actions; +import static org.apache.hadoop.ozone.security.acl.iam.IamSessionPolicyResolver.resolve; +import static org.apache.hadoop.ozone.security.acl.iam.IamSessionPolicyResolver.validateAndCategorizeResources; +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.Collections; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; +import java.util.stream.Stream; +import org.apache.hadoop.ozone.om.exceptions.OMException; +import org.apache.hadoop.ozone.security.acl.IOzoneObj; +import org.apache.hadoop.ozone.security.acl.OzoneObj; +import org.apache.hadoop.ozone.security.acl.OzoneObjInfo; +import org.apache.hadoop.ozone.security.acl.iam.IamSessionPolicyResolver.S3Action; +import org.junit.jupiter.api.Test; + +/** + * Test class for {@link IamSessionPolicyResolver}. + * */ +public class TestIamSessionPolicyResolver { + + private static final String VOLUME = "s3v"; + private static final Set ALL_OBJECT_ACTIONS = strSet( + "AbortMultipartUpload", "DeleteObject", "DeleteObjectTagging", "GetObject", "GetObjectTagging", + "ListMultipartUploadParts", "PutObject", "PutObjectTagging"); + + private static final Set ALL_BUCKET_ACTIONS = strSet( + "CreateBucket", "DeleteBucket", "GetBucketAcl", "ListBucket", "ListBucketMultipartUploads", "PutBucketAcl"); + + private static final Set ALL_BUCKET_ACTIONS_WITH_LIST_ALL_MY_BUCKETS; + + static { + final Set tempSet = new HashSet<>(ALL_BUCKET_ACTIONS); + tempSet.add("ListAllMyBuckets"); + ALL_BUCKET_ACTIONS_WITH_LIST_ALL_MY_BUCKETS = Collections.unmodifiableSet(tempSet); + } + + private static final Set ALL_BUCKET_AND_OBJECT_ACTIONS; + + static { + final Set tempSet = new HashSet<>(ALL_BUCKET_ACTIONS); + tempSet.addAll(ALL_OBJECT_ACTIONS); + ALL_BUCKET_AND_OBJECT_ACTIONS = Collections.unmodifiableSet(tempSet); + } + + private static final Set ALL_OBJECT_ACTIONS_WITH_LIST_BUCKET; + + static { + final Set tempSet = new HashSet<>(ALL_OBJECT_ACTIONS); + tempSet.add("ListBucket"); + ALL_OBJECT_ACTIONS_WITH_LIST_BUCKET = Collections.unmodifiableSet(tempSet); + } + + @Test + public void testUnsupportedConditionOperatorThrows() { + final String json = "{\n" + + " \"Statement\": [{\n" + + " \"Effect\": \"Allow\",\n" + + " \"Action\": \"s3:ListBucket\",\n" + + " \"Resource\": \"arn:aws:s3:::b\",\n" + + " \"Condition\": { \"StringNotEqualsIgnoreCase\": { \"s3:prefix\": \"x/*\" } }\n" + + " }]\n" + + "}"; + + expectResolveThrowsForBothAuthorizers( + json, "IAM session policy: Unsupported Condition operator - StringNotEqualsIgnoreCase", + NOT_SUPPORTED_OPERATION); + } + + @Test + public void testUnsupportedConditionAttributeThrows() { + final String json = "{\n" + + " \"Statement\": [{\n" + + " \"Effect\": \"Allow\",\n" + + " \"Action\": \"s3:ListBucket\",\n" + + " \"Resource\": \"arn:aws:s3:::b\",\n" + + " \"Condition\": { \"StringEquals\": { \"aws:SourceArn\": \"arn:aws:s3:::d\" } }\n" + + " }]\n" + + "}"; + + expectResolveThrowsForBothAuthorizers( + json, "IAM session policy: Unsupported Condition key name - aws:SourceArn", NOT_SUPPORTED_OPERATION); + } + + @Test + public void testMultipleConditionKeysWithPrefixFirstThrows() { + final String json = "{\n" + + " \"Statement\": [{\n" + + " \"Effect\": \"Allow\",\n" + + " \"Action\": \"s3:ListBucket\",\n" + + " \"Resource\": \"arn:aws:s3:::b\",\n" + + " \"Condition\": { \"StringEquals\": { \"s3:prefix\": \"x\", \"aws:SourceIp\": \"1.2.3.4\" } }\n" + + " }]\n" + + "}"; + + expectResolveThrowsForBothAuthorizers( + json, "IAM session policy: Only one Condition key is supported per operator", NOT_SUPPORTED_OPERATION); + } + + @Test + public void testMultipleConditionKeysWithUnsupportedKeyFirstThrows() { + final String json = "{\n" + + " \"Statement\": [{\n" + + " \"Effect\": \"Allow\",\n" + + " \"Action\": \"s3:ListBucket\",\n" + + " \"Resource\": \"arn:aws:s3:::b\",\n" + + " \"Condition\": { \"StringEquals\": { \"aws:SourceIp\": \"1.2.3.4\", \"s3:prefix\": \"x\" } }\n" + + " }]\n" + + "}"; + + expectResolveThrowsForBothAuthorizers( + json, "IAM session policy: Only one Condition key is supported per operator", NOT_SUPPORTED_OPERATION); + } + + @Test + public void testUnsupportedEffectThrows() { + final String json = "{\n" + + " \"Statement\": [{\n" + + " \"Effect\": \"Deny\",\n" + // unsupported effect + " \"Action\": \"s3:ListBucket\",\n" + + " \"Resource\": \"arn:aws:s3:::proj-*\"\n" + + " }]\n" + + "}"; + + expectResolveThrowsForBothAuthorizers( + json, "IAM session policy: Unsupported Effect - Deny", NOT_SUPPORTED_OPERATION); + } + + @Test + public void testInvalidJsonWithoutStatementThrows() { + final String json = "{\n" + + " \"RandomAttribute\": [{\n" + + " \"Effect\": \"Allow\",\n" + + " \"Action\": \"s3:ListBucket\",\n" + + " \"Resource\": \"arn:aws:s3:::b\",\n" + + " \"Condition\": { \"StringEquals\": { \"s3:prefix\": \"x/*\" } }\n" + + " }]\n" + + "}"; + + expectResolveThrowsForBothAuthorizers( + json, "IAM session policy: Invalid policy JSON - missing Statement", MALFORMED_POLICY_DOCUMENT); + } + + @Test + public void testInvalidJsonWithEmptyStatementArrayThrows() { + final String json = "{\n" + + " \"Version\": \"2012-10-17\",\n" + + " \"Statement\": []\n" + + "}"; + + expectResolveThrowsForBothAuthorizers( + json, "IAM session policy: No Statement(s) found in policy", MALFORMED_POLICY_DOCUMENT); + } + + @Test + public void testInvalidEffectThrows() { + final String json = "{\n" + + " \"Statement\": [{\n" + + " \"Effect\": [\"Allow\"],\n" + + " \"Action\": \"s3:ListBucket\",\n" + + " \"Resource\": \"arn:aws:s3:::bucket1\"\n" + + " }]\n" + + "}"; + + expectResolveThrowsForBothAuthorizers( + json, "IAM session policy: Invalid Effect in JSON policy (must be a String) - [\"Allow\"]", + MALFORMED_POLICY_DOCUMENT); + + final String jsonWithNull = "{\n" + + " \"Statement\": [{\n" + + " \"Effect\": null,\n" + + " \"Action\": \"s3:ListBucket\",\n" + + " \"Resource\": \"arn:aws:s3:::bucket1\"\n" + + " }]\n" + + "}"; + + expectResolveThrowsForBothAuthorizers( + jsonWithNull, "IAM session policy: Invalid Effect in JSON policy (must be a String) - null", + MALFORMED_POLICY_DOCUMENT); + } + + @Test + public void testMissingEffectInStatementThrows() { + final String json = "{\n" + + " \"Statement\": [{\n" + + " \"Action\": \"s3:ListBucket\",\n" + + " \"Resource\": \"arn:aws:s3:::bucket1\"\n" + + " }]\n" + + "}"; + + expectResolveThrowsForBothAuthorizers( + json, "IAM session policy: Effect is missing from JSON policy", MALFORMED_POLICY_DOCUMENT); + } + + @Test + public void testDuplicateStatementKeysThrow() { + final String duplicateActionGetThenStar = "{\n" + + " \"Statement\": [{\n" + + " \"Effect\": \"Allow\",\n" + + " \"Action\": \"s3:GetObject\",\n" + + " \"Action\": \"s3:*\",\n" + + " \"Resource\": \"arn:aws:s3:::bucket1/*\"\n" + + " }]\n" + + "}"; + final String duplicateActionStarThenGet = "{\n" + + " \"Statement\": [{\n" + + " \"Effect\": \"Allow\",\n" + + " \"Action\": \"s3:*\",\n" + + " \"Action\": \"s3:GetObject\",\n" + + " \"Resource\": \"arn:aws:s3:::bucket1/*\"\n" + + " }]\n" + + "}"; + final String duplicateEffect = "{\n" + + " \"Statement\": [{\n" + + " \"Effect\": \"Allow\",\n" + + " \"Effect\": \"Deny\",\n" + + " \"Action\": \"s3:GetObject\",\n" + + " \"Resource\": \"arn:aws:s3:::bucket1/*\"\n" + + " }]\n" + + "}"; + final String duplicateResource = "{\n" + + " \"Statement\": [{\n" + + " \"Effect\": \"Allow\",\n" + + " \"Action\": \"s3:GetObject\",\n" + + " \"Resource\": \"arn:aws:s3:::bucket1/*\",\n" + + " \"Resource\": \"arn:aws:s3:::bucket2/*\"\n" + + " }]\n" + + "}"; + + expectDuplicateFieldThrowsForBothAuthorizers(duplicateActionGetThenStar, "Action"); + expectDuplicateFieldThrowsForBothAuthorizers(duplicateActionStarThenGet, "Action"); + expectDuplicateFieldThrowsForBothAuthorizers(duplicateEffect, "Effect"); + expectDuplicateFieldThrowsForBothAuthorizers(duplicateResource, "Resource"); + } + + @Test + public void testDuplicateNestedConditionKeysThrow() { + final String duplicateS3Prefix = "{\n" + + " \"Statement\": [{\n" + + " \"Effect\": \"Allow\",\n" + + " \"Action\": \"s3:ListBucket\",\n" + + " \"Resource\": \"arn:aws:s3:::bucket1\",\n" + + " \"Condition\": { \"StringEquals\": { \"s3:prefix\": \"team/*\", \"s3:prefix\": \"other/*\" } }\n" + + " }]\n" + + "}"; + + expectDuplicateFieldThrowsForBothAuthorizers(duplicateS3Prefix, "s3:prefix"); + } + + @Test + public void testDuplicateConditionAtStatementLevelThrows() { + final String duplicateConditionStringEqualsThenStringLike = "{\n" + + " \"Statement\": [{\n" + + " \"Effect\": \"Allow\",\n" + + " \"Action\": \"s3:ListBucket\",\n" + + " \"Resource\": \"arn:aws:s3:::bucket1\",\n" + + " \"Condition\": { \"StringEquals\": { \"s3:prefix\": \"team/*\" } },\n" + + " \"Condition\": { \"StringLike\": { \"s3:prefix\": \"other/*\" } }\n" + + " }]\n" + + "}"; + final String duplicateConditionStringLikeThenStringEquals = "{\n" + + " \"Statement\": [{\n" + + " \"Effect\": \"Allow\",\n" + + " \"Action\": \"s3:ListBucket\",\n" + + " \"Resource\": \"arn:aws:s3:::bucket1\",\n" + + " \"Condition\": { \"StringLike\": { \"s3:prefix\": \"other/*\" } },\n" + + " \"Condition\": { \"StringEquals\": { \"s3:prefix\": \"team/*\" } }\n" + + " }]\n" + + "}"; + + expectDuplicateFieldThrowsForBothAuthorizers(duplicateConditionStringEqualsThenStringLike, "Condition"); + expectDuplicateFieldThrowsForBothAuthorizers(duplicateConditionStringLikeThenStringEquals, "Condition"); + } + + @Test + public void testInvalidStatementElementThrows() { + final String statementScalar = "{\n" + + " \"Statement\": \"not-an-object\"\n" + + "}"; + final String statementArrayWithNonObject = "{\n" + + " \"Statement\": [\"not-an-object\"]\n" + + "}"; + + expectResolveThrowsForBothAuthorizers( + statementScalar, "IAM session policy: Invalid Statement in JSON policy (must be an Object) - \"not-an-object\"", + MALFORMED_POLICY_DOCUMENT); + expectResolveThrowsForBothAuthorizers( + statementArrayWithNonObject, + "IAM session policy: Invalid Statement in JSON policy (must be an Object) - \"not-an-object\"", + MALFORMED_POLICY_DOCUMENT); + } + + @Test + public void testMissingActionInStatementThrows() { + final String json = "{\n" + + " \"Statement\": [{\n" + + " \"Effect\": \"Allow\",\n" + + " \"Resource\": \"arn:aws:s3:::bucket1/*\"\n" + + " }]\n" + + "}"; + + expectResolveThrowsForBothAuthorizers( + json, "IAM session policy: No Action(s) found in policy", MALFORMED_POLICY_DOCUMENT); + } + + @Test + public void testMissingResourceInStatementThrows() { + final String json = "{\n" + + " \"Statement\": [{\n" + + " \"Effect\": \"Allow\",\n" + + " \"Action\": \"s3:GetObject\"\n" + + " }]\n" + + "}"; + + expectResolveThrowsForBothAuthorizers( + json, "IAM session policy: No Resource(s) found in policy", MALFORMED_POLICY_DOCUMENT); + } + + @Test + public void testNullResourceInStatementThrows() { + final String json = "{\n" + + " \"Statement\": [{\n" + + " \"Effect\": \"Allow\",\n" + + " \"Action\": \"s3:GetObject\",\n" + + " \"Resource\": null\n" + + " }]\n" + + "}"; + + expectResolveThrowsForBothAuthorizers( + json, "IAM session policy: No Resource(s) found in policy", MALFORMED_POLICY_DOCUMENT); + } + + @Test + public void testInvalidResourceInStatementThrows() { + final String json = "{\n" + + " \"Statement\": [{\n" + + " \"Effect\": \"Allow\",\n" + + " \"Action\": \"s3:GetObject\",\n" + + " \"Resource\": \"INVALID\"\n" + + " }]\n" + + "}"; + + expectResolveThrowsForBothAuthorizers( + json, "IAM session policy: Unsupported Resource Arn - INVALID", NOT_SUPPORTED_OPERATION); + } + + @Test + public void testUnsupportedStatementElementsThrow() { + final Set unsupportedStatementElements = strSet("NotAction", "NotResource", "Principal"); + for (String unsupportedStatementElement : unsupportedStatementElements) { + final String json = "{\n" + + " \"Statement\": [{\n" + + " \"Effect\": \"Allow\",\n" + + " \"Action\": \"s3:GetObject\",\n" + + " \"Resource\": \"arn:aws:s3:::bucket1/*\",\n" + + " \"" + unsupportedStatementElement + "\": \"ignored\"\n" + + " }]\n" + + "}"; + + expectResolveThrowsForBothAuthorizers( + json, "IAM session policy: Unsupported statement element - " + unsupportedStatementElement, + MALFORMED_POLICY_DOCUMENT); + } + } + + @Test + public void testInvalidActionShapeThrows() { + final String actionObject = "{\n" + + " \"Statement\": [{\n" + + " \"Effect\": \"Allow\",\n" + + " \"Action\": {\"Name\":\"s3:GetObject\"},\n" + + " \"Resource\": \"arn:aws:s3:::bucket1/*\"\n" + + " }]\n" + + "}"; + final String actionArrayWithNonString = "{\n" + + " \"Statement\": [{\n" + + " \"Effect\": \"Allow\",\n" + + " \"Action\": [\"s3:GetObject\", 1],\n" + + " \"Resource\": \"arn:aws:s3:::bucket1/*\"\n" + + " }]\n" + + "}"; + final String emptyActionArray = "{\n" + + " \"Statement\": [{\n" + + " \"Effect\": \"Allow\",\n" + + " \"Action\": [],\n" + + " \"Resource\": \"arn:aws:s3:::bucket1/*\"\n" + + " }]\n" + + "}"; + final String nullAction = "{\n" + + " \"Statement\": [{\n" + + " \"Effect\": \"Allow\",\n" + + " \"Action\": null,\n" + + " \"Resource\": \"arn:aws:s3:::bucket1/*\"\n" + + " }]\n" + + "}"; + + expectResolveThrowsForBothAuthorizers( + actionObject, "IAM session policy: Invalid Action in JSON policy (must be a String or Array of Strings) - " + + "{\"Name\":\"s3:GetObject\"}", MALFORMED_POLICY_DOCUMENT); + expectResolveThrowsForBothAuthorizers( + actionArrayWithNonString, + "IAM session policy: Invalid Action in JSON policy (must be a String or Array of Strings) - " + + "[\"s3:GetObject\",1]", MALFORMED_POLICY_DOCUMENT); + expectResolveThrowsForBothAuthorizers( + emptyActionArray, "IAM session policy: No Action(s) found in policy", MALFORMED_POLICY_DOCUMENT); + expectResolveThrowsForBothAuthorizers( + nullAction, "IAM session policy: No Action(s) found in policy", MALFORMED_POLICY_DOCUMENT); + } + + @Test + public void testInvalidResourceShapeThrows() { + final String resourceObject = "{\n" + + " \"Statement\": [{\n" + + " \"Effect\": \"Allow\",\n" + + " \"Action\": \"s3:GetObject\",\n" + + " \"Resource\": {\"Arn\":\"arn:aws:s3:::bucket1/*\"}\n" + + " }]\n" + + "}"; + final String resourceArrayWithNonString = "{\n" + + " \"Statement\": [{\n" + + " \"Effect\": \"Allow\",\n" + + " \"Action\": \"s3:GetObject\",\n" + + " \"Resource\": [\"arn:aws:s3:::bucket1/*\", 1]\n" + + " }]\n" + + "}"; + final String emptyResourceArray = "{\n" + + " \"Statement\": [{\n" + + " \"Effect\": \"Allow\",\n" + + " \"Action\": \"s3:GetObject\",\n" + + " \"Resource\": []\n" + + " }]\n" + + "}"; + final String nullResource = "{\n" + + " \"Statement\": [{\n" + + " \"Effect\": \"Allow\",\n" + + " \"Action\": \"s3:GetObject\",\n" + + " \"Resource\": null\n" + + " }]\n" + + "}"; + + expectResolveThrowsForBothAuthorizers( + resourceObject, "IAM session policy: Invalid Resource in JSON policy (must be a String or " + + "Array of Strings) - {\"Arn\":\"arn:aws:s3:::bucket1/*\"}", MALFORMED_POLICY_DOCUMENT); + expectResolveThrowsForBothAuthorizers( + resourceArrayWithNonString, + "IAM session policy: Invalid Resource in JSON policy (must be a String or Array of Strings) - " + + "[\"arn:aws:s3:::bucket1/*\",1]", MALFORMED_POLICY_DOCUMENT); + expectResolveThrowsForBothAuthorizers( + emptyResourceArray, "IAM session policy: No Resource(s) found in policy", MALFORMED_POLICY_DOCUMENT); + expectResolveThrowsForBothAuthorizers( + nullResource, "IAM session policy: No Resource(s) found in policy", MALFORMED_POLICY_DOCUMENT); + } + + @Test + public void testInvalidNumberOfConditionsThrows() { + final String json = "{\n" + + " \"Statement\": [\n" + + " {\n" + + " \"Effect\": \"Allow\",\n" + + " \"Action\": \"s3:ListBucket\",\n" + + " \"Resource\": \"arn:aws:s3:::b\",\n" + + " \"Condition\": [\n" + + " {\n" + + " \"StringEquals\": {\n" + + " \"aws:SourceArn\": \"arn:aws:s3:::d\"\n" + + " }\n" + + " },\n" + + " {\n" + + " \"StringEquals\": {\n" + + " \"aws:SourceArn\": \"arn:aws:s3:::e\"\n" + + " }\n" + + " }\n" + + " ]\n" + + " }\n" + + " ]\n" + + "}"; + + expectResolveThrowsForBothAuthorizers( + json, "IAM session policy: Only one Condition is supported", NOT_SUPPORTED_OPERATION); + } + + @Test + public void testInvalidConditionThrows() { + final String json = "{\n" + + " \"Statement\": [\n" + + " {\n" + + " \"Effect\": \"Allow\",\n" + + " \"Action\": \"s3:ListBucket\",\n" + + " \"Resource\": \"arn:aws:s3:::b\",\n" + + " \"Condition\": [\"RandomCondition\"]\n" + + " }\n" + + " ]\n" + + "}"; + + expectResolveThrowsForBothAuthorizers( + json, "IAM session policy: Invalid Condition (must have operator StringEquals or StringLike and key name " + + "s3:prefix) - [\"RandomCondition\"]", MALFORMED_POLICY_DOCUMENT); + } + + @Test + public void testInvalidConditionAttributeMissingStringEqualsThrows() { + final String json = "{\n" + + " \"Statement\": [{\n" + + " \"Effect\": \"Allow\",\n" + + " \"Action\": \"s3:ListBucket\",\n" + + " \"Resource\": \"arn:aws:s3:::b\",\n" + + " \"Condition\": { \"StringEquals\": null }\n" + + " }]\n" + + "}"; + + expectResolveThrowsForBothAuthorizers( + json, "IAM session policy: Missing Condition operator value for StringEquals", + MALFORMED_POLICY_DOCUMENT); + } + + @Test + public void testInvalidConditionAttributeStructureThrows() { + final String json = "{\n" + + " \"Statement\": [{\n" + + " \"Effect\": \"Allow\",\n" + + " \"Action\": \"s3:ListBucket\",\n" + + " \"Resource\": \"arn:aws:s3:::b\",\n" + + " \"Condition\": { \"StringEquals\": [{ \"s3:prefix\": \"folder/\" }] }\n" + + " }]\n" + + "}"; + + expectResolveThrowsForBothAuthorizers( + json, "IAM session policy: Invalid Condition operator value structure - [{\"s3:prefix\":\"folder/\"}]", + MALFORMED_POLICY_DOCUMENT); + } + + @Test + public void testInvalidS3PrefixConditionValueThrows() { + final String nullPrefix = "{\n" + + " \"Statement\": [{\n" + + " \"Effect\": \"Allow\",\n" + + " \"Action\": \"s3:ListBucket\",\n" + + " \"Resource\": \"arn:aws:s3:::b\",\n" + + " \"Condition\": { \"StringEquals\": { \"s3:prefix\": null } }\n" + + " }]\n" + + "}"; + final String objectPrefix = "{\n" + + " \"Statement\": [{\n" + + " \"Effect\": \"Allow\",\n" + + " \"Action\": \"s3:ListBucket\",\n" + + " \"Resource\": \"arn:aws:s3:::b\",\n" + + " \"Condition\": { \"StringEquals\": { \"s3:prefix\": {} } }\n" + + " }]\n" + + "}"; + final String mixedStringAndNumberArray = "{\n" + + " \"Statement\": [{\n" + + " \"Effect\": \"Allow\",\n" + + " \"Action\": \"s3:ListBucket\",\n" + + " \"Resource\": \"arn:aws:s3:::b\",\n" + + " \"Condition\": { \"StringEquals\": { \"s3:prefix\": [\"team/*\", 1] } }\n" + + " }]\n" + + "}"; + + final String invalidPrefixMessagePrefix = "IAM session policy: Invalid s3:prefix in Condition (must be a " + + "String, Number, Boolean, or homogeneous Array of Strings, Numbers, or Booleans) - "; + + expectResolveThrowsForBothAuthorizers(nullPrefix, invalidPrefixMessagePrefix + "null", MALFORMED_POLICY_DOCUMENT); + expectResolveThrowsForBothAuthorizers(objectPrefix, invalidPrefixMessagePrefix + "{}", MALFORMED_POLICY_DOCUMENT); + expectResolveThrowsForBothAuthorizers( + mixedStringAndNumberArray, invalidPrefixMessagePrefix + "[\"team/*\",1]", MALFORMED_POLICY_DOCUMENT); + } + + @Test + public void testAcceptedS3PrefixConditionValueCoercion() throws OMException { + final String numericScalar = "{\n" + + " \"Statement\": [{\n" + + " \"Effect\": \"Allow\",\n" + + " \"Action\": \"s3:ListBucket\",\n" + + " \"Resource\": \"arn:aws:s3:::my-bucket\",\n" + + " \"Condition\": { \"StringEquals\": { \"s3:prefix\": 123 } }\n" + + " }]\n" + + "}"; + final String booleanScalar = "{\n" + + " \"Statement\": [{\n" + + " \"Effect\": \"Allow\",\n" + + " \"Action\": \"s3:ListBucket\",\n" + + " \"Resource\": \"arn:aws:s3:::my-bucket\",\n" + + " \"Condition\": { \"StringEquals\": { \"s3:prefix\": true } }\n" + + " }]\n" + + "}"; + final String numericArray = "{\n" + + " \"Statement\": [{\n" + + " \"Effect\": \"Allow\",\n" + + " \"Action\": \"s3:ListBucket\",\n" + + " \"Resource\": \"arn:aws:s3:::my-bucket\",\n" + + " \"Condition\": { \"StringEquals\": { \"s3:prefix\": [123] } }\n" + + " }]\n" + + "}"; + + final Set numericScalarNative = resolve(numericScalar, VOLUME, NATIVE); + final Set numericScalarRanger = resolve(numericScalar, VOLUME, RANGER); + assertThat(numericScalarNative).containsExactlyInAnyOrder( + new OzoneGrant(objSet(volume(), prefix("my-bucket", "123")), acls(READ), strSet("ListBucket")), + new OzoneGrant(objSet(bucket("my-bucket")), acls(READ, LIST), strSet("ListBucket"))); + assertThat(numericScalarRanger).containsExactlyInAnyOrder( + new OzoneGrant(objSet(volume(), key("my-bucket", "123")), acls(READ), strSet("ListBucket")), + new OzoneGrant(objSet(bucket("my-bucket")), acls(READ, LIST), strSet("ListBucket"))); + + final Set booleanScalarNative = resolve(booleanScalar, VOLUME, NATIVE); + final Set booleanScalarRanger = resolve(booleanScalar, VOLUME, RANGER); + assertThat(booleanScalarNative).containsExactlyInAnyOrder( + new OzoneGrant(objSet(volume(), prefix("my-bucket", "true")), acls(READ), strSet("ListBucket")), + new OzoneGrant(objSet(bucket("my-bucket")), acls(READ, LIST), strSet("ListBucket"))); + assertThat(booleanScalarRanger).containsExactlyInAnyOrder( + new OzoneGrant(objSet(volume(), key("my-bucket", "true")), acls(READ), strSet("ListBucket")), + new OzoneGrant(objSet(bucket("my-bucket")), acls(READ, LIST), strSet("ListBucket"))); + + final Set numericArrayNative = resolve(numericArray, VOLUME, NATIVE); + final Set numericArrayRanger = resolve(numericArray, VOLUME, RANGER); + assertThat(numericArrayNative).containsExactlyInAnyOrder( + new OzoneGrant(objSet(volume(), prefix("my-bucket", "123")), acls(READ), strSet("ListBucket")), + new OzoneGrant(objSet(bucket("my-bucket")), acls(READ, LIST), strSet("ListBucket"))); + assertThat(numericArrayRanger).containsExactlyInAnyOrder( + new OzoneGrant(objSet(volume(), key("my-bucket", "123")), acls(READ), strSet("ListBucket")), + new OzoneGrant(objSet(bucket("my-bucket")), acls(READ, LIST), strSet("ListBucket"))); + } + + @Test + public void testEmptyS3PrefixConditionArrayDoesNotGrantAccess() throws OMException { + final String emptyPrefixArrayOnAnyResource = "{\n" + + " \"Statement\": [{\n" + + " \"Effect\": \"Allow\",\n" + + " \"Action\": \"s3:ListBucket\",\n" + + " \"Resource\": \"*\",\n" + + " \"Condition\": { \"StringEquals\": { \"s3:prefix\": [] } }\n" + + " }]\n" + + "}"; + final String emptyPrefixArrayOnBucket = "{\n" + + " \"Statement\": [{\n" + + " \"Effect\": \"Allow\",\n" + + " \"Action\": \"s3:ListBucket\",\n" + + " \"Resource\": \"arn:aws:s3:::my-bucket\",\n" + + " \"Condition\": { \"StringEquals\": { \"s3:prefix\": [] } }\n" + + " }]\n" + + "}"; + + assertThat(resolve(emptyPrefixArrayOnAnyResource, VOLUME, NATIVE)).isEmpty(); + assertThat(resolve(emptyPrefixArrayOnAnyResource, VOLUME, RANGER)).isEmpty(); + assertThat(resolve(emptyPrefixArrayOnBucket, VOLUME, NATIVE)).isEmpty(); + assertThat(resolve(emptyPrefixArrayOnBucket, VOLUME, RANGER)).isEmpty(); + } + + @Test + public void testEmptyS3PrefixConditionArrayWithMultipleActionsAndResourcesDoesNotGrantAccess() throws OMException { + final String json = "{\n" + + " \"Statement\": [{\n" + + " \"Effect\": \"Allow\",\n" + + " \"Action\": [\"s3:ListBucket\", \"s3:PutObject\", \"s3:DeleteObject\"],\n" + + " \"Resource\": [\n" + + " \"arn:aws:s3:::my-bucket\",\n" + + " \"arn:aws:s3:::my-bucket/*\"\n" + + " ],\n" + + " \"Condition\": { \"StringEquals\": { \"s3:prefix\": [] } }\n" + + " }]\n" + + "}"; + + assertThat(resolve(json, VOLUME, NATIVE)).isEmpty(); + assertThat(resolve(json, VOLUME, RANGER)).isEmpty(); + } + + @Test + public void testInvalidJsonThrows() { + final String invalidJson = "{[{{}]\"\""; + + expectResolveThrowsForBothAuthorizers( + invalidJson, "IAM session policy: Invalid policy JSON (most likely JSON structure is incorrect)", + MALFORMED_POLICY_DOCUMENT); + } + + @Test + public void testJsonExceedsMaxLengthThrows() { + final String json = createJsonStringLargerThan2048Characters(); + + expectResolveThrowsForBothAuthorizers( + json, "IAM session policy: Invalid policy JSON - exceeds maximum length of 2048 characters", + MALFORMED_POLICY_DOCUMENT); + } + + @Test + public void testJsonAtMaxLengthSucceeds() throws OMException { + // Create a JSON string that is exactly 2048 characters + final String json = create2048CharJsonString(); + assertThat(json.length()).isEqualTo(2048); + + // Must not throw an exception + resolve(json, VOLUME, NATIVE); + resolve(json, VOLUME, RANGER); + } + + @Test + public void testConditionKeyMustBeCaseInsensitive() throws OMException { + final String json = "{\n" + + " \"Statement\": [{\n" + + " \"Effect\": \"Allow\",\n" + + " \"Action\": \"s3:ListBucket\",\n" + + " \"Resource\": \"arn:aws:s3:::b\",\n" + + " \"Condition\": { \"StringEquals\": { \"S3:PRefiX\": \"x/*\" } }\n" + + " }]\n" + + "}"; + + // Must not throw exception + resolve(json, VOLUME, NATIVE); + resolve(json, VOLUME, RANGER); + } + + @Test + public void testEffectMustBeCaseSensitive() { + final String json = "{\n" + + " \"Statement\": [{\n" + + " \"Effect\": \"aLLOw\",\n" + + " \"Action\": \"s3:ListBucket\",\n" + + " \"Resource\": \"arn:aws:s3:::b\",\n" + + " \"Condition\": { \"StringEquals\": { \"s3:prefix\": \"x/*\" } }\n" + + " }]\n" + + "}"; + + expectResolveThrowsForBothAuthorizers( + json, "IAM session policy: Unsupported Effect - aLLOw", NOT_SUPPORTED_OPERATION); + } + + @Test + public void testBuildCaseInsensitiveS3ActionMapMatchesConstant() { + assertThat(buildCaseInsensitiveS3ActionMap()).isEqualTo(IamSessionPolicyResolver.S3_ACTION_MAP_CI); + } + + @Test + public void testBuildCaseInsensitiveS3ActionMap() { + final Map> caseInsensitiveS3ActionMap = buildCaseInsensitiveS3ActionMap(); + // Verify that individual S3 actions are present + assertThat(caseInsensitiveS3ActionMap).containsKeys( + "s3:listbucket", "s3:getobject", "s3:putobject", "s3:deleteobject", "s3:createbucket", "s3:listallmybuckets"); + + // Verify that wildcard actions are present + assertThat(caseInsensitiveS3ActionMap).containsKeys( + "s3:get*", "s3:put*", "s3:list*", "s3:delete*", "s3:create*"); + + // Verify s3:Get* contains Get actions + final Set getActions = caseInsensitiveS3ActionMap.get("s3:get*"); + assertThat(getActions).containsOnly(GET_OBJECT, GET_BUCKET_ACL, GET_OBJECT_TAGGING); + + // Verify s3:Put* contains Put actions + final Set putActions = caseInsensitiveS3ActionMap.get("s3:put*"); + assertThat(putActions).containsOnly(PUT_OBJECT, PUT_OBJECT_TAGGING, PUT_BUCKET_ACL); + + // Verify s3:List* contains List actions + final Set listActions = caseInsensitiveS3ActionMap.get("s3:list*"); + assertThat(listActions).containsOnly( + LIST_BUCKET, LIST_ALL_MY_BUCKETS, LIST_BUCKET_MULTIPART_UPLOADS, LIST_MULTIPART_UPLOAD_PARTS); + + // Verify s3:Delete* contains Delete actions + final Set deleteActions = caseInsensitiveS3ActionMap.get("s3:delete*"); + assertThat(deleteActions).containsOnly(DELETE_OBJECT, DELETE_BUCKET, DELETE_OBJECT_TAGGING); + + // Verify s3:Create* contains Create actions + final Set createActions = caseInsensitiveS3ActionMap.get("s3:create*"); + assertThat(createActions).containsOnly(CREATE_BUCKET); + } + + @Test + public void testBuildCaseInsensitiveS3ActionMapIndividualActionsContainSingleEntry() { + final Map> actionMap = buildCaseInsensitiveS3ActionMap(); + + // Individual actions should map to a set with exactly one entry + final Set listBucketAction = actionMap.get("s3:listbucket"); + assertThat(listBucketAction).hasSize(1); + + final Set getObjectAction = actionMap.get("s3:getobject"); + assertThat(getObjectAction).hasSize(1); + } + + @Test + public void testMapPolicyActionsToS3ActionsWithNullReturnsEmpty() { + final Set result = mapPolicyActionsToS3Actions(null); + assertThat(result).isEmpty(); + } + + @Test + public void testMapPolicyActionsToS3ActionsWithEmptyListReturnsEmpty() { + final Set result = mapPolicyActionsToS3Actions(emptySet()); + assertThat(result).isEmpty(); + } + + @Test + public void testMapPolicyActionsToS3ActionsWithSingleActionMapsCorrectly() { + final Set listBucket = mapPolicyActionsToS3Actions(Collections.singleton("s3:ListBucket")); + assertThat(listBucket).containsOnly(LIST_BUCKET); + + // Ensure case-insensitive action works + final Set listBucketCi = mapPolicyActionsToS3Actions(Collections.singleton("S3:ListBuCKet")); + assertThat(listBucketCi).containsOnly(LIST_BUCKET); + + final Set deleteObject = mapPolicyActionsToS3Actions(Collections.singleton("s3:DeleteObject")); + assertThat(deleteObject).containsOnly(DELETE_OBJECT); + + // Ensure case-insensitive action works + final Set deleteObjectCi = mapPolicyActionsToS3Actions(Collections.singleton("S3:DeLETeObjeCT")); + assertThat(deleteObjectCi).containsOnly(DELETE_OBJECT); + } + + @Test + public void testMapPolicyActionsToS3ActionsWithMultipleActionsMapAllCorrectly() { + final Set result = mapPolicyActionsToS3Actions(strSet("s3:ListBucket", "s3:GetObject", "s3:PutObject")); + assertThat(result).containsOnly(LIST_BUCKET, GET_OBJECT, PUT_OBJECT); + } + + @Test + public void testMapPolicyActionsToS3ActionsWithWildcardExpansion() { + final Set result = mapPolicyActionsToS3Actions(Collections.singleton("s3:Get*")); + assertThat(result).containsOnly(GET_OBJECT, GET_BUCKET_ACL, GET_OBJECT_TAGGING); + + // Ensure it is case-insensitive + final Set resultCi = mapPolicyActionsToS3Actions(Collections.singleton("s3:gET*")); + assertThat(resultCi).containsOnly(GET_OBJECT, GET_BUCKET_ACL, GET_OBJECT_TAGGING); + } + + @Test + public void testMapPolicyActionsToS3ActionsWithS3StarReturnsAll() { + final Set result = mapPolicyActionsToS3Actions(Collections.singleton("s3:*")); + assertThat(result).containsOnly( + LIST_ALL_MY_BUCKETS, CREATE_BUCKET, DELETE_BUCKET, GET_BUCKET_ACL, LIST_BUCKET, LIST_BUCKET_MULTIPART_UPLOADS, + PUT_BUCKET_ACL, ABORT_MULTIPART_UPLOAD, DELETE_OBJECT, DELETE_OBJECT_TAGGING, GET_OBJECT, GET_OBJECT_TAGGING, + LIST_MULTIPART_UPLOAD_PARTS, PUT_OBJECT, PUT_OBJECT_TAGGING); + + final Set resultCi = mapPolicyActionsToS3Actions(Collections.singleton("S3:*")); + assertThat(resultCi).isEqualTo(result); + } + + @Test + public void testMapPolicyActionsToS3ActionsIgnoresUnsupportedActions() { + final Set result = mapPolicyActionsToS3Actions(strSet("s3:GetAccelerateConfiguration", "s3:GetObject")); + // Unsupported action should be silently ignored + assertThat(result).containsOnly(GET_OBJECT); + } + + @Test + public void testMapPolicyActionsToS3ActionsWithOnlyUnsupportedActionsReturnsEmpty() { + final Set result = mapPolicyActionsToS3Actions( + strSet("s3:GetAccelerateConfiguration", "s3:PutBucketVersioning")); + assertThat(result).isEmpty(); + } + + @Test + public void testMapPolicyActionsToS3ActionsDeduplicatesResults() { + final Set result = mapPolicyActionsToS3Actions(strSet("s3:Get*", "s3:GetObject", "s3:GetBucketAcl")); + assertThat(result).containsOnly(GET_OBJECT, GET_BUCKET_ACL, GET_OBJECT_TAGGING); + } + + @Test + public void testMapPolicyActionsToS3ActionsHandlesMultipleWildcards() { + final Set result = mapPolicyActionsToS3Actions(strSet("s3:Get*", "s3:Put*")); + assertThat(result).containsOnly( + GET_OBJECT, GET_BUCKET_ACL, GET_OBJECT_TAGGING, PUT_OBJECT, PUT_OBJECT_TAGGING, PUT_BUCKET_ACL); + } + + @Test + public void testMapPolicyActionsToS3ActionsWithS3StarIgnoresOtherActions() { + final Set result = mapPolicyActionsToS3Actions(strSet("s3:*", "s3:GetObject", "s3:PutObject")); + // When s3:* is present, it should return all supported concrete actions + assertThat(result).containsOnly( + LIST_ALL_MY_BUCKETS, CREATE_BUCKET, DELETE_BUCKET, GET_BUCKET_ACL, LIST_BUCKET, LIST_BUCKET_MULTIPART_UPLOADS, + PUT_BUCKET_ACL, ABORT_MULTIPART_UPLOAD, DELETE_OBJECT, DELETE_OBJECT_TAGGING, GET_OBJECT, GET_OBJECT_TAGGING, + LIST_MULTIPART_UPLOAD_PARTS, PUT_OBJECT, PUT_OBJECT_TAGGING); + } + + @Test + public void testValidateAndCategorizeResourcesWithWildcard() throws OMException { + expectOMExceptionWithCode( + () -> validateAndCategorizeResources(NATIVE, Collections.singleton("*")), + "IAM session policy: Wildcard bucket patterns are not supported for Ozone native authorizer", + NOT_SUPPORTED_OPERATION); + + final Set resultRanger = validateAndCategorizeResources( + RANGER, Collections.singleton("*")); + assertThat(resultRanger).containsOnly( + new IamSessionPolicyResolver.ResourceSpec(S3ResourceType.ANY, "*", null, null)); + } + + @Test + public void testValidateAndCategorizeResourcesWithSingleBucket() throws OMException { + final IamSessionPolicyResolver.ResourceSpec expectedResourceSpec = new IamSessionPolicyResolver.ResourceSpec( + S3ResourceType.BUCKET, "my-bucket", null, null); + + final Set resultNative = validateAndCategorizeResources( + NATIVE, Collections.singleton("arn:aws:s3:::my-bucket")); + assertThat(resultNative).containsOnly(expectedResourceSpec); + + final Set resultRanger = validateAndCategorizeResources( + RANGER, Collections.singleton("arn:aws:s3:::my-bucket")); + assertThat(resultRanger).containsOnly(expectedResourceSpec); + } + + @Test + public void testValidateAndCategorizeResourcesWithBucketWildcard() throws OMException { + final IamSessionPolicyResolver.ResourceSpec expectedResourceSpec = new IamSessionPolicyResolver.ResourceSpec( + S3ResourceType.BUCKET_WILDCARD, "my-bucket*", null, null); + + expectOMExceptionWithCode( + () -> validateAndCategorizeResources(NATIVE, Collections.singleton("arn:aws:s3:::my-bucket*")), + "IAM session policy: Wildcard bucket patterns are not supported for Ozone native authorizer", + NOT_SUPPORTED_OPERATION); + + final Set resultRanger = validateAndCategorizeResources( + RANGER, Collections.singleton("arn:aws:s3:::my-bucket*")); + assertThat(resultRanger).containsOnly(expectedResourceSpec); + } + + @Test + public void testValidateAndCategorizeResourcesWithBucketWildcardAndExactObjectKey() throws OMException { + final IamSessionPolicyResolver.ResourceSpec expectedResourceSpec = new IamSessionPolicyResolver.ResourceSpec( + S3ResourceType.OBJECT_EXACT, "*", null, "myKey.txt"); + + expectOMExceptionWithCode( + () -> validateAndCategorizeResources(NATIVE, Collections.singleton("arn:aws:s3:::*/myKey.txt")), + "IAM session policy: Wildcard bucket patterns are not supported for Ozone native authorizer", + NOT_SUPPORTED_OPERATION); + + final Set resultRanger = validateAndCategorizeResources( + RANGER, Collections.singleton("arn:aws:s3:::*/myKey.txt")); + assertThat(resultRanger).containsOnly(expectedResourceSpec); + } + + @Test + public void testValidateAndCategorizeResourcesWithBucketWildcardAndObjectWildcard() throws OMException { + expectOMExceptionWithCode( + () -> validateAndCategorizeResources(NATIVE, Collections.singleton("arn:aws:s3:::*/*")), + "IAM session policy: Wildcard bucket patterns are not supported for Ozone native authorizer", + NOT_SUPPORTED_OPERATION); + + final IamSessionPolicyResolver.ResourceSpec expectedResourceSpec = new IamSessionPolicyResolver.ResourceSpec( + S3ResourceType.OBJECT_PREFIX_WILDCARD, "*", "*", null); + final Set resultRanger = validateAndCategorizeResources( + RANGER, Collections.singleton("arn:aws:s3:::*/*")); + assertThat(resultRanger).containsOnly(expectedResourceSpec); + } + + @Test + public void testValidateAndCategorizeResourcesWithBucketAndExactObjectKey() throws OMException { + final IamSessionPolicyResolver.ResourceSpec expectedResourceSpec = new IamSessionPolicyResolver.ResourceSpec( + S3ResourceType.OBJECT_EXACT, "bucket1", null, "key.txt"); + + final Set resultNative = validateAndCategorizeResources( + NATIVE, Collections.singleton("arn:aws:s3:::bucket1/key.txt")); + assertThat(resultNative).containsOnly(expectedResourceSpec); + + final Set resultRanger = validateAndCategorizeResources( + RANGER, Collections.singleton("arn:aws:s3:::bucket1/key.txt")); + assertThat(resultRanger).containsOnly(expectedResourceSpec); + } + + @Test + public void testValidateAndCategorizeResourcesWithBucketAndExactObjectKeyWithPath() throws OMException { + final IamSessionPolicyResolver.ResourceSpec expectedResourceSpec = new IamSessionPolicyResolver.ResourceSpec( + S3ResourceType.OBJECT_EXACT, "bucket2", null, "path/folder/nested/key.txt"); + + final Set resultNative = validateAndCategorizeResources( + NATIVE, Collections.singleton("arn:aws:s3:::bucket2/path/folder/nested/key.txt")); + assertThat(resultNative).containsOnly(expectedResourceSpec); + + final Set resultRanger = validateAndCategorizeResources( + RANGER, Collections.singleton("arn:aws:s3:::bucket2/path/folder/nested/key.txt")); + assertThat(resultRanger).containsOnly(expectedResourceSpec); + } + + @Test + public void testValidateAndCategorizeResourcesWithBucketAndObjectPrefixAndEmpty() throws OMException { + final IamSessionPolicyResolver.ResourceSpec expectedNativeResourceSpec = new IamSessionPolicyResolver.ResourceSpec( + S3ResourceType.OBJECT_PREFIX, "bucket3", "", null); + final Set resultNative = validateAndCategorizeResources( + NATIVE, Collections.singleton("arn:aws:s3:::bucket3/*")); + assertThat(resultNative).containsOnly(expectedNativeResourceSpec); + + final IamSessionPolicyResolver.ResourceSpec expectedRangerResourceSpec = new IamSessionPolicyResolver.ResourceSpec( + S3ResourceType.OBJECT_PREFIX_WILDCARD, "bucket3", "*", null); + final Set resultRanger = validateAndCategorizeResources( + RANGER, Collections.singleton("arn:aws:s3:::bucket3/*")); + assertThat(resultRanger).containsOnly(expectedRangerResourceSpec); + } + + @Test + public void testValidateAndCategorizeResourcesWithBucketAndObjectPrefixAndEmptyWithPath() throws OMException { + final IamSessionPolicyResolver.ResourceSpec expectedNativeResourceSpec = new IamSessionPolicyResolver.ResourceSpec( + S3ResourceType.OBJECT_PREFIX, "bucket3", "path/b/", null); + final Set resultNative = validateAndCategorizeResources( + NATIVE, Collections.singleton("arn:aws:s3:::bucket3/path/b/*")); + assertThat(resultNative).containsOnly(expectedNativeResourceSpec); + + final IamSessionPolicyResolver.ResourceSpec expectedRangerResourceSpec = new IamSessionPolicyResolver.ResourceSpec( + S3ResourceType.OBJECT_PREFIX_WILDCARD, "bucket3", "path/b/*", null); + final Set resultRanger = validateAndCategorizeResources( + RANGER, Collections.singleton("arn:aws:s3:::bucket3/path/b/*")); + assertThat(resultRanger).containsOnly(expectedRangerResourceSpec); + } + + @Test + public void testValidateAndCategorizeResourcesWithBucketAndObjectPrefixAndNonEmpty() throws OMException { + final IamSessionPolicyResolver.ResourceSpec expectedNativeResourceSpec = new IamSessionPolicyResolver.ResourceSpec( + S3ResourceType.OBJECT_PREFIX, "bucket3", "test", null); + final Set resultNative = validateAndCategorizeResources( + NATIVE, Collections.singleton("arn:aws:s3:::bucket3/test*")); + assertThat(resultNative).containsOnly(expectedNativeResourceSpec); + + final IamSessionPolicyResolver.ResourceSpec expectedRangerResourceSpec = new IamSessionPolicyResolver.ResourceSpec( + S3ResourceType.OBJECT_PREFIX_WILDCARD, "bucket3", "test*", null); + final Set resultRanger = validateAndCategorizeResources( + RANGER, Collections.singleton("arn:aws:s3:::bucket3/test*")); + assertThat(resultRanger).containsOnly(expectedRangerResourceSpec); + } + + @Test + public void testValidateAndCategorizeResourcesWithBucketAndObjectPrefixAndNonEmptyWithPath() throws OMException { + final IamSessionPolicyResolver.ResourceSpec expectedNativeResourceSpec = new IamSessionPolicyResolver.ResourceSpec( + S3ResourceType.OBJECT_PREFIX, "bucket", "a/b/test", null); + final Set resultNative = validateAndCategorizeResources( + NATIVE, Collections.singleton("arn:aws:s3:::bucket/a/b/test*")); + assertThat(resultNative).containsOnly(expectedNativeResourceSpec); + + final IamSessionPolicyResolver.ResourceSpec expectedRangerResourceSpec = new IamSessionPolicyResolver.ResourceSpec( + S3ResourceType.OBJECT_PREFIX_WILDCARD, "bucket", "a/b/test*", null); + final Set resultRanger = validateAndCategorizeResources( + RANGER, Collections.singleton("arn:aws:s3:::bucket/a/b/test*")); + assertThat(resultRanger).containsOnly(expectedRangerResourceSpec); + } + + @Test + public void testValidateAndCategorizeResourcesWithBucketAndObjectPrefixWildcardNotAtEnd() throws OMException { + expectOMExceptionWithCode( + () -> validateAndCategorizeResources(NATIVE, Collections.singleton("arn:aws:s3:::bucket3/*.log")), + "IAM session policy: Wildcard prefix patterns are not supported for Ozone native authorizer " + + "if wildcard is not at the end", NOT_SUPPORTED_OPERATION); + + final IamSessionPolicyResolver.ResourceSpec expectedRangerResourceSpec = new IamSessionPolicyResolver.ResourceSpec( + S3ResourceType.OBJECT_PREFIX_WILDCARD, "bucket3", "*.log", null); + final Set resultRanger = validateAndCategorizeResources( + RANGER, Collections.singleton("arn:aws:s3:::bucket3/*.log")); + assertThat(resultRanger).containsOnly(expectedRangerResourceSpec); + } + + @Test + public void testValidateAndCategorizeResourcesWithBucketAndObjectPrefixWildcardNotAtEndWithPath() throws OMException { + expectOMExceptionWithCode( + () -> validateAndCategorizeResources(NATIVE, Collections.singleton("arn:aws:s3:::bucket/a/q/*.ps")), + "IAM session policy: Wildcard prefix patterns are not supported for Ozone native authorizer if " + + "wildcard is not at the end", NOT_SUPPORTED_OPERATION); + + final IamSessionPolicyResolver.ResourceSpec expectedRangerResourceSpec = new IamSessionPolicyResolver.ResourceSpec( + S3ResourceType.OBJECT_PREFIX_WILDCARD, "bucket", "a/q/*.ps", null); + final Set resultRanger = validateAndCategorizeResources( + RANGER, Collections.singleton("arn:aws:s3:::bucket/a/q/*.ps")); + assertThat(resultRanger).containsOnly(expectedRangerResourceSpec); + } + + @Test + public void testValidateAndCategorizeResourcesWithBucketAndObjectPrefixWildcardOneAtEndAndOneNotAtEnd() + throws OMException { + expectOMExceptionWithCode( + () -> validateAndCategorizeResources(NATIVE, Collections.singleton("arn:aws:s3:::bucket3/*key*")), + "IAM session policy: Wildcard prefix patterns are not supported for Ozone native authorizer " + + "if wildcard is not at the end", NOT_SUPPORTED_OPERATION); + + final IamSessionPolicyResolver.ResourceSpec expectedRangerResourceSpec = new IamSessionPolicyResolver.ResourceSpec( + S3ResourceType.OBJECT_PREFIX_WILDCARD, "bucket3", "*key*", null); + final Set resultRanger = validateAndCategorizeResources( + RANGER, Collections.singleton("arn:aws:s3:::bucket3/*key*")); + assertThat(resultRanger).containsOnly(expectedRangerResourceSpec); + } + + @Test + public void testValidateAndCategorizeResourcesWithBucketAndObjectPrefixWildcardOneAtEndAndOneNotAtEndWithPath() + throws OMException { + expectOMExceptionWithCode( + () -> validateAndCategorizeResources(NATIVE, Collections.singleton("arn:aws:s3:::bucket3/a/b/t/*key*")), + "IAM session policy: Wildcard prefix patterns are not supported for Ozone native authorizer " + + "if wildcard is not at the end", NOT_SUPPORTED_OPERATION); + + final IamSessionPolicyResolver.ResourceSpec expectedRangerResourceSpec = new IamSessionPolicyResolver.ResourceSpec( + S3ResourceType.OBJECT_PREFIX_WILDCARD, "bucket3", "a/b/t/*key*", null); + final Set resultRanger = validateAndCategorizeResources( + RANGER, Collections.singleton("arn:aws:s3:::bucket3/a/b/t/*key*")); + assertThat(resultRanger).containsOnly(expectedRangerResourceSpec); + } + + @Test + public void testValidateAndCategorizeResourcesWithMultipleResources() throws OMException { + final Set resultNative = validateAndCategorizeResources( + NATIVE, strSet("arn:aws:s3:::bucket1", "arn:aws:s3:::bucket2/*", "arn:aws:s3:::bucket3/key.txt")); + assertThat(resultNative).containsOnly( + new IamSessionPolicyResolver.ResourceSpec(S3ResourceType.BUCKET, "bucket1", null, null), + new IamSessionPolicyResolver.ResourceSpec(S3ResourceType.OBJECT_PREFIX, "bucket2", "", null), + new IamSessionPolicyResolver.ResourceSpec(S3ResourceType.OBJECT_EXACT, "bucket3", null, "key.txt")); + + final Set resultRanger = validateAndCategorizeResources( + RANGER, strSet("arn:aws:s3:::bucket1", "arn:aws:s3:::bucket2/*", "arn:aws:s3:::bucket3/key.txt")); + assertThat(resultRanger).containsOnly( + new IamSessionPolicyResolver.ResourceSpec(S3ResourceType.BUCKET, "bucket1", null, null), + new IamSessionPolicyResolver.ResourceSpec(S3ResourceType.OBJECT_PREFIX_WILDCARD, "bucket2", "*", null), + new IamSessionPolicyResolver.ResourceSpec(S3ResourceType.OBJECT_EXACT, "bucket3", null, "key.txt")); + } + + @Test + public void testValidateAndCategorizeResourcesWithInvalidArnThrows() { + final String invalidArn = "arn:aws:ec2:::bucket"; + expectOMExceptionWithCode( + () -> validateAndCategorizeResources(NATIVE, Collections.singleton(invalidArn)), + "IAM session policy: Unsupported Resource Arn - " + invalidArn, NOT_SUPPORTED_OPERATION); + expectOMExceptionWithCode( + () -> validateAndCategorizeResources(RANGER, Collections.singleton(invalidArn)), + "IAM session policy: Unsupported Resource Arn - " + invalidArn, NOT_SUPPORTED_OPERATION); + } + + @Test + public void testValidateAndCategorizeResourcesWithArnWithNoBucketThrows() { + expectOMExceptionWithCode( + () -> validateAndCategorizeResources(NATIVE, Collections.singleton("arn:aws:s3:::")), + "IAM session policy: Invalid Resource Arn - arn:aws:s3:::", MALFORMED_POLICY_DOCUMENT); + expectOMExceptionWithCode( + () -> validateAndCategorizeResources(RANGER, Collections.singleton("arn:aws:s3:::")), + "IAM session policy: Invalid Resource Arn - arn:aws:s3:::", MALFORMED_POLICY_DOCUMENT); + } + + @Test + public void testValidateAndCategorizeResourcesWithNoResourcesThrows() { + expectOMExceptionWithCode( + () -> validateAndCategorizeResources(NATIVE, emptySet()), "IAM session policy: No Resource(s) found in policy", + MALFORMED_POLICY_DOCUMENT); + expectOMExceptionWithCode( + () -> validateAndCategorizeResources(RANGER, emptySet()), "IAM session policy: No Resource(s) found in policy", + MALFORMED_POLICY_DOCUMENT); + } + + @Test + public void testCreatePathsAndPermissionsWithResourceAny() { + // This also tests that acls are deduplicated across different resource types + final Set actions = Stream.of(LIST_ALL_MY_BUCKETS, LIST_BUCKET, GET_OBJECT) + .collect(Collectors.toSet()); // actions at volume, bucket and key levels + final Set resourceSpecs = Collections.singleton( + new IamSessionPolicyResolver.ResourceSpec(S3ResourceType.ANY, "*", null, null)); + + expectIllegalArgumentException( + () -> createPathsAndPermissions( + VOLUME, NATIVE, actions, resourceSpecs, null, new LinkedHashMap<>(), new LinkedHashMap<>()), + "ResourceSpec type ANY not supported for OzoneNativeAuthorizer"); + + final Map> objToAclsMapRanger = new LinkedHashMap<>(); + final Map> objToActionsMapRanger = new LinkedHashMap<>(); + createPathsAndPermissions(VOLUME, RANGER, actions, resourceSpecs, null, objToAclsMapRanger, objToActionsMapRanger); + final Set resultRanger = groupObjectsByAclsAndActions(objToAclsMapRanger, objToActionsMapRanger); + // volume and bucket have READ, LIST; key has READ; result is now grouped by (ACLs, S3 actions) + assertThat(resultRanger).containsExactlyInAnyOrder( + new OzoneGrant(objSet(volume()), acls(READ, LIST), strSet("ListAllMyBuckets", "ListBucket", "GetObject")), + new OzoneGrant(objSet(bucket("*")), acls(READ, LIST), strSet("ListBucket", "GetObject")), + new OzoneGrant(objSet(key("*", "*")), acls(READ), strSet("ListBucket", "GetObject"))); + } + + @Test + public void testCreatePathsAndPermissionsWithBucketResourceThatIsListBucket() { + final Set actions = Collections.singleton(LIST_BUCKET); + final Set resourceSpecs = Collections.singleton( + new IamSessionPolicyResolver.ResourceSpec(S3ResourceType.BUCKET, "bucket1", null, null)); + final Set readAndListObject = objSet(bucket("bucket1")); + + final Map> objToAclsMapNative = new LinkedHashMap<>(); + final Map> objToActionsMapNative = new LinkedHashMap<>(); + final Set nativeReadObjects = objSet(volume(), prefix("bucket1", "")); + createPathsAndPermissions(VOLUME, NATIVE, actions, resourceSpecs, null, objToAclsMapNative, objToActionsMapNative); + final Set resultNative = groupObjectsByAclsAndActions(objToAclsMapNative, objToActionsMapNative); + assertThat(resultNative).containsExactlyInAnyOrder( + new OzoneGrant(readAndListObject, acls(READ, LIST), strSet("ListBucket")), + new OzoneGrant(nativeReadObjects, acls(READ), strSet("ListBucket"))); + + final Map> objToAclsMapRanger = new LinkedHashMap<>(); + final Map> objToActionsMapRanger = new LinkedHashMap<>(); + final Set rangerReadObjects = objSet(volume(), key("bucket1", "*")); + createPathsAndPermissions(VOLUME, RANGER, actions, resourceSpecs, null, objToAclsMapRanger, objToActionsMapRanger); + final Set resultRanger = groupObjectsByAclsAndActions(objToAclsMapRanger, objToActionsMapRanger); + assertThat(resultRanger).containsExactlyInAnyOrder( + new OzoneGrant(readAndListObject, acls(READ, LIST), strSet("ListBucket")), + new OzoneGrant(rangerReadObjects, acls(READ), strSet("ListBucket"))); + } + + @Test + public void testCreatePathsAndPermissionsWithBucketResourceThatIsNotListBucket() { + final Set actions = Collections.singleton(CREATE_BUCKET); + final Set resourceSpecs = Collections.singleton( + new IamSessionPolicyResolver.ResourceSpec(S3ResourceType.BUCKET, "bucket1", null, null)); + final Set createObject = objSet(bucket("bucket1")); + final Set readObject = objSet(volume()); + + final Map> objToAclsMapNative = new LinkedHashMap<>(); + final Map> objToActionsMapNative = new LinkedHashMap<>(); + createPathsAndPermissions(VOLUME, NATIVE, actions, resourceSpecs, null, objToAclsMapNative, objToActionsMapNative); + final Set resultNative = groupObjectsByAclsAndActions(objToAclsMapNative, objToActionsMapNative); + assertThat(resultNative).containsExactlyInAnyOrder( + new OzoneGrant(createObject, acls(CREATE), strSet("CreateBucket")), + new OzoneGrant(readObject, acls(READ), strSet("CreateBucket"))); + + final Map> objToAclsMapRanger = new LinkedHashMap<>(); + final Map> objToActionsMapRanger = new LinkedHashMap<>(); + createPathsAndPermissions(VOLUME, RANGER, actions, resourceSpecs, null, objToAclsMapRanger, objToActionsMapRanger); + final Set resultRanger = groupObjectsByAclsAndActions(objToAclsMapRanger, objToActionsMapRanger); + assertThat(resultRanger).containsExactlyInAnyOrder( + new OzoneGrant(createObject, acls(CREATE), strSet("CreateBucket")), + new OzoneGrant(readObject, acls(READ), strSet("CreateBucket"))); + } + + @Test + public void testCreatePathsAndPermissionsWithBucketWildcardResource() { + final Set actions = Collections.singleton(PUT_BUCKET_ACL); + final Set resourceSpecs = Collections.singleton( + new IamSessionPolicyResolver.ResourceSpec(S3ResourceType.BUCKET_WILDCARD, "bucket1*", null, null)); + final Set readReadAclAndWriteAclObject = objSet(bucket("bucket1*")); + final Set readVolume = objSet(volume()); + + expectIllegalArgumentException( + () -> createPathsAndPermissions( + VOLUME, NATIVE, actions, resourceSpecs, null, new LinkedHashMap<>(), new LinkedHashMap<>()), + "ResourceSpec type BUCKET_WILDCARD not supported for OzoneNativeAuthorizer"); + + final Map> objToAclsMapRanger = new LinkedHashMap<>(); + final Map> objToActionsMapRanger = new LinkedHashMap<>(); + createPathsAndPermissions(VOLUME, RANGER, actions, resourceSpecs, null, objToAclsMapRanger, objToActionsMapRanger); + final Set resultRanger = groupObjectsByAclsAndActions(objToAclsMapRanger, objToActionsMapRanger); + assertThat(resultRanger).containsExactlyInAnyOrder( + new OzoneGrant(readReadAclAndWriteAclObject, acls(READ, READ_ACL, WRITE_ACL), strSet("PutBucketAcl")), + new OzoneGrant(readVolume, acls(READ), strSet("PutBucketAcl"))); + } + + @Test + public void testCreatePathsAndPermissionsWithBucketsWildcardResourceAll() { + // For AWS IAM, s3:ListAllMyBuckets supports both "*" and "arn:aws:s3:::*" as + // Resource values. The "*" case is covered by testCreatePathsAndPermissionsWithResourceAny. + // This test ensures that "arn:aws:s3:::*" (parsed as BUCKET_WILDCARD with bucket="*") + // also grants the expected volume-level permissions for ListAllMyBuckets. + final Set actions = Stream.of(LIST_ALL_MY_BUCKETS, LIST_BUCKET) + .collect(Collectors.toSet()); + final Set resourceSpecs = Collections.singleton( + new IamSessionPolicyResolver.ResourceSpec(S3ResourceType.BUCKET_WILDCARD, "*", null, null)); + + expectIllegalArgumentException( + () -> createPathsAndPermissions( + VOLUME, NATIVE, actions, resourceSpecs, null, new LinkedHashMap<>(), new LinkedHashMap<>()), + "ResourceSpec type BUCKET_WILDCARD not supported for OzoneNativeAuthorizer"); + + final Map> objToAclsMapRanger = new LinkedHashMap<>(); + final Map> objToActionsMapRanger = new LinkedHashMap<>(); + createPathsAndPermissions(VOLUME, RANGER, actions, resourceSpecs, null, objToAclsMapRanger, objToActionsMapRanger); + + // Both the volume and the wildcard bucket should end up with READ + LIST permissions. + // We also need READ access on the keys + final Set resultRanger = groupObjectsByAclsAndActions(objToAclsMapRanger, objToActionsMapRanger); + final Set volumeObj = objSet(volume()); + final Set bucketObj = objSet(bucket("*")); + final Set readObjects = objSet(key("*", "*")); + assertThat(resultRanger).containsExactlyInAnyOrder( + new OzoneGrant(volumeObj, acls(READ, LIST), strSet("ListAllMyBuckets", "ListBucket")), + new OzoneGrant(bucketObj, acls(READ, LIST), strSet("ListBucket")), + new OzoneGrant(readObjects, acls(READ), strSet("ListBucket"))); + } + + @Test + public void testCreatePathsAndPermissionsWithObjectExactResource() { + final Set actions = Collections.singleton(GET_OBJECT); + final Set resourceSpecs = Collections.singleton( + new IamSessionPolicyResolver.ResourceSpec(S3ResourceType.OBJECT_EXACT, "bucket1", null, "key.txt")); + final Set readVolumeBucketAndKey = objSet(volume(), bucket("bucket1"), key("bucket1", "key.txt")); + + final Map> objToAclsMapNative = new LinkedHashMap<>(); + final Map> objToActionsMapNative = new LinkedHashMap<>(); + createPathsAndPermissions(VOLUME, NATIVE, actions, resourceSpecs, null, objToAclsMapNative, objToActionsMapNative); + final Set resultNative = groupObjectsByAclsAndActions(objToAclsMapNative, objToActionsMapNative); + assertThat(resultNative).containsExactlyInAnyOrder( + new OzoneGrant(readVolumeBucketAndKey, acls(READ), strSet("GetObject"))); + + final Map> objToAclsMapRanger = new LinkedHashMap<>(); + final Map> objToActionsMapRanger = new LinkedHashMap<>(); + createPathsAndPermissions(VOLUME, RANGER, actions, resourceSpecs, null, objToAclsMapRanger, objToActionsMapRanger); + final Set resultRanger = groupObjectsByAclsAndActions(objToAclsMapRanger, objToActionsMapRanger); + assertThat(resultRanger).containsExactlyInAnyOrder( + new OzoneGrant(readVolumeBucketAndKey, acls(READ), strSet("GetObject"))); + } + + @Test + public void testCreatePathsAndPermissionsWithDeleteObjectGrantsDeleteOnKey() { + final Set actions = Collections.singleton(DELETE_OBJECT); + final Set resourceSpecs = Collections.singleton( + new IamSessionPolicyResolver.ResourceSpec(S3ResourceType.OBJECT_EXACT, "bucket1", null, "key.txt")); + final Set readVolumeAndBucket = objSet(volume(), bucket("bucket1")); + final Set deleteKey = objSet(key("bucket1", "key.txt")); + + final Map> objToAclsMapNative = new LinkedHashMap<>(); + final Map> objToActionsMapNative = new LinkedHashMap<>(); + createPathsAndPermissions(VOLUME, NATIVE, actions, resourceSpecs, null, objToAclsMapNative, objToActionsMapNative); + final Set resultNative = groupObjectsByAclsAndActions(objToAclsMapNative, objToActionsMapNative); + assertThat(resultNative).containsExactlyInAnyOrder( + new OzoneGrant(readVolumeAndBucket, acls(READ), strSet("DeleteObject")), + new OzoneGrant(deleteKey, acls(DELETE), strSet("DeleteObject"))); + + final Map> objToAclsMapRanger = new LinkedHashMap<>(); + final Map> objToActionsMapRanger = new LinkedHashMap<>(); + createPathsAndPermissions(VOLUME, RANGER, actions, resourceSpecs, null, objToAclsMapRanger, objToActionsMapRanger); + final Set resultRanger = groupObjectsByAclsAndActions(objToAclsMapRanger, objToActionsMapRanger); + assertThat(resultRanger).containsExactlyInAnyOrder( + new OzoneGrant(readVolumeAndBucket, acls(READ), strSet("DeleteObject")), + new OzoneGrant(deleteKey, acls(DELETE), strSet("DeleteObject"))); + } + + @Test + public void testCreatePathsAndPermissionsWithAbortMultipartUploadGrantsWriteOnKey() { + final Set actions = Collections.singleton(ABORT_MULTIPART_UPLOAD); + final Set resourceSpecs = Collections.singleton( + new IamSessionPolicyResolver.ResourceSpec(S3ResourceType.OBJECT_EXACT, "bucket1", null, "key.txt")); + final Set readVolumeAndBucket = objSet(volume(), bucket("bucket1")); + final Set writeKey = objSet(key("bucket1", "key.txt")); + + final Map> objToAclsMapNative = new LinkedHashMap<>(); + final Map> objToActionsMapNative = new LinkedHashMap<>(); + createPathsAndPermissions(VOLUME, NATIVE, actions, resourceSpecs, null, objToAclsMapNative, objToActionsMapNative); + final Set resultNative = groupObjectsByAclsAndActions(objToAclsMapNative, objToActionsMapNative); + assertThat(resultNative).containsExactlyInAnyOrder( + new OzoneGrant(readVolumeAndBucket, acls(READ), strSet("AbortMultipartUpload")), + new OzoneGrant(writeKey, acls(WRITE), strSet("AbortMultipartUpload"))); + + final Map> objToAclsMapRanger = new LinkedHashMap<>(); + final Map> objToActionsMapRanger = new LinkedHashMap<>(); + createPathsAndPermissions(VOLUME, RANGER, actions, resourceSpecs, null, objToAclsMapRanger, objToActionsMapRanger); + final Set resultRanger = groupObjectsByAclsAndActions(objToAclsMapRanger, objToActionsMapRanger); + assertThat(resultRanger).containsExactlyInAnyOrder( + new OzoneGrant(readVolumeAndBucket, acls(READ), strSet("AbortMultipartUpload")), + new OzoneGrant(writeKey, acls(WRITE), strSet("AbortMultipartUpload"))); + } + + @Test + public void testCreatePathsAndPermissionsWithObjectPrefixResource() { + final Set actions = Collections.singleton(GET_OBJECT); + + final Set resourceSpecs = Collections.singleton( + new IamSessionPolicyResolver.ResourceSpec(S3ResourceType.OBJECT_PREFIX, "bucket1", "prefix/", null)); + final Set nativeReadVolumeBucketAndPrefix = objSet( + bucket("bucket1"), volume(), prefix("bucket1", "prefix/")); + final Map> objToAclsMapNative = new LinkedHashMap<>(); + final Map> objToActionsMapNative = new LinkedHashMap<>(); + createPathsAndPermissions(VOLUME, NATIVE, actions, resourceSpecs, null, objToAclsMapNative, objToActionsMapNative); + final Set resultNative = groupObjectsByAclsAndActions(objToAclsMapNative, objToActionsMapNative); + assertThat(resultNative).containsExactlyInAnyOrder( + new OzoneGrant(nativeReadVolumeBucketAndPrefix, acls(READ), strSet("GetObject"))); + + expectIllegalArgumentException( + () -> createPathsAndPermissions( + VOLUME, RANGER, actions, resourceSpecs, null, new LinkedHashMap<>(), new LinkedHashMap<>()), + "ResourceSpec type OBJECT_PREFIX not supported for RangerOzoneAuthorizer"); + } + + @Test + public void testCreatePathsAndPermissionsWithObjectPrefixWildcardResource() { + final Set actions = Collections.singleton(GET_OBJECT); + final Set resourceSpecs = Collections.singleton( + new IamSessionPolicyResolver.ResourceSpec(S3ResourceType.OBJECT_PREFIX_WILDCARD, "bucket1", "prefix/*", null)); + + expectIllegalArgumentException( + () -> createPathsAndPermissions( + VOLUME, NATIVE, actions, resourceSpecs, null, new LinkedHashMap<>(), new LinkedHashMap<>()), + "ResourceSpec type OBJECT_PREFIX_WILDCARD not supported for OzoneNativeAuthorizer"); + + final Map> objToAclsMapRanger = new LinkedHashMap<>(); + final Map> objToActionsMapRanger = new LinkedHashMap<>(); + createPathsAndPermissions(VOLUME, RANGER, actions, resourceSpecs, null, objToAclsMapRanger, objToActionsMapRanger); + final Set resultRanger = groupObjectsByAclsAndActions(objToAclsMapRanger, objToActionsMapRanger); + assertThat(resultRanger).containsExactlyInAnyOrder( + new OzoneGrant( + objSet(bucket("bucket1"), volume(), key("bucket1", "prefix/*")), acls(READ), strSet("GetObject"))); + } + + @Test + public void testCreatePathsAndPermissionsWithConditionPrefixesForObjectActionMustIgnoreConditionPrefixes() { + final Set actions = Collections.singleton(GET_OBJECT); + final Set prefixes = strSet("folder1/", "folder2/"); + final IamSessionPolicyResolver.Condition condition = new IamSessionPolicyResolver.Condition( + "StringEquals", prefixes); + + final Set nativeResourceSpecs = Collections.singleton( + new IamSessionPolicyResolver.ResourceSpec(S3ResourceType.OBJECT_PREFIX, "bucket1", "", null)); + final Map> objToAclsMapNative = new LinkedHashMap<>(); + final Map> objToActionsMapNative = new LinkedHashMap<>(); + final Set nativeReadVolumeBucketAndPrefix = objSet(bucket("bucket1"), volume(), prefix("bucket1", "")); + createPathsAndPermissions( + VOLUME, NATIVE, actions, nativeResourceSpecs, condition, objToAclsMapNative, objToActionsMapNative); + final Set resultNative = groupObjectsByAclsAndActions(objToAclsMapNative, objToActionsMapNative); + assertThat(resultNative).containsExactlyInAnyOrder( + new OzoneGrant(nativeReadVolumeBucketAndPrefix, acls(READ), strSet("GetObject"))); + + final Set rangerResourceSpecs = Collections.singleton( + new IamSessionPolicyResolver.ResourceSpec(S3ResourceType.OBJECT_PREFIX_WILDCARD, "bucket1", "*", null)); + final Map> objToAclsMapRanger = new LinkedHashMap<>(); + final Map> objToActionsMapRanger = new LinkedHashMap<>(); + final Set rangerReadVolumeBucketAndKey = objSet(bucket("bucket1"), volume(), key("bucket1", "*")); + createPathsAndPermissions( + VOLUME, RANGER, actions, rangerResourceSpecs, condition, objToAclsMapRanger, objToActionsMapRanger); + final Set resultRanger = groupObjectsByAclsAndActions(objToAclsMapRanger, objToActionsMapRanger); + assertThat(resultRanger).containsExactlyInAnyOrder( + new OzoneGrant(rangerReadVolumeBucketAndKey, acls(READ), strSet("GetObject"))); + } + + @Test + public void testCreatePathsAndPermissionsWithConditionPrefixesForBucketActionWhenActionIsListBucket() { + final Set actions = Collections.singleton(LIST_BUCKET); + final Set prefixes = strSet("folder1/", "folder2/"); + final IamSessionPolicyResolver.Condition condition = new IamSessionPolicyResolver.Condition( + "StringEquals", prefixes); + + final Set nativeResourceSpecs = Collections.singleton( + new IamSessionPolicyResolver.ResourceSpec(S3ResourceType.BUCKET, "bucket1", null, null)); + final Set nativeReadObjects = objSet( + prefix("bucket1", "folder1/"), prefix("bucket1", "folder2/"), volume()); + final Set nativeReadAndListObject = objSet(bucket("bucket1")); + final Map> objToAclsMapNative = new LinkedHashMap<>(); + final Map> objToActionsMapNative = new LinkedHashMap<>(); + createPathsAndPermissions( + VOLUME, NATIVE, actions, nativeResourceSpecs, condition, objToAclsMapNative, objToActionsMapNative); + final Set resultNative = groupObjectsByAclsAndActions(objToAclsMapNative, objToActionsMapNative); + assertThat(resultNative).containsExactlyInAnyOrder( + new OzoneGrant(nativeReadAndListObject, acls(READ, LIST), strSet("ListBucket")), + new OzoneGrant(nativeReadObjects, acls(READ), strSet("ListBucket"))); + + final Set rangerResourceSpecs = Collections.singleton( + new IamSessionPolicyResolver.ResourceSpec(S3ResourceType.BUCKET, "bucket1", null, null)); + final Set rangerReadObjects = objSet( + key("bucket1", "folder1/"), key("bucket1", "folder2/"), volume()); + final Set rangerReadAndListObject = objSet(bucket("bucket1")); + final Map> objToAclsMapRanger = new LinkedHashMap<>(); + final Map> objToActionsMapRanger = new LinkedHashMap<>(); + createPathsAndPermissions( + VOLUME, RANGER, actions, rangerResourceSpecs, condition, objToAclsMapRanger, objToActionsMapRanger); + final Set resultRanger = groupObjectsByAclsAndActions(objToAclsMapRanger, objToActionsMapRanger); + assertThat(resultRanger).containsExactlyInAnyOrder( + new OzoneGrant(rangerReadAndListObject, acls(READ, LIST), strSet("ListBucket")), + new OzoneGrant(rangerReadObjects, acls(READ), strSet("ListBucket"))); + } + + @Test + public void testCreatePathsAndPermissionsWithConditionPrefixesForBucketActionWhenActionIsNotListBucket() { + final Set actions = Collections.singleton(GET_BUCKET_ACL); + final Set prefixes = strSet("folder1/", "folder2/"); + final IamSessionPolicyResolver.Condition condition = new IamSessionPolicyResolver.Condition( + "StringEquals", prefixes); + final Set readObject = objSet(volume()); + final Set readAndReadAclObject = objSet(bucket("bucket1")); + + final Set nativeResourceSpecs = Collections.singleton( + new IamSessionPolicyResolver.ResourceSpec(S3ResourceType.BUCKET, "bucket1", null, null)); + final Map> objToAclsMapNative = new LinkedHashMap<>(); + final Map> objToActionsMapNative = new LinkedHashMap<>(); + createPathsAndPermissions( + VOLUME, NATIVE, actions, nativeResourceSpecs, condition, objToAclsMapNative, objToActionsMapNative); + final Set resultNative = groupObjectsByAclsAndActions(objToAclsMapNative, objToActionsMapNative); + assertThat(resultNative).containsExactlyInAnyOrder( + new OzoneGrant(readObject, acls(READ), strSet("GetBucketAcl")), + new OzoneGrant(readAndReadAclObject, acls(READ, READ_ACL), strSet("GetBucketAcl"))); + + final Set rangerResourceSpecs = Collections.singleton( + new IamSessionPolicyResolver.ResourceSpec(S3ResourceType.BUCKET, "bucket1", null, null)); + final Map> objToAclsMapRanger = new LinkedHashMap<>(); + final Map> objToActionsMapRanger = new LinkedHashMap<>(); + createPathsAndPermissions( + VOLUME, RANGER, actions, rangerResourceSpecs, condition, objToAclsMapRanger, objToActionsMapRanger); + final Set resultRanger = groupObjectsByAclsAndActions(objToAclsMapRanger, objToActionsMapRanger); + assertThat(resultRanger).containsExactlyInAnyOrder( + new OzoneGrant(readObject, acls(READ), strSet("GetBucketAcl")), + new OzoneGrant(readAndReadAclObject, acls(READ, READ_ACL), strSet("GetBucketAcl"))); + } + + @Test + public void testCreatePathsAndPermissionsWithNoMappedActions() { + final Set actions = emptySet(); + + final Set nativeResourceSpecs = Collections.singleton( + new IamSessionPolicyResolver.ResourceSpec(S3ResourceType.OBJECT_PREFIX, "bucket1", null, null)); + final Map> objToAclsMapNative = new LinkedHashMap<>(); + final Map> objToActionsMapNative = new LinkedHashMap<>(); + createPathsAndPermissions( + VOLUME, NATIVE, actions, nativeResourceSpecs, null, objToAclsMapNative, objToActionsMapNative); + final Set resultNative = groupObjectsByAclsAndActions(objToAclsMapNative, objToActionsMapNative); + assertThat(resultNative).isEmpty(); + + final Set rangerResourceSpecs = Collections.singleton( + new IamSessionPolicyResolver.ResourceSpec(S3ResourceType.OBJECT_PREFIX_WILDCARD, "bucket1", null, null)); + final Map> objToAclsMapRanger = new LinkedHashMap<>(); + final Map> objToActionsMapRanger = new LinkedHashMap<>(); + createPathsAndPermissions( + VOLUME, RANGER, actions, rangerResourceSpecs, null, objToAclsMapRanger, objToActionsMapRanger); + final Set resultRanger = groupObjectsByAclsAndActions(objToAclsMapRanger, objToActionsMapRanger); + assertThat(resultRanger).isEmpty(); + } + + @Test + public void testCreatePathsAndPermissionsWithNoMappedResources() { + final Set actions = Collections.singleton(GET_OBJECT); + final Set resourceSpecs = emptySet(); + + final Map> objToAclsMapNative = new LinkedHashMap<>(); + final Map> objToActionsMapNative = new LinkedHashMap<>(); + createPathsAndPermissions( + VOLUME, NATIVE, actions, resourceSpecs, null, objToAclsMapNative, objToActionsMapNative); + final Set resultNative = groupObjectsByAclsAndActions(objToAclsMapNative, objToActionsMapNative); + assertThat(resultNative).isEmpty(); + + final Map> objToAclsMapRanger = new LinkedHashMap<>(); + final Map> objToActionsMapRanger = new LinkedHashMap<>(); + createPathsAndPermissions( + VOLUME, RANGER, actions, resourceSpecs, null, objToAclsMapRanger, objToActionsMapRanger); + final Set resultRanger = groupObjectsByAclsAndActions(objToAclsMapRanger, objToActionsMapRanger); + assertThat(resultRanger).isEmpty(); + } + + @Test + public void testCreatePathsAndPermissionsDeduplicatesAcrossSameResourceTypes() { + final Set actions = Stream.of(GET_OBJECT, GET_OBJECT_TAGGING, DELETE_OBJECT, DELETE_OBJECT_TAGGING) + .collect(Collectors.toSet()); + final Set resourceSpecs = Collections.singleton( + new IamSessionPolicyResolver.ResourceSpec(S3ResourceType.OBJECT_EXACT, "bucket1", null, "key.txt")); + final Set readAndDeleteAndWriteObject = objSet(key("bucket1", "key.txt")); + final Set readObjects = objSet(bucket("bucket1"), volume()); + + final Map> objToAclsMapNative = new LinkedHashMap<>(); + final Map> objToActionsMapNative = new LinkedHashMap<>(); + createPathsAndPermissions(VOLUME, NATIVE, actions, resourceSpecs, null, objToAclsMapNative, objToActionsMapNative); + final Set resultNative = groupObjectsByAclsAndActions(objToAclsMapNative, objToActionsMapNative); + assertThat(resultNative).containsExactlyInAnyOrder( + new OzoneGrant( + readAndDeleteAndWriteObject, acls(READ, DELETE, WRITE), + strSet("GetObject", "GetObjectTagging", "DeleteObject", "DeleteObjectTagging")), + new OzoneGrant(readObjects, acls(READ), + strSet("GetObject", "GetObjectTagging", "DeleteObject", "DeleteObjectTagging"))); + + final Map> objToAclsMapRanger = new LinkedHashMap<>(); + final Map> objToActionsMapRanger = new LinkedHashMap<>(); + createPathsAndPermissions(VOLUME, RANGER, actions, resourceSpecs, null, objToAclsMapRanger, objToActionsMapRanger); + final Set resultRanger = groupObjectsByAclsAndActions(objToAclsMapRanger, objToActionsMapRanger); + assertThat(resultRanger).containsExactlyInAnyOrder( + new OzoneGrant( + readAndDeleteAndWriteObject, acls(READ, DELETE, WRITE), + strSet("GetObject", "GetObjectTagging", "DeleteObject", "DeleteObjectTagging")), + new OzoneGrant(readObjects, acls(READ), + strSet("GetObject", "GetObjectTagging", "DeleteObject", "DeleteObjectTagging"))); + } + + @Test + public void testCreatePathsAndPermissionsWithAllConcreteS3Actions() { + final Set actions = Stream.of(S3Action.values()) + .collect(Collectors.toSet()); + final Set resourceSpecs = Stream.of( + new IamSessionPolicyResolver.ResourceSpec(S3ResourceType.OBJECT_EXACT, "bucket1", null, "key.txt"), + new IamSessionPolicyResolver.ResourceSpec(S3ResourceType.BUCKET, "bucket2", null, null)) + .collect(Collectors.toSet()); + final Map> objToAclsMapNative = new LinkedHashMap<>(); + final Map> objToActionsMapNative = new LinkedHashMap<>(); + createPathsAndPermissions(VOLUME, NATIVE, actions, resourceSpecs, null, objToAclsMapNative, objToActionsMapNative); + final Set resultNative = groupObjectsByAclsAndActions(objToAclsMapNative, objToActionsMapNative); + assertThat(resultNative).containsExactlyInAnyOrder( + new OzoneGrant(objSet(key("bucket1", "key.txt")), acls(READ, CREATE, WRITE, DELETE), ALL_OBJECT_ACTIONS), + new OzoneGrant(objSet(bucket("bucket1")), acls(READ), ALL_OBJECT_ACTIONS), + new OzoneGrant(objSet(volume()), acls(READ), ALL_BUCKET_AND_OBJECT_ACTIONS), + new OzoneGrant( + objSet(bucket("bucket2")), acls(READ, LIST, CREATE, DELETE, READ_ACL, WRITE_ACL), ALL_BUCKET_ACTIONS), + new OzoneGrant(objSet(prefix("bucket2", "")), acls(READ), strSet("ListBucket"))); + + final Map> objToAclsMapRanger = new LinkedHashMap<>(); + final Map> objToActionsMapRanger = new LinkedHashMap<>(); + createPathsAndPermissions(VOLUME, RANGER, actions, resourceSpecs, null, objToAclsMapRanger, objToActionsMapRanger); + final Set resultRanger = groupObjectsByAclsAndActions(objToAclsMapRanger, objToActionsMapRanger); + assertThat(resultRanger).containsExactlyInAnyOrder( + new OzoneGrant(objSet(key("bucket1", "key.txt")), acls(READ, CREATE, WRITE, DELETE), ALL_OBJECT_ACTIONS), + new OzoneGrant(objSet(bucket("bucket1")), acls(READ), ALL_OBJECT_ACTIONS), + new OzoneGrant(objSet(volume()), acls(READ), ALL_BUCKET_AND_OBJECT_ACTIONS), + new OzoneGrant( + objSet(bucket("bucket2")), acls(READ, LIST, CREATE, DELETE, READ_ACL, WRITE_ACL), ALL_BUCKET_ACTIONS), + new OzoneGrant(objSet(key("bucket2", "*")), acls(READ), strSet("ListBucket"))); + + } + + @Test + public void testDeduplicatesAcrossMultipleStatementsWhenSameStatementsArePresent() throws OMException { + final String json = "{\n" + + " \"Version\": \"2012-10-17\",\n" + + " \"Statement\": [\n" + + " {\n" + + " \"Effect\": \"Allow\",\n" + + " \"Action\": [\n" + + " \"s3:GetBucketAcl\",\n" + + " \"s3:PutBucketAcl\",\n" + + " \"s3:ListBucket\"\n" + + " ],\n" + + " \"Resource\": \"arn:aws:s3:::my-bucket\"\n" + + " },\n" + + " {\n" + + " \"Effect\": \"Allow\",\n" + + " \"Action\": [\n" + + " \"s3:GetBucketAcl\",\n" + + " \"s3:PutBucketAcl\",\n" + + " \"s3:ListBucket\"\n" + + " ],\n" + + " \"Resource\": \"arn:aws:s3:::my-bucket\"\n" + + " }\n" + + " ]\n" + + "}"; + + final Set resolvedFromNativeAuthorizer = resolve(json, VOLUME, NATIVE); + final Set resolvedFromRangerAuthorizer = resolve(json, VOLUME, RANGER); + + // Ensure what we got is what we expected + final Set expectedResolvedNative = new LinkedHashSet<>(); + // Expected for native: bucket READ, LIST, READ_ACL, WRITE_ACL; volume and prefix "" READ + final Set bucketSet = objSet(bucket("my-bucket")); + final Set bucketAcls = acls(READ, LIST, READ_ACL, WRITE_ACL); + expectedResolvedNative.add( + new OzoneGrant(bucketSet, bucketAcls, strSet("GetBucketAcl", "PutBucketAcl", "ListBucket"))); + expectedResolvedNative.add(new OzoneGrant(objSet(prefix("my-bucket", "")), acls(READ), strSet("ListBucket"))); + expectedResolvedNative.add( + new OzoneGrant(objSet(volume()), acls(READ), strSet("GetBucketAcl", "PutBucketAcl", "ListBucket"))); + assertThat(resolvedFromNativeAuthorizer).isEqualTo(expectedResolvedNative); + + final Set expectedResolvedRanger = new LinkedHashSet<>(); + // Expected for Ranger: bucket READ, LIST, READ_ACL, WRITE_ACL; volume and key "*" READ + expectedResolvedRanger.add( + new OzoneGrant(bucketSet, bucketAcls, strSet("GetBucketAcl", "PutBucketAcl", "ListBucket"))); + expectedResolvedRanger.add( + new OzoneGrant( + objSet(volume()), acls(READ), strSet("GetBucketAcl", "PutBucketAcl", "ListBucket"))); + expectedResolvedRanger.add(new OzoneGrant(objSet(key("my-bucket", "*")), acls(READ), strSet("ListBucket"))); + assertThat(resolvedFromRangerAuthorizer).isEqualTo(expectedResolvedRanger); + } + + @Test + public void testDeduplicatesAcrossMultipleStatementsForSameActionsButDifferentResource() throws OMException { + final String json = "{\n" + + " \"Version\": \"2012-10-17\",\n" + + " \"Statement\": [\n" + + " {\n" + + " \"Effect\": \"Allow\",\n" + + " \"Action\": [\n" + + " \"s3:GetBucketAcl\",\n" + + " \"s3:PutBucketAcl\",\n" + + " \"s3:ListBucket\"\n" + + " ],\n" + + " \"Resource\": \"arn:aws:s3:::my-bucket\"\n" + + " },\n" + + " {\n" + + " \"Effect\": \"Allow\",\n" + + " \"Action\": [\n" + + " \"s3:GetBucketAcl\",\n" + + " \"s3:PutBucketAcl\",\n" + + " \"s3:ListBucket\"\n" + + " ],\n" + + " \"Resource\": \"arn:aws:s3:::my-bucket2\"\n" + + " }\n" + + " ]\n" + + "}"; + + final Set resolvedFromNativeAuthorizer = resolve(json, VOLUME, NATIVE); + final Set resolvedFromRangerAuthorizer = resolve(json, VOLUME, RANGER); + + // Ensure what we got is what we expected + final Set expectedResolvedNative = new LinkedHashSet<>(); + // Expected for native: bucket READ, LIST, READ_ACL, WRITE_ACL; volume and prefix "" READ + final Set bucketSet = objSet(bucket("my-bucket"), bucket("my-bucket2")); + final Set bucketAcls = acls(READ, LIST, READ_ACL, WRITE_ACL); + expectedResolvedNative.add( + new OzoneGrant(bucketSet, bucketAcls, strSet("GetBucketAcl", "PutBucketAcl", "ListBucket"))); + expectedResolvedNative.add(new OzoneGrant( + objSet(prefix("my-bucket2", ""), prefix("my-bucket", "")), acls(READ), strSet("ListBucket"))); + expectedResolvedNative.add( + new OzoneGrant(objSet(volume()), acls(READ), strSet("GetBucketAcl", "PutBucketAcl", "ListBucket"))); + assertThat(resolvedFromNativeAuthorizer).isEqualTo(expectedResolvedNative); + + final Set expectedResolvedRanger = new LinkedHashSet<>(); + // Expected for Ranger: bucket READ, LIST, READ_ACL, WRITE_ACL; volume and key "*" READ + expectedResolvedRanger.add( + new OzoneGrant(bucketSet, bucketAcls, strSet("GetBucketAcl", "PutBucketAcl", "ListBucket"))); + expectedResolvedRanger.add( + new OzoneGrant(objSet(key("my-bucket2", "*"), key("my-bucket", "*")), acls(READ), strSet("ListBucket"))); + expectedResolvedRanger.add( + new OzoneGrant(objSet(volume()), acls(READ), strSet("GetBucketAcl", "PutBucketAcl", "ListBucket"))); + assertThat(resolvedFromRangerAuthorizer).isEqualTo(expectedResolvedRanger); + } + + @Test + public void testDeduplicatesAcrossMultipleStatementsForDifferentActionsButSameResource() throws OMException { + final String json = "{\n" + + " \"Version\": \"2012-10-17\",\n" + + " \"Statement\": [\n" + + " {\n" + + " \"Effect\": \"Allow\",\n" + + " \"Action\": [\n" + + " \"s3:GetBucketAcl\",\n" + + " \"s3:PutBucketAcl\",\n" + + " \"s3:ListBucket\"\n" + + " ],\n" + + " \"Resource\": \"arn:aws:s3:::my-bucket\"\n" + + " },\n" + + " {\n" + + " \"Effect\": \"Allow\",\n" + + " \"Action\": [\n" + + " \"s3:GetBucketAcl\",\n" + + " \"s3:CreateBucket\"\n" + + " ],\n" + + " \"Resource\": \"arn:aws:s3:::my-bucket\"\n" + + " }\n" + + " ]\n" + + "}"; + + final Set resolvedFromNativeAuthorizer = resolve(json, VOLUME, NATIVE); + final Set resolvedFromRangerAuthorizer = resolve(json, VOLUME, RANGER); + + // Ensure what we got is what we expected + final Set expectedResolvedNative = new LinkedHashSet<>(); + // Expected for native: bucket READ, LIST, READ_ACL, WRITE_ACL, CREATE; volume, prefix "" READ + final Set bucketSet = objSet(bucket("my-bucket")); + final Set bucketAcls = acls(READ, LIST, READ_ACL, WRITE_ACL, CREATE); + expectedResolvedNative.add( + new OzoneGrant(bucketSet, bucketAcls, strSet("GetBucketAcl", "PutBucketAcl", "ListBucket", "CreateBucket"))); + expectedResolvedNative.add(new OzoneGrant(objSet(prefix("my-bucket", "")), acls(READ), strSet("ListBucket"))); + expectedResolvedNative.add( + new OzoneGrant( + objSet(volume()), acls(READ), strSet("GetBucketAcl", "PutBucketAcl", "ListBucket", "CreateBucket"))); + assertThat(resolvedFromNativeAuthorizer).isEqualTo(expectedResolvedNative); + + final Set expectedResolvedRanger = new LinkedHashSet<>(); + // Expected for Ranger: bucket READ, LIST, READ_ACL, WRITE_ACL, CREATE; volume, key "*" READ + expectedResolvedRanger.add( + new OzoneGrant(bucketSet, bucketAcls, strSet("GetBucketAcl", "PutBucketAcl", "ListBucket", "CreateBucket"))); + expectedResolvedRanger.add(new OzoneGrant(objSet(key("my-bucket", "*")), acls(READ), strSet("ListBucket"))); + expectedResolvedRanger.add( + new OzoneGrant( + objSet(volume()), acls(READ), strSet("GetBucketAcl", "PutBucketAcl", "ListBucket", "CreateBucket"))); + assertThat(resolvedFromRangerAuthorizer).isEqualTo(expectedResolvedRanger); + } + + @Test + public void testDeduplicatesAcrossMultipleStatementsWhenAllActionPresent() throws OMException { + final String json = "{\n" + + " \"Version\": \"2012-10-17\",\n" + + " \"Statement\": [\n" + + " {\n" + + " \"Effect\": \"Allow\",\n" + + " \"Action\": [\n" + + " \"s3:GetBucketAcl\",\n" + + " \"s3:PutBucketAcl\",\n" + + " \"s3:ListBucket\"\n" + + " ],\n" + + " \"Resource\": \"arn:aws:s3:::my-bucket\"\n" + + " },\n" + + " {\n" + + " \"Effect\": \"Allow\",\n" + + " \"Action\": \"s3:*\",\n" + + " \"Resource\": \"arn:aws:s3:::my-bucket\"\n" + + " }\n" + + " ]\n" + + "}"; + + final Set resolvedFromNativeAuthorizer = resolve(json, VOLUME, NATIVE); + final Set resolvedFromRangerAuthorizer = resolve(json, VOLUME, RANGER); + + // Ensure what we got is what we expected + final Set expectedResolvedNative = new LinkedHashSet<>(); + // Expected for native: bucket union of supported bucket ACLs; volume READ; prefix "" READ (from ListBucket) + final Set bucketSet = objSet(bucket("my-bucket")); + final Set bucketAcls = acls(READ, LIST, CREATE, DELETE, READ_ACL, WRITE_ACL); + expectedResolvedNative.add(new OzoneGrant(bucketSet, bucketAcls, ALL_BUCKET_ACTIONS)); + expectedResolvedNative.add(new OzoneGrant(objSet(prefix("my-bucket", "")), acls(READ), strSet("ListBucket"))); + expectedResolvedNative.add(new OzoneGrant(objSet(volume()), acls(READ), ALL_BUCKET_ACTIONS)); + assertThat(resolvedFromNativeAuthorizer).isEqualTo(expectedResolvedNative); + + final Set expectedResolvedRanger = new LinkedHashSet<>(); + // Expected for Ranger: bucket union of supported bucket ACLs; volume READ; key "*" READ (from ListBucket) + expectedResolvedRanger.add(new OzoneGrant(bucketSet, bucketAcls, ALL_BUCKET_ACTIONS)); + expectedResolvedRanger.add(new OzoneGrant(objSet(key("my-bucket", "*")), acls(READ), strSet("ListBucket"))); + expectedResolvedRanger.add(new OzoneGrant(objSet(volume()), acls(READ), ALL_BUCKET_ACTIONS)); + assertThat(resolvedFromRangerAuthorizer).isEqualTo(expectedResolvedRanger); + } + + @Test + public void testAllowGetPutOnKey() throws OMException { + final String json = "{\n" + + " \"Version\": \"2012-10-17\",\n" + + " \"Statement\": [{\n" + + " \"Effect\": \"Allow\",\n" + + " \"Action\": [\"s3:GetObject\", \"s3:PutObject\"],\n" + + " \"Resource\": \"arn:aws:s3:::my-bucket/folder/file.txt\"\n" + + " }]\n" + + "}"; + + final Set resolvedFromNativeAuthorizer = resolve(json, VOLUME, NATIVE); + final Set resolvedFromRangerAuthorizer = resolve(json, VOLUME, RANGER); + + // Ensure what we got is what we expected + final Set expectedResolvedFromBothAuthorizers = new LinkedHashSet<>(); + // Expected: READ, CREATE, WRITE on key; bucket READ; volume READ + final Set keySet = objSet(key("my-bucket", "folder/file.txt")); + final Set keyAcls = acls(READ, CREATE, WRITE); + expectedResolvedFromBothAuthorizers.add( + new OzoneGrant(objSet(volume(), bucket("my-bucket")), acls(READ), strSet("GetObject", "PutObject"))); + expectedResolvedFromBothAuthorizers.add( + new OzoneGrant(keySet, keyAcls, strSet("GetObject", "PutObject"))); + + assertThat(resolvedFromNativeAuthorizer).isEqualTo(expectedResolvedFromBothAuthorizers); + assertThat(resolvedFromRangerAuthorizer).isEqualTo(expectedResolvedFromBothAuthorizers); + } + + @Test + public void testAllActionsForKey() throws OMException { + final String json = "{\n" + + " \"Statement\": [{\n" + + " \"Effect\": \"Allow\",\n" + + " \"Action\": \"s3:*\",\n" + + " \"Resource\": \"arn:aws:s3:::my-bucket/*\"\n" + + " }]\n" + + "}"; + + final Set resolvedFromNativeAuthorizer = resolve(json, VOLUME, NATIVE); + final Set resolvedFromRangerAuthorizer = resolve(json, VOLUME, RANGER); + + // Ensure what we got is what we expected + final Set expectedResolvedNative = new LinkedHashSet<>(); + // Expected for native: all supported object ACLs on prefix "" under bucket; bucket READ, volume READ + final Set keyPrefixSet = objSet(prefix("my-bucket", "")); + final Set allKeyAcls = acls(READ, CREATE, WRITE, DELETE); + expectedResolvedNative.add(new OzoneGrant(keyPrefixSet, allKeyAcls, ALL_OBJECT_ACTIONS)); + expectedResolvedNative.add(new OzoneGrant(objSet(volume(), bucket("my-bucket")), acls(READ), ALL_OBJECT_ACTIONS)); + assertThat(resolvedFromNativeAuthorizer).isEqualTo(expectedResolvedNative); + + // Expected for Ranger: all supported object ACLs for resource type KEY with key name "*" + final Set expectedResolvedRanger = new LinkedHashSet<>(); + final Set rangerKeySet = objSet(key("my-bucket", "*")); + expectedResolvedRanger.add(new OzoneGrant(rangerKeySet, allKeyAcls, ALL_OBJECT_ACTIONS)); + expectedResolvedRanger.add(new OzoneGrant(objSet(volume(), bucket("my-bucket")), acls(READ), ALL_OBJECT_ACTIONS)); + assertThat(resolvedFromRangerAuthorizer).isEqualTo(expectedResolvedRanger); + } + + @Test + public void testAllActionsForKeyWithPrefixCondition() throws OMException { + final String json = "{\n" + + " \"Statement\": [{\n" + + " \"Effect\": \"Allow\",\n" + + " \"Action\": \"s3:*\",\n" + + " \"Resource\": \"arn:aws:s3:::my-bucket/*\",\n" + + " \"Condition\": {\n" + + " \"StringLike\": {\n" + + " \"s3:prefix\": [ \"team/folder\", \"team/folder/*\" ]\n" + + " }\n" + + " }\n" + + " }]\n" + + "}"; + + final Set resolvedFromNativeAuthorizer = resolve(json, VOLUME, NATIVE); + final Set resolvedFromRangerAuthorizer = resolve(json, VOLUME, RANGER); + + // Ensure what we got is what we expected - only ListBucket supports s3:prefix and that is a bucket action, + // not object action + assertThat(resolvedFromNativeAuthorizer).isEmpty(); + assertThat(resolvedFromRangerAuthorizer).isEmpty(); + } + + @Test + public void testAllActionsForBucket() throws OMException { + final String json = "{\n" + + " \"Statement\": [{\n" + + " \"Effect\": \"Allow\",\n" + + " \"Action\": \"s3:*\",\n" + + " \"Resource\": \"arn:aws:s3:::my-bucket\"\n" + + " }]\n" + + "}"; + + final Set resolvedFromNativeAuthorizer = resolve(json, VOLUME, NATIVE); + final Set resolvedFromRangerAuthorizer = resolve(json, VOLUME, RANGER); + + // Ensure what we got is what we expected + final Set expectedResolvedNative = new LinkedHashSet<>(); + // Expected for native: union of supported bucket ACLs for bucket; volume READ; prefix "" READ (from ListBucket) + final Set bucketSet = objSet(bucket("my-bucket")); + final Set allBucketAcls = acls(READ, LIST, CREATE, DELETE, READ_ACL, WRITE_ACL); + expectedResolvedNative.add(new OzoneGrant(bucketSet, allBucketAcls, ALL_BUCKET_ACTIONS)); + expectedResolvedNative.add(new OzoneGrant(objSet(prefix("my-bucket", "")), acls(READ), strSet("ListBucket"))); + expectedResolvedNative.add(new OzoneGrant(objSet(volume()), acls(READ), ALL_BUCKET_ACTIONS)); + assertThat(resolvedFromNativeAuthorizer).isEqualTo(expectedResolvedNative); + + // Expected for Ranger: union of supported bucket ACLs for bucket; volume READ; key "*" READ (from ListBucket) + final Set expectedResolvedRanger = new LinkedHashSet<>(); + expectedResolvedRanger.add(new OzoneGrant(bucketSet, allBucketAcls, ALL_BUCKET_ACTIONS)); + expectedResolvedRanger.add(new OzoneGrant(objSet(key("my-bucket", "*")), acls(READ), strSet("ListBucket"))); + expectedResolvedRanger.add(new OzoneGrant(objSet(volume()), acls(READ), ALL_BUCKET_ACTIONS)); + assertThat(resolvedFromRangerAuthorizer).isEqualTo(expectedResolvedRanger); + } + + @Test + public void testAllActionsForBucketWithPrefixCondition() throws OMException { + final String json = "{\n" + + " \"Statement\": [{\n" + + " \"Effect\": \"Allow\",\n" + + " \"Action\": \"s3:*\",\n" + + " \"Resource\": \"arn:aws:s3:::my-bucket\",\n" + + " \"Condition\": {\n" + + " \"StringLike\": {\n" + + " \"s3:prefix\": [ \"team/folder\", \"team/folder/*\" ]\n" + + " }\n" + + " }\n" + + " }]\n" + + "}"; + + final Set resolvedFromNativeAuthorizer = resolve(json, VOLUME, NATIVE); + final Set resolvedFromRangerAuthorizer = resolve(json, VOLUME, RANGER); + + // Ensure what we got is what we expected + final Set expectedResolvedNative = new LinkedHashSet<>(); + // Expected for native: READ, LIST ACLs for bucket (only ListBucket supports s3:prefix); volume READ; + // prefix "team/folder", "team/folder/" READ + final Set bucketSet = objSet(bucket("my-bucket")); + final Set bucketAcls = acls(READ, LIST); + expectedResolvedNative.add( + new OzoneGrant( + objSet(volume(), prefix("my-bucket", "team/folder"), + prefix("my-bucket", "team/folder/")), acls(READ), strSet("ListBucket"))); + expectedResolvedNative.add(new OzoneGrant(bucketSet, bucketAcls, strSet("ListBucket"))); + assertThat(resolvedFromNativeAuthorizer).isEqualTo(expectedResolvedNative); + + // Expected for Ranger: READ, LIST ACLs for bucket (only ListBucket supports s3:prefix); volume READ, + // key "team/folder", "team/folder/*" READ + final Set expectedResolvedRanger = new LinkedHashSet<>(); + expectedResolvedRanger.add( + new OzoneGrant( + objSet(volume(), key("my-bucket", "team/folder"), key("my-bucket", "team/folder/*")), + acls(READ), strSet("ListBucket"))); + expectedResolvedRanger.add(new OzoneGrant(bucketSet, bucketAcls, strSet("ListBucket"))); + assertThat(resolvedFromRangerAuthorizer).isEqualTo(expectedResolvedRanger); + } + + @Test + public void testMultipleResourcesInSeparateStatements() throws OMException { + final String json = "{\n" + + " \"Version\": \"2012-10-17\",\n" + + " \"Statement\": [\n" + + " {\n" + + " \"Effect\": \"Allow\",\n" + + " \"Action\": [\n" + + " \"s3:GetBucketAcl\",\n" + + " \"s3:PutBucketAcl\",\n" + + " \"s3:ListBucket\"\n" + + " ],\n" + + " \"Resource\": \"arn:aws:s3:::my-bucket\"\n" + + " },\n" + + " {\n" + + " \"Effect\": \"Allow\",\n" + + " \"Action\": \"s3:*\",\n" + + " \"Resource\": \"arn:aws:s3:::my-bucket/*\"\n" + + " }\n" + + " ]\n" + + "}"; + + final Set resolvedFromNativeAuthorizer = resolve(json, VOLUME, NATIVE); + final Set resolvedFromRangerAuthorizer = resolve(json, VOLUME, RANGER); + + // Ensure what we got is what we expected + final Set expectedResolvedNative = new LinkedHashSet<>(); + // Expected for native: bucket READ, LIST, READ_ACL, WRITE_ACL; volume READ; prefix "" has all supported object + // ACLs and is further restricted to the object actions + ListBucket. + final Set bucketSet = objSet(bucket("my-bucket")); + final Set bucketAcls = acls(READ, LIST, READ_ACL, WRITE_ACL); + final Set bucketAndObjectActions = new LinkedHashSet<>( + strSet("GetBucketAcl", "PutBucketAcl", "ListBucket")); + bucketAndObjectActions.addAll(ALL_OBJECT_ACTIONS); + expectedResolvedNative.add(new OzoneGrant(bucketSet, bucketAcls, bucketAndObjectActions)); + expectedResolvedNative.add(new OzoneGrant(objSet(volume()), acls(READ), bucketAndObjectActions)); + final Set keyPrefixSet = objSet(prefix("my-bucket", "")); + final Set allKeyAcls = acls(READ, CREATE, WRITE, DELETE); + expectedResolvedNative.add(new OzoneGrant(keyPrefixSet, allKeyAcls, ALL_OBJECT_ACTIONS_WITH_LIST_BUCKET)); + assertThat(resolvedFromNativeAuthorizer).isEqualTo(expectedResolvedNative); + + final Set expectedResolvedRanger = new LinkedHashSet<>(); + // Expected for Ranger: bucket READ, LIST, READ_ACL, WRITE_ACL; volume READ; key "*" has all supported object ACLs + // and is further restricted to the object actions + ListBucket. + expectedResolvedRanger.add(new OzoneGrant(bucketSet, bucketAcls, bucketAndObjectActions)); + expectedResolvedRanger.add(new OzoneGrant(objSet(volume()), acls(READ), bucketAndObjectActions)); + final Set rangerKeySet = objSet(key("my-bucket", "*")); + expectedResolvedRanger.add(new OzoneGrant(rangerKeySet, allKeyAcls, ALL_OBJECT_ACTIONS_WITH_LIST_BUCKET)); + assertThat(resolvedFromRangerAuthorizer).isEqualTo(expectedResolvedRanger); + } + + @Test + public void testMultipleResourcesInOneStatement() throws OMException { + final String json = "{\n" + + " \"Version\": \"2012-10-17\",\n" + + " \"Statement\": [\n" + + " {\n" + + " \"Effect\": \"Allow\",\n" + + " \"Action\": [\n" + + " \"s3:*\"\n" + + " ],\n" + + " \"Resource\": [\n" + + " \"arn:aws:s3:::my-bucket\",\n" + + " \"arn:aws:s3:::my-bucket/*\"\n" + + " ]\n" + + " }\n" + + " ]\n" + + "}"; + + final Set resolvedFromNativeAuthorizer = resolve(json, VOLUME, NATIVE); + final Set resolvedFromRangerAuthorizer = resolve(json, VOLUME, RANGER); + + // Ensure what we got is what we expected + final Set expectedResolvedNative = new LinkedHashSet<>(); + // Expected for native: bucket union of supported bucket ACLs; prefix "" union of supported object ACLs; volume READ + final Set bucketSet = objSet(bucket("my-bucket")); + expectedResolvedNative.add( + new OzoneGrant( + bucketSet, acls(READ, LIST, CREATE, DELETE, READ_ACL, WRITE_ACL), ALL_BUCKET_AND_OBJECT_ACTIONS)); + expectedResolvedNative.add( + new OzoneGrant( + objSet(prefix("my-bucket", "")), acls(READ, CREATE, WRITE, DELETE), ALL_OBJECT_ACTIONS_WITH_LIST_BUCKET)); + expectedResolvedNative.add(new OzoneGrant(objSet(volume()), acls(READ), ALL_BUCKET_AND_OBJECT_ACTIONS)); + assertThat(resolvedFromNativeAuthorizer).isEqualTo(expectedResolvedNative); + + final Set expectedResolvedRanger = new LinkedHashSet<>(); + // Expected for Ranger: bucket union of supported bucket ACLs; key "*" union of supported object ACLs; volume READ + expectedResolvedRanger.add( + new OzoneGrant( + bucketSet, acls(READ, LIST, CREATE, DELETE, READ_ACL, WRITE_ACL), ALL_BUCKET_AND_OBJECT_ACTIONS)); + expectedResolvedRanger.add( + new OzoneGrant( + objSet(key("my-bucket", "*")), acls(READ, CREATE, WRITE, DELETE), ALL_OBJECT_ACTIONS_WITH_LIST_BUCKET)); + expectedResolvedRanger.add(new OzoneGrant(objSet(volume()), acls(READ), ALL_BUCKET_AND_OBJECT_ACTIONS)); + assertThat(resolvedFromRangerAuthorizer).isEqualTo(expectedResolvedRanger); + } + + @Test + public void testMultipleResourcesWithDifferentBucketsAndDeepPathsInOneStatement() throws OMException { + final String json = "{\n" + + " \"Version\": \"2012-10-17\",\n" + + " \"Statement\": [\n" + + " {\n" + + " \"Effect\": \"Allow\",\n" + + " \"Action\": [\n" + + " \"s3:*\"\n" + + " ],\n" + + " \"Resource\": [\n" + + " \"arn:aws:s3:::my-bucket/team/folder1/security/*\",\n" + + " \"arn:aws:s3:::my-bucket2/team/folder2/misc/*\"\n" + + " ]\n" + + " }\n" + + " ]\n" + + "}"; + + final Set resolvedFromNativeAuthorizer = resolve(json, VOLUME, NATIVE); + final Set resolvedFromRangerAuthorizer = resolve(json, VOLUME, RANGER); + + // Ensure what we got is what we expected + final Set expectedResolvedNative = new LinkedHashSet<>(); + // Expected for native: all supported object ACLs on both prefixes; bucket READ; volume READ + final Set keyPrefixSet = objSet( + prefix("my-bucket", "team/folder1/security/"), prefix("my-bucket2", "team/folder2/misc/")); + expectedResolvedNative.add(new OzoneGrant(keyPrefixSet, acls(READ, CREATE, WRITE, DELETE), ALL_OBJECT_ACTIONS)); + expectedResolvedNative.add( + new OzoneGrant(objSet(volume(), bucket("my-bucket"), bucket("my-bucket2")), acls(READ), ALL_OBJECT_ACTIONS)); + assertThat(resolvedFromNativeAuthorizer).isEqualTo(expectedResolvedNative); + + final Set expectedResolvedRanger = new LinkedHashSet<>(); + // Expected for Ranger: all supported object ACLs for resource type KEY with key name + // "team/folder1/security/*" under my-bucket and "team/folder2/misc/*" under my-bucket2; bucket READ; volume READ + final Set rangerKeySet = objSet( + key("my-bucket", "team/folder1/security/*"), key("my-bucket2", "team/folder2/misc/*")); + expectedResolvedRanger.add(new OzoneGrant(rangerKeySet, acls(READ, CREATE, WRITE, DELETE), ALL_OBJECT_ACTIONS)); + expectedResolvedRanger.add( + new OzoneGrant(objSet(volume(), bucket("my-bucket"), bucket("my-bucket2")), acls(READ), ALL_OBJECT_ACTIONS)); + assertThat(resolvedFromRangerAuthorizer).isEqualTo(expectedResolvedRanger); + } + + @Test + public void testUnsupportedActionIgnoredWhenItIsTheOnlyAction() throws OMException { + final String json = "{\n" + + " \"Statement\": [{\n" + + " \"Effect\": \"Allow\",\n" + + " \"Action\": \"s3:ReplicateObject\",\n" + // unsupported action + " \"Resource\": \"*\"\n" + + " }]\n" + + "}"; + + final Set resolvedFromNativeAuthorizer = resolve(json, VOLUME, NATIVE); + final Set resolvedFromRangerAuthorizer = resolve(json, VOLUME, RANGER); + + // Ensure what we got is what we expected + assertThat(resolvedFromNativeAuthorizer).isEmpty(); + assertThat(resolvedFromRangerAuthorizer).isEmpty(); + } + + @Test + public void testUnsupportedResourceArnThrows() { + final String json = "{\n" + + " \"Statement\": [{\n" + + " \"Effect\": \"Allow\",\n" + + " \"Action\": \"s3:ListBucket\",\n" + + " \"Resource\": \"arn:aws:dynamodb:us-east-2:123456789012:table/example-table\"\n" + + " }]\n" + + "}"; + + expectResolveThrowsForBothAuthorizers( + json, "IAM session policy: Unsupported Resource Arn - " + + "arn:aws:dynamodb:us-east-2:123456789012:table/example-table", NOT_SUPPORTED_OPERATION); + } + + @Test + public void testListBucketWithWildcard() throws OMException { + final String json = "{\n" + + " \"Statement\": [{\n" + + " \"Effect\": \"Allow\",\n" + + " \"Action\": \"s3:ListBucket\",\n" + + " \"Resource\": \"arn:aws:s3:::proj-*\"\n" + + " }]\n" + + "}"; + + // Wildcards on bucket are not supported for Native authorizer + expectBucketWildcardUnsupportedExceptionForNativeAuthorizer(json); + + final Set resolvedFromRangerAuthorizer = resolve(json, VOLUME, RANGER); + // Ensure what we got is what we expected + final Set expectedResolvedRanger = new LinkedHashSet<>(); + // Expected for Ranger: bucket READ and LIST on wildcard pattern; volume and key "*" READ + final Set bucketSet = objSet(bucket("proj-*")); + final Set bucketAcls = acls(READ, LIST); + expectedResolvedRanger.add(new OzoneGrant(bucketSet, bucketAcls, strSet("ListBucket"))); + expectedResolvedRanger.add(new OzoneGrant(objSet(volume(), key("proj-*", "*")), acls(READ), strSet("ListBucket"))); + assertThat(resolvedFromRangerAuthorizer).isEqualTo(expectedResolvedRanger); + } + + @Test + public void testListBucketOperationsWithNoPrefixes() throws OMException { + final String json = "{\n" + + " \"Statement\": [\n" + + " {\n" + + " \"Effect\": \"Allow\",\n" + + " \"Action\": [\n" + + " \"s3:ListBucket\",\n" + + " \"s3:ListBucketMultipartUploads\"\n" + + " ],\n" + + " \"Resource\": \"arn:aws:s3:::proj\"\n" + + " }\n" + + " ]\n" + + "}"; + + final Set resolvedFromNativeAuthorizer = resolve(json, VOLUME, NATIVE); + final Set resolvedFromRangerAuthorizer = resolve(json, VOLUME, RANGER); + + // Ensure what we got is what we expected + final Set expectedResolvedNative = new LinkedHashSet<>(); + // Expected for native: bucket READ and LIST; volume, prefix "" READ + final Set bucketSet = objSet(bucket("proj")); + final Set bucketAcls = acls(READ, LIST); + expectedResolvedNative.add( + new OzoneGrant(bucketSet, bucketAcls, strSet("ListBucket", "ListBucketMultipartUploads"))); + expectedResolvedNative.add( + new OzoneGrant(objSet(volume()), acls(READ), strSet("ListBucket", "ListBucketMultipartUploads"))); + expectedResolvedNative.add( + new OzoneGrant(objSet(prefix("proj", "")), acls(READ), strSet("ListBucket"))); + assertThat(resolvedFromNativeAuthorizer).isEqualTo(expectedResolvedNative); + + // Expected for Ranger: bucket READ and LIST; volume, key "*" READ + final Set expectedResolvedRanger = new LinkedHashSet<>(); + expectedResolvedRanger.add( + new OzoneGrant(bucketSet, bucketAcls, strSet("ListBucket", "ListBucketMultipartUploads"))); + expectedResolvedRanger.add( + new OzoneGrant(objSet(volume()), acls(READ), strSet("ListBucket", "ListBucketMultipartUploads"))); + expectedResolvedRanger.add( + new OzoneGrant(objSet(key("proj", "*")), acls(READ), strSet("ListBucket"))); + assertThat(resolvedFromRangerAuthorizer).isEqualTo(expectedResolvedRanger); + } + + @Test + public void testIgnoresUnsupportedActionsWhenSupportedActionsAreIncluded() throws OMException { + final String json = "{\n" + + " \"Version\": \"2012-10-17\",\n" + + " \"Statement\": [\n" + + " {\n" + + " \"Sid\": \"AllowListingOfDataLakeFolder\",\n" + + " \"Effect\": \"Allow\",\n" + + " \"Action\": [\n" + + " \"s3:GetAccelerateConfiguration\",\n" + // unsupported action + " \"s3:GetBucketAcl\",\n" + // ignored because it doesn't support s3:prefix condition + " \"s3:GetObject\",\n" + // object-level action not applied for bucket + " \"s3:GetObjectAcl\",\n" + // unsupported action + " \"s3:ListBucket\",\n" + + " \"s3:ListBucketMultipartUploads\"\n" + // ignored because it doesn't support s3:prefix condition + " ],\n" + + " \"Resource\": \"arn:aws:s3:::bucket1\",\n" + + " \"Condition\": {\n" + + " \"StringLike\": {\n" + + " \"s3:prefix\": [ \"team/folder\", \"team/folder/*\" ]\n" + + " }\n" + + " }\n" + + " }\n" + + " ]\n" + + "}"; + + final Set resolvedFromNativeAuthorizer = resolve(json, VOLUME, NATIVE); + final Set resolvedFromRangerAuthorizer = resolve(json, VOLUME, RANGER); + + // Ensure what we got is what we expected + final Set expectedResolvedNative = new LinkedHashSet<>(); + + // Expected for native: READ, LIST bucket acls; volume and prefixes "team/folder", "team/folder/" READ + final Set bucketSet = objSet(bucket("bucket1")); + final Set bucketAcls = acls(READ, LIST); + expectedResolvedNative.add(new OzoneGrant(bucketSet, bucketAcls, strSet("ListBucket"))); + expectedResolvedNative.add( + new OzoneGrant( + objSet(volume(), prefix("bucket1", "team/folder"), prefix("bucket1", "team/folder/")), + acls(READ), strSet("ListBucket"))); + assertThat(resolvedFromNativeAuthorizer).isEqualTo(expectedResolvedNative); + + final Set expectedResolvedRanger = new LinkedHashSet<>(); + // Expected for Ranger: READ, LIST bucket acls; volume and keys "team/folder" and "team/folder/*" READ + expectedResolvedRanger.add(new OzoneGrant(bucketSet, bucketAcls, strSet("ListBucket"))); + expectedResolvedRanger.add( + new OzoneGrant( + objSet(volume(), key("bucket1", "team/folder"), key("bucket1", "team/folder/*")), + acls(READ), strSet("ListBucket"))); + assertThat(resolvedFromRangerAuthorizer).isEqualTo(expectedResolvedRanger); + } + + @Test + public void testMultiplePrefixesWithWildcards() throws OMException { + final String json = "{\n" + + " \"Statement\": [{\n" + + " \"Effect\": \"Allow\",\n" + + " \"Action\": \"s3:GetObject\",\n" + + " \"Resource\": \"arn:aws:s3:::logs/*\",\n" + + " \"Condition\": { \"StringEquals\": { \"s3:prefix\": [\"a/*\", \"b/*\"] } }\n" + + " }]\n" + + "}"; + + final Set resolvedFromNativeAuthorizer = resolve(json, VOLUME, NATIVE); + final Set resolvedFromRangerAuthorizer = resolve(json, VOLUME, RANGER); + + // s3:prefix conditions do not apply to object actions like s3:GetObject. + assertThat(resolvedFromNativeAuthorizer).isEmpty(); + assertThat(resolvedFromRangerAuthorizer).isEmpty(); + } + + @Test + public void testListAndGetWithPrefixConditionSkipsObjectAction() throws OMException { + final String json = "{\n" + + " \"Statement\": [{\n" + + " \"Effect\": \"Allow\",\n" + + " \"Action\": [\"s3:ListBucket\", \"s3:GetObject\"],\n" + + " \"Resource\": [\"arn:aws:s3:::logs\", \"arn:aws:s3:::logs/*\"],\n" + + " \"Condition\": { \"StringLike\": { \"s3:prefix\": \"team/*\" } }\n" + + " }]\n" + + "}"; + + final Set resolvedFromNativeAuthorizer = resolve(json, VOLUME, NATIVE); + final Set resolvedFromRangerAuthorizer = resolve(json, VOLUME, RANGER); + + // Expected for native (GetObject is ignored because s3:prefix is present): READ, LIST bucket acls; volume READ; + // prefix "log/team" READ + final Set expectedResolvedNative = new LinkedHashSet<>(); + expectedResolvedNative.add(new OzoneGrant(objSet(bucket("logs")), acls(READ, LIST), strSet("ListBucket"))); + expectedResolvedNative.add( + new OzoneGrant(objSet(volume(), prefix("logs", "team/")), acls(READ), strSet("ListBucket"))); + assertThat(resolvedFromNativeAuthorizer).isEqualTo(expectedResolvedNative); + + // Expected for Ranger (GetObject is ignored because s3:prefix is present): READ, LIST bucket acls; volume READ; + // key "log/team/*" READ + final Set expectedResolvedRanger = new LinkedHashSet<>(); + expectedResolvedRanger.add(new OzoneGrant(objSet(bucket("logs")), acls(READ, LIST), strSet("ListBucket"))); + expectedResolvedRanger.add( + new OzoneGrant(objSet(volume(), key("logs", "team/*")), acls(READ), strSet("ListBucket"))); + assertThat(resolvedFromRangerAuthorizer).isEqualTo(expectedResolvedRanger); + } + + @Test + public void testListBucketOnObjectResourceReturnsEmpty() throws OMException { + final String json = "{\n" + + " \"Statement\": [{\n" + + " \"Effect\": \"Allow\",\n" + + " \"Action\": \"s3:ListBucket\",\n" + + " \"Resource\": \"arn:aws:s3:::logs/*\"\n" + + " }]\n" + + "}"; + + final Set resolvedFromNativeAuthorizer = resolve(json, VOLUME, NATIVE); + final Set resolvedFromRangerAuthorizer = resolve(json, VOLUME, RANGER); + + assertThat(resolvedFromNativeAuthorizer).isEmpty(); + assertThat(resolvedFromRangerAuthorizer).isEmpty(); + } + + @Test + public void testObjectResourceWithWildcardInMiddle() throws OMException { + final String json = "{\n" + + " \"Statement\": [{\n" + + " \"Effect\": \"Allow\",\n" + + " \"Action\": \"s3:GetObject\",\n" + + " \"Resource\": \"arn:aws:s3:::logs/file*.log\"\n" + + " }]\n" + + "}"; + + // Wildcards in middle of object resource are not supported for Native authorizer + expectResolveThrows( + json, NATIVE, "IAM session policy: Wildcard prefix patterns are not supported for Ozone native " + + "authorizer if wildcard is not at the end", NOT_SUPPORTED_OPERATION); + + final Set resolvedFromRangerAuthorizer = resolve(json, VOLUME, RANGER); + // Ensure what we got is what we expected + final Set expectedResolvedRanger = new LinkedHashSet<>(); + // Expected for Ranger: READ acl on key "file*.log", bucket READ, volume READ + expectedResolvedRanger.add( + new OzoneGrant(objSet(key("logs", "file*.log"), bucket("logs"), volume()), acls(READ), strSet("GetObject"))); + assertThat(resolvedFromRangerAuthorizer).isEqualTo(expectedResolvedRanger); + } + + @Test + public void testObjectResourceWithPrefixWildcard() throws OMException { + final String json = "{\n" + + " \"Statement\": [{\n" + + " \"Effect\": \"Allow\",\n" + + " \"Action\": \"s3:GetObject\",\n" + + " \"Resource\": \"arn:aws:s3:::myBucket/file*\"\n" + + " }]\n" + + "}"; + + final Set resolvedFromNativeAuthorizer = resolve(json, VOLUME, NATIVE); + final Set resolvedFromRangerAuthorizer = resolve(json, VOLUME, RANGER); + + // Ensure what we got is what we expected + final Set expectedResolvedNative = new LinkedHashSet<>(); + // Expected for native: READ acl on prefix "file" under bucket, bucket READ, volume READ + expectedResolvedNative.add( + new OzoneGrant( + objSet(prefix("myBucket", "file"), bucket("myBucket"), volume()), acls(READ), strSet("GetObject"))); + assertThat(resolvedFromNativeAuthorizer).isEqualTo(expectedResolvedNative); + + final Set expectedResolvedRanger = new LinkedHashSet<>(); + // Expected for Ranger: READ acl on key "file*", bucket READ, volume READ + expectedResolvedRanger.add( + new OzoneGrant( + objSet(key("myBucket", "file*"), bucket("myBucket"), volume()), acls(READ), strSet("GetObject"))); + assertThat(resolvedFromRangerAuthorizer).isEqualTo(expectedResolvedRanger); + } + + @Test + public void testBucketActionOnAllResources() throws OMException { + final String json = "{\n" + + " \"Statement\": [{\n" + + " \"Effect\": \"Allow\",\n" + + " \"Action\": [\n" + + " \"s3:ListAllMyBuckets\",\n" + + " \"s3:ListBucket\"\n" + + " ],\n" + + " \"Resource\": \"*\"\n" + + " }]\n" + + "}"; + + // Wildcards on bucket are not supported for Native authorizer + expectBucketWildcardUnsupportedExceptionForNativeAuthorizer(json); + + final Set resolvedFromRangerAuthorizer = resolve(json, VOLUME, RANGER); + // Ensure what we got is what we expected + final Set expectedResolvedRanger = new LinkedHashSet<>(); + // Expected for Ranger: READ and LIST on volume and bucket (wildcard), READ on key "*" + expectedResolvedRanger.add( + new OzoneGrant(objSet(volume()), acls(READ, LIST), strSet("ListAllMyBuckets", "ListBucket"))); + expectedResolvedRanger.add(new OzoneGrant(objSet(bucket("*")), acls(READ, LIST), strSet("ListBucket"))); + expectedResolvedRanger.add(new OzoneGrant(objSet(key("*", "*")), acls(READ), strSet("ListBucket"))); + assertThat(resolvedFromRangerAuthorizer).isEqualTo(expectedResolvedRanger); + } + + @Test + public void testObjectActionOnAllResources() throws OMException { + final String json = "{\n" + + " \"Statement\": [{\n" + + " \"Effect\": \"Allow\",\n" + + " \"Action\": \"s3:PutObject\",\n" + + " \"Resource\": \"*\"\n" + + " }]\n" + + "}"; + + // Wildcards on bucket are not supported for Native authorizer + expectBucketWildcardUnsupportedExceptionForNativeAuthorizer(json); + + final Set resolvedFromRangerAuthorizer = resolve(json, VOLUME, RANGER); + // Ensure what we got is what we expected + final Set expectedResolvedRanger = new LinkedHashSet<>(); + // Expected for Ranger: CREATE and WRITE key acls on wildcard pattern, bucket READ, volume READ + final Set keySet = objSet(key("*", "*")); + final Set keyAcls = acls(CREATE, WRITE); + expectedResolvedRanger.add(new OzoneGrant(keySet, keyAcls, strSet("PutObject"))); + expectedResolvedRanger.add(new OzoneGrant(objSet(volume(), bucket("*")), acls(READ), strSet("PutObject"))); + assertThat(resolvedFromRangerAuthorizer).isEqualTo(expectedResolvedRanger); + } + + @Test + public void testAllActionsOnAllResourcesWithPrefixCondition() throws OMException { + final String json = "{\n" + + " \"Statement\": [{\n" + + " \"Effect\": \"Allow\",\n" + + " \"Action\": \"s3:*\",\n" + + " \"Resource\": \"*\",\n" + + " \"Condition\": {\n" + + " \"StringLike\": {\n" + + " \"s3:prefix\": [ \"team/folder\", \"team/folder/*\" ]\n" + + " }\n" + + " }\n" + + " }]\n" + + "}"; + + // Wildcards on bucket are not supported for Native authorizer + expectBucketWildcardUnsupportedExceptionForNativeAuthorizer(json); + + final Set resolvedFromRangerAuthorizer = resolve(json, VOLUME, RANGER); + // Ensure what we got is what we expected + final Set expectedResolvedRanger = new LinkedHashSet<>(); + // Expected for Ranger: (only ListBucket supports s3:prefix) READ volume; READ, LIST acl on bucket; + // READ on key "team/folder", "team/folder/*" + expectedResolvedRanger.add(new OzoneGrant(objSet(bucket("*")), acls(READ, LIST), strSet("ListBucket"))); + expectedResolvedRanger.add( + new OzoneGrant( + objSet(volume(), key("*", "team/folder"), key("*", "team/folder/*")), acls(READ), strSet("ListBucket"))); + assertThat(resolvedFromRangerAuthorizer).isEqualTo(expectedResolvedRanger); + } + + @Test + public void testAllActionsOnAllResources() throws OMException { + final String json = "{\n" + + " \"Statement\": [{\n" + + " \"Effect\": \"Allow\",\n" + + " \"Action\": \"s3:*\",\n" + + " \"Resource\": \"*\"\n" + + " }]\n" + + "}"; + + // Wildcards on bucket are not supported for Native authorizer + expectBucketWildcardUnsupportedExceptionForNativeAuthorizer(json); + + final Set resolvedFromRangerAuthorizer = resolve(json, VOLUME, RANGER); + // Ensure what we got is what we expected + final Set expectedResolvedRanger = new LinkedHashSet<>(); + // Expected for Ranger: + // - volume READ, LIST (ListAllMyBuckets applies at volume scope; other actions imply navigation READ) + // - bucket union of supported bucket ACLs + // - key union of supported object ACLs (and is only action-restricted to object actions + ListBucket) + expectedResolvedRanger.add(new OzoneGrant(objSet(volume()), acls(READ, LIST), emptySet())); + expectedResolvedRanger.add( + new OzoneGrant( + objSet(bucket("*")), acls(READ, LIST, CREATE, DELETE, READ_ACL, WRITE_ACL), ALL_BUCKET_AND_OBJECT_ACTIONS)); + expectedResolvedRanger.add( + new OzoneGrant(objSet(key("*", "*")), acls(READ, CREATE, WRITE, DELETE), ALL_OBJECT_ACTIONS_WITH_LIST_BUCKET)); + assertThat(resolvedFromRangerAuthorizer).isEqualTo(expectedResolvedRanger); + } + + @Test + public void testAllActionsOnAllBucketResources() throws OMException { + final String json = "{\n" + + " \"Statement\": [{\n" + + " \"Effect\": \"Allow\",\n" + + " \"Action\": \"s3:*\",\n" + + " \"Resource\": \"arn:aws:s3:::*\"\n" + + " }]\n" + + "}"; + + // Wildcards on bucket are not supported for Native authorizer + expectBucketWildcardUnsupportedExceptionForNativeAuthorizer(json); + + final Set resolvedFromRangerAuthorizer = resolve(json, VOLUME, RANGER); + // Ensure what we got is what we expected + final Set expectedResolvedRanger = new LinkedHashSet<>(); + // Expected for Ranger: union of supported bucket ACLs on wildcard bucket; volume READ, LIST; key "*" READ + final Set bucketSet = objSet(bucket("*")); + final Set bucketAcls = acls(READ, LIST, CREATE, DELETE, READ_ACL, WRITE_ACL); + expectedResolvedRanger.add(new OzoneGrant(bucketSet, bucketAcls, ALL_BUCKET_ACTIONS)); + expectedResolvedRanger.add(new OzoneGrant(objSet(key("*", "*")), acls(READ), strSet("ListBucket"))); + expectedResolvedRanger.add( + new OzoneGrant(objSet(volume()), acls(READ, LIST), ALL_BUCKET_ACTIONS_WITH_LIST_ALL_MY_BUCKETS)); + assertThat(resolvedFromRangerAuthorizer).isEqualTo(expectedResolvedRanger); + } + + @Test + public void testAllActionsOnAllObjectResources() throws OMException { + final String json = "{\n" + + " \"Statement\": [{\n" + + " \"Effect\": \"Allow\",\n" + + " \"Action\": \"s3:*\",\n" + + " \"Resource\": \"arn:aws:s3:::*/*\"\n" + + " }]\n" + + "}"; + + // Wildcards on bucket are not supported for Native authorizer + expectBucketWildcardUnsupportedExceptionForNativeAuthorizer(json); + + final Set resolvedFromRangerAuthorizer = resolve(json, VOLUME, RANGER); + // Ensure what we got is what we expected + final Set expectedResolvedRanger = new LinkedHashSet<>(); + // Expected for Ranger: union of supported object ACLs on wildcard key; bucket READ; volume READ + final Set keySet = objSet(key("*", "*")); + final Set keyAcls = acls(READ, CREATE, WRITE, DELETE); + expectedResolvedRanger.add(new OzoneGrant(keySet, keyAcls, ALL_OBJECT_ACTIONS)); + expectedResolvedRanger.add(new OzoneGrant(objSet(volume(), bucket("*")), acls(READ), ALL_OBJECT_ACTIONS)); + assertThat(resolvedFromRangerAuthorizer).isEqualTo(expectedResolvedRanger); + } + + @Test + public void testWildcardActionGroupGetStar() throws OMException { + final String json = "{\n" + + " \"Statement\": [{\n" + + " \"Effect\": \"Allow\",\n" + + " \"Action\": \"s3:Get*\",\n" + + " \"Resource\": [\n" + + " \"arn:aws:s3:::my-bucket\",\n" + + " \"arn:aws:s3:::my-bucket/*\"\n" + + " ]\n" + + " }]\n" + + "}"; + + final Set resolvedFromNativeAuthorizer = resolve(json, VOLUME, NATIVE); + final Set resolvedFromRangerAuthorizer = resolve(json, VOLUME, RANGER); + + // Ensure what we got is what we expected + final Set expectedResolvedNative = new LinkedHashSet<>(); + // Expected for native: bucket READ, READ_ACL acls + final Set bucketSet = objSet(bucket("my-bucket")); + final Set bucketAcls = acls(READ, READ_ACL); + expectedResolvedNative.add( + new OzoneGrant(bucketSet, bucketAcls, strSet("GetBucketAcl", "GetObject", "GetObjectTagging"))); + // Expected for native: READ acl on prefix "" under bucket; volume READ + expectedResolvedNative.add( + new OzoneGrant(objSet(prefix("my-bucket", "")), acls(READ), strSet("GetObject", "GetObjectTagging"))); + expectedResolvedNative.add( + new OzoneGrant(objSet(volume()), acls(READ), strSet("GetBucketAcl", "GetObject", "GetObjectTagging"))); + assertThat(resolvedFromNativeAuthorizer).isEqualTo(expectedResolvedNative); + + final Set expectedResolvedRanger = new LinkedHashSet<>(); + // Expected for Ranger: bucket READ, READ_ACL acls + expectedResolvedRanger.add( + new OzoneGrant(bucketSet, bucketAcls, strSet("GetBucketAcl", "GetObject", "GetObjectTagging"))); + // Expected for Ranger: READ key acl for resource type KEY with key name "*"; volume READ + expectedResolvedRanger.add( + new OzoneGrant(objSet(key("my-bucket", "*")), acls(READ), strSet("GetObject", "GetObjectTagging"))); + expectedResolvedRanger.add( + new OzoneGrant(objSet(volume()), acls(READ), strSet("GetBucketAcl", "GetObject", "GetObjectTagging"))); + assertThat(resolvedFromRangerAuthorizer).isEqualTo(expectedResolvedRanger); + } + + @Test + public void testWildcardActionGroupListStar() throws OMException { + final String json = "{\n" + + " \"Statement\": [{\n" + + " \"Effect\": \"Allow\",\n" + + " \"Action\": \"s3:List*\",\n" + + " \"Resource\": [\n" + + " \"arn:aws:s3:::my-bucket\",\n" + + " \"arn:aws:s3:::my-bucket/*\"\n" + // ListMultipartUploadParts has READ effect on file/object resources + " ]\n" + + " }]\n" + + "}"; + + final Set resolvedFromNativeAuthorizer = resolve(json, VOLUME, NATIVE); + final Set resolvedFromRangerAuthorizer = resolve(json, VOLUME, RANGER); + + // Ensure what we got is what we expected + final Set expectedResolvedNative = new LinkedHashSet<>(); + // Expected for native: READ, LIST bucket acls, READ acl on prefix "" under bucket; volume READ + final Set readAndListObjectsNative = objSet(bucket("my-bucket")); + expectedResolvedNative.add( + new OzoneGrant( + readAndListObjectsNative, acls(READ, LIST), + strSet("ListBucket", "ListBucketMultipartUploads", "ListMultipartUploadParts"))); + expectedResolvedNative.add( + new OzoneGrant(objSet(volume()), acls(READ), + strSet("ListBucket", "ListBucketMultipartUploads", "ListMultipartUploadParts"))); + expectedResolvedNative.add( + new OzoneGrant(objSet(prefix("my-bucket", "")), acls(READ), strSet("ListBucket", "ListMultipartUploadParts"))); + assertThat(resolvedFromNativeAuthorizer).isEqualTo(expectedResolvedNative); + + final Set expectedResolvedRanger = new LinkedHashSet<>(); + // Expected for Ranger: READ, LIST bucket acls; READ key acl for resource type KEY with key name "*"; + // volume READ + final Set readAndListObjectRanger = objSet(bucket("my-bucket")); + expectedResolvedRanger.add( + new OzoneGrant( + readAndListObjectRanger, acls(READ, LIST), + strSet("ListBucket", "ListBucketMultipartUploads", "ListMultipartUploadParts"))); + expectedResolvedRanger.add( + new OzoneGrant( + objSet(volume()), acls(READ), + strSet("ListBucket", "ListBucketMultipartUploads", "ListMultipartUploadParts"))); + expectedResolvedRanger.add( + new OzoneGrant(objSet(key("my-bucket", "*")), acls(READ), strSet("ListBucket", "ListMultipartUploadParts"))); + assertThat(resolvedFromRangerAuthorizer).isEqualTo(expectedResolvedRanger); + } + + @Test + public void testWildcardActionGroupPutStar() throws OMException { + final String json = "{\n" + + " \"Statement\": [{\n" + + " \"Effect\": \"Allow\",\n" + + " \"Action\": \"s3:Put*\",\n" + + " \"Resource\": [\n" + + " \"arn:aws:s3:::my-bucket\",\n" + + " \"arn:aws:s3:::my-bucket/*\"\n" + + " ]\n" + + " }]\n" + + "}"; + + final Set resolvedFromNativeAuthorizer = resolve(json, VOLUME, NATIVE); + final Set resolvedFromRangerAuthorizer = resolve(json, VOLUME, RANGER); + + // Ensure what we got is what we expected + final Set expectedResolvedNative = new LinkedHashSet<>(); + // Expected for native: bucket READ, READ_ACL, WRITE_ACL acl + final Set bucketSet = objSet(bucket("my-bucket")); + final Set bucketAcl = acls(READ, READ_ACL, WRITE_ACL); + expectedResolvedNative.add( + new OzoneGrant(bucketSet, bucketAcl, strSet("PutBucketAcl", "PutObject", "PutObjectTagging"))); + // Expected for native: CREATE, WRITE acls on prefix "" under bucket + final Set keyPrefixSet = objSet(prefix("my-bucket", "")); + final Set keyAcls = acls(CREATE, WRITE); + expectedResolvedNative.add(new OzoneGrant(keyPrefixSet, keyAcls, strSet("PutObject", "PutObjectTagging"))); + // Expected for native: volume READ + expectedResolvedNative.add( + new OzoneGrant(objSet(volume()), acls(READ), strSet("PutBucketAcl", "PutObject", "PutObjectTagging"))); + assertThat(resolvedFromNativeAuthorizer).isEqualTo(expectedResolvedNative); + + final Set expectedResolvedRanger = new LinkedHashSet<>(); + // Expected for Ranger: bucket READ, READ_ACL, WRITE_ACL acl + expectedResolvedRanger.add( + new OzoneGrant(bucketSet, bucketAcl, strSet("PutBucketAcl", "PutObject", "PutObjectTagging"))); + // Expected for Ranger: CREATE, WRITE key acls for resource type KEY with key name "*" + final Set rangerKeySet = objSet(key("my-bucket", "*")); + expectedResolvedRanger.add(new OzoneGrant(rangerKeySet, keyAcls, strSet("PutObject", "PutObjectTagging"))); + // Expected for Ranger: volume READ + expectedResolvedRanger.add( + new OzoneGrant(objSet(volume()), acls(READ), strSet("PutBucketAcl", "PutObject", "PutObjectTagging"))); + assertThat(resolvedFromRangerAuthorizer).isEqualTo(expectedResolvedRanger); + } + + @Test + public void testWildcardActionGroupDeleteStar() throws OMException { + final String json = "{\n" + + " \"Statement\": [{\n" + + " \"Effect\": \"Allow\",\n" + + " \"Action\": \"s3:Delete*\",\n" + + " \"Resource\": [\n" + + " \"arn:aws:s3:::my-bucket\",\n" + + " \"arn:aws:s3:::my-bucket/*\"\n" + + " ]\n" + + " }]\n" + + "}"; + + final Set resolvedFromNativeAuthorizer = resolve(json, VOLUME, NATIVE); + final Set resolvedFromRangerAuthorizer = resolve(json, VOLUME, RANGER); + + // Ensure what we got is what we expected + final Set expectedResolvedNative = new LinkedHashSet<>(); + // Expected for native: DELETE and WRITE on prefix "" under bucket; bucket READ, DELETE; volume READ + final Set resourceSetNative = objSet(prefix("my-bucket", "")); + expectedResolvedNative.add( + new OzoneGrant(resourceSetNative, acls(DELETE, WRITE), strSet("DeleteObject", "DeleteObjectTagging"))); + expectedResolvedNative.add( + new OzoneGrant( + objSet(bucket("my-bucket")), acls(READ, DELETE), + strSet("DeleteBucket", "DeleteObject", "DeleteObjectTagging"))); + expectedResolvedNative.add( + new OzoneGrant(objSet(volume()), acls(READ), strSet("DeleteBucket", "DeleteObject", "DeleteObjectTagging"))); + assertThat(resolvedFromNativeAuthorizer).isEqualTo(expectedResolvedNative); + + final Set expectedResolvedRanger = new LinkedHashSet<>(); + // Expected for Ranger: DELETE and WRITE on resource type KEY with key name "*"; bucket READ, DELETE; volume READ + final Set resourceSetRanger = objSet(key("my-bucket", "*")); + expectedResolvedRanger.add( + new OzoneGrant(resourceSetRanger, acls(DELETE, WRITE), strSet("DeleteObject", "DeleteObjectTagging"))); + expectedResolvedRanger.add( + new OzoneGrant( + objSet(bucket("my-bucket")), acls(READ, DELETE), + strSet("DeleteBucket", "DeleteObject", "DeleteObjectTagging"))); + expectedResolvedRanger.add( + new OzoneGrant(objSet(volume()), acls(READ), strSet("DeleteBucket", "DeleteObject", "DeleteObjectTagging"))); + assertThat(resolvedFromRangerAuthorizer).isEqualTo(expectedResolvedRanger); + } + + @Test + public void testMismatchedActionAndResourceReturnsEmpty() throws OMException { + final String json = "{\n" + + " \"Statement\": [{\n" + + " \"Effect\": \"Allow\",\n" + + " \"Action\": \"s3:GetObject\",\n" + // object-level action + " \"Resource\": \"arn:aws:s3:::my-bucket\"\n" + // bucket-level resource + " }]\n" + + "}"; + + final Set resolvedFromNativeAuthorizer = resolve(json, VOLUME, NATIVE); + final Set resolvedFromRangerAuthorizer = resolve(json, VOLUME, RANGER); + + // Ensure what we got is what we expected + assertThat(resolvedFromNativeAuthorizer).isEmpty(); + assertThat(resolvedFromRangerAuthorizer).isEmpty(); + } + + @Test + public void testInvalidResourceArnThrows() { + final String json = "{\n" + + " \"Statement\": [{\n" + + " \"Effect\": \"Allow\",\n" + + " \"Action\": \"s3:ListBucket\",\n" + + " \"Resource\": \"arn:aws:s3:::\"\n" + + " }]\n" + + "}"; + + expectResolveThrowsForBothAuthorizers( + json, "IAM session policy: Invalid Resource Arn - arn:aws:s3:::", MALFORMED_POLICY_DOCUMENT); + } + + private static void expectIllegalArgumentException(Runnable runnable, String expectedMessage) { + try { + runnable.run(); + throw new AssertionError("Expected exception not thrown"); + } catch (IllegalArgumentException ex) { + assertThat(ex.getMessage()).isEqualTo(expectedMessage); + } + } + + private static void expectOMExceptionWithCode(RunnableThrowingOMException runnable, String expectedMessage, + OMException.ResultCodes expectedCode) { + try { + runnable.run(); + throw new AssertionError("Expected exception not thrown"); + } catch (OMException ex) { + assertThat(ex.getMessage()).isEqualTo(expectedMessage); + assertThat(ex.getResult()).isEqualTo(expectedCode); + } + } + + @FunctionalInterface + private interface RunnableThrowingOMException { + void run() throws OMException; + } + + private static IOzoneObj key(String bucket, String key) { + return OzoneObjInfo.Builder.newBuilder() + .setResType(OzoneObj.ResourceType.KEY) + .setStoreType(OzoneObj.StoreType.OZONE) + .setVolumeName(VOLUME) + .setBucketName(bucket) + .setKeyName(key) + .build(); + } + + private static IOzoneObj volume() { + return OzoneObjInfo.Builder.newBuilder() + .setResType(OzoneObj.ResourceType.VOLUME) + .setStoreType(OzoneObj.StoreType.OZONE) + .setVolumeName(VOLUME) + .build(); + } + + private static IOzoneObj bucket(String bucket) { + return OzoneObjInfo.Builder.newBuilder() + .setResType(OzoneObj.ResourceType.BUCKET) + .setStoreType(OzoneObj.StoreType.OZONE) + .setVolumeName(VOLUME) + .setBucketName(bucket) + .build(); + } + + private static IOzoneObj prefix(String bucket, String prefix) { + return OzoneObjInfo.Builder.newBuilder() + .setResType(OzoneObj.ResourceType.PREFIX) + .setStoreType(OzoneObj.StoreType.OZONE) + .setVolumeName(VOLUME) + .setBucketName(bucket) + .setPrefixName(prefix) + .build(); + } + + private static Set objSet(IOzoneObj... objs) { + final Set s = new LinkedHashSet<>(); + Collections.addAll(s, objs); + return s; + } + + private static Set acls(ACLType... types) { + final Set s = new LinkedHashSet<>(); + Collections.addAll(s, types); + return s; + } + + private static Set strSet(String... strs) { + final Set s = new LinkedHashSet<>(); + Collections.addAll(s, strs); + return s; + } + + private static void expectResolveThrows(String json, IamSessionPolicyResolver.AuthorizerType authorizerType, + String expectedMessage, OMException.ResultCodes expectedCode) { + try { + resolve(json, VOLUME, authorizerType); + throw new AssertionError("Expected exception not thrown"); + } catch (OMException ex) { + assertThat(ex.getMessage()).isEqualTo(expectedMessage); + assertThat(ex.getResult()).isEqualTo(expectedCode); + } + } + + private static void expectResolveThrowsForBothAuthorizers(String json, String expectedMessage, + OMException.ResultCodes expectedCode) { + expectResolveThrows(json, NATIVE, expectedMessage, expectedCode); + expectResolveThrows(json, RANGER, expectedMessage, expectedCode); + } + + private static void expectDuplicateFieldThrowsForBothAuthorizers(String json, String duplicateFieldName) { + expectResolveThrowsForBothAuthorizers( + json, "IAM session policy: Duplicate field '" + duplicateFieldName + "' in session policy", + MALFORMED_POLICY_DOCUMENT); + } + + /** + * Ensure resources containing wildcards in buckets throw an Exception + * when the OzoneNativeAuthorizer is used. + */ + private static void expectBucketWildcardUnsupportedExceptionForNativeAuthorizer(String json) { + try { + resolve(json, VOLUME, NATIVE); + throw new AssertionError("Expected exception not thrown"); + } catch (OMException ex) { + assertThat(ex.getMessage()).isEqualTo( + "IAM session policy: Wildcard bucket patterns are not supported for Ozone native authorizer"); + assertThat(ex.getResult()).isEqualTo(NOT_SUPPORTED_OPERATION); + } + } + + private static String createJsonStringLargerThan2048Characters() { + final StringBuilder jsonBuilder = new StringBuilder(); + jsonBuilder.append("{\n") + .append(" \"Statement\": [{\n") + .append(" \"Effect\": \"Allow\",\n") + .append(" \"Action\": \"s3:ListBucket\",\n") + .append(" \"Resource\": \"arn:aws:s3:::"); + // Add enough characters to exceed 2048 + while (jsonBuilder.length() < 2048) { + jsonBuilder.append("very-long-bucket-name-that-exceeds-the-limit-"); + } + jsonBuilder.append("\"\n") + .append(" }]\n") + .append('}'); + return jsonBuilder.toString(); + } + + private static String create2048CharJsonString() { + final StringBuilder jsonBuilder = new StringBuilder(); + jsonBuilder.append("{\n") + .append(" \"Statement\": [{\n") + .append(" \"Effect\": \"Allow\",\n") + .append(" \"Action\": \"s3:ListBucket\",\n") + .append(" \"Resource\": \"arn:aws:s3:::"); + // Add characters to reach exactly 2048 (accounting for closing brackets and newlines) + // Closing part: "\"\n }]\n}" = 8 chars + while (jsonBuilder.length() < 2048 - 8) { + jsonBuilder.append('a'); + } + jsonBuilder.append("\"\n }]\n}"); + return jsonBuilder.toString(); + } +} + diff --git a/hadoop-ozone/common/src/test/java/org/apache/hadoop/ozone/security/acl/iam/package-info.java b/hadoop-ozone/common/src/test/java/org/apache/hadoop/ozone/security/acl/iam/package-info.java new file mode 100644 index 000000000000..c5e8264687b8 --- /dev/null +++ b/hadoop-ozone/common/src/test/java/org/apache/hadoop/ozone/security/acl/iam/package-info.java @@ -0,0 +1,21 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * Unit tests related to IAM policies. + */ +package org.apache.hadoop.ozone.security.acl.iam; diff --git a/hadoop-ozone/dist/src/main/compose/common/init-kdc.sh b/hadoop-ozone/dist/src/main/compose/common/init-kdc.sh index 3fd00a2b4f36..665d3da8264c 100755 --- a/hadoop-ozone/dist/src/main/compose/common/init-kdc.sh +++ b/hadoop-ozone/dist/src/main/compose/common/init-kdc.sh @@ -37,6 +37,9 @@ export_keytab testuser/om testuser export_keytab testuser/recon testuser export_keytab testuser/s3g testuser export_keytab testuser/scm testuser +export_keytab svc-iceberg-rest-catalog/s3g svc-iceberg-rest-catalog +export_keytab svc-iceberg-userA/s3g svc-iceberg-userA +export_keytab svc-iceberg-userB/s3g svc-iceberg-userB export_keytab testuser2/dn testuser2 export_keytab testuser2/httpfs testuser2 diff --git a/hadoop-ozone/dist/src/main/compose/common/ranger.yaml b/hadoop-ozone/dist/src/main/compose/common/ranger.yaml index 8ecc69afcffb..210ae3d2d6c0 100644 --- a/hadoop-ozone/dist/src/main/compose/common/ranger.yaml +++ b/hadoop-ozone/dist/src/main/compose/common/ranger.yaml @@ -27,7 +27,7 @@ services: POSTGRES_PASSWORD: "rangerR0cks!" volumes: # The location of the init_postgres.sh file changed in Ranger 2.8 - - ${RANGER_SOURCE_DIR}/dev-support/ranger-docker/scripts/rdbms/init_postgres.sh:/docker-entrypoint-initdb.d/init_postgres.sh + - ${RANGER_INIT_POSTGRES_SH}:/docker-entrypoint-initdb.d/init_postgres.sh healthcheck: test: 'su -c "pg_isready -q" postgres' interval: 10s @@ -63,8 +63,20 @@ services: RANGER_AUDIT_DB_USERNAME: "rangeradmin" RANGER_AUDIT_DB_PASSWORD: "rangerR0cks!" RANGER_VERSION: ${RANGER_VERSION} + FF_ENABLE_OZONE_ACTION_MATCHES_CONDITION: "${FF_ENABLE_OZONE_ACTION_MATCHES_CONDITION:-false}" + # apache/ranger:2.9.0 currently uses Java 8, so Java 9+ flags like + # --add-opens must be opt-in for Ranger images that need them. + JAVA_OPTS: "${RANGER_JAVA_OPTS:-}" + entrypoint: + - bash + - -c + - | + if [ -f /opt/ranger/admin/install.properties ]; then + cp /opt/ranger/admin/install.properties /home/ranger/scripts/ranger-admin-install.properties + fi + exec /home/ranger/scripts/ranger.sh volumes: - - ${RANGER_SOURCE_DIR}/dev-support/ranger-docker/scripts/admin/ranger-admin-install-postgres.properties:/opt/ranger/admin/install.properties + - ${RANGER_ADMIN_INSTALL_PROPERTIES}:/opt/ranger/admin/install.properties healthcheck: test: 'grep "Successfully retrieved .*dev_ozone" /var/log/ranger/ranger-admin*log' interval: 2s diff --git a/hadoop-ozone/dist/src/main/compose/ozonesecure-ha/.env b/hadoop-ozone/dist/src/main/compose/ozonesecure-ha/.env index ac0582396f84..10da8c215beb 100644 --- a/hadoop-ozone/dist/src/main/compose/ozonesecure-ha/.env +++ b/hadoop-ozone/dist/src/main/compose/ozonesecure-ha/.env @@ -28,3 +28,4 @@ RANGER_IMAGE=apache/ranger RANGER_IMAGE_VERSION=${ranger.version} RANGER_VERSION=${ranger.version} WAITFOR_TIMEOUT=3000 +FF_ENABLE_OZONE_ACTION_MATCHES_CONDITION=true diff --git a/hadoop-ozone/dist/src/main/compose/ozonesecure-ha/docker-config b/hadoop-ozone/dist/src/main/compose/ozonesecure-ha/docker-config index 38487ac51de9..4dc737f56d59 100644 --- a/hadoop-ozone/dist/src/main/compose/ozonesecure-ha/docker-config +++ b/hadoop-ozone/dist/src/main/compose/ozonesecure-ha/docker-config @@ -104,6 +104,9 @@ OZONE-SITE.XML_ozone.security.http.kerberos.enabled=true OZONE-SITE.XML_ozone.s3g.secret.http.enabled=true OZONE-SITE.XML_ozone.http.filter.initializers=org.apache.hadoop.security.AuthenticationFilterInitializer +# Enable S3 Gateway STS (AWS STS compatible) endpoint on s3g (http://s3g:9880) +OZONE-SITE.XML_ozone.s3g.sts.http.enabled=true + OZONE-SITE.XML_ozone.om.http.auth.type=kerberos OZONE-SITE.XML_hdds.scm.http.auth.type=kerberos OZONE-SITE.XML_hdds.datanode.http.auth.type=kerberos diff --git a/hadoop-ozone/dist/src/main/compose/ozonesecure-ha/polaris-setup.sh b/hadoop-ozone/dist/src/main/compose/ozonesecure-ha/polaris-setup.sh new file mode 100755 index 000000000000..951d9576ce10 --- /dev/null +++ b/hadoop-ozone/dist/src/main/compose/ozonesecure-ha/polaris-setup.sh @@ -0,0 +1,123 @@ +#!/usr/bin/env sh +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +set -eu + +apk add --no-cache jq >/dev/null + +realm="${POLARIS_REALM:-POLARIS}" +catalog_name="${POLARIS_CATALOG_NAME:-quickstart_catalog}" +storage_location="${POLARIS_STORAGE_LOCATION:-s3://iceberg-obs/polaris-smoke}" +s3_endpoint="${POLARIS_S3_ENDPOINT:-http://s3g:9878}" +sts_endpoint="${POLARIS_STS_ENDPOINT:-http://s3g:9880}" +role_arn="${POLARIS_ROLE_ARN:-arn:aws:iam::123456789012:role/iceberg-data-all-access-obs}" + +if [ -z "${POLARIS_AWS_ACCESS_KEY_ID:-}" ] || [ -z "${POLARIS_AWS_SECRET_ACCESS_KEY:-}" ]; then + echo "POLARIS_AWS_ACCESS_KEY_ID and POLARIS_AWS_SECRET_ACCESS_KEY must be set" + exit 1 +fi + +echo "Waiting for S3 gateway at ${s3_endpoint}..." +attempt=0 +while [ "${attempt}" -lt 30 ]; do + if curl --silent --show-error --include \ + --user "${POLARIS_AWS_ACCESS_KEY_ID}:${POLARIS_AWS_SECRET_ACCESS_KEY}" \ + --aws-sigv4 "aws:amz:us-west-2:s3" \ + "${s3_endpoint}/" >/dev/null 2>&1; then + echo "${s3_endpoint} is available" + break + fi + attempt=$((attempt + 1)) + sleep 2 +done +if [ "${attempt}" -ge 30 ]; then + echo "Timed out waiting for S3 gateway at ${s3_endpoint}" + exit 1 +fi + +echo "Obtaining Polaris OAuth token..." +token="$( + curl --fail-with-body --silent \ + --user "${CLIENT_ID}:${CLIENT_SECRET}" \ + -H "Polaris-Realm: ${realm}" \ + -d grant_type=client_credentials \ + -d scope=PRINCIPAL_ROLE:ALL \ + "http://polaris:8181/api/catalog/v1/oauth/tokens" \ + | jq -r .access_token +)" +if [ -z "${token}" ] || [ "${token}" = "null" ]; then + echo "Failed to obtain access token." + exit 1 +fi + +storage_config_info="$( + jq -n \ + --arg endpoint "${s3_endpoint}" \ + --arg endpointInternal "${s3_endpoint}" \ + --arg stsEndpoint "${sts_endpoint}" \ + --arg roleArn "${role_arn}" \ + '{ + storageType: "S3", + endpoint: $endpoint, + endpointInternal: $endpointInternal, + stsEndpoint: $stsEndpoint, + roleArn: $roleArn, + stsUnavailable: false, + pathStyleAccess: true, + region: "us-west-2" + }' +)" + +payload="$( + jq -n \ + --arg name "${catalog_name}" \ + --arg location "${storage_location}" \ + --argjson storageConfigInfo "${storage_config_info}" \ + '{ + catalog: { + name: $name, + type: "INTERNAL", + readOnly: false, + properties: { + "default-base-location": $location + }, + storageConfigInfo: $storageConfigInfo + } + }' +)" + +echo "Creating catalog ${catalog_name} in realm ${realm}..." +curl --fail-with-body --silent \ + -H "Authorization: Bearer ${token}" \ + -H "Accept: application/json" \ + -H "Content-Type: application/json" \ + -H "Polaris-Realm: ${realm}" \ + "http://polaris:8181/api/management/v1/catalogs" \ + -d "${payload}" + +echo +echo "Granting catalog_admin CATALOG_MANAGE_CONTENT on ${catalog_name}..." +curl --fail-with-body --silent \ + -H "Authorization: Bearer ${token}" \ + -H "Content-Type: application/json" \ + -H "Polaris-Realm: ${realm}" \ + -X PUT \ + "http://polaris:8181/api/management/v1/catalogs/${catalog_name}/catalog-roles/catalog_admin/grants" \ + -d '{"type":"catalog", "privilege":"CATALOG_MANAGE_CONTENT"}' + +echo +echo "Polaris catalog setup complete." diff --git a/hadoop-ozone/dist/src/main/compose/ozonesecure-ha/polaris-smoketest.sh b/hadoop-ozone/dist/src/main/compose/ozonesecure-ha/polaris-smoketest.sh new file mode 100755 index 000000000000..a53bb9e9a47f --- /dev/null +++ b/hadoop-ozone/dist/src/main/compose/ozonesecure-ha/polaris-smoketest.sh @@ -0,0 +1,113 @@ +#!/usr/bin/env bash +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +set -e -u -o pipefail + +COMPOSE_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" >/dev/null 2>&1 && pwd )" +export COMPOSE_DIR + +if [[ -z "${RANGER_VERSION:-}" ]]; then + # shellcheck source=/dev/null + source "${COMPOSE_DIR}/.env" +fi + +# shellcheck source=/dev/null +source "${COMPOSE_DIR}/../testlib.sh" + +: "${POLARIS_IMAGE:=apache/polaris:1.4.1}" +: "${SPARK_SQL_IMAGE:=apache/spark:3.5.7-scala2.12-java17-ubuntu}" +: "${POLARIS_CATALOG_NAME:=quickstart_catalog}" +: "${POLARIS_STORAGE_LOCATION:=s3://iceberg-obs/polaris-smoke}" +: "${POLARIS_ICEBERG_SPARK_RUNTIME_VERSION:=1.10.1}" +: "${ICEBERG_SVC_CATALOG_USER:=svc-iceberg-rest-catalog}" +: "${ICEBERG_SVC_CATALOG_PRINCIPAL:=${ICEBERG_SVC_CATALOG_USER}/s3g@EXAMPLE.COM}" +: "${ICEBERG_SVC_CATALOG_KEYTAB:=/etc/security/keytabs/${ICEBERG_SVC_CATALOG_USER}.keytab}" + +export POLARIS_IMAGE SPARK_SQL_IMAGE POLARIS_CATALOG_NAME POLARIS_STORAGE_LOCATION + +if [[ "${COMPOSE_FILE:-}" != *polaris.yaml* ]]; then + export COMPOSE_FILE="${COMPOSE_FILE:-docker-compose.yaml:ranger.yaml:../common/ranger.yaml}:polaris.yaml" +fi + +echo "Fetching permanent S3 credentials for ${ICEBERG_SVC_CATALOG_USER}..." +s3_secret_output="$( + docker-compose exec -T s3g bash -lc \ + "kinit -kt ${ICEBERG_SVC_CATALOG_KEYTAB} ${ICEBERG_SVC_CATALOG_PRINCIPAL} && ozone sh volume info s3v && ozone s3 getsecret" +)" + +POLARIS_AWS_ACCESS_KEY_ID="$( + echo "${s3_secret_output}" | grep -o 'awsAccessKey=[^[:space:]]*' | head -1 | cut -d= -f2 +)" +POLARIS_AWS_SECRET_ACCESS_KEY="$( + echo "${s3_secret_output}" | grep -o 'awsSecret=[^[:space:]]*' | head -1 | cut -d= -f2 +)" + +if [[ -z "${POLARIS_AWS_ACCESS_KEY_ID}" || -z "${POLARIS_AWS_SECRET_ACCESS_KEY}" ]]; then + echo "ERROR: Failed to parse S3 credentials from ozone s3 getsecret output:" + echo "${s3_secret_output}" + exit 1 +fi + +export POLARIS_AWS_ACCESS_KEY_ID POLARIS_AWS_SECRET_ACCESS_KEY + +echo "Starting Polaris (${POLARIS_IMAGE})..." +docker-compose --ansi never up -d polaris + +wait_for_port polaris 8181 120 + +echo "Provisioning Polaris catalog (${POLARIS_CATALOG_NAME})..." +docker-compose --ansi never run --rm polaris-setup + +spark_packages="org.apache.iceberg:iceberg-spark-runtime-3.5_2.12:${POLARIS_ICEBERG_SPARK_RUNTIME_VERSION},org.apache.iceberg:iceberg-aws-bundle:${POLARIS_ICEBERG_SPARK_RUNTIME_VERSION}" +sql_file="/opt/hadoop/smoketest/security/ozone-secure-sts-polaris.sql" + +echo "Running Spark SQL against Polaris with STS vended credentials..." +set +e +spark_output="$( + docker-compose --ansi never run --rm spark-sql \ + /opt/spark/bin/spark-sql \ + --packages "${spark_packages}" \ + --conf spark.sql.extensions=org.apache.iceberg.spark.extensions.IcebergSparkSessionExtensions \ + --conf spark.sql.catalog.polaris=org.apache.iceberg.spark.SparkCatalog \ + --conf spark.sql.catalog.polaris.type=rest \ + --conf spark.sql.catalog.polaris.uri=http://polaris:8181/api/catalog \ + --conf spark.sql.catalog.polaris.rest.auth.type=oauth2 \ + --conf spark.sql.catalog.polaris.oauth2-server-uri=http://polaris:8181/api/catalog/v1/oauth/tokens \ + --conf spark.sql.catalog.polaris.token-refresh-enabled=false \ + --conf spark.sql.catalog.polaris.warehouse="${POLARIS_CATALOG_NAME}" \ + --conf spark.sql.catalog.polaris.scope=PRINCIPAL_ROLE:ALL \ + --conf spark.sql.catalog.polaris.credential=root:s3cr3t \ + --conf spark.sql.catalog.polaris.client.region=us-west-2 \ + --conf spark.sql.catalog.polaris.header.X-Iceberg-Access-Delegation=vended-credentials \ + -f "${sql_file}" 2>&1 +)" +spark_exit_code=$? +set -e + +echo "${spark_output}" + +if [[ "${spark_exit_code}" -ne 0 ]]; then + echo "ERROR: spark-sql exited with status ${spark_exit_code}" + exit "${spark_exit_code}" +fi + +if ! echo "${spark_output}" | grep -Fq "testing STS"; then + echo "ERROR: Expected Spark output to contain inserted row value 'testing STS'" + exit 1 +fi + +echo "Polaris STS smoke test passed." diff --git a/hadoop-ozone/dist/src/main/compose/ozonesecure-ha/polaris.yaml b/hadoop-ozone/dist/src/main/compose/ozonesecure-ha/polaris.yaml new file mode 100644 index 000000000000..c22db2380258 --- /dev/null +++ b/hadoop-ozone/dist/src/main/compose/ozonesecure-ha/polaris.yaml @@ -0,0 +1,77 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# Apache Polaris + Spark SQL overlay for ozonesecure-ha STS smoketests. +# Requires POLARIS_AWS_ACCESS_KEY_ID / POLARIS_AWS_SECRET_ACCESS_KEY at runtime +# (fetched from ozone s3 getsecret by polaris-smoketest.sh). + +services: + polaris: + image: ${POLARIS_IMAGE} + hostname: polaris + dns_search: . + ports: + - 8181:8181 + environment: + AWS_REGION: us-west-2 + AWS_ACCESS_KEY_ID: ${POLARIS_AWS_ACCESS_KEY_ID} + AWS_SECRET_ACCESS_KEY: ${POLARIS_AWS_SECRET_ACCESS_KEY} + POLARIS_BOOTSTRAP_CREDENTIALS: POLARIS,root,s3cr3t + polaris.realm-context.realms: POLARIS + polaris.features."ALLOW_SETTING_S3_ENDPOINTS": "true" + quarkus.otel.sdk.disabled: "true" + healthcheck: + test: ["CMD", "curl", "--fail", "http://localhost:8182/q/health"] + interval: 2s + timeout: 10s + retries: 60 + start_period: 10s + networks: + ozone_net: + ipv4_address: 172.25.0.124 + + polaris-setup: + image: alpine/curl:8.19.0 + dns_search: . + depends_on: + polaris: + condition: service_healthy + environment: + CLIENT_ID: root + CLIENT_SECRET: s3cr3t + POLARIS_REALM: POLARIS + POLARIS_CATALOG_NAME: ${POLARIS_CATALOG_NAME:-quickstart_catalog} + POLARIS_STORAGE_LOCATION: ${POLARIS_STORAGE_LOCATION:-s3://iceberg-obs/polaris-smoke} + POLARIS_S3_ENDPOINT: http://s3g:9878 + POLARIS_STS_ENDPOINT: http://s3g:9880 + POLARIS_ROLE_ARN: arn:aws:iam::123456789012:role/iceberg-data-all-access-obs + POLARIS_AWS_ACCESS_KEY_ID: ${POLARIS_AWS_ACCESS_KEY_ID} + POLARIS_AWS_SECRET_ACCESS_KEY: ${POLARIS_AWS_SECRET_ACCESS_KEY} + volumes: + - ./polaris-setup.sh:/polaris-setup.sh:ro + entrypoint: ["/bin/sh", "/polaris-setup.sh"] + networks: + ozone_net: {} + + spark-sql: + image: ${SPARK_SQL_IMAGE} + user: "0:0" + dns_search: . + volumes: + - ../..:/opt/hadoop + - ${RANGER_M2_DIR:-${HOME}/.m2}:/root/.m2 + networks: + ozone_net: {} diff --git a/hadoop-ozone/dist/src/main/compose/ozonesecure-ha/ranger-testlib.sh b/hadoop-ozone/dist/src/main/compose/ozonesecure-ha/ranger-testlib.sh new file mode 100644 index 000000000000..1046a412b24e --- /dev/null +++ b/hadoop-ozone/dist/src/main/compose/ozonesecure-ha/ranger-testlib.sh @@ -0,0 +1,111 @@ +#!/usr/bin/env bash +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +_ranger_testlib_dir="$( cd "$( dirname "${BASH_SOURCE[0]}" )" >/dev/null 2>&1 && pwd )" +: "${COMPOSE_DIR:=$_ranger_testlib_dir}" +export COMPOSE_DIR + +# shellcheck source=/dev/null +source "$COMPOSE_DIR/../testlib.sh" + +setup_ranger_acceptance_env() { + # Load FF_ENABLE_OZONE_ACTION_MATCHES_CONDITION from .env without overriding other env. + # Ranger reads this value from install.properties (not process env), but we allow + # controlling the mounted install.properties via .env. + if [[ -z "${FF_ENABLE_OZONE_ACTION_MATCHES_CONDITION:-}" ]] && [[ -f "${COMPOSE_DIR}/.env" ]]; then + local ff_from_dotenv + ff_from_dotenv="$( + ( + set -a + # shellcheck source=/dev/null + source "${COMPOSE_DIR}/.env" || true + echo "${FF_ENABLE_OZONE_ACTION_MATCHES_CONDITION:-}" + ) 2>/dev/null + )" + if [[ -n "${ff_from_dotenv}" ]]; then + export FF_ENABLE_OZONE_ACTION_MATCHES_CONDITION="${ff_from_dotenv}" + fi + fi + + if [[ -z "${RANGER_VERSION:-}" ]]; then + export RANGER_VERSION="${ranger.version}" + fi + + : "${DOWNLOAD_DIR:=${TEMP_DIR:-/tmp}}" + + export COMPOSE_FILE=docker-compose.yaml:ranger.yaml:../common/ranger.yaml + export OM_SERVICE_ID="omservice" + export SCM=scm1.org + export SECURITY_ENABLED=true + + if [[ "${SKIP_APACHE_VERIFY_DOWNLOAD}" != "true" ]]; then + curl -LO https://downloads.apache.org/ranger/KEYS + gpg --import KEYS + fi + + download_and_verify_apache_release "ranger/${RANGER_VERSION}/apache-ranger-${RANGER_VERSION}.tar.gz" + tar -C "${DOWNLOAD_DIR}" -x -z -f "${DOWNLOAD_DIR}/apache-ranger-${RANGER_VERSION}.tar.gz" + export RANGER_SOURCE_DIR="${DOWNLOAD_DIR}/apache-ranger-${RANGER_VERSION}" + chmod -R a+rX "${RANGER_SOURCE_DIR}" + export RANGER_INIT_POSTGRES_SH="${RANGER_SOURCE_DIR}/dev-support/ranger-docker/scripts/rdbms/init_postgres.sh" + + # Create a temp install.properties so we can override feature flags from .env. + local ranger_admin_install_properties_src + ranger_admin_install_properties_src="${RANGER_SOURCE_DIR}/dev-support/ranger-docker/scripts/admin/ranger-admin-install-postgres.properties" + RANGER_ADMIN_INSTALL_PROPERTIES="$(mktemp "${DOWNLOAD_DIR%/}/ranger-admin-install-postgres.XXXXXX")" + cp -f "${ranger_admin_install_properties_src}" "${RANGER_ADMIN_INSTALL_PROPERTIES}" + chmod a+r "${RANGER_ADMIN_INSTALL_PROPERTIES}" + + local ff + ff="$(echo "${FF_ENABLE_OZONE_ACTION_MATCHES_CONDITION:-false}" | tr '[:upper:]' '[:lower:]')" + if [[ "${ff}" != "true" ]]; then + ff="false" + fi + if grep -Eq '^[[:space:]]*#?[[:space:]]*FF_ENABLE_OZONE_ACTION_MATCHES_CONDITION=' "${RANGER_ADMIN_INSTALL_PROPERTIES}"; then + perl -pi -e "s@^[[:space:]]*#?[[:space:]]*FF_ENABLE_OZONE_ACTION_MATCHES_CONDITION=.*@FF_ENABLE_OZONE_ACTION_MATCHES_CONDITION=${ff}@g" \ + "${RANGER_ADMIN_INSTALL_PROPERTIES}" + else + printf '\nFF_ENABLE_OZONE_ACTION_MATCHES_CONDITION=%s\n' "${ff}" >> "${RANGER_ADMIN_INSTALL_PROPERTIES}" + fi + + export RANGER_ADMIN_INSTALL_PROPERTIES + + # Ranger docker support scripts moved between releases (eg: from config/*.sh to scripts/**). + # Ensure we don't fail if a glob doesn't match, but still make init scripts executable when present. + if [[ -d "${RANGER_SOURCE_DIR}/dev-support/ranger-docker" ]]; then + find "${RANGER_SOURCE_DIR}/dev-support/ranger-docker" -type f -name '*.sh' -exec chmod a+x {} + + fi + download_and_verify_apache_release "ranger/${RANGER_VERSION}/plugins/ozone/ranger-${RANGER_VERSION}-ozone-plugin.tar.gz" + tar -C "${DOWNLOAD_DIR}" -x -z -f "${DOWNLOAD_DIR}/ranger-${RANGER_VERSION}-ozone-plugin.tar.gz" + export RANGER_OZONE_PLUGIN_DIR="${DOWNLOAD_DIR}/ranger-${RANGER_VERSION}-ozone-plugin" + chmod -R a+rX "${RANGER_OZONE_PLUGIN_DIR}" + chmod a+x "${RANGER_OZONE_PLUGIN_DIR}"/*.sh + + # customizations before install + perl -wpl -i \ + -e 's@^POLICY_MGR_URL=.*@POLICY_MGR_URL=http://ranger:6080@;' \ + -e 's@^REPOSITORY_NAME=.*@REPOSITORY_NAME=dev_ozone@;' \ + -e 's@^CUSTOM_USER=ozone@CUSTOM_USER=hadoop@;' \ + -e 's@^XAAUDIT.LOG4J.ENABLE=true@XAAUDIT.LOG4J.ENABLE=false@;' \ + -e 's@^XAAUDIT.LOG4J.DESTINATION.LOG4J=true@XAAUDIT.LOG4J.DESTINATION.LOG4J=false@;' \ + "${RANGER_OZONE_PLUGIN_DIR}/install.properties" + + echo 'machine ranger login admin password rangerR0cks!' > "${COMPOSE_DIR}/../../.netrc" + + start_docker_env + wait_for_port ranger 6080 120 +} diff --git a/hadoop-ozone/dist/src/main/compose/ozonesecure-ha/ranger.yaml b/hadoop-ozone/dist/src/main/compose/ozonesecure-ha/ranger.yaml index 5cc29a134241..2dad84dbb5f4 100644 --- a/hadoop-ozone/dist/src/main/compose/ozonesecure-ha/ranger.yaml +++ b/hadoop-ozone/dist/src/main/compose/ozonesecure-ha/ranger.yaml @@ -17,7 +17,9 @@ x-om-ranger-config: &om-ranger-config environment: - OZONE_MANAGER_CLASSPATH: "/opt/ranger/ozone-plugin/lib/libext/*:/opt/ozone/conf" + # Ranger 2.8.x+ ozone-plugin tarball: jars live under lib/libext/ and + # lib/libext/ranger-ozone-plugin-impl/ (ranger-audit-dest-solr-*.jar is in the impl dir). + OZONE_MANAGER_CLASSPATH: "/opt/ranger/ozone-plugin/lib/libext/*:/opt/ranger/ozone-plugin/lib/libext/ranger-ozone-plugin-impl/*:/opt/ozone/conf" OZONE-SITE.XML_ozone.acl.authorizer.class: "org.apache.ranger.authorization.ozone.authorizer.RangerOzoneAuthorizer" OZONE-SITE.XML_ozone.om.multitenancy.enabled: "true" OZONE-SITE.XML_ozone.om.ranger.https-address: "http://ranger:6080" @@ -37,7 +39,10 @@ x-om-ranger-config: - ${RANGER_OZONE_PLUGIN_DIR}:/opt/ranger/ozone-plugin tmpfs: - /opt/ozone/conf - command: bash -c "sudo --preserve-env /opt/ranger/ozone-plugin/enable-ozone-plugin.sh && /opt/hadoop/bin/ozone om" + # JDK17+ no longer ships javax.annotation, but the Ranger Ozone plugin's Jersey2 + # client needs javax.annotation.Priority. Copy the jar into the plugin impl dir + # so the isolated RangerPluginClassLoader can find it. + command: bash -c 'sudo mkdir -p /opt/ranger/ozone-plugin/lib/libext/ranger-ozone-plugin-impl && for j in /opt/hadoop/share/ozone/lib/javax.annotation-api-*.jar; do [ -e "$$j" ] || continue; dest="/opt/ranger/ozone-plugin/lib/libext/ranger-ozone-plugin-impl/$$(basename "$$j")"; sudo test -e "$$dest" || sudo cp -n "$$j" "$$dest" || sudo test -e "$$dest"; done && sudo --preserve-env /opt/ranger/ozone-plugin/enable-ozone-plugin.sh && exec /opt/hadoop/bin/ozone om' services: om1: diff --git a/hadoop-ozone/dist/src/main/compose/ozonesecure-ha/test-ranger.sh b/hadoop-ozone/dist/src/main/compose/ozonesecure-ha/test-ranger.sh index f28be9c8f7e3..7d6950ccaa70 100755 --- a/hadoop-ozone/dist/src/main/compose/ozonesecure-ha/test-ranger.sh +++ b/hadoop-ozone/dist/src/main/compose/ozonesecure-ha/test-ranger.sh @@ -20,54 +20,10 @@ COMPOSE_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" >/dev/null 2>&1 && pwd )" export COMPOSE_DIR -if [[ -z "${RANGER_VERSION:-}" ]]; then - export RANGER_VERSION="${ranger.version}" -fi - -: "${DOWNLOAD_DIR:=${TEMP_DIR:-/tmp}}" - # shellcheck source=/dev/null -source "$COMPOSE_DIR/../testlib.sh" - -export COMPOSE_FILE=docker-compose.yaml:ranger.yaml:../common/ranger.yaml -export OM_SERVICE_ID="omservice" -export SCM=scm1.org -export SECURITY_ENABLED=true - -if [[ "${SKIP_APACHE_VERIFY_DOWNLOAD}" != "true" ]]; then - curl -LO https://downloads.apache.org/ranger/KEYS - gpg --import KEYS -fi - -download_and_verify_apache_release "ranger/${RANGER_VERSION}/apache-ranger-${RANGER_VERSION}.tar.gz" -tar -C "${DOWNLOAD_DIR}" -x -z -f "${DOWNLOAD_DIR}/apache-ranger-${RANGER_VERSION}.tar.gz" -export RANGER_SOURCE_DIR="${DOWNLOAD_DIR}/apache-ranger-${RANGER_VERSION}" -chmod -R a+rX "${RANGER_SOURCE_DIR}" - -# Ranger docker support scripts moved between releases (eg: from config/*.sh to scripts/**). -# Ensure we don't fail if a glob doesn't match, but still make init scripts executable when present. -if [[ -d "${RANGER_SOURCE_DIR}/dev-support/ranger-docker" ]]; then - find "${RANGER_SOURCE_DIR}/dev-support/ranger-docker" -type f -name '*.sh' -exec chmod a+x {} + -fi -download_and_verify_apache_release "ranger/${RANGER_VERSION}/plugins/ozone/ranger-${RANGER_VERSION}-ozone-plugin.tar.gz" -tar -C "${DOWNLOAD_DIR}" -x -z -f "${DOWNLOAD_DIR}/ranger-${RANGER_VERSION}-ozone-plugin.tar.gz" -export RANGER_OZONE_PLUGIN_DIR="${DOWNLOAD_DIR}/ranger-${RANGER_VERSION}-ozone-plugin" -chmod -R a+rX "${RANGER_OZONE_PLUGIN_DIR}" -chmod a+x "${RANGER_OZONE_PLUGIN_DIR}"/*.sh - -# customizations before install -perl -wpl -i \ - -e 's@^POLICY_MGR_URL=.*@POLICY_MGR_URL=http://ranger:6080@;' \ - -e 's@^REPOSITORY_NAME=.*@REPOSITORY_NAME=dev_ozone@;' \ - -e 's@^CUSTOM_USER=ozone@CUSTOM_USER=hadoop@;' \ - -e 's@^XAAUDIT.LOG4J.ENABLE=true@XAAUDIT.LOG4J.ENABLE=false@;' \ - -e 's@^XAAUDIT.LOG4J.DESTINATION.LOG4J=true@XAAUDIT.LOG4J.DESTINATION.LOG4J=false@;' \ - "${RANGER_OZONE_PLUGIN_DIR}/install.properties" - -echo 'machine ranger login admin password rangerR0cks!' > ../../.netrc +source "$COMPOSE_DIR/ranger-testlib.sh" -start_docker_env -wait_for_port ranger 6080 120 +setup_ranger_acceptance_env execute_robot_test s3g -v USER:hdfs kinit.robot execute_robot_test s3g freon/generate.robot diff --git a/hadoop-ozone/dist/src/main/compose/ozonesecure-ha/test-sts.sh b/hadoop-ozone/dist/src/main/compose/ozonesecure-ha/test-sts.sh new file mode 100755 index 000000000000..879dd1b58519 --- /dev/null +++ b/hadoop-ozone/dist/src/main/compose/ozonesecure-ha/test-sts.sh @@ -0,0 +1,30 @@ +#!/usr/bin/env bash +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +#suite:sts + +COMPOSE_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" >/dev/null 2>&1 && pwd )" +export COMPOSE_DIR + +# shellcheck source=/dev/null +source "$COMPOSE_DIR/ranger-testlib.sh" + +setup_ranger_acceptance_env + +execute_robot_test s3g -v RANGER_ENDPOINT_URL:"http://ranger:6080" -v USER:hdfs security/ozone-secure-sts.robot +execute_robot_test s3g -v RANGER_ENDPOINT_URL:"http://ranger:6080" -v USER:hdfs security/ozone-secure-sts-multitenant.robot +"${COMPOSE_DIR}/polaris-smoketest.sh" diff --git a/hadoop-ozone/dist/src/main/compose/ozonesecure/docker-config b/hadoop-ozone/dist/src/main/compose/ozonesecure/docker-config index 2fc32beeb970..4748bd647eaf 100644 --- a/hadoop-ozone/dist/src/main/compose/ozonesecure/docker-config +++ b/hadoop-ozone/dist/src/main/compose/ozonesecure/docker-config @@ -96,6 +96,7 @@ OZONE-SITE.XML_hdds.datanode.kerberos.keytab.file=/etc/security/keytabs/dn.keyta OZONE-SITE.XML_ozone.security.http.kerberos.enabled=true OZONE-SITE.XML_ozone.s3g.secret.http.enabled=true +OZONE-SITE.XML_ozone.s3g.sts.http.enabled=true OZONE-SITE.XML_ozone.http.filter.initializers=org.apache.hadoop.security.AuthenticationFilterInitializer OZONE-SITE.XML_ozone.om.http.auth.type=kerberos diff --git a/hadoop-ozone/dist/src/main/smoketest/security/generate_oversized_session_policy.py b/hadoop-ozone/dist/src/main/smoketest/security/generate_oversized_session_policy.py new file mode 100644 index 000000000000..7eb416151bdf --- /dev/null +++ b/hadoop-ozone/dist/src/main/smoketest/security/generate_oversized_session_policy.py @@ -0,0 +1,36 @@ +#!/usr/bin/env python3 + +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import json + + +def main() -> None: + statement = { + "Effect": "Allow", + "Action": "s3:GetObject", + "Resource": "arn:aws:s3:::bucket123/*", + } + policy = {"Version": "2012-10-17", "Statement": [statement]} + base = json.dumps(policy, separators=(",", ":")) + # Keep the payload comfortably above the STS policy size limit. + policy["Pad"] = "X" * (35000 - len(base) + 64) + print(json.dumps(policy, separators=(",", ":"))) + + +if __name__ == "__main__": + main() diff --git a/hadoop-ozone/dist/src/main/smoketest/security/mutate_sts_session_token.py b/hadoop-ozone/dist/src/main/smoketest/security/mutate_sts_session_token.py new file mode 100644 index 000000000000..68c8c17b9ec2 --- /dev/null +++ b/hadoop-ozone/dist/src/main/smoketest/security/mutate_sts_session_token.py @@ -0,0 +1,216 @@ +#!/usr/bin/env python3 + +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +""" +Mutate an STS session token to simulate tampering attacks. + +The token is base64url-encoded binary data whose fields are length-prefixed +with Hadoop VInt (variable-length integer) encoding, in this order: + 1. identifier (contains the session policy JSON) + 2. password (the HMAC signature bytes) + 3. kind (token type string, e.g. "STSToken") + 4. service (the service type, e.g. "STS") + +Supported MUTATION_TYPE values: + service - corrupt the service type so token lookup fails + signature - flip a bit in the password so signature verification fails + session_policy - alter the policy inside the identifier to test policy enforcement +""" + +import base64 +import os + + +# --------------------------------------------------------------------------- +# Hadoop VInt encoding +# +# Single-byte range: values -112 .. 127 are stored as one byte (as signed). +# Multi-byte: a leading "length byte" encodes the sign and number of +# additional bytes: +# positive multi-byte: first byte is -113..-120 → 2..9 extra bytes +# negative multi-byte: first byte is -121..-128 → 2..9 extra bytes +# --------------------------------------------------------------------------- + +def _vint_byte(value: int) -> int: + """Return value in the range 0-255 (treat Python int as signed byte).""" + return value & 0xFF + + +def read_vint(buf: bytes, pos: int) -> tuple[int, int]: + """Read a Hadoop VInt from buf at pos. Returns (value, bytes_consumed). + + The first byte encodes the sign and the total byte count: + -113 .. -120 → positive, (total - 1) extra bytes (-111 - first extra bytes) + -121 .. -128 → negative, (total - 1) extra bytes (-119 - first extra bytes) + """ + if pos >= len(buf): + raise ValueError(f"VInt read out of bounds at position {pos}") + + first = buf[pos] if buf[pos] < 128 else buf[pos] - 256 # unsigned → signed + + # Single-byte: -112 to 127 + if first >= -112: + return first, 1 + + # Multi-byte: first byte encodes sign and number of extra bytes + is_negative = first < -120 + # decode_vint_size returns *total* bytes including the first byte; + # subtract 1 to get the number of payload bytes that follow. + total_bytes = (-119 - first) if is_negative else (-111 - first) + n_extra = total_bytes - 1 + + end = pos + 1 + n_extra + if end > len(buf): + raise ValueError(f"Truncated VInt at position {pos}: need {total_bytes} bytes, have {len(buf) - pos}") + + magnitude = int.from_bytes(buf[pos + 1:end], byteorder="big") + value = ~magnitude if is_negative else magnitude + return value, total_bytes + + +def write_vint(value: int) -> bytes: + """Encode an integer as a Hadoop VInt. + + For multi-byte values the first byte is: + positive: -113 - (n_extra - 1) → -113 down to -120 (1..8 extra bytes) + negative: -121 - (n_extra - 1) → -121 down to -128 (1..8 extra bytes) + Followed by the magnitude bytes big-endian. + """ + # Single-byte range: stored directly as signed byte + if -112 <= value <= 127: + return bytes([_vint_byte(value)]) + + # For multi-byte we store the magnitude (complement for negatives) + magnitude = (~value) if value < 0 else value + + # Count bytes needed for the magnitude (at least 1) + tmp = magnitude + n_extra = 0 + while tmp != 0: + tmp >>= 8 + n_extra += 1 + if n_extra == 0: + n_extra = 1 + + # First byte encodes sign and extra count + first = (-120 - n_extra) if value < 0 else (-112 - n_extra) + + return bytes([_vint_byte(first)]) + magnitude.to_bytes(n_extra, byteorder="big") + + +# --------------------------------------------------------------------------- +# Token parsing / reassembly +# --------------------------------------------------------------------------- + +def read_field(buf: bytes, pos: int) -> tuple[bytearray, int]: + """Read one length-prefixed field. Returns (field_bytes, new_pos).""" + length, n = read_vint(buf, pos) + if length < 0: + raise ValueError(f"Negative field length {length} at position {pos}") + pos += n + if pos + length > len(buf): + raise ValueError(f"Field length {length} exceeds remaining bytes at position {pos}") + return bytearray(buf[pos:pos + length]), pos + length + + +def write_field(data: bytes) -> bytes: + """Encode one length-prefixed field.""" + return write_vint(len(data)) + bytes(data) + + +def decode_token(token: str) -> tuple[bytearray, bytearray, bytearray, bytearray]: + """Base64url-decode and parse token into its four fields.""" + raw = base64.urlsafe_b64decode(token + "=" * ((4 - len(token) % 4) % 4)) + pos = 0 + identifier, pos = read_field(raw, pos) + password, pos = read_field(raw, pos) + kind, pos = read_field(raw, pos) + service, pos = read_field(raw, pos) + return identifier, password, kind, service + + +def encode_token(identifier: bytes, password: bytes, kind: bytes, service: bytes) -> str: + """Reassemble the four fields and base64url-encode the result.""" + raw = write_field(identifier) + write_field(password) + write_field(kind) + write_field(service) + return base64.urlsafe_b64encode(raw).decode().rstrip("=") + + +# --------------------------------------------------------------------------- +# Mutations +# --------------------------------------------------------------------------- + +def mutate_service(service: bytearray) -> bytearray: + """Replace service bytes with garbage of the same length.""" + return bytearray(b"BAD" if len(service) == 3 else b"X" * len(service)) + + +def mutate_signature(password: bytearray) -> bytearray: + """Flip the first bit of the signature.""" + if not password: + raise ValueError("Token password is empty") + password[0] ^= 0x01 + return password + + +def mutate_session_policy(identifier: bytearray) -> bytearray: + """Corrupt the first permission value in the embedded session policy. + + The identifier contains the session policy JSON (UTF-8) as a substring. + """ + marker = b'"permissions":["' + start = identifier.find(marker) + if start < 0: + raise ValueError('Could not find \'"permissions":["\' in identifier to mutate') + + value_start = start + len(marker) + value_end = identifier.find(b'"', value_start) + if value_end < 0: + raise ValueError('Could not find end-quote for first permission to mutate') + + if value_end == value_start: + raise ValueError("First permission value is empty; nothing to mutate") + + # Flip one byte in the permission string to ensure token tampering is detected. + identifier[value_start] ^= 0x01 + return identifier + + +# --------------------------------------------------------------------------- +# Entry point +# --------------------------------------------------------------------------- + +def main() -> None: + token = os.environ["SESSION_TOKEN"] + mutation = os.environ["MUTATION_TYPE"] + + identifier, password, kind, service = decode_token(token) + + if mutation == "service": + service = mutate_service(service) + elif mutation == "signature": + password = mutate_signature(password) + elif mutation == "session_policy": + identifier = mutate_session_policy(identifier) + else: + raise ValueError(f"Unsupported mutation type: {mutation!r}") + + print(encode_token(identifier, password, kind, service)) + + +if __name__ == "__main__": + main() diff --git a/hadoop-ozone/dist/src/main/smoketest/security/ozone-secure-sts-multitenant.robot b/hadoop-ozone/dist/src/main/smoketest/security/ozone-secure-sts-multitenant.robot new file mode 100644 index 000000000000..f4c56839dcae --- /dev/null +++ b/hadoop-ozone/dist/src/main/smoketest/security/ozone-secure-sts-multitenant.robot @@ -0,0 +1,335 @@ +# Licensed to the Apache Software Foundation (ASF) under one or more +# contributor license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright ownership. +# The ASF licenses this file to You under the Apache License, Version 2.0 +# (the "License"); you may not use this file except in compliance with +# the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +*** Settings *** +Suite Setup Skip If '${RANGER_ENDPOINT_URL}' == '' No Ranger +Documentation Smoke test for S3 STS AssumeRole + Temp Creds (Multi-Tenant Scenario) +Resource ./ozone-secure-sts.resource +Test Timeout 10 minutes + +*** Variables *** +${TENANT_ONE} sts-tenant-one +${TENANT_TWO} sts-tenant-two +${TENANT_THREE} sts-tenant-three +${USER_A} svc-iceberg-userA +${USER_B} svc-iceberg-userB +${TENANT_ONE_ROLE} sts-tenant-one-role +${TENANT_TWO_ROLE} sts-tenant-two-role +${TENANT_THREE_ROLE} sts-tenant-three-role +${TENANT_ONE_ICEBERG_BUCKET} iceberg-tenant-one-bucket +${TENANT_TWO_ICEBERG_BUCKET} iceberg-tenant-two-bucket +${TENANT_THREE_ICEBERG_BUCKET} iceberg-tenant-three-bucket +${TENANT_TWO_ANOTHER_BUCKET} tenant-two-another-bucket +${TENANT_ONE_LINKED_BUCKET} tenant-one-a-linked-bucket +${ICEBERG_BUCKET_TESTFILE} testfile23 +${ANOTHER_BUCKET_TESTFILE} testfile00 +${OM_ADMIN_USER} hdfs + +*** Test Cases *** +Create Users in Ranger + ${user_json} = Set Variable { "loginId": "${USER_A}", "name": "${USER_A}", "password": "Password123", "firstName": "User A Iceberg REST", "lastName": "Catalog", "emailAddress": "${USER_A}@example.com", "userRoleList": ["ROLE_USER"], "userPermList": [ { "moduleId": 1, "isAllowed": 1 }, { "moduleId": 3, "isAllowed": 1 }, { "moduleId": 7, "isAllowed": 1 } ] } + Create Ranger User ${user_json} + ${user_json} = Set Variable { "loginId": "${USER_B}", "name": "${USER_B}", "password": "Password123", "firstName": "User B Iceberg REST", "lastName": "Catalog", "emailAddress": "${USER_B}@example.com", "userRoleList": ["ROLE_USER"], "userPermList": [ { "moduleId": 1, "isAllowed": 1 }, { "moduleId": 3, "isAllowed": 1 }, { "moduleId": 7, "isAllowed": 1 } ] } + Create Ranger User ${user_json} + +Create Tenants + Kinit test user ${OM_ADMIN_USER} ${OM_ADMIN_USER}.keytab + ${output} = Execute ozone tenant --verbose create ${TENANT_ONE} + Should contain ${output} "tenantId" : "${TENANT_ONE}" + ${output} = Execute ozone tenant --verbose create ${TENANT_TWO} + Should contain ${output} "tenantId" : "${TENANT_TWO}" + ${output} = Execute ozone tenant --verbose create ${TENANT_THREE} + Should contain ${output} "tenantId" : "${TENANT_THREE}" + +Assign Users to Tenants + ${accessKeyId} ${secretKey} = Assign User To Tenant And Get Credentials ${USER_A} ${TENANT_ONE} + Set Global Variable ${USER_A_T1_PERM_ACCESS_KEY_ID} ${accessKeyId} + Set Global Variable ${USER_A_T1_PERM_SECRET_KEY} ${secretKey} + + ${accessKeyId} ${secretKey} = Assign User To Tenant And Get Credentials ${USER_A} ${TENANT_TWO} + Set Global Variable ${USER_A_T2_PERM_ACCESS_KEY_ID} ${accessKeyId} + Set Global Variable ${USER_A_T2_PERM_SECRET_KEY} ${secretKey} + + ${accessKeyId} ${secretKey} = Assign User To Tenant And Get Credentials ${USER_B} ${TENANT_THREE} + Set Global Variable ${USER_B_T3_PERM_ACCESS_KEY_ID} ${accessKeyId} + Set Global Variable ${USER_B_T3_PERM_SECRET_KEY} ${secretKey} + +Create Roles in Ranger + ${role_json} = Set Variable { "name": "${TENANT_ONE_ROLE}", "description": "Tenant One Role" } + Create Ranger Role ${role_json} + ${role_json} = Set Variable { "name": "${TENANT_TWO_ROLE}", "description": "Tenant Two Role" } + Create Ranger Role ${role_json} + ${role_json} = Set Variable { "name": "${TENANT_THREE_ROLE}", "description": "Tenant Three Role" } + Create Ranger Role ${role_json} + +Create Assume Role Policies + Create Ranger Assume Role Policy ${TENANT_ONE_ROLE} ${USER_A} + Create Ranger Assume Role Policy ${TENANT_TWO_ROLE} ${USER_A} + Create Ranger Assume Role Policy ${TENANT_THREE_ROLE} ${USER_B} + +Update Tenant Volume Access policies + # This policy gives '${TENANT_ONE_ROLE}' role READ,LIST permission on volume ${TENANT_ONE}. + # It also gives '${USER_A}' user READ permission on volume ${TENANT_ONE}. + ${policy_item_json} = Set Variable [ { "accesses": [ { "type": "read", "isAllowed": true }, { "type": "list", "isAllowed": true } ], "roles": [ "${TENANT_ONE_ROLE}" ], "delegateAdmin": false }, { "accesses": [ { "type": "read", "isAllowed": true } ], "users": [ "${USER_A}" ], "delegateAdmin": false } ] + Update Ranger Policy Items ${TENANT_ONE}-VolumeAccess ${policy_item_json} + + # This policy gives '${TENANT_TWO_ROLE}' role READ,LIST permission on volume ${TENANT_TWO}. + # It also gives '${USER_A}' user READ permission on volume ${TENANT_TWO}. + # It also gives '${TENANT_ONE_ROLE}' role READ permission on volume ${TENANT_TWO} so it can access ${TENANT_ONE}/${TENANT_ONE_LINKED_BUCKET}. + ${policy_item_json} = Set Variable [ { "accesses": [ { "type": "read", "isAllowed": true }, { "type": "list", "isAllowed": true } ], "roles": [ "${TENANT_TWO_ROLE}" ], "delegateAdmin": false }, { "accesses": [ { "type": "read", "isAllowed": true } ], "users": [ "${USER_A}" ], "delegateAdmin": false }, { "accesses": [ { "type": "read", "isAllowed": true } ], "roles": [ "${TENANT_ONE_ROLE}" ], "delegateAdmin": false } ] + Update Ranger Policy Items ${TENANT_TWO}-VolumeAccess ${policy_item_json} + + # This policy gives '${TENANT_THREE_ROLE}' role READ,LIST permission on volume ${TENANT_THREE}. + # It also gives '${USER_B}' user READ permission on volume ${TENANT_THREE}. + ${policy_item_json} = Set Variable [ { "accesses": [ { "type": "read", "isAllowed": true }, { "type": "list", "isAllowed": true } ], "roles": [ "${TENANT_THREE_ROLE}" ], "delegateAdmin": false }, { "accesses": [ { "type": "read", "isAllowed": true } ], "users": [ "${USER_B}" ], "delegateAdmin": false } ] + Update Ranger Policy Items ${TENANT_THREE}-VolumeAccess ${policy_item_json} + +Create Bucket Access policies + # This policy gives '${TENANT_ONE_ROLE}' role ALL permission on buckets ${TENANT_ONE}/${TENANT_ONE_ICEBERG_BUCKET} + # and ${TENANT_ONE}/${TENANT_ONE_LINKED_BUCKET}. + # It also gives '${USER_A}' user READ, CREATE permissions on bucket ${TENANT_ONE}/${TENANT_ONE_ICEBERG_BUCKET}. + ${policy_json} = Set Variable { "isEnabled": true, "service": "dev_ozone", "name": "${TENANT_ONE} ${TENANT_ONE_ICEBERG_BUCKET} and ${TENANT_ONE_LINKED_BUCKET} access", "policyType": 0, "policyPriority": 0, "isAuditEnabled": true, "resources": { "volume": { "values": [ "${TENANT_ONE}" ], "isExcludes": false, "isRecursive": false }, "bucket": { "values": [ "${TENANT_ONE_ICEBERG_BUCKET}", "${TENANT_ONE_LINKED_BUCKET}" ], "isExcludes": false, "isRecursive": false } }, "policyItems": [ { "accesses": [ { "type": "all", "isAllowed": true } ], "roles": [ "${TENANT_ONE_ROLE}" ], "delegateAdmin": false }, { "accesses": [ { "type": "read", "isAllowed": true }, { "type": "create", "isAllowed": true } ], "users": [ "${USER_A}" ], "delegateAdmin": false } ], "serviceType": "ozone", "isDenyAllElse": false } + Create Ranger Policy ${policy_json} + + # This policy gives '${TENANT_TWO_ROLE}' role ALL permission on bucket ${TENANT_TWO}/${TENANT_TWO_ICEBERG_BUCKET}. + # It also gives '${USER_A}' user READ, CREATE permissions on bucket ${TENANT_TWO}/${TENANT_TWO_ICEBERG_BUCKET}. + ${policy_json} = Set Variable { "isEnabled": true, "service": "dev_ozone", "name": "${TENANT_TWO} ${TENANT_TWO_ICEBERG_BUCKET} access", "policyType": 0, "policyPriority": 0, "isAuditEnabled": true, "resources": { "volume": { "values": [ "${TENANT_TWO}" ], "isExcludes": false, "isRecursive": false }, "bucket": { "values": [ "${TENANT_TWO_ICEBERG_BUCKET}" ], "isExcludes": false, "isRecursive": false } }, "policyItems": [ { "accesses": [ { "type": "all", "isAllowed": true } ], "roles": [ "${TENANT_TWO_ROLE}" ], "delegateAdmin": false }, { "accesses": [ { "type": "read", "isAllowed": true }, { "type": "create", "isAllowed": true } ], "users": [ "${USER_A}" ], "delegateAdmin": false } ], "serviceType": "ozone", "isDenyAllElse": false } + Create Ranger Policy ${policy_json} + + # This policy gives '${TENANT_THREE_ROLE}' role ALL permission on bucket ${TENANT_THREE}/${TENANT_THREE_ICEBERG_BUCKET}. + # It also gives '${USER_B}' user READ, CREATE permissions on bucket ${TENANT_THREE}/${TENANT_THREE_ICEBERG_BUCKET}. + ${policy_json} = Set Variable { "isEnabled": true, "service": "dev_ozone", "name": "${TENANT_THREE} ${TENANT_THREE_ICEBERG_BUCKET} access", "policyType": 0, "policyPriority": 0, "isAuditEnabled": true, "resources": { "volume": { "values": [ "${TENANT_THREE}" ], "isExcludes": false, "isRecursive": false }, "bucket": { "values": [ "${TENANT_THREE_ICEBERG_BUCKET}" ], "isExcludes": false, "isRecursive": false } }, "policyItems": [ { "accesses": [ { "type": "all", "isAllowed": true } ], "roles": [ "${TENANT_THREE_ROLE}" ], "delegateAdmin": false }, { "accesses": [ { "type": "read", "isAllowed": true }, { "type": "create", "isAllowed": true } ], "users": [ "${USER_B}" ], "delegateAdmin": false } ], "serviceType": "ozone", "isDenyAllElse": false } + Create Ranger Policy ${policy_json} + + # This policy gives '${TENANT_ONE_ROLE}' role ALL permission on bucket ${TENANT_TWO}/${TENANT_TWO_ANOTHER_BUCKET}. + # It also gives '${USER_A}' user READ, CREATE permissions on bucket ${TENANT_TWO}/${TENANT_TWO_ANOTHER_BUCKET}. + ${policy_json} = Set Variable { "isEnabled": true, "service": "dev_ozone", "name": "${TENANT_TWO} ${TENANT_TWO_ANOTHER_BUCKET} access", "policyType": 0, "policyPriority": 0, "isAuditEnabled": true, "resources": { "volume": { "values": [ "${TENANT_TWO}" ], "isExcludes": false, "isRecursive": false }, "bucket": { "values": [ "${TENANT_TWO_ANOTHER_BUCKET}" ], "isExcludes": false, "isRecursive": false } }, "policyItems": [ { "accesses": [ { "type": "all", "isAllowed": true } ], "roles": [ "${TENANT_ONE_ROLE}" ], "delegateAdmin": false }, { "accesses": [ { "type": "read", "isAllowed": true }, { "type": "create", "isAllowed": true } ], "users": [ "${USER_A}" ], "delegateAdmin": false } ], "serviceType": "ozone", "isDenyAllElse": false } + Create Ranger Policy ${policy_json} + +Create Iceberg Bucket and Another Bucket Table Access policies + # This policy gives '${TENANT_ONE_ROLE}' role ALL permission on keys ${TENANT_ONE}/${TENANT_ONE_ICEBERG_BUCKET}/* + # and keys ${TENANT_ONE}/${TENANT_ONE_LINKED_BUCKET}/*. + ${policy_json} = Set Variable { "isEnabled": true, "service": "dev_ozone", "name": "${TENANT_ONE} ${TENANT_ONE_ICEBERG_BUCKET} and ${TENANT_ONE_LINKED_BUCKET} table access", "policyType": 0, "policyPriority": 0, "isAuditEnabled": true, "resources": { "volume": { "values": [ "${TENANT_ONE}" ], "isExcludes": false, "isRecursive": false }, "bucket": { "values": [ "${TENANT_ONE_ICEBERG_BUCKET}", "${TENANT_ONE_LINKED_BUCKET}" ], "isExcludes": false, "isRecursive": false }, "key": { "values": [ "*" ], "isExcludes": false, "isRecursive": true } }, "policyItems": [ { "accesses": [ { "type": "all", "isAllowed": true } ], "roles": [ "${TENANT_ONE_ROLE}" ], "delegateAdmin": false } ], "serviceType": "ozone", "isDenyAllElse": false } + Create Ranger Policy ${policy_json} + + # This policy gives '${TENANT_TWO_ROLE}' role ALL permission on keys ${TENANT_TWO}/${TENANT_TWO_ICEBERG_BUCKET}/*. + ${policy_json} = Set Variable { "isEnabled": true, "service": "dev_ozone", "name": "${TENANT_TWO} ${TENANT_TWO_ICEBERG_BUCKET} table access", "policyType": 0, "policyPriority": 0, "isAuditEnabled": true, "resources": { "volume": { "values": [ "${TENANT_TWO}" ], "isExcludes": false, "isRecursive": false }, "bucket": { "values": [ "${TENANT_TWO_ICEBERG_BUCKET}" ], "isExcludes": false, "isRecursive": false }, "key": { "values": [ "*" ], "isExcludes": false, "isRecursive": true } }, "policyItems": [ { "accesses": [ { "type": "all", "isAllowed": true } ], "roles": [ "${TENANT_TWO_ROLE}" ], "delegateAdmin": false } ], "serviceType": "ozone", "isDenyAllElse": false } + Create Ranger Policy ${policy_json} + + # This policy gives '${TENANT_THREE_ROLE}' role ALL permission on keys ${TENANT_THREE}/${TENANT_THREE_ICEBERG_BUCKET}/*. + ${policy_json} = Set Variable { "isEnabled": true, "service": "dev_ozone", "name": "${TENANT_THREE} ${TENANT_THREE_ICEBERG_BUCKET} table access", "policyType": 0, "policyPriority": 0, "isAuditEnabled": true, "resources": { "volume": { "values": [ "${TENANT_THREE}" ], "isExcludes": false, "isRecursive": false }, "bucket": { "values": [ "${TENANT_THREE_ICEBERG_BUCKET}" ], "isExcludes": false, "isRecursive": false }, "key": { "values": [ "*" ], "isExcludes": false, "isRecursive": true } }, "policyItems": [ { "accesses": [ { "type": "all", "isAllowed": true } ], "roles": [ "${TENANT_THREE_ROLE}" ], "delegateAdmin": false } ], "serviceType": "ozone", "isDenyAllElse": false } + Create Ranger Policy ${policy_json} + + # This policy gives '${TENANT_ONE_ROLE}' role ALL permission on keys ${TENANT_TWO}/${TENANT_TWO_ANOTHER_BUCKET}/*. + ${policy_json} = Set Variable { "isEnabled": true, "service": "dev_ozone", "name": "${TENANT_TWO} ${TENANT_TWO_ANOTHER_BUCKET} table access", "policyType": 0, "policyPriority": 0, "isAuditEnabled": true, "resources": { "volume": { "values": [ "${TENANT_TWO}" ], "isExcludes": false, "isRecursive": false }, "bucket": { "values": [ "${TENANT_TWO_ANOTHER_BUCKET}" ], "isExcludes": false, "isRecursive": false }, "key": { "values": [ "*" ], "isExcludes": false, "isRecursive": true } }, "policyItems": [ { "accesses": [ { "type": "all", "isAllowed": true } ], "roles": [ "${TENANT_ONE_ROLE}" ], "delegateAdmin": false } ], "serviceType": "ozone", "isDenyAllElse": false } + Create Ranger Policy ${policy_json} + + Refresh Ranger Policy Cache + +Get S3 Credentials for Principals, Create Buckets, and Upload Files to Buckets + Kinit test user ${USER_A} ${USER_A}.keytab + + # Kinit OM_ADMIN_USER that has access to all volumes/buckets per Ranger default policies + Kinit test user ${OM_ADMIN_USER} ${OM_ADMIN_USER}.keytab + + # Tenant 1 data + Execute ozone sh bucket create /${TENANT_ONE}/${TENANT_ONE_ICEBERG_BUCKET} + Create File ${TEMP_DIR}/${ICEBERG_BUCKET_TESTFILE} + Execute ozone sh key put /${TENANT_ONE}/${TENANT_ONE_ICEBERG_BUCKET}/${ICEBERG_BUCKET_TESTFILE} ${TEMP_DIR}/${ICEBERG_BUCKET_TESTFILE} + + # Tenant 2 data and linked bucket + Execute ozone sh bucket create /${TENANT_TWO}/${TENANT_TWO_ICEBERG_BUCKET} + Execute ozone sh key put /${TENANT_TWO}/${TENANT_TWO_ICEBERG_BUCKET}/${ICEBERG_BUCKET_TESTFILE} ${TEMP_DIR}/${ICEBERG_BUCKET_TESTFILE} + Execute ozone sh bucket create /${TENANT_TWO}/${TENANT_TWO_ANOTHER_BUCKET} + Create File ${TEMP_DIR}/${ANOTHER_BUCKET_TESTFILE} + Execute ozone sh key put /${TENANT_TWO}/${TENANT_TWO_ANOTHER_BUCKET}/${ANOTHER_BUCKET_TESTFILE} ${TEMP_DIR}/${ANOTHER_BUCKET_TESTFILE} + # Link ${TENANT_ONE}/${TENANT_ONE_LINKED_BUCKET} to ${TENANT_TWO}/${TENANT_TWO_ANOTHER_BUCKET} + Execute ozone sh bucket link /${TENANT_TWO}/${TENANT_TWO_ANOTHER_BUCKET} /${TENANT_ONE}/${TENANT_ONE_LINKED_BUCKET} + Execute ozone sh key put /${TENANT_ONE}/${TENANT_ONE_LINKED_BUCKET}/${ANOTHER_BUCKET_TESTFILE} ${TEMP_DIR}/${ANOTHER_BUCKET_TESTFILE} + + # Tenant 3 data + Execute ozone sh bucket create /${TENANT_THREE}/${TENANT_THREE_ICEBERG_BUCKET} + Execute ozone sh key put /${TENANT_THREE}/${TENANT_THREE_ICEBERG_BUCKET}/${ICEBERG_BUCKET_TESTFILE} ${TEMP_DIR}/${ICEBERG_BUCKET_TESTFILE} + +Create Role-Scoped Tokens for All Three Tenants + Set Global Variable ${ROLE_ARN} arn:aws:iam::123456789012:role/${TENANT_ONE_ROLE} + # Waiting for Ranger policy cache refresh - ${USER_A} needs to be able to assume role + Wait Until Keyword Succeeds 60s 5s Assume Role And Get Temporary Credentials perm_access_key_id=${USER_A_T1_PERM_ACCESS_KEY_ID} perm_secret_key=${USER_A_T1_PERM_SECRET_KEY} role_arn=${ROLE_ARN} + Set Global Variable ${USER_A_T1_STS_ACCESS_KEY_ID} ${STS_ACCESS_KEY_ID} + Set Global Variable ${USER_A_T1_STS_SECRET_KEY} ${STS_SECRET_KEY} + Set Global Variable ${USER_A_T1_STS_SESSION_TOKEN} ${STS_SESSION_TOKEN} + + Set Global Variable ${ROLE_ARN} arn:aws:iam::123456789012:role/${TENANT_TWO_ROLE} + Assume Role And Get Temporary Credentials perm_access_key_id=${USER_A_T2_PERM_ACCESS_KEY_ID} perm_secret_key=${USER_A_T2_PERM_SECRET_KEY} role_arn=${ROLE_ARN} + Set Global Variable ${USER_A_T2_STS_ACCESS_KEY_ID} ${STS_ACCESS_KEY_ID} + Set Global Variable ${USER_A_T2_STS_SECRET_KEY} ${STS_SECRET_KEY} + Set Global Variable ${USER_A_T2_STS_SESSION_TOKEN} ${STS_SESSION_TOKEN} + + Set Global Variable ${ROLE_ARN} arn:aws:iam::123456789012:role/${TENANT_THREE_ROLE} + Assume Role And Get Temporary Credentials perm_access_key_id=${USER_B_T3_PERM_ACCESS_KEY_ID} perm_secret_key=${USER_B_T3_PERM_SECRET_KEY} role_arn=${ROLE_ARN} + Set Global Variable ${USER_B_T3_STS_ACCESS_KEY_ID} ${STS_ACCESS_KEY_ID} + Set Global Variable ${USER_B_T3_STS_SECRET_KEY} ${STS_SECRET_KEY} + Set Global Variable ${USER_B_T3_STS_SESSION_TOKEN} ${STS_SESSION_TOKEN} + +Verify Role-Scoped Token Accesses + Configure STS Profile ${USER_A_T1_STS_ACCESS_KEY_ID} ${USER_A_T1_STS_SECRET_KEY} ${USER_A_T1_STS_SESSION_TOKEN} + + # This token should be able to read from ${TENANT_ONE}/${TENANT_ONE_ICEBERG_BUCKET} but not ${TENANT_TWO}/${TENANT_TWO_ICEBERG_BUCKET} + # nor ${TENANT_THREE}/${TENANT_THREE_ICEBERG_BUCKET}. Also verify that it can write to ${TENANT_ONE}/${TENANT_ONE_ICEBERG_BUCKET}. + Get Object Should Succeed ${TENANT_ONE_ICEBERG_BUCKET} ${ICEBERG_BUCKET_TESTFILE} + Get Object Should Fail ${TENANT_TWO_ICEBERG_BUCKET} ${ICEBERG_BUCKET_TESTFILE} NoSuchBucket + Get Object Should Fail ${TENANT_THREE_ICEBERG_BUCKET} ${ICEBERG_BUCKET_TESTFILE} NoSuchBucket + Put Object Should Succeed ${TENANT_ONE_ICEBERG_BUCKET} ${ICEBERG_BUCKET_TESTFILE} + + # This token should be able to read from/write to ${TENANT_ONE}/${TENANT_ONE_LINKED_BUCKET} which is linked to + # ${TENANT_TWO}/${TENANT_TWO_ANOTHER_BUCKET}. It should NOT be able to read from/write to ${TENANT_TWO}/${TENANT_TWO_ANOTHER_BUCKET} + # even though the ${TENANT_ONE_ROLE} has access because the permanent credential only has access to ${TENANT_ONE} volume. + Get Object Should Succeed ${TENANT_ONE_LINKED_BUCKET} ${ANOTHER_BUCKET_TESTFILE} + Put Object Should Succeed ${TENANT_ONE_LINKED_BUCKET} ${ANOTHER_BUCKET_TESTFILE} + Get Object Should Fail ${TENANT_TWO_ANOTHER_BUCKET} ${ANOTHER_BUCKET_TESTFILE} NoSuchBucket + Put Object Should Fail ${TENANT_TWO_ANOTHER_BUCKET} ${ANOTHER_BUCKET_TESTFILE} NoSuchBucket + + Configure STS Profile ${USER_A_T2_STS_ACCESS_KEY_ID} ${USER_A_T2_STS_SECRET_KEY} ${USER_A_T2_STS_SESSION_TOKEN} + + # This token should be able to read from ${TENANT_TWO}/${TENANT_TWO_ICEBERG_BUCKET} but not ${TENANT_ONE}/${TENANT_ONE_ICEBERG_BUCKET} + # nor ${TENANT_TWO}/${TENANT_TWO_ANOTHER_BUCKET} nor ${TENANT_THREE}/${TENANT_THREE_ICEBERG_BUCKET} + # nor ${TENANT_ONE}/${TENANT_ONE_LINKED_BUCKET}. + # Also verify that it can write to ${TENANT_TWO}/${TENANT_TWO_ICEBERG_BUCKET}. + Get Object Should Succeed ${TENANT_TWO_ICEBERG_BUCKET} ${ICEBERG_BUCKET_TESTFILE} + Get Object Should Fail ${TENANT_ONE_ICEBERG_BUCKET} ${ICEBERG_BUCKET_TESTFILE} NoSuchBucket + Get Object Should Fail ${TENANT_TWO_ANOTHER_BUCKET} ${ANOTHER_BUCKET_TESTFILE} AccessDenied + Get Object Should Fail ${TENANT_THREE_ICEBERG_BUCKET} ${ICEBERG_BUCKET_TESTFILE} NoSuchBucket + Get Object Should Fail ${TENANT_ONE_LINKED_BUCKET} ${ANOTHER_BUCKET_TESTFILE} NoSuchBucket + Put Object Should Succeed ${TENANT_TWO_ICEBERG_BUCKET} ${ICEBERG_BUCKET_TESTFILE} + + Configure STS Profile ${USER_B_T3_STS_ACCESS_KEY_ID} ${USER_B_T3_STS_SECRET_KEY} ${USER_B_T3_STS_SESSION_TOKEN} + + # This token should be able to read from ${TENANT_THREE}/${TENANT_THREE_ICEBERG_BUCKET} but not ${TENANT_ONE}/${TENANT_ONE_ICEBERG_BUCKET} + # nor ${TENANT_TWO}/${TENANT_TWO_ICEBERG_BUCKET} nor ${TENANT_TWO}/${TENANT_TWO_ANOTHER_BUCKET} + # nor ${TENANT_ONE}/${TENANT_ONE_LINKED_BUCKET}. + # Also verify that it can write to ${TENANT_THREE}/${TENANT_THREE_ICEBERG_BUCKET}. + Get Object Should Succeed ${TENANT_THREE_ICEBERG_BUCKET} ${ICEBERG_BUCKET_TESTFILE} + Get Object Should Fail ${TENANT_ONE_ICEBERG_BUCKET} ${ICEBERG_BUCKET_TESTFILE} NoSuchBucket + Get Object Should Fail ${TENANT_TWO_ICEBERG_BUCKET} ${ICEBERG_BUCKET_TESTFILE} NoSuchBucket + Get Object Should Fail ${TENANT_TWO_ANOTHER_BUCKET} ${ANOTHER_BUCKET_TESTFILE} NoSuchBucket + Get Object Should Fail ${TENANT_ONE_LINKED_BUCKET} ${ANOTHER_BUCKET_TESTFILE} NoSuchBucket + Put Object Should Succeed ${TENANT_THREE_ICEBERG_BUCKET} ${ICEBERG_BUCKET_TESTFILE} + +Create Limited-Scoped Tokens for All Three Tenants + # Limit scope to read-only for keys in ${TENANT_ONE_ICEBERG_BUCKET} (note ${TENANT_ONE_LINKED_BUCKET} is excluded) + ${session_policy} = Set Variable {"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:GetObject","Resource": "arn:aws:s3:::${TENANT_ONE_ICEBERG_BUCKET}/*" }]} + Set Global Variable ${ROLE_ARN} arn:aws:iam::123456789012:role/${TENANT_ONE_ROLE} + Assume Role And Get Temporary Credentials policy_json=${session_policy} perm_access_key_id=${USER_A_T1_PERM_ACCESS_KEY_ID} perm_secret_key=${USER_A_T1_PERM_SECRET_KEY} role_arn=${ROLE_ARN} + Set Global Variable ${USER_A_T1_STS_ACCESS_KEY_ID} ${STS_ACCESS_KEY_ID} + Set Global Variable ${USER_A_T1_STS_SECRET_KEY} ${STS_SECRET_KEY} + Set Global Variable ${USER_A_T1_STS_SESSION_TOKEN} ${STS_SESSION_TOKEN} + + # Limit scope to read-only for keys in ${TENANT_TWO_ICEBERG_BUCKET} + ${session_policy} = Set Variable {"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:GetObject","Resource":"arn:aws:s3:::${TENANT_TWO_ICEBERG_BUCKET}/*"}]} + Set Global Variable ${ROLE_ARN} arn:aws:iam::123456789012:role/${TENANT_TWO_ROLE} + Assume Role And Get Temporary Credentials policy_json=${session_policy} perm_access_key_id=${USER_A_T2_PERM_ACCESS_KEY_ID} perm_secret_key=${USER_A_T2_PERM_SECRET_KEY} role_arn=${ROLE_ARN} + Set Global Variable ${USER_A_T2_STS_ACCESS_KEY_ID} ${STS_ACCESS_KEY_ID} + Set Global Variable ${USER_A_T2_STS_SECRET_KEY} ${STS_SECRET_KEY} + Set Global Variable ${USER_A_T2_STS_SESSION_TOKEN} ${STS_SESSION_TOKEN} + + # Limit scope to read-only for ${TENANT_THREE_ICEBERG_BUCKET} keys + ${session_policy} = Set Variable {"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:GetObject","Resource":"arn:aws:s3:::${TENANT_THREE_ICEBERG_BUCKET}/*"}]} + Set Global Variable ${ROLE_ARN} arn:aws:iam::123456789012:role/${TENANT_THREE_ROLE} + Assume Role And Get Temporary Credentials policy_json=${session_policy} perm_access_key_id=${USER_B_T3_PERM_ACCESS_KEY_ID} perm_secret_key=${USER_B_T3_PERM_SECRET_KEY} role_arn=${ROLE_ARN} + Set Global Variable ${USER_B_T3_STS_ACCESS_KEY_ID} ${STS_ACCESS_KEY_ID} + Set Global Variable ${USER_B_T3_STS_SECRET_KEY} ${STS_SECRET_KEY} + Set Global Variable ${USER_B_T3_STS_SESSION_TOKEN} ${STS_SESSION_TOKEN} + +Verify Limited-Scoped Token Accesses + Configure STS Profile ${USER_A_T1_STS_ACCESS_KEY_ID} ${USER_A_T1_STS_SECRET_KEY} ${USER_A_T1_STS_SESSION_TOKEN} + + # This token should be able to read from ${TENANT_ONE}/${TENANT_ONE_ICEBERG_BUCKET} but not ${TENANT_TWO}/${TENANT_TWO_ICEBERG_BUCKET} + # nor ${TENANT_THREE}/${TENANT_THREE_ICEBERG_BUCKET}. Also verify that it CANNOT write to ${TENANT_ONE}/${TENANT_ONE_ICEBERG_BUCKET}. + Get Object Should Succeed ${TENANT_ONE_ICEBERG_BUCKET} ${ICEBERG_BUCKET_TESTFILE} + Get Object Should Fail ${TENANT_TWO_ICEBERG_BUCKET} ${ICEBERG_BUCKET_TESTFILE} NoSuchBucket + Get Object Should Fail ${TENANT_THREE_ICEBERG_BUCKET} ${ICEBERG_BUCKET_TESTFILE} NoSuchBucket + Put Object Should Fail ${TENANT_ONE_ICEBERG_BUCKET} ${ICEBERG_BUCKET_TESTFILE} AccessDenied + + # This token should NOT be able to read from/write to ${TENANT_ONE}/${TENANT_ONE_LINKED_BUCKET} (because of the session policy) which is linked to + # ${TENANT_TWO}/${TENANT_TWO_ANOTHER_BUCKET}. It should not be able to read from/write to ${TENANT_TWO}/${TENANT_TWO_ANOTHER_BUCKET} + # even though the ${TENANT_ONE_ROLE} has access because the permanent credential only has access to ${TENANT_ONE}. + Get Object Should Fail ${TENANT_ONE_LINKED_BUCKET} ${ANOTHER_BUCKET_TESTFILE} AccessDenied + Put Object Should Fail ${TENANT_ONE_LINKED_BUCKET} ${ANOTHER_BUCKET_TESTFILE} AccessDenied + Get Object Should Fail ${TENANT_TWO_ANOTHER_BUCKET} ${ANOTHER_BUCKET_TESTFILE} NoSuchBucket + Put Object Should Fail ${TENANT_TWO_ANOTHER_BUCKET} ${ANOTHER_BUCKET_TESTFILE} AccessDenied + + Configure STS Profile ${USER_A_T2_STS_ACCESS_KEY_ID} ${USER_A_T2_STS_SECRET_KEY} ${USER_A_T2_STS_SESSION_TOKEN} + + # This token should be able to read from ${TENANT_TWO}/${TENANT_TWO_ICEBERG_BUCKET} but not ${TENANT_ONE}/${TENANT_ONE_ICEBERG_BUCKET} + # nor ${TENANT_TWO}/${TENANT_TWO_ANOTHER_BUCKET} nor ${TENANT_THREE}/${TENANT_THREE_ICEBERG_BUCKET} + # nor ${TENANT_ONE}/${TENANT_ONE_LINKED_BUCKET}. + # Also verify that it CANNOT write to ${TENANT_TWO}/${TENANT_TWO_ICEBERG_BUCKET}. + Get Object Should Succeed ${TENANT_TWO_ICEBERG_BUCKET} ${ICEBERG_BUCKET_TESTFILE} + Get Object Should Fail ${TENANT_ONE_ICEBERG_BUCKET} ${ICEBERG_BUCKET_TESTFILE} NoSuchBucket + Get Object Should Fail ${TENANT_TWO_ANOTHER_BUCKET} ${ANOTHER_BUCKET_TESTFILE} AccessDenied + Get Object Should Fail ${TENANT_THREE_ICEBERG_BUCKET} ${ICEBERG_BUCKET_TESTFILE} NoSuchBucket + Get Object Should Fail ${TENANT_ONE_LINKED_BUCKET} ${ANOTHER_BUCKET_TESTFILE} NoSuchBucket + Put Object Should Fail ${TENANT_TWO_ICEBERG_BUCKET} ${ICEBERG_BUCKET_TESTFILE} AccessDenied + + Configure STS Profile ${USER_B_T3_STS_ACCESS_KEY_ID} ${USER_B_T3_STS_SECRET_KEY} ${USER_B_T3_STS_SESSION_TOKEN} + + # This token should be able to read from ${TENANT_THREE}/${TENANT_THREE_ICEBERG_BUCKET} but not ${TENANT_ONE}/${TENANT_ONE_ICEBERG_BUCKET} + # nor ${TENANT_TWO}/${TENANT_TWO_ICEBERG_BUCKET} nor ${TENANT_TWO}/${TENANT_TWO_ANOTHER_BUCKET} + # nor ${TENANT_ONE}/${TENANT_ONE_LINKED_BUCKET}. + # Also verify that it CANNOT write to ${TENANT_THREE}/${TENANT_THREE_ICEBERG_BUCKET}. + Get Object Should Succeed ${TENANT_THREE_ICEBERG_BUCKET} ${ICEBERG_BUCKET_TESTFILE} + Get Object Should Fail ${TENANT_ONE_ICEBERG_BUCKET} ${ICEBERG_BUCKET_TESTFILE} NoSuchBucket + Get Object Should Fail ${TENANT_TWO_ICEBERG_BUCKET} ${ICEBERG_BUCKET_TESTFILE} NoSuchBucket + Get Object Should Fail ${TENANT_TWO_ANOTHER_BUCKET} ${ANOTHER_BUCKET_TESTFILE} NoSuchBucket + Get Object Should Fail ${TENANT_ONE_LINKED_BUCKET} ${ANOTHER_BUCKET_TESTFILE} NoSuchBucket + Put Object Should Fail ${TENANT_THREE_ICEBERG_BUCKET} ${ICEBERG_BUCKET_TESTFILE} AccessDenied + +Tenant One Role Session Policy Must Not Access Tenant Three Bucket + ${session_policy} = Set Variable {"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:GetObject","Resource":"arn:aws:s3:::${TENANT_THREE_ICEBERG_BUCKET}/*"}]} + Set Global Variable ${ROLE_ARN} arn:aws:iam::123456789012:role/${TENANT_ONE_ROLE} + Assume Role And Get Temporary Credentials policy_json=${session_policy} perm_access_key_id=${USER_A_T1_PERM_ACCESS_KEY_ID} perm_secret_key=${USER_A_T1_PERM_SECRET_KEY} role_arn=${ROLE_ARN} + Configure STS Profile ${STS_ACCESS_KEY_ID} ${STS_SECRET_KEY} ${STS_SESSION_TOKEN} + Get Object Should Fail ${TENANT_THREE_ICEBERG_BUCKET} ${ICEBERG_BUCKET_TESTFILE} NoSuchBucket + +Tenant One User Must Not Assume Tenant Three Role + Assume Role Should Fail perm_access_key_id=${USER_A_T1_PERM_ACCESS_KEY_ID} perm_secret_key=${USER_A_T1_PERM_SECRET_KEY} expected_error=AccessDenied role_arn=arn:aws:iam::123456789012:role/${TENANT_THREE_ROLE} + +Multitenant Revocation Scenarios + # Create another session token for ${USER_A} in ${TENANT_ONE}, verify it works, then revoke that permanent secret and verify the token no longer works. + # Also verify the token for ${USER_A} in ${TENANT_TWO} still works + Set Global Variable ${ROLE_ARN} arn:aws:iam::123456789012:role/${TENANT_ONE_ROLE} + Assume Role And Get Temporary Credentials perm_access_key_id=${USER_A_T1_PERM_ACCESS_KEY_ID} perm_secret_key=${USER_A_T1_PERM_SECRET_KEY} role_arn=${ROLE_ARN} + Set Global Variable ${USER_A_T1_STS_ACCESS_KEY_ID} ${STS_ACCESS_KEY_ID} + Set Global Variable ${USER_A_T1_STS_SECRET_KEY} ${STS_SECRET_KEY} + Set Global Variable ${USER_A_T1_STS_SESSION_TOKEN} ${STS_SESSION_TOKEN} + Configure STS Profile ${USER_A_T1_STS_ACCESS_KEY_ID} ${USER_A_T1_STS_SECRET_KEY} ${USER_A_T1_STS_SESSION_TOKEN} + Get Object Should Succeed ${TENANT_ONE_ICEBERG_BUCKET} ${ICEBERG_BUCKET_TESTFILE} + + Kinit test user ${OM_ADMIN_USER} ${OM_ADMIN_USER}.keytab + ${output} = Execute ozone tenant --verbose user revoke '${TENANT_ONE}$${USER_A}' + Should contain ${output} Revoked accessId '${TENANT_ONE}$${USER_A}'. + Get Object Should Fail ${TENANT_ONE_ICEBERG_BUCKET} ${ICEBERG_BUCKET_TESTFILE} AccessDenied + + Configure STS Profile ${USER_A_T2_STS_ACCESS_KEY_ID} ${USER_A_T2_STS_SECRET_KEY} ${USER_A_T2_STS_SESSION_TOKEN} + Get Object Should Succeed ${TENANT_TWO_ICEBERG_BUCKET} ${ICEBERG_BUCKET_TESTFILE} + + # Revoking the secret for ${USER_B} should NOT affect the session token, as it is based on ${TENANT_THREE}$${USER_B} combination. + Kinit test user ${USER_B} ${USER_B}.keytab + Execute ozone s3 getsecret -u ${TEST_USER} ${OM_HA_PARAM} + Execute ozone s3 revokesecret -y -u ${TEST_USER} ${OM_HA_PARAM} + Configure STS Profile ${USER_B_T3_STS_ACCESS_KEY_ID} ${USER_B_T3_STS_SECRET_KEY} ${USER_B_T3_STS_SESSION_TOKEN} + Get Object Should Succeed ${TENANT_THREE_ICEBERG_BUCKET} ${ICEBERG_BUCKET_TESTFILE} + + diff --git a/hadoop-ozone/dist/src/main/smoketest/security/ozone-secure-sts-polaris.sql b/hadoop-ozone/dist/src/main/smoketest/security/ozone-secure-sts-polaris.sql new file mode 100644 index 000000000000..85ae802273be --- /dev/null +++ b/hadoop-ozone/dist/src/main/smoketest/security/ozone-secure-sts-polaris.sql @@ -0,0 +1,27 @@ +-- Licensed to the Apache Software Foundation (ASF) under one +-- or more contributor license agreements. See the NOTICE file +-- distributed with this work for additional information +-- regarding copyright ownership. The ASF licenses this file +-- to you under the Apache License, Version 2.0 (the +-- "License"); you may not use this file except in compliance +-- with the License. You may obtain a copy of the License at +-- +-- http://www.apache.org/licenses/LICENSE-2.0 +-- +-- Unless required by applicable law or agreed to in writing, software +-- distributed under the License is distributed on an "AS IS" BASIS, +-- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +-- See the License for the specific language governing permissions and +-- limitations under the License. + +USE polaris; + +CREATE NAMESPACE IF NOT EXISTS ozone_sts_smoke; + +DROP TABLE IF EXISTS ozone_sts_smoke.my_table; + +CREATE TABLE ozone_sts_smoke.my_table (id INT, name STRING); + +INSERT INTO ozone_sts_smoke.my_table VALUES (1, 'testing STS'); + +SELECT * FROM ozone_sts_smoke.my_table; diff --git a/hadoop-ozone/dist/src/main/smoketest/security/ozone-secure-sts.resource b/hadoop-ozone/dist/src/main/smoketest/security/ozone-secure-sts.resource new file mode 100644 index 000000000000..4ebf2ec1b3bd --- /dev/null +++ b/hadoop-ozone/dist/src/main/smoketest/security/ozone-secure-sts.resource @@ -0,0 +1,375 @@ +# Licensed to the Apache Software Foundation (ASF) under one or more +# contributor license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright ownership. +# The ASF licenses this file to You under the Apache License, Version 2.0 +# (the "License"); you may not use this file except in compliance with +# the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +*** Settings *** +Library OperatingSystem +Library String +Library BuiltIn +Library DateTime +Library Collections +Resource ../admincli/lib.resource +Resource ../commonlib.robot +Resource ../s3/commonawslib.robot + +*** Variables *** +${RANGER_ENDPOINT_URL} ${EMPTY} +${STS_ENDPOINT_URL} http://s3g:9880 +${S3G_ENDPOINT_URL} http://s3g:9878 +${ROLE_SESSION_NAME} sts-session-name +${EXPIRED_STS_TOKEN_PROFILE} expired_sts_token +${EXPIRED_STS_TOKEN_ACCESS_KEY_ID} ${EMPTY} +${EXPIRED_STS_TOKEN_SECRET_ACCESS_KEY} ${EMPTY} +${EXPIRED_STS_TOKEN_SESSION_TOKEN} ${EMPTY} +${EXPIRED_STS_TOKEN_EXPIRATION} ${EMPTY} + +*** Keywords *** +Configure AWS Profile + [Arguments] ${profile} ${access_key} ${secret_key} ${session_token}=${EMPTY} ${region}=us-east-1 + Run Keyword Install aws cli + # Use v4 signatures so presign URL will work (Ozone rejects v2 signatures) + Execute aws configure set s3.signature_version s3v4 --profile ${profile} + Execute aws configure set aws_access_key_id ${access_key} --profile ${profile} + Execute aws configure set aws_secret_access_key ${secret_key} --profile ${profile} + Execute aws configure set region ${region} --profile ${profile} + Run Keyword If '${session_token}' != '${EMPTY}' Execute aws configure set aws_session_token ${session_token} --profile ${profile} + +Configure STS Profile + [Arguments] ${access_key} ${secret_key} ${session_token} + Configure AWS Profile sts ${access_key} ${secret_key} ${session_token} + +Create Ranger Artifact + [Arguments] ${json} ${endpoint_url} + Pass Execution If '${RANGER_ENDPOINT_URL}' == '' No Ranger + ${result} = Execute curl --silent --show-error --include --location --netrc --request POST --header "Content-Type: application/json" --header "accept: application/json" --data '${json}' '${endpoint_url}' + Should Contain ${result} HTTP/1.1 200 + +Create Ranger User + [Arguments] ${user_json} + # Note: the /service/xusers/secure/users endpoint must be used below so that the userPermList can be set. Without + # the userPermList being set, the user cannot be added to a Ranger policy. + Create Ranger Artifact ${user_json} '${RANGER_ENDPOINT_URL}/service/xusers/secure/users' + +Create Ranger Role + [Arguments] ${role_json} + Create Ranger Artifact ${role_json} '${RANGER_ENDPOINT_URL}/service/roles/roles' + +Create Ranger Policy + [Arguments] ${policy_json} + Create Ranger Artifact ${policy_json} '${RANGER_ENDPOINT_URL}/service/public/v2/api/policy' + +Create Ranger Assume Role Policy + [Arguments] ${role_name} ${user_name} + ${policy_json} = Set Variable { "isEnabled": true, "service": "dev_ozone", "name": "${role_name} assume role policy", "policyType": 0, "policyPriority": 0, "isAuditEnabled": true, "resources": { "role": { "values": ["${role_name}"], "isExcludes": false, "isRecursive": false } }, "policyItems": [ { "accesses": [ { "type": "assume_role", "isAllowed": true } ], "users": [ "${user_name}" ], "delegateAdmin": false } ], "serviceType": "ozone", "isDenyAllElse": false } + Create Ranger Policy ${policy_json} + +Update Ranger Policy Items + [Arguments] ${policy_name} ${policy_item_json} + Pass Execution If '${RANGER_ENDPOINT_URL}' == '' No Ranger + # Fetch the existing policy json, update by id. + ${encoded_name} = Execute python3 -c "import urllib.parse,sys; print(urllib.parse.quote(sys.argv[1]))" "${policy_name}" + ${policy} = Execute curl --silent --show-error --location --netrc --request GET --header "accept: application/json" "${RANGER_ENDPOINT_URL}/service/public/v2/api/service/dev_ozone/policy/${encoded_name}" + ${policy_id} = Execute printf '%s' '${policy}' | jq -r '.id // empty' + Should Not Be Empty ${policy_id} + ${updated} = Execute printf '%s' '${policy}' | jq '.policyItems += ${policy_item_json}' + ${result} = Execute curl --silent --show-error --include --location --netrc -X PUT -H "Content-Type: application/json" -H "accept: application/json" --data '${updated}' "${RANGER_ENDPOINT_URL}/service/public/v2/api/policy/${policy_id}" + Should Contain ${result} HTTP/1.1 200 + +Refresh Ranger Policy Cache + [Arguments] ${admin_user}=hdfs ${admin_keytab}=hdfs.keytab + Kinit test user ${admin_user} ${admin_keytab} + ${om_param} = Get OM Service Param + ${output} = Execute ozone admin om updateranger ${om_param} + Should Contain ${output} Operation completed successfully + +Assume Role And Get Temporary Credentials + [Arguments] ${perm_access_key_id} ${perm_secret_key} ${policy_json}=${EMPTY} ${role_arn}=${ROLE_ARN_OBS} ${role_session_name}=${ROLE_SESSION_NAME} ${duration_seconds}=900 + Configure AWS Profile permanent ${perm_access_key_id} ${perm_secret_key} + + ${cmd} = Set Variable aws sts assume-role --endpoint-url ${STS_ENDPOINT_URL} --role-arn ${role_arn} --role-session-name ${role_session_name} --output json --profile permanent + ${cmd} = Set Variable If '${duration_seconds}' != '${EMPTY}' ${cmd} --duration-seconds ${duration_seconds} ${cmd} + ${cmd} = Set Variable If '${policy_json}' != '${EMPTY}' ${cmd} --policy '${policy_json}' ${cmd} + + ${json} = Execute ${cmd} + + # Don't include the latency of the AssumeRole call when checking the expiration + ${now} = Get Current Date time_zone=UTC + + Should Contain ${json} Credentials + + ${stsAccessKeyId} = Execute echo '${json}' | jq -r '.Credentials.AccessKeyId' + ${stsSecretKey} = Execute echo '${json}' | jq -r '.Credentials.SecretAccessKey' + ${stsSessionToken} = Execute echo '${json}' | jq -r '.Credentials.SessionToken' + ${assumedRoleId} = Execute echo '${json}' | jq -r '.AssumedRoleUser.AssumedRoleId' + ${assumedRoleUserArn} = Execute echo '${json}' | jq -r '.AssumedRoleUser.Arn' + Should Start With ${stsAccessKeyId} ASIA + Set Global Variable ${STS_ACCESS_KEY_ID} ${stsAccessKeyId} + Set Global Variable ${STS_SECRET_KEY} ${stsSecretKey} + Set Global Variable ${STS_SESSION_TOKEN} ${stsSessionToken} + Set Global Variable ${STS_ASSUMED_ROLE_ID} ${assumedRoleId} + Set Global Variable ${STS_ASSUMED_ROLE_USER_ARN} ${assumedRoleUserArn} + + ${expected_duration} = Set Variable ${duration_seconds} + # Ensure the expected duration defaults to 3600 seconds (1 hour) if not specified + ${expected_duration} = Set Variable If '${duration_seconds}' == '${EMPTY}' 3600 ${expected_duration} + ${minimum_expected} = Evaluate int(${expected_duration}) - 2 + ${maximum_expected} = Evaluate int(${expected_duration}) + 2 + + # Verify Expiration based on requested duration (or default 1h), with small grace (plus or minus 2 seconds) for clock skew. + ${expiration} = Execute echo '${json}' | jq -r '.Credentials.Expiration' + ${time_diff} = Subtract Date From Date ${expiration} ${now} + Should Be True ${time_diff} >= ${minimum_expected} Expected expiration to be at least ${minimum_expected}s in the future, but was ${time_diff}s + Should Be True ${time_diff} <= ${maximum_expected} Expected expiration to be at most ${maximum_expected}s in the future, but was ${time_diff}s + +Assume Role And Store Expired STS Token Credentials + # Issue a 900s STS credential and store it in EXPIRED_STS_TOKEN_* globals only (it should never be used/modified until the "Expired STS temporary credentials return ExpiredToken on S3 APIs" test). + [Arguments] ${perm_access_key_id} ${perm_secret_key} ${role_arn} ${duration_seconds}=900 + Configure AWS Profile permanent ${perm_access_key_id} ${perm_secret_key} + ${suffix} = Generate Random String 8 [LOWER] + ${role_session_name} = Set Variable expired-sts-token-${suffix} + + ${cmd} = Set Variable aws sts assume-role --endpoint-url ${STS_ENDPOINT_URL} --role-arn ${role_arn} --role-session-name ${role_session_name} --duration-seconds ${duration_seconds} --output json --profile permanent + + ${json} = Execute ${cmd} + Should Contain ${json} Credentials + + ${expiredStsAccessKeyId} = Execute printf '%s' '${json}' | jq -r '.Credentials.AccessKeyId' + ${expiredStsSecretKey} = Execute printf '%s' '${json}' | jq -r '.Credentials.SecretAccessKey' + ${expiredStsSessionToken} = Execute printf '%s' '${json}' | jq -r '.Credentials.SessionToken' + ${expiration} = Execute printf '%s' '${json}' | jq -r '.Credentials.Expiration' + Should Start With ${expiredStsAccessKeyId} ASIA + Set Global Variable ${EXPIRED_STS_TOKEN_ACCESS_KEY_ID} ${expiredStsAccessKeyId} + Set Global Variable ${EXPIRED_STS_TOKEN_SECRET_ACCESS_KEY} ${expiredStsSecretKey} + Set Global Variable ${EXPIRED_STS_TOKEN_SESSION_TOKEN} ${expiredStsSessionToken} + Set Global Variable ${EXPIRED_STS_TOKEN_EXPIRATION} ${expiration} + +Expired STS Token Credentials Should Be Available + Skip If '${EXPIRED_STS_TOKEN_EXPIRATION}' == '${EMPTY}' Expired STS token credentials are unavailable. Run the prerequisite AssumeRole test before expired-token checks. + Should Not Be Empty ${EXPIRED_STS_TOKEN_ACCESS_KEY_ID} Expired STS token access key ID is unavailable. + Should Not Be Empty ${EXPIRED_STS_TOKEN_SECRET_ACCESS_KEY} Expired STS token secret key is unavailable. + Should Not Be Empty ${EXPIRED_STS_TOKEN_SESSION_TOKEN} Expired STS token session token is unavailable. + +Wait Until Expired STS Token Expiration Elapsed + # Matches the +/-2s skew tolerance used for AssumeRole duration checks. + Expired STS Token Credentials Should Be Available + ${now} = Get Current Date time_zone=UTC + ${secondsUntilExpiration} = Subtract Date From Date ${EXPIRED_STS_TOKEN_EXPIRATION} ${now} + ${remaining} = Evaluate ${secondsUntilExpiration} + 2 + Run Keyword If ${remaining} > 0 Sleep ${remaining} + +Configure Expired STS Token S3 Profile + Expired STS Token Credentials Should Be Available + Configure AWS Profile ${EXPIRED_STS_TOKEN_PROFILE} ${EXPIRED_STS_TOKEN_ACCESS_KEY_ID} ${EXPIRED_STS_TOKEN_SECRET_ACCESS_KEY} ${EXPIRED_STS_TOKEN_SESSION_TOKEN} + +Execute S3api Expect Expired Token + [Arguments] ${command_tail} + ${output} = Execute And Ignore Error aws s3api --endpoint-url ${S3G_ENDPOINT_URL} ${command_tail} --profile ${EXPIRED_STS_TOKEN_PROFILE} + Should Contain ${output} ExpiredToken + +Execute S3api Expect Expired Token Head Operation + [Arguments] ${command_tail} ${expected_aws_operation_name} + [Documentation] head-bucket and head-object omit S3 XML error bodies so we can't check for ExpiredToken in the body. awscli surfaces HTTP 400 and the operation label (HeadBucket / HeadObject) instead of ExpiredToken. Assert status and operation together to avoid matching unrelated 400s. + ${output} = Execute And Ignore Error aws s3api --endpoint-url ${S3G_ENDPOINT_URL} ${command_tail} --profile ${EXPIRED_STS_TOKEN_PROFILE} + Should Contain ${output} (400) + Should Contain ${output} ${expected_aws_operation_name} + +Assume Role And Configure STS Profile + [Arguments] ${perm_access_key_id} ${perm_secret_key} ${policy_json}=${EMPTY} ${role_arn}=${ROLE_ARN_OBS} ${role_session_name}=${ROLE_SESSION_NAME} ${duration_seconds}=900 + Assume Role And Get Temporary Credentials perm_access_key_id=${perm_access_key_id} perm_secret_key=${perm_secret_key} policy_json=${policy_json} role_arn=${role_arn} role_session_name=${role_session_name} duration_seconds=${duration_seconds} + Configure STS Profile ${STS_ACCESS_KEY_ID} ${STS_SECRET_KEY} ${STS_SESSION_TOKEN} + +Assume Role Should Fail + [Arguments] ${perm_access_key_id} ${perm_secret_key} ${policy_json}=${EMPTY} ${expected_error}=AccessDenied ${expected_http_code}=${EMPTY} ${role_arn}=${ROLE_ARN_OBS} ${role_session_name}=${ROLE_SESSION_NAME} ${duration_seconds}=900 ${extra_cli_args}=${EMPTY} ${sts_endpoint_url}=${STS_ENDPOINT_URL} + Configure AWS Profile permanent ${perm_access_key_id} ${perm_secret_key} + + IF '${expected_http_code}' != '${EMPTY}' + # Note: curl in the s3g container doesn't reliably support --aws-sigv4, + # so use awscli debug output to capture the HTTP response code. + ${cmd} = Set Variable aws sts assume-role --endpoint-url ${sts_endpoint_url} --role-arn ${role_arn} --role-session-name ${role_session_name} --profile permanent --debug 2>&1 + ${cmd} = Set Variable If '${duration_seconds}' != '${EMPTY}' ${cmd} --duration-seconds ${duration_seconds} ${cmd} + ${cmd} = Set Variable If '${policy_json}' != '${EMPTY}' ${cmd} --policy '${policy_json}' ${cmd} + ${cmd} = Set Variable If '${extra_cli_args}' != '${EMPTY}' ${cmd} ${extra_cli_args} ${cmd} + + ${output} = Execute And Ignore Error ${cmd} + Should Contain ${output} ${expected_error} + + @{http_codes} = Get Regexp Matches ${output} (?m)^.*"POST .*" ([0-9]{3}) .* 1 + ${code_count} = Get Length ${http_codes} + Should Be True ${code_count} > 0 Expected to find an HTTP status code in awscli --debug output, but none was found. + ${http_code} = Get From List ${http_codes} -1 + Should Be Equal As Strings ${http_code} ${expected_http_code} + ELSE + ${cmd} = Set Variable aws sts assume-role --endpoint-url ${sts_endpoint_url} --role-arn ${role_arn} --role-session-name ${role_session_name} --output json --profile permanent + ${cmd} = Set Variable If '${duration_seconds}' != '${EMPTY}' ${cmd} --duration-seconds ${duration_seconds} ${cmd} + ${cmd} = Set Variable If '${policy_json}' != '${EMPTY}' ${cmd} --policy '${policy_json}' ${cmd} + + ${output} = Execute And Ignore Error ${cmd} + Should Contain ${output} ${expected_error} + END + +Assume Role Should Fail Using Curl + # This keyword is needed to test boundary cases that the aws client prevents you from issuing, such as too short duration for token + [Arguments] ${perm_access_key_id} ${perm_secret_key} ${policy_json}=${EMPTY} ${expected_error}=AccessDenied ${expected_http_code}=400 ${role_arn}=${ROLE_ARN_OBS} ${role_session_name}=${ROLE_SESSION_NAME} ${duration_seconds}=900 ${extra_curl_params}=${EMPTY} + ${cmd} = Set Variable curl --silent --show-error --include --request POST --aws-sigv4 "aws:amz:us-east-1:sts" --user '${perm_access_key_id}:${perm_secret_key}' --header "Content-Type: application/x-www-form-urlencoded" --data-urlencode "Action=AssumeRole" --data-urlencode "Version=2011-06-15" --data-urlencode "RoleArn=${role_arn}" --data-urlencode "RoleSessionName=${role_session_name}" ${STS_ENDPOINT_URL} + ${cmd} = Set Variable If '${duration_seconds}' != '${EMPTY}' ${cmd} --data-urlencode "DurationSeconds=${duration_seconds}" ${cmd} + ${cmd} = Set Variable If '${policy_json}' != '${EMPTY}' ${cmd} --data-urlencode "Policy=${policy_json}" ${cmd} + ${cmd} = Set Variable If '${extra_curl_params}' != '${EMPTY}' ${cmd} ${extra_curl_params} ${cmd} + ${output} = Execute And Ignore Error ${cmd} + Should Contain ${output} ${expected_error} + @{http_codes} = Get Regexp Matches ${output} (?m)^HTTP/[0-9.]+ ([0-9]{3}) 1 + ${code_count} = Get Length ${http_codes} + Should Be True ${code_count} > 0 Expected to find an HTTP status code in curl output, but none was found. + ${http_code} = Get From List ${http_codes} -1 + Should Be Equal As Strings ${http_code} ${expected_http_code} + +Assume Role Should Fail Using Curl Get + # STS supports GET requests with query parameters. Use this to validate query-string parsing / validation. + [Arguments] ${perm_access_key_id} ${perm_secret_key} ${policy_json}=${EMPTY} ${expected_error}=AccessDenied ${expected_http_code}=400 ${role_arn}=${ROLE_ARN_OBS} ${role_session_name}=${ROLE_SESSION_NAME} ${duration_seconds}=900 ${extra_curl_params}=${EMPTY} + ${cmd} = Set Variable curl --silent --show-error --include --request GET --get --aws-sigv4 "aws:amz:us-east-1:sts" --user '${perm_access_key_id}:${perm_secret_key}' --header "Content-Type: application/x-www-form-urlencoded" --data-urlencode "Action=AssumeRole" --data-urlencode "Version=2011-06-15" --data-urlencode "RoleArn=${role_arn}" --data-urlencode "RoleSessionName=${role_session_name}" ${STS_ENDPOINT_URL} + ${cmd} = Set Variable If '${duration_seconds}' != '${EMPTY}' ${cmd} --data-urlencode "DurationSeconds=${duration_seconds}" ${cmd} + ${cmd} = Set Variable If '${policy_json}' != '${EMPTY}' ${cmd} --data-urlencode "Policy=${policy_json}" ${cmd} + ${cmd} = Set Variable If '${extra_curl_params}' != '${EMPTY}' ${cmd} ${extra_curl_params} ${cmd} + ${output} = Execute And Ignore Error ${cmd} + Should Contain ${output} ${expected_error} + @{http_codes} = Get Regexp Matches ${output} (?m)^HTTP/[0-9.]+ ([0-9]{3}) 1 + ${code_count} = Get Length ${http_codes} + Should Be True ${code_count} > 0 Expected to find an HTTP status code in curl output, but none was found. + ${http_code} = Get From List ${http_codes} -1 + Should Be Equal As Strings ${http_code} ${expected_http_code} + +Get Assume Role Debug Output + # This keyword is needed to check headers on the AssumeRole to ensure they comply with AWS + [Arguments] ${perm_access_key_id} ${perm_secret_key} ${policy_json}=${EMPTY} ${role_arn}=${ROLE_ARN_OBS} ${role_session_name}=${ROLE_SESSION_NAME} ${duration_seconds}=900 + Configure AWS Profile permanent ${perm_access_key_id} ${perm_secret_key} + ${cmd} = Set Variable aws sts assume-role --endpoint-url ${STS_ENDPOINT_URL} --role-arn ${role_arn} --role-session-name ${role_session_name} --profile permanent --debug 2>&1 + ${cmd} = Set Variable If '${duration_seconds}' != '${EMPTY}' ${cmd} --duration-seconds ${duration_seconds} ${cmd} + ${cmd} = Set Variable If '${policy_json}' != '${EMPTY}' ${cmd} --policy '${policy_json}' ${cmd} + ${output} = Execute And Ignore Error ${cmd} + [Return] ${output} + +Assume Role Response Headers Should Be Present + [Arguments] ${perm_access_key_id} ${perm_secret_key} ${role_arn}=${ROLE_ARN_OBS} + ${output} = Get Assume Role Debug Output ${perm_access_key_id} ${perm_secret_key} role_arn=${role_arn} + Should Contain ${output} X-Amz-Sts-Extended-Request-Id + Should Contain ${output} x-amzn-RequestId + +Generate Oversized Session Policy + ${policy} = Execute python3 /opt/hadoop/smoketest/security/generate_oversized_session_policy.py + [Return] ${policy} + +Mutate STS Session Token + [Arguments] ${session_token} ${mutation_type} + ${mutated} = Execute SESSION_TOKEN='${session_token}' MUTATION_TYPE='${mutation_type}' python3 /opt/hadoop/smoketest/security/mutate_sts_session_token.py + [Return] ${mutated} + +Assign User To Tenant And Get Credentials + [Arguments] ${user} ${tenant} + ${output} = Execute ozone tenant --verbose user assign ${user} --tenant=${tenant} + Should Contain ${output} Assigned '${user}' to '${tenant}' + @{access_key_matches} = Get Regexp Matches ${output} (?m)(?<=export AWS_ACCESS_KEY_ID=).*$ + @{secret_key_matches} = Get Regexp Matches ${output} (?m)(?<=export AWS_SECRET_ACCESS_KEY=).*$ + ${access_key_id} = Strip String ${access_key_matches[0]} + ${secret_key} = Strip String ${secret_key_matches[0]} + [Return] ${access_key_id} ${secret_key} + +Get Object Should Succeed + [Arguments] ${bucket} ${key} ${destination}=${TEMP_DIR}/${key} ${profile}=sts + ${output} = Execute And Ignore Error aws s3api --endpoint-url ${S3G_ENDPOINT_URL} get-object --bucket ${bucket} --key ${key} ${destination} --profile ${profile} + Should Contain ${output} "AcceptRanges": "bytes" + +Get Object Should Fail + [Arguments] ${bucket} ${key} ${expectedFailureMessage} ${destination}=${TEMP_DIR}/${key} ${profile}=sts + ${output} = Execute And Ignore Error aws s3api --endpoint-url ${S3G_ENDPOINT_URL} get-object --bucket ${bucket} --key ${key} ${destination} --profile ${profile} + Should Contain ${output} ${expectedFailureMessage} + +Put Object Should Succeed + [Arguments] ${bucket} ${key} ${body}=${TEMP_DIR}/${key} ${profile}=sts + ${output} = Execute And Ignore Error aws s3api --endpoint-url ${S3G_ENDPOINT_URL} put-object --bucket ${bucket} --key ${key} --body ${body} --profile ${profile} + Should Contain ${output} "ETag" + +Put Object Should Fail + [Arguments] ${bucket} ${key} ${expectedFailureMessage} ${body}=${TEMP_DIR}/${key} ${profile}=sts + ${output} = Execute And Ignore Error aws s3api --endpoint-url ${S3G_ENDPOINT_URL} put-object --bucket ${bucket} --key ${key} --body ${body} --profile ${profile} + Should Contain ${output} ${expectedFailureMessage} + +Create Bucket Should Fail + [Arguments] ${bucket} ${expectedFailureMessage}=AccessDenied ${profile}=sts + ${output} = Execute And Ignore Error aws s3api --endpoint-url ${S3G_ENDPOINT_URL} create-bucket --bucket ${bucket} --profile ${profile} + Should Contain ${output} ${expectedFailureMessage} + +List Object Keys Should Succeed + [Arguments] ${bucket} ${api}=list-objects ${request_prefix}=${EMPTY} ${profile}=sts ${delimiter}=${EMPTY} + ${cmd} = Set Variable aws s3api --endpoint-url ${S3G_ENDPOINT_URL} ${api} --bucket ${bucket} --output json --profile ${profile} + ${cmd} = Set Variable If '${request_prefix}' != '${EMPTY}' ${cmd} --prefix ${request_prefix} ${cmd} + ${cmd} = Set Variable If '${delimiter}' != '${EMPTY}' ${cmd} --delimiter '${delimiter}' ${cmd} + ${output} = Execute And Ignore Error ${cmd} + Should Not Contain ${output} AccessDenied + ${keys_json} = Execute echo '${output}' | jq -c '[(.Contents // [])[] | .Key] | sort' + [Return] ${keys_json} + +List Object Keys Should Fail + [Arguments] ${bucket} ${api}=list-objects ${expected_failure}=AccessDenied ${request_prefix}=${EMPTY} ${profile}=sts ${delimiter}=${EMPTY} + ${cmd} = Set Variable aws s3api --endpoint-url ${S3G_ENDPOINT_URL} ${api} --bucket ${bucket} --output json --profile ${profile} + ${cmd} = Set Variable If '${request_prefix}' != '${EMPTY}' ${cmd} --prefix ${request_prefix} ${cmd} + ${cmd} = Set Variable If '${delimiter}' != '${EMPTY}' ${cmd} --delimiter '${delimiter}' ${cmd} + ${output} = Execute And Ignore Error ${cmd} + Should Contain ${output} ${expected_failure} + +Assert Listed Keys Json Should Equal + [Arguments] ${actual_keys_json} ${expected_keys_json} + # This compares JSON to another JSON + ${actual_list} = Evaluate json.loads($actual_keys_json) modules=json + ${expected_list} = Evaluate json.loads($expected_keys_json) modules=json + Lists Should Be Equal ${actual_list} ${expected_list} + +Assert Listed Keys Should Equal + [Arguments] ${actual_keys_json} @{expected_keys} + # This compares JSON to a list of strings + ${expected_sorted} = Copy List ${expected_keys} + Sort List ${expected_sorted} + ${actual_list} = Evaluate json.loads($actual_keys_json) modules=json + Lists Should Be Equal ${actual_list} ${expected_sorted} + +Get Caller Identity + [Arguments] ${profile} + ${json} = Execute aws sts get-caller-identity --endpoint-url ${STS_ENDPOINT_URL} --output json --profile ${profile} + ${account} = Execute echo '${json}' | jq -r '.Account' + ${arn} = Execute echo '${json}' | jq -r '.Arn' + ${userId} = Execute echo '${json}' | jq -r '.UserId' + [Return] ${json} ${account} ${arn} ${userId} + +Get Caller Identity Using Curl + # AWS CLI always sends Version=2011-06-15 and rejects unknown flags, so use curl to test version validation. + # curl 7.76.1's --aws-sigv4 omits the port from the signed canonical "host" header (it signs "host:s3g" + # while sending "Host: s3g:9880"), so Ozone's SigV4 validation for the non-default STS port rejects it. + # Send an explicit Host header without the port so the sent and signed host values match. + # This should also work for newer versions of curl as well + [Arguments] ${perm_access_key_id} ${perm_secret_key} ${api_version}=2011-06-15 ${extra_curl_params}=${EMPTY} + ${sts_host} = Evaluate urllib.parse.urlparse("${STS_ENDPOINT_URL}").hostname modules=urllib.parse + ${cmd} = Set Variable curl --silent --show-error --include --request POST --aws-sigv4 "aws:amz:us-east-1:sts" --user '${perm_access_key_id}:${perm_secret_key}' --header "Host: ${sts_host}" --header "Content-Type: application/x-www-form-urlencoded" --data-urlencode "Action=GetCallerIdentity" + ${cmd} = Set Variable If '${api_version}' != '${EMPTY}' ${cmd} --data-urlencode "Version=${api_version}" ${cmd} + ${cmd} = Set Variable If '${extra_curl_params}' != '${EMPTY}' ${cmd} ${extra_curl_params} ${cmd} + ${cmd} = Set Variable ${cmd} ${STS_ENDPOINT_URL} + ${output} = Execute And Ignore Error ${cmd} + [Return] ${output} + +Get Caller Identity Should Fail + [Arguments] ${expected_error_contains} ${api_version}=${EMPTY} + ${output} = Get Caller Identity Using Curl ${PERMANENT_ACCESS_KEY_ID} ${PERMANENT_SECRET_KEY} api_version=${api_version} + Should Contain ${output} ${expected_error_contains} + @{http_codes} = Get Regexp Matches ${output} (?m)^HTTP/[0-9.]+ ([0-9]{3}) 1 + ${code_count} = Get Length ${http_codes} + Should Be True ${code_count} > 0 Expected to find an HTTP status code in curl output, but none was found. + ${http_code} = Get From List ${http_codes} -1 + Should Be Equal As Strings ${http_code} 400 diff --git a/hadoop-ozone/dist/src/main/smoketest/security/ozone-secure-sts.robot b/hadoop-ozone/dist/src/main/smoketest/security/ozone-secure-sts.robot new file mode 100644 index 000000000000..2368b1da68ec --- /dev/null +++ b/hadoop-ozone/dist/src/main/smoketest/security/ozone-secure-sts.robot @@ -0,0 +1,1466 @@ +# Licensed to the Apache Software Foundation (ASF) under one or more +# contributor license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright ownership. +# The ASF licenses this file to You under the Apache License, Version 2.0 +# (the "License"); you may not use this file except in compliance with +# the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +*** Settings *** +Suite Setup Skip If '${RANGER_ENDPOINT_URL}' == '' No Ranger +Documentation Smoke test for S3 STS AssumeRole + Temp Creds +Resource ./ozone-secure-sts.resource +Test Timeout 10 minutes + +*** Variables *** +${ICEBERG_SVC_CATALOG_USER} svc-iceberg-rest-catalog +${ICEBERG_ALL_ACCESS_ROLE_OBS} iceberg-data-all-access-obs +${ICEBERG_ALL_ACCESS_ROLE_FSO} iceberg-data-all-access-fso +${ICEBERG_MULTI_BUCKET_ROLE} iceberg-data-all-access-multi +# Role used for sts-bucket-* resources. The create/delete bucket smoke tests need it so they can create a +# temporary bucket, exercise the session policy under test, and then clean up that bucket in the same test. +${STS_TEMP_BUCKET_ROLE} sts-temp-bucket-access +${ICEBERG_READ_ONLY_ROLE_OBS} iceberg-data-read-only-obs +${ICEBERG_READ_ONLY_ROLE_FSO} iceberg-data-read-only-fso +${ICEBERG_BUCKET_OBS} iceberg-obs +${ICEBERG_BUCKET_FSO} iceberg-fso +${ICEBERG_LAYOUT_OBS} OBJECT_STORE +${ICEBERG_LAYOUT_FSO} FILE_SYSTEM_OPTIMIZED +${ICEBERG_BUCKET_TESTFILE} file1.txt +${ICEBERG_ALL_ACCESS_ROLE_OBS_ARN} arn:aws:iam::123456789012:role/${ICEBERG_ALL_ACCESS_ROLE_OBS} +${ICEBERG_ALL_ACCESS_ROLE_FSO_ARN} arn:aws:iam::123456789012:role/${ICEBERG_ALL_ACCESS_ROLE_FSO} +${ICEBERG_MULTI_BUCKET_ROLE_ARN} arn:aws:iam::123456789012:role/${ICEBERG_MULTI_BUCKET_ROLE} +${STS_TEMP_BUCKET_ROLE_ARN} arn:aws:iam::123456789012:role/${STS_TEMP_BUCKET_ROLE} +${READ_ONLY_ROLE_OBS_ARN} arn:aws:iam::123456789012:role/${ICEBERG_READ_ONLY_ROLE_OBS} +${READ_ONLY_ROLE_FSO_ARN} arn:aws:iam::123456789012:role/${ICEBERG_READ_ONLY_ROLE_FSO} +${PARTIAL_LIST_ALL_BUCKETS_VOL_READ_ROLE} partial-list-all-buckets-vol-read +${PARTIAL_LIST_ALL_BUCKETS_VOL_LIST_ROLE} partial-list-all-buckets-vol-list +${PARTIAL_BUCKET_READ_ROLE} partial-bucket-read +${PARTIAL_BUCKET_READ_UPLOAD_PREFIX_ROLE} partial-bucket-read-upload-prefix +${PARTIAL_BUCKET_LIST_ROLE} partial-bucket-list +${PARTIAL_BUCKET_READ_ACL_ROLE} partial-bucket-read-acl +${PARTIAL_PUT_OBJECT_KEY_CREATE_ROLE} partial-put-object-key-create +${PARTIAL_PUT_OBJECT_KEY_WRITE_ROLE} partial-put-object-key-write +${ACTION_MATCHES_PUTOBJECT_READ_ROLE} action-matches-putobject-read +${ACTION_MATCHES_PUTOBJECT_CREATE_WRITE_ROLE} action-matches-putobject-create-write +${ACTION_MATCHES_GETOBJECT_PUTOBJECT_ROLE} action-matches-getobject-putobject +${ACTION_MATCHES_UPLOADPARTCOPY_EXPECTED_OWNER_ROLE} action-matches-uploadpartcopy-expected-owner +${ACTION_MATCHES_GET_STAR_READ_ROLE} action-matches-get-star-read +${PARTIAL_LIST_ALL_BUCKETS_VOL_READ_ROLE_ARN} arn:aws:iam::123456789012:role/${PARTIAL_LIST_ALL_BUCKETS_VOL_READ_ROLE} +${PARTIAL_LIST_ALL_BUCKETS_VOL_LIST_ROLE_ARN} arn:aws:iam::123456789012:role/${PARTIAL_LIST_ALL_BUCKETS_VOL_LIST_ROLE} +${PARTIAL_BUCKET_READ_ROLE_ARN} arn:aws:iam::123456789012:role/${PARTIAL_BUCKET_READ_ROLE} +${PARTIAL_BUCKET_READ_UPLOAD_PREFIX_ROLE_ARN} arn:aws:iam::123456789012:role/${PARTIAL_BUCKET_READ_UPLOAD_PREFIX_ROLE} +${PARTIAL_BUCKET_LIST_ROLE_ARN} arn:aws:iam::123456789012:role/${PARTIAL_BUCKET_LIST_ROLE} +${PARTIAL_BUCKET_READ_ACL_ROLE_ARN} arn:aws:iam::123456789012:role/${PARTIAL_BUCKET_READ_ACL_ROLE} +${PARTIAL_PUT_OBJECT_KEY_CREATE_ROLE_ARN} arn:aws:iam::123456789012:role/${PARTIAL_PUT_OBJECT_KEY_CREATE_ROLE} +${PARTIAL_PUT_OBJECT_KEY_WRITE_ROLE_ARN} arn:aws:iam::123456789012:role/${PARTIAL_PUT_OBJECT_KEY_WRITE_ROLE} +${ACTION_MATCHES_PUTOBJECT_READ_ROLE_ARN} arn:aws:iam::123456789012:role/${ACTION_MATCHES_PUTOBJECT_READ_ROLE} +${ACTION_MATCHES_PUTOBJECT_CREATE_WRITE_ROLE_ARN} arn:aws:iam::123456789012:role/${ACTION_MATCHES_PUTOBJECT_CREATE_WRITE_ROLE} +${ACTION_MATCHES_GETOBJECT_PUTOBJECT_ROLE_ARN} arn:aws:iam::123456789012:role/${ACTION_MATCHES_GETOBJECT_PUTOBJECT_ROLE} +${ACTION_MATCHES_UPLOADPARTCOPY_EXPECTED_OWNER_ROLE_ARN} arn:aws:iam::123456789012:role/${ACTION_MATCHES_UPLOADPARTCOPY_EXPECTED_OWNER_ROLE} +${ACTION_MATCHES_GET_STAR_READ_ROLE_ARN} arn:aws:iam::123456789012:role/${ACTION_MATCHES_GET_STAR_READ_ROLE} +${STS_LINK_BUCKET_ROLE} sts-link-bucket-role +${STS_LINK_BUCKET_ROLE_ARN} arn:aws:iam::123456789012:role/${STS_LINK_BUCKET_ROLE} +${STS_LINK_BUCKET_SOURCE} sts-link-bucket-source +${STS_LINK_BUCKET_LINKED} s3v-sts-link-bucket-source +${STS_LINK_BUCKET_TESTFILE} link-bucket-testfile.txt +${STS_LINK_BUCKET_CHAIN_SOURCE} sts-chain-source +${STS_LINK_BUCKET_CHAIN_INTERMEDIATE} s3v-sts-chain-intermediate +${STS_LINK_BUCKET_CHAIN_FINAL} s3v-sts-chain-final +${STS_LINK_BUCKET_CHAIN_TESTFILE} chain-link-testfile.txt +${TEST_USER_ADMIN} testuser +${TEST_USER_NON_ADMIN} testuser2 +@{ICEBERG_OBJECT_KEYS} file1.txt file1again.txt folder/pepper.txt folder/salt.txt userA/userA.txt userB/userB.txt userAfile.txt +@{ICEBERG_LISTABLE_OBJECT_KEYS_OBS} file1.txt file1again.txt folder/pepper.txt folder/salt.txt userA/userA.txt userB/userB.txt userAfile.txt zeroByteFile zeroByteFolder/ +@{ICEBERG_LISTABLE_OBJECT_KEYS_FSO} file1.txt file1again.txt folder/ folder/pepper.txt folder/salt.txt userA/ userA/userA.txt userAfile.txt userB/ userB/userB.txt zeroByteFile zeroByteFolder +@{ICEBERG_PREFIX_USERA_OBS} userA/userA.txt +@{ICEBERG_PREFIX_USERA_FSO} userA/ userA/userA.txt +@{ICEBERG_PREFIX_USERA_STAR_OBS} userA/userA.txt userAfile.txt +@{ICEBERG_PREFIX_USERA_STAR_FSO} userA/ userA/userA.txt +@{ICEBERG_PREFIX_USER_OBS} userA/userA.txt userB/userB.txt userAfile.txt +@{ICEBERG_PREFIX_USER_FSO} userA/ userA/userA.txt userB/ userB/userB.txt userAfile.txt + +*** Keywords *** +Populate Iceberg Bucket + [Arguments] ${bucket} + FOR ${key} IN @{ICEBERG_OBJECT_KEYS} + ${parent} = Evaluate __import__('os').path.dirname($key) + Run Keyword If '${parent}' != '' Execute mkdir -p ${TEMP_DIR}/${parent} + ${local_path} = Set Variable ${TEMP_DIR}/${key} + Create File ${local_path} iceberg test content + Execute ozone sh key put /s3v/${bucket}/${key} ${local_path} + END + Create File ${TEMP_DIR}/zeroByteFile + Execute ozone sh key put /s3v/${bucket}/zeroByteFile ${TEMP_DIR}/zeroByteFile + # Upload an explicit folder marker object to match AWS semantics. + Create File ${TEMP_DIR}/zero-byte-marker + Execute ozone sh key put /s3v/${bucket}/zeroByteFolder/ ${TEMP_DIR}/zero-byte-marker + +Setup Same Volume Link Bucket + # Create a source bucket and a same-volume linked bucket (s3v/source -> s3v/s3v-source). + Kinit test user hdfs hdfs.keytab + Execute ozone sh bucket create --layout ${ICEBERG_LAYOUT_OBS} /s3v/${STS_LINK_BUCKET_SOURCE} + Create File ${TEMP_DIR}/${STS_LINK_BUCKET_TESTFILE} link bucket test content + Execute ozone sh key put /s3v/${STS_LINK_BUCKET_SOURCE}/${STS_LINK_BUCKET_TESTFILE} ${TEMP_DIR}/${STS_LINK_BUCKET_TESTFILE} + Execute ozone sh bucket link /s3v/${STS_LINK_BUCKET_SOURCE} /s3v/${STS_LINK_BUCKET_LINKED} + +Setup Chained Same Volume Link Buckets + # Create source <- intermediate <- final chained links in s3v. + Kinit test user hdfs hdfs.keytab + Execute ozone sh bucket create --layout ${ICEBERG_LAYOUT_OBS} /s3v/${STS_LINK_BUCKET_CHAIN_SOURCE} + Create File ${TEMP_DIR}/${STS_LINK_BUCKET_CHAIN_TESTFILE} chained link test content + Execute ozone sh key put /s3v/${STS_LINK_BUCKET_CHAIN_SOURCE}/${STS_LINK_BUCKET_CHAIN_TESTFILE} ${TEMP_DIR}/${STS_LINK_BUCKET_CHAIN_TESTFILE} + Execute ozone sh bucket link /s3v/${STS_LINK_BUCKET_CHAIN_SOURCE} /s3v/${STS_LINK_BUCKET_CHAIN_INTERMEDIATE} + Execute ozone sh bucket link /s3v/${STS_LINK_BUCKET_CHAIN_INTERMEDIATE} /s3v/${STS_LINK_BUCKET_CHAIN_FINAL} + +Head Bucket Should Succeed + [Arguments] ${bucket} ${profile}=sts + Execute and checkrc aws s3api --endpoint-url ${S3G_ENDPOINT_URL} head-bucket --bucket ${bucket} --profile ${profile} 0 + +Run List Prefix And Delimiter Policy Matrix For Bucket And Api + [Arguments] ${bucket} ${role_arn} ${api} + # Capture baseline (non-STS) behavior using the permanent credentials. When using STS, it will behave just like + # non-STS S3 behavior in terms of listing results, with the addition of checking authorization if any s3:prefix + # was specified in the inline session policy + Configure AWS Profile permanent ${PERMANENT_ACCESS_KEY_ID} ${PERMANENT_SECRET_KEY} + ${baseline_no_prefix_no_delimiter} = List Object Keys Should Succeed ${bucket} ${api} profile=permanent + ${baseline_userA_slash_prefix_no_delimiter} = List Object Keys Should Succeed ${bucket} ${api} userA/ profile=permanent + ${baseline_userA_prefix_no_delimiter} = List Object Keys Should Succeed ${bucket} ${api} userA profile=permanent + ${baseline_no_prefix_slash_delimiter} = List Object Keys Should Succeed ${bucket} ${api} profile=permanent delimiter=/ + ${baseline_userA_slash_prefix_slash_delimiter} = List Object Keys Should Succeed ${bucket} ${api} userA/ profile=permanent delimiter=/ + ${baseline_userA_prefix_slash_delimiter} = List Object Keys Should Succeed ${bucket} ${api} userA profile=permanent delimiter=/ + ${baseline_userA_userA_prefix_no_delimiter} = List Object Keys Should Succeed ${bucket} ${api} userA/userA profile=permanent + ${baseline_userA_userA_prefix_slash_delimiter} = List Object Keys Should Succeed ${bucket} ${api} userA/userA profile=permanent delimiter=/ + ${baseline_user_prefix_no_delimiter} = List Object Keys Should Succeed ${bucket} ${api} user profile=permanent + ${baseline_user_prefix_slash_delimiter} = List Object Keys Should Succeed ${bucket} ${api} user profile=permanent delimiter=/ + IF '${bucket}' == '${ICEBERG_BUCKET_OBS}' + Assert Listed Keys Should Equal ${baseline_no_prefix_no_delimiter} @{ICEBERG_LISTABLE_OBJECT_KEYS_OBS} + Assert Listed Keys Should Equal ${baseline_userA_slash_prefix_no_delimiter} @{ICEBERG_PREFIX_USERA_OBS} + Assert Listed Keys Should Equal ${baseline_userA_prefix_no_delimiter} @{ICEBERG_PREFIX_USERA_STAR_OBS} + Assert Listed Keys Should Equal ${baseline_user_prefix_no_delimiter} @{ICEBERG_PREFIX_USER_OBS} + ELSE IF '${bucket}' == '${ICEBERG_BUCKET_FSO}' + Assert Listed Keys Should Equal ${baseline_no_prefix_no_delimiter} @{ICEBERG_LISTABLE_OBJECT_KEYS_FSO} + Assert Listed Keys Should Equal ${baseline_userA_slash_prefix_no_delimiter} @{ICEBERG_PREFIX_USERA_FSO} + Assert Listed Keys Should Equal ${baseline_userA_prefix_no_delimiter} @{ICEBERG_PREFIX_USERA_STAR_FSO} + Assert Listed Keys Should Equal ${baseline_user_prefix_no_delimiter} @{ICEBERG_PREFIX_USER_FSO} + END + + # a) IAM session policy with no s3:prefix condition. + ${session_policy} = Set Variable {"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:ListBucket","Resource":"arn:aws:s3:::${bucket}"}]} + Assume Role And Configure STS Profile policy_json=${session_policy} perm_access_key_id=${PERMANENT_ACCESS_KEY_ID} perm_secret_key=${PERMANENT_SECRET_KEY} role_arn=${role_arn} + ${keys_json} = List Object Keys Should Succeed ${bucket} ${api} + Assert Listed Keys Json Should Equal ${keys_json} ${baseline_no_prefix_no_delimiter} + ${keys_json} = List Object Keys Should Succeed ${bucket} ${api} userA/ + Assert Listed Keys Json Should Equal ${keys_json} ${baseline_userA_slash_prefix_no_delimiter} + ${keys_json} = List Object Keys Should Succeed ${bucket} ${api} delimiter=/ + Assert Listed Keys Json Should Equal ${keys_json} ${baseline_no_prefix_slash_delimiter} + ${keys_json} = List Object Keys Should Succeed ${bucket} ${api} userA/ delimiter=/ + Assert Listed Keys Json Should Equal ${keys_json} ${baseline_userA_slash_prefix_slash_delimiter} + + # b1) StringEquals without wildcard. + ${session_policy} = Set Variable {"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:ListBucket","Resource":"arn:aws:s3:::${bucket}","Condition":{"StringEquals":{"s3:prefix":"userA/"}}}]} + Assume Role And Configure STS Profile policy_json=${session_policy} perm_access_key_id=${PERMANENT_ACCESS_KEY_ID} perm_secret_key=${PERMANENT_SECRET_KEY} role_arn=${role_arn} + ${keys_json} = List Object Keys Should Succeed ${bucket} ${api} userA/ + Assert Listed Keys Json Should Equal ${keys_json} ${baseline_userA_slash_prefix_no_delimiter} + ${keys_json} = List Object Keys Should Succeed ${bucket} ${api} userA/ delimiter=/ + Assert Listed Keys Json Should Equal ${keys_json} ${baseline_userA_slash_prefix_slash_delimiter} + # If a prefix was authorized in the session policy, but the user did not supply any prefix, verify access denied + List Object Keys Should Fail ${bucket} ${api} AccessDenied + # If a prefix was authorized in the session policy, but the user supplied a different prefix, verify access denied (userA does not match userA/) + List Object Keys Should Fail ${bucket} ${api} AccessDenied userA + + # b2) StringEquals with wildcard only, wildcard prefix should be ignored and deny. + ${session_policy} = Set Variable {"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:ListBucket","Resource":"arn:aws:s3:::${bucket}","Condition":{"StringEquals":{"s3:prefix":"userA/*"}}}]} + Assume Role And Configure STS Profile policy_json=${session_policy} perm_access_key_id=${PERMANENT_ACCESS_KEY_ID} perm_secret_key=${PERMANENT_SECRET_KEY} role_arn=${role_arn} + List Object Keys Should Fail ${bucket} ${api} AccessDenied userA/ + List Object Keys Should Fail ${bucket} ${api} AccessDenied userA/ delimiter=/ + List Object Keys Should Fail ${bucket} ${api} AccessDenied + List Object Keys Should Fail ${bucket} ${api} AccessDenied delimiter=/ + List Object Keys Should Fail ${bucket} ${api} AccessDenied userA + List Object Keys Should Fail ${bucket} ${api} AccessDenied userA delimiter=/ + + # b3) StringEquals mixed values, wildcard entry is ignored but exact entry still works. Ranger doesn't support literal asterisk matching + ${session_policy} = Set Variable {"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:ListBucket","Resource":"arn:aws:s3:::${bucket}","Condition":{"StringEquals":{"s3:prefix":["userA/","userA/*"]}}}]} + Assume Role And Configure STS Profile policy_json=${session_policy} perm_access_key_id=${PERMANENT_ACCESS_KEY_ID} perm_secret_key=${PERMANENT_SECRET_KEY} role_arn=${role_arn} + ${keys_json} = List Object Keys Should Succeed ${bucket} ${api} userA/ + Assert Listed Keys Json Should Equal ${keys_json} ${baseline_userA_slash_prefix_no_delimiter} + ${keys_json} = List Object Keys Should Succeed ${bucket} ${api} userA/ delimiter=/ + Assert Listed Keys Json Should Equal ${keys_json} ${baseline_userA_slash_prefix_slash_delimiter} + List Object Keys Should Fail ${bucket} ${api} AccessDenied + List Object Keys Should Fail ${bucket} ${api} AccessDenied delimiter=/ + List Object Keys Should Fail ${bucket} ${api} AccessDenied userA + List Object Keys Should Fail ${bucket} ${api} AccessDenied userA delimiter=/ + + # b4) StringEquals without wildcard, deeper prefix with delimiter. + ${session_policy} = Set Variable {"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:ListBucket","Resource":"arn:aws:s3:::${bucket}","Condition":{"StringEquals":{"s3:prefix":"userA/userA"}}}]} + Assume Role And Configure STS Profile policy_json=${session_policy} perm_access_key_id=${PERMANENT_ACCESS_KEY_ID} perm_secret_key=${PERMANENT_SECRET_KEY} role_arn=${role_arn} + ${keys_json} = List Object Keys Should Succeed ${bucket} ${api} userA/userA delimiter=/ + Assert Listed Keys Json Should Equal ${keys_json} ${baseline_userA_userA_prefix_slash_delimiter} + ${keys_json} = List Object Keys Should Succeed ${bucket} ${api} userA/userA + Assert Listed Keys Json Should Equal ${keys_json} ${baseline_userA_userA_prefix_no_delimiter} + List Object Keys Should Fail ${bucket} ${api} AccessDenied + List Object Keys Should Fail ${bucket} ${api} AccessDenied delimiter=/ + List Object Keys Should Fail ${bucket} ${api} AccessDenied userA + List Object Keys Should Fail ${bucket} ${api} AccessDenied userA delimiter=/ + + # c1) StringLike without wildcard. + ${session_policy} = Set Variable {"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:ListBucket","Resource":"arn:aws:s3:::${bucket}","Condition":{"StringLike":{"s3:prefix":"userA/"}}}]} + Assume Role And Configure STS Profile policy_json=${session_policy} perm_access_key_id=${PERMANENT_ACCESS_KEY_ID} perm_secret_key=${PERMANENT_SECRET_KEY} role_arn=${role_arn} + ${keys_json} = List Object Keys Should Succeed ${bucket} ${api} userA/ + Assert Listed Keys Json Should Equal ${keys_json} ${baseline_userA_slash_prefix_no_delimiter} + ${keys_json} = List Object Keys Should Succeed ${bucket} ${api} userA/ delimiter=/ + Assert Listed Keys Json Should Equal ${keys_json} ${baseline_userA_slash_prefix_slash_delimiter} + List Object Keys Should Fail ${bucket} ${api} AccessDenied + List Object Keys Should Fail ${bucket} ${api} AccessDenied delimiter=/ + List Object Keys Should Fail ${bucket} ${api} AccessDenied userA + List Object Keys Should Fail ${bucket} ${api} AccessDenied userA delimiter=/ + + # c2) StringLike with wildcard and slash (userA/* edge case). + ${session_policy} = Set Variable {"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:ListBucket","Resource":"arn:aws:s3:::${bucket}","Condition":{"StringLike":{"s3:prefix":"userA/*"}}}]} + Assume Role And Configure STS Profile policy_json=${session_policy} perm_access_key_id=${PERMANENT_ACCESS_KEY_ID} perm_secret_key=${PERMANENT_SECRET_KEY} role_arn=${role_arn} + ${keys_json} = List Object Keys Should Succeed ${bucket} ${api} userA/ + Assert Listed Keys Json Should Equal ${keys_json} ${baseline_userA_slash_prefix_no_delimiter} + ${keys_json} = List Object Keys Should Succeed ${bucket} ${api} userA/ delimiter=/ + Assert Listed Keys Json Should Equal ${keys_json} ${baseline_userA_slash_prefix_slash_delimiter} + List Object Keys Should Fail ${bucket} ${api} AccessDenied + List Object Keys Should Fail ${bucket} ${api} AccessDenied delimiter=/ + List Object Keys Should Fail ${bucket} ${api} AccessDenied userA + List Object Keys Should Fail ${bucket} ${api} AccessDenied userA delimiter=/ + + # c3) StringLike with wildcard only (userA* edge case). + ${session_policy} = Set Variable {"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:ListBucket","Resource":"arn:aws:s3:::${bucket}","Condition":{"StringLike":{"s3:prefix":"userA*"}}}]} + Assume Role And Configure STS Profile policy_json=${session_policy} perm_access_key_id=${PERMANENT_ACCESS_KEY_ID} perm_secret_key=${PERMANENT_SECRET_KEY} role_arn=${role_arn} + ${keys_json} = List Object Keys Should Succeed ${bucket} ${api} userA + Assert Listed Keys Json Should Equal ${keys_json} ${baseline_userA_prefix_no_delimiter} + ${keys_json} = List Object Keys Should Succeed ${bucket} ${api} userA delimiter=/ + Assert Listed Keys Json Should Equal ${keys_json} ${baseline_userA_prefix_slash_delimiter} + ${keys_json} = List Object Keys Should Succeed ${bucket} ${api} userA/ + Assert Listed Keys Json Should Equal ${keys_json} ${baseline_userA_slash_prefix_no_delimiter} + ${keys_json} = List Object Keys Should Succeed ${bucket} ${api} userA/ delimiter=/ + Assert Listed Keys Json Should Equal ${keys_json} ${baseline_userA_slash_prefix_slash_delimiter} + List Object Keys Should Fail ${bucket} ${api} AccessDenied + List Object Keys Should Fail ${bucket} ${api} AccessDenied delimiter=/ + + # c4) StringLike with prefix only (user edge case). + ${session_policy} = Set Variable {"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:ListBucket","Resource":"arn:aws:s3:::${bucket}","Condition":{"StringLike":{"s3:prefix":"user"}}}]} + Assume Role And Configure STS Profile policy_json=${session_policy} perm_access_key_id=${PERMANENT_ACCESS_KEY_ID} perm_secret_key=${PERMANENT_SECRET_KEY} role_arn=${role_arn} + ${keys_json} = List Object Keys Should Succeed ${bucket} ${api} user + Assert Listed Keys Json Should Equal ${keys_json} ${baseline_user_prefix_no_delimiter} + ${keys_json} = List Object Keys Should Succeed ${bucket} ${api} user delimiter=/ + Assert Listed Keys Json Should Equal ${keys_json} ${baseline_user_prefix_slash_delimiter} + List Object Keys Should Fail ${bucket} ${api} AccessDenied + List Object Keys Should Fail ${bucket} ${api} AccessDenied delimiter=/ + List Object Keys Should Fail ${bucket} ${api} AccessDenied userA + List Object Keys Should Fail ${bucket} ${api} AccessDenied userA delimiter=/ + + # d) No IAM session policy (it should work just like the baseline) + Assume Role And Configure STS Profile perm_access_key_id=${PERMANENT_ACCESS_KEY_ID} perm_secret_key=${PERMANENT_SECRET_KEY} role_arn=${role_arn} + ${keys_json} = List Object Keys Should Succeed ${bucket} ${api} + Assert Listed Keys Json Should Equal ${keys_json} ${baseline_no_prefix_no_delimiter} + ${keys_json} = List Object Keys Should Succeed ${bucket} ${api} delimiter=/ + Assert Listed Keys Json Should Equal ${keys_json} ${baseline_no_prefix_slash_delimiter} + ${keys_json} = List Object Keys Should Succeed ${bucket} ${api} userA + Assert Listed Keys Json Should Equal ${keys_json} ${baseline_userA_prefix_no_delimiter} + ${keys_json} = List Object Keys Should Succeed ${bucket} ${api} userA delimiter=/ + Assert Listed Keys Json Should Equal ${keys_json} ${baseline_userA_prefix_slash_delimiter} + ${keys_json} = List Object Keys Should Succeed ${bucket} ${api} userA/ + Assert Listed Keys Json Should Equal ${keys_json} ${baseline_userA_slash_prefix_no_delimiter} + ${keys_json} = List Object Keys Should Succeed ${bucket} ${api} userA/ delimiter=/ + Assert Listed Keys Json Should Equal ${keys_json} ${baseline_userA_slash_prefix_slash_delimiter} + +Configure STS Profile With Bogus Credential Part + [Arguments] ${bogus_part} + IF '${bogus_part}' == 'accessKeyId' + Configure STS Profile bogusAccessKeyId ${STS_SECRET_KEY} ${STS_SESSION_TOKEN} + ELSE IF '${bogus_part}' == 'secretKey' + Configure STS Profile ${STS_ACCESS_KEY_ID} bogusSecretKey ${STS_SESSION_TOKEN} + ELSE IF '${bogus_part}' == 'sessionToken' + Configure STS Profile ${STS_ACCESS_KEY_ID} ${STS_SECRET_KEY} bogusSessionToken + END + +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 } ] } + Create Ranger User ${user_json} + +Create All Access Roles in Ranger + FOR ${role} IN ${ICEBERG_ALL_ACCESS_ROLE_OBS} ${ICEBERG_ALL_ACCESS_ROLE_FSO} ${ICEBERG_MULTI_BUCKET_ROLE} + ${role_json} = Set Variable { "name": "${role}", "description": "Iceberg data all access" } + Create Ranger Role ${role_json} + END + +Create Read Only Roles in Ranger + FOR ${role} IN ${ICEBERG_READ_ONLY_ROLE_OBS} ${ICEBERG_READ_ONLY_ROLE_FSO} + ${role_json} = Set Variable { "name": "${role}", "description": "Iceberg data read only" } + Create Ranger Role ${role_json} + END + +Create Temp Bucket Access Policies + # ${STS_TEMP_BUCKET_ROLE} keeps the sts-bucket-* namespace available for the create/delete bucket tests. + ${role_json} = Set Variable { "name": "${STS_TEMP_BUCKET_ROLE}", "description": "STS temp bucket access" } + Create Ranger Role ${role_json} + + Create Ranger Assume Role Policy ${STS_TEMP_BUCKET_ROLE} ${ICEBERG_SVC_CATALOG_USER} + + ${bucket_policy} = Set Variable { "isEnabled": true, "service": "dev_ozone", "name": "sts temp bucket access", "policyType": 0, "policyPriority": 0, "isAuditEnabled": true, "resources": { "volume": { "values": [ "s3v" ], "isExcludes": false, "isRecursive": false }, "bucket": { "values": [ "sts-bucket-*" ], "isExcludes": false, "isRecursive": false }, "key": { "values": [ "*" ], "isExcludes": false, "isRecursive": true } }, "policyItems": [ { "accesses": [ { "type": "all", "isAllowed": true } ], "roles": [ "${STS_TEMP_BUCKET_ROLE}" ], "delegateAdmin": false }, { "accesses": [ { "type": "read", "isAllowed": true } ], "roles": [ "${ICEBERG_READ_ONLY_ROLE_OBS}" ], "delegateAdmin": false } ], "serviceType": "ozone", "isDenyAllElse": false } + Create Ranger Policy ${bucket_policy} + +Create All Access Assume Role Policies + # This policy gives '${ICEBERG_SVC_CATALOG_USER}' user ASSUME_ROLE permission on each all-access role. + FOR ${role} IN ${ICEBERG_ALL_ACCESS_ROLE_OBS} ${ICEBERG_ALL_ACCESS_ROLE_FSO} ${ICEBERG_MULTI_BUCKET_ROLE} + Create Ranger Assume Role Policy ${role} ${ICEBERG_SVC_CATALOG_USER} + END + +Create Read Only Assume Role Policies + # This policy gives '${ICEBERG_SVC_CATALOG_USER}' user ASSUME_ROLE permission on each read-only role. + FOR ${role} IN ${ICEBERG_READ_ONLY_ROLE_OBS} ${ICEBERG_READ_ONLY_ROLE_FSO} + Create Ranger Assume Role Policy ${role} ${ICEBERG_SVC_CATALOG_USER} + END + +Create Iceberg Volume Access Policy + # This policy gives all iceberg roles READ,LIST permission on volume s3v. + # It also gives '${ICEBERG_SVC_CATALOG_USER}' user READ permission on volume s3v. + ${policy_json} = Set Variable { "isEnabled": true, "service": "dev_ozone", "name": "iceberg volume access", "policyType": 0, "policyPriority": 0, "isAuditEnabled": true, "resources": { "volume": { "values": [ "s3v" ], "isExcludes": false, "isRecursive": false } }, "policyItems": [ { "accesses": [ { "type": "read", "isAllowed": true }, { "type": "list", "isAllowed": true } ], "roles": [ "${ICEBERG_ALL_ACCESS_ROLE_OBS}", "${ICEBERG_ALL_ACCESS_ROLE_FSO}", "${ICEBERG_MULTI_BUCKET_ROLE}", "${STS_TEMP_BUCKET_ROLE}", "${ICEBERG_READ_ONLY_ROLE_OBS}", "${ICEBERG_READ_ONLY_ROLE_FSO}" ], "delegateAdmin": false }, { "accesses": [ { "type": "read", "isAllowed": true } ], "users": [ "${ICEBERG_SVC_CATALOG_USER}" ], "delegateAdmin": false } ], "serviceType": "ozone", "isDenyAllElse": false } + Create Ranger Policy ${policy_json} + +Create Iceberg Bucket Access Policies + # This loop gives '${ICEBERG_ALL_ACCESS_ROLE_OBS}' ALL permission on '${ICEBERG_BUCKET_OBS}', '${ICEBERG_SVC_CATALOG_USER}' user READ, LIST permission on '${ICEBERG_BUCKET_OBS}' [because hdfs user creates the buckets, we need READ, LIST to get the baseline results], '${ICEBERG_READ_ONLY_ROLE_OBS}' READ permission on '${ICEBERG_BUCKET_OBS}' + # It also gives '${ICEBERG_ALL_ACCESS_ROLE_FSO}' ALL permission on '${ICEBERG_BUCKET_FSO}', ${ICEBERG_SVC_CATALOG_USER}' user READ, LIST permission on '${ICEBERG_BUCKET_FSO}' [because hdfs user creates the buckets, we need READ, LIST to get the baseline results], '${ICEBERG_READ_ONLY_ROLE_FSO}' READ permission on '${ICEBERG_BUCKET_FSO}' + FOR ${bucket} ${all_access_role} ${read_only_role} IN + ... ${ICEBERG_BUCKET_OBS} ${ICEBERG_ALL_ACCESS_ROLE_OBS} ${ICEBERG_READ_ONLY_ROLE_OBS} + ... ${ICEBERG_BUCKET_FSO} ${ICEBERG_ALL_ACCESS_ROLE_FSO} ${ICEBERG_READ_ONLY_ROLE_FSO} + ${policy_json} = Set Variable { "isEnabled": true, "service": "dev_ozone", "name": "iceberg ${bucket} bucket access", "policyType": 0, "policyPriority": 0, "isAuditEnabled": true, "resources": { "volume": { "values": [ "s3v" ], "isExcludes": false, "isRecursive": false }, "bucket": { "values": [ "${bucket}" ], "isExcludes": false, "isRecursive": false } }, "policyItems": [ { "accesses": [ { "type": "all", "isAllowed": true } ], "roles": [ "${all_access_role}" ], "delegateAdmin": false }, { "accesses": [ { "type": "read", "isAllowed": true }, { "type": "list", "isAllowed": true } ], "users": [ "${ICEBERG_SVC_CATALOG_USER}" ], "delegateAdmin": false }, { "accesses": [ { "type": "read", "isAllowed": true } ], "roles": [ "${read_only_role}" ], "delegateAdmin": false } ], "serviceType": "ozone", "isDenyAllElse": false } + Create Ranger Policy ${policy_json} + END + +Create Iceberg Table Access Policies + # This loop gives '${ICEBERG_ALL_ACCESS_ROLE_OBS}' ALL permission on '${ICEBERG_BUCKET_OBS}'/* keys, '${ICEBERG_SVC_CATALOG_USER}' user READ permission on '${ICEBERG_BUCKET_OBS}'/* keys [because hdfs user creates the buckets, we need READ to get the baseline results], '${ICEBERG_READ_ONLY_ROLE_OBS}' READ permission on '${ICEBERG_BUCKET_OBS}'/* keys + # It also gives '${ICEBERG_ALL_ACCESS_ROLE_FSO}' ALL permission on '${ICEBERG_BUCKET_FSO}'/* keys, '${ICEBERG_SVC_CATALOG_USER}' user READ permission on '${ICEBERG_BUCKET_FSO}'/* keys [because hdfs user creates the buckets, we need READ to get the baseline results], '${ICEBERG_READ_ONLY_ROLE_FSO}' READ permission on '${ICEBERG_BUCKET_FSO}'/* keys + FOR ${bucket} ${all_access_role} ${read_only_role} IN + ... ${ICEBERG_BUCKET_OBS} ${ICEBERG_ALL_ACCESS_ROLE_OBS} ${ICEBERG_READ_ONLY_ROLE_OBS} + ... ${ICEBERG_BUCKET_FSO} ${ICEBERG_ALL_ACCESS_ROLE_FSO} ${ICEBERG_READ_ONLY_ROLE_FSO} + ${policy_json} = Set Variable { "isEnabled": true, "service": "dev_ozone", "name": "iceberg ${bucket} table access", "policyType": 0, "policyPriority": 0, "isAuditEnabled": true, "resources": { "volume": { "values": [ "s3v" ], "isExcludes": false, "isRecursive": false }, "bucket": { "values": [ "${bucket}" ], "isExcludes": false, "isRecursive": false }, "key": { "values": [ "*" ], "isExcludes": false, "isRecursive": true } }, "policyItems": [ { "accesses": [ { "type": "all", "isAllowed": true } ], "roles": [ "${all_access_role}" ], "delegateAdmin": false }, { "accesses": [ { "type": "read", "isAllowed": true } ], "users": [ "${ICEBERG_SVC_CATALOG_USER}" ], "delegateAdmin": false }, { "accesses": [ { "type": "read", "isAllowed": true } ], "roles": [ "${read_only_role}" ], "delegateAdmin": false } ], "serviceType": "ozone", "isDenyAllElse": false } + Create Ranger Policy ${policy_json} + END + +Create Iceberg Multi-Bucket Role Policies + ${bucket_policy} = Set Variable { "isEnabled": true, "service": "dev_ozone", "name": "iceberg multi bucket access", "policyType": 0, "policyPriority": 0, "isAuditEnabled": true, "resources": { "volume": { "values": [ "s3v" ], "isExcludes": false, "isRecursive": false }, "bucket": { "values": [ "${ICEBERG_BUCKET_OBS}", "${ICEBERG_BUCKET_FSO}" ], "isExcludes": false, "isRecursive": false } }, "policyItems": [ { "accesses": [ { "type": "all", "isAllowed": true } ], "roles": [ "${ICEBERG_MULTI_BUCKET_ROLE}" ], "delegateAdmin": false } ], "serviceType": "ozone", "isDenyAllElse": false } + Create Ranger Policy ${bucket_policy} + ${key_policy} = Set Variable { "isEnabled": true, "service": "dev_ozone", "name": "iceberg multi table access", "policyType": 0, "policyPriority": 0, "isAuditEnabled": true, "resources": { "volume": { "values": [ "s3v" ], "isExcludes": false, "isRecursive": false }, "bucket": { "values": [ "${ICEBERG_BUCKET_OBS}", "${ICEBERG_BUCKET_FSO}" ], "isExcludes": false, "isRecursive": false }, "key": { "values": [ "*" ], "isExcludes": false, "isRecursive": true } }, "policyItems": [ { "accesses": [ { "type": "all", "isAllowed": true } ], "roles": [ "${ICEBERG_MULTI_BUCKET_ROLE}" ], "delegateAdmin": false } ], "serviceType": "ozone", "isDenyAllElse": false } + Create Ranger Policy ${key_policy} + +Create STS Link Bucket Role in Ranger + ${role_json} = Set Variable { "name": "${STS_LINK_BUCKET_ROLE}", "description": "STS linked bucket authorization regression role" } + Create Ranger Role ${role_json} + Create Ranger Assume Role Policy ${STS_LINK_BUCKET_ROLE} ${ICEBERG_SVC_CATALOG_USER} + +Create STS Link Bucket Access Policies + # Grant volume access and linked-bucket bucket/key policies for session-policy scoping. + # Also grant source-bucket key access on the role so Ranger allows the resolved key path; the STS + # session policy (linked bucket only) is what this regression exercises. + ${policy_items} = Set Variable [ { "accesses": [ { "type": "read", "isAllowed": true }, { "type": "list", "isAllowed": true } ], "roles": [ "${STS_LINK_BUCKET_ROLE}" ], "delegateAdmin": false } ] + Update Ranger Policy Items iceberg volume access ${policy_items} + ${bucket_policy} = Set Variable { "isEnabled": true, "service": "dev_ozone", "name": "sts linked bucket access", "policyType": 0, "policyPriority": 0, "isAuditEnabled": true, "resources": { "volume": { "values": [ "s3v" ], "isExcludes": false, "isRecursive": false }, "bucket": { "values": [ "${STS_LINK_BUCKET_LINKED}" ], "isExcludes": false, "isRecursive": false } }, "policyItems": [ { "accesses": [ { "type": "all", "isAllowed": true } ], "roles": [ "${STS_LINK_BUCKET_ROLE}" ], "delegateAdmin": false } ], "serviceType": "ozone", "isDenyAllElse": false } + Create Ranger Policy ${bucket_policy} + ${key_policy} = Set Variable { "isEnabled": true, "service": "dev_ozone", "name": "sts linked bucket table access", "policyType": 0, "policyPriority": 0, "isAuditEnabled": true, "resources": { "volume": { "values": [ "s3v" ], "isExcludes": false, "isRecursive": false }, "bucket": { "values": [ "${STS_LINK_BUCKET_LINKED}" ], "isExcludes": false, "isRecursive": false }, "key": { "values": [ "*" ], "isExcludes": false, "isRecursive": true } }, "policyItems": [ { "accesses": [ { "type": "all", "isAllowed": true } ], "roles": [ "${STS_LINK_BUCKET_ROLE}" ], "delegateAdmin": false } ], "serviceType": "ozone", "isDenyAllElse": false } + Create Ranger Policy ${key_policy} + ${source_key_policy} = Set Variable { "isEnabled": true, "service": "dev_ozone", "name": "sts linked bucket source table access", "policyType": 0, "policyPriority": 0, "isAuditEnabled": true, "resources": { "volume": { "values": [ "s3v" ], "isExcludes": false, "isRecursive": false }, "bucket": { "values": [ "${STS_LINK_BUCKET_SOURCE}" ], "isExcludes": false, "isRecursive": false }, "key": { "values": [ "*" ], "isExcludes": false, "isRecursive": true } }, "policyItems": [ { "accesses": [ { "type": "all", "isAllowed": true } ], "roles": [ "${STS_LINK_BUCKET_ROLE}" ], "delegateAdmin": false } ], "serviceType": "ozone", "isDenyAllElse": false } + Create Ranger Policy ${source_key_policy} + ${chain_bucket_policy} = Set Variable { "isEnabled": true, "service": "dev_ozone", "name": "sts chained link bucket access", "policyType": 0, "policyPriority": 0, "isAuditEnabled": true, "resources": { "volume": { "values": [ "s3v" ], "isExcludes": false, "isRecursive": false }, "bucket": { "values": [ "${STS_LINK_BUCKET_CHAIN_FINAL}", "${STS_LINK_BUCKET_CHAIN_INTERMEDIATE}" ], "isExcludes": false, "isRecursive": false } }, "policyItems": [ { "accesses": [ { "type": "all", "isAllowed": true } ], "roles": [ "${STS_LINK_BUCKET_ROLE}" ], "delegateAdmin": false } ], "serviceType": "ozone", "isDenyAllElse": false } + Create Ranger Policy ${chain_bucket_policy} + ${chain_key_policy} = Set Variable { "isEnabled": true, "service": "dev_ozone", "name": "sts chained link bucket table access", "policyType": 0, "policyPriority": 0, "isAuditEnabled": true, "resources": { "volume": { "values": [ "s3v" ], "isExcludes": false, "isRecursive": false }, "bucket": { "values": [ "${STS_LINK_BUCKET_CHAIN_FINAL}" ], "isExcludes": false, "isRecursive": false }, "key": { "values": [ "*" ], "isExcludes": false, "isRecursive": true } }, "policyItems": [ { "accesses": [ { "type": "all", "isAllowed": true } ], "roles": [ "${STS_LINK_BUCKET_ROLE}" ], "delegateAdmin": false } ], "serviceType": "ozone", "isDenyAllElse": false } + Create Ranger Policy ${chain_key_policy} + ${chain_source_key_policy} = Set Variable { "isEnabled": true, "service": "dev_ozone", "name": "sts chained link bucket source table access", "policyType": 0, "policyPriority": 0, "isAuditEnabled": true, "resources": { "volume": { "values": [ "s3v" ], "isExcludes": false, "isRecursive": false }, "bucket": { "values": [ "${STS_LINK_BUCKET_CHAIN_SOURCE}" ], "isExcludes": false, "isRecursive": false }, "key": { "values": [ "*" ], "isExcludes": false, "isRecursive": true } }, "policyItems": [ { "accesses": [ { "type": "all", "isAllowed": true } ], "roles": [ "${STS_LINK_BUCKET_ROLE}" ], "delegateAdmin": false } ], "serviceType": "ozone", "isDenyAllElse": false } + Create Ranger Policy ${chain_source_key_policy} + Refresh Ranger Policy Cache + +Create Partial Access Roles in Ranger + FOR ${role} IN ${PARTIAL_LIST_ALL_BUCKETS_VOL_READ_ROLE} ${PARTIAL_LIST_ALL_BUCKETS_VOL_LIST_ROLE} ${PARTIAL_BUCKET_READ_ROLE} ${PARTIAL_BUCKET_READ_UPLOAD_PREFIX_ROLE} ${PARTIAL_BUCKET_LIST_ROLE} ${PARTIAL_BUCKET_READ_ACL_ROLE} ${PARTIAL_PUT_OBJECT_KEY_CREATE_ROLE} ${PARTIAL_PUT_OBJECT_KEY_WRITE_ROLE} + ${role_json} = Set Variable { "name": "${role}", "description": "Partial access role" } + Create Ranger Role ${role_json} + END + +Create Partial Access Assume Role Policies + FOR ${role} IN ${PARTIAL_LIST_ALL_BUCKETS_VOL_READ_ROLE} ${PARTIAL_LIST_ALL_BUCKETS_VOL_LIST_ROLE} ${PARTIAL_BUCKET_READ_ROLE} ${PARTIAL_BUCKET_READ_UPLOAD_PREFIX_ROLE} ${PARTIAL_BUCKET_LIST_ROLE} ${PARTIAL_BUCKET_READ_ACL_ROLE} ${PARTIAL_PUT_OBJECT_KEY_CREATE_ROLE} ${PARTIAL_PUT_OBJECT_KEY_WRITE_ROLE} + Create Ranger Assume Role Policy ${role} ${ICEBERG_SVC_CATALOG_USER} + END + +Create Partial Access Volume Policies + # Append partial-role items to existing "iceberg volume access" policy to avoid duplicate resourceSignature conflicts. + ${policy_items} = Set Variable [ { "accesses": [ { "type": "read", "isAllowed": true } ], "roles": [ "${PARTIAL_LIST_ALL_BUCKETS_VOL_READ_ROLE}" ], "delegateAdmin": false }, { "accesses": [ { "type": "list", "isAllowed": true } ], "roles": [ "${PARTIAL_LIST_ALL_BUCKETS_VOL_LIST_ROLE}" ], "delegateAdmin": false }, { "accesses": [ { "type": "read", "isAllowed": true } ], "roles": [ "${PARTIAL_BUCKET_READ_ROLE}", "${PARTIAL_BUCKET_READ_UPLOAD_PREFIX_ROLE}", "${PARTIAL_BUCKET_LIST_ROLE}", "${PARTIAL_BUCKET_READ_ACL_ROLE}", "${PARTIAL_PUT_OBJECT_KEY_CREATE_ROLE}", "${PARTIAL_PUT_OBJECT_KEY_WRITE_ROLE}" ], "delegateAdmin": false } ] + Update Ranger Policy Items iceberg volume access ${policy_items} + +Create Partial Access Bucket Policies + # Append partial-role items to existing "iceberg ${ICEBERG_BUCKET_OBS} bucket access" policy. + ${policy_items} = Set Variable [ { "accesses": [ { "type": "read", "isAllowed": true } ], "roles": [ "${PARTIAL_BUCKET_READ_ROLE}" ], "delegateAdmin": false }, { "accesses": [ { "type": "read", "isAllowed": true } ], "roles": [ "${PARTIAL_BUCKET_READ_UPLOAD_PREFIX_ROLE}" ], "delegateAdmin": false }, { "accesses": [ { "type": "list", "isAllowed": true } ], "roles": [ "${PARTIAL_BUCKET_LIST_ROLE}" ], "delegateAdmin": false }, { "accesses": [ { "type": "read_acl", "isAllowed": true } ], "roles": [ "${PARTIAL_BUCKET_READ_ACL_ROLE}" ], "delegateAdmin": false }, { "accesses": [ { "type": "read", "isAllowed": true } ], "roles": [ "${PARTIAL_PUT_OBJECT_KEY_CREATE_ROLE}", "${PARTIAL_PUT_OBJECT_KEY_WRITE_ROLE}" ], "delegateAdmin": false } ] + Update Ranger Policy Items iceberg ${ICEBERG_BUCKET_OBS} bucket access ${policy_items} + +Create Partial Access Table Policies + # Append partial-role items to existing "iceberg ${ICEBERG_BUCKET_OBS} table access" policy. + ${policy_items} = Set Variable [ { "accesses": [ { "type": "list", "isAllowed": true } ], "roles": [ "${PARTIAL_BUCKET_READ_ROLE}", "${PARTIAL_BUCKET_LIST_ROLE}" ], "delegateAdmin": false }, { "accesses": [ { "type": "create", "isAllowed": true } ], "roles": [ "${PARTIAL_PUT_OBJECT_KEY_CREATE_ROLE}" ], "delegateAdmin": false }, { "accesses": [ { "type": "write", "isAllowed": true } ], "roles": [ "${PARTIAL_PUT_OBJECT_KEY_WRITE_ROLE}" ], "delegateAdmin": false } ] + Update Ranger Policy Items iceberg ${ICEBERG_BUCKET_OBS} table access ${policy_items} + # One-off policy for Negative A in "STS session policy ListBucket must require bucket READ and LIST": + # Grant LIST on key prefix "upload*" (not "*") so it doesn't implicitly satisfy bucket-level LIST in Ranger matching. + ${upload_prefix_policy} = Set Variable { "isEnabled": true, "service": "dev_ozone", "name": "iceberg ${ICEBERG_BUCKET_OBS} upload prefix list", "policyType": 0, "policyPriority": 0, "isAuditEnabled": true, "resources": { "volume": { "values": [ "s3v" ], "isExcludes": false, "isRecursive": false }, "bucket": { "values": [ "${ICEBERG_BUCKET_OBS}" ], "isExcludes": false, "isRecursive": false }, "key": { "values": [ "upload*" ], "isExcludes": false, "isRecursive": true } }, "policyItems": [ { "accesses": [ { "type": "list", "isAllowed": true } ], "roles": [ "${PARTIAL_BUCKET_READ_UPLOAD_PREFIX_ROLE}" ], "delegateAdmin": false } ], "serviceType": "ozone", "isDenyAllElse": false } + Create Ranger Policy ${upload_prefix_policy} + +Create Action Matches Roles in Ranger + FOR ${role} IN ${ACTION_MATCHES_PUTOBJECT_READ_ROLE} ${ACTION_MATCHES_PUTOBJECT_CREATE_WRITE_ROLE} ${ACTION_MATCHES_GETOBJECT_PUTOBJECT_ROLE} ${ACTION_MATCHES_UPLOADPARTCOPY_EXPECTED_OWNER_ROLE} ${ACTION_MATCHES_GET_STAR_READ_ROLE} + ${role_json} = Set Variable { "name": "${role}", "description": "Action-matches scoped role" } + Create Ranger Role ${role_json} + END + +Create Action Matches Assume Role Policies + FOR ${role} IN ${ACTION_MATCHES_PUTOBJECT_READ_ROLE} ${ACTION_MATCHES_PUTOBJECT_CREATE_WRITE_ROLE} ${ACTION_MATCHES_GETOBJECT_PUTOBJECT_ROLE} ${ACTION_MATCHES_UPLOADPARTCOPY_EXPECTED_OWNER_ROLE} ${ACTION_MATCHES_GET_STAR_READ_ROLE} + Create Ranger Assume Role Policy ${role} ${ICEBERG_SVC_CATALOG_USER} + END + +Create Action Matches Volume Policies + # Apply action-matches conditions at volume level so READ is scoped to the intended S3 action. + ${policy_items} = Set Variable [ { "accesses": [ { "type": "read", "isAllowed": true } ], "roles": [ "${ACTION_MATCHES_PUTOBJECT_READ_ROLE}" ], "conditions": [ { "type": "action-matches", "values": [ "PutObject" ] } ], "delegateAdmin": false }, { "accesses": [ { "type": "read", "isAllowed": true } ], "roles": [ "${ACTION_MATCHES_PUTOBJECT_CREATE_WRITE_ROLE}" ], "conditions": [ { "type": "action-matches", "values": [ "PutObject" ] } ], "delegateAdmin": false }, { "accesses": [ { "type": "read", "isAllowed": true } ], "roles": [ "${ACTION_MATCHES_GETOBJECT_PUTOBJECT_ROLE}" ], "conditions": [ { "type": "action-matches", "values": [ "GetObject", "PutObject" ] } ], "delegateAdmin": false }, { "accesses": [ { "type": "read", "isAllowed": true } ], "roles": [ "${ACTION_MATCHES_UPLOADPARTCOPY_EXPECTED_OWNER_ROLE}" ], "conditions": [ { "type": "action-matches", "values": [ "GetObject", "PutObject" ] } ], "delegateAdmin": false }, { "accesses": [ { "type": "read", "isAllowed": true } ], "roles": [ "${ACTION_MATCHES_GET_STAR_READ_ROLE}" ], "conditions": [ { "type": "action-matches", "values": [ "Get*" ] } ], "delegateAdmin": false } ] + Update Ranger Policy Items iceberg volume access ${policy_items} + +Create Action Matches Bucket Policies + ${policy_items} = Set Variable [ { "accesses": [ { "type": "read", "isAllowed": true } ], "roles": [ "${ACTION_MATCHES_PUTOBJECT_READ_ROLE}" ], "conditions": [ { "type": "action-matches", "values": [ "PutObject" ] } ], "delegateAdmin": false }, { "accesses": [ { "type": "read", "isAllowed": true } ], "roles": [ "${ACTION_MATCHES_PUTOBJECT_CREATE_WRITE_ROLE}" ], "conditions": [ { "type": "action-matches", "values": [ "PutObject" ] } ], "delegateAdmin": false }, { "accesses": [ { "type": "read", "isAllowed": true } ], "roles": [ "${ACTION_MATCHES_GETOBJECT_PUTOBJECT_ROLE}" ], "conditions": [ { "type": "action-matches", "values": [ "GetObject", "PutObject" ] } ], "delegateAdmin": false }, { "accesses": [ { "type": "read", "isAllowed": true } ], "roles": [ "${ACTION_MATCHES_UPLOADPARTCOPY_EXPECTED_OWNER_ROLE}" ], "conditions": [ { "type": "action-matches", "values": [ "GetObject", "PutObject" ] } ], "delegateAdmin": false }, { "accesses": [ { "type": "read", "isAllowed": true } ], "roles": [ "${ACTION_MATCHES_GET_STAR_READ_ROLE}" ], "conditions": [ { "type": "action-matches", "values": [ "Get*" ] } ], "delegateAdmin": false } ] + Update Ranger Policy Items iceberg ${ICEBERG_BUCKET_OBS} bucket access ${policy_items} + +Create Action Matches Table Policies + # READ with action-matches=PutObject must not authorize PutObject; CREATE+WRITE with action-matches=PutObject must only authorize PutObject. + # READ+CREATE+WRITE with action-matches=GetObject and PutObject supports UploadPartCopy source read and destination write. + # READ with action-matches=Get* must authorize GetObject but not PutObject. + ${policy_items} = Set Variable [ { "accesses": [ { "type": "read", "isAllowed": true } ], "roles": [ "${ACTION_MATCHES_PUTOBJECT_READ_ROLE}" ], "conditions": [ { "type": "action-matches", "values": [ "PutObject" ] } ], "delegateAdmin": false }, { "accesses": [ { "type": "create", "isAllowed": true }, { "type": "write", "isAllowed": true } ], "roles": [ "${ACTION_MATCHES_PUTOBJECT_CREATE_WRITE_ROLE}" ], "conditions": [ { "type": "action-matches", "values": [ "PutObject" ] } ], "delegateAdmin": false }, { "accesses": [ { "type": "read", "isAllowed": true }, { "type": "create", "isAllowed": true }, { "type": "write", "isAllowed": true } ], "roles": [ "${ACTION_MATCHES_GETOBJECT_PUTOBJECT_ROLE}" ], "conditions": [ { "type": "action-matches", "values": [ "GetObject", "PutObject" ] } ], "delegateAdmin": false }, { "accesses": [ { "type": "read", "isAllowed": true }, { "type": "create", "isAllowed": true }, { "type": "write", "isAllowed": true } ], "roles": [ "${ACTION_MATCHES_UPLOADPARTCOPY_EXPECTED_OWNER_ROLE}" ], "conditions": [ { "type": "action-matches", "values": [ "GetObject", "PutObject" ] } ], "delegateAdmin": false }, { "accesses": [ { "type": "read", "isAllowed": true } ], "roles": [ "${ACTION_MATCHES_GET_STAR_READ_ROLE}" ], "conditions": [ { "type": "action-matches", "values": [ "Get*" ] } ], "delegateAdmin": false } ] + Update Ranger Policy Items iceberg ${ICEBERG_BUCKET_OBS} table access ${policy_items} + +Get S3 Credentials for Service Catalog Principal, Create Iceberg Buckets, and Upload Files + Kinit test user ${ICEBERG_SVC_CATALOG_USER} ${ICEBERG_SVC_CATALOG_USER}.keytab + + # Waiting for Ranger policy cache refresh - ${ICEBERG_SVC_CATALOG_USER} needs to be able to read s3v volume + Wait Until Keyword Succeeds 30s 5s Execute ozone sh volume info s3v + + ${output} = Execute ozone s3 getsecret + ${accessKeyId} = Get Regexp Matches ${output} (?<=awsAccessKey=).* + ${secretKey} = Get Regexp Matches ${output} (?<=awsSecret=).* + ${accessKeyId} = Set Variable ${accessKeyId[0]} + ${secretKey} = Set Variable ${secretKey[0]} + Set Global Variable ${PERMANENT_ACCESS_KEY_ID} ${accessKeyId} + Set Global Variable ${PERMANENT_SECRET_KEY} ${secretKey} + + # Create buckets as a different user so the permanent credential principal isn't the bucket owner. + # Otherwise Ranger's default owner privileges can mask missing READ/LIST permissions in STS tests. + # Populate the buckets as this user as well. + Kinit test user hdfs hdfs.keytab + Execute ozone sh bucket create --layout ${ICEBERG_LAYOUT_OBS} /s3v/${ICEBERG_BUCKET_OBS} + Execute ozone sh bucket create --layout ${ICEBERG_LAYOUT_FSO} /s3v/${ICEBERG_BUCKET_FSO} + Populate Iceberg Bucket ${ICEBERG_BUCKET_OBS} + Populate Iceberg Bucket ${ICEBERG_BUCKET_FSO} + Setup Same Volume Link Bucket + Setup Chained Same Volume Link Buckets + + # Switch back to the service catalog principal for running S3/STS requests. + Kinit test user ${ICEBERG_SVC_CATALOG_USER} ${ICEBERG_SVC_CATALOG_USER}.keytab + + # Long-lived 15-minute STS credential to test STS token expiration and proper error message + Assume Role And Store Expired STS Token Credentials perm_access_key_id=${PERMANENT_ACCESS_KEY_ID} perm_secret_key=${PERMANENT_SECRET_KEY} role_arn=${ICEBERG_ALL_ACCESS_ROLE_OBS_ARN} + +Assume Role for Limited-Scope Token + # All access role is limited to read-only via session policy + FOR ${bucket} ${role_arn} IN + ... ${ICEBERG_BUCKET_OBS} ${ICEBERG_ALL_ACCESS_ROLE_OBS_ARN} + ... ${ICEBERG_BUCKET_FSO} ${ICEBERG_ALL_ACCESS_ROLE_FSO_ARN} + ${session_policy} = Set Variable {"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:GetObject","Resource":"arn:aws:s3:::${bucket}/*"}]} + Assume Role And Configure STS Profile policy_json=${session_policy} perm_access_key_id=${PERMANENT_ACCESS_KEY_ID} perm_secret_key=${PERMANENT_SECRET_KEY} role_arn=${role_arn} + ${bucketSuffix} = Generate Random String 8 [LOWER] + ${tmp_bucket} = Set Variable sts-bucket-${bucketSuffix} + Create Bucket Should Fail ${tmp_bucket} + Get Object Should Succeed ${bucket} ${ICEBERG_BUCKET_TESTFILE} + Put Object Should Fail ${bucket} ${ICEBERG_BUCKET_TESTFILE} AccessDenied + END + +Assume Role for Role-Scoped Token + # Create token with full permissions of all access role + FOR ${bucket} ${role_arn} IN + ... ${ICEBERG_BUCKET_OBS} ${ICEBERG_ALL_ACCESS_ROLE_OBS_ARN} + ... ${ICEBERG_BUCKET_FSO} ${ICEBERG_ALL_ACCESS_ROLE_FSO_ARN} + Assume Role And Configure STS Profile perm_access_key_id=${PERMANENT_ACCESS_KEY_ID} perm_secret_key=${PERMANENT_SECRET_KEY} role_arn=${role_arn} + ${bucketSuffix} = Generate Random String 8 [LOWER] + ${tmp_bucket} = Set Variable sts-bucket-${bucketSuffix} + Create Bucket Should Fail ${tmp_bucket} + Get Object Should Succeed ${bucket} ${ICEBERG_BUCKET_TESTFILE} + Put Object Should Succeed ${bucket} ${ICEBERG_BUCKET_TESTFILE} + END + +Assume Role with Invalid Action in Session Policy + # s3:InvalidAction in the session policy is not valid => no access is given to the token + FOR ${bucket} ${role_arn} IN + ... ${ICEBERG_BUCKET_OBS} ${ICEBERG_ALL_ACCESS_ROLE_OBS_ARN} + ... ${ICEBERG_BUCKET_FSO} ${ICEBERG_ALL_ACCESS_ROLE_FSO_ARN} + ${session_policy} = Set Variable {"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:InvalidAction","Resource":"arn:aws:s3:::${bucket}/*"}]} + Assume Role And Configure STS Profile policy_json=${session_policy} perm_access_key_id=${PERMANENT_ACCESS_KEY_ID} perm_secret_key=${PERMANENT_SECRET_KEY} role_arn=${role_arn} + Get Object Should Fail ${bucket} ${ICEBERG_BUCKET_TESTFILE} AccessDenied + Put Object Should Fail ${bucket} ${ICEBERG_BUCKET_TESTFILE} AccessDenied + END + +Assume Role with Mismatched Action and Resource in Session Policy + # s3:GetObject is for object resources but the Resource "arn:aws:s3:::bucket" is a bucket resource => no access is given to the token + FOR ${bucket} ${role_arn} IN + ... ${ICEBERG_BUCKET_OBS} ${ICEBERG_ALL_ACCESS_ROLE_OBS_ARN} + ... ${ICEBERG_BUCKET_FSO} ${ICEBERG_ALL_ACCESS_ROLE_FSO_ARN} + ${session_policy} = Set Variable {"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:GetObject","Resource":"arn:aws:s3:::${bucket}"}]} + Assume Role And Configure STS Profile policy_json=${session_policy} perm_access_key_id=${PERMANENT_ACCESS_KEY_ID} perm_secret_key=${PERMANENT_SECRET_KEY} role_arn=${role_arn} + Get Object Should Fail ${bucket} ${ICEBERG_BUCKET_TESTFILE} AccessDenied + Put Object Should Fail ${bucket} ${ICEBERG_BUCKET_TESTFILE} AccessDenied + END + +Assume Role with Elevated Access in Session Policy Should Fail + # Assume read-only role but try to grant write access in session policy - this should not be allowed => no access given to the token + FOR ${bucket} ${read_only_role_arn} IN + ... ${ICEBERG_BUCKET_OBS} ${READ_ONLY_ROLE_OBS_ARN} + ... ${ICEBERG_BUCKET_FSO} ${READ_ONLY_ROLE_FSO_ARN} + ${session_policy} = Set Variable {"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:PutObject","Resource":"arn:aws:s3:::${bucket}/*"}]} + Assume Role And Configure STS Profile policy_json=${session_policy} perm_access_key_id=${PERMANENT_ACCESS_KEY_ID} perm_secret_key=${PERMANENT_SECRET_KEY} role_arn=${read_only_role_arn} + Get Object Should Fail ${bucket} ${ICEBERG_BUCKET_TESTFILE} AccessDenied + Put Object Should Fail ${bucket} ${ICEBERG_BUCKET_TESTFILE} AccessDenied + END + +Assume Role with Malformed Session Policy JSON Should Fail + ${session_policy} = Set Variable {"ThisIsMalformed"} + FOR ${role_arn} IN ${ICEBERG_ALL_ACCESS_ROLE_OBS_ARN} ${ICEBERG_ALL_ACCESS_ROLE_FSO_ARN} + Assume Role Should Fail perm_access_key_id=${PERMANENT_ACCESS_KEY_ID} perm_secret_key=${PERMANENT_SECRET_KEY} policy_json=${session_policy} expected_error=MalformedPolicyDocument expected_http_code=400 role_arn=${role_arn} + END + +Assume Role with Unsupported Condition Operator in Session Policy Should Fail + # StringNotEqualsIgnoreCase is currently unsupported + FOR ${bucket} ${role_arn} IN + ... ${ICEBERG_BUCKET_OBS} ${ICEBERG_ALL_ACCESS_ROLE_OBS_ARN} + ... ${ICEBERG_BUCKET_FSO} ${ICEBERG_ALL_ACCESS_ROLE_FSO_ARN} + ${session_policy} = Set Variable {"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:GetObject","Resource":"arn:aws:s3:::${bucket}/*","Condition":{"StringNotEqualsIgnoreCase":{"s3:prefix":"my_table/*"}}}]} + Assume Role Should Fail perm_access_key_id=${PERMANENT_ACCESS_KEY_ID} perm_secret_key=${PERMANENT_SECRET_KEY} policy_json=${session_policy} expected_error=UnsupportedOperation expected_http_code=501 role_arn=${role_arn} + END + +Assume Role with GetObject in Session Policy Using s3:prefix Should Fail + FOR ${bucket} ${role_arn} IN + ... ${ICEBERG_BUCKET_OBS} ${ICEBERG_ALL_ACCESS_ROLE_OBS_ARN} + ... ${ICEBERG_BUCKET_FSO} ${ICEBERG_ALL_ACCESS_ROLE_FSO_ARN} + ${session_policy} = Set Variable {"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:GetObject","Resource":"arn:aws:s3:::${bucket}/*","Condition":{"StringLike":{"s3:prefix":"file1*"}}}]} + Assume Role And Configure STS Profile policy_json=${session_policy} perm_access_key_id=${PERMANENT_ACCESS_KEY_ID} perm_secret_key=${PERMANENT_SECRET_KEY} role_arn=${role_arn} + Get Object Should Fail ${bucket} ${ICEBERG_BUCKET_TESTFILE} AccessDenied + END + +Assume Role with GetObject in Session Policy Should Not Allow List Buckets + FOR ${bucket} ${role_arn} IN + ... ${ICEBERG_BUCKET_OBS} ${ICEBERG_ALL_ACCESS_ROLE_OBS_ARN} + ... ${ICEBERG_BUCKET_FSO} ${ICEBERG_ALL_ACCESS_ROLE_FSO_ARN} + ${session_policy} = Set Variable {"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:GetObject","Resource":"arn:aws:s3:::${bucket}/*"}]} + Assume Role And Configure STS Profile policy_json=${session_policy} perm_access_key_id=${PERMANENT_ACCESS_KEY_ID} perm_secret_key=${PERMANENT_SECRET_KEY} role_arn=${role_arn} + List Object Keys Should Fail ${bucket} list-objects AccessDenied + List Object Keys Should Fail ${bucket} list-objects-v2 AccessDenied + END + +STS Token with Bogus Credential Part Should Fail + # If two of the three STS credential components are valid and one is bogus, the token must not give any access + FOR ${bucket} ${role_arn} IN + ... ${ICEBERG_BUCKET_OBS} ${ICEBERG_ALL_ACCESS_ROLE_OBS_ARN} + ... ${ICEBERG_BUCKET_FSO} ${ICEBERG_ALL_ACCESS_ROLE_FSO_ARN} + Assume Role And Get Temporary Credentials perm_access_key_id=${PERMANENT_ACCESS_KEY_ID} perm_secret_key=${PERMANENT_SECRET_KEY} role_arn=${role_arn} + FOR ${bogus_part} IN accessKeyId secretKey sessionToken + Configure STS Profile With Bogus Credential Part ${bogus_part} + ${bucketSuffix} = Generate Random String 8 [LOWER] + ${tmp_bucket} = Set Variable sts-bucket-${bucketSuffix} + Create Bucket Should Fail ${tmp_bucket} + Get Object Should Fail ${bucket} ${ICEBERG_BUCKET_TESTFILE} AccessDenied + Put Object Should Fail ${bucket} ${ICEBERG_BUCKET_TESTFILE} AccessDenied + END + END + +Assume Role Without Ranger Permission Should Fail + # A user who doesn't have assume role permission in Ranger should not be able to invoke the api + Kinit test user ${TEST_USER_NON_ADMIN} ${TEST_USER_NON_ADMIN}.keytab + + ${output} = Execute ozone s3 getsecret + ${accessKeyId} = Get Regexp Matches ${output} (?<=awsAccessKey=).* + ${secretKey} = Get Regexp Matches ${output} (?<=awsSecret=).* + ${accessKeyId} = Set Variable ${accessKeyId[0]} + ${secretKey} = Set Variable ${secretKey[0]} + + FOR ${role_arn} IN ${ICEBERG_ALL_ACCESS_ROLE_OBS_ARN} ${ICEBERG_ALL_ACCESS_ROLE_FSO_ARN} + Assume Role Should Fail perm_access_key_id=${accessKeyId} perm_secret_key=${secretKey} expected_error=AccessDenied role_arn=${role_arn} + END + +Assume Role With Incorrect Permanent Credentials Should Fail + Kinit test user ${ICEBERG_SVC_CATALOG_USER} ${ICEBERG_SVC_CATALOG_USER}.keytab + + FOR ${role_arn} IN ${ICEBERG_ALL_ACCESS_ROLE_OBS_ARN} ${ICEBERG_ALL_ACCESS_ROLE_FSO_ARN} + Assume Role Should Fail perm_access_key_id=${PERMANENT_ACCESS_KEY_ID} perm_secret_key=InvalidSecretKey expected_error=InvalidClientTokenId role_arn=${role_arn} + END + +STS Token with Presigned URL + FOR ${bucket} ${role_arn} IN + ... ${ICEBERG_BUCKET_OBS} ${ICEBERG_ALL_ACCESS_ROLE_OBS_ARN} + ... ${ICEBERG_BUCKET_FSO} ${ICEBERG_ALL_ACCESS_ROLE_FSO_ARN} + Assume Role And Configure STS Profile perm_access_key_id=${PERMANENT_ACCESS_KEY_ID} perm_secret_key=${PERMANENT_SECRET_KEY} role_arn=${role_arn} + ${presigned_url} = Execute aws s3 presign s3://${bucket}/${ICEBERG_BUCKET_TESTFILE} --endpoint-url ${S3G_ENDPOINT_URL} --profile sts + Should Contain ${presigned_url} X-Amz-Algorithm=AWS4-HMAC-SHA256 + Should Contain ${presigned_url} X-Amz-Security-Token= + ${output} = Execute curl -v '${presigned_url}' + Should Contain ${output} HTTP/1.1 200 OK + END + +Verify Token Revocation via CLI + FOR ${bucket} ${role_arn} IN + ... ${ICEBERG_BUCKET_OBS} ${ICEBERG_ALL_ACCESS_ROLE_OBS_ARN} + ... ${ICEBERG_BUCKET_FSO} ${ICEBERG_ALL_ACCESS_ROLE_FSO_ARN} + # 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} + + # 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 -o ${PERMANENT_ACCESS_KEY_ID} -y ${OM_HA_PARAM} + Should Contain ${output} USER_MISMATCH + END + +Revoke STS Token Should Fail For Unknown Original Access Key Id + # Revoking a bogus originalAccessKeyId must fail before writing to the revocation table. + Kinit test user ${TEST_USER_ADMIN} ${TEST_USER_ADMIN}.keytab + ${output} = Execute And Ignore Error ozone s3 revokeststoken -o bogus-original-access-key-id -y ${OM_HA_PARAM} + Should Contain ${output} ACCESS_ID_NOT_FOUND + Should Contain ${output} does not exist + +List Objects V1 and V2 IAM Session Policy Matrix for OBS and FSO + Kinit test user ${ICEBERG_SVC_CATALOG_USER} ${ICEBERG_SVC_CATALOG_USER}.keytab + + FOR ${bucket} ${role_arn} IN + ... ${ICEBERG_BUCKET_OBS} ${ICEBERG_ALL_ACCESS_ROLE_OBS_ARN} + ... ${ICEBERG_BUCKET_FSO} ${ICEBERG_ALL_ACCESS_ROLE_FSO_ARN} + FOR ${api} IN list-objects list-objects-v2 + Run List Prefix And Delimiter Policy Matrix For Bucket And Api ${bucket} ${role_arn} ${api} + END + END + +Assume Role Request With Oversized Payload Should Fail + ${large_policy} = Generate Oversized Session Policy + Assume Role Should Fail perm_access_key_id=${PERMANENT_ACCESS_KEY_ID} perm_secret_key=${PERMANENT_SECRET_KEY} policy_json=${large_policy} expected_error=PayloadTooLarge role_arn=${ICEBERG_ALL_ACCESS_ROLE_OBS_ARN} + +Doubled STS Session Token Must Fail + # Concatenating a valid session token with itself must be rejected as non-canonical. + Assume Role And Get Temporary Credentials perm_access_key_id=${PERMANENT_ACCESS_KEY_ID} perm_secret_key=${PERMANENT_SECRET_KEY} role_arn=${ICEBERG_ALL_ACCESS_ROLE_OBS_ARN} + ${doubled_token} = Catenate SEPARATOR= ${STS_SESSION_TOKEN} ${STS_SESSION_TOKEN} + Configure STS Profile ${STS_ACCESS_KEY_ID} ${STS_SECRET_KEY} ${doubled_token} + Get Object Should Fail ${ICEBERG_BUCKET_OBS} ${ICEBERG_BUCKET_TESTFILE} AccessDenied + Put Object Should Fail ${ICEBERG_BUCKET_OBS} ${ICEBERG_BUCKET_TESTFILE} AccessDenied + +Tampered STS Token Service, Policy, or Signature Must Fail + # Taking valid STS session token and mutating different parts of it must render it unusable + ${session_policy} = Set Variable {"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:GetObject","Resource":"arn:aws:s3:::${ICEBERG_BUCKET_OBS}/*"}]} + Assume Role And Get Temporary Credentials policy_json=${session_policy} perm_access_key_id=${PERMANENT_ACCESS_KEY_ID} perm_secret_key=${PERMANENT_SECRET_KEY} role_arn=${ICEBERG_ALL_ACCESS_ROLE_OBS_ARN} + ${token_with_service_tamper} = Mutate STS Session Token ${STS_SESSION_TOKEN} service + Configure STS Profile ${STS_ACCESS_KEY_ID} ${STS_SECRET_KEY} ${token_with_service_tamper} + Get Object Should Fail ${ICEBERG_BUCKET_OBS} ${ICEBERG_BUCKET_TESTFILE} AccessDenied + Put Object Should Fail ${ICEBERG_BUCKET_OBS} ${ICEBERG_BUCKET_TESTFILE} AccessDenied + + ${token_with_policy_tamper} = Mutate STS Session Token ${STS_SESSION_TOKEN} session_policy + Configure STS Profile ${STS_ACCESS_KEY_ID} ${STS_SECRET_KEY} ${token_with_policy_tamper} + Get Object Should Fail ${ICEBERG_BUCKET_OBS} ${ICEBERG_BUCKET_TESTFILE} AccessDenied + Put Object Should Fail ${ICEBERG_BUCKET_OBS} ${ICEBERG_BUCKET_TESTFILE} AccessDenied + + ${token_with_signature_tamper} = Mutate STS Session Token ${STS_SESSION_TOKEN} signature + Configure STS Profile ${STS_ACCESS_KEY_ID} ${STS_SECRET_KEY} ${token_with_signature_tamper} + Get Object Should Fail ${ICEBERG_BUCKET_OBS} ${ICEBERG_BUCKET_TESTFILE} AccessDenied + Put Object Should Fail ${ICEBERG_BUCKET_OBS} ${ICEBERG_BUCKET_TESTFILE} AccessDenied + + # 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} + Configure STS Profile ${STS_ACCESS_KEY_ID} ${STS_SECRET_KEY} ${STS_SESSION_TOKEN} + Get Object Should Succeed ${ICEBERG_BUCKET_OBS} ${ICEBERG_BUCKET_TESTFILE} + Get Object Should Succeed ${ICEBERG_BUCKET_FSO} ${ICEBERG_BUCKET_TESTFILE} + Put Object Should Fail ${ICEBERG_BUCKET_OBS} ${ICEBERG_BUCKET_TESTFILE} AccessDenied + Put Object Should Fail ${ICEBERG_BUCKET_FSO} ${ICEBERG_BUCKET_TESTFILE} AccessDenied + +Assume Role Session Policy With Wildcard Bucket Should Work + ${session_policy} = Set Variable {"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:GetObject","Resource":"arn:aws:s3:::iceberg-*/*"}]} + Assume Role And Get Temporary Credentials policy_json=${session_policy} perm_access_key_id=${PERMANENT_ACCESS_KEY_ID} perm_secret_key=${PERMANENT_SECRET_KEY} role_arn=${ICEBERG_MULTI_BUCKET_ROLE_ARN} + Configure STS Profile ${STS_ACCESS_KEY_ID} ${STS_SECRET_KEY} ${STS_SESSION_TOKEN} + Get Object Should Succeed ${ICEBERG_BUCKET_OBS} ${ICEBERG_BUCKET_TESTFILE} + Get Object Should Succeed ${ICEBERG_BUCKET_FSO} ${ICEBERG_BUCKET_TESTFILE} + Put Object Should Fail ${ICEBERG_BUCKET_OBS} ${ICEBERG_BUCKET_TESTFILE} AccessDenied + +Assume Role Without Duration Should Default To One Hour + # The Assume Role and Get Temporary Credentials keyword has a check for default expiration of 3600 seconds (i.e. one hour) if duration is not supplied, which it is not supplied here + Assume Role And Get Temporary Credentials perm_access_key_id=${PERMANENT_ACCESS_KEY_ID} perm_secret_key=${PERMANENT_SECRET_KEY} role_arn=${ICEBERG_ALL_ACCESS_ROLE_OBS_ARN} duration_seconds=${EMPTY} + Configure STS Profile ${STS_ACCESS_KEY_ID} ${STS_SECRET_KEY} ${STS_SESSION_TOKEN} + Get Object Should Succeed ${ICEBERG_BUCKET_OBS} ${ICEBERG_BUCKET_TESTFILE} + +Assume Role Should Fail For Invalid Sts Endpoint Path + Assume Role Should Fail perm_access_key_id=${PERMANENT_ACCESS_KEY_ID} perm_secret_key=${PERMANENT_SECRET_KEY} expected_error=ValidationError expected_http_code=400 role_arn=${ICEBERG_ALL_ACCESS_ROLE_OBS_ARN} sts_endpoint_url=${STS_ENDPOINT_URL}/sts + Assume Role Should Fail perm_access_key_id=${PERMANENT_ACCESS_KEY_ID} perm_secret_key=${PERMANENT_SECRET_KEY} expected_error=ValidationError expected_http_code=400 role_arn=${ICEBERG_ALL_ACCESS_ROLE_OBS_ARN} sts_endpoint_url=${STS_ENDPOINT_URL}/invalidEndpoint + +Assume Role Should Fail For Too Short Role Arn + Assume Role Should Fail Using Curl perm_access_key_id=${PERMANENT_ACCESS_KEY_ID} perm_secret_key=${PERMANENT_SECRET_KEY} expected_error=ValidationError expected_http_code=400 role_arn=a + +Assume Role Should Fail For Too Short Role Session Name + Assume Role Should Fail Using Curl perm_access_key_id=${PERMANENT_ACCESS_KEY_ID} perm_secret_key=${PERMANENT_SECRET_KEY} expected_error=ValidationError expected_http_code=400 role_arn=${ICEBERG_ALL_ACCESS_ROLE_OBS_ARN} role_session_name=a + +Assume Role With ExternalId Should Fail As UnsupportedOperation + Assume Role Should Fail perm_access_key_id=${PERMANENT_ACCESS_KEY_ID} perm_secret_key=${PERMANENT_SECRET_KEY} expected_error=UnsupportedOperation expected_http_code=501 role_arn=${ICEBERG_ALL_ACCESS_ROLE_OBS_ARN} extra_cli_args=--external-id test-external-id + +Assume Role With Session Tags Should Fail As UnsupportedOperation + Assume Role Should Fail perm_access_key_id=${PERMANENT_ACCESS_KEY_ID} perm_secret_key=${PERMANENT_SECRET_KEY} expected_error=UnsupportedOperation expected_http_code=501 role_arn=${ICEBERG_ALL_ACCESS_ROLE_OBS_ARN} extra_cli_args=--tags Key=tag-key1,Value=tag-value1 + +Assume Role With ExternalId In Query Should Fail As UnsupportedOperation + Assume Role Should Fail Using Curl Get perm_access_key_id=${PERMANENT_ACCESS_KEY_ID} perm_secret_key=${PERMANENT_SECRET_KEY} expected_error=UnsupportedOperation expected_http_code=501 role_arn=${ICEBERG_ALL_ACCESS_ROLE_OBS_ARN} extra_curl_params=--data-urlencode "ExternalId=test-external-id" + +Assume Role With Session Tags In Query Should Fail As UnsupportedOperation + Assume Role Should Fail Using Curl Get perm_access_key_id=${PERMANENT_ACCESS_KEY_ID} perm_secret_key=${PERMANENT_SECRET_KEY} expected_error=UnsupportedOperation expected_http_code=501 role_arn=${ICEBERG_ALL_ACCESS_ROLE_OBS_ARN} extra_curl_params=--data-urlencode "Tags.member.1.Key=tag-key1" --data-urlencode "Tags.member.1.Value=tag-value1" + +Assume Role With Transitive Tag Keys Should Fail As UnsupportedOperation + Assume Role Should Fail perm_access_key_id=${PERMANENT_ACCESS_KEY_ID} perm_secret_key=${PERMANENT_SECRET_KEY} expected_error=UnsupportedOperation expected_http_code=501 role_arn=${ICEBERG_ALL_ACCESS_ROLE_OBS_ARN} extra_cli_args=--transitive-tag-keys tag-key1 + +Assume Role With PolicyArns Should Fail As UnsupportedOperation + Assume Role Should Fail Using Curl perm_access_key_id=${PERMANENT_ACCESS_KEY_ID} perm_secret_key=${PERMANENT_SECRET_KEY} expected_error=UnsupportedOperation expected_http_code=501 role_arn=${ICEBERG_ALL_ACCESS_ROLE_OBS_ARN} extra_curl_params=--data-urlencode "PolicyArns.member.1=arn:aws:iam::123456789012:policy/test-policy" + +Assume Role Response Should Include STS Request Headers + Assume Role Response Headers Should Be Present ${PERMANENT_ACCESS_KEY_ID} ${PERMANENT_SECRET_KEY} role_arn=${ICEBERG_ALL_ACCESS_ROLE_OBS_ARN} + +STS session policy ListAllMyBuckets must require volume READ and LIST + # Positive control + ${session_policy} = Set Variable {"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:ListAllMyBuckets","Resource":"*"}]} + Assume Role And Configure STS Profile policy_json=${session_policy} perm_access_key_id=${PERMANENT_ACCESS_KEY_ID} perm_secret_key=${PERMANENT_SECRET_KEY} role_arn=${ICEBERG_ALL_ACCESS_ROLE_OBS_ARN} + ${output} = Execute aws s3api --endpoint-url ${S3G_ENDPOINT_URL} list-buckets --profile sts + Should Contain ${output} ${ICEBERG_BUCKET_OBS} + + # Negative A: missing LIST + Assume Role And Configure STS Profile policy_json=${session_policy} perm_access_key_id=${PERMANENT_ACCESS_KEY_ID} perm_secret_key=${PERMANENT_SECRET_KEY} role_arn=${PARTIAL_LIST_ALL_BUCKETS_VOL_READ_ROLE_ARN} + ${output} = Execute And Ignore Error aws s3api --endpoint-url ${S3G_ENDPOINT_URL} list-buckets --profile sts + Should Contain ${output} AccessDenied + + # Negative B: missing READ + Assume Role And Configure STS Profile policy_json=${session_policy} perm_access_key_id=${PERMANENT_ACCESS_KEY_ID} perm_secret_key=${PERMANENT_SECRET_KEY} role_arn=${PARTIAL_LIST_ALL_BUCKETS_VOL_LIST_ROLE_ARN} + ${output} = Execute And Ignore Error aws s3api --endpoint-url ${S3G_ENDPOINT_URL} list-buckets --profile sts + Should Contain ${output} AccessDenied + +STS session policy ListBucket must require bucket READ and LIST + # Positive control + ${session_policy} = Set Variable {"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:ListBucket","Resource":"arn:aws:s3:::${ICEBERG_BUCKET_OBS}"}]} + Assume Role And Configure STS Profile policy_json=${session_policy} perm_access_key_id=${PERMANENT_ACCESS_KEY_ID} perm_secret_key=${PERMANENT_SECRET_KEY} role_arn=${ICEBERG_ALL_ACCESS_ROLE_OBS_ARN} + List Object Keys Should Succeed ${ICEBERG_BUCKET_OBS} + + # Negative A: missing LIST + Assume Role And Configure STS Profile policy_json=${session_policy} perm_access_key_id=${PERMANENT_ACCESS_KEY_ID} perm_secret_key=${PERMANENT_SECRET_KEY} role_arn=${PARTIAL_BUCKET_READ_UPLOAD_PREFIX_ROLE_ARN} + List Object Keys Should Fail ${ICEBERG_BUCKET_OBS} list-objects AccessDenied upload + + # Negative B: missing READ + Assume Role And Configure STS Profile policy_json=${session_policy} perm_access_key_id=${PERMANENT_ACCESS_KEY_ID} perm_secret_key=${PERMANENT_SECRET_KEY} role_arn=${PARTIAL_BUCKET_LIST_ROLE_ARN} + List Object Keys Should Fail ${ICEBERG_BUCKET_OBS} list-objects AccessDenied + +STS session policy ListBucketMultipartUploads must require bucket READ and LIST + # Positive control + ${session_policy} = Set Variable {"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:ListBucketMultipartUploads","Resource":"arn:aws:s3:::${ICEBERG_BUCKET_OBS}"}]} + Assume Role And Configure STS Profile policy_json=${session_policy} perm_access_key_id=${PERMANENT_ACCESS_KEY_ID} perm_secret_key=${PERMANENT_SECRET_KEY} role_arn=${ICEBERG_ALL_ACCESS_ROLE_OBS_ARN} + ${output} = Execute aws s3api --endpoint-url ${S3G_ENDPOINT_URL} list-multipart-uploads --bucket ${ICEBERG_BUCKET_OBS} --profile sts + Should Not Contain ${output} AccessDenied + + # Negative A: missing LIST + Assume Role And Configure STS Profile policy_json=${session_policy} perm_access_key_id=${PERMANENT_ACCESS_KEY_ID} perm_secret_key=${PERMANENT_SECRET_KEY} role_arn=${READ_ONLY_ROLE_OBS_ARN} + ${output} = Execute And Ignore Error aws s3api --endpoint-url ${S3G_ENDPOINT_URL} list-multipart-uploads --bucket ${ICEBERG_BUCKET_OBS} --profile sts + Should Contain ${output} AccessDenied + + # Negative B: missing READ + Assume Role And Configure STS Profile policy_json=${session_policy} perm_access_key_id=${PERMANENT_ACCESS_KEY_ID} perm_secret_key=${PERMANENT_SECRET_KEY} role_arn=${PARTIAL_BUCKET_LIST_ROLE_ARN} + ${output} = Execute And Ignore Error aws s3api --endpoint-url ${S3G_ENDPOINT_URL} list-multipart-uploads --bucket ${ICEBERG_BUCKET_OBS} --profile sts + Should Contain ${output} AccessDenied + +STS session policy GetBucketAcl must require bucket READ and READ_ACL + # Positive control + ${session_policy} = Set Variable {"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:GetBucketAcl","Resource":"arn:aws:s3:::${ICEBERG_BUCKET_OBS}"}]} + Assume Role And Configure STS Profile policy_json=${session_policy} perm_access_key_id=${PERMANENT_ACCESS_KEY_ID} perm_secret_key=${PERMANENT_SECRET_KEY} role_arn=${ICEBERG_ALL_ACCESS_ROLE_OBS_ARN} + ${output} = Execute aws s3api --endpoint-url ${S3G_ENDPOINT_URL} get-bucket-acl --bucket ${ICEBERG_BUCKET_OBS} --profile sts + Should Contain ${output} Owner + + # Negative A: missing READ_ACL + Assume Role And Configure STS Profile policy_json=${session_policy} perm_access_key_id=${PERMANENT_ACCESS_KEY_ID} perm_secret_key=${PERMANENT_SECRET_KEY} role_arn=${PARTIAL_BUCKET_READ_ROLE_ARN} + ${output} = Execute And Ignore Error aws s3api --endpoint-url ${S3G_ENDPOINT_URL} get-bucket-acl --bucket ${ICEBERG_BUCKET_OBS} --profile sts + Should Contain ${output} AccessDenied + + # Negative B: missing READ + Assume Role And Configure STS Profile policy_json=${session_policy} perm_access_key_id=${PERMANENT_ACCESS_KEY_ID} perm_secret_key=${PERMANENT_SECRET_KEY} role_arn=${PARTIAL_BUCKET_READ_ACL_ROLE_ARN} + ${output} = Execute And Ignore Error aws s3api --endpoint-url ${S3G_ENDPOINT_URL} get-bucket-acl --bucket ${ICEBERG_BUCKET_OBS} --profile sts + Should Contain ${output} AccessDenied + +STS session policy PutObject must require key CREATE and WRITE + # Positive control + ${session_policy} = Set Variable {"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:PutObject","Resource":"arn:aws:s3:::${ICEBERG_BUCKET_OBS}/*"}]} + Assume Role And Configure STS Profile policy_json=${session_policy} perm_access_key_id=${PERMANENT_ACCESS_KEY_ID} perm_secret_key=${PERMANENT_SECRET_KEY} role_arn=${ICEBERG_ALL_ACCESS_ROLE_OBS_ARN} + Put Object Should Succeed ${ICEBERG_BUCKET_OBS} ${ICEBERG_BUCKET_TESTFILE} + + # Negative A: missing WRITE + Assume Role And Configure STS Profile policy_json=${session_policy} perm_access_key_id=${PERMANENT_ACCESS_KEY_ID} perm_secret_key=${PERMANENT_SECRET_KEY} role_arn=${PARTIAL_PUT_OBJECT_KEY_CREATE_ROLE_ARN} + Put Object Should Fail ${ICEBERG_BUCKET_OBS} ${ICEBERG_BUCKET_TESTFILE} AccessDenied + + # Negative B: missing CREATE + Assume Role And Configure STS Profile policy_json=${session_policy} perm_access_key_id=${PERMANENT_ACCESS_KEY_ID} perm_secret_key=${PERMANENT_SECRET_KEY} role_arn=${PARTIAL_PUT_OBJECT_KEY_WRITE_ROLE_ARN} + Put Object Should Fail ${ICEBERG_BUCKET_OBS} ${ICEBERG_BUCKET_TESTFILE} AccessDenied + +Ranger action-matches PutObject with READ only must deny PutObject + ${key_suffix} = Generate Random String 8 [LOWER] + ${key} = Set Variable sts-object-${key_suffix}.txt + ${local_path} = Set Variable ${TEMP_DIR}/${key} + Create File ${local_path} action-matches read-only content + ${session_policy} = Set Variable {"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:PutObject","Resource":"arn:aws:s3:::${ICEBERG_BUCKET_OBS}/${key}"}]} + + # Role alone: READ permission scoped to PutObject must not allow PutObject. + Assume Role And Configure STS Profile perm_access_key_id=${PERMANENT_ACCESS_KEY_ID} perm_secret_key=${PERMANENT_SECRET_KEY} role_arn=${ACTION_MATCHES_PUTOBJECT_READ_ROLE_ARN} + Put Object Should Fail ${ICEBERG_BUCKET_OBS} ${key} AccessDenied ${local_path} + + # Role plus session policy PutObject: still must not allow PutObject. + Assume Role And Configure STS Profile policy_json=${session_policy} perm_access_key_id=${PERMANENT_ACCESS_KEY_ID} perm_secret_key=${PERMANENT_SECRET_KEY} role_arn=${ACTION_MATCHES_PUTOBJECT_READ_ROLE_ARN} + Put Object Should Fail ${ICEBERG_BUCKET_OBS} ${key} AccessDenied ${local_path} + +Ranger action-matches PutObject with CREATE and WRITE allows only PutObject + ${key_suffix} = Generate Random String 8 [LOWER] + ${key} = Set Variable sts-object-${key_suffix}.txt + ${local_path} = Set Variable ${TEMP_DIR}/${key} + Create File ${local_path} action-matches create-write content + + Assume Role And Configure STS Profile perm_access_key_id=${PERMANENT_ACCESS_KEY_ID} perm_secret_key=${PERMANENT_SECRET_KEY} role_arn=${ACTION_MATCHES_PUTOBJECT_CREATE_WRITE_ROLE_ARN} + Put Object Should Succeed ${ICEBERG_BUCKET_OBS} ${key} ${local_path} + Get Object Should Fail ${ICEBERG_BUCKET_OBS} ${key} AccessDenied + ${output} = Execute And Ignore Error aws s3api --endpoint-url ${S3G_ENDPOINT_URL} put-object-tagging --bucket ${ICEBERG_BUCKET_OBS} --key ${key} --tagging '{"TagSet":[{"Key":"tag-key1","Value":"tag-value1"}]}' --profile sts + Should Contain ${output} AccessDenied + +Ranger action-matches GetObject with READ allows UploadPartCopy source read + ${src_key} = Set Variable ${ICEBERG_BUCKET_TESTFILE} + ${key_suffix} = Generate Random String 8 [LOWER] + ${dest_key} = Set Variable sts-mpu-action-matches-get-${key_suffix}.txt + + # Positive: GetObject on source + PutObject on destination via action-matches-getobject-putobject Ranger policies and session policy should allow UploadPartCopy. + ${allow_policy} = Set Variable {"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:GetObject","Resource":"arn:aws:s3:::${ICEBERG_BUCKET_OBS}/${src_key}"},{"Effect":"Allow","Action":"s3:PutObject","Resource":"arn:aws:s3:::${ICEBERG_BUCKET_OBS}/${dest_key}"}]} + Assume Role And Configure STS Profile policy_json=${allow_policy} perm_access_key_id=${PERMANENT_ACCESS_KEY_ID} perm_secret_key=${PERMANENT_SECRET_KEY} role_arn=${ACTION_MATCHES_GETOBJECT_PUTOBJECT_ROLE_ARN} + ${output} = Execute aws s3api --endpoint-url ${S3G_ENDPOINT_URL} create-multipart-upload --bucket ${ICEBERG_BUCKET_OBS} --key ${dest_key} --profile sts + ${upload_id} = Execute echo '${output}' | jq -r '.UploadId' + Should Not Be Empty ${upload_id} + ${output} = Execute aws s3api --endpoint-url ${S3G_ENDPOINT_URL} upload-part-copy --bucket ${ICEBERG_BUCKET_OBS} --key ${dest_key} --part-number 1 --upload-id ${upload_id} --copy-source ${ICEBERG_BUCKET_OBS}/${src_key} --profile sts + Should Contain ${output} CopyPartResult + +Ranger action-matches Get* with READ allows GetObject but denies PutObject + ${key_suffix} = Generate Random String 8 [LOWER] + ${key} = Set Variable sts-object-${key_suffix}.txt + ${local_path} = Set Variable ${TEMP_DIR}/${key} + Create File ${local_path} action-matches get-star content + + Assume Role And Configure STS Profile perm_access_key_id=${PERMANENT_ACCESS_KEY_ID} perm_secret_key=${PERMANENT_SECRET_KEY} role_arn=${ACTION_MATCHES_GET_STAR_READ_ROLE_ARN} + Get Object Should Succeed ${ICEBERG_BUCKET_OBS} ${ICEBERG_BUCKET_TESTFILE} + Put Object Should Fail ${ICEBERG_BUCKET_OBS} ${key} AccessDenied ${local_path} + +STS session policy CreateBucket must require bucket CREATE + ${bucket_suffix} = Generate Random String 8 [LOWER] + ${bucket} = Set Variable sts-bucket-${bucket_suffix} + ${create_policy} = Set Variable {"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:CreateBucket","Resource":"arn:aws:s3:::${bucket}"}]} + ${delete_policy} = Set Variable {"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:DeleteBucket","Resource":"arn:aws:s3:::${bucket}"}]} + + # Negative: missing CREATE + Assume Role And Configure STS Profile policy_json=${create_policy} perm_access_key_id=${PERMANENT_ACCESS_KEY_ID} perm_secret_key=${PERMANENT_SECRET_KEY} role_arn=${READ_ONLY_ROLE_OBS_ARN} + ${output} = Execute And Ignore Error aws s3api --endpoint-url ${S3G_ENDPOINT_URL} create-bucket --bucket ${bucket} --profile sts + Should Contain ${output} AccessDenied + + # Positive control + Assume Role And Configure STS Profile policy_json=${create_policy} perm_access_key_id=${PERMANENT_ACCESS_KEY_ID} perm_secret_key=${PERMANENT_SECRET_KEY} role_arn=${STS_TEMP_BUCKET_ROLE_ARN} + ${output} = Execute aws s3api --endpoint-url ${S3G_ENDPOINT_URL} create-bucket --bucket ${bucket} --profile sts + Should Contain ${output} Location + Should Contain ${output} ${bucket} + + # Cleanup + Assume Role And Configure STS Profile policy_json=${delete_policy} perm_access_key_id=${PERMANENT_ACCESS_KEY_ID} perm_secret_key=${PERMANENT_SECRET_KEY} role_arn=${STS_TEMP_BUCKET_ROLE_ARN} + ${output} = Execute aws s3api --endpoint-url ${S3G_ENDPOINT_URL} delete-bucket --bucket ${bucket} --profile sts + Should Not Contain ${output} AccessDenied + +STS session policy DeleteBucket must require bucket DELETE + ${bucket_suffix} = Generate Random String 8 [LOWER] + ${bucket} = Set Variable sts-bucket-${bucket_suffix} + ${delete_policy} = Set Variable {"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:DeleteBucket","Resource":"arn:aws:s3:::${bucket}"}]} + + # Prepare the bucket as a different principal so bucket-owner privileges + # do not mask missing DELETE permissions in the negative case. + Kinit test user hdfs hdfs.keytab + ${output} = Execute ozone sh bucket create --layout ${ICEBERG_LAYOUT_OBS} /s3v/${bucket} + Kinit test user ${ICEBERG_SVC_CATALOG_USER} svc-iceberg-rest-catalog.keytab + + # Negative: missing DELETE + Assume Role And Configure STS Profile policy_json=${delete_policy} perm_access_key_id=${PERMANENT_ACCESS_KEY_ID} perm_secret_key=${PERMANENT_SECRET_KEY} role_arn=${READ_ONLY_ROLE_OBS_ARN} + ${output} = Execute And Ignore Error aws s3api --endpoint-url ${S3G_ENDPOINT_URL} delete-bucket --bucket ${bucket} --profile sts + Should Contain ${output} AccessDenied + + # Positive control + Assume Role And Configure STS Profile policy_json=${delete_policy} perm_access_key_id=${PERMANENT_ACCESS_KEY_ID} perm_secret_key=${PERMANENT_SECRET_KEY} role_arn=${STS_TEMP_BUCKET_ROLE_ARN} + ${output} = Execute aws s3api --endpoint-url ${S3G_ENDPOINT_URL} delete-bucket --bucket ${bucket} --profile sts + Should Not Contain ${output} AccessDenied + +STS session policy PutBucketAcl must require bucket WRITE_ACL + ${session_policy} = Set Variable {"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:PutBucketAcl","Resource":"arn:aws:s3:::${ICEBERG_BUCKET_OBS}"}]} + + # Positive control + Assume Role And Configure STS Profile policy_json=${session_policy} perm_access_key_id=${PERMANENT_ACCESS_KEY_ID} perm_secret_key=${PERMANENT_SECRET_KEY} role_arn=${ICEBERG_ALL_ACCESS_ROLE_OBS_ARN} + ${output} = Execute aws s3api --endpoint-url ${S3G_ENDPOINT_URL} put-bucket-acl --bucket ${ICEBERG_BUCKET_OBS} --grant-read "" --profile sts + Should Not Contain ${output} AccessDenied + + # Negative: missing WRITE_ACL + Assume Role And Configure STS Profile policy_json=${session_policy} perm_access_key_id=${PERMANENT_ACCESS_KEY_ID} perm_secret_key=${PERMANENT_SECRET_KEY} role_arn=${PARTIAL_BUCKET_READ_ROLE_ARN} + ${output} = Execute And Ignore Error aws s3api --endpoint-url ${S3G_ENDPOINT_URL} put-bucket-acl --bucket ${ICEBERG_BUCKET_OBS} --grant-read "" --profile sts + Should Contain ${output} AccessDenied + +STS session policy GetObjectTagging must require key READ + ${key_suffix} = Generate Random String 8 [LOWER] + ${key} = Set Variable sts-object-${key_suffix}.txt + ${local_path} = Set Variable ${TEMP_DIR}/${key} + Create File ${local_path} get-object-tagging content + ${put_policy} = Set Variable {"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:PutObject","Resource":"arn:aws:s3:::${ICEBERG_BUCKET_OBS}/${key}"}]} + ${get_tag_policy} = Set Variable {"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:GetObjectTagging","Resource":"arn:aws:s3:::${ICEBERG_BUCKET_OBS}/${key}"}]} + + Assume Role And Configure STS Profile policy_json=${put_policy} perm_access_key_id=${PERMANENT_ACCESS_KEY_ID} perm_secret_key=${PERMANENT_SECRET_KEY} role_arn=${ICEBERG_ALL_ACCESS_ROLE_OBS_ARN} + Put Object Should Succeed ${ICEBERG_BUCKET_OBS} ${key} ${local_path} + + # Positive control + Assume Role And Configure STS Profile policy_json=${get_tag_policy} perm_access_key_id=${PERMANENT_ACCESS_KEY_ID} perm_secret_key=${PERMANENT_SECRET_KEY} role_arn=${ICEBERG_ALL_ACCESS_ROLE_OBS_ARN} + ${output} = Execute aws s3api --endpoint-url ${S3G_ENDPOINT_URL} get-object-tagging --bucket ${ICEBERG_BUCKET_OBS} --key ${key} --profile sts + Should Contain ${output} TagSet + ${tag_count} = Execute echo '${output}' | jq -r '.TagSet | length' + Should Be Equal As Strings ${tag_count} 0 + + # Negative: missing READ - ${PARTIAL_BUCKET_READ_ROLE_ARN} has LIST permission on the key + Assume Role And Get Temporary Credentials policy_json=${get_tag_policy} perm_access_key_id=${PERMANENT_ACCESS_KEY_ID} perm_secret_key=${PERMANENT_SECRET_KEY} role_arn=${PARTIAL_BUCKET_READ_ROLE_ARN} + Configure STS Profile ${STS_ACCESS_KEY_ID} ${STS_SECRET_KEY} ${STS_SESSION_TOKEN} + ${output} = Execute And Ignore Error aws s3api --endpoint-url ${S3G_ENDPOINT_URL} get-object-tagging --bucket ${ICEBERG_BUCKET_OBS} --key ${key} --profile sts + Should Contain ${output} AccessDenied + +STS session policy PutObjectTagging must require key WRITE + ${key_suffix} = Generate Random String 8 [LOWER] + ${key} = Set Variable sts-object-${key_suffix}.txt + ${local_path} = Set Variable ${TEMP_DIR}/${key} + Create File ${local_path} put-object-tagging content + ${put_policy} = Set Variable {"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:PutObject","Resource":"arn:aws:s3:::${ICEBERG_BUCKET_OBS}/${key}"}]} + ${get_tag_policy} = Set Variable {"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:GetObjectTagging","Resource":"arn:aws:s3:::${ICEBERG_BUCKET_OBS}/${key}"}]} + ${put_tag_policy} = Set Variable {"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:PutObjectTagging","Resource":"arn:aws:s3:::${ICEBERG_BUCKET_OBS}/${key}"}]} + + Assume Role And Configure STS Profile policy_json=${put_policy} perm_access_key_id=${PERMANENT_ACCESS_KEY_ID} perm_secret_key=${PERMANENT_SECRET_KEY} role_arn=${ICEBERG_ALL_ACCESS_ROLE_OBS_ARN} + Put Object Should Succeed ${ICEBERG_BUCKET_OBS} ${key} ${local_path} + + # Positive control + Assume Role And Configure STS Profile policy_json=${put_tag_policy} perm_access_key_id=${PERMANENT_ACCESS_KEY_ID} perm_secret_key=${PERMANENT_SECRET_KEY} role_arn=${ICEBERG_ALL_ACCESS_ROLE_OBS_ARN} + ${output} = Execute aws s3api --endpoint-url ${S3G_ENDPOINT_URL} put-object-tagging --bucket ${ICEBERG_BUCKET_OBS} --key ${key} --tagging '{"TagSet":[{"Key":"tag-key1","Value":"tag-value1"}]}' --profile sts + Should Not Contain ${output} AccessDenied + + # Read tags with a session that allows GetObjectTagging + Assume Role And Configure STS Profile policy_json=${get_tag_policy} perm_access_key_id=${PERMANENT_ACCESS_KEY_ID} perm_secret_key=${PERMANENT_SECRET_KEY} role_arn=${ICEBERG_ALL_ACCESS_ROLE_OBS_ARN} + ${output} = Execute aws s3api --endpoint-url ${S3G_ENDPOINT_URL} get-object-tagging --bucket ${ICEBERG_BUCKET_OBS} --key ${key} --profile sts + Should Contain ${output} TagSet + ${tag_count} = Execute echo '${output}' | jq -r '.TagSet | length' + Should Be Equal As Strings ${tag_count} 1 + + # Negative: missing WRITE + Assume Role And Get Temporary Credentials policy_json=${put_tag_policy} perm_access_key_id=${PERMANENT_ACCESS_KEY_ID} perm_secret_key=${PERMANENT_SECRET_KEY} role_arn=${PARTIAL_BUCKET_READ_ROLE_ARN} + Configure STS Profile ${STS_ACCESS_KEY_ID} ${STS_SECRET_KEY} ${STS_SESSION_TOKEN} + ${output} = Execute And Ignore Error aws s3api --endpoint-url ${S3G_ENDPOINT_URL} put-object-tagging --bucket ${ICEBERG_BUCKET_OBS} --key ${key} --tagging '{"TagSet":[{"Key":"tag-key2","Value":"tag-value2"}]}' --profile sts + Should Contain ${output} AccessDenied + +STS session policy DeleteObjectTagging must require key WRITE + ${key_suffix} = Generate Random String 8 [LOWER] + ${key} = Set Variable sts-object-${key_suffix}.txt + ${local_path} = Set Variable ${TEMP_DIR}/${key} + Create File ${local_path} delete-object-tagging content + ${put_policy} = Set Variable {"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:PutObject","Resource":"arn:aws:s3:::${ICEBERG_BUCKET_OBS}/${key}"}]} + ${put_tag_policy} = Set Variable {"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:PutObjectTagging","Resource":"arn:aws:s3:::${ICEBERG_BUCKET_OBS}/${key}"}]} + ${delete_tag_policy} = Set Variable {"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:DeleteObjectTagging","Resource":"arn:aws:s3:::${ICEBERG_BUCKET_OBS}/${key}"}]} + + Assume Role And Configure STS Profile policy_json=${put_policy} perm_access_key_id=${PERMANENT_ACCESS_KEY_ID} perm_secret_key=${PERMANENT_SECRET_KEY} role_arn=${ICEBERG_ALL_ACCESS_ROLE_OBS_ARN} + Put Object Should Succeed ${ICEBERG_BUCKET_OBS} ${key} ${local_path} + + # Seed a tag so the delete actually has something to remove. + Assume Role And Configure STS Profile policy_json=${put_tag_policy} perm_access_key_id=${PERMANENT_ACCESS_KEY_ID} perm_secret_key=${PERMANENT_SECRET_KEY} role_arn=${ICEBERG_ALL_ACCESS_ROLE_OBS_ARN} + ${output} = Execute aws s3api --endpoint-url ${S3G_ENDPOINT_URL} put-object-tagging --bucket ${ICEBERG_BUCKET_OBS} --key ${key} --tagging '{"TagSet":[{"Key":"tag-key1","Value":"tag-value1"}]}' --profile sts + Should Not Contain ${output} AccessDenied + + # Negative: missing WRITE + Assume Role And Configure STS Profile policy_json=${delete_tag_policy} perm_access_key_id=${PERMANENT_ACCESS_KEY_ID} perm_secret_key=${PERMANENT_SECRET_KEY} role_arn=${PARTIAL_BUCKET_READ_ROLE_ARN} + ${output} = Execute And Ignore Error aws s3api --endpoint-url ${S3G_ENDPOINT_URL} delete-object-tagging --bucket ${ICEBERG_BUCKET_OBS} --key ${key} --profile sts + Should Contain ${output} AccessDenied + + # Positive control + Assume Role And Configure STS Profile policy_json=${delete_tag_policy} perm_access_key_id=${PERMANENT_ACCESS_KEY_ID} perm_secret_key=${PERMANENT_SECRET_KEY} role_arn=${ICEBERG_ALL_ACCESS_ROLE_OBS_ARN} + ${output} = Execute aws s3api --endpoint-url ${S3G_ENDPOINT_URL} delete-object-tagging --bucket ${ICEBERG_BUCKET_OBS} --key ${key} --profile sts + Should Not Contain ${output} AccessDenied + + # Read tags with a session that allows GetObjectTagging + ${get_tag_policy} = Set Variable {"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:GetObjectTagging","Resource":"arn:aws:s3:::${ICEBERG_BUCKET_OBS}/${key}"}]} + Assume Role And Get Temporary Credentials policy_json=${get_tag_policy} perm_access_key_id=${PERMANENT_ACCESS_KEY_ID} perm_secret_key=${PERMANENT_SECRET_KEY} role_arn=${ICEBERG_ALL_ACCESS_ROLE_OBS_ARN} + Configure STS Profile ${STS_ACCESS_KEY_ID} ${STS_SECRET_KEY} ${STS_SESSION_TOKEN} + ${output} = Execute aws s3api --endpoint-url ${S3G_ENDPOINT_URL} get-object-tagging --bucket ${ICEBERG_BUCKET_OBS} --key ${key} --profile sts + Should Contain ${output} TagSet + ${tag_count} = Execute echo '${output}' | jq -r '.TagSet | length' + Should Be Equal As Strings ${tag_count} 0 + +STS session policy DeleteObject must require key DELETE + ${key_suffix} = Generate Random String 8 [LOWER] + ${key} = Set Variable sts-object-${key_suffix}.txt + ${local_path} = Set Variable ${TEMP_DIR}/${key} + Create File ${local_path} delete-object content + ${put_policy} = Set Variable {"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:PutObject","Resource":"arn:aws:s3:::${ICEBERG_BUCKET_OBS}/${key}"}]} + ${delete_object_policy} = Set Variable {"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:DeleteObject","Resource":"arn:aws:s3:::${ICEBERG_BUCKET_OBS}/${key}"}]} + + Assume Role And Configure STS Profile policy_json=${put_policy} perm_access_key_id=${PERMANENT_ACCESS_KEY_ID} perm_secret_key=${PERMANENT_SECRET_KEY} role_arn=${ICEBERG_ALL_ACCESS_ROLE_OBS_ARN} + Put Object Should Succeed ${ICEBERG_BUCKET_OBS} ${key} ${local_path} + + # Negative: missing DELETE + Assume Role And Configure STS Profile policy_json=${delete_object_policy} perm_access_key_id=${PERMANENT_ACCESS_KEY_ID} perm_secret_key=${PERMANENT_SECRET_KEY} role_arn=${PARTIAL_BUCKET_READ_ROLE_ARN} + ${output} = Execute And Ignore Error aws s3api --endpoint-url ${S3G_ENDPOINT_URL} delete-object --bucket ${ICEBERG_BUCKET_OBS} --key ${key} --profile sts + Should Contain ${output} AccessDenied + + # Positive control + Assume Role And Configure STS Profile policy_json=${delete_object_policy} perm_access_key_id=${PERMANENT_ACCESS_KEY_ID} perm_secret_key=${PERMANENT_SECRET_KEY} role_arn=${ICEBERG_ALL_ACCESS_ROLE_OBS_ARN} + ${output} = Execute aws s3api --endpoint-url ${S3G_ENDPOINT_URL} delete-object --bucket ${ICEBERG_BUCKET_OBS} --key ${key} --profile sts + Should Not Contain ${output} AccessDenied + +STS session policy ListMultipartUploadParts must require key READ + ${key_suffix} = Generate Random String 8 [LOWER] + ${key} = Set Variable sts-mpu-${key_suffix}.txt + ${local_path} = Set Variable ${TEMP_DIR}/${key} + Create File ${local_path} list multipart upload content + ${put_policy} = Set Variable {"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:PutObject","Resource":"arn:aws:s3:::${ICEBERG_BUCKET_OBS}/${key}"}]} + ${list_parts_policy} = Set Variable {"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:ListMultipartUploadParts","Resource":"arn:aws:s3:::${ICEBERG_BUCKET_OBS}/${key}"}]} + + Assume Role And Configure STS Profile policy_json=${put_policy} perm_access_key_id=${PERMANENT_ACCESS_KEY_ID} perm_secret_key=${PERMANENT_SECRET_KEY} role_arn=${ICEBERG_ALL_ACCESS_ROLE_OBS_ARN} + ${output} = Execute aws s3api --endpoint-url ${S3G_ENDPOINT_URL} create-multipart-upload --bucket ${ICEBERG_BUCKET_OBS} --key ${key} --profile sts + ${upload_id} = Execute echo '${output}' | jq -r '.UploadId' + ${output} = Execute aws s3api --endpoint-url ${S3G_ENDPOINT_URL} upload-part --bucket ${ICEBERG_BUCKET_OBS} --key ${key} --part-number 1 --body ${local_path} --upload-id ${upload_id} --profile sts + Should Contain ${output} ETag + + # Positive control + Assume Role And Configure STS Profile policy_json=${list_parts_policy} perm_access_key_id=${PERMANENT_ACCESS_KEY_ID} perm_secret_key=${PERMANENT_SECRET_KEY} role_arn=${ICEBERG_ALL_ACCESS_ROLE_OBS_ARN} + ${output} = Execute aws s3api --endpoint-url ${S3G_ENDPOINT_URL} list-parts --bucket ${ICEBERG_BUCKET_OBS} --key ${key} --upload-id ${upload_id} --profile sts + Should Contain ${output} PartNumber + + # Negative: missing READ + Assume Role And Configure STS Profile policy_json=${list_parts_policy} perm_access_key_id=${PERMANENT_ACCESS_KEY_ID} perm_secret_key=${PERMANENT_SECRET_KEY} role_arn=${PARTIAL_BUCKET_LIST_ROLE_ARN} + ${output} = Execute And Ignore Error aws s3api --endpoint-url ${S3G_ENDPOINT_URL} list-parts --bucket ${ICEBERG_BUCKET_OBS} --key ${key} --upload-id ${upload_id} --profile sts + Should Contain ${output} AccessDenied + +STS session policy AbortMultipartUpload must require key WRITE + ${key_suffix} = Generate Random String 8 [LOWER] + ${key} = Set Variable sts-mpu-${key_suffix}.txt + ${local_path} = Set Variable ${TEMP_DIR}/${key} + Create File ${local_path} abort multipart upload content + ${put_policy} = Set Variable {"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:PutObject","Resource":"arn:aws:s3:::${ICEBERG_BUCKET_OBS}/${key}"}]} + ${abort_policy} = Set Variable {"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:AbortMultipartUpload","Resource":"arn:aws:s3:::${ICEBERG_BUCKET_OBS}/${key}"}]} + + Assume Role And Configure STS Profile policy_json=${put_policy} perm_access_key_id=${PERMANENT_ACCESS_KEY_ID} perm_secret_key=${PERMANENT_SECRET_KEY} role_arn=${ICEBERG_ALL_ACCESS_ROLE_OBS_ARN} + ${output} = Execute aws s3api --endpoint-url ${S3G_ENDPOINT_URL} create-multipart-upload --bucket ${ICEBERG_BUCKET_OBS} --key ${key} --profile sts + ${upload_id} = Execute echo '${output}' | jq -r '.UploadId' + ${output} = Execute aws s3api --endpoint-url ${S3G_ENDPOINT_URL} upload-part --bucket ${ICEBERG_BUCKET_OBS} --key ${key} --part-number 1 --body ${local_path} --upload-id ${upload_id} --profile sts + Should Contain ${output} ETag + + # Negative: missing WRITE + Assume Role And Configure STS Profile policy_json=${abort_policy} perm_access_key_id=${PERMANENT_ACCESS_KEY_ID} perm_secret_key=${PERMANENT_SECRET_KEY} role_arn=${PARTIAL_BUCKET_READ_ROLE_ARN} + ${output} = Execute And Ignore Error aws s3api --endpoint-url ${S3G_ENDPOINT_URL} abort-multipart-upload --bucket ${ICEBERG_BUCKET_OBS} --key ${key} --upload-id ${upload_id} --profile sts + Should Contain ${output} AccessDenied + + # Positive control + Assume Role And Configure STS Profile policy_json=${abort_policy} perm_access_key_id=${PERMANENT_ACCESS_KEY_ID} perm_secret_key=${PERMANENT_SECRET_KEY} role_arn=${ICEBERG_ALL_ACCESS_ROLE_OBS_ARN} + ${output} = Execute aws s3api --endpoint-url ${S3G_ENDPOINT_URL} abort-multipart-upload --bucket ${ICEBERG_BUCKET_OBS} --key ${key} --upload-id ${upload_id} --profile sts + Should Not Contain ${output} AccessDenied + +STS session policy containing only GetObject must deny GetObjectTagging + ${session_policy} = Set Variable {"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:GetObject","Resource":"arn:aws:s3:::${ICEBERG_BUCKET_OBS}/${ICEBERG_BUCKET_TESTFILE}"}]} + Assume Role And Configure STS Profile policy_json=${session_policy} perm_access_key_id=${PERMANENT_ACCESS_KEY_ID} perm_secret_key=${PERMANENT_SECRET_KEY} role_arn=${ICEBERG_ALL_ACCESS_ROLE_OBS_ARN} + Get Object Should Succeed ${ICEBERG_BUCKET_OBS} ${ICEBERG_BUCKET_TESTFILE} + ${output} = Execute And Ignore Error aws s3api --endpoint-url ${S3G_ENDPOINT_URL} get-object-tagging --bucket ${ICEBERG_BUCKET_OBS} --key ${ICEBERG_BUCKET_TESTFILE} --profile sts + Should Contain ${output} AccessDenied + +STS session policy containing only PutObject must deny PutObjectTagging and DeleteObjectTagging + ${key_suffix} = Generate Random String 8 [LOWER] + ${key} = Set Variable sts-object-${key_suffix}.txt + ${local_path} = Set Variable ${TEMP_DIR}/${key} + Create File ${local_path} put-object-only policy content + ${put_only_policy} = Set Variable {"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:PutObject","Resource":"arn:aws:s3:::${ICEBERG_BUCKET_OBS}/${key}"}]} + + Assume Role And Configure STS Profile policy_json=${put_only_policy} perm_access_key_id=${PERMANENT_ACCESS_KEY_ID} perm_secret_key=${PERMANENT_SECRET_KEY} role_arn=${ICEBERG_ALL_ACCESS_ROLE_OBS_ARN} + Put Object Should Succeed ${ICEBERG_BUCKET_OBS} ${key} ${local_path} + + ${output} = Execute And Ignore Error aws s3api --endpoint-url ${S3G_ENDPOINT_URL} put-object-tagging --bucket ${ICEBERG_BUCKET_OBS} --key ${key} --tagging '{"TagSet":[{"Key":"tag-key1","Value":"tag-value1"}]}' --profile sts + Should Contain ${output} AccessDenied + ${output} = Execute And Ignore Error aws s3api --endpoint-url ${S3G_ENDPOINT_URL} delete-object-tagging --bucket ${ICEBERG_BUCKET_OBS} --key ${key} --profile sts + Should Contain ${output} AccessDenied + +STS session policy CopyObject must require source GetObject and destination PutObject + ${src_key} = Set Variable ${ICEBERG_BUCKET_TESTFILE} + ${key_suffix} = Generate Random String 8 [LOWER] + ${dest_key} = Set Variable sts-copy-dest-${key_suffix}.txt + + # Positive: GetObject on source + PutObject on destination should allow CopyObject + ${copy_allow_policy} = Set Variable {"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:GetObject","Resource":"arn:aws:s3:::${ICEBERG_BUCKET_OBS}/${src_key}"},{"Effect":"Allow","Action":"s3:PutObject","Resource":"arn:aws:s3:::${ICEBERG_BUCKET_OBS}/${dest_key}"}]} + Assume Role And Configure STS Profile policy_json=${copy_allow_policy} perm_access_key_id=${PERMANENT_ACCESS_KEY_ID} perm_secret_key=${PERMANENT_SECRET_KEY} role_arn=${ICEBERG_ALL_ACCESS_ROLE_OBS_ARN} + ${output} = Execute aws s3api --endpoint-url ${S3G_ENDPOINT_URL} copy-object --bucket ${ICEBERG_BUCKET_OBS} --key ${dest_key} --copy-source ${ICEBERG_BUCKET_OBS}/${src_key} --profile sts + Should Contain ${output} CopyObjectResult + + # Negative A: missing source GetObject should deny CopyObject even with destination PutObject + ${copy_missing_source_get} = Set Variable {"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:PutObject","Resource":"arn:aws:s3:::${ICEBERG_BUCKET_OBS}/${dest_key}"}]} + Assume Role And Configure STS Profile policy_json=${copy_missing_source_get} perm_access_key_id=${PERMANENT_ACCESS_KEY_ID} perm_secret_key=${PERMANENT_SECRET_KEY} role_arn=${ICEBERG_ALL_ACCESS_ROLE_OBS_ARN} + ${output} = Execute And Ignore Error aws s3api --endpoint-url ${S3G_ENDPOINT_URL} copy-object --bucket ${ICEBERG_BUCKET_OBS} --key ${dest_key} --copy-source ${ICEBERG_BUCKET_OBS}/${src_key} --profile sts + Should Contain ${output} AccessDenied + + # Negative B: missing destination PutObject should deny CopyObject even with source GetObject + ${copy_missing_dest_put} = Set Variable {"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:GetObject","Resource":"arn:aws:s3:::${ICEBERG_BUCKET_OBS}/${src_key}"}]} + Assume Role And Configure STS Profile policy_json=${copy_missing_dest_put} perm_access_key_id=${PERMANENT_ACCESS_KEY_ID} perm_secret_key=${PERMANENT_SECRET_KEY} role_arn=${ICEBERG_ALL_ACCESS_ROLE_OBS_ARN} + ${output} = Execute And Ignore Error aws s3api --endpoint-url ${S3G_ENDPOINT_URL} copy-object --bucket ${ICEBERG_BUCKET_OBS} --key ${dest_key} --copy-source ${ICEBERG_BUCKET_OBS}/${src_key} --profile sts + Should Contain ${output} AccessDenied + +STS session policy UploadPartCopy must require source GetObject and destination PutObject + ${src_key} = Set Variable ${ICEBERG_BUCKET_TESTFILE} + ${key_suffix} = Generate Random String 8 [LOWER] + ${dest_key} = Set Variable sts-mpu-copy-${key_suffix}.txt + + # Positive: GetObject on source + PutObject on destination should allow UploadPartCopy + ${allow_policy} = Set Variable {"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:GetObject","Resource":"arn:aws:s3:::${ICEBERG_BUCKET_OBS}/${src_key}"},{"Effect":"Allow","Action":"s3:PutObject","Resource":"arn:aws:s3:::${ICEBERG_BUCKET_OBS}/${dest_key}"}]} + Assume Role And Configure STS Profile policy_json=${allow_policy} perm_access_key_id=${PERMANENT_ACCESS_KEY_ID} perm_secret_key=${PERMANENT_SECRET_KEY} role_arn=${ICEBERG_ALL_ACCESS_ROLE_OBS_ARN} + ${output} = Execute aws s3api --endpoint-url ${S3G_ENDPOINT_URL} create-multipart-upload --bucket ${ICEBERG_BUCKET_OBS} --key ${dest_key} --profile sts + ${upload_id} = Execute echo '${output}' | jq -r '.UploadId' + Should Not Be Empty ${upload_id} + ${output} = Execute aws s3api --endpoint-url ${S3G_ENDPOINT_URL} upload-part-copy --bucket ${ICEBERG_BUCKET_OBS} --key ${dest_key} --part-number 1 --upload-id ${upload_id} --copy-source ${ICEBERG_BUCKET_OBS}/${src_key} --profile sts + Should Contain ${output} CopyPartResult + + # Negative A: missing source GetObject should deny UploadPartCopy even with destination PutObject + ${missing_source_get} = Set Variable {"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:PutObject","Resource":"arn:aws:s3:::${ICEBERG_BUCKET_OBS}/${dest_key}"}]} + Assume Role And Configure STS Profile policy_json=${missing_source_get} perm_access_key_id=${PERMANENT_ACCESS_KEY_ID} perm_secret_key=${PERMANENT_SECRET_KEY} role_arn=${ICEBERG_ALL_ACCESS_ROLE_OBS_ARN} + ${output} = Execute And Ignore Error aws s3api --endpoint-url ${S3G_ENDPOINT_URL} create-multipart-upload --bucket ${ICEBERG_BUCKET_OBS} --key ${dest_key} --profile sts + ${upload_id} = Execute echo '${output}' | jq -r '.UploadId' + Should Not Be Empty ${upload_id} + ${output} = Execute And Ignore Error aws s3api --endpoint-url ${S3G_ENDPOINT_URL} upload-part-copy --bucket ${ICEBERG_BUCKET_OBS} --key ${dest_key} --part-number 1 --upload-id ${upload_id} --copy-source ${ICEBERG_BUCKET_OBS}/${src_key} --profile sts + Should Contain ${output} AccessDenied + + # Negative B: missing destination PutObject should deny UploadPartCopy even with source GetObject + # Create MPU under PutObject so we can get an upload ID first. + ${put_only_policy} = Set Variable {"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:PutObject","Resource":"arn:aws:s3:::${ICEBERG_BUCKET_OBS}/${dest_key}"}]} + Assume Role And Configure STS Profile policy_json=${put_only_policy} perm_access_key_id=${PERMANENT_ACCESS_KEY_ID} perm_secret_key=${PERMANENT_SECRET_KEY} role_arn=${ICEBERG_ALL_ACCESS_ROLE_OBS_ARN} + ${output} = Execute aws s3api --endpoint-url ${S3G_ENDPOINT_URL} create-multipart-upload --bucket ${ICEBERG_BUCKET_OBS} --key ${dest_key} --profile sts + ${upload_id} = Execute echo '${output}' | jq -r '.UploadId' + Should Not Be Empty ${upload_id} + # Switch to GetObject-only policy for the actual UploadPartCopy call. + ${missing_dest_put} = Set Variable {"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:GetObject","Resource":"arn:aws:s3:::${ICEBERG_BUCKET_OBS}/${src_key}"}]} + Assume Role And Configure STS Profile policy_json=${missing_dest_put} perm_access_key_id=${PERMANENT_ACCESS_KEY_ID} perm_secret_key=${PERMANENT_SECRET_KEY} role_arn=${ICEBERG_ALL_ACCESS_ROLE_OBS_ARN} + ${output} = Execute And Ignore Error aws s3api --endpoint-url ${S3G_ENDPOINT_URL} upload-part-copy --bucket ${ICEBERG_BUCKET_OBS} --key ${dest_key} --part-number 1 --upload-id ${upload_id} --copy-source ${ICEBERG_BUCKET_OBS}/${src_key} --profile sts + Should Contain ${output} AccessDenied + +Ranger action-matches UploadPartCopy expected-owner should allow + ${src_bucket} = Set Variable ${ICEBERG_BUCKET_OBS} + ${dest_bucket} = Set Variable ${ICEBERG_BUCKET_OBS} + ${src_key} = Set Variable ${ICEBERG_BUCKET_TESTFILE} + ${key_suffix} = Generate Random String 8 [LOWER] + ${dest_key} = Set Variable sts-mpu-expected-owner-${key_suffix}.txt + + # Discover source/destination bucket owners (needed to avoid BucketOwnerMismatch). + ${acl_policy} = Set Variable {"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:GetBucketAcl","Resource":"arn:aws:s3:::${dest_bucket}"}]} + Assume Role And Configure STS Profile policy_json=${acl_policy} perm_access_key_id=${PERMANENT_ACCESS_KEY_ID} perm_secret_key=${PERMANENT_SECRET_KEY} role_arn=${ICEBERG_ALL_ACCESS_ROLE_OBS_ARN} + ${src_acl} = Execute aws s3api --endpoint-url ${S3G_ENDPOINT_URL} get-bucket-acl --bucket ${src_bucket} --output json --profile sts + ${dst_acl} = Execute aws s3api --endpoint-url ${S3G_ENDPOINT_URL} get-bucket-acl --bucket ${dest_bucket} --output json --profile sts + ${src_owner} = Execute echo '${src_acl}' | jq -r '.Owner.DisplayName // .Owner.ID' + ${dst_owner} = Execute echo '${dst_acl}' | jq -r '.Owner.DisplayName // .Owner.ID' + + ${allow_policy} = Set Variable {"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:GetObject","Resource":"arn:aws:s3:::${src_bucket}/${src_key}"},{"Effect":"Allow","Action":"s3:PutObject","Resource":"arn:aws:s3:::${dest_bucket}/${dest_key}"}]} + Assume Role And Configure STS Profile policy_json=${allow_policy} perm_access_key_id=${PERMANENT_ACCESS_KEY_ID} perm_secret_key=${PERMANENT_SECRET_KEY} role_arn=${ACTION_MATCHES_UPLOADPARTCOPY_EXPECTED_OWNER_ROLE_ARN} + + ${output} = Execute aws s3api --endpoint-url ${S3G_ENDPOINT_URL} create-multipart-upload --bucket ${dest_bucket} --key ${dest_key} --profile sts + ${upload_id} = Execute echo '${output}' | jq -r '.UploadId' + Should Not Be Empty ${upload_id} + + ${output} = Execute aws s3api --endpoint-url ${S3G_ENDPOINT_URL} upload-part-copy --bucket ${dest_bucket} --key ${dest_key} --part-number 1 --upload-id ${upload_id} --copy-source ${src_bucket}/${src_key} --expected-source-bucket-owner ${src_owner} --expected-bucket-owner ${dst_owner} --profile sts + Should Contain ${output} CopyPartResult + +Ranger action-matches CopyObject expected-owner should allow + ${src_bucket} = Set Variable ${ICEBERG_BUCKET_OBS} + ${dest_bucket} = Set Variable ${ICEBERG_BUCKET_OBS} + ${src_key} = Set Variable ${ICEBERG_BUCKET_TESTFILE} + ${key_suffix} = Generate Random String 8 [LOWER] + ${dest_key} = Set Variable sts-copy-expected-owner-${key_suffix}.txt + + # Discover source/destination bucket owners (needed to avoid BucketOwnerMismatch). + ${acl_policy} = Set Variable {"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:GetBucketAcl","Resource":"arn:aws:s3:::${dest_bucket}"}]} + Assume Role And Configure STS Profile policy_json=${acl_policy} perm_access_key_id=${PERMANENT_ACCESS_KEY_ID} perm_secret_key=${PERMANENT_SECRET_KEY} role_arn=${ICEBERG_ALL_ACCESS_ROLE_OBS_ARN} + ${src_acl} = Execute aws s3api --endpoint-url ${S3G_ENDPOINT_URL} get-bucket-acl --bucket ${src_bucket} --output json --profile sts + ${dst_acl} = Execute aws s3api --endpoint-url ${S3G_ENDPOINT_URL} get-bucket-acl --bucket ${dest_bucket} --output json --profile sts + ${src_owner} = Execute echo '${src_acl}' | jq -r '.Owner.DisplayName // .Owner.ID' + ${dst_owner} = Execute echo '${dst_acl}' | jq -r '.Owner.DisplayName // .Owner.ID' + + ${allow_policy} = Set Variable {"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:GetObject","Resource":"arn:aws:s3:::${src_bucket}/${src_key}"},{"Effect":"Allow","Action":"s3:PutObject","Resource":"arn:aws:s3:::${dest_bucket}/${dest_key}"}]} + Assume Role And Configure STS Profile policy_json=${allow_policy} perm_access_key_id=${PERMANENT_ACCESS_KEY_ID} perm_secret_key=${PERMANENT_SECRET_KEY} role_arn=${ACTION_MATCHES_UPLOADPARTCOPY_EXPECTED_OWNER_ROLE_ARN} + + ${output} = Execute aws s3api --endpoint-url ${S3G_ENDPOINT_URL} copy-object --bucket ${dest_bucket} --key ${dest_key} --copy-source ${src_bucket}/${src_key} --expected-source-bucket-owner ${src_owner} --expected-bucket-owner ${dst_owner} --profile sts + Should Contain ${output} CopyObjectResult + +STS session policy s3:* on bucket resource must allow bucket APIs but deny ListAllMyBuckets and object APIs + ${bucket_star_policy} = Set Variable {"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:*","Resource":"arn:aws:s3:::${ICEBERG_BUCKET_OBS}"}]} + Assume Role And Configure STS Profile policy_json=${bucket_star_policy} perm_access_key_id=${PERMANENT_ACCESS_KEY_ID} perm_secret_key=${PERMANENT_SECRET_KEY} role_arn=${ICEBERG_ALL_ACCESS_ROLE_OBS_ARN} + + # Bucket APIs should work + List Object Keys Should Succeed ${ICEBERG_BUCKET_OBS} + + ${output} = Execute aws s3api --endpoint-url ${S3G_ENDPOINT_URL} get-bucket-acl --bucket ${ICEBERG_BUCKET_OBS} --profile sts + Should Contain ${output} Owner + + # ListAllMyBuckets should NOT work (needs Resource="*" or Resource="arn:aws:s3:::*") + ${output} = Execute And Ignore Error aws s3api --endpoint-url ${S3G_ENDPOINT_URL} list-buckets --profile sts + Should Contain ${output} AccessDenied + + # Object APIs should NOT work + Get Object Should Fail ${ICEBERG_BUCKET_OBS} ${ICEBERG_BUCKET_TESTFILE} AccessDenied + + ${key_suffix} = Generate Random String 8 [LOWER] + ${key} = Set Variable sts-bucket-star-${key_suffix}.txt + ${local_path} = Set Variable ${TEMP_DIR}/${key} + Create File ${local_path} bucket-star policy should deny PutObject + Put Object Should Fail ${ICEBERG_BUCKET_OBS} ${key} AccessDenied ${local_path} + +STS session policy s3:* on object resource must allow object APIs but deny ListAllMyBuckets and bucket APIs + ${key_suffix} = Generate Random String 8 [LOWER] + ${local_path} = Set Variable ${TEMP_DIR}/object-star-${key_suffix}.txt + Create File ${local_path} object-star policy content + ${object_star_policy} = Set Variable {"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:*","Resource":"arn:aws:s3:::${ICEBERG_BUCKET_OBS}/${ICEBERG_BUCKET_TESTFILE}"}]} + Assume Role And Configure STS Profile policy_json=${object_star_policy} perm_access_key_id=${PERMANENT_ACCESS_KEY_ID} perm_secret_key=${PERMANENT_SECRET_KEY} role_arn=${ICEBERG_ALL_ACCESS_ROLE_OBS_ARN} + + # Object APIs should work (on that single object ARN) + Get Object Should Succeed ${ICEBERG_BUCKET_OBS} ${ICEBERG_BUCKET_TESTFILE} + Put Object Should Succeed ${ICEBERG_BUCKET_OBS} ${ICEBERG_BUCKET_TESTFILE} ${local_path} + Get Object Should Fail ${ICEBERG_BUCKET_OBS} file1again.txt AccessDenied + + # ListAllMyBuckets should NOT work (needs Resource="*" or Resource="arn:aws:s3:::*") + ${output} = Execute And Ignore Error aws s3api --endpoint-url ${S3G_ENDPOINT_URL} list-buckets --profile sts + Should Contain ${output} AccessDenied + + # Bucket APIs should NOT work + List Object Keys Should Fail ${ICEBERG_BUCKET_OBS} list-objects AccessDenied + + ${output} = Execute And Ignore Error aws s3api --endpoint-url ${S3G_ENDPOINT_URL} get-bucket-acl --bucket ${ICEBERG_BUCKET_OBS} --profile sts + Should Contain ${output} AccessDenied + +STS session policy s3:* on * must allow ListAllMyBuckets, Create/ListBucket, and GetObject/PutObject + ${bucket_suffix} = Generate Random String 8 [LOWER] + ${bucket} = Set Variable sts-bucket-${bucket_suffix} + ${key_suffix} = Generate Random String 8 [LOWER] + ${key} = Set Variable sts-object-${key_suffix}.txt + ${local_path} = Set Variable ${TEMP_DIR}/${key} + ${download_path} = Set Variable ${TEMP_DIR}/${key}.download + Create File ${local_path} star-star policy content + + ${star_policy} = Set Variable {"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:*","Resource":"*"}]} + Assume Role And Configure STS Profile policy_json=${star_policy} perm_access_key_id=${PERMANENT_ACCESS_KEY_ID} perm_secret_key=${PERMANENT_SECRET_KEY} role_arn=${STS_TEMP_BUCKET_ROLE_ARN} + + # ListAllMyBuckets should work + ${output} = Execute aws s3api --endpoint-url ${S3G_ENDPOINT_URL} list-buckets --profile sts + Should Contain ${output} ${ICEBERG_BUCKET_OBS} + + # Create/ListBucket should work + ${output} = Execute aws s3api --endpoint-url ${S3G_ENDPOINT_URL} create-bucket --bucket ${bucket} --profile sts + Should Contain ${output} Location + + List Object Keys Should Succeed ${bucket} + + # Object APIs should work + Put Object Should Succeed ${bucket} ${key} ${local_path} + Get Object Should Succeed ${bucket} ${key} ${download_path} + + # Cleanup + ${output} = Execute aws s3api --endpoint-url ${S3G_ENDPOINT_URL} delete-object --bucket ${bucket} --key ${key} --profile sts + Should Not Contain ${output} AccessDenied + ${output} = Execute aws s3api --endpoint-url ${S3G_ENDPOINT_URL} delete-bucket --bucket ${bucket} --profile sts + Should Not Contain ${output} AccessDenied + +STS session policy containing only GetObject must deny DeleteObjects + ${bucket_suffix} = Generate Random String 8 [LOWER] + ${bucket} = Set Variable sts-bucket-deleteobjects-${bucket_suffix} + ${key_suffix} = Generate Random String 8 [LOWER] + ${key} = Set Variable sts-deny-deleteobjects-${key_suffix}.txt + ${local_path} = Set Variable ${TEMP_DIR}/${key} + Create File ${local_path} deleteobjects deny test content + + # Create bucket and object with full STS temp-bucket role permissions. + Assume Role And Configure STS Profile perm_access_key_id=${PERMANENT_ACCESS_KEY_ID} perm_secret_key=${PERMANENT_SECRET_KEY} role_arn=${STS_TEMP_BUCKET_ROLE_ARN} + ${output} = Execute aws s3api --endpoint-url ${S3G_ENDPOINT_URL} create-bucket --bucket ${bucket} --profile sts + Should Contain ${output} Location + ${output} = Execute aws s3api --endpoint-url ${S3G_ENDPOINT_URL} put-object --bucket ${bucket} --key ${key} --body ${local_path} --profile sts + Should Contain ${output} "ETag" + + # Restrict token to GetObject-only via session policy. DeleteObjects must return AccessDenied. + ${session_policy} = Set Variable {"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:GetObject","Resource":"arn:aws:s3:::${bucket}/*"}]} + Assume Role And Configure STS Profile policy_json=${session_policy} perm_access_key_id=${PERMANENT_ACCESS_KEY_ID} perm_secret_key=${PERMANENT_SECRET_KEY} role_arn=${STS_TEMP_BUCKET_ROLE_ARN} + ${output} = Execute And Ignore Error aws s3api --endpoint-url ${S3G_ENDPOINT_URL} delete-objects --bucket ${bucket} --delete 'Objects=[{Key=${key}}],Quiet=false' --profile sts + Run Keyword And Continue On Failure Should Contain ${output} AccessDenied + + # Cleanup using a full-permission token. + Assume Role And Configure STS Profile perm_access_key_id=${PERMANENT_ACCESS_KEY_ID} perm_secret_key=${PERMANENT_SECRET_KEY} role_arn=${STS_TEMP_BUCKET_ROLE_ARN} + ${output} = Execute aws s3api --endpoint-url ${S3G_ENDPOINT_URL} delete-object --bucket ${bucket} --key ${key} --profile sts + Should Not Contain ${output} AccessDenied + ${output} = Execute aws s3api --endpoint-url ${S3G_ENDPOINT_URL} delete-bucket --bucket ${bucket} --profile sts + Should Not Contain ${output} AccessDenied + +STS Session Policy On Linked Bucket Grants GetObject On Source Bucket + # Same-volume linked buckets (e.g. s3v/s3v-iceberg -> s3v/iceberg): session policy scoped to the linked + # bucket must also authorize ListBucket and GetObject on the source bucket. + Kinit test user ${ICEBERG_SVC_CATALOG_USER} ${ICEBERG_SVC_CATALOG_USER}.keytab + ${session_policy} = Set Variable {"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":["s3:ListBucket","s3:GetObject"],"Resource":["arn:aws:s3:::${STS_LINK_BUCKET_LINKED}","arn:aws:s3:::${STS_LINK_BUCKET_LINKED}/*"]}]} + Assume Role And Configure STS Profile policy_json=${session_policy} perm_access_key_id=${PERMANENT_ACCESS_KEY_ID} perm_secret_key=${PERMANENT_SECRET_KEY} role_arn=${STS_LINK_BUCKET_ROLE_ARN} + Head Bucket Should Succeed ${STS_LINK_BUCKET_LINKED} + Get Object Should Succeed ${STS_LINK_BUCKET_LINKED} ${STS_LINK_BUCKET_TESTFILE} + +STS Role On Linked Bucket Grants GetObject On Source Bucket + # Same-volume linked buckets: role permissions on the linked bucket must authorize + # key access on the source bucket when no inline session policy is supplied. + Kinit test user ${ICEBERG_SVC_CATALOG_USER} ${ICEBERG_SVC_CATALOG_USER}.keytab + Assume Role And Configure STS Profile perm_access_key_id=${PERMANENT_ACCESS_KEY_ID} perm_secret_key=${PERMANENT_SECRET_KEY} role_arn=${STS_LINK_BUCKET_ROLE_ARN} + Head Bucket Should Succeed ${STS_LINK_BUCKET_LINKED} + Get Object Should Succeed ${STS_LINK_BUCKET_LINKED} ${STS_LINK_BUCKET_TESTFILE} + +STS Session Policy On Chained Linked Bucket Grants GetObject On Source Bucket + # Chained linked buckets (source <- intermediate <- final): session policy scoped to the final + # linked bucket must also authorize ListBucket and GetObject on the source bucket. + Kinit test user ${ICEBERG_SVC_CATALOG_USER} ${ICEBERG_SVC_CATALOG_USER}.keytab + ${session_policy} = Set Variable {"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":["s3:ListBucket","s3:GetObject"],"Resource":["arn:aws:s3:::${STS_LINK_BUCKET_CHAIN_FINAL}","arn:aws:s3:::${STS_LINK_BUCKET_CHAIN_FINAL}/*"]}]} + Assume Role And Configure STS Profile policy_json=${session_policy} perm_access_key_id=${PERMANENT_ACCESS_KEY_ID} perm_secret_key=${PERMANENT_SECRET_KEY} role_arn=${STS_LINK_BUCKET_ROLE_ARN} + Head Bucket Should Succeed ${STS_LINK_BUCKET_CHAIN_FINAL} + Get Object Should Succeed ${STS_LINK_BUCKET_CHAIN_FINAL} ${STS_LINK_BUCKET_CHAIN_TESTFILE} + +STS Role On Chained Linked Bucket Grants GetObject On Source Bucket + # Chained linked buckets: role permissions on the final linked bucket must authorize + # key access on the source bucket when no inline session policy is supplied. + Kinit test user ${ICEBERG_SVC_CATALOG_USER} ${ICEBERG_SVC_CATALOG_USER}.keytab + Assume Role And Configure STS Profile perm_access_key_id=${PERMANENT_ACCESS_KEY_ID} perm_secret_key=${PERMANENT_SECRET_KEY} role_arn=${STS_LINK_BUCKET_ROLE_ARN} + Head Bucket Should Succeed ${STS_LINK_BUCKET_CHAIN_FINAL} + Get Object Should Succeed ${STS_LINK_BUCKET_CHAIN_FINAL} ${STS_LINK_BUCKET_CHAIN_TESTFILE} + +Get Caller Identity With Permanent Credentials Should Succeed + Configure AWS Profile permanent ${PERMANENT_ACCESS_KEY_ID} ${PERMANENT_SECRET_KEY} + ${json} ${account} ${arn} ${userId} = Get Caller Identity permanent + Should Be Equal ${account} 123456789012 + ${principal} = Execute klist | awk '/Default principal/ {print $3}' + Should Be Equal ${userId} ${principal} + Should Be Equal ${arn} arn:aws:iam::123456789012:user/${ICEBERG_SVC_CATALOG_USER} + +Get Caller Identity With STS Credentials Should Succeed + Assume Role And Configure STS Profile perm_access_key_id=${PERMANENT_ACCESS_KEY_ID} perm_secret_key=${PERMANENT_SECRET_KEY} role_arn=${ICEBERG_ALL_ACCESS_ROLE_OBS_ARN} + ${json} ${account} ${arn} ${userId} = Get Caller Identity sts + Should Be Equal ${account} 123456789012 + Should Be Equal ${userId} ${STS_ASSUMED_ROLE_ID} + Should Be Equal ${arn} ${STS_ASSUMED_ROLE_USER_ARN} + +Get Caller Identity Ignores Extra Parameters + # curl 7.76.1 produces an invalid SigV4 signature for STS GET requests with --get --data-urlencode. + ${extra_curl_params} = Set Variable --data-urlencode "RoleArn=${ICEBERG_ALL_ACCESS_ROLE_OBS_ARN}" --data-urlencode "RoleSessionName=${ROLE_SESSION_NAME}" --data-urlencode "DurationSeconds=3600" + ${output} = Get Caller Identity Using Curl ${PERMANENT_ACCESS_KEY_ID} ${PERMANENT_SECRET_KEY} extra_curl_params=${extra_curl_params} + Should Contain ${output} 123456789012 + @{http_codes} = Get Regexp Matches ${output} (?m)^HTTP/[0-9.]+ ([0-9]{3}) 1 + ${code_count} = Get Length ${http_codes} + Should Be True ${code_count} > 0 Expected to find an HTTP status code in curl output, but none was found. + ${http_code} = Get From List ${http_codes} -1 + Should Be Equal As Strings ${http_code} 200 + +Get Caller Identity Rejects Missing Version + Get Caller Identity Should Fail InvalidAction + +Get Caller Identity Rejects Invalid Version + Get Caller Identity Should Fail InvalidAction api_version=2020-01-01 + +Expired STS temporary credentials must return ExpiredToken on S3 APIs + # Increase timeout to account for 15 minute STS token expiration plus the time to execute the api calls + [Timeout] 25 minutes + ${dummy_mpu_upload_id} = Set Variable dummyExpiredStsMpuUploadId01 + + Wait Until Expired STS Token Expiration Elapsed + Configure Expired STS Token S3 Profile + + Execute S3api Expect Expired Token list-buckets --output json + Execute S3api Expect Expired Token Head Operation head-bucket --bucket ${ICEBERG_BUCKET_OBS} HeadBucket + Execute S3api Expect Expired Token list-objects-v2 --bucket ${ICEBERG_BUCKET_OBS} --output json + Execute S3api Expect Expired Token list-objects --bucket ${ICEBERG_BUCKET_OBS} --output json + Execute S3api Expect Expired Token get-object --bucket ${ICEBERG_BUCKET_OBS} --key ${ICEBERG_BUCKET_TESTFILE} ${TEMP_DIR}/expired-sts-token-get-object.out + + Create File ${TEMP_DIR}/expired-sts-token-put-object-body.txt expired sts token put body + + Execute S3api Expect Expired Token put-object --bucket ${ICEBERG_BUCKET_OBS} --key sts-expired-sts-token-put.txt --body ${TEMP_DIR}/expired-sts-token-put-object-body.txt + Execute S3api Expect Expired Token Head Operation head-object --bucket ${ICEBERG_BUCKET_OBS} --key ${ICEBERG_BUCKET_TESTFILE} HeadObject + Execute S3api Expect Expired Token delete-object --bucket ${ICEBERG_BUCKET_OBS} --key sts-expired-sts-token-delete-marker.txt + Execute S3api Expect Expired Token delete-objects --bucket ${ICEBERG_BUCKET_OBS} --delete 'Objects=[{Key=sts-expired-sts-token-delete-objects-marker.txt}],Quiet=false' + + ${bucket_suffix} = Generate Random String 8 [LOWER] + ${exp_bucket} = Set Variable sts-bucket-expired-sts-token-${bucket_suffix} + + Execute S3api Expect Expired Token create-bucket --bucket ${exp_bucket} + Execute S3api Expect Expired Token delete-bucket --bucket ${exp_bucket} + Execute S3api Expect Expired Token get-bucket-acl --bucket ${ICEBERG_BUCKET_OBS} + Execute S3api Expect Expired Token put-bucket-acl --bucket ${ICEBERG_BUCKET_OBS} --grant-read '' + Execute S3api Expect Expired Token list-multipart-uploads --bucket ${ICEBERG_BUCKET_OBS} + Execute S3api Expect Expired Token create-multipart-upload --bucket ${ICEBERG_BUCKET_OBS} --key sts-expired-sts-token-mpu.txt + Execute S3api Expect Expired Token upload-part --bucket ${ICEBERG_BUCKET_OBS} --key sts-expired-sts-token-mpu.txt --part-number 1 --body ${TEMP_DIR}/expired-sts-token-put-object-body.txt --upload-id ${dummy_mpu_upload_id} + Execute S3api Expect Expired Token upload-part-copy --bucket ${ICEBERG_BUCKET_OBS} --key sts-expired-sts-token-mpu-copy.txt --part-number 1 --upload-id ${dummy_mpu_upload_id} --copy-source ${ICEBERG_BUCKET_OBS}/${ICEBERG_BUCKET_TESTFILE} + Execute S3api Expect Expired Token list-parts --bucket ${ICEBERG_BUCKET_OBS} --key sts-expired-sts-token-mpu.txt --upload-id ${dummy_mpu_upload_id} + Execute S3api Expect Expired Token abort-multipart-upload --bucket ${ICEBERG_BUCKET_OBS} --key sts-expired-sts-token-mpu.txt --upload-id ${dummy_mpu_upload_id} + Execute S3api Expect Expired Token complete-multipart-upload --bucket ${ICEBERG_BUCKET_OBS} --key sts-expired-sts-token-mpu.txt --upload-id ${dummy_mpu_upload_id} --multipart-upload '{"Parts":[{"ETag":"d41d8cd98f00b204e9800998ecf8427e","PartNumber":1}]}' + Execute S3api Expect Expired Token copy-object --bucket ${ICEBERG_BUCKET_OBS} --copy-source ${ICEBERG_BUCKET_OBS}/${ICEBERG_BUCKET_TESTFILE} --key sts-expired-sts-token-copy-dest.txt + Execute S3api Expect Expired Token get-object-tagging --bucket ${ICEBERG_BUCKET_OBS} --key ${ICEBERG_BUCKET_TESTFILE} + Execute S3api Expect Expired Token put-object-tagging --bucket ${ICEBERG_BUCKET_OBS} --key ${ICEBERG_BUCKET_TESTFILE} --tagging '{"TagSet":[{"Key":"tag-key-expired-sts-token","Value":"tag-value-expired-sts-token"}]}' + Execute S3api Expect Expired Token delete-object-tagging --bucket ${ICEBERG_BUCKET_OBS} --key ${ICEBERG_BUCKET_TESTFILE} + +Revoking Permanent User Must Revoke Existing Session Token + # Create session tokens for both buckets, verify they work, then revoke permanent user secret and verify both fail. + Assume Role And Get Temporary Credentials perm_access_key_id=${PERMANENT_ACCESS_KEY_ID} perm_secret_key=${PERMANENT_SECRET_KEY} role_arn=${ICEBERG_ALL_ACCESS_ROLE_OBS_ARN} + Set Test Variable ${OBS_STS_ACCESS_KEY_ID} ${STS_ACCESS_KEY_ID} + Set Test Variable ${OBS_STS_SECRET_KEY} ${STS_SECRET_KEY} + Set Test Variable ${OBS_STS_SESSION_TOKEN} ${STS_SESSION_TOKEN} + Configure STS Profile ${OBS_STS_ACCESS_KEY_ID} ${OBS_STS_SECRET_KEY} ${OBS_STS_SESSION_TOKEN} + Get Object Should Succeed ${ICEBERG_BUCKET_OBS} ${ICEBERG_BUCKET_TESTFILE} + + Assume Role And Get Temporary Credentials perm_access_key_id=${PERMANENT_ACCESS_KEY_ID} perm_secret_key=${PERMANENT_SECRET_KEY} role_arn=${ICEBERG_ALL_ACCESS_ROLE_FSO_ARN} + Set Test Variable ${FSO_STS_ACCESS_KEY_ID} ${STS_ACCESS_KEY_ID} + Set Test Variable ${FSO_STS_SECRET_KEY} ${STS_SECRET_KEY} + Set Test Variable ${FSO_STS_SESSION_TOKEN} ${STS_SESSION_TOKEN} + Configure STS Profile ${FSO_STS_ACCESS_KEY_ID} ${FSO_STS_SECRET_KEY} ${FSO_STS_SESSION_TOKEN} + Get Object Should Succeed ${ICEBERG_BUCKET_FSO} ${ICEBERG_BUCKET_TESTFILE} + + # Log in again as user who owns the ${PERMANENT_ACCESS_KEY_ID} so we can issue revokesecret command. + Kinit test user ${ICEBERG_SVC_CATALOG_USER} ${ICEBERG_SVC_CATALOG_USER}.keytab + Execute ozone s3 revokesecret -y -u ${PERMANENT_ACCESS_KEY_ID} ${OM_HA_PARAM} + + # Session tokens must no longer work. + Configure STS Profile ${OBS_STS_ACCESS_KEY_ID} ${OBS_STS_SECRET_KEY} ${OBS_STS_SESSION_TOKEN} + Get Object Should Fail ${ICEBERG_BUCKET_OBS} ${ICEBERG_BUCKET_TESTFILE} AccessDenied + Configure STS Profile ${FSO_STS_ACCESS_KEY_ID} ${FSO_STS_SECRET_KEY} ${FSO_STS_SESSION_TOKEN} + Get Object Should Fail ${ICEBERG_BUCKET_FSO} ${ICEBERG_BUCKET_TESTFILE} AccessDenied diff --git a/hadoop-ozone/integration-test-s3/src/test/java/org/apache/hadoop/ozone/s3/awssdk/S3SDKTestUtils.java b/hadoop-ozone/integration-test-s3/src/test/java/org/apache/hadoop/ozone/s3/awssdk/S3SDKTestUtils.java index 2b3375052cec..5776b8b57763 100644 --- a/hadoop-ozone/integration-test-s3/src/test/java/org/apache/hadoop/ozone/s3/awssdk/S3SDKTestUtils.java +++ b/hadoop-ozone/integration-test-s3/src/test/java/org/apache/hadoop/ozone/s3/awssdk/S3SDKTestUtils.java @@ -38,6 +38,10 @@ import java.util.stream.Collectors; import org.apache.commons.io.IOUtils; import org.apache.commons.lang3.RandomUtils; +import org.apache.hadoop.ozone.MiniOzoneCluster; +import org.apache.hadoop.ozone.client.OzoneBucket; +import org.apache.hadoop.ozone.client.OzoneClient; +import org.apache.hadoop.ozone.client.OzoneMultipartUploadPartListParts; import org.apache.ozone.test.InputSubstream; /** @@ -54,6 +58,8 @@ public final class S3SDKTestUtils { public static final Pattern UPLOAD_ID_PATTERN = Pattern.compile("(.+?)"); + private static final int DEFAULT_LIST_PARTS_MAX = 100; + /** * One page of a paginated ListBuckets response. */ @@ -111,6 +117,33 @@ public static List filterToExpectedBuckets( private S3SDKTestUtils() { } + /** + * Returns the modification time of a key stored in OM, in epoch milliseconds. + */ + public static long getOmKeyModificationTime(MiniOzoneCluster cluster, String bucketName, String keyName) + throws IOException { + try (OzoneClient ozoneClient = cluster.newClient()) { + final OzoneBucket bucket = ozoneClient.getObjectStore().getS3Volume().getBucket(bucketName); + return bucket.getKey(keyName).getModificationTime().toEpochMilli(); + } + } + + /** + * Returns the modification time of a multipart upload part stored in OM, in epoch milliseconds. + */ + public static long getOmPartModificationTime(MiniOzoneCluster cluster, String bucketName, String keyName, + String uploadId, int partNumber) throws IOException { + try (OzoneClient ozoneClient = cluster.newClient()) { + final OzoneBucket bucket = ozoneClient.getObjectStore().getS3Volume().getBucket(bucketName); + final OzoneMultipartUploadPartListParts parts = bucket.listParts(keyName, uploadId, 0, DEFAULT_LIST_PARTS_MAX); + return parts.getPartInfoList().stream() + .filter(part -> part.getPartNumber() == partNumber) + .findFirst() + .orElseThrow(() -> new IllegalStateException("Part " + partNumber + " not found for upload " + uploadId)) + .getModificationTime(); + } + } + /** * Calculate the MD5 digest from an input stream from a specific offset and length. * @param inputStream The input stream where the digest will be read from. diff --git a/hadoop-ozone/integration-test-s3/src/test/java/org/apache/hadoop/ozone/s3/awssdk/package-info.java b/hadoop-ozone/integration-test-s3/src/test/java/org/apache/hadoop/ozone/s3/awssdk/package-info.java new file mode 100644 index 000000000000..3ecbb322cb40 --- /dev/null +++ b/hadoop-ozone/integration-test-s3/src/test/java/org/apache/hadoop/ozone/s3/awssdk/package-info.java @@ -0,0 +1,21 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * Shared utilities and integration tests for the AWS S3 SDK. + */ +package org.apache.hadoop.ozone.s3.awssdk; diff --git a/hadoop-ozone/integration-test-s3/src/test/java/org/apache/hadoop/ozone/s3/awssdk/v1/AbstractS3SDKV1Tests.java b/hadoop-ozone/integration-test-s3/src/test/java/org/apache/hadoop/ozone/s3/awssdk/v1/AbstractS3SDKV1Tests.java index 615c3bc8083b..42e8140dcb87 100644 --- a/hadoop-ozone/integration-test-s3/src/test/java/org/apache/hadoop/ozone/s3/awssdk/v1/AbstractS3SDKV1Tests.java +++ b/hadoop-ozone/integration-test-s3/src/test/java/org/apache/hadoop/ozone/s3/awssdk/v1/AbstractS3SDKV1Tests.java @@ -20,6 +20,8 @@ import static org.apache.hadoop.ozone.OzoneConsts.MB; import static org.apache.hadoop.ozone.s3.awssdk.S3SDKTestUtils.calculateDigest; import static org.apache.hadoop.ozone.s3.awssdk.S3SDKTestUtils.createFile; +import static org.apache.hadoop.ozone.s3.awssdk.S3SDKTestUtils.getOmKeyModificationTime; +import static org.apache.hadoop.ozone.s3.awssdk.S3SDKTestUtils.getOmPartModificationTime; import static org.apache.hadoop.ozone.s3.util.S3Consts.CUSTOM_METADATA_HEADER_PREFIX; import static org.apache.hadoop.ozone.s3.util.S3Utils.stripQuotes; import static org.assertj.core.api.Assertions.assertThat; @@ -47,6 +49,8 @@ import com.amazonaws.services.s3.model.CompleteMultipartUploadResult; import com.amazonaws.services.s3.model.CopyObjectRequest; import com.amazonaws.services.s3.model.CopyObjectResult; +import com.amazonaws.services.s3.model.CopyPartRequest; +import com.amazonaws.services.s3.model.CopyPartResult; import com.amazonaws.services.s3.model.CreateBucketRequest; import com.amazonaws.services.s3.model.DeleteBucketTaggingConfigurationRequest; import com.amazonaws.services.s3.model.GeneratePresignedUrlRequest; @@ -800,6 +804,69 @@ public void testCopyObject() { assertEquals("37b51d194a7513e45b56f6524f2d51f2", copyResult.getETag()); } + @Test + public void testCopyObjectLastModifiedMatchesOmDb() throws Exception { + final String sourceBucketName = getBucketName(); + final String destBucketName = getBucketName(); + final String sourceKey = getKeyName(); + final String destKey = getKeyName(); + final String content = "copy-last-modified-test-content"; + s3Client.createBucket(sourceBucketName); + s3Client.createBucket(destBucketName); + + final InputStream inputStream = new ByteArrayInputStream(content.getBytes(StandardCharsets.UTF_8)); + s3Client.putObject(sourceBucketName, sourceKey, inputStream, new ObjectMetadata()); + + final long sourceModificationTime = getOmKeyModificationTime(cluster, sourceBucketName, sourceKey); + + Thread.sleep(100); + + final CopyObjectResult copyResult = s3Client.copyObject(sourceBucketName, sourceKey, destBucketName, destKey); + + assertNotNull(copyResult.getLastModifiedDate()); + final long responseModificationTime = copyResult.getLastModifiedDate().getTime(); + final long destModificationTime = getOmKeyModificationTime(cluster, destBucketName, destKey); + + assertEquals(destModificationTime, responseModificationTime); + assertNotEquals(sourceModificationTime, responseModificationTime); + } + + @Test + public void testUploadPartCopyLastModifiedMatchesOmDb() throws Exception { + final String bucketName = getBucketName(); + final String sourceKey = getKeyName(); + final String destKey = getKeyName(); + final String content = "copy-last-modified-test-content"; + s3Client.createBucket(bucketName); + + final InputStream inputStream = new ByteArrayInputStream(content.getBytes(StandardCharsets.UTF_8)); + s3Client.putObject(bucketName, sourceKey, inputStream, new ObjectMetadata()); + + final long sourceModificationTime = getOmKeyModificationTime(cluster, bucketName, sourceKey); + + Thread.sleep(100); + + final InitiateMultipartUploadResult initResponse = s3Client.initiateMultipartUpload( + new InitiateMultipartUploadRequest(bucketName, destKey)); + final String uploadId = initResponse.getUploadId(); + + final CopyPartResult copyPartResult = s3Client.copyPart( + new CopyPartRequest() + .withSourceBucketName(bucketName) + .withSourceKey(sourceKey) + .withDestinationBucketName(bucketName) + .withDestinationKey(destKey) + .withUploadId(uploadId) + .withPartNumber(1)); + + assertNotNull(copyPartResult.getLastModifiedDate()); + final long responseModificationTime = copyPartResult.getLastModifiedDate().getTime(); + final long destPartModificationTime = getOmPartModificationTime(cluster, bucketName, destKey, uploadId, 1); + + assertEquals(destPartModificationTime, responseModificationTime); + assertNotEquals(sourceModificationTime, responseModificationTime); + } + @Test public void testCopyObjectWithSourceIfMatch() { final String sourceBucketName = getBucketName("source"); diff --git a/hadoop-ozone/integration-test-s3/src/test/java/org/apache/hadoop/ozone/s3/awssdk/v2/AbstractS3SDKV2Tests.java b/hadoop-ozone/integration-test-s3/src/test/java/org/apache/hadoop/ozone/s3/awssdk/v2/AbstractS3SDKV2Tests.java index c9f7276da511..bfcf5b741bc3 100644 --- a/hadoop-ozone/integration-test-s3/src/test/java/org/apache/hadoop/ozone/s3/awssdk/v2/AbstractS3SDKV2Tests.java +++ b/hadoop-ozone/integration-test-s3/src/test/java/org/apache/hadoop/ozone/s3/awssdk/v2/AbstractS3SDKV2Tests.java @@ -20,6 +20,8 @@ import static org.apache.hadoop.ozone.OzoneConsts.MB; import static org.apache.hadoop.ozone.s3.awssdk.S3SDKTestUtils.calculateDigest; import static org.apache.hadoop.ozone.s3.awssdk.S3SDKTestUtils.createFile; +import static org.apache.hadoop.ozone.s3.awssdk.S3SDKTestUtils.getOmKeyModificationTime; +import static org.apache.hadoop.ozone.s3.awssdk.S3SDKTestUtils.getOmPartModificationTime; import static org.apache.hadoop.ozone.s3.util.S3Utils.stripQuotes; import static org.apache.http.HttpStatus.SC_BAD_REQUEST; import static org.apache.http.HttpStatus.SC_OK; @@ -176,6 +178,7 @@ import software.amazon.awssdk.services.s3.model.Tag; import software.amazon.awssdk.services.s3.model.Tagging; import software.amazon.awssdk.services.s3.model.UploadPartCopyRequest; +import software.amazon.awssdk.services.s3.model.UploadPartCopyResponse; import software.amazon.awssdk.services.s3.model.UploadPartRequest; import software.amazon.awssdk.services.s3.model.UploadPartResponse; import software.amazon.awssdk.services.s3.presigner.S3Presigner; @@ -1328,6 +1331,73 @@ public void testCopyObject() { assertEquals("\"37b51d194a7513e45b56f6524f2d51f2\"", copyObjectResponse.copyObjectResult().eTag()); } + @Test + public void testCopyObjectLastModifiedMatchesOmDb() throws Exception { + final String sourceBucketName = getBucketName(); + final String destBucketName = getBucketName(); + final String sourceKey = getKeyName(); + final String destKey = getKeyName(); + final String content = "copy-last-modified-test-content"; + s3Client.createBucket(b -> b.bucket(sourceBucketName)); + s3Client.createBucket(b -> b.bucket(destBucketName)); + + s3Client.putObject(b -> b.bucket(sourceBucketName).key(sourceKey), RequestBody.fromString(content)); + + final long sourceModificationTime = getOmKeyModificationTime(cluster, sourceBucketName, sourceKey); + + Thread.sleep(100); + + final CopyObjectResponse copyObjectResponse = s3Client.copyObject( + CopyObjectRequest.builder() + .sourceBucket(sourceBucketName) + .sourceKey(sourceKey) + .destinationBucket(destBucketName) + .destinationKey(destKey) + .build()); + + assertNotNull(copyObjectResponse.copyObjectResult().lastModified()); + final long responseModificationTime = copyObjectResponse.copyObjectResult().lastModified().toEpochMilli(); + final long destModificationTime = getOmKeyModificationTime(cluster, destBucketName, destKey); + + assertEquals(destModificationTime, responseModificationTime); + assertNotEquals(sourceModificationTime, responseModificationTime); + } + + @Test + public void testUploadPartCopyLastModifiedMatchesOmDb() throws Exception { + final String bucketName = getBucketName(); + final String sourceKey = getKeyName(); + final String destKey = getKeyName(); + final String content = "copy-last-modified-test-content"; + s3Client.createBucket(b -> b.bucket(bucketName)); + + s3Client.putObject(b -> b.bucket(bucketName).key(sourceKey), RequestBody.fromString(content)); + + final long sourceModificationTime = getOmKeyModificationTime(cluster, bucketName, sourceKey); + + Thread.sleep(100); + + final CreateMultipartUploadResponse initResponse = s3Client.createMultipartUpload( + b -> b.bucket(bucketName).key(destKey)); + final String uploadId = initResponse.uploadId(); + + final UploadPartCopyResponse copyPartResponse = s3Client.uploadPartCopy( + b -> b + .sourceBucket(bucketName) + .sourceKey(sourceKey) + .destinationBucket(bucketName) + .destinationKey(destKey) + .uploadId(uploadId) + .partNumber(1)); + + assertNotNull(copyPartResponse.copyPartResult().lastModified()); + final long responseModificationTime = copyPartResponse.copyPartResult().lastModified().toEpochMilli(); + final long destPartModificationTime = getOmPartModificationTime(cluster, bucketName, destKey, uploadId, 1); + + assertEquals(destPartModificationTime, responseModificationTime); + assertNotEquals(sourceModificationTime, responseModificationTime); + } + @Test public void testCopyObjectToSelfWithMetadataReplace() { final String bucketName = getBucketName(); diff --git a/hadoop-ozone/integration-test-s3/src/test/java/org/apache/hadoop/ozone/s3/awssdk/v2/package-info.java b/hadoop-ozone/integration-test-s3/src/test/java/org/apache/hadoop/ozone/s3/awssdk/v2/package-info.java new file mode 100644 index 000000000000..9e921b645750 --- /dev/null +++ b/hadoop-ozone/integration-test-s3/src/test/java/org/apache/hadoop/ozone/s3/awssdk/v2/package-info.java @@ -0,0 +1,21 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * Integration tests for the AWS Java SDK v2 S3 client. + */ +package org.apache.hadoop.ozone.s3.awssdk.v2; diff --git a/hadoop-ozone/integration-test/src/test/java/org/apache/hadoop/ozone/TestOzoneConfigurationFields.java b/hadoop-ozone/integration-test/src/test/java/org/apache/hadoop/ozone/TestOzoneConfigurationFields.java index dacaa417564d..418c62a1d12e 100644 --- a/hadoop-ozone/integration-test/src/test/java/org/apache/hadoop/ozone/TestOzoneConfigurationFields.java +++ b/hadoop-ozone/integration-test/src/test/java/org/apache/hadoop/ozone/TestOzoneConfigurationFields.java @@ -36,6 +36,7 @@ import org.apache.hadoop.ozone.recon.ReconServerConfigKeys; import org.apache.hadoop.ozone.s3.S3GatewayConfigKeys; import org.apache.hadoop.ozone.s3secret.S3SecretConfigKeys; +import org.apache.hadoop.ozone.s3sts.S3STSConfigKeys; import org.junit.jupiter.api.Test; /** @@ -59,6 +60,7 @@ public void initializeMemberVariables() { ReconConfigKeys.class, ReconServerConfigKeys.class, S3GatewayConfigKeys.class, S3SecretConfigKeys.class, + S3STSConfigKeys.class }; errorIfMissingConfigProps = true; errorIfMissingXmlProps = true; diff --git a/hadoop-ozone/interface-client/src/main/proto/OmClientProtocol.proto b/hadoop-ozone/interface-client/src/main/proto/OmClientProtocol.proto index 1cf0033cacb3..3702b021ac20 100644 --- a/hadoop-ozone/interface-client/src/main/proto/OmClientProtocol.proto +++ b/hadoop-ozone/interface-client/src/main/proto/OmClientProtocol.proto @@ -166,6 +166,10 @@ enum Type { GetLifecycleServiceStatus = 150; SetLifecycleServiceStatus = 151; SaveLifecycleScanState = 152; + AssumeRole = 153; + RevokeSTSToken = 154; + DeleteRevokedSTSTokens = 155; + GetCallerIdentity = 156; } enum SafeMode { @@ -326,6 +330,11 @@ message OMRequest { optional GetLifecycleServiceStatusRequest getLifecycleServiceStatusRequest = 151; optional SetLifecycleServiceStatusRequest setLifecycleServiceStatusRequest = 152; optional SaveLifecycleScanStateRequest saveLifecycleScanStateRequest = 153; + optional AssumeRoleRequest assumeRoleRequest = 154; + optional RevokeSTSTokenRequest revokeSTSTokenRequest = 155; + optional DeleteRevokedSTSTokensRequest deleteRevokedSTSTokensRequest = 156; + optional UpdateAssumeRoleRequest updateAssumeRoleRequest = 157; + optional GetCallerIdentityRequest getCallerIdentityRequest = 158; } message OMResponse { @@ -471,6 +480,10 @@ message OMResponse { optional GetLifecycleServiceStatusResponse getLifecycleServiceStatusResponse = 150; optional SetLifecycleServiceStatusResponse setLifecycleServiceStatusResponse = 151; optional SaveLifecycleScanStateResponse saveLifecycleScanStateResponse = 152; + optional AssumeRoleResponse assumeRoleResponse = 153; + optional RevokeSTSTokenResponse revokeSTSTokenResponse = 154; + optional DeleteRevokedSTSTokensResponse deleteRevokedSTSTokensResponse = 155; + optional GetCallerIdentityResponse getCallerIdentityResponse = 156; } enum Status { @@ -609,6 +622,8 @@ enum Status { LIFECYCLE_CONFIGURATION_NOT_FOUND = 102; UPDATE_ID_NOT_MATCH = 103; + REVOKED_TOKEN = 104; + MALFORMED_POLICY_DOCUMENT = 105; } /** @@ -1346,6 +1361,9 @@ message ListStatusRequest { required string startKey = 3; required uint64 numEntries = 4; optional bool allowPartialPrefix = 5; + // When keyArgs.keyName is empty (root listing), this is the original S3/list + // prefix for STS auth. Enables LIST check on this prefix instead of "*". + optional string listPrefix = 6; } message ListStatusResponse { @@ -1562,6 +1580,7 @@ message OMTokenProto { enum Type { DELEGATION_TOKEN = 1; S3AUTHINFO = 2; + S3_STS_TOKEN = 3; }; required Type type = 1; optional uint32 version = 2; @@ -1579,6 +1598,13 @@ message OMTokenProto { optional string strToSign = 14; optional string omServiceId = 15 [deprecated = true]; optional string secretKeyId = 16; + // STS-specific fields + optional string roleArn = 17; + optional string originalAccessKeyId = 18; + optional string secretAccessKey = 19; + optional string sessionPolicy = 20; + optional string assumedRoleId = 21; + optional string assumedRoleUserArn = 22; } message SecretKeyProto { @@ -1615,6 +1641,8 @@ message CommitKeyRequest { message CommitKeyResponse { + // Modification time of the committed key, set by OM during preExecute. + optional uint64 modificationTime = 1; } message AllocateBlockRequest { @@ -1820,6 +1848,8 @@ message MultipartCommitUploadPartResponse { optional string partName = 1; // This one is returned as Etag for S3. optional string eTag = 2; + // Modification time of the committed part key, set by OM during preExecute. + optional uint64 modificationTime = 3; } message MultipartUploadCompleteRequest { @@ -2369,6 +2399,20 @@ message S3Authentication { optional string stringToSign = 1; optional string signature = 2; optional string accessId = 3; + // If present, indicates this request uses STS temporary credentials + // and carries the base64-encoded session token for validation. + optional string sessionToken = 4; + // The following fields are resolved from the STS session token by OM. + // They are used to enforce STS session policies during Ratis apply. + // They must be written or cleared by the OM leader when the token is validated. + optional string resolvedStsSessionPolicy = 5; + optional string resolvedStsRoleArn = 6; + optional string resolvedStsOriginalAccessKeyId = 7; + optional string resolvedStsTempAccessKeyId = 8; + optional string resolvedStsSecretKeyId = 9; + // S3 action without the s3: prefix for this request (e.g. GetObject), set by S3 Gateway for use + // in finer-grained STS permissions. + optional string s3Action = 10; } message RecoverLeaseRequest { @@ -2462,6 +2506,69 @@ message DeleteObjectTaggingRequest { message DeleteObjectTaggingResponse { } +message AssumeRoleRequest { + required string roleArn = 1; + required string roleSessionName = 2; + optional int32 durationSeconds = 3 [default = 3600]; + optional string awsIamSessionPolicy = 4; + required string requestId = 5; +} + +message AssumeRoleResponse { + required string accessKeyId = 1; + required string secretAccessKey = 2; + required string sessionToken = 3; + required uint64 expirationEpochSeconds = 4; + required string assumedRoleId = 5; +} + +/** + This request will be used internally by OM to replicate credentials generated by the leader + across the OMs in HA mode. This ensures all OMs have identical audit logs. +*/ +message UpdateAssumeRoleRequest { + required string roleArn = 1; + required string roleSessionName = 2; + required int32 durationSeconds = 3; + optional string awsIamSessionPolicy = 4; + required string requestId = 5; + // Leader-generated credentials + required string tempAccessKeyId = 6; + required string secretAccessKey = 7; + required string roleId = 8; + optional string sessionToken = 9; + optional uint64 expirationEpochSeconds = 10; +} + +message RevokeSTSTokenRequest { + required string originalAccessKeyId = 1; + // Leader-generated revocation cutoff, replicated across OMs in HA mode. + optional uint64 revocationTimeMillis = 2; +} + +message RevokeSTSTokenResponse { +} + +/** + This will contain a list of originalAccessKeyIds whose revocation entries should be removed from + the s3RevokedStsTokenTable. +*/ +message DeleteRevokedSTSTokensRequest { + repeated string originalAccessKeyId = 1; +} + +message DeleteRevokedSTSTokensResponse { +} + +message GetCallerIdentityRequest { +} + +message GetCallerIdentityResponse { + optional string account = 1; + optional string arn = 2; + optional string userId = 3; +} + enum ReadConsistencyProto { // Unspecified consistency, the read consistency behavior is decided // by the OM diff --git a/hadoop-ozone/interface-storage/src/main/java/org/apache/hadoop/ozone/om/OMMetadataManager.java b/hadoop-ozone/interface-storage/src/main/java/org/apache/hadoop/ozone/om/OMMetadataManager.java index 75b95f7bdcdf..37059ba4600d 100644 --- a/hadoop-ozone/interface-storage/src/main/java/org/apache/hadoop/ozone/om/OMMetadataManager.java +++ b/hadoop-ozone/interface-storage/src/main/java/org/apache/hadoop/ozone/om/OMMetadataManager.java @@ -528,6 +528,14 @@ OmLifecycleConfiguration getLifecycleConfiguration(String volumeName, */ Table getMetaTable(); + /** + * Gets the S3RevokedStsTokenTable. + * + * @return Table. + */ + Table getS3RevokedStsTokenTable(); + + /** * Returns number of rows in a table. This should not be used for very * large tables. diff --git a/hadoop-ozone/ozone-manager/pom.xml b/hadoop-ozone/ozone-manager/pom.xml index 9f8a89403515..fb07c36f9523 100644 --- a/hadoop-ozone/ozone-manager/pom.xml +++ b/hadoop-ozone/ozone-manager/pom.xml @@ -209,6 +209,10 @@ org.aspectj aspectjrt + + org.bouncycastle + bcprov-jdk18on + org.eclipse.jetty jetty-webapp diff --git a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/audit/OMAction.java b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/audit/OMAction.java index 31710b50a71a..16e4eaa05366 100644 --- a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/audit/OMAction.java +++ b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/audit/OMAction.java @@ -83,6 +83,10 @@ public enum OMAction implements AuditAction { SET_S3_SECRET, REVOKE_S3_SECRET, + // STS Actions + S3_ASSUME_ROLE, + REVOKE_STS_TOKEN, + CREATE_TENANT, DELETE_TENANT, LIST_TENANT, 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 e4c4f2293a92..ecbfc7ac3954 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 @@ -191,6 +191,8 @@ public class OmMetadataManagerImpl implements OMMetadataManager, private Table snapshotRenamedTable; private Table compactionLogTable; + private Table s3RevokedStsTokenTable; + private OzoneManager ozoneManager; // Epoch is used to generate the objectIDs. The most significant 2 bits of @@ -539,6 +541,11 @@ protected void initializeOmTables(CacheType cacheType, compactionLogTable = initializer.get(OMDBDefinition.COMPACTION_LOG_TABLE_DEF); + // 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); + lifecycleConfigurationTable = initializer.get(OMDBDefinition.LIFECYCLE_CONFIGURATION_TABLE_DEF, cacheType); lifecycleScanStateTable = initializer.get(OMDBDefinition.LIFECYCLE_SCAN_STATE_TABLE_DEF, cacheType); } @@ -1753,6 +1760,11 @@ public Table getCompactionLogTable() { return compactionLogTable; } + @Override + public Table getS3RevokedStsTokenTable() { + return s3RevokedStsTokenTable; + } + @Override public Table getLifecycleConfigurationTable() { return lifecycleConfigurationTable; 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 020fb1a3922b..434d05132bf5 100644 --- a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/OmMetadataReader.java +++ b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/OmMetadataReader.java @@ -32,6 +32,7 @@ import java.util.List; import java.util.Map; import java.util.stream.Collectors; +import org.apache.commons.lang3.StringUtils; import org.apache.commons.lang3.tuple.Pair; import org.apache.hadoop.ipc_.ProtobufRpcEngine; import org.apache.hadoop.ipc_.Server; @@ -57,6 +58,8 @@ import org.apache.hadoop.ozone.om.helpers.S3VolumeContext; import org.apache.hadoop.ozone.om.protocolPB.grpc.GrpcClientConstants; import org.apache.hadoop.ozone.om.request.OMClientRequest; +import org.apache.hadoop.ozone.protocol.proto.OzoneManagerProtocolProtos.S3Authentication; +import org.apache.hadoop.ozone.security.STSTokenIdentifier; import org.apache.hadoop.ozone.security.acl.IAccessAuthorizer; import org.apache.hadoop.ozone.security.acl.IAccessAuthorizer.ACLIdentityType; import org.apache.hadoop.ozone.security.acl.IAccessAuthorizer.ACLType; @@ -237,8 +240,7 @@ public List listStatus(OmKeyArgs args, boolean recursive, try { if (isAclEnabled) { - checkAcls(getResourceType(args), StoreType.OZONE, ACLType.READ, - bucket, args.getKeyName()); + checkListStatusAcls(args, bucket); } metrics.incNumListStatus(); return keyManager.listStatus(args, recursive, startKey, @@ -274,8 +276,7 @@ public List listStatusLight(OmKeyArgs args, try { if (isAclEnabled) { - checkAcls(getResourceType(resolvedArgs), StoreType.OZONE, ACLType.READ, - bucket, resolvedArgs.getKeyName()); + checkListStatusAcls(resolvedArgs, bucket); } metrics.incNumListStatus(); List ozoneFileStatuses = keyManager.listStatus( @@ -307,8 +308,7 @@ public OzoneFileStatus getFileStatus(OmKeyArgs args) throws IOException { try { if (isAclEnabled) { - checkAcls(getResourceType(args), StoreType.OZONE, ACLType.READ, - bucket, args.getKeyName()); + checkAcls(getResourceType(args), StoreType.OZONE, ACLType.READ, bucket, args.getKeyName()); } metrics.incNumGetFileStatus(); return keyManager.getFileStatus(args, getClientAddress()); @@ -373,10 +373,23 @@ public ListKeysResult listKeys(String volumeName, String bucketName, try { if (isAclEnabled) { - captureLatencyNs(perfMetrics.getListKeysAclCheckLatencyNs(), () -> - checkAcls(ResourceType.BUCKET, StoreType.OZONE, ACLType.LIST, - bucket.realVolume(), bucket.realBucket(), keyPrefix) - ); + if (isStsS3Request()) { + // Check with key null to ensure there is LIST permission on the bucket + captureLatencyNs( + perfMetrics.getListKeysAclCheckLatencyNs(), () -> checkAcls( + ResourceType.BUCKET, StoreType.OZONE, ACLType.LIST, bucket.realVolume(), bucket.realBucket(), + null)); + // With STS we must check acl on the prefix to be compliant with AWS + final String aclKey = (keyPrefix == null || keyPrefix.isEmpty()) ? "*" : keyPrefix; + captureLatencyNs( + perfMetrics.getListKeysAclCheckLatencyNs(), () -> checkAcls( + ResourceType.KEY, StoreType.OZONE, ACLType.READ, bucket.realVolume(), bucket.realBucket(), aclKey)); + } else { + captureLatencyNs(perfMetrics.getListKeysAclCheckLatencyNs(), () -> + checkAcls(ResourceType.BUCKET, StoreType.OZONE, ACLType.LIST, + bucket.realVolume(), bucket.realBucket(), keyPrefix) + ); + } } metrics.incNumKeyLists(); return keyManager.listKeys(bucket.realVolume(), bucket.realBucket(), @@ -583,8 +596,9 @@ void checkAcls(ResourceType resType, StoreType store, throws IOException { UserGroupInformation user; if (getS3Auth() != null) { + final String effectiveAccessId = OzoneManager.getS3AuthEffectiveAccessId(); String principal = - OzoneAclUtils.accessIdToUserPrincipal(getS3Auth().getAccessId()); + OzoneAclUtils.accessIdToUserPrincipal(effectiveAccessId); user = UserGroupInformation.createRemoteUser(principal); } else { user = ProtobufRpcEngine.Server.getRemoteUser(); @@ -618,8 +632,9 @@ void checkAcls(ResourceType resType, StoreType store, throws IOException { UserGroupInformation user; if (getS3Auth() != null) { + final String effectiveAccessId = OzoneManager.getS3AuthEffectiveAccessId(); String principal = - OzoneAclUtils.accessIdToUserPrincipal(getS3Auth().getAccessId()); + OzoneAclUtils.accessIdToUserPrincipal(effectiveAccessId); user = UserGroupInformation.createRemoteUser(principal); } else { user = ProtobufRpcEngine.Server.getRemoteUser(); @@ -660,16 +675,15 @@ public boolean checkAcls(ResourceType resType, StoreType storeType, .setVolumeName(vol) .setBucketName(bucket) .setKeyName(key).build(); - RequestContext context = RequestContext.newBuilder() + RequestContext.Builder contextBuilder = RequestContext.newBuilder() .setClientUgi(ugi) .setIp(remoteAddress) .setHost(hostName) .setAclType(ACLIdentityType.USER) .setAclRights(aclType) - .setOwnerName(owner) - .build(); + .setOwnerName(owner); - return checkAcls(obj, context, throwIfPermissionDenied); + return checkAcls(obj, contextBuilder, throwIfPermissionDenied); } /** @@ -679,9 +693,12 @@ public boolean checkAcls(ResourceType resType, StoreType storeType, * @throws OMException ResultCodes.PERMISSION_DENIED if permission denied * and throwOnPermissionDenied set to true. */ - public boolean checkAcls(OzoneObj obj, RequestContext context, + public boolean checkAcls(OzoneObj obj, RequestContext.Builder contextBuilder, boolean throwIfPermissionDenied) throws OMException { + maybeAddToContextFromThreadLocal(contextBuilder); + final RequestContext context = contextBuilder.build(); + if (!captureLatencyNs(perfMetrics::setCheckAccessLatencyNs, () -> accessAuthorizer.checkAccess(obj, context))) { if (throwIfPermissionDenied) { @@ -691,11 +708,25 @@ public boolean checkAcls(OzoneObj obj, RequestContext context, "Bucket:" + obj.getBucketName() + " " : ""; String keyName = obj.getKeyName() != null ? "Key:" + obj.getKeyName() : ""; + // For STS tokens, make clear that the user is using an assumed role, otherwise the access denied + // message could be confusing + String user = context.getClientUgi().getShortUserName(); + final STSTokenIdentifier stsTokenIdentifier = OzoneManager.getStsTokenIdentifier(); + if (stsTokenIdentifier != null) { + final StringBuilder builder = new StringBuilder(user) + .append(" (STS assumed role arn = ") + .append(stsTokenIdentifier.getRoleArn()) + .append(", tempAccessKeyId = ") + .append(stsTokenIdentifier.getTempAccessKeyId()) + .append(')'); + user = builder.toString(); + } log.warn("User {} doesn't have {} permission to access {} {}{}{}", - context.getClientUgi().getShortUserName(), context.getAclRights(), + user, + context.getAclRights(), obj.getResourceType(), volumeName, bucketName, keyName); throw new OMException( - "User " + context.getClientUgi().getShortUserName() + + "User " + user + " doesn't have " + context.getAclRights() + " permission to access " + obj.getResourceType() + " " + volumeName + bucketName + keyName, ResultCodes.PERMISSION_DENIED); @@ -706,6 +737,32 @@ public boolean checkAcls(OzoneObj obj, RequestContext context, } } + /** + * Enriches the given {@link RequestContext.Builder} with per-request fields from the Ozone Manager + * thread locals: the session policy from {@link STSTokenIdentifier} (set on STS requests) and the + * S3 action from {@link S3Authentication} (set on S3 requests). Either or both may be absent, in + * which case the corresponding field is left untouched on the builder. + *

+ * The S3 action is only propagated when the S3 STS feature flag is enabled, since it is only used + * for fine-grained STS authorization. + * @param contextBuilder the builder to enrich in-place + */ + private void maybeAddToContextFromThreadLocal(RequestContext.Builder contextBuilder) { + if (!ozoneManager.isS3STSEnabled()) { + return; + } + + final STSTokenIdentifier stsTokenIdentifier = OzoneManager.getStsTokenIdentifier(); + if (stsTokenIdentifier != null) { + contextBuilder.setSessionPolicy(stsTokenIdentifier.getSessionPolicy()); + } + + final S3Authentication s3Authentication = OzoneManager.getS3Auth(); + if (s3Authentication != null && s3Authentication.hasS3Action() && !s3Authentication.getS3Action().isEmpty()) { + contextBuilder.setS3Action(s3Authentication.getS3Action()); + } + } + static String getClientAddress() { String clientMachine = Server.getRemoteAddress(); if (clientMachine == null) { //not a RPC client @@ -756,6 +813,59 @@ public boolean isNativeAuthorizerEnabled() { return accessAuthorizer.isNative(); } + private boolean isStsS3Request() { + return getS3Auth() != null && OzoneManager.getStsTokenIdentifier() != null; + } + + private void checkListStatusAcls(OmKeyArgs args, ResolvedBucket bucket) throws IOException { + if (isStsS3Request()) { + checkAcls( + ResourceType.KEY, StoreType.OZONE, ACLType.READ, bucket.realVolume(), bucket.realBucket(), + getStsListStatusAclKey(args)); + return; + } + + checkAcls(getResourceType(args), StoreType.OZONE, ACLType.READ, bucket, args.getKeyName()); + } + + private static String getStsListStatusAclKey(OmKeyArgs args) throws OMException { + // When listPrefix is set (original S3 ListObjects prefix), authorize READ on that prefix for the whole listing, + // including FSO traversal where keyName is an internal directory (e.g. userA) under prefix user. + final String listPrefix = args.getListPrefix(); + final String keyName = args.getKeyName(); + if (StringUtils.isNotBlank(listPrefix)) { + if (StringUtils.isBlank(keyName) || isStsListPathUnderRequestPrefix(keyName, listPrefix)) { + return listPrefix; + } + throw new OMException( + "STS listStatus: key path: " + keyName + " does not match authorized list prefix: " + listPrefix, + ResultCodes.PERMISSION_DENIED); + } + if (StringUtils.isNotEmpty(keyName)) { + return keyName; + } + return "*"; + } + + /** + * For STS, {@code listPrefix} is the original S3 ListObjects prefix. Internal FSO listing may call + * {@code listStatus} with {@code keyName} set to a subdirectory (e.g. userA) while the session policy + * still authorizes only the request prefix (e.g. user). This returns true when {@code keyName} is the + * prefix itself, a key path under that prefix, or an ancestor directory on the way to a deeper prefix. + */ + private static boolean isStsListPathUnderRequestPrefix(String keyName, String listPrefix) { + if (StringUtils.isBlank(listPrefix) || StringUtils.isBlank(keyName)) { + return false; + } + if (keyName.equals(listPrefix)) { + return true; + } + if (keyName.startsWith(listPrefix)) { + return true; + } + return listPrefix.startsWith(keyName + "/"); + } + private ResourceType getResourceType(OmKeyArgs args) { if (args.getKeyName() == null || args.getKeyName().isEmpty()) { return ResourceType.BUCKET; diff --git a/hadoop-ozone/ozone-manager/src/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 a53c0217efdc..1960e1fc3bdc 100644 --- a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/OzoneManager.java +++ b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/OzoneManager.java @@ -145,6 +145,7 @@ import java.util.HashMap; import java.util.HashSet; import java.util.LinkedHashMap; +import java.util.LinkedHashSet; import java.util.List; import java.util.Map; import java.util.Objects; @@ -308,6 +309,7 @@ import org.apache.hadoop.ozone.om.service.KeyLifecycleService; import org.apache.hadoop.ozone.om.service.OMRangerBGSyncService; import org.apache.hadoop.ozone.om.service.QuotaRepairTask; +import org.apache.hadoop.ozone.om.service.RevokedSTSTokenCleanupService; import org.apache.hadoop.ozone.om.snapshot.defrag.SnapshotDefragService; import org.apache.hadoop.ozone.om.upgrade.OMLayoutFeature; import org.apache.hadoop.ozone.om.upgrade.OMLayoutVersionManager; @@ -329,6 +331,8 @@ import org.apache.hadoop.ozone.security.OMCertificateClient; import org.apache.hadoop.ozone.security.OzoneDelegationTokenSecretManager; import org.apache.hadoop.ozone.security.OzoneTokenIdentifier; +import org.apache.hadoop.ozone.security.STSTokenIdentifier; +import org.apache.hadoop.ozone.security.STSTokenSecretManager; import org.apache.hadoop.ozone.security.acl.IAccessAuthorizer; import org.apache.hadoop.ozone.security.acl.IAccessAuthorizer.ACLIdentityType; import org.apache.hadoop.ozone.security.acl.IAccessAuthorizer.ACLType; @@ -400,10 +404,14 @@ public final class OzoneManager extends ServiceRuntimeInfoImpl private static final ThreadLocal S3_AUTH = new ThreadLocal<>(); + // STS token (if present) + private static final ThreadLocal STS_TOKEN = new ThreadLocal<>(); + private static boolean securityEnabled = false; private final ReconfigurationHandler reconfigurationHandler; private OzoneDelegationTokenSecretManager delegationTokenMgr; + private STSTokenSecretManager stsTokenSecretManager; private OzoneBlockTokenSecretManager blockTokenMgr; private CertificateClient certClient; private SecretKeyClient secretKeyClient; @@ -457,6 +465,7 @@ public final class OzoneManager extends ServiceRuntimeInfoImpl private final boolean isSpnegoEnabled; private final SecurityConfig secConfig; private S3SecretManager s3SecretManager; + private RevokedSTSTokenCleanupService revokedSTSTokenCleanupService; private final boolean isOmGrpcServerEnabled; private volatile boolean isOmRpcServerRunning = false; private volatile boolean isOmGrpcServerRunning = false; @@ -491,6 +500,7 @@ public final class OzoneManager extends ServiceRuntimeInfoImpl private final boolean isS3MultiTenancyEnabled; private final boolean isStrictS3; + private final boolean isS3STSEnabled; private ExitManager exitManager; /** Test-only hook to fail a checkpoint-install DB backup part way through. */ private FaultInjector checkpointBackupInjector; @@ -715,7 +725,13 @@ private OzoneManager(OzoneConfiguration conf, StartupOption startupOption) this.isS3MultiTenancyEnabled = OMMultiTenantManager.checkAndEnableMultiTenancy(this, conf); + // Enable S3 STS if config key is set + this.isS3STSEnabled = conf.getBoolean( + OzoneConfigKeys.OZONE_S3G_STS_HTTP_ENABLED_KEY, + OzoneConfigKeys.OZONE_S3G_STS_HTTP_ENABLED_DEFAULT); + metrics = OMMetrics.create(conf); + omSnapshotIntMetrics = OmSnapshotInternalMetrics.create(); perfMetrics = OMPerformanceMetrics.register(); omDeletionMetrics = DeletingServiceMetrics.create(); @@ -901,6 +917,47 @@ public static S3Authentication getS3Auth() { return S3_AUTH.get(); } + /** + * Set the STS token identifier for the current RPC handler thread. + */ + public static void setStsTokenIdentifier(STSTokenIdentifier val) { + STS_TOKEN.set(val); + } + + /** + * Get the STS token identifier for the current RPC handler thread. + */ + public static STSTokenIdentifier getStsTokenIdentifier() { + return STS_TOKEN.get(); + } + + /** + * Returns the effective accessId for the current request. If STS temporary credentials are being used, + * the access key id will be the original access key id (i.e. the creator of the token). + */ + public static String getS3AuthEffectiveAccessId() throws OMException { + final S3Authentication s3Auth = getS3Auth(); + if (s3Auth == null) { + return null; + } + + // If session token is present, try to resolve originalAccessKeyId from token + if (s3Auth.hasSessionToken() && !s3Auth.getSessionToken().isEmpty()) { + final STSTokenIdentifier stsTokenIdentifier = getStsTokenIdentifier(); + if (stsTokenIdentifier == null) { + throw new OMException( + "OMClientRequest has session token but no token identifier in OzoneManager", INVALID_REQUEST); + } + final String originalAccessKeyId = stsTokenIdentifier.getOriginalAccessKeyId(); + if (originalAccessKeyId != null && !originalAccessKeyId.isEmpty()) { + return originalAccessKeyId; + } else { + throw new OMException("Invalid STS Token format - could not find originalAccessKeyId", INVALID_REQUEST); + } + } + return s3Auth.getAccessId(); + } + /** Returns the ThreadName prefix for the current OM. */ public String getThreadNamePrefix() { return threadPrefix; @@ -1006,6 +1063,7 @@ private void instantiateServices(boolean withNewSnapshot) throws IOException { if (secConfig.isSecurityEnabled() || testSecureOmFlag) { try { delegationTokenMgr = createDelegationTokenSecretManager(configuration); + stsTokenSecretManager = createSTSTokenSecretManager(); } catch (IllegalArgumentException e) { if (metadataManager != null) { // to avoid the unit test leak report failure @@ -1128,6 +1186,13 @@ public boolean isStrictS3() { return isStrictS3; } + /** + * Returns true if S3 STS is enabled; false otherwise. + */ + public boolean isS3STSEnabled() { + return isS3STSEnabled; + } + /** * Throws OMException FEATURE_NOT_ENABLED if S3 multi-tenancy is not enabled. */ @@ -1141,6 +1206,21 @@ public void checkS3MultiTenancyEnabled() throws OMException { FEATURE_NOT_ENABLED); } + /** + * Throws OMException FEATURE_NOT_ENABLED if S3 STS (AssumeRole) is not enabled. + */ + public void checkS3STSEnabled() throws OMException { + if (isS3STSEnabled()) { + if (getAccessAuthorizer().isNative()) { + throw new OMException("S3 STS is not enabled for Ozone Native Authorizer", FEATURE_NOT_ENABLED); + } + return; + } + + throw new OMException("S3 STS is not enabled. Please set " + OzoneConfigKeys.OZONE_S3G_STS_HTTP_ENABLED_KEY + + " to true and restart all OMs.", FEATURE_NOT_ENABLED); + } + /** * Return config value of {@link OzoneConfigKeys#OZONE_SECURITY_ENABLED_KEY}. */ @@ -1281,6 +1361,10 @@ private OzoneBlockTokenSecretManager createBlockTokenSecretManager() { return new OzoneBlockTokenSecretManager(expiryTime, secretKeyClient); } + private STSTokenSecretManager createSTSTokenSecretManager() { + return new STSTokenSecretManager(secretKeyClient); + } + private void stopSecretManager() { if (secretKeyClient != null) { LOG.info("Stopping secret key client."); @@ -1295,6 +1379,21 @@ private void stopSecretManager() { LOG.error("Failed to stop delegation token manager", e); } } + + if (stsTokenSecretManager != null) { + // STS token secret manager doesn't need explicit stop method + // as it uses the shared secret key client + LOG.info("Stopping OM STS token secret manager."); + } + } + + /** + * Get the secret key client for this OzoneManager. + * + * @return the secret key client + */ + public SecretKeyClient getSecretKeyClient() { + return secretKeyClient; } @Override @@ -1379,6 +1478,9 @@ public void setSecretKeyClient(SecretKeyClient secretKeyClient) { if (delegationTokenMgr != null) { delegationTokenMgr.setSecretKeyClient(secretKeyClient); } + if (stsTokenSecretManager != null) { + stsTokenSecretManager.setSecretKeyClient(secretKeyClient); + } } /** @@ -1933,6 +2035,18 @@ public void start() throws IOException { keyManager.start(configuration); + final long stsTokenCleanupInterval = configuration.getTimeDuration( + OMConfigKeys.OZONE_OM_STS_TOKEN_CLEANUP_SERVICE_INTERVAL, + OMConfigKeys.OZONE_OM_STS_TOKEN_CLEANUP_SERVICE_INTERVAL_DEFAULT, + TimeUnit.MILLISECONDS); + final long stsTokenCleanupTimeout = configuration.getTimeDuration( + OMConfigKeys.OZONE_OM_STS_TOKEN_CLEANUP_SERVICE_TIMEOUT, + OMConfigKeys.OZONE_OM_STS_TOKEN_CLEANUP_SERVICE_TIMEOUT_DEFAULT, + TimeUnit.MILLISECONDS); + revokedSTSTokenCleanupService = new RevokedSTSTokenCleanupService( + stsTokenCleanupInterval, TimeUnit.MILLISECONDS, stsTokenCleanupTimeout, this); + revokedSTSTokenCleanupService.start(); + try { httpServer = new OzoneManagerHttpServer(configuration, this); httpServer.start(); @@ -2521,6 +2635,9 @@ public boolean stop() { if (edekCacheLoader != null) { edekCacheLoader.shutdown(); } + if (revokedSTSTokenCleanupService != null) { + revokedSTSTokenCleanupService.shutdown(); + } return true; } catch (Exception e) { LOG.error("OzoneManager stop failed.", e); @@ -2813,16 +2930,15 @@ public boolean checkAcls(ResourceType resType, StoreType storeType, .setVolumeName(vol) .setBucketName(bucket) .setKeyName(key).build(); - RequestContext context = RequestContext.newBuilder() + RequestContext.Builder contextBuilder = RequestContext.newBuilder() .setClientUgi(ugi) .setIp(remoteAddress) .setHost(hostName) .setAclType(ACLIdentityType.USER) .setAclRights(aclType) - .setOwnerName(owner) - .build(); + .setOwnerName(owner); - return omMetadataReader.checkAcls(obj, context, throwIfPermissionDenied); + return omMetadataReader.checkAcls(obj, contextBuilder, throwIfPermissionDenied); } /** @@ -4002,7 +4118,12 @@ S3VolumeContext getS3VolumeContext(boolean skipChecks) throws IOException { s3Volume); } } else { - String accessId = s3Auth.getAccessId(); + // If this S3 request is authenticated with an STS session token, map + // the request to the *original* long-lived access ID so that the + // temporary credentials inherit that user's ACLs. Otherwise, fall back + // to the accessId included directly in the S3Authentication. + final String accessId = getS3AuthEffectiveAccessId(); + // If S3 Multi-Tenancy is not enabled, all S3 requests will be redirected // to the default s3v for compatibility final Optional optionalTenantId = isS3MultiTenancyEnabled() ? @@ -4869,6 +4990,10 @@ public OzoneDelegationTokenSecretManager getDelegationTokenMgr() { return delegationTokenMgr; } + public STSTokenSecretManager getSTSTokenSecretManager() { + return stsTokenSecretManager; + } + /** * Return the list of Ozone administrators in effect. */ @@ -5002,19 +5127,16 @@ public ResolvedBucket resolveBucketLink(OmKeyArgs args) public ResolvedBucket resolveBucketLink(Pair requested, OMClientRequest omClientRequest) throws IOException { + final Set> linkChain = new LinkedHashSet<>(); OmBucketInfo resolved; if (getAclsEnabled()) { - resolved = resolveBucketLink(requested, new HashSet<>(), - omClientRequest.createUGIForApi(), - omClientRequest.getRemoteAddress(), - omClientRequest.getHostName(), - false); + resolved = resolveBucketLink( + requested, linkChain, omClientRequest.createUGIForApi(), omClientRequest.getRemoteAddress(), + omClientRequest.getHostName(), false); } else { - resolved = resolveBucketLink(requested, new HashSet<>(), - null, null, null, false); + resolved = resolveBucketLink(requested, linkChain, null, null, null, false); } - return new ResolvedBucket(requested.getLeft(), requested.getRight(), - resolved); + return new ResolvedBucket(requested.getLeft(), requested.getRight(), resolved, linkChain); } public ResolvedBucket resolveBucketLink(Pair requested, @@ -5026,25 +5148,22 @@ public ResolvedBucket resolveBucketLink(Pair requested, boolean allowDanglingBuckets, boolean aclEnabled) throws IOException { + final Set> linkChain = new LinkedHashSet<>(); OmBucketInfo resolved; if (aclEnabled) { UserGroupInformation ugi = getRemoteUser(); if (getS3Auth() != null) { - ugi = UserGroupInformation.createRemoteUser( - OzoneAclUtils.accessIdToUserPrincipal(getS3Auth().getAccessId())); + final String principal = OzoneAclUtils.accessIdToUserPrincipal(getS3AuthEffectiveAccessId()); + ugi = UserGroupInformation.createRemoteUser(principal); } InetAddress remoteIp = Server.getRemoteIp(); - resolved = resolveBucketLink(requested, new HashSet<>(), - ugi, - remoteIp != null ? remoteIp : omRpcAddress.getAddress(), - remoteIp != null ? remoteIp.getHostName() : - omRpcAddress.getHostName(), allowDanglingBuckets, aclEnabled); + resolved = resolveBucketLink( + requested, linkChain, ugi, remoteIp != null ? remoteIp : omRpcAddress.getAddress(), + remoteIp != null ? remoteIp.getHostName() : omRpcAddress.getHostName(), allowDanglingBuckets, aclEnabled); } else { - resolved = resolveBucketLink(requested, new HashSet<>(), - null, null, null, allowDanglingBuckets, aclEnabled); + resolved = resolveBucketLink(requested, linkChain, null, null, null, allowDanglingBuckets, aclEnabled); } - return new ResolvedBucket(requested.getLeft(), requested.getRight(), - resolved); + return new ResolvedBucket(requested.getLeft(), requested.getRight(), resolved, linkChain); } private OmBucketInfo resolveBucketLink( diff --git a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/ResolvedBucket.java b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/ResolvedBucket.java index 19b41355d091..6dd50feff615 100644 --- a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/ResolvedBucket.java +++ b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/ResolvedBucket.java @@ -17,6 +17,9 @@ package org.apache.hadoop.ozone.om; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; import java.util.LinkedHashMap; import java.util.Map; import java.util.Objects; @@ -44,11 +47,18 @@ public class ResolvedBucket { private final String realBucket; private final String bucketOwner; private final BucketLayout bucketLayout; + private final Collection> linkChain; public ResolvedBucket(String requestedVolume, String requestedBucket, OmBucketInfo resolved) { + this(requestedVolume, requestedBucket, resolved, Collections.emptyList()); + } + + public ResolvedBucket(String requestedVolume, String requestedBucket, + OmBucketInfo resolved, Collection> linkChain) { this.requestedVolume = requestedVolume; this.requestedBucket = requestedBucket; + this.linkChain = unmodifiableLinkChain(linkChain); if (resolved != null) { this.realVolume = resolved.getVolumeName(); this.realBucket = resolved.getBucketName(); @@ -65,12 +75,28 @@ public ResolvedBucket(String requestedVolume, String requestedBucket, public ResolvedBucket(String requestedVolume, String requestedBucket, String realVolume, String realBucket, String bucketOwner, BucketLayout bucketLayout) { + this(requestedVolume, requestedBucket, realVolume, realBucket, bucketOwner, bucketLayout, + Collections.emptyList()); + } + + public ResolvedBucket(String requestedVolume, String requestedBucket, + String realVolume, String realBucket, String bucketOwner, + BucketLayout bucketLayout, Collection> linkChain) { this.requestedVolume = requestedVolume; this.requestedBucket = requestedBucket; this.realVolume = realVolume; this.realBucket = realBucket; this.bucketOwner = bucketOwner; this.bucketLayout = bucketLayout; + this.linkChain = unmodifiableLinkChain(linkChain); + } + + private static Collection> unmodifiableLinkChain( + Collection> linkChain) { + if (linkChain == null || linkChain.isEmpty()) { + return Collections.emptyList(); + } + return Collections.unmodifiableList(new ArrayList<>(linkChain)); } public ResolvedBucket(Pair requested, @@ -104,6 +130,14 @@ public BucketLayout bucketLayout() { return bucketLayout; } + /** + * Ordered link buckets followed when resolving from the requested bucket to the real bucket. + * Each pair is {@code (volume, bucket)}. Empty when the requested bucket is not a link. + */ + public Collection> linkChain() { + return linkChain; + } + public OmKeyArgs update(OmKeyArgs args) { return isLink() ? args.toBuilder() diff --git a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/codec/OMDBDefinition.java b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/codec/OMDBDefinition.java index 0d51c6e8ab25..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 @@ -53,13 +53,14 @@ * OM database definitions. *

  * {@code
- * User, Token and Secret Tables:
+ * User, Token, Secret and Revoked STS Token Tables:
  * |------------------------------------------------------------------------|
- * |        Column Family |                 Mapping                         |
+ * |          Column Family |                 Mapping                       |
  * |------------------------------------------------------------------------|
- * |            userTable |             /user :- UserVolumeInfo             |
- * |          dTokenTable |      OzoneTokenID :- renew_time                 |
- * |        s3SecretTable | s3g_access_key_id :- s3Secret                   |
+ * |              userTable |             /user :- UserVolumeInfo           |
+ * |            dTokenTable |      OzoneTokenID :- renew_time               |
+ * |          s3SecretTable | s3g_access_key_id :- s3Secret                 |
+ * | s3RevokedStsTokenTable | originalAccessKeyId :- revocationTimeMillis   |
  * |------------------------------------------------------------------------|
  * }
  * 
@@ -145,7 +146,7 @@ public final class OMDBDefinition extends DBDefinition.WithMap { //--------------------------------------------------------------------------- - // User, Token and Secret Tables: + // User, Token, Secret and Revoked STS Token Tables: public static final String USER_TABLE = "userTable"; /** userTable: /user :- UserVolumeInfo. */ public static final DBColumnFamilyDefinition USER_TABLE_DEF @@ -167,6 +168,16 @@ public final class OMDBDefinition extends DBDefinition.WithMap { StringCodec.get(), S3SecretValue.getCodec()); + public static final String S3_REVOKED_STS_TOKEN_TABLE = "s3RevokedStsTokenTable"; + /** + * 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(), + LongCodec.get()); + //--------------------------------------------------------------------------- // Volume, Bucket, Prefix and Transaction Tables: public static final String VOLUME_TABLE = "volumeTable"; @@ -371,6 +382,7 @@ public final class OMDBDefinition extends DBDefinition.WithMap { TRANSACTION_INFO_TABLE_DEF, USER_TABLE_DEF, VOLUME_TABLE_DEF, + S3_REVOKED_STS_TOKEN_TABLE_DEF, LIFECYCLE_CONFIGURATION_TABLE_DEF, LIFECYCLE_SCAN_STATE_TABLE_DEF); diff --git a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/helpers/OMAuditLogger.java b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/helpers/OMAuditLogger.java index 0a01f2e493d9..120f975a85ed 100644 --- a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/helpers/OMAuditLogger.java +++ b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/helpers/OMAuditLogger.java @@ -95,6 +95,8 @@ private static void init() { CMD_AUDIT_ACTION_MAP.put(Type.GetObjectTagging, OMAction.GET_OBJECT_TAGGING); CMD_AUDIT_ACTION_MAP.put(Type.PutObjectTagging, OMAction.PUT_OBJECT_TAGGING); CMD_AUDIT_ACTION_MAP.put(Type.DeleteObjectTagging, OMAction.DELETE_OBJECT_TAGGING); + CMD_AUDIT_ACTION_MAP.put(Type.AssumeRole, OMAction.S3_ASSUME_ROLE); + CMD_AUDIT_ACTION_MAP.put(Type.RevokeSTSToken, OMAction.REVOKE_STS_TOKEN); CMD_AUDIT_ACTION_MAP.put(Type.GetBucketTagging, OMAction.GET_BUCKET_TAGGING); CMD_AUDIT_ACTION_MAP.put(Type.PutBucketTagging, OMAction.PUT_BUCKET_TAGGING); CMD_AUDIT_ACTION_MAP.put(Type.DeleteBucketTagging, OMAction.DELETE_BUCKET_TAGGING); 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 9bb39d19727f..50bb1f3b3762 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; @@ -57,6 +58,8 @@ import org.apache.hadoop.ozone.protocol.proto.OzoneManagerProtocolProtos.OMResponse; import org.apache.hadoop.ozone.protocolPB.OzoneManagerRequestHandler; import org.apache.hadoop.ozone.protocolPB.RequestHandler; +import org.apache.hadoop.ozone.security.STSSecurityUtil; +import org.apache.hadoop.ozone.security.STSTokenIdentifier; import org.apache.hadoop.security.UserGroupInformation; import org.apache.hadoop.util.Time; import org.apache.hadoop.util.concurrent.HadoopExecutors; @@ -691,7 +694,41 @@ public void close() { */ @VisibleForTesting OMResponse runCommand(OMRequest request, TermIndex termIndex) { + boolean isS3AuthThreadLocalSet = false; + boolean isStsThreadLocalSet = false; try { + if (ozoneManager.isSecurityEnabled() && request.hasS3Authentication()) { + // STS token verification runs on the leader RPC path so we don't need to recheck here on the apply + // after the log is committed + STSSecurityUtil.ensureResolvedStsFieldsInvariants(request); + + final OzoneManagerProtocolProtos.S3Authentication s3Auth = request.getS3Authentication(); + // ThreadLocal carries S3 action for OmMetadataReader. + OzoneManager.setS3Auth(s3Auth); + isS3AuthThreadLocalSet = true; + + 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( + STSTokenIdentifier.Params.newBuilder() + .setTempAccessKeyId( + s3Auth.hasResolvedStsTempAccessKeyId() ? s3Auth.getResolvedStsTempAccessKeyId() : "") + .setOriginalAccessKeyId( + s3Auth.hasResolvedStsOriginalAccessKeyId() ? s3Auth.getResolvedStsOriginalAccessKeyId() : "") + .setRoleArn(s3Auth.hasResolvedStsRoleArn() ? s3Auth.getResolvedStsRoleArn() : "") + .setCreationTime(Instant.MAX) + .setExpiry(Instant.MAX) // ensure it deterministically is not expired + .setSecretAccessKey(null) // no secretAccessKey needed + .setSessionPolicy( + s3Auth.hasResolvedStsSessionPolicy() ? s3Auth.getResolvedStsSessionPolicy() : "") + .setManagedSecretKey(null) // no ManagedSecretKey needed + .build()); + OzoneManager.setStsTokenIdentifier(rehydratedTokenIdentifier); + isStsThreadLocalSet = true; + } + } ExecutionContext context = ExecutionContext.of(termIndex.getIndex(), termIndex); final OMClientResponse omClientResponse = handler.handleWriteRequest( request, context, ozoneManagerDoubleBuffer); @@ -710,6 +747,13 @@ OMResponse runCommand(OMRequest request, TermIndex termIndex) { // For any Runtime exceptions, terminate OM. String errorMessage = "Request " + request + " failed with exception"; ExitUtils.terminate(1, errorMessage, e, LOG); + } finally { + if (isS3AuthThreadLocalSet) { + OzoneManager.setS3Auth(null); + } + if (isStsThreadLocalSet) { + OzoneManager.setStsTokenIdentifier(null); + } } return null; } diff --git a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/ratis/utils/OzoneManagerRatisUtils.java b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/ratis/utils/OzoneManagerRatisUtils.java index 94a328a77c17..bc281abecb67 100644 --- a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/ratis/utils/OzoneManagerRatisUtils.java +++ b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/ratis/utils/OzoneManagerRatisUtils.java @@ -26,6 +26,8 @@ import java.io.IOException; import java.nio.file.InvalidPathException; import java.nio.file.Path; +import java.time.Clock; +import java.time.ZoneOffset; import org.apache.hadoop.hdds.conf.ConfigurationSource; import org.apache.hadoop.hdds.conf.OzoneConfiguration; import org.apache.hadoop.hdds.protocol.proto.HddsProtos.NodeType; @@ -69,7 +71,10 @@ import org.apache.hadoop.ozone.om.request.lifecycle.OMLifecycleSetServiceStatusRequest; import org.apache.hadoop.ozone.om.request.s3.multipart.S3ExpiredMultipartUploadsAbortRequest; import org.apache.hadoop.ozone.om.request.s3.security.OMSetSecretRequest; +import org.apache.hadoop.ozone.om.request.s3.security.S3AssumeRoleRequest; +import org.apache.hadoop.ozone.om.request.s3.security.S3DeleteRevokedSTSTokensRequest; import org.apache.hadoop.ozone.om.request.s3.security.S3GetSecretRequest; +import org.apache.hadoop.ozone.om.request.s3.security.S3RevokeSTSTokenRequest; import org.apache.hadoop.ozone.om.request.s3.security.S3RevokeSecretRequest; import org.apache.hadoop.ozone.om.request.s3.tagging.S3DeleteBucketTaggingRequest; import org.apache.hadoop.ozone.om.request.s3.tagging.S3PutBucketTaggingRequest; @@ -120,6 +125,8 @@ public final class OzoneManagerRatisUtils { private static final Logger LOG = LoggerFactory .getLogger(OzoneManagerRatisUtils.class); + private static final Clock CLOCK = Clock.system(ZoneOffset.UTC); + private OzoneManagerRatisUtils() { } @@ -199,6 +206,13 @@ public static OMClientRequest createClientRequest(OMRequest omRequest, return new OMSetSecretRequest(omRequest); case RevokeS3Secret: return new S3RevokeSecretRequest(omRequest); + case AssumeRole: + ozoneManager.checkS3STSEnabled(); + return new S3AssumeRoleRequest(omRequest, CLOCK); + case RevokeSTSToken: + return new S3RevokeSTSTokenRequest(omRequest); + case DeleteRevokedSTSTokens: + return new S3DeleteRevokedSTSTokensRequest(omRequest); case PurgeKeys: return new OMKeyPurgeRequest(omRequest); case PurgeDirectories: diff --git a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/OMClientRequest.java b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/OMClientRequest.java index a916475f7c41..d63baf6d4d00 100644 --- a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/OMClientRequest.java +++ b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/OMClientRequest.java @@ -27,6 +27,7 @@ import java.util.LinkedHashMap; import java.util.Map; import java.util.Objects; +import java.util.UUID; import org.apache.commons.lang3.StringUtils; import org.apache.hadoop.hdds.utils.TransactionInfo; import org.apache.hadoop.ipc_.ProtobufRpcEngine; @@ -45,14 +46,17 @@ 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.response.OMClientResponse; +import org.apache.hadoop.ozone.om.upgrade.OMLayoutVersionManager; import org.apache.hadoop.ozone.protocol.proto.OzoneManagerProtocolProtos; import org.apache.hadoop.ozone.protocol.proto.OzoneManagerProtocolProtos.LayoutVersion; import org.apache.hadoop.ozone.protocol.proto.OzoneManagerProtocolProtos.OMRequest; import org.apache.hadoop.ozone.protocol.proto.OzoneManagerProtocolProtos.OMResponse; +import org.apache.hadoop.ozone.security.STSTokenIdentifier; import org.apache.hadoop.ozone.security.acl.IAccessAuthorizer; import org.apache.hadoop.ozone.security.acl.OzoneObj; import org.apache.hadoop.ozone.security.acl.OzoneObjInfo; @@ -111,15 +115,66 @@ public OMClientRequest(OMRequest omRequest) { */ public OMRequest preExecute(OzoneManager ozoneManager) throws IOException { - LayoutVersion layoutVersion = LayoutVersion.newBuilder() - .setVersion(ozoneManager.getVersionManager().getMetadataLayoutVersion()) - .build(); - omRequest = getOmRequest().toBuilder() - .setUserInfo(getUserIfNotExists(ozoneManager)) - .setLayoutVersion(layoutVersion).build(); + final OMRequest.Builder requestBuilder = getOmRequest().toBuilder() + .setUserInfo(getUserIfNotExists(ozoneManager)); + + // VersionManager is always expected in production OzoneManager instances. + // Some unit tests use a minimal mocked OzoneManager, so perform null check here. + final OMLayoutVersionManager versionManager = ozoneManager.getVersionManager(); + if (versionManager != null) { + final LayoutVersion layoutVersion = LayoutVersion.newBuilder() + .setVersion(versionManager.getMetadataLayoutVersion()) + .build(); + requestBuilder.setLayoutVersion(layoutVersion); + } + + if (requestBuilder.hasS3Authentication()) { + final OzoneManagerProtocolProtos.S3Authentication s3Auth = requestBuilder.getS3Authentication(); + final boolean hasSessionToken = s3Auth.hasSessionToken() && !s3Auth.getSessionToken().isEmpty(); + final STSTokenIdentifier stsTokenIdentifier = OzoneManager.getStsTokenIdentifier(); + + // This should not happen, so explicitly throw an error. An existing sessionToken + // implies prior STS validation must have populated the ThreadLocal. + if (ozoneManager.isSecurityEnabled() && hasSessionToken && stsTokenIdentifier == null) { + throw new OMException( + "S3Authentication has session token but no STS token identifier in OzoneManager ThreadLocal", + OMException.ResultCodes.INVALID_REQUEST); + } + + requestBuilder.setS3Authentication(resolveS3Authentication(s3Auth, stsTokenIdentifier)); + } + + omRequest = requestBuilder.build(); return omRequest; } + private static OzoneManagerProtocolProtos.S3Authentication resolveS3Authentication( + OzoneManagerProtocolProtos.S3Authentication s3Auth, STSTokenIdentifier stsTokenIdentifier) { + final OzoneManagerProtocolProtos.S3Authentication.Builder s3AuthBuilder = s3Auth.toBuilder(); + + if (s3Auth.hasSessionToken() && !s3Auth.getSessionToken().isEmpty() && stsTokenIdentifier != null) { + s3AuthBuilder.setResolvedStsSessionPolicy( + StringUtils.defaultString(stsTokenIdentifier.getSessionPolicy())); + s3AuthBuilder.setResolvedStsRoleArn( + StringUtils.defaultString(stsTokenIdentifier.getRoleArn())); + s3AuthBuilder.setResolvedStsOriginalAccessKeyId( + StringUtils.defaultString(stsTokenIdentifier.getOriginalAccessKeyId())); + s3AuthBuilder.setResolvedStsTempAccessKeyId( + StringUtils.defaultString(stsTokenIdentifier.getTempAccessKeyId())); + final UUID secretKeyId = stsTokenIdentifier.getSecretKeyId(); + s3AuthBuilder.setResolvedStsSecretKeyId( + secretKeyId != null ? secretKeyId.toString() : ""); + } else { + s3AuthBuilder.clearResolvedStsSessionPolicy(); + s3AuthBuilder.clearResolvedStsRoleArn(); + s3AuthBuilder.clearResolvedStsOriginalAccessKeyId(); + s3AuthBuilder.clearResolvedStsTempAccessKeyId(); + s3AuthBuilder.clearResolvedStsSecretKeyId(); + } + + return s3AuthBuilder.build(); + } + /** * Performs any request specific failure handling during request * submission. An example of this would be an undo of any steps @@ -167,11 +222,33 @@ public OzoneManagerProtocolProtos.UserInfo getUserInfo() throws IOException { OzoneManagerProtocolProtos.UserInfo.Builder userInfo = OzoneManagerProtocolProtos.UserInfo.newBuilder(); - // If S3 Authentication is set, determine user based on access ID. + // If S3 Authentication is set, determine user based on STS token first, + // falling back to accessId if session token not present. if (omRequest.hasS3Authentication()) { - String principal = OzoneAclUtils.accessIdToUserPrincipal( - omRequest.getS3Authentication().getAccessId()); - userInfo.setUserName(principal); + final String accessKeyId = omRequest.getS3Authentication().getAccessId(); + if (accessKeyId.startsWith(S3STSUtils.STS_TOKEN_PREFIX) && + !omRequest.getS3Authentication().hasSessionToken()) { + throw new IOException("Error with STS token", new AuthenticationException( + "Missing session token for accessKeyId: " + accessKeyId)); + } + if (omRequest.getS3Authentication().hasSessionToken()) { + try { + final String originalAccessKeyId = OzoneManager.getS3AuthEffectiveAccessId(); + if (originalAccessKeyId != null && !originalAccessKeyId.isEmpty()) { + final String principal = OzoneAclUtils.accessIdToUserPrincipal(originalAccessKeyId); + userInfo.setUserName(principal); + } else { + throw new AuthenticationException( + "Invalid STS Token - originalAccessKeyId was null or empty: " + originalAccessKeyId); + } + } catch (Exception e) { + throw new IOException("Error with STS Token", e); + } + } else { + String principal = OzoneAclUtils.accessIdToUserPrincipal( + omRequest.getS3Authentication().getAccessId()); + userInfo.setUserName(principal); + } } else if (user != null) { // Added not null checks, as in UT's these values might be null. userInfo.setUserName(user.getUserName()); @@ -311,7 +388,7 @@ protected void checkACLsWithFSO(OzoneManager ozoneManager, String volumeName, OmMetadataReader omMetadataReader = (OmMetadataReader) rcMetadataReader.get(); - omMetadataReader.checkAcls(obj, contextBuilder.build(), true); + omMetadataReader.checkAcls(obj, contextBuilder, true); } } } diff --git a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/bucket/OMBucketSetOwnerRequest.java b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/bucket/OMBucketSetOwnerRequest.java index 6d0c90cdca29..888e8b48dfb0 100644 --- a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/bucket/OMBucketSetOwnerRequest.java +++ b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/bucket/OMBucketSetOwnerRequest.java @@ -63,15 +63,16 @@ public OMBucketSetOwnerRequest(OMRequest omRequest) { @Override public OMRequest preExecute(OzoneManager ozoneManager) throws IOException { + final OMRequest omRequest = super.preExecute(ozoneManager); + long modificationTime = Time.now(); OzoneManagerProtocolProtos.SetBucketPropertyRequest.Builder - setBucketPropertyRequestBuilder = getOmRequest() + setBucketPropertyRequestBuilder = omRequest .getSetBucketPropertyRequest().toBuilder() .setModificationTime(modificationTime); - return getOmRequest().toBuilder() + return omRequest.toBuilder() .setSetBucketPropertyRequest(setBucketPropertyRequestBuilder) - .setUserInfo(getUserInfo()) .build(); } diff --git a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/bucket/OMBucketSetPropertyRequest.java b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/bucket/OMBucketSetPropertyRequest.java index a88e5fb73334..7563ba9e4b8b 100644 --- a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/bucket/OMBucketSetPropertyRequest.java +++ b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/bucket/OMBucketSetPropertyRequest.java @@ -79,14 +79,16 @@ public OMBucketSetPropertyRequest(OMRequest omRequest) { @Override public OMRequest preExecute(OzoneManager ozoneManager) throws IOException { + final OMRequest omRequest = super.preExecute(ozoneManager); + long modificationTime = Time.now(); OzoneManagerProtocolProtos.SetBucketPropertyRequest.Builder - setBucketPropertyRequestBuilder = getOmRequest() + setBucketPropertyRequestBuilder = omRequest .getSetBucketPropertyRequest().toBuilder() .setModificationTime(modificationTime); BucketArgs bucketArgs = - getOmRequest().getSetBucketPropertyRequest().getBucketArgs(); + omRequest.getSetBucketPropertyRequest().getBucketArgs(); if (bucketArgs.hasBekInfo()) { KeyProviderCryptoExtension kmsProvider = ozoneManager.getKmsProvider(); @@ -97,9 +99,8 @@ public OMRequest preExecute(OzoneManager ozoneManager) setBucketPropertyRequestBuilder.setBucketArgs(bucketArgsBuilder.build()); } - return getOmRequest().toBuilder() + return omRequest.toBuilder() .setSetBucketPropertyRequest(setBucketPropertyRequestBuilder) - .setUserInfo(getUserInfo()) .build(); } diff --git a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/bucket/acl/OMBucketAddAclRequest.java b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/bucket/acl/OMBucketAddAclRequest.java index 1b3ce4e3f84b..843e95865c2a 100644 --- a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/bucket/acl/OMBucketAddAclRequest.java +++ b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/bucket/acl/OMBucketAddAclRequest.java @@ -56,14 +56,15 @@ public class OMBucketAddAclRequest extends OMBucketAclRequest { @Override public OMRequest preExecute(OzoneManager ozoneManager) throws IOException { - long modificationTime = Time.now(); - OzoneManagerProtocolProtos.AddAclRequest.Builder addAclRequestBuilder = - getOmRequest().getAddAclRequest().toBuilder() + final OMRequest omRequest = super.preExecute(ozoneManager); + + final long modificationTime = Time.now(); + final OzoneManagerProtocolProtos.AddAclRequest.Builder addAclRequestBuilder = + omRequest.getAddAclRequest().toBuilder() .setModificationTime(modificationTime); - return getOmRequest().toBuilder() + return omRequest.toBuilder() .setAddAclRequest(addAclRequestBuilder) - .setUserInfo(getUserInfo()) .build(); } diff --git a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/bucket/acl/OMBucketRemoveAclRequest.java b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/bucket/acl/OMBucketRemoveAclRequest.java index 13839ddb58a7..4ac27878eb86 100644 --- a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/bucket/acl/OMBucketRemoveAclRequest.java +++ b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/bucket/acl/OMBucketRemoveAclRequest.java @@ -55,14 +55,15 @@ public class OMBucketRemoveAclRequest extends OMBucketAclRequest { @Override public OMRequest preExecute(OzoneManager ozoneManager) throws IOException { - long modificationTime = Time.now(); - OzoneManagerProtocolProtos.RemoveAclRequest.Builder removeAclRequestBuilder - = getOmRequest().getRemoveAclRequest().toBuilder() + final OMRequest omRequest = super.preExecute(ozoneManager); + + final long modificationTime = Time.now(); + final OzoneManagerProtocolProtos.RemoveAclRequest.Builder removeAclRequestBuilder = + omRequest.getRemoveAclRequest().toBuilder() .setModificationTime(modificationTime); - return getOmRequest().toBuilder() + return omRequest.toBuilder() .setRemoveAclRequest(removeAclRequestBuilder) - .setUserInfo(getUserInfo()) .build(); } diff --git a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/bucket/acl/OMBucketSetAclRequest.java b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/bucket/acl/OMBucketSetAclRequest.java index 97dca83c1978..678c4ba0dc86 100644 --- a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/bucket/acl/OMBucketSetAclRequest.java +++ b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/bucket/acl/OMBucketSetAclRequest.java @@ -54,14 +54,15 @@ public class OMBucketSetAclRequest extends OMBucketAclRequest { @Override public OMRequest preExecute(OzoneManager ozoneManager) throws IOException { - long modificationTime = Time.now(); - OzoneManagerProtocolProtos.SetAclRequest.Builder setAclRequestBuilder = + final long modificationTime = Time.now(); + final OzoneManagerProtocolProtos.SetAclRequest.Builder setAclRequestBuilder = getOmRequest().getSetAclRequest().toBuilder() .setModificationTime(modificationTime); - return getOmRequest().toBuilder() + // super.preExecute resolves S3Authentication (STS) for Ratis apply. Merge SetAclRequest changes on top. + final OMRequest request = super.preExecute(ozoneManager); + return request.toBuilder() .setSetAclRequest(setAclRequestBuilder) - .setUserInfo(getUserInfo()) .build(); } diff --git a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/file/OMFileCreateRequest.java b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/file/OMFileCreateRequest.java index 35e1ac238f7b..1fb1c22ebeba 100644 --- a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/file/OMFileCreateRequest.java +++ b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/file/OMFileCreateRequest.java @@ -82,7 +82,7 @@ public OMFileCreateRequest(OMRequest omRequest, BucketLayout bucketLayout) { @Override public OMRequest preExecute(OzoneManager ozoneManager) throws IOException { - CreateFileRequest createFileRequest = super.preExecute(ozoneManager) + final CreateFileRequest createFileRequest = super.preExecute(ozoneManager) .getCreateFileRequest(); Objects.requireNonNull(createFileRequest, "createFileRequest == null"); diff --git a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/file/OMRecoverLeaseRequest.java b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/file/OMRecoverLeaseRequest.java index 1eca4893d112..fb58c58e550b 100644 --- a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/file/OMRecoverLeaseRequest.java +++ b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/file/OMRecoverLeaseRequest.java @@ -96,7 +96,7 @@ public OMRecoverLeaseRequest(OMRequest omRequest) { @Override @DisallowedUntilLayoutVersion(HBASE_SUPPORT) public OMRequest preExecute(OzoneManager ozoneManager) throws IOException { - OMRequest request = super.preExecute(ozoneManager); + final OMRequest request = super.preExecute(ozoneManager); RecoverLeaseRequest recoverLeaseRequest = request.getRecoverLeaseRequest(); String keyPath = recoverLeaseRequest.getKeyName(); diff --git a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/key/OMKeyCommitRequest.java b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/key/OMKeyCommitRequest.java index 0789e1288507..6e4386aae5f8 100644 --- a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/key/OMKeyCommitRequest.java +++ b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/key/OMKeyCommitRequest.java @@ -62,6 +62,7 @@ import org.apache.hadoop.ozone.om.response.key.OMKeyCommitResponse; import org.apache.hadoop.ozone.om.upgrade.OMLayoutFeature; import org.apache.hadoop.ozone.protocol.proto.OzoneManagerProtocolProtos.CommitKeyRequest; +import org.apache.hadoop.ozone.protocol.proto.OzoneManagerProtocolProtos.CommitKeyResponse; import org.apache.hadoop.ozone.protocol.proto.OzoneManagerProtocolProtos.KeyArgs; import org.apache.hadoop.ozone.protocol.proto.OzoneManagerProtocolProtos.KeyLocation; import org.apache.hadoop.ozone.protocol.proto.OzoneManagerProtocolProtos.OMRequest; @@ -87,7 +88,7 @@ public OMKeyCommitRequest(OMRequest omRequest, BucketLayout bucketLayout) { @Override public OMRequest preExecute(OzoneManager ozoneManager) throws IOException { - OMRequest request = super.preExecute(ozoneManager); + final OMRequest request = super.preExecute(ozoneManager); CommitKeyRequest commitKeyRequest = request.getCommitKeyRequest(); Objects.requireNonNull(commitKeyRequest, "commitKeyRequest == null"); @@ -406,6 +407,10 @@ public OMClientResponse validateAndUpdateCache(OzoneManager ozoneManager, Execut omBucketInfo.incrUsedBytes(correctedSpace); + omResponse.setCommitKeyResponse(CommitKeyResponse.newBuilder() + .setModificationTime(commitKeyArgs.getModificationTime()) + .build()); + omClientResponse = new OMKeyCommitResponse(omResponse.build(), omKeyInfo, dbOzoneKey, dbOpenKey, omBucketInfo.copyObject(), oldKeyVersionsToDeleteMap, isHSync, newOpenKeyInfo, dbOpenKeyToDeleteKey, openKeyToDelete); diff --git a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/key/OMKeyCommitRequestWithFSO.java b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/key/OMKeyCommitRequestWithFSO.java index 9bc6f7ec0d9d..8b3d32bdc1f7 100644 --- a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/key/OMKeyCommitRequestWithFSO.java +++ b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/key/OMKeyCommitRequestWithFSO.java @@ -52,6 +52,7 @@ import org.apache.hadoop.ozone.om.response.OMClientResponse; import org.apache.hadoop.ozone.om.response.key.OMKeyCommitResponseWithFSO; import org.apache.hadoop.ozone.protocol.proto.OzoneManagerProtocolProtos.CommitKeyRequest; +import org.apache.hadoop.ozone.protocol.proto.OzoneManagerProtocolProtos.CommitKeyResponse; import org.apache.hadoop.ozone.protocol.proto.OzoneManagerProtocolProtos.KeyArgs; import org.apache.hadoop.ozone.protocol.proto.OzoneManagerProtocolProtos.OMRequest; import org.apache.hadoop.ozone.protocol.proto.OzoneManagerProtocolProtos.OMResponse; @@ -350,6 +351,10 @@ public OMClientResponse validateAndUpdateCache(OzoneManager ozoneManager, Execut omBucketInfo.incrUsedBytes(correctedSpace); + omResponse.setCommitKeyResponse(CommitKeyResponse.newBuilder() + .setModificationTime(commitKeyArgs.getModificationTime()) + .build()); + omClientResponse = new OMKeyCommitResponseWithFSO(omResponse.build(), omKeyInfo, dbFileKey, dbOpenFileKey, omBucketInfo.copyObject(), oldKeyVersionsToDeleteMap, volumeId, isHSync, newOpenKeyInfo, dbOpenKeyToDeleteKey, openKeyToDelete); diff --git a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/key/OMKeyCreateRequest.java b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/key/OMKeyCreateRequest.java index 6059d6c46c75..71d49df7bfcd 100644 --- a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/key/OMKeyCreateRequest.java +++ b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/key/OMKeyCreateRequest.java @@ -91,7 +91,7 @@ public OMKeyCreateRequest(OMRequest omRequest, BucketLayout bucketLayout) { @Override public OMRequest preExecute(OzoneManager ozoneManager) throws IOException { - CreateKeyRequest createKeyRequest = super.preExecute(ozoneManager) + final CreateKeyRequest createKeyRequest = super.preExecute(ozoneManager) .getCreateKeyRequest(); Objects.requireNonNull(createKeyRequest, "createKeyRequest == null"); diff --git a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/key/OMKeyDeleteRequest.java b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/key/OMKeyDeleteRequest.java index 26287ca66d26..1aa65f362544 100644 --- a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/key/OMKeyDeleteRequest.java +++ b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/key/OMKeyDeleteRequest.java @@ -75,7 +75,7 @@ public OMKeyDeleteRequest(OMRequest omRequest, BucketLayout bucketLayout) { @Override public OMRequest preExecute(OzoneManager ozoneManager) throws IOException { - DeleteKeyRequest deleteKeyRequest = super.preExecute(ozoneManager) + final DeleteKeyRequest deleteKeyRequest = super.preExecute(ozoneManager) .getDeleteKeyRequest(); Objects.requireNonNull(deleteKeyRequest, "deleteKeyRequest == null"); diff --git a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/key/OMKeyRenameRequest.java b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/key/OMKeyRenameRequest.java index 850f111a913f..9c46eba8f46f 100644 --- a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/key/OMKeyRenameRequest.java +++ b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/key/OMKeyRenameRequest.java @@ -72,7 +72,7 @@ public OMKeyRenameRequest(OMRequest omRequest, BucketLayout bucketLayout) { @Override public OMRequest preExecute(OzoneManager ozoneManager) throws IOException { - RenameKeyRequest renameKeyRequest = super.preExecute(ozoneManager) + final RenameKeyRequest renameKeyRequest = super.preExecute(ozoneManager) .getRenameKeyRequest(); Objects.requireNonNull(renameKeyRequest, "renameKeyRequest == null"); diff --git a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/key/OMKeySetTimesRequest.java b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/key/OMKeySetTimesRequest.java index eef06ef2b41e..fc46fbe34091 100644 --- a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/key/OMKeySetTimesRequest.java +++ b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/key/OMKeySetTimesRequest.java @@ -62,7 +62,7 @@ public class OMKeySetTimesRequest extends OMKeyRequest { @Override public OMRequest preExecute(OzoneManager ozoneManager) throws IOException { - OMRequest request = super.preExecute(ozoneManager); + final OMRequest request = super.preExecute(ozoneManager); SetTimesRequest setTimesRequest = request.getSetTimesRequest(); String keyPath = setTimesRequest.getKeyArgs().getKeyName(); String normalizedKeyPath = diff --git a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/key/OMKeysDeleteRequest.java b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/key/OMKeysDeleteRequest.java index 3fc15da75ee9..9a60ebbd391c 100644 --- a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/key/OMKeysDeleteRequest.java +++ b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/key/OMKeysDeleteRequest.java @@ -96,7 +96,8 @@ public OMKeysDeleteRequest(OMRequest omRequest, BucketLayout bucketLayout) { @Override public OMRequest preExecute(OzoneManager ozoneManager) throws IOException { - DeleteKeysRequest deleteKeysRequest = super.preExecute(ozoneManager).getDeleteKeysRequest(); + final OMRequest omRequest = super.preExecute(ozoneManager); + DeleteKeysRequest deleteKeysRequest = omRequest.getDeleteKeysRequest(); Objects.requireNonNull(deleteKeysRequest, "deleteKeysRequest == null"); if (deleteKeysRequest.getSourceType() == RequestSource.LIFECYCLE && deleteKeysRequest.hasScanState()) { @@ -110,7 +111,7 @@ public OMRequest preExecute(OzoneManager ozoneManager) throws IOException { } } - return getOmRequest(); + return omRequest; } @Override @SuppressWarnings("methodlength") diff --git a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/key/acl/OMKeyAddAclRequest.java b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/key/acl/OMKeyAddAclRequest.java index 367e3c87db68..777ff972453c 100644 --- a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/key/acl/OMKeyAddAclRequest.java +++ b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/key/acl/OMKeyAddAclRequest.java @@ -63,14 +63,15 @@ public class OMKeyAddAclRequest extends OMKeyAclRequest { @Override public OMRequest preExecute(OzoneManager ozoneManager) throws IOException { - long modificationTime = Time.now(); - OzoneManagerProtocolProtos.AddAclRequest.Builder addAclRequestBuilder = - getOmRequest().getAddAclRequest().toBuilder() + final OMRequest omRequest = super.preExecute(ozoneManager); + + final long modificationTime = Time.now(); + final OzoneManagerProtocolProtos.AddAclRequest.Builder addAclRequestBuilder = + omRequest.getAddAclRequest().toBuilder() .setModificationTime(modificationTime); - return getOmRequest().toBuilder() + return omRequest.toBuilder() .setAddAclRequest(addAclRequestBuilder) - .setUserInfo(getUserInfo()) .build(); } diff --git a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/key/acl/OMKeyRemoveAclRequest.java b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/key/acl/OMKeyRemoveAclRequest.java index 0de996fd28ad..8761b7db359b 100644 --- a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/key/acl/OMKeyRemoveAclRequest.java +++ b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/key/acl/OMKeyRemoveAclRequest.java @@ -63,14 +63,15 @@ public class OMKeyRemoveAclRequest extends OMKeyAclRequest { @Override public OMRequest preExecute(OzoneManager ozoneManager) throws IOException { - long modificationTime = Time.now(); - OzoneManagerProtocolProtos.RemoveAclRequest.Builder removeAclRequestBuilder - = getOmRequest().getRemoveAclRequest().toBuilder() + final OMRequest omRequest = super.preExecute(ozoneManager); + + final long modificationTime = Time.now(); + final OzoneManagerProtocolProtos.RemoveAclRequest.Builder removeAclRequestBuilder = + omRequest.getRemoveAclRequest().toBuilder() .setModificationTime(modificationTime); - return getOmRequest().toBuilder() + return omRequest.toBuilder() .setRemoveAclRequest(removeAclRequestBuilder) - .setUserInfo(getUserInfo()) .build(); } diff --git a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/key/acl/OMKeySetAclRequest.java b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/key/acl/OMKeySetAclRequest.java index b0bd2f8fe528..bd9b7f677b47 100644 --- a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/key/acl/OMKeySetAclRequest.java +++ b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/key/acl/OMKeySetAclRequest.java @@ -64,14 +64,15 @@ public class OMKeySetAclRequest extends OMKeyAclRequest { @Override public OMRequest preExecute(OzoneManager ozoneManager) throws IOException { - long modificationTime = Time.now(); - OzoneManagerProtocolProtos.SetAclRequest.Builder setAclRequestBuilder = - getOmRequest().getSetAclRequest().toBuilder() + final OMRequest omRequest = super.preExecute(ozoneManager); + + final long modificationTime = Time.now(); + final OzoneManagerProtocolProtos.SetAclRequest.Builder setAclRequestBuilder = + omRequest.getSetAclRequest().toBuilder() .setModificationTime(modificationTime); - return getOmRequest().toBuilder() + return omRequest.toBuilder() .setSetAclRequest(setAclRequestBuilder) - .setUserInfo(getUserInfo()) .build(); } diff --git a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/lifecycle/OMLifecycleConfigurationDeleteRequest.java b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/lifecycle/OMLifecycleConfigurationDeleteRequest.java index 3d4100e06fc9..bdcbd7379acd 100644 --- a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/lifecycle/OMLifecycleConfigurationDeleteRequest.java +++ b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/lifecycle/OMLifecycleConfigurationDeleteRequest.java @@ -66,7 +66,7 @@ public OMLifecycleConfigurationDeleteRequest(OMRequest omRequest) { @Override public OMRequest preExecute(OzoneManager ozoneManager) throws IOException { - OMRequest request = super.preExecute(ozoneManager); + final OMRequest request = super.preExecute(ozoneManager); DeleteLifecycleConfigurationRequest deleteLifecycleConfigurationRequest = request.getDeleteLifecycleConfigurationRequest(); diff --git a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/lifecycle/OMLifecycleConfigurationSetRequest.java b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/lifecycle/OMLifecycleConfigurationSetRequest.java index 2959c05320d4..e57239a32b60 100644 --- a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/lifecycle/OMLifecycleConfigurationSetRequest.java +++ b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/lifecycle/OMLifecycleConfigurationSetRequest.java @@ -75,7 +75,7 @@ public OMLifecycleConfigurationSetRequest(OMRequest omRequest) { @Override public OMRequest preExecute(OzoneManager ozoneManager) throws IOException { - OMRequest omRequest = super.preExecute(ozoneManager); + final OMRequest omRequest = super.preExecute(ozoneManager); SetLifecycleConfigurationRequest request = omRequest.getSetLifecycleConfigurationRequest(); LifecycleConfiguration lifecycleConfiguration = diff --git a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/lifecycle/OMLifecycleSaveScanStateRequest.java b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/lifecycle/OMLifecycleSaveScanStateRequest.java index 8a97f9d41911..91d9ff62fd0f 100644 --- a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/lifecycle/OMLifecycleSaveScanStateRequest.java +++ b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/lifecycle/OMLifecycleSaveScanStateRequest.java @@ -44,7 +44,7 @@ public OMLifecycleSaveScanStateRequest(OMRequest omRequest) { @Override public OMRequest preExecute(OzoneManager ozoneManager) throws IOException { - OMRequest omRequest = super.preExecute(ozoneManager); + final OMRequest omRequest = super.preExecute(ozoneManager); if (ozoneManager.isAdminAuthorizationEnabled()) { UserGroupInformation ugi = createUGIForApi(); if (!ozoneManager.isAdmin(ugi)) { diff --git a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/lifecycle/OMLifecycleSetServiceStatusRequest.java b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/lifecycle/OMLifecycleSetServiceStatusRequest.java index 67dea0c363a5..3aa18daf0b71 100644 --- a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/lifecycle/OMLifecycleSetServiceStatusRequest.java +++ b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/lifecycle/OMLifecycleSetServiceStatusRequest.java @@ -57,7 +57,7 @@ public OMLifecycleSetServiceStatusRequest(OMRequest omRequest) { @Override public OMRequest preExecute(OzoneManager ozoneManager) throws IOException { - OMRequest request = super.preExecute(ozoneManager); + final OMRequest request = super.preExecute(ozoneManager); if (ozoneManager.isAdminAuthorizationEnabled()) { boolean suspend = request.getSetLifecycleServiceStatusRequest().getSuspend(); diff --git a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/s3/multipart/S3MultipartUploadAbortRequest.java b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/s3/multipart/S3MultipartUploadAbortRequest.java index 7f67856ff8b7..bd7c738812ba 100644 --- a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/s3/multipart/S3MultipartUploadAbortRequest.java +++ b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/s3/multipart/S3MultipartUploadAbortRequest.java @@ -82,7 +82,7 @@ public S3MultipartUploadAbortRequest(OMRequest omRequest, @Override public OMRequest preExecute(OzoneManager ozoneManager) throws IOException { - KeyArgs keyArgs = super.preExecute(ozoneManager) + final KeyArgs keyArgs = super.preExecute(ozoneManager) .getAbortMultiPartUploadRequest().getKeyArgs(); String keyPath = keyArgs.getKeyName(); keyPath = validateAndNormalizeKey(ozoneManager.getEnableFileSystemPaths(), diff --git a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/s3/multipart/S3MultipartUploadCommitPartRequest.java b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/s3/multipart/S3MultipartUploadCommitPartRequest.java index 24fe698336a4..89d82cc1605a 100644 --- a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/s3/multipart/S3MultipartUploadCommitPartRequest.java +++ b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/s3/multipart/S3MultipartUploadCommitPartRequest.java @@ -312,6 +312,7 @@ public OMClientResponse validateAndUpdateCache(OzoneManager ozoneManager, Execut if (eTag != null) { commitResponseBuilder.setETag(eTag); } + commitResponseBuilder.setModificationTime(keyArgs.getModificationTime()); omResponse.setCommitMultiPartUploadResponse(commitResponseBuilder); omClientResponse = getOmClientResponse(ozoneManager, keyVersionsToDeleteMap, openKey, diff --git a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/s3/security/OMSetSecretRequest.java b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/s3/security/OMSetSecretRequest.java index 4b0fceb821af..9f8d6ca1ee29 100644 --- a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/s3/security/OMSetSecretRequest.java +++ b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/s3/security/OMSetSecretRequest.java @@ -55,11 +55,11 @@ public OMSetSecretRequest(OMRequest omRequest) { @Override public OMRequest preExecute(OzoneManager ozoneManager) throws IOException { + final OMRequest omRequest = super.preExecute(ozoneManager); final OMMetadataManager omMetadataManager = ozoneManager.getMetadataManager(); - final SetS3SecretRequest request = - getOmRequest().getSetS3SecretRequest(); + final SetS3SecretRequest request = omRequest.getSetS3SecretRequest(); final String accessId = request.getAccessId(); @@ -94,7 +94,7 @@ public OMRequest preExecute(OzoneManager ozoneManager) throws IOException { S3SecretRequestHelper.checkAccessIdSecretOpPermission( ozoneManager, ugi, accessId); - return getOmRequest(); + return omRequest; } @Override diff --git a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/s3/security/S3AssumeRoleRequest.java b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/s3/security/S3AssumeRoleRequest.java new file mode 100644 index 000000000000..173ae19461fe --- /dev/null +++ b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/s3/security/S3AssumeRoleRequest.java @@ -0,0 +1,611 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.hadoop.ozone.om.request.s3.security; + +import static org.apache.hadoop.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; +import com.google.common.base.Strings; +import java.io.IOException; +import java.net.InetAddress; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.security.SecureRandom; +import java.time.Clock; +import java.time.Instant; +import java.util.EnumSet; +import java.util.HashMap; +import java.util.LinkedHashSet; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import org.apache.commons.lang3.StringUtils; +import org.apache.commons.lang3.tuple.Pair; +import org.apache.hadoop.hdds.scm.client.HddsClientUtils; +import org.apache.hadoop.ipc_.ProtobufRpcEngine; +import org.apache.hadoop.ozone.OzoneConsts; +import org.apache.hadoop.ozone.audit.AuditLogger; +import org.apache.hadoop.ozone.audit.OMAction; +import org.apache.hadoop.ozone.om.OzoneAclUtils; +import org.apache.hadoop.ozone.om.OzoneManager; +import org.apache.hadoop.ozone.om.ResolvedBucket; +import org.apache.hadoop.ozone.om.exceptions.OMException; +import org.apache.hadoop.ozone.om.execution.flowcontrol.ExecutionContext; +import org.apache.hadoop.ozone.om.helpers.AwsRoleArnValidator; +import org.apache.hadoop.ozone.om.helpers.S3STSUtils; +import org.apache.hadoop.ozone.om.request.OMClientRequest; +import org.apache.hadoop.ozone.om.request.util.OmResponseUtil; +import org.apache.hadoop.ozone.om.response.OMClientResponse; +import org.apache.hadoop.ozone.om.response.s3.security.S3AssumeRoleResponse; +import org.apache.hadoop.ozone.protocol.proto.OzoneManagerProtocolProtos; +import org.apache.hadoop.ozone.protocol.proto.OzoneManagerProtocolProtos.AssumeRoleRequest; +import org.apache.hadoop.ozone.protocol.proto.OzoneManagerProtocolProtos.AssumeRoleResponse; +import org.apache.hadoop.ozone.protocol.proto.OzoneManagerProtocolProtos.OMRequest; +import org.apache.hadoop.ozone.protocol.proto.OzoneManagerProtocolProtos.UpdateAssumeRoleRequest; +import org.apache.hadoop.ozone.security.STSTokenSecretManager; +import org.apache.hadoop.ozone.security.acl.IAccessAuthorizer.ACLType; +import org.apache.hadoop.ozone.security.acl.IOzoneObj; +import org.apache.hadoop.ozone.security.acl.OzoneObj; +import org.apache.hadoop.ozone.security.acl.OzoneObjInfo; +import org.apache.hadoop.ozone.security.acl.iam.IamSessionPolicyResolver; +import org.apache.hadoop.security.UserGroupInformation; + +/** + * Handles S3AssumeRoleRequest request. + */ +public class S3AssumeRoleRequest extends OMClientRequest { + + private static final SecureRandom SECURE_RANDOM; + + static { + SecureRandom secureRandom; + try { + // Prefer non-blocking native PRNG where available + secureRandom = SecureRandom.getInstance("NativePRNGNonBlocking"); + } catch (Exception e) { + // Fallback to default SecureRandom implementation + secureRandom = new SecureRandom(); + } + SECURE_RANDOM = secureRandom; + } + + private static final int 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_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(); + + private final Clock clock; + + public S3AssumeRoleRequest(OMRequest omRequest, Clock clock) { + super(omRequest); + this.clock = clock; + } + + @Override + public OMRequest preExecute(OzoneManager ozoneManager) throws IOException { + final OMRequest omRequest = super.preExecute(ozoneManager); + final AssumeRoleRequest assumeRoleRequest = omRequest.getAssumeRoleRequest(); + + final int durationSeconds = assumeRoleRequest.getDurationSeconds(); + final String roleSessionName = assumeRoleRequest.getRoleSessionName(); + final String roleArn = assumeRoleRequest.getRoleArn(); + final String awsIamSessionPolicy = assumeRoleRequest.getAwsIamSessionPolicy(); + final String requestId = assumeRoleRequest.getRequestId(); + final OzoneManagerProtocolProtos.UserInfo userInfo = omRequest.getUserInfo(); + final AuditLogger auditLogger = ozoneManager.getAuditLogger(); + final Map auditMap = new HashMap<>(); + S3STSUtils.addAssumeRoleAuditParams( + auditMap, roleArn, roleSessionName, awsIamSessionPolicy, durationSeconds, requestId); + + try { + if (!omRequest.hasS3Authentication()) { + throw new OMException( + "S3AssumeRoleRequest does not have S3 authentication", OMException.ResultCodes.INVALID_REQUEST); + } + + // Brief overview of flow: + // The STS Endpoint makes the AssumeRole call, which when received by OM leader (via this method), + // it will validate the request, authorize via Ranger, generate temporary credentials + // (tempAccessKeyId, secretAccessKey), roleId, and the signed session token. + // The original AssumeRole request is converted to an UpdateAssumeRoleRequest with the generated + // values. This update request will be submitted to Ratis and replicated across all OMs. + // All OMs in HA mode therefore will have identical audit logs with the same tempAccessKeyId. + S3STSUtils.validateDuration(durationSeconds); + S3STSUtils.validateRoleSessionName(roleSessionName); + final String targetRoleName = AwsRoleArnValidator.validateAndExtractRoleNameFromArn(roleArn); + + // Generate temporary AWS credentials using cryptographically strong SecureRandom, and a + // deterministic roleId derived from the role ARN. + final String tempAccessKeyId = STS_TOKEN_PREFIX + generateSecureRandomStringUsingChars( + STS_ACCESS_KEY_ID_ALLOWED_CHARS, STS_ACCESS_KEY_ID_ALLOWED_CHARS_LENGTH, + STS_ACCESS_KEY_ID_RANDOM_LENGTH); + final String secretAccessKey = generateSecureRandomStringUsingChars( + CHARS_FOR_SECRET_ACCESS_KEYS, CHARS_FOR_SECRET_ACCESS_KEYS_LENGTH, STS_SECRET_ACCESS_KEY_LENGTH); + final String roleId = generateDeterministicRoleId(roleArn); + final String assumedRoleId = roleId + ":" + roleSessionName; + final String assumedRoleUserArn = S3STSUtils.toAssumedRoleUserArn(roleArn, roleSessionName); + + final Instant creationInstant = clock.instant(); + final String sessionToken = generateSessionToken(GenerateSessionTokenParams.newBuilder() + .setTargetRoleName(targetRoleName) + .setOmRequest(omRequest) + .setOzoneManager(ozoneManager) + .setAssumeRoleRequest(assumeRoleRequest) + .setSecretAccessKey(secretAccessKey) + .setTempAccessKeyId(tempAccessKeyId) + .setAssumedRoleId(assumedRoleId) + .setAssumedRoleUserArn(assumedRoleUserArn) + .setCreationTime(creationInstant) + .build()); + final long expirationEpochSeconds = creationInstant.plusSeconds(durationSeconds).getEpochSecond(); + auditMap.put(OzoneConsts.S3_STS_TEMP_ACCESS_KEY_ID, tempAccessKeyId); + + // Build UpdateAssumeRoleRequest with leader-generated credentials and session token + final UpdateAssumeRoleRequest.Builder updateAssumeRoleRequestBuilder = + UpdateAssumeRoleRequest.newBuilder() + .setRoleArn(roleArn) + .setRoleSessionName(roleSessionName) + .setDurationSeconds(durationSeconds) + .setRequestId(requestId) + .setTempAccessKeyId(tempAccessKeyId) + .setSecretAccessKey(secretAccessKey) + .setRoleId(roleId) + .setSessionToken(sessionToken) + .setExpirationEpochSeconds(expirationEpochSeconds); + + if (assumeRoleRequest.hasAwsIamSessionPolicy()) { + updateAssumeRoleRequestBuilder.setAwsIamSessionPolicy(awsIamSessionPolicy); + } + + return omRequest.toBuilder() + .setUpdateAssumeRoleRequest(updateAssumeRoleRequestBuilder.build()) + .build(); + } catch (OMException e) { + markForAudit(auditLogger, buildAuditMessage(OMAction.S3_ASSUME_ROLE, auditMap, e, userInfo)); + throw e; + } catch (IOException e) { + final OMException omException = new OMException( + "Failed to generate STS token for role: " + roleArn, e, OMException.ResultCodes.INTERNAL_ERROR); + markForAudit(auditLogger, buildAuditMessage(OMAction.S3_ASSUME_ROLE, auditMap, omException, userInfo)); + throw omException; + } + } + + @Override + public OMClientResponse validateAndUpdateCache(OzoneManager ozoneManager, ExecutionContext context) { + final OMRequest omRequest = getOmRequest(); + final AssumeRoleRequest assumeRoleRequest = omRequest.getAssumeRoleRequest(); + final UpdateAssumeRoleRequest updateAssumeRoleRequest = omRequest.getUpdateAssumeRoleRequest(); + + final int durationSeconds = assumeRoleRequest.getDurationSeconds(); + final String roleSessionName = assumeRoleRequest.getRoleSessionName(); + final String roleArn = assumeRoleRequest.getRoleArn(); + final String awsIamSessionPolicy = assumeRoleRequest.getAwsIamSessionPolicy(); + final String requestId = assumeRoleRequest.getRequestId(); + + // Extract leader-generated credentials and roleId from UpdateAssumeRoleRequest + final String tempAccessKeyId = updateAssumeRoleRequest.getTempAccessKeyId(); + final String secretAccessKey = updateAssumeRoleRequest.getSecretAccessKey(); + final String roleId = updateAssumeRoleRequest.getRoleId(); + final String sessionToken = updateAssumeRoleRequest.getSessionToken(); + final long expirationEpochSeconds = updateAssumeRoleRequest.getExpirationEpochSeconds(); + + final Map auditMap = new HashMap<>(); + final AuditLogger auditLogger = ozoneManager.getAuditLogger(); + final OzoneManagerProtocolProtos.UserInfo userInfo = omRequest.getUserInfo(); + S3STSUtils.addAssumeRoleAuditParams( + auditMap, roleArn, roleSessionName, awsIamSessionPolicy, durationSeconds, requestId); + + Exception exception = null; + OMClientResponse omClientResponse; + try { + if (Strings.isNullOrEmpty(tempAccessKeyId) || Strings.isNullOrEmpty(secretAccessKey) || + Strings.isNullOrEmpty(roleId) || Strings.isNullOrEmpty(sessionToken) || expirationEpochSeconds <= 0) { + throw new OMException( + "UpdateAssumeRoleRequest is missing leader-generated AssumeRole fields", + OMException.ResultCodes.INVALID_REQUEST); + } + + final String assumedRoleId = roleId + ":" + roleSessionName; + + auditMap.put(OzoneConsts.S3_STS_TEMP_ACCESS_KEY_ID, tempAccessKeyId); + + final AssumeRoleResponse.Builder responseBuilder = AssumeRoleResponse.newBuilder() + .setAccessKeyId(tempAccessKeyId) + .setSecretAccessKey(secretAccessKey) + .setSessionToken(sessionToken) + .setExpirationEpochSeconds(expirationEpochSeconds) + .setAssumedRoleId(assumedRoleId); + + omClientResponse = new S3AssumeRoleResponse( + OmResponseUtil.getOMResponseBuilder(omRequest) + .setAssumeRoleResponse(responseBuilder.build()) + .build()); + } catch (OMException e) { + exception = e; + omClientResponse = new S3AssumeRoleResponse( + createErrorOMResponse(OmResponseUtil.getOMResponseBuilder(omRequest), e)); + } + + markForAudit(auditLogger, buildAuditMessage(OMAction.S3_ASSUME_ROLE, auditMap, exception, userInfo)); + + return omClientResponse; + } + + /** + * Generates session token using components from the AssumeRoleRequest. + */ + private String generateSessionToken(GenerateSessionTokenParams params) throws IOException { + final OzoneManager ozoneManager = params.getOzoneManager(); + final OMRequest omRequest = params.getOmRequest(); + final AssumeRoleRequest assumeRoleRequest = params.getAssumeRoleRequest(); + + InetAddress remoteIp = ProtobufRpcEngine.Server.getRemoteIp(); + if (remoteIp == null) { + remoteIp = ozoneManager.getOmRpcServerAddr().getAddress(); + } + + final String hostName = remoteIp != null ? remoteIp.getHostName() : ozoneManager.getOmRpcServerAddr().getHostName(); + + // Determine the caller's access key ID - this will be referred to as the original + // access key id. When STS tokens are used, the tokens will be authorized as + // the kerberos principal associated to the original access key id, in conjunction with the + // role permissions and optional AWS IAM session policy permissions. + final String originalAccessKeyId = omRequest.getS3Authentication().getAccessId(); + + final String principal = OzoneAclUtils.accessIdToUserPrincipal(originalAccessKeyId); + final UserGroupInformation ugi = UserGroupInformation.createRemoteUser(principal); + + final String roleArn = assumeRoleRequest.getRoleArn(); + final String sessionPolicy = getSessionPolicy( + ozoneManager, originalAccessKeyId, assumeRoleRequest.getAwsIamSessionPolicy(), hostName, remoteIp, ugi, + params.getTargetRoleName()); + + return ozoneManager.getSTSTokenSecretManager().createSTSTokenString( + STSTokenSecretManager.CreateSTSTokenParams.newBuilder() + .setTempAccessKeyId(params.getTempAccessKeyId()) + .setOriginalAccessKeyId(originalAccessKeyId) + .setRoleArn(roleArn) + .setDurationSeconds(assumeRoleRequest.getDurationSeconds()) + .setSecretAccessKey(params.getSecretAccessKey()) + .setSessionPolicy(sessionPolicy) + .setAssumedRoleId(params.getAssumedRoleId()) + .setAssumedRoleUserArn(params.getAssumedRoleUserArn()) + .setCreationTime(params.getCreationTime()) + .build()); + } + + /** + * Parameters for {@link #generateSessionToken(GenerateSessionTokenParams)}. + */ + private static final class GenerateSessionTokenParams { + private final String targetRoleName; + private final OMRequest omRequest; + private final OzoneManager ozoneManager; + private final AssumeRoleRequest assumeRoleRequest; + private final String secretAccessKey; + private final String tempAccessKeyId; + private final String assumedRoleId; + private final String assumedRoleUserArn; + private final Instant creationInstant; + + private GenerateSessionTokenParams(Builder builder) { + this.targetRoleName = builder.targetRoleName; + this.omRequest = builder.omRequest; + this.ozoneManager = builder.ozoneManager; + this.assumeRoleRequest = builder.assumeRoleRequest; + this.secretAccessKey = builder.secretAccessKey; + this.tempAccessKeyId = builder.tempAccessKeyId; + this.assumedRoleId = builder.assumedRoleId; + this.assumedRoleUserArn = builder.assumedRoleUserArn; + this.creationInstant = builder.creationInstant; + } + + static Builder newBuilder() { + return new Builder(); + } + + String getTargetRoleName() { + return targetRoleName; + } + + OMRequest getOmRequest() { + return omRequest; + } + + OzoneManager getOzoneManager() { + return ozoneManager; + } + + AssumeRoleRequest getAssumeRoleRequest() { + return assumeRoleRequest; + } + + String getSecretAccessKey() { + return secretAccessKey; + } + + String getTempAccessKeyId() { + return tempAccessKeyId; + } + + String getAssumedRoleId() { + return assumedRoleId; + } + + String getAssumedRoleUserArn() { + return assumedRoleUserArn; + } + + Instant getCreationTime() { + return creationInstant; + } + + private static final class Builder { + private String targetRoleName; + private OMRequest omRequest; + private OzoneManager ozoneManager; + private AssumeRoleRequest assumeRoleRequest; + private String secretAccessKey; + private String tempAccessKeyId; + private String assumedRoleId; + private String assumedRoleUserArn; + private Instant creationInstant; + + Builder setTargetRoleName(String value) { + this.targetRoleName = value; + return this; + } + + Builder setOmRequest(OMRequest value) { + this.omRequest = value; + return this; + } + + Builder setOzoneManager(OzoneManager value) { + this.ozoneManager = value; + return this; + } + + Builder setAssumeRoleRequest(AssumeRoleRequest value) { + this.assumeRoleRequest = value; + return this; + } + + Builder setSecretAccessKey(String value) { + this.secretAccessKey = value; + return this; + } + + Builder setTempAccessKeyId(String value) { + this.tempAccessKeyId = value; + return this; + } + + Builder setAssumedRoleId(String value) { + this.assumedRoleId = value; + return this; + } + + Builder setAssumedRoleUserArn(String value) { + this.assumedRoleUserArn = value; + return this; + } + + Builder setCreationTime(Instant instant) { + this.creationInstant = instant; + return this; + } + + GenerateSessionTokenParams build() { + return new GenerateSessionTokenParams(this); + } + } + } + + /** + * Calls utility to convert IAM Policy to Ozone nomenclature and uses this output as input + * to IAccessAuthorizer.generateAssumeRoleSessionPolicy() which is currently only implemented + * by RangerOzoneAuthorizer. + */ + @VisibleForTesting + String getSessionPolicy(OzoneManager ozoneManager, String originalAccessKeyId, String awsIamPolicy, + String hostName, InetAddress remoteIp, UserGroupInformation ugi, String targetRoleName) throws IOException { + + final String volumeName; + if (ozoneManager.isS3MultiTenancyEnabled()) { + final Optional tenantOpt = ozoneManager.getMultiTenantManager() + .getTenantForAccessID(originalAccessKeyId); + if (tenantOpt.isPresent()) { + volumeName = ozoneManager.getMultiTenantManager() + .getTenantVolumeName(tenantOpt.get()); + } else { + volumeName = HddsClientUtils.getDefaultS3VolumeName(ozoneManager.getConfiguration()); + } + } else { + volumeName = HddsClientUtils.getDefaultS3VolumeName(ozoneManager.getConfiguration()); + } + + final Set grants = Strings.isNullOrEmpty(awsIamPolicy) ? + null : + resolveGrantsAgainstBucketLinks( + IamSessionPolicyResolver.resolve(awsIamPolicy, volumeName, IamSessionPolicyResolver.AuthorizerType.RANGER), + (linkVolume, linkBucket) -> ozoneManager.resolveBucketLink(Pair.of(linkVolume, linkBucket), true, false)); + + return ozoneManager.getAccessAuthorizer().generateAssumeRoleSessionPolicy( + new org.apache.hadoop.ozone.security.acl.AssumeRoleRequest( + hostName, remoteIp, ugi, targetRoleName, grants)); + } + + /** + * Rewrites the resolved session-policy grants so that any bucket, key, or prefix resource that names a + * bucket link is anchored to the link's source volume and bucket - the resource paths the OM authorizes + * against once the link is resolved at request time. READ on each link bucket in the chain (and, when the + * chain crosses volumes, READ on each distinct volume except the requested one) is retained so OM can follow + * every hop at request time, which keeps the generated token as small as possible. + *

+ * The link target is resolved when the token is generated, so the token grants access to whatever the link + * points to at that moment. If the link is later re-pointed, the token no longer grants access to the new + * target. + * + * @param grants the grants produced by {@link IamSessionPolicyResolver}, possibly {@code null} + * @param linkResolver resolves a (volume, bucket) pair to its link target + * @return the link-aware grants, or the input unchanged when there is nothing to resolve + */ + @VisibleForTesting + static Set resolveGrantsAgainstBucketLinks(Set grants, + BucketLinkResolver linkResolver) throws IOException { + if (grants == null || grants.isEmpty()) { + return grants; + } + + final Map, ResolvedBucket> resolutionCache = new HashMap<>(); + final Set linkFollowObjects = new LinkedHashSet<>(); + final Set resolvedGrants = new LinkedHashSet<>(); + + for (OzoneGrant grant : grants) { + final Set resolvedObjects = new LinkedHashSet<>(); + for (IOzoneObj object : grant.getObjects()) { + resolvedObjects.add( + resolveObjectAgainstBucketLink((OzoneObj) object, linkResolver, resolutionCache, linkFollowObjects)); + } + resolvedGrants.add(new OzoneGrant(resolvedObjects, grant.getPermissions(), grant.getS3Actions())); + } + + // Retain only the READ required to follow each link hop at request time. + if (!linkFollowObjects.isEmpty()) { + resolvedGrants.add(new OzoneGrant(linkFollowObjects, EnumSet.of(ACLType.READ))); + } + + return resolvedGrants; + } + + /** + * Resolves a single grant object against its bucket link. Bucket, key, and prefix objects that name a + * link bucket are rewritten to the link's source volume and bucket, and the READ needed to follow each hop + * in the link chain is collected in {@code linkFollowObjects}. All other objects (volume resources and + * wildcard buckets) are returned unchanged. + */ + private static IOzoneObj resolveObjectAgainstBucketLink(OzoneObj object, BucketLinkResolver linkResolver, + Map, ResolvedBucket> resolutionCache, Set linkFollowObjects) + throws IOException { + final OzoneObj.ResourceType resourceType = object.getResourceType(); + if (resourceType != OzoneObj.ResourceType.BUCKET + && resourceType != OzoneObj.ResourceType.KEY + && resourceType != OzoneObj.ResourceType.PREFIX) { + return object; + } + + final String volumeName = object.getVolumeName(); + final String bucketName = object.getBucketName(); + // Wildcard or unspecified names cannot correspond to a concrete link bucket. + if (StringUtils.isBlank(volumeName) || StringUtils.isBlank(bucketName) || hasWildcard(volumeName) || + hasWildcard(bucketName)) { + return object; + } + + final Pair requested = Pair.of(volumeName, bucketName); + ResolvedBucket resolved = resolutionCache.get(requested); + if (resolved == null) { + resolved = linkResolver.resolve(volumeName, bucketName); + resolutionCache.put(requested, resolved); + } + if (resolved == null || resolved.isDangling() || !resolved.isLink()) { + return object; + } + + final Set chainVolumes = new LinkedHashSet<>(); + for (Pair link : resolved.linkChain()) { + linkFollowObjects.add(newResourceObj(OzoneObj.ResourceType.BUCKET, link.getLeft(), link.getRight())); + chainVolumes.add(link.getLeft()); + } + chainVolumes.add(resolved.realVolume()); + chainVolumes.remove(volumeName); + for (String vol : chainVolumes) { + linkFollowObjects.add(newResourceObj(OzoneObj.ResourceType.VOLUME, vol, null)); + } + + return OzoneObjInfo.Builder.fromOzoneObj(object) + .setVolumeName(resolved.realVolume()) + .setBucketName(resolved.realBucket()) + .build(); + } + + private static IOzoneObj newResourceObj(OzoneObj.ResourceType resourceType, String volumeName, String bucketName) { + final OzoneObjInfo.Builder builder = OzoneObjInfo.Builder.newBuilder() + .setResType(resourceType) + .setStoreType(OzoneObj.StoreType.OZONE) + .setVolumeName(volumeName); + if (bucketName != null) { + builder.setBucketName(bucketName); + } + return builder.build(); + } + + private static boolean hasWildcard(String name) { + return name.indexOf('*') >= 0 || name.indexOf('?') >= 0; + } + + /** + * Resolves a (volume, bucket) pair to its link target, following bucket links. Implementations must not + * enforce ACLs, so that session-policy generation stays deterministic across OMs and does not depend on + * the external authorizer. + */ + @FunctionalInterface + interface BucketLinkResolver { + ResolvedBucket resolve(String volumeName, String bucketName) throws IOException; + } + + /** + * Generates a deterministic role ID from the role ARN so the same role returns the same ID on every + * AssumeRole invocation, matching AWS behavior where RoleId is stable for a given role. + */ + @VisibleForTesting + static String generateDeterministicRoleId(String roleArn) { + try { + final MessageDigest digest = MessageDigest.getInstance("SHA-256"); + final byte[] hash = digest.digest(roleArn.getBytes(StandardCharsets.UTF_8)); + final StringBuilder sb = new StringBuilder(STS_ROLE_ID_LENGTH); + for (int i = 0; i < STS_ROLE_ID_LENGTH; i++) { + sb.append(STS_ACCESS_KEY_ID_ALLOWED_CHARS.charAt((hash[i] & 0xFF) % STS_ACCESS_KEY_ID_ALLOWED_CHARS_LENGTH)); + } + return ASSUME_ROLE_ID_PREFIX + sb; + } catch (NoSuchAlgorithmException e) { + throw new IllegalStateException("SHA-256 not available", e); + } + } + + /** + * Generates a cryptographically strong String of the supplied stringLength using supplied chars. + */ + @VisibleForTesting + static String generateSecureRandomStringUsingChars(String chars, int charsLength, int stringLength) { + final StringBuilder sb = new StringBuilder(stringLength); + for (int i = 0; i < stringLength; i++) { + sb.append(chars.charAt(SECURE_RANDOM.nextInt(charsLength))); + } + return sb.toString(); + } +} diff --git a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/s3/security/S3DeleteRevokedSTSTokensRequest.java b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/s3/security/S3DeleteRevokedSTSTokensRequest.java new file mode 100644 index 000000000000..81558ec58504 --- /dev/null +++ b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/s3/security/S3DeleteRevokedSTSTokensRequest.java @@ -0,0 +1,71 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.hadoop.ozone.om.request.s3.security; + +import java.io.IOException; +import java.util.List; +import org.apache.hadoop.ozone.om.OzoneManager; +import org.apache.hadoop.ozone.om.exceptions.OMException; +import org.apache.hadoop.ozone.om.execution.flowcontrol.ExecutionContext; +import org.apache.hadoop.ozone.om.request.OMClientRequest; +import org.apache.hadoop.ozone.om.request.util.OmResponseUtil; +import org.apache.hadoop.ozone.om.response.OMClientResponse; +import org.apache.hadoop.ozone.om.response.s3.security.S3DeleteRevokedSTSTokensResponse; +import org.apache.hadoop.ozone.om.service.RevokedSTSTokenCleanupService; +import org.apache.hadoop.ozone.protocol.proto.OzoneManagerProtocolProtos.DeleteRevokedSTSTokensRequest; +import org.apache.hadoop.ozone.protocol.proto.OzoneManagerProtocolProtos.OMRequest; +import org.apache.hadoop.ozone.protocol.proto.OzoneManagerProtocolProtos.OMResponse; +import org.apache.hadoop.security.UserGroupInformation; +import org.apache.hadoop.security.authentication.client.AuthenticationException; + +/** + * Handles DeleteRevokedSTSTokens requests submitted by {@link RevokedSTSTokenCleanupService}. + * Each request contains originalAccessKeyIds to remove from the revocation table. + */ +public class S3DeleteRevokedSTSTokensRequest extends OMClientRequest { + + public S3DeleteRevokedSTSTokensRequest(OMRequest omRequest) { + super(omRequest); + } + + @Override + public OMRequest preExecute(OzoneManager ozoneManager) throws IOException { + final UserGroupInformation ugi; + try { + ugi = createUGI(); + } catch (AuthenticationException e) { + throw new OMException(e, OMException.ResultCodes.PERMISSION_DENIED); + } + if (!ozoneManager.isAdmin(ugi) && !ozoneManager.isS3Admin(ugi)) { + throw new OMException("Only admins can delete revoked STS tokens", OMException.ResultCodes.PERMISSION_DENIED); + } + + return super.preExecute(ozoneManager); + } + + @Override + public OMClientResponse validateAndUpdateCache(OzoneManager ozoneManager, ExecutionContext context) { + final DeleteRevokedSTSTokensRequest request = getOmRequest().getDeleteRevokedSTSTokensRequest(); + final OMResponse.Builder omResponse = OmResponseUtil.getOMResponseBuilder(getOmRequest()); + + 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/S3GetSecretRequest.java b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/s3/security/S3GetSecretRequest.java index 2c698abefe06..c0609d7a52e3 100644 --- a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/s3/security/S3GetSecretRequest.java +++ b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/s3/security/S3GetSecretRequest.java @@ -59,9 +59,9 @@ public S3GetSecretRequest(OMRequest omRequest) { @Override public OMRequest preExecute(OzoneManager ozoneManager) throws IOException { + final OMRequest omRequest = super.preExecute(ozoneManager); - final GetS3SecretRequest s3GetSecretRequest = - getOmRequest().getGetS3SecretRequest(); + final GetS3SecretRequest s3GetSecretRequest = omRequest.getGetS3SecretRequest(); // The proto field kerberosID is effectively accessId w/ Multi-Tenancy // @@ -83,10 +83,7 @@ public OMRequest preExecute(OzoneManager ozoneManager) throws IOException { // way S3Secret created by leader, will be replicated across all // OMs. With this approach, original GetS3Secret request from // client does not need any proto changes. - OMRequest.Builder omRequest = OMRequest.newBuilder() - .setUserInfo(getUserInfo()) - .setCmdType(getOmRequest().getCmdType()) - .setClientId(getOmRequest().getClientId()); + final OMRequest.Builder omRequestBuilder = omRequest.toBuilder(); // createIfNotExist defaults to true if not specified. boolean createIfNotExist = !s3GetSecretRequest.hasCreateIfNotExist() @@ -98,7 +95,7 @@ public OMRequest preExecute(OzoneManager ozoneManager) throws IOException { .setKerberosID(accessId) // See Note 1 above .setCreateIfNotExist(createIfNotExist) .build(); - omRequest.setGetS3SecretRequest(newGetS3SecretRequest); + omRequestBuilder.setGetS3SecretRequest(newGetS3SecretRequest); // When createIfNotExist is true, pass UpdateGetS3SecretRequest message; // otherwise, just use GetS3SecretRequest message. @@ -113,14 +110,12 @@ public OMRequest preExecute(OzoneManager ozoneManager) throws IOException { .setAwsSecret(s3Secret) .build(); - omRequest.setUpdateGetS3SecretRequest(updateGetS3SecretRequest); - } - - if (getOmRequest().hasTraceID()) { - omRequest.setTraceID(getOmRequest().getTraceID()); + omRequestBuilder.setUpdateGetS3SecretRequest(updateGetS3SecretRequest); + } else { + omRequestBuilder.clearUpdateGetS3SecretRequest(); } - return omRequest.build(); + return omRequestBuilder.build(); } @Override diff --git a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/s3/security/S3RevokeSTSTokenRequest.java b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/s3/security/S3RevokeSTSTokenRequest.java new file mode 100644 index 000000000000..02e6cac1b3d4 --- /dev/null +++ b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/s3/security/S3RevokeSTSTokenRequest.java @@ -0,0 +1,157 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.hadoop.ozone.om.request.s3.security; + +import static org.apache.hadoop.ozone.om.exceptions.OMException.ResultCodes.ACCESS_ID_NOT_FOUND; +import static org.apache.hadoop.ozone.om.exceptions.OMException.ResultCodes.INTERNAL_ERROR; +import static org.apache.hadoop.ozone.om.exceptions.OMException.ResultCodes.INVALID_REQUEST; + +import java.io.IOException; +import java.time.Clock; +import java.time.ZoneOffset; +import java.util.HashMap; +import java.util.Map; +import org.apache.commons.lang3.StringUtils; +import org.apache.hadoop.hdds.utils.db.cache.CacheKey; +import org.apache.hadoop.hdds.utils.db.cache.CacheValue; +import org.apache.hadoop.ozone.OzoneConsts; +import org.apache.hadoop.ozone.audit.OMAction; +import org.apache.hadoop.ozone.om.OzoneManager; +import org.apache.hadoop.ozone.om.exceptions.OMException; +import org.apache.hadoop.ozone.om.execution.flowcontrol.ExecutionContext; +import org.apache.hadoop.ozone.om.request.OMClientRequest; +import org.apache.hadoop.ozone.om.request.util.OmResponseUtil; +import org.apache.hadoop.ozone.om.response.OMClientResponse; +import org.apache.hadoop.ozone.om.response.s3.security.S3RevokeSTSTokenResponse; +import org.apache.hadoop.ozone.protocol.proto.OzoneManagerProtocolProtos.OMRequest; +import org.apache.hadoop.ozone.protocol.proto.OzoneManagerProtocolProtos.OMResponse; +import org.apache.hadoop.ozone.protocol.proto.OzoneManagerProtocolProtos.RevokeSTSTokenRequest; +import org.apache.hadoop.security.UserGroupInformation; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Handles S3RevokeSTSTokenRequest request. + * + *

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

+ * + *

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

+ */ +public class S3RevokeSTSTokenRequest extends OMClientRequest { + + private static final Logger LOG = LoggerFactory.getLogger(S3RevokeSTSTokenRequest.class); + private static final Clock CLOCK = Clock.system(ZoneOffset.UTC); + + public S3RevokeSTSTokenRequest(OMRequest omRequest) { + super(omRequest); + } + + @Override + public OMRequest preExecute(OzoneManager ozoneManager) throws IOException { + final OMRequest omRequest = super.preExecute(ozoneManager); + final RevokeSTSTokenRequest revokeReq = omRequest.getRevokeSTSTokenRequest(); + validateRevokeRequestFields(revokeReq); + + // 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 originalAccessKeyId = revokeReq.getOriginalAccessKeyId(); + + final UserGroupInformation ugi = S3SecretRequestHelper.getOrCreateUgi(originalAccessKeyId); + S3SecretRequestHelper.checkAccessIdSecretOpPermission(ozoneManager, ugi, originalAccessKeyId); + + if (!ozoneManager.getS3SecretManager().hasS3Secret(originalAccessKeyId)) { + throw new OMException("originalAccessKeyId does not exist: " + originalAccessKeyId, ACCESS_ID_NOT_FOUND); + } + + final long revocationTimeMillis = CLOCK.millis(); + final RevokeSTSTokenRequest updatedRevokeReq = revokeReq.toBuilder() + .setRevocationTimeMillis(revocationTimeMillis) + .build(); + + return omRequest.toBuilder() + .setRevokeSTSTokenRequest(updatedRevokeReq) + .build(); + } + + @Override + public OMClientResponse validateAndUpdateCache(OzoneManager ozoneManager, ExecutionContext context) { + final OMResponse.Builder omResponse = OmResponseUtil.getOMResponseBuilder(getOmRequest()); + IOException exception = null; + OMClientResponse omClientResponse; + final Map auditMap = new HashMap<>(); + + try { + final RevokeSTSTokenRequest revokeReq = validateReplicatedRevokeRequestFields(getOmRequest()); + final String originalAccessKeyId = revokeReq.getOriginalAccessKeyId(); + auditMap.put(OzoneConsts.S3_REVOKESTSTOKEN_USER, originalAccessKeyId); + final long revocationTimeMillis = revokeReq.getRevocationTimeMillis(); + + // All actual DB mutations are done in the response's addToDBBatch(). + omClientResponse = new S3RevokeSTSTokenResponse(originalAccessKeyId, revocationTimeMillis, 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 + markForAudit( + ozoneManager.getAuditLogger(), buildAuditMessage( + OMAction.REVOKE_STS_TOKEN, auditMap, exception, getOmRequest().getUserInfo())); + return omClientResponse; + } + + private static void validateRevokeRequestFields(RevokeSTSTokenRequest revokeReq) throws OMException { + final String originalAccessKeyId = revokeReq.getOriginalAccessKeyId(); + if (StringUtils.isEmpty(originalAccessKeyId)) { + throw new OMException("originalAccessKeyId is required for STS token revocation", INVALID_REQUEST); + } + if (revokeReq.hasRevocationTimeMillis()) { + throw new OMException("revocationTimeMillis must not be set by client", INVALID_REQUEST); + } + } + + private static RevokeSTSTokenRequest validateReplicatedRevokeRequestFields(OMRequest omRequest) throws OMException { + if (!omRequest.hasRevokeSTSTokenRequest()) { + throw new OMException("revokeSTSTokenRequest is required for STS token revocation", INTERNAL_ERROR); + } + final RevokeSTSTokenRequest revokeReq = omRequest.getRevokeSTSTokenRequest(); + if (StringUtils.isEmpty(revokeReq.getOriginalAccessKeyId())) { + throw new OMException("originalAccessKeyId is required for STS token revocation", INTERNAL_ERROR); + } + if (!revokeReq.hasRevocationTimeMillis()) { + throw new OMException("revocationTimeMillis is required for STS token revocation", INTERNAL_ERROR); + } + return revokeReq; + } +} diff --git a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/s3/security/S3RevokeSecretRequest.java b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/s3/security/S3RevokeSecretRequest.java index 24e9b1a3b46e..f74d5a57977a 100644 --- a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/s3/security/S3RevokeSecretRequest.java +++ b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/s3/security/S3RevokeSecretRequest.java @@ -50,8 +50,9 @@ public S3RevokeSecretRequest(OMRequest omRequest) { @Override public OMRequest preExecute(OzoneManager ozoneManager) throws IOException { + final OMRequest omRequest = super.preExecute(ozoneManager); final RevokeS3SecretRequest s3RevokeSecretRequest = - getOmRequest().getRevokeS3SecretRequest(); + omRequest.getRevokeS3SecretRequest(); final String accessId = s3RevokeSecretRequest.getKerberosID(); final UserGroupInformation ugi = S3SecretRequestHelper.getOrCreateUgi(accessId); @@ -63,16 +64,9 @@ public OMRequest preExecute(OzoneManager ozoneManager) throws IOException { RevokeS3SecretRequest.newBuilder() .setKerberosID(accessId).build(); - OMRequest.Builder omRequest = OMRequest.newBuilder() + return omRequest.toBuilder() .setRevokeS3SecretRequest(revokeS3SecretRequest) - .setCmdType(getOmRequest().getCmdType()) - .setClientId(getOmRequest().getClientId()); - - if (getOmRequest().hasTraceID()) { - omRequest.setTraceID(getOmRequest().getTraceID()); - } - - return omRequest.build(); + .build(); } @Override diff --git a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/s3/tagging/S3BucketTaggingRequestBase.java b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/s3/tagging/S3BucketTaggingRequestBase.java index 2c19dcbdcf63..eaf16d843612 100644 --- a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/s3/tagging/S3BucketTaggingRequestBase.java +++ b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/s3/tagging/S3BucketTaggingRequestBase.java @@ -60,7 +60,7 @@ protected S3BucketTaggingRequestBase(OMRequest omRequest) { @Override public OMRequest preExecute(OzoneManager ozoneManager) throws IOException { - OMRequest baseRequest = super.preExecute(ozoneManager); + final OMRequest baseRequest = super.preExecute(ozoneManager); BucketArgs bucketArgs = getRequestBucketArgs(baseRequest); OmBucketArgs omBucketArgs = OmBucketArgs.getFromProtobuf(bucketArgs); diff --git a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/security/OMCancelDelegationTokenRequest.java b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/security/OMCancelDelegationTokenRequest.java index ab3cc4fc0ab7..0b6ba0138601 100644 --- a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/security/OMCancelDelegationTokenRequest.java +++ b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/security/OMCancelDelegationTokenRequest.java @@ -59,7 +59,7 @@ public OMCancelDelegationTokenRequest(OMRequest omRequest) { @Override public OMRequest preExecute(OzoneManager ozoneManager) throws IOException { // We need to populate user info in our request object. - OMRequest request = super.preExecute(ozoneManager); + final OMRequest request = super.preExecute(ozoneManager); AuditLogger auditLogger = ozoneManager.getAuditLogger(); Map auditMap = null; diff --git a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/security/OMGetDelegationTokenRequest.java b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/security/OMGetDelegationTokenRequest.java index 109d39ddab98..8106c6331cb5 100644 --- a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/security/OMGetDelegationTokenRequest.java +++ b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/security/OMGetDelegationTokenRequest.java @@ -63,7 +63,7 @@ public OMGetDelegationTokenRequest(OMRequest omRequest) { @Override public OMRequest preExecute(OzoneManager ozoneManager) throws IOException { // We need to populate user info in our request object. - OMRequest request = super.preExecute(ozoneManager); + final OMRequest request = super.preExecute(ozoneManager); GetDelegationTokenRequestProto getDelegationTokenRequest = request.getGetDelegationTokenRequest(); diff --git a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/security/OMRenewDelegationTokenRequest.java b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/security/OMRenewDelegationTokenRequest.java index fbac8d4b14c9..38e86c3ce6fe 100644 --- a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/security/OMRenewDelegationTokenRequest.java +++ b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/security/OMRenewDelegationTokenRequest.java @@ -60,7 +60,7 @@ public OMRenewDelegationTokenRequest(OMRequest omRequest) { @Override public OMRequest preExecute(OzoneManager ozoneManager) throws IOException { // We need to populate user info in our request object. - OMRequest request = super.preExecute(ozoneManager); + final OMRequest request = super.preExecute(ozoneManager); RenewDelegationTokenRequestProto renewDelegationTokenRequest = request.getRenewDelegationTokenRequest(); diff --git a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/snapshot/OMSnapshotMoveTableKeysRequest.java b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/snapshot/OMSnapshotMoveTableKeysRequest.java index fef5dc76c4de..440e2aed7724 100644 --- a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/snapshot/OMSnapshotMoveTableKeysRequest.java +++ b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/snapshot/OMSnapshotMoveTableKeysRequest.java @@ -74,9 +74,10 @@ public OMSnapshotMoveTableKeysRequest(OMRequest omRequest) { @Override public OMRequest preExecute(OzoneManager ozoneManager) throws IOException { - OmMetadataManagerImpl omMetadataManager = (OmMetadataManagerImpl) ozoneManager.getMetadataManager(); + final OMRequest omRequest = super.preExecute(ozoneManager); + final OmMetadataManagerImpl omMetadataManager = (OmMetadataManagerImpl) ozoneManager.getMetadataManager(); SnapshotChainManager snapshotChainManager = omMetadataManager.getSnapshotChainManager(); - SnapshotMoveTableKeysRequest moveTableKeysRequest = getOmRequest().getSnapshotMoveTableKeysRequest(); + SnapshotMoveTableKeysRequest moveTableKeysRequest = omRequest.getSnapshotMoveTableKeysRequest(); UUID fromSnapshotID = fromProtobuf(moveTableKeysRequest.getFromSnapshotID()); SnapshotInfo fromSnapshot = SnapshotUtils.getSnapshotInfo(ozoneManager, snapshotChainManager, fromSnapshotID); @@ -179,7 +180,7 @@ public OMRequest preExecute(OzoneManager ozoneManager) throws IOException { } } } - return getOmRequest().toBuilder().setSnapshotMoveTableKeysRequest( + return omRequest.toBuilder().setSnapshotMoveTableKeysRequest( moveTableKeysRequest.toBuilder().clearDeletedDirs().clearDeletedKeys().clearRenamedKeys() .addAllDeletedKeys(deletedKeys).addAllDeletedDirs(deletedDirs) .addAllRenamedKeys(renamedKeysList).build()).build(); diff --git a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/volume/OMVolumeDeleteRequest.java b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/volume/OMVolumeDeleteRequest.java index 307360484598..62dff8f3e66c 100644 --- a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/volume/OMVolumeDeleteRequest.java +++ b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/volume/OMVolumeDeleteRequest.java @@ -62,7 +62,7 @@ public OMVolumeDeleteRequest(OMRequest omRequest) { @Override public OMRequest preExecute(OzoneManager ozoneManager) throws IOException { - OMRequest request = super.preExecute(ozoneManager); + final OMRequest request = super.preExecute(ozoneManager); DeleteVolumeRequest deleteVolumeRequest = getOmRequest().getDeleteVolumeRequest(); Objects.requireNonNull(deleteVolumeRequest); diff --git a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/volume/OMVolumeSetOwnerRequest.java b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/volume/OMVolumeSetOwnerRequest.java index 0ec12291fdc6..2cc86f3b8a5b 100644 --- a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/volume/OMVolumeSetOwnerRequest.java +++ b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/volume/OMVolumeSetOwnerRequest.java @@ -62,15 +62,15 @@ public OMVolumeSetOwnerRequest(OMRequest omRequest) { @Override public OMRequest preExecute(OzoneManager ozoneManager) throws IOException { - OMRequest request = super.preExecute(ozoneManager); + final OMRequest omRequest = super.preExecute(ozoneManager); long modificationTime = Time.now(); - SetVolumePropertyRequest.Builder setPropertyRequestBuilder = getOmRequest() + SetVolumePropertyRequest.Builder setPropertyRequestBuilder = omRequest .getSetVolumePropertyRequest().toBuilder() .setModificationTime(modificationTime); SetVolumePropertyRequest setVolumePropertyRequest = - getOmRequest().getSetVolumePropertyRequest(); + omRequest.getSetVolumePropertyRequest(); String volume = setVolumePropertyRequest.getVolumeName(); // ACL check during preExecute @@ -87,12 +87,12 @@ public OMRequest preExecute(OzoneManager ozoneManager) throws IOException { setVolumePropertyRequest.getOwnerName()); markForAudit(ozoneManager.getAuditLogger(), buildAuditMessage(OMAction.SET_OWNER, auditMap, ex, - request.getUserInfo())); + omRequest.getUserInfo())); throw ex; } } - return request.toBuilder() + return omRequest.toBuilder() .setSetVolumePropertyRequest(setPropertyRequestBuilder) .build(); } diff --git a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/volume/OMVolumeSetQuotaRequest.java b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/volume/OMVolumeSetQuotaRequest.java index 990d22c69ae2..cf6f946c6ae9 100644 --- a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/volume/OMVolumeSetQuotaRequest.java +++ b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/volume/OMVolumeSetQuotaRequest.java @@ -64,15 +64,15 @@ public OMVolumeSetQuotaRequest(OMRequest omRequest) { @Override public OMRequest preExecute(OzoneManager ozoneManager) throws IOException { - OMRequest request = super.preExecute(ozoneManager); + final OMRequest omRequest = super.preExecute(ozoneManager); long modificationTime = Time.now(); - SetVolumePropertyRequest.Builder setPropertyRequestBuilder = getOmRequest() + SetVolumePropertyRequest.Builder setPropertyRequestBuilder = omRequest .getSetVolumePropertyRequest().toBuilder() .setModificationTime(modificationTime); SetVolumePropertyRequest setVolumePropertyRequest = - getOmRequest().getSetVolumePropertyRequest(); + omRequest.getSetVolumePropertyRequest(); String volume = setVolumePropertyRequest.getVolumeName(); // ACL check during preExecute @@ -89,12 +89,12 @@ public OMRequest preExecute(OzoneManager ozoneManager) throws IOException { String.valueOf(setVolumePropertyRequest.getQuotaInBytes())); markForAudit(ozoneManager.getAuditLogger(), buildAuditMessage(OMAction.SET_QUOTA, auditMap, ex, - request.getUserInfo())); + omRequest.getUserInfo())); throw ex; } } - return request.toBuilder() + return omRequest.toBuilder() .setSetVolumePropertyRequest(setPropertyRequestBuilder) .build(); } diff --git a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/volume/acl/OMVolumeAddAclRequest.java b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/volume/acl/OMVolumeAddAclRequest.java index 6dfc64547189..f7f58a57dc4c 100644 --- a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/volume/acl/OMVolumeAddAclRequest.java +++ b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/volume/acl/OMVolumeAddAclRequest.java @@ -59,10 +59,10 @@ public class OMVolumeAddAclRequest extends OMVolumeAclRequest { @Override public OMRequest preExecute(OzoneManager ozoneManager) throws IOException { // Call parent preExecute to perform ACL check - OMRequest omRequest = super.preExecute(ozoneManager); + final OMRequest omRequest = super.preExecute(ozoneManager); - long modificationTime = Time.now(); - OzoneManagerProtocolProtos.AddAclRequest.Builder addAclRequestBuilder = + final long modificationTime = Time.now(); + final OzoneManagerProtocolProtos.AddAclRequest.Builder addAclRequestBuilder = omRequest.getAddAclRequest().toBuilder() .setModificationTime(modificationTime); diff --git a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/volume/acl/OMVolumeRemoveAclRequest.java b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/volume/acl/OMVolumeRemoveAclRequest.java index ceabf00b5663..00e3a4cb8402 100644 --- a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/volume/acl/OMVolumeRemoveAclRequest.java +++ b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/volume/acl/OMVolumeRemoveAclRequest.java @@ -59,11 +59,11 @@ public class OMVolumeRemoveAclRequest extends OMVolumeAclRequest { @Override public OMRequest preExecute(OzoneManager ozoneManager) throws IOException { // Call parent preExecute to perform ACL check - OMRequest omRequest = super.preExecute(ozoneManager); + final OMRequest omRequest = super.preExecute(ozoneManager); - long modificationTime = Time.now(); - OzoneManagerProtocolProtos.RemoveAclRequest.Builder removeAclRequestBuilder - = omRequest.getRemoveAclRequest().toBuilder() + final long modificationTime = Time.now(); + final OzoneManagerProtocolProtos.RemoveAclRequest.Builder removeAclRequestBuilder = + omRequest.getRemoveAclRequest().toBuilder() .setModificationTime(modificationTime); return omRequest.toBuilder() diff --git a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/volume/acl/OMVolumeSetAclRequest.java b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/volume/acl/OMVolumeSetAclRequest.java index c68f24906d71..15417294b499 100644 --- a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/volume/acl/OMVolumeSetAclRequest.java +++ b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/volume/acl/OMVolumeSetAclRequest.java @@ -58,10 +58,10 @@ public class OMVolumeSetAclRequest extends OMVolumeAclRequest { @Override public OMRequest preExecute(OzoneManager ozoneManager) throws IOException { // Call parent preExecute to perform ACL check - OMRequest omRequest = super.preExecute(ozoneManager); + final OMRequest omRequest = super.preExecute(ozoneManager); - long modificationTime = Time.now(); - OzoneManagerProtocolProtos.SetAclRequest.Builder setAclRequestBuilder = + final long modificationTime = Time.now(); + final OzoneManagerProtocolProtos.SetAclRequest.Builder setAclRequestBuilder = omRequest.getSetAclRequest().toBuilder() .setModificationTime(modificationTime); diff --git a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/response/s3/security/S3AssumeRoleResponse.java b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/response/s3/security/S3AssumeRoleResponse.java new file mode 100644 index 000000000000..18ed41a55f73 --- /dev/null +++ b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/response/s3/security/S3AssumeRoleResponse.java @@ -0,0 +1,42 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.hadoop.ozone.om.response.s3.security; + +import java.io.IOException; +import org.apache.hadoop.hdds.utils.db.BatchOperation; +import org.apache.hadoop.ozone.om.OMMetadataManager; +import org.apache.hadoop.ozone.om.response.CleanupTableInfo; +import org.apache.hadoop.ozone.om.response.OMClientResponse; +import org.apache.hadoop.ozone.protocol.proto.OzoneManagerProtocolProtos.OMResponse; + +/** + * Response for AssumeRole request. + * This is a stateless operation that doesn't modify any database tables. + */ +@CleanupTableInfo() +public class S3AssumeRoleResponse extends OMClientResponse { + + public S3AssumeRoleResponse(OMResponse omResponse) { + super(omResponse); + } + + @Override + public void addToDBBatch(OMMetadataManager omMetadataManager, BatchOperation batchOperation) throws IOException { + // No database changes for assume role - it's stateless + } +} diff --git a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/response/s3/security/S3DeleteRevokedSTSTokensResponse.java b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/response/s3/security/S3DeleteRevokedSTSTokensResponse.java new file mode 100644 index 000000000000..a1b255689de5 --- /dev/null +++ b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/response/s3/security/S3DeleteRevokedSTSTokensResponse.java @@ -0,0 +1,66 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.hadoop.ozone.om.response.s3.security; + +import static org.apache.hadoop.ozone.om.codec.OMDBDefinition.S3_REVOKED_STS_TOKEN_TABLE; +import static org.apache.hadoop.ozone.protocol.proto.OzoneManagerProtocolProtos.Status.OK; + +import jakarta.annotation.Nonnull; +import java.io.IOException; +import java.util.List; +import org.apache.hadoop.hdds.utils.db.BatchOperation; +import org.apache.hadoop.hdds.utils.db.Table; +import org.apache.hadoop.ozone.om.OMMetadataManager; +import org.apache.hadoop.ozone.om.response.CleanupTableInfo; +import org.apache.hadoop.ozone.om.response.OMClientResponse; +import org.apache.hadoop.ozone.protocol.proto.OzoneManagerProtocolProtos.OMResponse; + +/** + * Response for DeleteRevokedSTSTokens request. + */ +@CleanupTableInfo(cleanupTables = {S3_REVOKED_STS_TOKEN_TABLE}) +public class S3DeleteRevokedSTSTokensResponse extends OMClientResponse { + + private final List originalAccessKeyIds; + + public S3DeleteRevokedSTSTokensResponse(List originalAccessKeyIds, @Nonnull OMResponse omResponse) { + super(omResponse); + this.originalAccessKeyIds = originalAccessKeyIds; + } + + @Override + public void addToDBBatch(OMMetadataManager omMetadataManager, BatchOperation batchOperation) throws IOException { + if (originalAccessKeyIds == null || originalAccessKeyIds.isEmpty()) { + return; + } + if (!getOMResponse().hasStatus() || getOMResponse().getStatus() != OK) { + return; + } + + final Table table = omMetadataManager.getS3RevokedStsTokenTable(); + if (table == null) { + return; + } + + 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 new file mode 100644 index 000000000000..db9233357ed2 --- /dev/null +++ b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/response/s3/security/S3RevokeSTSTokenResponse.java @@ -0,0 +1,58 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.hadoop.ozone.om.response.s3.security; + +import static org.apache.hadoop.ozone.om.codec.OMDBDefinition.S3_REVOKED_STS_TOKEN_TABLE; +import static org.apache.hadoop.ozone.protocol.proto.OzoneManagerProtocolProtos.Status.OK; + +import jakarta.annotation.Nonnull; +import java.io.IOException; +import org.apache.hadoop.hdds.utils.db.BatchOperation; +import org.apache.hadoop.hdds.utils.db.Table; +import org.apache.hadoop.ozone.om.OMMetadataManager; +import org.apache.hadoop.ozone.om.response.CleanupTableInfo; +import org.apache.hadoop.ozone.om.response.OMClientResponse; +import org.apache.hadoop.ozone.protocol.proto.OzoneManagerProtocolProtos.OMResponse; + +/** + * Response for RevokeSTSToken request. + */ +@CleanupTableInfo(cleanupTables = {S3_REVOKED_STS_TOKEN_TABLE}) +public class S3RevokeSTSTokenResponse extends OMClientResponse { + + private final String originalAccessKeyId; + private final long revocationTimeMillis; + + public S3RevokeSTSTokenResponse(String originalAccessKeyId, long revocationTimeMillis, + @Nonnull OMResponse omResponse) { + super(omResponse); + this.originalAccessKeyId = originalAccessKeyId; + this.revocationTimeMillis = revocationTimeMillis; + } + + @Override + public void addToDBBatch(OMMetadataManager omMetadataManager, BatchOperation batchOperation) throws IOException { + if (originalAccessKeyId != null && getOMResponse().hasStatus() && getOMResponse().getStatus() == OK) { + final Table table = omMetadataManager.getS3RevokedStsTokenTable(); + if (table != null) { + // 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 new file mode 100644 index 000000000000..c627f6a21cb7 --- /dev/null +++ b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/service/RevokedSTSTokenCleanupService.java @@ -0,0 +1,269 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.hadoop.ozone.om.service; + +import com.google.common.annotations.VisibleForTesting; +import com.google.protobuf.ServiceException; +import java.io.IOException; +import java.time.Clock; +import java.time.ZoneOffset; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicLong; +import org.apache.hadoop.hdds.conf.StorageUnit; +import org.apache.hadoop.hdds.utils.BackgroundService; +import org.apache.hadoop.hdds.utils.BackgroundTask; +import org.apache.hadoop.hdds.utils.BackgroundTaskQueue; +import org.apache.hadoop.hdds.utils.BackgroundTaskResult; +import org.apache.hadoop.hdds.utils.db.Table; +import org.apache.hadoop.ozone.ClientVersion; +import org.apache.hadoop.ozone.om.OMConfigKeys; +import org.apache.hadoop.ozone.om.OMMetadataManager; +import org.apache.hadoop.ozone.om.OzoneManager; +import org.apache.hadoop.ozone.om.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; +import org.apache.hadoop.ozone.protocol.proto.OzoneManagerProtocolProtos.OMResponse; +import org.apache.hadoop.ozone.protocol.proto.OzoneManagerProtocolProtos.Type; +import org.apache.hadoop.util.Time; +import org.apache.ratis.protocol.ClientId; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Background service that periodically scans the revoked STS token table and submits OM requests to + * remove entries have been present past the cleanup threshold. + */ +public class RevokedSTSTokenCleanupService extends BackgroundService { + private static final Logger LOG = LoggerFactory.getLogger(RevokedSTSTokenCleanupService.class); + + // Use a single thread + private static final int REVOKED_STS_TOKEN_CLEANER_CORE_POOL_SIZE = 1; + private static final Clock CLOCK = Clock.system(ZoneOffset.UTC); + // 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; + private final AtomicBoolean suspended; + private final AtomicLong runCount; + private final AtomicLong submittedDeletedEntryCount; + private final AtomicLong callIdCount; + // Dummy client ID to use for response, since this is triggered by a + // service, not the client. + private final ClientId clientId = ClientId.randomId(); + private final int ratisByteLimit; + + /** + * Creates a Revoked STS Token cleanup service. + * + * @param interval the interval between successive runs + * @param unit the time unit for {@code interval} + * @param serviceTimeout timeout for a single run + * @param ozoneManager the OzoneManager instance + */ + public RevokedSTSTokenCleanupService(long interval, TimeUnit unit, long serviceTimeout, OzoneManager ozoneManager) { + super( + "RevokedSTSTokenCleanupService", interval, unit, REVOKED_STS_TOKEN_CLEANER_CORE_POOL_SIZE, + serviceTimeout, ozoneManager.getThreadNamePrefix()); + this.ozoneManager = ozoneManager; + this.metadataManager = ozoneManager.getMetadataManager(); + this.suspended = new AtomicBoolean(false); + this.runCount = new AtomicLong(0); + this.submittedDeletedEntryCount = new AtomicLong(0); + this.callIdCount = new AtomicLong(0); + int limit = (int) ozoneManager.getConfiguration().getStorageSize( + OMConfigKeys.OZONE_OM_RATIS_LOG_APPENDER_QUEUE_BYTE_LIMIT, + OMConfigKeys.OZONE_OM_RATIS_LOG_APPENDER_QUEUE_BYTE_LIMIT_DEFAULT, StorageUnit.BYTES); + // Always go to 90% of max limit for request as other header(s) will be added + this.ratisByteLimit = (int) (limit * 0.9); + } + + /** + * Returns the number of times this Background service has run. + * @return Long, run count. + */ + @VisibleForTesting + public long getRunCount() { + return runCount.get(); + } + + /** + * Returns the number of entries this Background service has submitted for deletion. + * @return Long, submitted for deletion entry count. + */ + @VisibleForTesting + public long getSubmittedDeletedEntryCount() { + return submittedDeletedEntryCount.get(); + } + + @Override + public BackgroundTaskQueue getTasks() { + final BackgroundTaskQueue queue = new BackgroundTaskQueue(); + queue.add(new RevokedSTSTokenCleanupTask()); + return queue; + } + + private boolean shouldRun() { + return !suspended.get() && ozoneManager.isLeaderReady(); + } + + private final class RevokedSTSTokenCleanupTask implements BackgroundTask { + + @Override + public BackgroundTaskResult call() throws Exception { + if (!shouldRun()) { + return BackgroundTaskResult.EmptyTaskResult.newResult(); + } + + final long startTime = Time.monotonicNow(); + runCount.incrementAndGet(); + final Table revokedStsTokenTable = metadataManager.getS3RevokedStsTokenTable(); + + long deletedInRun = 0; + final List batch = new ArrayList<>(); + + try (Table.KeyValueIterator iterator = revokedStsTokenTable.iterator()) { + iterator.seekToFirst(); + while (iterator.hasNext()) { + final Table.KeyValue entry = iterator.next(); + final String originalAccessKeyId = entry.getKey(); + final Long revocationTimeMillis = entry.getValue(); + + 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(originalAccessKeyId); + int batchWithCandidateSize = getBatchSerializedSize(batchCopyWithCandidate); + + // If adding this originalAccessKeyId would exceed the limit, submit the current batch + if (batchWithCandidateSize > ratisByteLimit) { + if (!batch.isEmpty()) { + if (submitCleanupRequest(batch)) { + deletedInRun += batch.size(); + } else { + LOG.warn("Failed to submit batch of {} revoked tokens.", batch.size()); + } + batch.clear(); + + // Re-calculate the size of the candidate key alone in an empty batch + // to check if it exceeds the limit by itself. + final List singleCandidateBatch = new ArrayList<>(); + singleCandidateBatch.add(originalAccessKeyId); + batchWithCandidateSize = getBatchSerializedSize(singleCandidateBatch); + } + + // Check if the single key exceeds the limit (either strictly single or after flush) + if (batchWithCandidateSize > ratisByteLimit) { + LOG.error( + "Single originalAccessKeyId entry size ({}) would exceed the ratisByteLimit ({}). " + + "revocationTimeMillis: {}", batchWithCandidateSize, ratisByteLimit, revocationTimeMillis); + continue; + } + } + batch.add(originalAccessKeyId); + } + } + } catch (IOException e) { + LOG.error("Failure while scanning s3RevokedStsTokenTable. It will be retried in the next interval", e); + if (deletedInRun == 0) { + return BackgroundTaskResult.EmptyTaskResult.newResult(); + } + } + + // Submit any remaining tokens + if (!batch.isEmpty()) { + if (submitCleanupRequest(batch)) { + deletedInRun += batch.size(); + } else { + LOG.warn("Failed to submit final batch of {} revoked tokens.", batch.size()); + } + } + + // Update stats + if (deletedInRun > 0) { + submittedDeletedEntryCount.addAndGet(deletedInRun); + LOG.info("Found and removed {} revoked STS token entries.", deletedInRun); + } + + final long elapsed = Time.monotonicNow() - startTime; + LOG.info("RevokedSTSTokenCleanupService run completed. deletedEntriesInRun={}, totalDeletedEntries={}, " + + "callIdCount={}, elapsedTimeMs={}", deletedInRun, submittedDeletedEntryCount.get(), callIdCount.get(), + elapsed); + + final long resultCount = deletedInRun; + return () -> (int) resultCount; + } + + /** + * Returns true if the revocation cutoff is older than the cleanup threshold. + */ + private boolean shouldCleanup(long revocationTimeMillis) { + final long now = CLOCK.millis(); + + if (now - revocationTimeMillis > CLEANUP_THRESHOLD) { + if (LOG.isDebugEnabled()) { + LOG.debug( + "Revoked STS token cutoff at {} is older than {} ms, will clean up. Current time: {}", + revocationTimeMillis, CLEANUP_THRESHOLD, now); + } + return true; + } + return false; + } + + /** + * Builds and submits an OMRequest to delete the provided originalAccessKeyId revocation entries. + */ + private boolean submitCleanupRequest(List originalAccessKeyIds) { + final DeleteRevokedSTSTokensRequest request = DeleteRevokedSTSTokensRequest.newBuilder() + .addAllOriginalAccessKeyId(originalAccessKeyIds) + .build(); + + final OMRequest omRequest = OMRequest.newBuilder() + .setCmdType(Type.DeleteRevokedSTSTokens) + .setDeleteRevokedSTSTokensRequest(request) + .setClientId(clientId.toString()) + .setVersion(ClientVersion.CURRENT_VERSION) + .build(); + + try { + final OMResponse omResponse = OzoneManagerRatisUtils.submitRequest( + ozoneManager, omRequest, clientId, callIdCount.incrementAndGet()); + return omResponse != null && omResponse.getSuccess(); + } catch (ServiceException e) { + LOG.error("Revoked STS token cleanup request failed. Will retry at next run.", e); + return false; + } + } + + private int getBatchSerializedSize(List originalAccessKeyIdBatch) { + final DeleteRevokedSTSTokensRequest request = DeleteRevokedSTSTokensRequest.newBuilder() + .addAllOriginalAccessKeyId(originalAccessKeyIdBatch) + .build(); + + return request.getSerializedSize(); + } + } +} + + diff --git a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/protocolPB/OzoneManagerProtocolServerSideTranslatorPB.java b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/protocolPB/OzoneManagerProtocolServerSideTranslatorPB.java index c1343711c9a8..b5d46fcdb172 100644 --- a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/protocolPB/OzoneManagerProtocolServerSideTranslatorPB.java +++ b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/protocolPB/OzoneManagerProtocolServerSideTranslatorPB.java @@ -210,6 +210,7 @@ private OMResponse internalProcessRequest(OMRequest request) throws ServiceExcep return ozoneManager.getOmExecutionFlow().submit(request, true); } finally { OzoneManager.setS3Auth(null); + OzoneManager.setStsTokenIdentifier(null); } } diff --git a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/protocolPB/OzoneManagerRequestHandler.java b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/protocolPB/OzoneManagerRequestHandler.java index 8131023cb5ea..19e012350d2d 100644 --- a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/protocolPB/OzoneManagerRequestHandler.java +++ b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/protocolPB/OzoneManagerRequestHandler.java @@ -61,6 +61,7 @@ import org.apache.hadoop.hdds.scm.protocolPB.OzonePBHelper; import org.apache.hadoop.hdds.utils.FaultInjector; import org.apache.hadoop.ozone.OzoneAcl; +import org.apache.hadoop.ozone.om.OzoneAclUtils; import org.apache.hadoop.ozone.om.OzoneManager; import org.apache.hadoop.ozone.om.OzoneManagerPrepareState; import org.apache.hadoop.ozone.om.exceptions.OMException; @@ -86,6 +87,7 @@ import org.apache.hadoop.ozone.om.helpers.OpenKeySession; import org.apache.hadoop.ozone.om.helpers.OzoneFileStatus; import org.apache.hadoop.ozone.om.helpers.OzoneFileStatusLight; +import org.apache.hadoop.ozone.om.helpers.S3STSUtils; import org.apache.hadoop.ozone.om.helpers.ServiceInfo; import org.apache.hadoop.ozone.om.helpers.ServiceInfoEx; import org.apache.hadoop.ozone.om.helpers.SnapshotDiffJob; @@ -113,6 +115,7 @@ import org.apache.hadoop.ozone.protocol.proto.OzoneManagerProtocolProtos.FinalizeUpgradeProgressResponse; import org.apache.hadoop.ozone.protocol.proto.OzoneManagerProtocolProtos.GetBucketTaggingRequest; import org.apache.hadoop.ozone.protocol.proto.OzoneManagerProtocolProtos.GetBucketTaggingResponse; +import org.apache.hadoop.ozone.protocol.proto.OzoneManagerProtocolProtos.GetCallerIdentityResponse; import org.apache.hadoop.ozone.protocol.proto.OzoneManagerProtocolProtos.GetFileStatusRequest; import org.apache.hadoop.ozone.protocol.proto.OzoneManagerProtocolProtos.GetFileStatusResponse; import org.apache.hadoop.ozone.protocol.proto.OzoneManagerProtocolProtos.GetKeyInfoRequest; @@ -171,11 +174,13 @@ import org.apache.hadoop.ozone.protocol.proto.OzoneManagerProtocolProtos.TenantListUserResponse; import org.apache.hadoop.ozone.protocol.proto.OzoneManagerProtocolProtos.Type; import org.apache.hadoop.ozone.request.validation.RequestProcessingPhase; +import org.apache.hadoop.ozone.security.STSTokenIdentifier; import org.apache.hadoop.ozone.security.acl.OzoneObjInfo; import org.apache.hadoop.ozone.snapshot.ListSnapshotResponse; import org.apache.hadoop.ozone.upgrade.UpgradeFinalization.StatusAndMessages; import org.apache.hadoop.ozone.util.PayloadUtils; import org.apache.hadoop.ozone.util.ProtobufUtils; +import org.apache.hadoop.security.UserGroupInformation; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -316,6 +321,9 @@ public OMResponse handleReadRequest(OMRequest request) { getS3VolumeContext(); responseBuilder.setGetS3VolumeContextResponse(s3VolumeContextResponse); break; + case GetCallerIdentity: + responseBuilder.setGetCallerIdentityResponse(getCallerIdentity()); + break; case TenantGetUserInfo: impl.checkS3MultiTenancyEnabled(); TenantGetUserInfoResponse getUserInfoResponse = tenantGetUserInfo( @@ -1294,14 +1302,17 @@ private ListStatusResponse listStatus( private ListStatusLightResponse listStatusLight( ListStatusRequest request, int clientVersion) throws IOException { KeyArgs keyArgs = request.getKeyArgs(); - OmKeyArgs omKeyArgs = new OmKeyArgs.Builder() + OmKeyArgs.Builder omKeyArgsBuilder = new OmKeyArgs.Builder() .setVolumeName(keyArgs.getVolumeName()) .setBucketName(keyArgs.getBucketName()) .setKeyName(keyArgs.getKeyName()) .setSortDatanodesInPipeline(false) .setLatestVersionLocation(true) - .setHeadOp(keyArgs.getHeadOp()) - .build(); + .setHeadOp(keyArgs.getHeadOp()); + if (request.hasListPrefix() && !request.getListPrefix().isEmpty()) { + omKeyArgsBuilder.setListPrefix(request.getListPrefix()); + } + OmKeyArgs omKeyArgs = omKeyArgsBuilder.build(); boolean allowPartialPrefixes = request.hasAllowPartialPrefix() && request.getAllowPartialPrefix(); List statuses = @@ -1445,6 +1456,22 @@ private GetS3VolumeContextResponse getS3VolumeContext() return impl.getS3VolumeContext().getProtobuf(); } + private GetCallerIdentityResponse getCallerIdentity() throws OMException { + impl.checkS3STSEnabled(); + if (OzoneManager.getS3Auth() == null) { + throw new OMException( + "GetCallerIdentity does not have S3 authentication", OMException.ResultCodes.INVALID_REQUEST); + } + final STSTokenIdentifier stsTokenIdentifier = OzoneManager.getStsTokenIdentifier(); + if (stsTokenIdentifier != null) { + return S3STSUtils.resolveCallerIdentityForStsCredentials( + stsTokenIdentifier.getAssumedRoleId(), stsTokenIdentifier.getAssumedRoleUserArn()).getProtobuf(); + } + final String resolvedPrincipal = OzoneAclUtils.accessIdToUserPrincipal(OzoneManager.getS3AuthEffectiveAccessId()); + final String kerberosShortName = UserGroupInformation.createRemoteUser(resolvedPrincipal).getShortUserName(); + return S3STSUtils.resolveCallerIdentityForPermanentCredentials(resolvedPrincipal, kerberosShortName).getProtobuf(); + } + @DisallowedUntilLayoutVersion(FILESYSTEM_SNAPSHOT) private SnapshotDiffResponse snapshotDiff( SnapshotDiffRequest snapshotDiffRequest) throws IOException { diff --git a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/security/S3SecurityUtil.java b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/security/S3SecurityUtil.java index 860c69242b21..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 @@ -17,13 +17,21 @@ package org.apache.hadoop.ozone.security; +import static org.apache.hadoop.ozone.om.exceptions.OMException.ResultCodes.INTERNAL_ERROR; import static org.apache.hadoop.ozone.om.exceptions.OMException.ResultCodes.INVALID_TOKEN; +import static org.apache.hadoop.ozone.om.exceptions.OMException.ResultCodes.REVOKED_TOKEN; import static org.apache.hadoop.ozone.protocol.proto.OzoneManagerProtocolProtos.OMTokenProto.Type.S3AUTHINFO; import com.google.protobuf.ServiceException; +import java.io.IOException; +import java.time.Clock; +import java.time.ZoneOffset; import org.apache.hadoop.hdds.annotation.InterfaceAudience; import org.apache.hadoop.hdds.annotation.InterfaceStability; +import org.apache.hadoop.hdds.utils.db.Table; import org.apache.hadoop.io.Text; +import org.apache.hadoop.ozone.om.AWSV4AuthValidator; +import org.apache.hadoop.ozone.om.OMMetadataManager; import org.apache.hadoop.ozone.om.OzoneManager; import org.apache.hadoop.ozone.om.exceptions.OMException; import org.apache.hadoop.ozone.om.exceptions.OMLeaderNotReadyException; @@ -32,6 +40,8 @@ import org.apache.hadoop.ozone.protocol.proto.OzoneManagerProtocolProtos.S3Authentication; import org.apache.hadoop.ozone.protocolPB.OzoneManagerProtocolServerSideTranslatorPB; import org.apache.hadoop.security.token.SecretManager; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; /** * Utility class which holds methods required for parse/validation of @@ -41,6 +51,9 @@ @InterfaceStability.Evolving public final class S3SecurityUtil { + private static final Clock CLOCK = Clock.system(ZoneOffset.UTC); + private static final Logger LOG = LoggerFactory.getLogger(S3SecurityUtil.class); + private S3SecurityUtil() { } @@ -54,6 +67,43 @@ private S3SecurityUtil() { public static void validateS3Credential(OMRequest omRequest, OzoneManager ozoneManager) throws ServiceException, OMException { if (ozoneManager.isSecurityEnabled()) { + // If STS session token is present, decode, decrypt and validate it once and save in thread-local + if (omRequest.getS3Authentication().hasSessionToken()) { + final String token = omRequest.getS3Authentication().getSessionToken(); + if (!token.isEmpty()) { + final STSTokenIdentifier stsTokenIdentifier = STSSecurityUtil.constructValidateAndDecryptSTSToken( + token, ozoneManager.getSecretKeyClient(), CLOCK); + + // Ensure the token is not revoked + 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); + } + + // Ensure the principal that created the STS token (originalAccessKeyId) has not been revoked + if (isOriginalAccessKeyIdRevoked(stsTokenIdentifier, ozoneManager)) { + LOG.info("OriginalAccessKeyId for session token has been revoked: {}, {}", + stsTokenIdentifier.getOriginalAccessKeyId(), stsTokenIdentifier.getTempAccessKeyId()); + throw new OMException("STS token no longer valid: OriginalAccessKeyId principal revoked", REVOKED_TOKEN); + } + + // Ensure the access key ID in the request matches the one encoded in the token. + // This prevents using a valid token and secretKey to authenticate arbitrary accessKeyIds. + final String requestAccessId = omRequest.getS3Authentication().getAccessId(); + if (!requestAccessId.equals(stsTokenIdentifier.getTempAccessKeyId())) { + throw new OMException( + "STS token validation failed - accessKeyId is invalid for session token", INVALID_TOKEN); + } + + // HMAC signature and expiration were validated above. Now validate AWS signature. + validateSTSTokenAwsSignature(stsTokenIdentifier, omRequest); + OzoneManager.setStsTokenIdentifier(stsTokenIdentifier); + return; + } + } + OzoneTokenIdentifier s3Token = constructS3Token(omRequest); try { // authenticate user with signature verification through @@ -89,4 +139,71 @@ public static OzoneTokenIdentifier constructS3Token(OMRequest omRequest) { s3Token.setOwner(new Text(auth.getAccessId())); return s3Token; } + + /** + * Validates the AWS signature of an STSTokenIdentifier that has already been decrypted. + * @param stsTokenIdentifier the decrypted STS token + * @param omRequest the OMRequest containing STS token + * @throws OMException if the AWS signature validation fails + */ + private static void validateSTSTokenAwsSignature(STSTokenIdentifier stsTokenIdentifier, OMRequest omRequest) + throws OMException { + final String secretAccessKey = stsTokenIdentifier.getSecretAccessKey(); + final S3Authentication s3Authentication = omRequest.getS3Authentication(); + if (AWSV4AuthValidator.validateRequest( + s3Authentication.getStringToSign(), s3Authentication.getSignature(), secretAccessKey)) { + return; + } + throw new OMException( + "STS token validation failed for token: " + omRequest.getS3Authentication().getSessionToken(), INVALID_TOKEN); + } + + /** + * Returns true if the STS token was created before the revocation cutoff for its originalAccessKeyId. + */ + 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"; + LOG.warn(msg); + throw new OMException(msg, INTERNAL_ERROR); + } + + final Table revokedStsTokenTable = metadataManager.getS3RevokedStsTokenTable(); + if (revokedStsTokenTable == null) { + final String msg = "Could not determine STS revocation: revokedStsTokenTable is null"; + LOG.warn(msg); + throw new OMException(msg, INTERNAL_ERROR); + } + + 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); + throw new OMException(msg, e, INTERNAL_ERROR); + } + } + + /** + * Returns true if the originalAccessKeyId of the STS token has been revoked. + */ + private static boolean isOriginalAccessKeyIdRevoked(STSTokenIdentifier stsTokenIdentifier, OzoneManager ozoneManager) + throws OMException { + // We already know originalAccessKeyId is not null from STSSecurityUtil.ensureEssentialFieldsArePresentInToken() + // method called from STSSecurityUtil.constructValidateAndDecryptSTSToken() method above + final String originalAccessKeyId = stsTokenIdentifier.getOriginalAccessKeyId(); + try { + // If the secret for the original principal is missing, it means it was revoked + return !ozoneManager.getS3SecretManager().hasS3Secret(originalAccessKeyId); + } catch (IOException e) { + final String msg = "Could not determine if original principal is revoked: " + e.getMessage(); + LOG.warn(msg, e); + throw new OMException(msg, e, INTERNAL_ERROR); + } + } } diff --git a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/security/STSSecurityUtil.java b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/security/STSSecurityUtil.java new file mode 100644 index 000000000000..8862f16d235e --- /dev/null +++ b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/security/STSSecurityUtil.java @@ -0,0 +1,235 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.hadoop.ozone.security; + +import static org.apache.hadoop.ozone.om.exceptions.OMException.ResultCodes.INVALID_TOKEN; +import static org.apache.hadoop.ozone.om.exceptions.OMException.ResultCodes.TOKEN_EXPIRED; + +import com.google.common.annotations.VisibleForTesting; +import com.google.protobuf.InvalidProtocolBufferException; +import java.io.IOException; +import java.time.Clock; +import java.util.UUID; +import org.apache.commons.lang3.StringUtils; +import org.apache.hadoop.hdds.annotation.InterfaceAudience; +import org.apache.hadoop.hdds.annotation.InterfaceStability; +import org.apache.hadoop.hdds.security.symmetric.ManagedSecretKey; +import org.apache.hadoop.hdds.security.symmetric.SecretKeyClient; +import org.apache.hadoop.ozone.om.exceptions.OMException; +import org.apache.hadoop.ozone.protocol.proto.OzoneManagerProtocolProtos; +import org.apache.hadoop.ozone.protocol.proto.OzoneManagerProtocolProtos.OMTokenProto; +import org.apache.hadoop.ozone.protocol.proto.OzoneManagerProtocolProtos.S3Authentication; +import org.apache.hadoop.security.token.SecretManager; +import org.apache.hadoop.security.token.Token; + +/** + * Utility class with methods to validate and decrypt STS tokens. + */ +@InterfaceAudience.Private +@InterfaceStability.Evolving +public final class STSSecurityUtil { + private STSSecurityUtil() { + } + + /** + * Constructs, validates and decrypts STS session token. + * + * @param sessionToken the session token from the x-amz-security-token header + * @param secretKeyClient the Ozone Manager secretKeyClient + * @param clock the system clock + * @return the STSTokenIdentifier with decrypted secretAccessKey + * @throws OMException if the token is not valid or processing failed otherwise + */ + public static STSTokenIdentifier constructValidateAndDecryptSTSToken(String sessionToken, + SecretKeyClient secretKeyClient, Clock clock) throws OMException { + try { + final Token token = decodeTokenFromString(sessionToken); + return verifyAndDecryptToken(token, secretKeyClient, clock); + } catch (SecretManager.InvalidToken e) { + throw new OMException("Invalid STS token format: " + e.getMessage(), e, INVALID_TOKEN); + } + } + + /** + * Verifies an STS Token by performing multiple checks. + * + * @param token the token to verify + * @param clock the system clock + * @return the STSTokenIdentifier with decrypted secretAccessKey + * @throws SecretManager.InvalidToken if the token is invalid + */ + private static STSTokenIdentifier verifyAndDecryptToken(Token token, + SecretKeyClient secretKeyClient, Clock clock) throws SecretManager.InvalidToken, OMException { + if (!STSTokenIdentifier.KIND_NAME.equals(token.getKind())) { + throw new SecretManager.InvalidToken("Invalid STS token - kind is incorrect: " + token.getKind()); + } + + if (!STSTokenIdentifier.STS_SERVICE.equals(token.getService().toString())) { + throw new SecretManager.InvalidToken("Invalid STS token - service is incorrect: " + token.getService()); + } + + final byte[] tokenBytes = token.getIdentifier(); + final OMTokenProto proto; + try { + proto = OMTokenProto.parseFrom(tokenBytes); + } catch (InvalidProtocolBufferException e) { + throw new SecretManager.InvalidToken("Invalid STS token - could not parse protocol buffer: " + e.getMessage()); + } + final UUID secretKeyId; + try { + secretKeyId = UUID.fromString(proto.getSecretKeyId()); + } catch (IllegalArgumentException e) { + throw new SecretManager.InvalidToken("Invalid STS token - secretKeyId was not valid: " + proto.getSecretKeyId()); + } + + final STSTokenIdentifier tokenId = new STSTokenIdentifier(); + final ManagedSecretKey secretKey; + try { + secretKey = getValidatedSecretKey(secretKeyId, secretKeyClient); + tokenId.setManagedSecretKey(secretKey); + tokenId.readFromByteArray(tokenBytes); + } catch (OMException e) { + throw e; + } catch (IOException e) { + throw new SecretManager.InvalidToken("Invalid STS token - could not readFromByteArray: " + e.getMessage()); + } + + // Ensure essential fields are present in the token + ensureEssentialFieldsArePresentInToken(tokenId); + + // Check expiration + if (tokenId.isExpired(clock.instant())) { + throw new OMException("Invalid STS token - token expired at " + tokenId.getExpiry(), TOKEN_EXPIRED); + } + + // Verify token signature against the original identifier bytes + if (!tokenId.isValidSignature(tokenBytes, token.getPassword())) { + throw new SecretManager.InvalidToken("Invalid STS token - signature is not correct for token: " + tokenId); + } + + return tokenId; + } + + private static ManagedSecretKey getValidatedSecretKey(UUID secretKeyId, SecretKeyClient secretKeyClient) + throws SecretManager.InvalidToken, OMException { + if (secretKeyId == null) { + throw new SecretManager.InvalidToken("STS token missing secret key ID"); + } + + final ManagedSecretKey secretKey; + try { + secretKey = secretKeyClient.getSecretKey(secretKeyId); + } catch (Exception e) { + throw new SecretManager.InvalidToken("Failed to retrieve secret key: " + e.getMessage()); + } + + if (secretKey == null) { + throw new SecretManager.InvalidToken("Secret key not found for STS token secretKeyId: " + secretKeyId); + } + + if (secretKey.isExpired()) { + throw new OMException( + "Token cannot be verified due to expired secret key: " + secretKeyId + " Token expired at " + + secretKey.getExpiryTime(), TOKEN_EXPIRED); + } + + return secretKey; + } + + private static Token decodeTokenFromString(String encodedToken) + throws SecretManager.InvalidToken { + final Token token = new Token<>(); + // token.decodeFromUrlString() only declares IOException, but deserialization can throw + // unchecked exceptions (e.g. NegativeArraySizeException) when malformed input decodes to a + // negative byte-array length. Map those to InvalidToken (via catching RuntimeException) + // instead of failing the OM request. + try { + token.decodeFromUrlString(encodedToken); + final String canonical = token.encodeToUrlString(); + if (!canonical.equals(encodedToken)) { + throw new SecretManager.InvalidToken("Failed to decode STS token string: non-canonical token encoding"); + } + return token; + } catch (IOException | RuntimeException e) { + throw new SecretManager.InvalidToken("Failed to decode STS token string: " + e); + } + } + + @VisibleForTesting + static void ensureEssentialFieldsArePresentInToken(STSTokenIdentifier stsTokenIdentifier) + throws SecretManager.InvalidToken { + if (StringUtils.isEmpty(stsTokenIdentifier.getTempAccessKeyId())) { + throw new SecretManager.InvalidToken("Invalid STS token - tempAccessKeyId is null/empty"); + } + if (stsTokenIdentifier.getExpiry() == null) { + throw new SecretManager.InvalidToken("Invalid STS token - expiry is null"); + } + if (StringUtils.isEmpty(stsTokenIdentifier.getRoleArn())) { + throw new SecretManager.InvalidToken("Invalid STS token - roleArn is null/empty"); + } + if (StringUtils.isEmpty(stsTokenIdentifier.getOriginalAccessKeyId())) { + throw new SecretManager.InvalidToken("Invalid STS token - originalAccessKeyId is null/empty"); + } + if (StringUtils.isEmpty(stsTokenIdentifier.getSecretAccessKey())) { + throw new SecretManager.InvalidToken("Invalid STS token - secretAccessKey is null/empty"); + } + if (stsTokenIdentifier.getCreationTime() == null) { + throw new SecretManager.InvalidToken("Invalid STS token - creationTime is null"); + } + } + + /** + * Ensures STS-related {@link S3Authentication} fields are structurally consistent on the Ratis + * apply path. Cryptographic validation (signature, expiry, secret key lookup) runs on the leader + * RPC path (e.g. {@code S3SecurityUtil.validateS3Credential}). This method performs no crypto and does + * not contact {@link SecretKeyClient}, keeping the apply thread deterministic and lightweight. + * + * @param request OM request possibly containing S3 authentication + * @throws OMException if resolved fields and session token presence are inconsistent + */ + public static void ensureResolvedStsFieldsInvariants(OzoneManagerProtocolProtos.OMRequest request) + throws OMException { + if (!request.hasS3Authentication()) { + return; + } + + final S3Authentication s3Auth = request.getS3Authentication(); + final boolean hasSessionToken = s3Auth.hasSessionToken() && !s3Auth.getSessionToken().isEmpty(); + + if (!hasSessionToken) { + // If sessionToken is missing/empty, resolved fields must be empty. + if (s3Auth.hasResolvedStsSessionPolicy() || s3Auth.hasResolvedStsRoleArn() || + s3Auth.hasResolvedStsOriginalAccessKeyId() || s3Auth.hasResolvedStsTempAccessKeyId() || + s3Auth.hasResolvedStsSecretKeyId()) { + throw new OMException("Resolved STS fields must be empty when sessionToken is not present", INVALID_TOKEN); + } + return; + } + + ensureResolvedFieldsArePresent(s3Auth); + } + + private static void ensureResolvedFieldsArePresent(S3Authentication s3Auth) throws OMException { + if (!s3Auth.hasResolvedStsSessionPolicy() || !s3Auth.hasResolvedStsRoleArn() || + !s3Auth.hasResolvedStsOriginalAccessKeyId() || !s3Auth.hasResolvedStsTempAccessKeyId() || + !s3Auth.hasResolvedStsSecretKeyId()) { + throw new OMException("Resolved STS fields must be present when sessionToken is present", INVALID_TOKEN); + } + } +} + diff --git a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/security/STSTokenEncryption.java b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/security/STSTokenEncryption.java new file mode 100644 index 000000000000..ef03da982b05 --- /dev/null +++ b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/security/STSTokenEncryption.java @@ -0,0 +1,205 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.hadoop.ozone.security; + +import com.google.common.base.Preconditions; +import java.nio.charset.StandardCharsets; +import java.security.SecureRandom; +import java.util.Base64; +import javax.crypto.Cipher; +import javax.crypto.spec.GCMParameterSpec; +import javax.crypto.spec.SecretKeySpec; +import org.apache.hadoop.hdds.annotation.InterfaceAudience; +import org.apache.hadoop.hdds.annotation.InterfaceStability; +import org.bouncycastle.crypto.digests.SHA256Digest; +import org.bouncycastle.crypto.generators.HKDFBytesGenerator; +import org.bouncycastle.crypto.params.HKDFParameters; +import org.bouncycastle.jce.provider.BouncyCastleProvider; + +/** + * Utility class for encrypting and decrypting sensitive data in STS tokens. + * Uses HKDF to derive an AES encryption key from the SCM ManagedSecretKey, + * then uses AES-GCM for authenticated encryption. + */ +@InterfaceAudience.Private +@InterfaceStability.Unstable +public final class STSTokenEncryption { + + // HKDF parameters + private static final byte[] HKDF_INFO = "STS-TOKEN-ENCRYPTION".getBytes(StandardCharsets.UTF_8); + private static final int HKDF_SALT_LENGTH = 16; // 128 bits + private static final int AES_KEY_LENGTH = 32; // 256 bits + + // AES-GCM parameters + private static final int GCM_IV_LENGTH = 12; // 96 bits + private static final int GCM_AUTHENTICATION_TAG_LENGTH_IN_BITS = 128; + private static final String AES_ALGORITHM = "AES"; + private static final String AES_CIPHER_TRANSFORMATION = "AES/GCM/NoPadding"; + + private static final SecureRandom SECURE_RANDOM; + private static final BouncyCastleProvider BC_PROVIDER = new BouncyCastleProvider(); + + private STSTokenEncryption() { + } + + static { + SecureRandom secureRandom; + try { + // Prefer non-blocking native PRNG where available + secureRandom = SecureRandom.getInstance("NativePRNGNonBlocking"); + } catch (Exception e) { + // Fallback to default SecureRandom implementation + secureRandom = new SecureRandom(); + } + SECURE_RANDOM = secureRandom; + } + + /** + * Encrypt sensitive data using AES-GCM with a key derived from the secret key via HKDF, + * binding the provided AAD to the authentication tag. + * + * @param plaintext the sensitive data to encrypt + * @param secretKeyBytes the secret key bytes from ManagedSecretKey + * @param aad additional authenticated data to bind + * @return base64-encoded encrypted data with Salt and IV prepended + * @throws STSTokenEncryptionException if encryption fails + */ + public static String encrypt(String plaintext, byte[] secretKeyBytes, byte[] aad) throws STSTokenEncryptionException { + Preconditions.checkArgument( + secretKeyBytes != null && secretKeyBytes.length > 0, "The secretKeyBytes must not be null nor empty"); + Preconditions.checkArgument(aad != null && aad.length > 0, "The aad must not be null nor empty"); + // Don't encrypt null/empty strings + if (plaintext == null || plaintext.isEmpty()) { + return plaintext; + } + + byte[] aesKey; + byte[] iv; + byte[] salt; + try { + // Generate random salt + salt = new byte[HKDF_SALT_LENGTH]; + SECURE_RANDOM.nextBytes(salt); + + // Derive AES key using HKDF with random salt + aesKey = deriveKey(secretKeyBytes, salt); + + // Generate random IV + iv = new byte[GCM_IV_LENGTH]; + SECURE_RANDOM.nextBytes(iv); + + // Initialize AES-GCM cipher + final Cipher cipher = Cipher.getInstance(AES_CIPHER_TRANSFORMATION, BC_PROVIDER); + final GCMParameterSpec spec = new GCMParameterSpec(GCM_AUTHENTICATION_TAG_LENGTH_IN_BITS, iv); + cipher.init(Cipher.ENCRYPT_MODE, new SecretKeySpec(aesKey, AES_ALGORITHM), spec); + cipher.updateAAD(aad); + + // Encrypt the plaintext + final byte[] ciphertext = cipher.doFinal(plaintext.getBytes(StandardCharsets.UTF_8)); + + // Combine salt, IV and ciphertext + final byte[] result = org.bouncycastle.util.Arrays.concatenate(salt, iv, ciphertext); + + return Base64.getEncoder().encodeToString(result); + } catch (Exception e) { + throw new STSTokenEncryptionException("Failed to encrypt sensitive data", e); + } + } + + /** + * Decrypt sensitive data using AES-GCM with a key derived from the secret key via HKDF, + * verifying the provided AAD bound to the authentication tag. + * + * @param encryptedData base64-encoded encrypted data with Salt and IV prepended + * @param secretKeyBytes the secret key bytes from ManagedSecretKey + * @param aad additional authenticated data to verify + * @return decrypted plaintext + * @throws STSTokenEncryptionException if decryption fails + */ + public static String decrypt(String encryptedData, byte[] secretKeyBytes, byte[] aad) + throws STSTokenEncryptionException { + Preconditions.checkArgument( + secretKeyBytes != null && secretKeyBytes.length > 0, "The secretKeyBytes must not be null nor empty"); + Preconditions.checkArgument(aad != null && aad.length > 0, "The aad must not be null nor empty"); + // Don't decrypt null/empty strings + if (encryptedData == null || encryptedData.isEmpty()) { + return encryptedData; + } + + byte[] aesKey; + try { + // Decode base64 + final byte[] data = Base64.getDecoder().decode(encryptedData); + + if (data.length < HKDF_SALT_LENGTH + GCM_IV_LENGTH) { + throw new STSTokenEncryptionException("Invalid encrypted data"); + } + + // Extract salt, IV and ciphertext + final byte[] salt = new byte[HKDF_SALT_LENGTH]; + final byte[] iv = new byte[GCM_IV_LENGTH]; + final byte[] ciphertext = new byte[data.length - HKDF_SALT_LENGTH - GCM_IV_LENGTH]; + + System.arraycopy(data, 0, salt, 0, HKDF_SALT_LENGTH); + System.arraycopy(data, HKDF_SALT_LENGTH, iv, 0, GCM_IV_LENGTH); + System.arraycopy(data, HKDF_SALT_LENGTH + GCM_IV_LENGTH, ciphertext, 0, ciphertext.length); + + // Derive AES key using HKDF with extracted salt + aesKey = deriveKey(secretKeyBytes, salt); + + // Initialize AES-GCM cipher + final Cipher cipher = Cipher.getInstance(AES_CIPHER_TRANSFORMATION, BC_PROVIDER); + final GCMParameterSpec spec = new GCMParameterSpec(GCM_AUTHENTICATION_TAG_LENGTH_IN_BITS, iv); + cipher.init(Cipher.DECRYPT_MODE, new SecretKeySpec(aesKey, AES_ALGORITHM), spec); + cipher.updateAAD(aad); + + // Decrypt the ciphertext + final byte[] output = cipher.doFinal(ciphertext); + + return new String(output, StandardCharsets.UTF_8); + } catch (Exception e) { + throw new STSTokenEncryptionException("Failed to decrypt sensitive data", e); + } + } + + /** + * Derive AES key using HKDF-SHA256. + */ + private static byte[] deriveKey(byte[] secretKeyBytes, byte[] salt) { + final HKDFBytesGenerator hkdf = new HKDFBytesGenerator(new SHA256Digest()); + hkdf.init(new HKDFParameters(secretKeyBytes, salt, HKDF_INFO)); + + final byte[] aesKey = new byte[AES_KEY_LENGTH]; + hkdf.generateBytes(aesKey, 0, AES_KEY_LENGTH); + return aesKey; + } + + /** + * Exception thrown when encryption/decryption operations fail. + */ + public static class STSTokenEncryptionException extends Exception { + public STSTokenEncryptionException(String message) { + super(message); + } + + public STSTokenEncryptionException(String message, Throwable cause) { + super(message, cause); + } + } +} + diff --git a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/security/STSTokenIdentifier.java b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/security/STSTokenIdentifier.java new file mode 100644 index 000000000000..c0928d93a2c7 --- /dev/null +++ b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/security/STSTokenIdentifier.java @@ -0,0 +1,482 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.hadoop.ozone.security; + +import com.google.common.annotations.VisibleForTesting; +import com.google.common.base.Preconditions; +import java.io.ByteArrayInputStream; +import java.io.DataInput; +import java.io.DataInputStream; +import java.io.DataOutput; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.time.Instant; +import java.util.Objects; +import java.util.UUID; +import org.apache.hadoop.hdds.annotation.InterfaceAudience; +import org.apache.hadoop.hdds.annotation.InterfaceStability; +import org.apache.hadoop.hdds.security.symmetric.ManagedSecretKey; +import org.apache.hadoop.hdds.security.token.ShortLivedTokenIdentifier; +import org.apache.hadoop.io.Text; +import org.apache.hadoop.ozone.protocol.proto.OzoneManagerProtocolProtos.OMTokenProto; + +/** + * Token identifier for STS (Security Token Service) tokens. + */ +@InterfaceAudience.Private +@InterfaceStability.Unstable +public class STSTokenIdentifier extends ShortLivedTokenIdentifier { + public static final Text KIND_NAME = new Text("STSToken"); + + // STS-specific fields + private String roleArn; + private String originalAccessKeyId; + private String secretAccessKey; + private String sessionPolicy; + private String assumedRoleId; + private String assumedRoleUserArn; + private Instant creationTime; + + // SCM secret key used for encrypting sensitive fields and signing this token + // It will NOT be encoded in the token. + private transient ManagedSecretKey managedSecretKey; + + // Service name for STS tokens + public static final String STS_SERVICE = "STS"; + + /** + * Create an empty STS token identifier. + */ + public STSTokenIdentifier() { + super(); + } + + /** + * Create a new STS token identifier with encryption support. + * + * @param params the STS token creation parameters + */ + public STSTokenIdentifier(Params params) { + super(params.getTempAccessKeyId(), params.getExpiry()); + this.originalAccessKeyId = params.getOriginalAccessKeyId(); + this.roleArn = params.getRoleArn(); + this.creationTime = params.getCreationTime(); + this.secretAccessKey = params.getSecretAccessKey(); + this.sessionPolicy = params.getSessionPolicy(); + this.managedSecretKey = params.getManagedSecretKey(); + // In the OzoneManagerStateMachine case, both secretAccessKey and managedSecretKey are set to null + if (this.secretAccessKey != null) { + if (this.managedSecretKey != null) { + setSecretKeyId(managedSecretKey.getId()); + } else { + throw new IllegalArgumentException("ManagedSecretKey is not set"); + } + } + this.assumedRoleId = params.getAssumedRoleId(); + this.assumedRoleUserArn = params.getAssumedRoleUserArn(); + } + + /** + * Parameters for constructing an {@link STSTokenIdentifier}. + */ + public static final class Params { + private final String tempAccessKeyId; + private final String originalAccessKeyId; + private final String roleArn; + private final Instant creationTime; + private final Instant expiry; + private final String secretAccessKey; + private final String sessionPolicy; + private final ManagedSecretKey managedSecretKey; + private final String assumedRoleId; + private final String assumedRoleUserArn; + + private Params(Builder builder) { + this.tempAccessKeyId = builder.tempAccessKeyId; + this.originalAccessKeyId = builder.originalAccessKeyId; + this.roleArn = builder.roleArn; + this.creationTime = builder.creationTime; + this.expiry = builder.expiry; + this.secretAccessKey = builder.secretAccessKey; + this.sessionPolicy = builder.sessionPolicy; + this.managedSecretKey = builder.managedSecretKey; + this.assumedRoleId = builder.assumedRoleId; + this.assumedRoleUserArn = builder.assumedRoleUserArn; + } + + public static Builder newBuilder() { + return new Builder(); + } + + public String getTempAccessKeyId() { + return tempAccessKeyId; + } + + public String getOriginalAccessKeyId() { + return originalAccessKeyId; + } + + public String getRoleArn() { + return roleArn; + } + + public Instant getCreationTime() { + return creationTime; + } + + public Instant getExpiry() { + return expiry; + } + + public String getSecretAccessKey() { + return secretAccessKey; + } + + public String getSessionPolicy() { + return sessionPolicy; + } + + public ManagedSecretKey getManagedSecretKey() { + return managedSecretKey; + } + + public String getAssumedRoleId() { + return assumedRoleId; + } + + public String getAssumedRoleUserArn() { + return assumedRoleUserArn; + } + + /** + * Builder for {@link Params}. + */ + public static final class Builder { + private String tempAccessKeyId; + private String originalAccessKeyId; + private String roleArn; + private Instant creationTime; + private Instant expiry; + private String secretAccessKey; + private String sessionPolicy; + private ManagedSecretKey managedSecretKey; + private String assumedRoleId; + private String assumedRoleUserArn; + + public Builder setTempAccessKeyId(String value) { + this.tempAccessKeyId = value; + return this; + } + + public Builder setOriginalAccessKeyId(String value) { + this.originalAccessKeyId = value; + return this; + } + + public Builder setRoleArn(String value) { + this.roleArn = value; + return this; + } + + public Builder setCreationTime(Instant value) { + this.creationTime = value; + return this; + } + + public Builder setExpiry(Instant value) { + this.expiry = value; + return this; + } + + public Builder setSecretAccessKey(String value) { + this.secretAccessKey = value; + return this; + } + + public Builder setSessionPolicy(String value) { + this.sessionPolicy = value; + return this; + } + + public Builder setManagedSecretKey(ManagedSecretKey value) { + this.managedSecretKey = value; + return this; + } + + public Builder setAssumedRoleId(String value) { + this.assumedRoleId = value; + return this; + } + + public Builder setAssumedRoleUserArn(String value) { + this.assumedRoleUserArn = value; + return this; + } + + public Params build() { + return new Params(this); + } + } + } + + @Override + public Text getKind() { + return KIND_NAME; + } + + @Override + public String getService() { + return STS_SERVICE; + } + + @Override + public void readFromByteArray(byte[] bytes) throws IOException { + final DataInputStream in = new DataInputStream(new ByteArrayInputStream(bytes)); + readFields(in); + } + + @Override + public void write(DataOutput out) throws IOException { + out.write(toProtoBuf().toByteArray()); + } + + @Override + public void readFields(DataInput in) throws IOException { + final OMTokenProto token = OMTokenProto.parseFrom((DataInputStream) in); + fromProtoBuf(token); + } + + /** + * Convert this identifier to protobuf format. + */ + public OMTokenProto toProtoBuf() throws IOException { + Preconditions.checkArgument(this.managedSecretKey != null, "The ManagedSecretKey must not be null"); + + final OMTokenProto.Builder builder = OMTokenProto.newBuilder() + .setType(OMTokenProto.Type.S3_STS_TOKEN) + .setIssueDate(creationTime.toEpochMilli()) + .setMaxDate(getExpiry().toEpochMilli()) + .setOwner(getOwnerId() != null ? getOwnerId() : "") + .setAccessKeyId(getOwnerId() != null ? getOwnerId() : "") + .setOriginalAccessKeyId(originalAccessKeyId != null ? originalAccessKeyId : "") + .setRoleArn(roleArn != null ? roleArn : "") + .setSecretAccessKey(secretAccessKey != null ? encryptSensitiveField(secretAccessKey) : "") + .setSecretKeyId(managedSecretKey.getId().toString()) + .setSessionPolicy(sessionPolicy != null ? sessionPolicy : "") + .setAssumedRoleId(assumedRoleId != null ? assumedRoleId : "") + .setAssumedRoleUserArn(assumedRoleUserArn != null ? assumedRoleUserArn : ""); + + return builder.build(); + } + + /** + * Initialize this identifier from protobuf. + */ + public void fromProtoBuf(OMTokenProto token) throws IOException { + Preconditions.checkArgument( + token.getType() == OMTokenProto.Type.S3_STS_TOKEN, + "Invalid token type for STSTokenIdentifier: " + token.getType()); + Preconditions.checkArgument(this.managedSecretKey != null, "The ManagedSecretKey must not be null"); + + setOwnerId(token.getOwner()); + setExpiry(Instant.ofEpochMilli(token.getMaxDate())); + + if (token.hasIssueDate()) { + this.creationTime = Instant.ofEpochMilli(token.getIssueDate()); + } + if (token.hasOriginalAccessKeyId()) { + this.originalAccessKeyId = token.getOriginalAccessKeyId(); + } + if (token.hasRoleArn()) { + this.roleArn = token.getRoleArn(); + } + if (token.hasSecretKeyId()) { + try { + setSecretKeyId(UUID.fromString(token.getSecretKeyId())); + } catch (IllegalArgumentException e) { + // Handle invalid UUID format gracefully + throw new IOException( + "Invalid secretKeyId format in STS token: " + token.getSecretKeyId(), e); + } + } + // Note: secretKeyId must be set before attempting to decrypt secretAccessKey + if (token.hasSecretAccessKey()) { + this.secretAccessKey = decryptSensitiveField(token.getSecretAccessKey()); + } + + if (token.hasSessionPolicy()) { + this.sessionPolicy = token.getSessionPolicy(); + } + if (token.hasAssumedRoleId()) { + this.assumedRoleId = token.getAssumedRoleId(); + } + if (token.hasAssumedRoleUserArn()) { + this.assumedRoleUserArn = token.getAssumedRoleUserArn(); + } + } + + /** + * Encrypt a sensitive field using the configured encryption key. + */ + private String encryptSensitiveField(String value) throws IOException { + try { + final byte[] aad = computeAadBytes(); + return STSTokenEncryption.encrypt(value, getSecretKeyBytes(), aad); + } catch (STSTokenEncryption.STSTokenEncryptionException e) { + throw new IOException("Token encryption failed", e); + } + } + + /** + * Decrypt a sensitive field using the configured encryption key. + */ + private String decryptSensitiveField(String encryptedValue) throws IOException { + try { + final byte[] aad = computeAadBytes(); + return STSTokenEncryption.decrypt(encryptedValue, getSecretKeyBytes(), aad); + } catch (STSTokenEncryption.STSTokenEncryptionException e) { + throw new IOException("Token decryption failed", e); + } + } + + /** + * Compute additional authenticated data to bind token context to encryption. + * Includes token type, ownerId, expiry millis, and secretKeyId. + */ + private byte[] computeAadBytes() { + final StringBuilder stringBuilder = new StringBuilder("v1|S3_STS_TOKEN|") + .append(getOwnerId()) + .append('|') + .append(getExpiry().toEpochMilli()) + .append('|') + .append(getSecretKeyId().toString()); + final String aad = stringBuilder.toString(); + return aad.getBytes(StandardCharsets.UTF_8); + } + + public String getRoleArn() { + return roleArn; + } + + public String getSecretAccessKey() { + return secretAccessKey; + } + + public String getOriginalAccessKeyId() { + return originalAccessKeyId; + } + + /** + * Get the temporary access key ID (same as owner). + */ + public String getTempAccessKeyId() { + return getOwnerId(); + } + + /** + * Optional session policy associated with this STS token, or null/empty if none. + */ + public String getSessionPolicy() { + return sessionPolicy; + } + + public String getAssumedRoleId() { + return assumedRoleId; + } + + public String getAssumedRoleUserArn() { + return assumedRoleUserArn; + } + + public Instant getCreationTime() { + return creationTime; + } + + // For test only + @VisibleForTesting + public void setManagedSecretKey(ManagedSecretKey secretKey) { + this.managedSecretKey = secretKey; + } + + public ManagedSecretKey getManagedSecretKey() { + return managedSecretKey; + } + + /** + * Sign serialized identifier bytes using the configured {@link ManagedSecretKey}. + */ + public byte[] sign(byte[] identifierBytes) { + Objects.requireNonNull(managedSecretKey, "ManagedSecretKey must be set before signing"); + return managedSecretKey.sign(identifierBytes); + } + + /** + * Verify a signature against serialized identifier bytes using the configured + * {@link ManagedSecretKey}. + */ + public boolean isValidSignature(byte[] identifierBytes, byte[] signature) { + Objects.requireNonNull(managedSecretKey, "ManagedSecretKey must be set before signature verification"); + return managedSecretKey.isValidSignature(identifierBytes, signature); + } + + private byte[] getSecretKeyBytes() { + if (managedSecretKey == null) { + throw new IllegalStateException("ManagedSecretKey is not set"); + } + + return managedSecretKey.getSecretKey().getEncoded(); + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + + if (o == null || getClass() != o.getClass()) { + return false; + } + + if (!super.equals(o)) { + return false; + } + + final STSTokenIdentifier that = (STSTokenIdentifier) o; + return Objects.equals(roleArn, that.roleArn) && Objects.equals(secretAccessKey, that.secretAccessKey) && + Objects.equals(originalAccessKeyId, that.originalAccessKeyId) && + Objects.equals(sessionPolicy, that.sessionPolicy) && Objects.equals(assumedRoleId, that.assumedRoleId) && + Objects.equals(assumedRoleUserArn, that.assumedRoleUserArn) && Objects.equals(creationTime, that.creationTime); + } + + @Override + public int hashCode() { + return Objects.hash( + super.hashCode(), roleArn, secretAccessKey, originalAccessKeyId, sessionPolicy, assumedRoleId, + assumedRoleUserArn, creationTime); + } + + @Override + public String toString() { + // Intentionally left off secretAccessKey + return "STSTokenIdentifier{" + "tempAccessKeyId='" + getOwnerId() + "'" + + ", originalAccessKeyId='" + originalAccessKeyId + "', roleArn='" + roleArn + "'" + + ", assumedRoleId='" + assumedRoleId + "', assumedRoleUserArn='" + assumedRoleUserArn + "'" + + ", creationTime='" + creationTime + "', expiry='" + getExpiry() + "', secretKeyId='" + getSecretKeyId() + + "', sessionPolicy='" + sessionPolicy + "'}"; + } +} diff --git a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/security/STSTokenSecretManager.java b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/security/STSTokenSecretManager.java new file mode 100644 index 000000000000..2a7b7b1feb29 --- /dev/null +++ b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/security/STSTokenSecretManager.java @@ -0,0 +1,234 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.hadoop.ozone.security; + +import java.io.IOException; +import java.time.Instant; +import java.util.Objects; +import org.apache.hadoop.hdds.annotation.InterfaceAudience; +import org.apache.hadoop.hdds.annotation.InterfaceStability; +import org.apache.hadoop.hdds.security.symmetric.SecretKeySignerClient; +import org.apache.hadoop.hdds.security.token.ShortLivedTokenSecretManager; +import org.apache.hadoop.io.Text; +import org.apache.hadoop.security.token.Token; + +/** + * Secret manager for STS (Security Token Service) tokens. + * This class extends ShortLivedTokenSecretManager to make use of functionality such as signing tokens, etc. + */ +@InterfaceAudience.Private +@InterfaceStability.Unstable +public class STSTokenSecretManager extends ShortLivedTokenSecretManager { + + private static final long TOKEN_MAX_LIFETIME = 43200 * 1000L; // 12 hours in milliseconds + + // Store reference to secret key client for encryption key access + private final SecretKeySignerClient secretKeyClient; + + /** + * Create a new STS token secret manager. + * + * @param secretKeyClient client for accessing secret keys from SCM + */ + public STSTokenSecretManager(SecretKeySignerClient secretKeyClient) { + super(TOKEN_MAX_LIFETIME, secretKeyClient); + this.secretKeyClient = secretKeyClient; + } + + /** + * Override token generation so that we first compute the identifier bytes, then sign exactly those bytes, and + * return a Token that contains those same identifier bytes. This avoids non-determinism from multiple serializations + * which would break unit tests. If we used the inherited generateToken() in ShortLivedTokenSecretManager, it + * would have made two serialization calls: + * 1) in the call to secretKey.sign() in the createPassword() method + * 2) in the call to tokenIdentifier.getBytes() for the Token constructor + * These two calls would produce different secretAccessKey encrypted values because of the random initialization + * vector and random salt and hence give non-deterministic return value, so here we are only serializing once. + */ + @Override + public Token generateToken(STSTokenIdentifier tokenIdentifier) { + // Note - the ManagedSecretKey will NOT be encoded in the token. When generateToken() is called, + // it eventually calls the write() method in STSTokenIdentifier which calls toProtoBuf(), and the + // ManagedSecretKey is not serialized there. + Objects.requireNonNull( + tokenIdentifier.getManagedSecretKey(), "ManagedSecretKey must be set on the token identifier before signing"); + final byte[] identifierBytes = tokenIdentifier.getBytes(); + final byte[] password = tokenIdentifier.sign(identifierBytes); + return new Token<>(identifierBytes, password, tokenIdentifier.getKind(), new Text(tokenIdentifier.getService())); + } + + /** + * Create an STS token and return it as an encoded string. + * + * @param params the STS token creation parameters + * @return base64 encoded token string + */ + public String createSTSTokenString(CreateSTSTokenParams params) throws IOException { + final Instant creationTime = params.getCreationTime(); + final Instant expiration = creationTime.plusSeconds(params.getDurationSeconds()); + + final STSTokenIdentifier identifier = new STSTokenIdentifier(STSTokenIdentifier.Params.newBuilder() + .setTempAccessKeyId(params.getTempAccessKeyId()) + .setOriginalAccessKeyId(params.getOriginalAccessKeyId()) + .setRoleArn(params.getRoleArn()) + .setCreationTime(creationTime) + .setExpiry(expiration) + .setSecretAccessKey(params.getSecretAccessKey()) + .setSessionPolicy(params.getSessionPolicy()) + .setManagedSecretKey(secretKeyClient.getCurrentSecretKey()) + .setAssumedRoleId(params.getAssumedRoleId()) + .setAssumedRoleUserArn(params.getAssumedRoleUserArn()) + .build()); + + final Token token = generateToken(identifier); + return token.encodeToUrlString(); + } + + /** + * Parameters for {@link #createSTSTokenString(CreateSTSTokenParams)}. + */ + public static final class CreateSTSTokenParams { + private final String tempAccessKeyId; + private final String originalAccessKeyId; + private final String roleArn; + private final int durationSeconds; + private final String secretAccessKey; + private final String sessionPolicy; + private final String assumedRoleId; + private final String assumedRoleUserArn; + private final Instant creationTime; + + private CreateSTSTokenParams(Builder builder) { + this.tempAccessKeyId = builder.tempAccessKeyId; + this.originalAccessKeyId = builder.originalAccessKeyId; + this.roleArn = builder.roleArn; + this.durationSeconds = builder.durationSeconds; + this.secretAccessKey = builder.secretAccessKey; + this.sessionPolicy = builder.sessionPolicy; + this.assumedRoleId = builder.assumedRoleId; + this.assumedRoleUserArn = builder.assumedRoleUserArn; + this.creationTime = builder.creationTime; + } + + public static Builder newBuilder() { + return new Builder(); + } + + public String getTempAccessKeyId() { + return tempAccessKeyId; + } + + public String getOriginalAccessKeyId() { + return originalAccessKeyId; + } + + public String getRoleArn() { + return roleArn; + } + + public int getDurationSeconds() { + return durationSeconds; + } + + public String getSecretAccessKey() { + return secretAccessKey; + } + + public String getSessionPolicy() { + return sessionPolicy; + } + + public String getAssumedRoleId() { + return assumedRoleId; + } + + public String getAssumedRoleUserArn() { + return assumedRoleUserArn; + } + + public Instant getCreationTime() { + return creationTime; + } + + /** + * Builder for {@link CreateSTSTokenParams}. + */ + public static final class Builder { + private String tempAccessKeyId; + private String originalAccessKeyId; + private String roleArn; + private int durationSeconds; + private String secretAccessKey; + private String sessionPolicy; + private String assumedRoleId; + private String assumedRoleUserArn; + private Instant creationTime; + + public Builder setTempAccessKeyId(String value) { + this.tempAccessKeyId = value; + return this; + } + + public Builder setOriginalAccessKeyId(String value) { + this.originalAccessKeyId = value; + return this; + } + + public Builder setRoleArn(String value) { + this.roleArn = value; + return this; + } + + public Builder setDurationSeconds(int value) { + this.durationSeconds = value; + return this; + } + + public Builder setSecretAccessKey(String value) { + this.secretAccessKey = value; + return this; + } + + public Builder setSessionPolicy(String value) { + this.sessionPolicy = value; + return this; + } + + public Builder setAssumedRoleId(String value) { + this.assumedRoleId = value; + return this; + } + + public Builder setAssumedRoleUserArn(String value) { + this.assumedRoleUserArn = value; + return this; + } + + public Builder setCreationTime(Instant creationTime) { + this.creationTime = creationTime; + return this; + } + + public CreateSTSTokenParams build() { + return new CreateSTSTokenParams(this); + } + } + } +} + + diff --git a/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/om/TestOMMetadataReader.java b/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/om/TestOMMetadataReader.java index 9b7d4a552f56..bb5d7fb289dd 100644 --- a/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/om/TestOMMetadataReader.java +++ b/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/om/TestOMMetadataReader.java @@ -17,27 +17,74 @@ package org.apache.hadoop.ozone.om; +import static org.apache.hadoop.ozone.om.helpers.BucketLayout.FILE_SYSTEM_OPTIMIZED; +import static org.apache.hadoop.ozone.security.acl.IAccessAuthorizer.ACLType.LIST; +import static org.apache.hadoop.ozone.security.acl.IAccessAuthorizer.ACLType.READ; +import static org.apache.hadoop.ozone.security.acl.OzoneObj.ResourceType.KEY; +import static org.apache.hadoop.ozone.security.acl.OzoneObj.ResourceType.VOLUME; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.reset; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import io.grpc.Context; +import java.io.IOException; +import java.net.InetAddress; +import java.net.InetSocketAddress; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import org.apache.commons.lang3.tuple.Pair; +import org.apache.hadoop.hdds.conf.OzoneConfiguration; import org.apache.hadoop.ipc_.Server; +import org.apache.hadoop.ozone.audit.AuditLogger; +import org.apache.hadoop.ozone.om.exceptions.OMException; +import org.apache.hadoop.ozone.om.exceptions.OMException.ResultCodes; +import org.apache.hadoop.ozone.om.helpers.ListKeysResult; +import org.apache.hadoop.ozone.om.helpers.OmKeyArgs; +import org.apache.hadoop.ozone.om.helpers.OzoneFileStatus; +import org.apache.hadoop.ozone.om.protocolPB.grpc.GrpcClientConstants; +import org.apache.hadoop.ozone.protocol.proto.OzoneManagerProtocolProtos.S3Authentication; +import org.apache.hadoop.ozone.security.STSTokenIdentifier; +import org.apache.hadoop.ozone.security.acl.IAccessAuthorizer; +import org.apache.hadoop.ozone.security.acl.OzoneObj; +import org.apache.hadoop.ozone.security.acl.OzoneObjInfo; +import org.apache.hadoop.ozone.security.acl.RequestContext; +import org.apache.hadoop.ozone.util.ConcurrentMutableRate; +import org.apache.hadoop.security.UserGroupInformation; +import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; import org.mockito.MockedStatic; +import org.slf4j.Logger; /** * Test ozone metadata reader. */ public class TestOMMetadataReader { + private static final String ACCESS_KEY_ID = "ASIA7O1AJD8VV4KCEAX5"; + private static final String VOLUME_NAME = "s3v"; + private static final String BUCKET_NAME = "bucket123"; + private static final String KEY_PREFIX = "prefix"; + private static final long MAX_KEYS = 100L; + + @AfterEach + public void clearOmThreadLocals() { + OzoneManager.setStsTokenIdentifier(null); + OzoneManager.setS3Auth(null); + } + @Test - public void testGetClientAddress() { - try ( - MockedStatic ipcServerStaticMock = mockStatic(Server.class); - MockedStatic grpcRequestContextStaticMock = mockStatic(Context.class); - ) { + public void testGetClientAddress() throws Exception { + try (MockedStatic ipcServerStaticMock = mockStatic(Server.class)) { // given String expectedClientAddressInCaseOfHadoopRpcCall = "hadoop.ipc.client.com"; @@ -45,15 +92,11 @@ public void testGetClientAddress() { .thenReturn(null, null, expectedClientAddressInCaseOfHadoopRpcCall); String expectedClientAddressInCaseOfGrpcCall = "172.45.23.4"; - Context.Key clientIpAddressKey = mock(Context.Key.class); - when(clientIpAddressKey.get()) - .thenReturn(expectedClientAddressInCaseOfGrpcCall, null, null); - - grpcRequestContextStaticMock.when(() -> Context.key("CLIENT_IP_ADDRESS")) - .thenReturn(clientIpAddressKey); - // when (GRPC call with defined client address) - String clientAddress = OmMetadataReader.getClientAddress(); + String clientAddress = Context.current() + .withValue(GrpcClientConstants.CLIENT_IP_ADDRESS_CTX_KEY, + expectedClientAddressInCaseOfGrpcCall) + .call(OmMetadataReader::getClientAddress); // then assertEquals(expectedClientAddressInCaseOfGrpcCall, clientAddress); @@ -69,4 +112,595 @@ public void testGetClientAddress() { } } + @Test + public void testCheckAclsAttachesSessionPolicyFromThreadLocal() throws Exception { + final String sessionPolicy = "session-policy-from-thread-local"; + setupStsTokenIdentifier(); + + final IAccessAuthorizer accessAuthorizer = createMockIAccessAuthorizerReturningTrue(); + final OmMetadataReader omMetadataReader = createMetadataReader(accessAuthorizer); + + final RequestContext.Builder contextWithoutSessionPolicyBuilder = createTestRequestContextBuilder(); + final OzoneObj obj = createTestOzoneObj(); + + assertTrue(omMetadataReader.checkAcls(obj, contextWithoutSessionPolicyBuilder, true)); + + verifySessionPolicyPassedToAuthorizer(accessAuthorizer, obj, sessionPolicy); + } + + @Test + public void testNoSessionPolicyWhenThreadLocalIsNull() throws Exception { + // No STS token identifier in thread local + OzoneManager.setStsTokenIdentifier(null); + + final IAccessAuthorizer accessAuthorizer = createMockIAccessAuthorizerReturningTrue(); + final OmMetadataReader omMetadataReader = createMetadataReader(accessAuthorizer); + + final RequestContext.Builder contextWithoutSessionPolicyBuilder = createTestRequestContextBuilder(); + final OzoneObj obj = createTestOzoneObj(); + + assertTrue(omMetadataReader.checkAcls(obj, contextWithoutSessionPolicyBuilder, true)); + + verifySessionPolicyPassedToAuthorizer(accessAuthorizer, obj, null); + } + + @Test + public void testCheckAclsAttachesS3ActionFromThreadLocal() throws Exception { + OzoneManager.setS3Auth(S3Authentication.newBuilder() + .setAccessId(ACCESS_KEY_ID) + .setS3Action("GetObject") + .build()); + + final IAccessAuthorizer accessAuthorizer = createMockIAccessAuthorizerReturningTrue(); + final OmMetadataReader omMetadataReader = createMetadataReader(accessAuthorizer); + + final RequestContext.Builder contextWithoutS3ActionBuilder = createTestRequestContextBuilder(); + final OzoneObj obj = createTestOzoneObj(); + + assertTrue(omMetadataReader.checkAcls(obj, contextWithoutS3ActionBuilder, true)); + + verifyS3ActionPassedToAuthorizer(accessAuthorizer, obj, "GetObject"); + } + + @Test + public void testCheckAclsDoesNotAttachS3ActionWhenStsFeatureDisabled() throws Exception { + OzoneManager.setS3Auth(S3Authentication.newBuilder() + .setAccessId(ACCESS_KEY_ID) + .setS3Action("GetObject") + .build()); + + final IAccessAuthorizer accessAuthorizer = createMockIAccessAuthorizerReturningTrue(); + final OmMetadataReader omMetadataReader = createMetadataReader(accessAuthorizer, mock(KeyManager.class), false); + + final RequestContext.Builder contextWithoutS3ActionBuilder = createTestRequestContextBuilder(); + final OzoneObj obj = createTestOzoneObj(); + + assertTrue(omMetadataReader.checkAcls(obj, contextWithoutS3ActionBuilder, true)); + + verifyS3ActionPassedToAuthorizer(accessAuthorizer, obj, null); + } + + @Test + public void testCheckAclsLeavesS3ActionUnsetWhenS3AuthThreadLocalNull() throws Exception { + final IAccessAuthorizer accessAuthorizer = createMockIAccessAuthorizerReturningTrue(); + final OmMetadataReader omMetadataReader = createMetadataReader(accessAuthorizer); + + final RequestContext.Builder contextWithoutS3ActionBuilder = createTestRequestContextBuilder(); + final OzoneObj obj = createTestOzoneObj(); + + assertTrue(omMetadataReader.checkAcls(obj, contextWithoutS3ActionBuilder, true)); + + verifyS3ActionPassedToAuthorizer(accessAuthorizer, obj, null); + } + + @Test + public void testCheckAclsAttachesSessionPolicyAndS3ActionFromThreadLocals() throws Exception { + setupStsTokenIdentifier(); + + OzoneManager.setS3Auth(S3Authentication.newBuilder() + .setAccessId(ACCESS_KEY_ID) + .setS3Action("PutObject") + .build()); + + final IAccessAuthorizer accessAuthorizer = createMockIAccessAuthorizerReturningTrue(); + final OmMetadataReader omMetadataReader = createMetadataReader(accessAuthorizer); + + final RequestContext.Builder baseContextBuilder = createTestRequestContextBuilder(); + final OzoneObj obj = createTestOzoneObj(); + + assertTrue(omMetadataReader.checkAcls(obj, baseContextBuilder, true)); + + verifySessionPolicyAndS3ActionPassedToAuthorizer(accessAuthorizer, obj); + } + + @Test + public void testListStatusUsesReadAclForStsS3Request() throws Exception { + setupStsS3Request(); + + final IAccessAuthorizer accessAuthorizer = createMockIAccessAuthorizerReturningTrue(); + final KeyManager keyManager = createListStatusKeyManagerReturningEmpty(); + + final OmMetadataReader omMetadataReader = createMetadataReader(accessAuthorizer, keyManager); + final OmKeyArgs args = createOmKeyArgs(); + + final List statuses = omMetadataReader.listStatus(args, false, "", MAX_KEYS, false); + assertTrue(statuses.isEmpty()); + + final List checks = captureAclChecks(accessAuthorizer, 2); + + // For STS S3 requests, listStatus() performs these checks: + // 1. Volume READ (for volume access) + // 2) Key READ (for the specific prefix being listed) + assertContainsVolumeReadCheck(checks); + assertContainsKeyReadCheckWithName(checks, KEY_PREFIX); + } + + @Test + public void testListStatusUsesReadAclForNonStsRequest() throws Exception { + setupNonStsS3Request(); + + final IAccessAuthorizer accessAuthorizer = createMockIAccessAuthorizerReturningTrue(); + final KeyManager keyManager = createListStatusKeyManagerReturningEmpty(); + + final OmMetadataReader omMetadataReader = createMetadataReader(accessAuthorizer, keyManager); + final OmKeyArgs args = createOmKeyArgs(); + + final List statuses = omMetadataReader.listStatus(args, false, "", MAX_KEYS, false); + assertTrue(statuses.isEmpty()); + + final List checks = captureAclChecks(accessAuthorizer, 2); + assertTrue(checks.stream().allMatch(check -> check.getContext().getAclRights() == READ)); + + assertContainsVolumeReadCheck(checks); + // We want to ensure the current behavior for non-STS requests remains the same + assertContainsKeyReadCheckWithName(checks); + } + + @Test + public void testListStatusUsesListPrefixForAclWhenKeyNameEmptyAndListPrefixSet() throws Exception { + setupStsS3Request(); + + final IAccessAuthorizer accessAuthorizer = createMockIAccessAuthorizerReturningTrue(); + final KeyManager keyManager = createListStatusKeyManagerReturningEmpty(); + + final OmMetadataReader omMetadataReader = createMetadataReader(accessAuthorizer, keyManager); + final OmKeyArgs args = new OmKeyArgs.Builder() + .setVolumeName(VOLUME_NAME) + .setBucketName(BUCKET_NAME) + .setKeyName("") + .setListPrefix("userA/") + .build(); + + final List statuses = omMetadataReader.listStatus(args, false, "", MAX_KEYS, false); + assertTrue(statuses.isEmpty()); + + final List checks = captureAclChecks(accessAuthorizer, 2); + assertContainsVolumeReadCheck(checks); + assertContainsKeyReadCheckWithName(checks, "userA/"); + } + + @Test + public void testListStatusUsesWildcardForAclWhenKeyNameAndListPrefixEmpty() throws Exception { + setupStsS3Request(); + + final IAccessAuthorizer accessAuthorizer = createMockIAccessAuthorizerReturningTrue(); + final KeyManager keyManager = createListStatusKeyManagerReturningEmpty(); + + final OmMetadataReader omMetadataReader = createMetadataReader(accessAuthorizer, keyManager); + final OmKeyArgs args = new OmKeyArgs.Builder() + .setVolumeName(VOLUME_NAME) + .setBucketName(BUCKET_NAME) + .setKeyName("") + .build(); + + final List statuses = omMetadataReader.listStatus(args, false, "", MAX_KEYS, false); + assertTrue(statuses.isEmpty()); + + final List checks = captureAclChecks(accessAuthorizer, 2); + assertContainsVolumeReadCheck(checks); + assertContainsKeyReadCheckWithName(checks, "*"); + } + + @Test + public void testListStatusUsesListPrefixForAclWhenKeyNameIsDescendantOfListPrefix() throws Exception { + setupStsS3Request(); + + final IAccessAuthorizer accessAuthorizer = createMockIAccessAuthorizerReturningTrue(); + final KeyManager keyManager = createListStatusKeyManagerReturningEmpty(); + + final OmMetadataReader omMetadataReader = createMetadataReader(accessAuthorizer, keyManager); + final OmKeyArgs args = new OmKeyArgs.Builder() + .setVolumeName(VOLUME_NAME) + .setBucketName(BUCKET_NAME) + .setKeyName("userA") + .setListPrefix("user") + .build(); + + final List statuses = omMetadataReader.listStatus(args, false, "", MAX_KEYS, false); + assertTrue(statuses.isEmpty()); + + final List checks = captureAclChecks(accessAuthorizer, 2); + assertContainsVolumeReadCheck(checks); + assertContainsKeyReadCheckWithName(checks, "user"); + } + + @Test + public void testListStatusUsesListPrefixForAclWhenKeyNameIsAncestorOfListPrefix() throws Exception { + setupStsS3Request(); + + final IAccessAuthorizer accessAuthorizer = createMockIAccessAuthorizerReturningTrue(); + final KeyManager keyManager = createListStatusKeyManagerReturningEmpty(); + + final OmMetadataReader omMetadataReader = createMetadataReader(accessAuthorizer, keyManager); + final OmKeyArgs args = new OmKeyArgs.Builder() + .setVolumeName(VOLUME_NAME) + .setBucketName(BUCKET_NAME) + .setKeyName("user") + .setListPrefix("user/foo") + .build(); + + final List statuses = omMetadataReader.listStatus(args, false, "", MAX_KEYS, false); + assertTrue(statuses.isEmpty()); + + final List checks = captureAclChecks(accessAuthorizer, 2); + assertContainsVolumeReadCheck(checks); + assertContainsKeyReadCheckWithName(checks, "user/foo"); + } + + @Test + public void testListStatusThrowsWhenStsKeyNameNotUnderListPrefix() throws Exception { + setupStsS3Request(); + + final IAccessAuthorizer accessAuthorizer = createMockIAccessAuthorizerReturningTrue(); + final KeyManager keyManager = createListStatusKeyManagerReturningEmpty(); + + final OmMetadataReader omMetadataReader = createMetadataReader(accessAuthorizer, keyManager); + final OmKeyArgs args = new OmKeyArgs.Builder() + .setVolumeName(VOLUME_NAME) + .setBucketName(BUCKET_NAME) + .setKeyName(KEY_PREFIX) + .setListPrefix("other/") + .build(); + + final OMException ex = assertThrows( + OMException.class, () -> omMetadataReader.listStatus(args, false, "", MAX_KEYS, false)); + assertEquals(ResultCodes.PERMISSION_DENIED, ex.getResult()); + } + + @Test + public void testListStatusLightUsesListPrefixForAclWhenKeyNameIsAncestorOfListPrefix() throws Exception { + setupStsS3Request(); + + final IAccessAuthorizer accessAuthorizer = createMockIAccessAuthorizerReturningTrue(); + final KeyManager keyManager = createListStatusKeyManagerReturningEmpty(); + + final OmMetadataReader omMetadataReader = createMetadataReader(accessAuthorizer, keyManager); + final OmKeyArgs args = new OmKeyArgs.Builder() + .setVolumeName(VOLUME_NAME) + .setBucketName(BUCKET_NAME) + .setKeyName("user") + .setListPrefix("user/foo") + .build(); + + omMetadataReader.listStatusLight(args, false, "", MAX_KEYS, false); + + final List checks = captureAclChecks(accessAuthorizer, 2); + assertContainsVolumeReadCheck(checks); + assertContainsKeyReadCheckWithName(checks, "user/foo"); + } + + @Test + public void testListStatusLightThrowsWhenStsKeyNameNotUnderListPrefix() throws Exception { + setupStsS3Request(); + + final IAccessAuthorizer accessAuthorizer = createMockIAccessAuthorizerReturningTrue(); + final KeyManager keyManager = createListStatusKeyManagerReturningEmpty(); + + final OmMetadataReader omMetadataReader = createMetadataReader(accessAuthorizer, keyManager); + final OmKeyArgs args = new OmKeyArgs.Builder() + .setVolumeName(VOLUME_NAME) + .setBucketName(BUCKET_NAME) + .setKeyName(KEY_PREFIX) + .setListPrefix("other/") + .build(); + + final OMException ex = assertThrows( + OMException.class, () -> omMetadataReader.listStatusLight(args, false, "", MAX_KEYS, false)); + assertEquals(ResultCodes.PERMISSION_DENIED, ex.getResult()); + } + + @Test + public void testGetFileStatusUsesReadAclForStsS3Request() throws Exception { + setupStsS3Request(); + + final IAccessAuthorizer accessAuthorizer = createMockIAccessAuthorizerReturningTrue(); + final KeyManager keyManager = createGetFileStatusKeyManagerReturningStatus(); + + final OmMetadataReader omMetadataReader = createMetadataReader(accessAuthorizer, keyManager); + final OmKeyArgs args = createOmKeyArgs(); + + omMetadataReader.getFileStatus(args); + + final List checks = captureAclChecks(accessAuthorizer, 2); + assertContainsVolumeReadCheck(checks); + assertContainsKeyReadCheckWithName(checks, KEY_PREFIX); + } + + @Test + public void testGetFileStatusUsesReadAclForNonStsS3Request() throws Exception { + setupNonStsS3Request(); + + final IAccessAuthorizer accessAuthorizer = createMockIAccessAuthorizerReturningTrue(); + final KeyManager keyManager = createGetFileStatusKeyManagerReturningStatus(); + + final OmMetadataReader omMetadataReader = createMetadataReader(accessAuthorizer, keyManager); + final OmKeyArgs args = createOmKeyArgs(); + + omMetadataReader.getFileStatus(args); + + final List checks = captureAclChecks(accessAuthorizer, 2); + assertContainsVolumeReadCheck(checks); + assertContainsKeyReadCheckWithName(checks); + } + + @Test + public void testListKeysUsesPrefixCheckForStsS3Request() throws Exception { + setupStsS3Request(); + + final IAccessAuthorizer accessAuthorizer = createMockIAccessAuthorizerReturningTrue(); + final KeyManager keyManager = createListKeysKeyManagerReturningEmpty(); + + final OmMetadataReader omMetadataReader = createMetadataReader(accessAuthorizer, keyManager); + + // Case 1: List with prefix "userA/" + omMetadataReader.listKeys(VOLUME_NAME, BUCKET_NAME, "", "userA/", (int) MAX_KEYS); + + List checks = captureAclChecks(accessAuthorizer, 4); + assertContainsBucketListCheck(checks); + assertContainsKeyReadCheckWithName(checks, "userA/"); + + // Reset to make case 2 assertions independent of case 1 captures. + reset(accessAuthorizer); + reenableAllowAllAccessChecks(accessAuthorizer); + + // Case 2: List with empty prefix (should check "*") + omMetadataReader.listKeys(VOLUME_NAME, BUCKET_NAME, "", "", (int) MAX_KEYS); + + checks = captureAclChecks(accessAuthorizer, 4); + assertContainsBucketListCheck(checks); + assertContainsKeyReadCheckWithName(checks, "*"); + } + + private OmMetadataReader createMetadataReader(IAccessAuthorizer accessAuthorizer) throws IOException { + return createMetadataReader(accessAuthorizer, mock(KeyManager.class)); + } + + private OmMetadataReader createMetadataReader(IAccessAuthorizer accessAuthorizer, KeyManager keyManager) + throws IOException { + return createMetadataReader(accessAuthorizer, keyManager, true); + } + + private OmMetadataReader createMetadataReader(IAccessAuthorizer accessAuthorizer, KeyManager keyManager, + boolean isS3StsEnabled) throws IOException { + final OzoneManager ozoneManager = mock(OzoneManager.class); + when(ozoneManager.getBucketManager()).thenReturn(mock(BucketManager.class)); + when(ozoneManager.getVolumeManager()).thenReturn(mock(VolumeManager.class)); + when(ozoneManager.getConfiguration()).thenReturn(new OzoneConfiguration()); + when(ozoneManager.getAclsEnabled()).thenReturn(true); + when(ozoneManager.isS3STSEnabled()).thenReturn(isS3StsEnabled); + final OMPerformanceMetrics perfMetrics = mock(OMPerformanceMetrics.class); + // OmMetadataReader uses these MutableRate metrics via MetricUtil.captureLatencyNs(...). + when(perfMetrics.getListKeysResolveBucketLatencyNs()).thenReturn(mock(ConcurrentMutableRate.class)); + when(perfMetrics.getListKeysAclCheckLatencyNs()).thenReturn(mock(ConcurrentMutableRate.class)); + when(ozoneManager.getPerfMetrics()).thenReturn(perfMetrics); + when(ozoneManager.getVolumeOwner(any(), any(), any())).thenReturn("volume-owner"); + when(ozoneManager.getBucketOwner(any(), any(), any(), any())).thenReturn("bucket-owner"); + when(ozoneManager.getOmRpcServerAddr()).thenReturn(new InetSocketAddress("127.0.0.1", 9874)); + when(ozoneManager.resolveBucketLink(any(Pair.class))) + .thenReturn( + new ResolvedBucket( + VOLUME_NAME, BUCKET_NAME, VOLUME_NAME, BUCKET_NAME, "bucket-owner", FILE_SYSTEM_OPTIMIZED)); + when(ozoneManager.resolveBucketLink(any(OmKeyArgs.class))) + .thenReturn( + new ResolvedBucket( + VOLUME_NAME, BUCKET_NAME, VOLUME_NAME, BUCKET_NAME, "bucket-owner", FILE_SYSTEM_OPTIMIZED)); + + return new OmMetadataReader( + keyManager, mock(PrefixManager.class), ozoneManager, mock(Logger.class), mock(AuditLogger.class), + mock(OmMetadataReaderMetrics.class), accessAuthorizer); + } + + /** + * Creates and sets a mock STSTokenIdentifier with a session policy in the thread-local. + */ + private void setupStsTokenIdentifier() { + final STSTokenIdentifier stsTokenIdentifier = mock(STSTokenIdentifier.class); + when(stsTokenIdentifier.getSessionPolicy()).thenReturn("session-policy-from-thread-local"); + OzoneManager.setStsTokenIdentifier(stsTokenIdentifier); + } + + /** + * Creates a mock IAccessAuthorizer that returns the specified result for checkAccess. + * @return the mocked IAccessAuthorizer + */ + private IAccessAuthorizer createMockIAccessAuthorizerReturningTrue() throws OMException { + final IAccessAuthorizer accessAuthorizer = mock(IAccessAuthorizer.class); + when(accessAuthorizer.checkAccess(any(OzoneObj.class), any(RequestContext.class))) + .thenReturn(true); + return accessAuthorizer; + } + + /** + * Creates a test RequestContext.Builder. + * + * @return the constructed RequestContext.Builder + */ + private RequestContext.Builder createTestRequestContextBuilder() { + return RequestContext.newBuilder() + .setClientUgi(UserGroupInformation.createRemoteUser("testUser")) + .setIp(InetAddress.getLoopbackAddress()) + .setHost("localhost") + .setAclType(IAccessAuthorizer.ACLIdentityType.USER) + .setAclRights(READ) + .setOwnerName("owner"); + } + + /** + * Creates a test OzoneObj representing a key. + * @return the constructed OzoneObj + */ + private OzoneObj createTestOzoneObj() { + return OzoneObjInfo.Builder.newBuilder() + .setResType(KEY) + .setStoreType(OzoneObj.StoreType.OZONE) + .setVolumeName(VOLUME_NAME) + .setBucketName(BUCKET_NAME) + .setKeyName("key") + .build(); + } + + private void setupStsS3Request() { + OzoneManager.setStsTokenIdentifier(mock(STSTokenIdentifier.class)); + OzoneManager.setS3Auth(S3Authentication.newBuilder().setAccessId(TestOMMetadataReader.ACCESS_KEY_ID).build()); + } + + private void setupNonStsS3Request() { + OzoneManager.setStsTokenIdentifier(null); + OzoneManager.setS3Auth(null); + } + + private OmKeyArgs createOmKeyArgs() { + return new OmKeyArgs.Builder() + .setVolumeName(VOLUME_NAME) + .setBucketName(BUCKET_NAME) + .setKeyName(TestOMMetadataReader.KEY_PREFIX) + .build(); + } + + private KeyManager createListStatusKeyManagerReturningEmpty() throws IOException { + final KeyManager keyManager = mock(KeyManager.class); + when(keyManager.listStatus(any(OmKeyArgs.class), eq(false), eq(""), eq(MAX_KEYS), any(), eq(false))) + .thenReturn(Collections.emptyList()); + return keyManager; + } + + private KeyManager createGetFileStatusKeyManagerReturningStatus() throws IOException { + final KeyManager keyManager = mock(KeyManager.class); + when(keyManager.getFileStatus(any(OmKeyArgs.class), any())) + .thenReturn(mock(OzoneFileStatus.class)); + return keyManager; + } + + private KeyManager createListKeysKeyManagerReturningEmpty() throws IOException { + final KeyManager keyManager = mock(KeyManager.class); + when(keyManager.listKeys(any(), any(), any(), any(), eq(100))) + .thenReturn(new ListKeysResult(Collections.emptyList(), false)); + return keyManager; + } + + private void reenableAllowAllAccessChecks(IAccessAuthorizer accessAuthorizer) throws OMException { + when(accessAuthorizer.checkAccess(any(OzoneObj.class), any(RequestContext.class))) + .thenReturn(true); + } + + /** + * Verifies that the accessAuthorizer received a call to checkAccess with the expected session policy. + * @param accessAuthorizer the mock authorizer to verify + * @param expectedObj the expected OzoneObj + * @param expectedSessionPolicy the expected session policy (could be null) + */ + private void verifySessionPolicyPassedToAuthorizer(IAccessAuthorizer accessAuthorizer, OzoneObj expectedObj, + String expectedSessionPolicy) throws OMException { + final ArgumentCaptor captor = ArgumentCaptor.forClass(RequestContext.class); + verify(accessAuthorizer).checkAccess(eq(expectedObj), captor.capture()); + assertEquals(expectedSessionPolicy, captor.getValue().getSessionPolicy()); + } + + /** + * Verifies that the accessAuthorizer received a call to checkAccess with the expected s3 action. + * @param accessAuthorizer the mock authorizer to verify + * @param expectedObj the expected OzoneObj + * @param expectedS3Action the expected s3 action (could be null) + */ + private void verifyS3ActionPassedToAuthorizer(IAccessAuthorizer accessAuthorizer, OzoneObj expectedObj, + String expectedS3Action) throws OMException { + final ArgumentCaptor captor = ArgumentCaptor.forClass(RequestContext.class); + verify(accessAuthorizer).checkAccess(eq(expectedObj), captor.capture()); + assertEquals(expectedS3Action, captor.getValue().getS3Action()); + } + + private void verifySessionPolicyAndS3ActionPassedToAuthorizer(IAccessAuthorizer accessAuthorizer, + OzoneObj expectedObj) throws OMException { + final ArgumentCaptor captor = ArgumentCaptor.forClass(RequestContext.class); + verify(accessAuthorizer).checkAccess(eq(expectedObj), captor.capture()); + assertEquals("session-policy-from-thread-local", captor.getValue().getSessionPolicy()); + assertEquals("PutObject", captor.getValue().getS3Action()); + } + + private List captureAclChecks(IAccessAuthorizer accessAuthorizer, int expectedCheckCount) + throws OMException { + final ArgumentCaptor objCaptor = ArgumentCaptor.forClass(OzoneObj.class); + final ArgumentCaptor ctxCaptor = ArgumentCaptor.forClass(RequestContext.class); + verify(accessAuthorizer, times(expectedCheckCount)).checkAccess(objCaptor.capture(), ctxCaptor.capture()); + return toAclChecks(objCaptor.getAllValues(), ctxCaptor.getAllValues()); + } + + private List toAclChecks(List objs, List contexts) { + assertEquals(objs.size(), contexts.size(), "Captured ACL objects and contexts should align"); + final List checks = new ArrayList<>(); + for (int i = 0; i < objs.size(); i++) { + checks.add(new AclCheck(objs.get(i), contexts.get(i))); + } + return checks; + } + + private void assertContainsVolumeReadCheck(List checks) { + assertTrue(checks.stream().anyMatch(this::isVolumeReadCheck), "Expected a VOLUME READ ACL check"); + } + + private boolean isVolumeReadCheck(AclCheck check) { + return check.getObj().getResourceType() == VOLUME && check.getContext().getAclRights() == READ; + } + + private void assertContainsBucketListCheck(List checks) { + assertTrue( + checks.stream().anyMatch( + check -> check.getObj().getResourceType() == OzoneObj.ResourceType.BUCKET && + check.getContext().getAclRights() == LIST), + "Expected a BUCKET LIST ACL check"); + } + + private void assertContainsKeyReadCheckWithName(List checks, String keyName) { + assertTrue( + checks.stream().anyMatch( + check -> check.getObj().getResourceType() == KEY && check.getContext().getAclRights() == READ && + keyName.equals(check.getObj().getKeyName())), + "Expected a KEY READ ACL check for key '" + keyName + "'"); + } + + private void assertContainsKeyReadCheckWithName(List checks) { + assertTrue( + checks.stream().anyMatch( + check -> check.getObj().getResourceType() == KEY && check.getContext().getAclRights() == READ && + TestOMMetadataReader.KEY_PREFIX.equals(check.getObj().getKeyName())), + "Expected a KEY READ ACL check for key '" + TestOMMetadataReader.KEY_PREFIX + "'"); + } + + private static final class AclCheck { + private final OzoneObj obj; + private final RequestContext context; + + private AclCheck(OzoneObj obj, RequestContext context) { + this.obj = obj; + this.context = context; + } + + private OzoneObj getObj() { + return obj; + } + + private RequestContext getContext() { + return context; + } + } } 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 1ffcc32f0acb..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 @@ -41,6 +41,7 @@ import static org.apache.hadoop.ozone.om.codec.OMDBDefinition.OPEN_KEY_TABLE; import static org.apache.hadoop.ozone.om.codec.OMDBDefinition.PREFIX_TABLE; import static org.apache.hadoop.ozone.om.codec.OMDBDefinition.PRINCIPAL_TO_ACCESS_IDS_TABLE; +import static org.apache.hadoop.ozone.om.codec.OMDBDefinition.S3_REVOKED_STS_TOKEN_TABLE; import static org.apache.hadoop.ozone.om.codec.OMDBDefinition.S3_SECRET_TABLE; import static org.apache.hadoop.ozone.om.codec.OMDBDefinition.SNAPSHOT_INFO_TABLE; import static org.apache.hadoop.ozone.om.codec.OMDBDefinition.SNAPSHOT_RENAMED_TABLE; @@ -55,6 +56,7 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -82,6 +84,7 @@ import org.apache.hadoop.hdds.protocol.StorageType; import org.apache.hadoop.hdds.protocol.proto.HddsProtos; import org.apache.hadoop.hdds.utils.TransactionInfo; +import org.apache.hadoop.hdds.utils.db.TypedTable; import org.apache.hadoop.hdds.utils.db.cache.CacheKey; import org.apache.hadoop.hdds.utils.db.cache.CacheValue; import org.apache.hadoop.ozone.om.codec.OMDBDefinition; @@ -109,6 +112,7 @@ import org.apache.hadoop.ozone.protocol.proto.OzoneManagerProtocolProtos.PartKeyInfo; import org.apache.hadoop.ozone.snapshot.ListSnapshotResponse; import org.apache.hadoop.util.Time; +import org.apache.ozone.test.MockClock; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; @@ -146,6 +150,7 @@ public class TestOmMetadataManager { SNAPSHOT_INFO_TABLE, SNAPSHOT_RENAMED_TABLE, COMPACTION_LOG_TABLE, + S3_REVOKED_STS_TOKEN_TABLE, LIFECYCLE_CONFIGURATION_TABLE, LIFECYCLE_SCAN_STATE_TABLE }; @@ -1524,6 +1529,44 @@ public void testGetMultipartUploadKeys() throws Exception { assertEquals(25, noPagination.size()); } + @Test + public void testS3RevokedStsTokenTablePutAndGet() throws Exception { + // Ensure the table is initialized + assertNotNull(omMetadataManager.getS3RevokedStsTokenTable(), "s3RevokedStsTokenTable should be initialized"); + + final MockClock clock = MockClock.newInstance(); + final String originalAccessKeyId1 = "orig-1"; + final long insertionTime1 = clock.millis(); + final String originalAccessKeyId2 = "orig-2"; + final long insertionTime2 = insertionTime1 + 1234L; + + // This table is configured as FULL_CACHE in OmMetadataManagerImpl. + // A put() writes to RocksDB but does not update the table cache, so get() and getIfExist() will return null unless + // the cache is updated with addCacheEntry(). getSkipCache() will read the DB instead of the cache. + final TypedTable revokedTable = + (TypedTable) omMetadataManager.getS3RevokedStsTokenTable(); + + revokedTable.put(originalAccessKeyId1, insertionTime1); + revokedTable.put(originalAccessKeyId2, insertionTime2); + + // Verify the values are persisted in RocksDB. + 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(originalAccessKeyId1, insertionTime1, 1L); + revokedTable.addCacheEntry(originalAccessKeyId2, insertionTime2, 1L); + + // Verify get and getIfExist return the stored value + assertEquals(insertionTime1, revokedTable.get(originalAccessKeyId1)); + assertEquals(insertionTime1, revokedTable.getIfExist(originalAccessKeyId1)); + assertEquals(insertionTime2, revokedTable.get(originalAccessKeyId2)); + assertEquals(insertionTime2, revokedTable.getIfExist(originalAccessKeyId2)); + + // Invalid originalAccessKeyId should return null for getIfExist. + assertNull(revokedTable.getIfExist("INVALID_ORIGINAL_ACCESS_KEY_ID")); + } + @Test public void testListKeysSpecialKeyNames() throws Exception { List keyNames = Arrays.asList(" ", "\"", diff --git a/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/om/TestOzoneManagerS3Auth.java b/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/om/TestOzoneManagerS3Auth.java new file mode 100644 index 000000000000..8ce4ee08bebb --- /dev/null +++ b/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/om/TestOzoneManagerS3Auth.java @@ -0,0 +1,123 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.hadoop.ozone.om; + +import static org.apache.hadoop.ozone.om.exceptions.OMException.ResultCodes.INVALID_REQUEST; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import org.apache.hadoop.ozone.om.exceptions.OMException; +import org.apache.hadoop.ozone.protocol.proto.OzoneManagerProtocolProtos.S3Authentication; +import org.apache.hadoop.ozone.security.STSTokenIdentifier; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; + +/** + * Test S3 Authentication logic in OzoneManager. + */ +public class TestOzoneManagerS3Auth { + + @AfterEach + public void tearDown() { + OzoneManager.setS3Auth(null); + OzoneManager.setStsTokenIdentifier(null); + } + + @Test + public void testGetS3AuthEffectiveAccessIdNoS3Auth() throws Exception { + OzoneManager.setS3Auth(null); + assertNull(OzoneManager.getS3AuthEffectiveAccessId()); + } + + @Test + public void testGetS3AuthEffectiveAccessIdNormal() throws Exception { + final String accessId = "accessId"; + final S3Authentication s3Auth = S3Authentication.newBuilder() + .setAccessId(accessId) + .setSignature("signature") + .setStringToSign("stringToSign") + .build(); + OzoneManager.setS3Auth(s3Auth); + + assertEquals(accessId, OzoneManager.getS3AuthEffectiveAccessId()); + } + + @Test + public void testGetS3AuthEffectiveAccessIdWithSessionToken() throws Exception { + final String tempAccessId = "ASIA12345"; + final String originalAccessId = "AKIAORIG98765"; + final String sessionToken = "sessionToken"; + + final S3Authentication s3Auth = S3Authentication.newBuilder() + .setAccessId(tempAccessId) + .setSignature("signature") + .setStringToSign("stringToSign") + .setSessionToken(sessionToken) + .build(); + OzoneManager.setS3Auth(s3Auth); + + final STSTokenIdentifier stsToken = mock(STSTokenIdentifier.class); + when(stsToken.getOriginalAccessKeyId()).thenReturn(originalAccessId); + OzoneManager.setStsTokenIdentifier(stsToken); + + assertEquals(originalAccessId, OzoneManager.getS3AuthEffectiveAccessId()); + } + + @Test + public void testGetS3AuthEffectiveAccessIdWithEmptySessionToken() throws Exception { + final String accessId = "AKIAORIG98765"; + final String emptySessionToken = ""; + + final S3Authentication s3Auth = S3Authentication.newBuilder() + .setAccessId(accessId) + .setSignature("signature") + .setStringToSign("stringToSign") + .setSessionToken(emptySessionToken) + .build(); + OzoneManager.setS3Auth(s3Auth); + + // Empty session token should be treated as if no session token is present. + assertEquals(accessId, OzoneManager.getS3AuthEffectiveAccessId()); + } + + @Test + public void testGetS3AuthEffectiveAccessIdWithSessionTokenMissingOriginalAccessKey() { + final String tempAccessId = "ASIA12345"; + final String sessionToken = "sessionToken"; + + final S3Authentication s3Auth = S3Authentication.newBuilder() + .setAccessId(tempAccessId) + .setSignature("signature") + .setStringToSign("stringToSign") + .setSessionToken(sessionToken) + .build(); + OzoneManager.setS3Auth(s3Auth); + + final STSTokenIdentifier stsToken = mock(STSTokenIdentifier.class); + when(stsToken.getOriginalAccessKeyId()).thenReturn(null); // Missing original ID + OzoneManager.setStsTokenIdentifier(stsToken); + + final OMException ex = assertThrows(OMException.class, OzoneManager::getS3AuthEffectiveAccessId); + assertEquals(INVALID_REQUEST, ex.getResult()); + assertEquals("Invalid STS Token format - could not find originalAccessKeyId", ex.getMessage()); + } +} + diff --git a/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/om/ratis/TestOzoneManagerRatisRequest.java b/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/om/ratis/TestOzoneManagerRatisRequest.java index f671c877c1f6..6a3c5bc95369 100644 --- a/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/om/ratis/TestOzoneManagerRatisRequest.java +++ b/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/om/ratis/TestOzoneManagerRatisRequest.java @@ -18,8 +18,10 @@ package org.apache.hadoop.ozone.om.ratis; import static org.apache.hadoop.ozone.protocol.proto.OzoneManagerProtocolProtos.Status.INVALID_REQUEST; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.CALLS_REAL_METHODS; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; @@ -43,6 +45,8 @@ import org.apache.hadoop.ozone.om.request.OMRequestTestUtils; import org.apache.hadoop.ozone.protocol.proto.OzoneManagerProtocolProtos; import org.apache.hadoop.ozone.protocolPB.OzoneManagerProtocolServerSideTranslatorPB; +import org.apache.hadoop.ozone.security.acl.IAccessAuthorizer; +import org.apache.ratis.protocol.ClientId; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; @@ -135,4 +139,58 @@ public void testUnknownRequestHandling() assertEquals(expectedResponse, actualResponse); } + + @Test + public void testAssumeRoleRejectedWhenStsDisabled() { + ozoneManager = mock(OzoneManager.class, CALLS_REAL_METHODS); + when(ozoneManager.isS3STSEnabled()).thenReturn(false); + + final OzoneManagerProtocolProtos.OMRequest omRequest = + OzoneManagerProtocolProtos.OMRequest.newBuilder() + .setCmdType(OzoneManagerProtocolProtos.Type.AssumeRole) + .setClientId(ClientId.randomId().toString()) + .build(); + + final OMException omException = assertThrows(OMException.class, + () -> OzoneManagerRatisUtils.createClientRequest(omRequest, ozoneManager)); + assertEquals(OMException.ResultCodes.FEATURE_NOT_ENABLED, omException.getResult()); + } + + @Test + public void testAssumeRoleRejectedWhenStsEnabledButNativeAuthorizerUsed() { + ozoneManager = mock(OzoneManager.class, CALLS_REAL_METHODS); + when(ozoneManager.isS3STSEnabled()).thenReturn(true); + + final IAccessAuthorizer authorizer = mock(IAccessAuthorizer.class); + when(authorizer.isNative()).thenReturn(true); + when(ozoneManager.getAccessAuthorizer()).thenReturn(authorizer); + + final OzoneManagerProtocolProtos.OMRequest omRequest = + OzoneManagerProtocolProtos.OMRequest.newBuilder() + .setCmdType(OzoneManagerProtocolProtos.Type.AssumeRole) + .setClientId(ClientId.randomId().toString()) + .build(); + + final OMException omException = assertThrows(OMException.class, + () -> OzoneManagerRatisUtils.createClientRequest(omRequest, ozoneManager)); + assertEquals(OMException.ResultCodes.FEATURE_NOT_ENABLED, omException.getResult()); + } + + @Test + public void testAssumeRoleAllowedWhenStsEnabledAndNativeAuthorizerNotUsed() { + ozoneManager = mock(OzoneManager.class, CALLS_REAL_METHODS); + when(ozoneManager.isS3STSEnabled()).thenReturn(true); + + final IAccessAuthorizer authorizer = mock(IAccessAuthorizer.class); + when(authorizer.isNative()).thenReturn(false); + when(ozoneManager.getAccessAuthorizer()).thenReturn(authorizer); + + final OzoneManagerProtocolProtos.OMRequest omRequest = + OzoneManagerProtocolProtos.OMRequest.newBuilder() + .setCmdType(OzoneManagerProtocolProtos.Type.AssumeRole) + .setClientId(ClientId.randomId().toString()) + .build(); + + assertDoesNotThrow(() -> OzoneManagerRatisUtils.createClientRequest(omRequest, ozoneManager)); + } } diff --git a/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/om/ratis/TestOzoneManagerStateMachine.java b/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/om/ratis/TestOzoneManagerStateMachine.java index f1ca677cbfb8..5cc0b52cfc52 100644 --- a/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/om/ratis/TestOzoneManagerStateMachine.java +++ b/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/om/ratis/TestOzoneManagerStateMachine.java @@ -135,6 +135,7 @@ public void setup() { @AfterEach public void tearDown() { sm.stop(); + OzoneManager.setStsTokenIdentifier(null); } // --- startTransaction tests --- @@ -415,6 +416,79 @@ public void testRunCommandRuntimeExceptionTerminates() throws Exception { () -> sm.runCommand(request, ti)); } + @Test + public void testRunCommandSetsAndClearsStsThreadLocal() throws Exception { + when(om.isSecurityEnabled()).thenReturn(true); + + final OzoneManagerProtocolProtos.S3Authentication s3Auth = + OzoneManagerProtocolProtos.S3Authentication.newBuilder() + .setAccessId("accessId") + .setSessionToken("sessionToken") + .setResolvedStsSessionPolicy("sessionPolicy") + .setResolvedStsRoleArn("roleArn") + .setResolvedStsOriginalAccessKeyId("originalAccessKeyId") + .setResolvedStsTempAccessKeyId("tempAccessKeyId") + .setResolvedStsSecretKeyId("secretKeyId") + .build(); + + final OMRequest request = sampleWriteRequest().toBuilder() + .setS3Authentication(s3Auth) + .build(); + final TermIndex ti = TermIndex.valueOf(1, 5); + + final OMResponse expectedResponse = OMResponse.newBuilder() + .setCmdType(Type.CreateKey) + .setStatus(Status.OK) + .setSuccess(true) + .build(); + + final OMClientResponse clientResponse = mock(OMClientResponse.class); + when(clientResponse.getOMResponse()).thenReturn(expectedResponse); + when(clientResponse.getOmLockDetails()).thenReturn(null); + + doAnswer(invocation -> { + assertNotNull(OzoneManager.getStsTokenIdentifier(), + "Expected STS ThreadLocal to be set during handler.handleWriteRequest"); + assertEquals("tempAccessKeyId", OzoneManager.getStsTokenIdentifier().getTempAccessKeyId()); + assertEquals("originalAccessKeyId", OzoneManager.getStsTokenIdentifier().getOriginalAccessKeyId()); + assertEquals("roleArn", OzoneManager.getStsTokenIdentifier().getRoleArn()); + assertEquals("sessionPolicy", OzoneManager.getStsTokenIdentifier().getSessionPolicy()); + return clientResponse; + }).when(handler).handleWriteRequest(eq(request), any(), eq(doubleBuffer)); + + assertNull(OzoneManager.getStsTokenIdentifier(), "Expected STS ThreadLocal to be clear before runCommand"); + + OMResponse result = sm.runCommand(request, ti); + + assertNotNull(result); + assertTrue(result.getSuccess()); + assertNull(OzoneManager.getStsTokenIdentifier(), "Expected STS ThreadLocal to be cleared after runCommand"); + } + + @Test + public void testRunCommandMissingResolvedStsFieldsReturnsErrorResponse() throws Exception { + when(om.isSecurityEnabled()).thenReturn(true); + + final OzoneManagerProtocolProtos.S3Authentication s3Auth = + OzoneManagerProtocolProtos.S3Authentication.newBuilder() + .setAccessId("accessId") + .setSessionToken("sessionToken") + .build(); + + final OMRequest request = sampleWriteRequest().toBuilder() + .setS3Authentication(s3Auth) + .build(); + final TermIndex ti = TermIndex.valueOf(1, 5); + + OMResponse result = sm.runCommand(request, ti); + + assertNotNull(result); + assertFalse(result.getSuccess()); + assertEquals(Status.INVALID_TOKEN, result.getStatus()); + assertNull(OzoneManager.getStsTokenIdentifier(), "Expected STS ThreadLocal to be cleared after runCommand"); + verify(handler, never()).handleWriteRequest(any(), any(), any()); + } + // --- processResponse tests --- @Test diff --git a/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/om/ratis/package-info.java b/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/om/ratis/package-info.java new file mode 100644 index 000000000000..3dea7af810ca --- /dev/null +++ b/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/om/ratis/package-info.java @@ -0,0 +1,21 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * Package contains test classes for OM Ratis server implementation. + */ +package org.apache.hadoop.ozone.om.ratis; diff --git a/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/om/request/TestOMClientRequestWithUserInfo.java b/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/om/request/TestOMClientRequestWithUserInfo.java index 2dc4e6b5d677..c2666804d57d 100644 --- a/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/om/request/TestOMClientRequestWithUserInfo.java +++ b/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/om/request/TestOMClientRequestWithUserInfo.java @@ -22,6 +22,7 @@ import static org.apache.hadoop.ozone.om.request.OMRequestTestUtils.newCreateBucketRequest; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.mockStatic; @@ -41,14 +42,19 @@ import org.apache.hadoop.ozone.om.OmConfig; import org.apache.hadoop.ozone.om.OmMetadataManagerImpl; import org.apache.hadoop.ozone.om.OzoneManager; +import org.apache.hadoop.ozone.om.exceptions.OMException; +import org.apache.hadoop.ozone.om.execution.flowcontrol.ExecutionContext; import org.apache.hadoop.ozone.om.helpers.BucketLayout; import org.apache.hadoop.ozone.om.protocolPB.grpc.GrpcClientConstants; import org.apache.hadoop.ozone.om.request.bucket.OMBucketCreateRequest; import org.apache.hadoop.ozone.om.request.key.OMKeyCommitRequest; +import org.apache.hadoop.ozone.om.response.OMClientResponse; import org.apache.hadoop.ozone.om.upgrade.OMLayoutVersionManager; import org.apache.hadoop.ozone.protocol.proto.OzoneManagerProtocolProtos; import org.apache.hadoop.ozone.protocol.proto.OzoneManagerProtocolProtos.BucketInfo; import org.apache.hadoop.ozone.protocol.proto.OzoneManagerProtocolProtos.OMRequest; +import org.apache.hadoop.ozone.protocol.proto.OzoneManagerProtocolProtos.S3Authentication; +import org.apache.hadoop.ozone.security.STSTokenIdentifier; import org.apache.hadoop.security.UserGroupInformation; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -178,4 +184,258 @@ public void testClientSuppliedUserNameDoesNotOverrideS3AuthIdentity() assertEquals("AccessId", omClientRequest.getUserInfo().getUserName()); } + @Test + public void testUserInfoWithSTSToken() throws IOException { + final String accessId = "ASIA12345"; + final String signature = "Signature"; + final String stringToSign = "StringToSign"; + final String sessionToken = "SessionToken"; + final String originalAccessKeyId = "AKIAORIGINAL"; + + final STSTokenIdentifier stsTokenIdentifier = mock(STSTokenIdentifier.class); + when(stsTokenIdentifier.getOriginalAccessKeyId()).thenReturn(originalAccessKeyId); + + final S3Authentication s3Authentication = S3Authentication.newBuilder() + .setAccessId(accessId) + .setSignature(signature) + .setStringToSign(stringToSign) + .setSessionToken(sessionToken) + .build(); + + OzoneManager.setS3Auth(s3Authentication); + OzoneManager.setStsTokenIdentifier(stsTokenIdentifier); + + try { + final OMRequest.Builder omRequestBuilder = OMRequest.newBuilder() + .setCmdType(OzoneManagerProtocolProtos.Type.CommitKey) + .setClientId(UUID.randomUUID().toString()) + .setS3Authentication(s3Authentication); + + final OMRequest omRequest = omRequestBuilder.build(); + final OMClientRequest omClientRequest = new OMKeyCommitRequest(omRequest, mock(BucketLayout.class)); + + final OzoneManagerProtocolProtos.UserInfo userInfo = omClientRequest.getUserInfo(); + assertEquals(originalAccessKeyId, userInfo.getUserName()); + } finally { + OzoneManager.setStsTokenIdentifier(null); + OzoneManager.setS3Auth(null); + } + } + + @Test + public void testPreExecuteOverwritesResolvedStsFields() throws Exception { + try (MockedStatic mockedRpcServer = mockStatic(Server.class)) { + mockedRpcServer.when(Server::getRemoteUser).thenReturn(userGroupInformation); + mockedRpcServer.when(Server::getRemoteIp).thenReturn(inetAddress); + mockedRpcServer.when(Server::getRemoteAddress).thenReturn(inetAddress.toString()); + + final String accessId = "ASIA12345"; + final String signature = "Signature"; + final String stringToSign = "StringToSign"; + final String sessionToken = "SessionToken"; + final String originalAccessKeyId = "AKIAORIGINAL"; + final String roleArn = "arn:aws:iam::123456789012:role/test-role"; + final String sessionPolicy = "test-session-policy"; + final UUID secretKeyId = UUID.randomUUID(); + + final STSTokenIdentifier stsTokenIdentifier = mock(STSTokenIdentifier.class); + when(stsTokenIdentifier.getSessionPolicy()).thenReturn(sessionPolicy); + when(stsTokenIdentifier.getRoleArn()).thenReturn(roleArn); + when(stsTokenIdentifier.getOriginalAccessKeyId()).thenReturn(originalAccessKeyId); + when(stsTokenIdentifier.getTempAccessKeyId()).thenReturn(accessId); + when(stsTokenIdentifier.getSecretKeyId()).thenReturn(secretKeyId); + + final S3Authentication s3Authentication = S3Authentication.newBuilder() + .setAccessId(accessId) + .setSignature(signature) + .setStringToSign(stringToSign) + .setSessionToken(sessionToken) + .setResolvedStsSessionPolicy("client-session-policy") + .setResolvedStsRoleArn("client-role") + .setResolvedStsOriginalAccessKeyId("client-original-access-key-id") + .setResolvedStsTempAccessKeyId("client-temp-access-key-id") + .setResolvedStsSecretKeyId("client-secret-key-id") + .build(); + + OzoneManager.setS3Auth(s3Authentication); + OzoneManager.setStsTokenIdentifier(stsTokenIdentifier); + + try { + final String bucketName = UUID.randomUUID().toString(); + final String volumeName = UUID.randomUUID().toString(); + final BucketInfo.Builder bucketInfo = + newBucketInfoBuilder(bucketName, volumeName) + .setIsVersionEnabled(true) + .setStorageType(StorageTypeProto.DISK); + + final OMRequest omRequest = newCreateBucketRequest(bucketInfo) + .setS3Authentication(s3Authentication) + .build(); + + final OMBucketCreateRequest omBucketCreateRequest = new OMBucketCreateRequest(omRequest); + final OMRequest modifiedRequest = omBucketCreateRequest.preExecute(ozoneManager); + final S3Authentication modifiedS3Auth = modifiedRequest.getS3Authentication(); + + assertEquals(sessionPolicy, modifiedS3Auth.getResolvedStsSessionPolicy()); + assertEquals(roleArn, modifiedS3Auth.getResolvedStsRoleArn()); + assertEquals(originalAccessKeyId, modifiedS3Auth.getResolvedStsOriginalAccessKeyId()); + assertEquals(accessId, modifiedS3Auth.getResolvedStsTempAccessKeyId()); + assertEquals(secretKeyId.toString(), modifiedS3Auth.getResolvedStsSecretKeyId()); + } finally { + OzoneManager.setStsTokenIdentifier(null); + OzoneManager.setS3Auth(null); + } + } + } + + @Test + public void testUserInfoWithSTSAccessKeyMissingSessionToken() { + final String accessId = "ASIA12345"; + final String signature = "Signature"; + final String stringToSign = "StringToSign"; + + final OMRequest omRequest = OMRequest.newBuilder() + .setCmdType(OzoneManagerProtocolProtos.Type.CommitKey) + .setClientId(UUID.randomUUID().toString()) + .setS3Authentication(S3Authentication.newBuilder() + .setAccessId(accessId) + .setSignature(signature) + .setStringToSign(stringToSign) + .build()) + .build(); + + final OMClientRequest omClientRequest = new OMKeyCommitRequest(omRequest, mock(BucketLayout.class)); + final IOException ex = assertThrows(IOException.class, omClientRequest::getUserInfo); + + assertEquals("Error with STS token", ex.getMessage()); + assertEquals("Missing session token for accessKeyId: " + accessId, ex.getCause().getMessage()); + } + + @Test + public void testUserInfoWithSessionTokenButNoStsTokenIdentifier() { + final String accessId = "ASIA12345"; + final String signature = "Signature"; + final String stringToSign = "StringToSign"; + final String sessionToken = "SessionToken"; + + final S3Authentication s3Authentication = S3Authentication.newBuilder() + .setAccessId(accessId) + .setSignature(signature) + .setStringToSign(stringToSign) + .setSessionToken(sessionToken) + .build(); + + final OMRequest omRequest = OMRequest.newBuilder() + .setCmdType(OzoneManagerProtocolProtos.Type.CommitKey) + .setClientId(UUID.randomUUID().toString()) + .setS3Authentication(s3Authentication) + .build(); + + OzoneManager.setS3Auth(s3Authentication); + OzoneManager.setStsTokenIdentifier(null); + + try { + final OMClientRequest omClientRequest = new OMKeyCommitRequest(omRequest, mock(BucketLayout.class)); + final IOException ex = assertThrows(IOException.class, omClientRequest::getUserInfo); + + assertEquals("Error with STS Token", ex.getMessage()); + assertEquals( + "OMClientRequest has session token but no token identifier in OzoneManager", ex.getCause().getMessage()); + } finally { + OzoneManager.setS3Auth(null); + } + } + + @Test + public void testUserInfoWithSessionTokenButEmptyOriginalAccessKeyId() { + final String accessId = "ASIA12345"; + final String signature = "Signature"; + final String stringToSign = "StringToSign"; + final String sessionToken = "SessionToken"; + + final STSTokenIdentifier stsTokenIdentifier = mock(STSTokenIdentifier.class); + when(stsTokenIdentifier.getOriginalAccessKeyId()).thenReturn(""); + + final S3Authentication s3Authentication = S3Authentication.newBuilder() + .setAccessId(accessId) + .setSignature(signature) + .setStringToSign(stringToSign) + .setSessionToken(sessionToken) + .build(); + + OzoneManager.setS3Auth(s3Authentication); + OzoneManager.setStsTokenIdentifier(stsTokenIdentifier); + + try { + final OMRequest omRequest = OMRequest.newBuilder() + .setCmdType(OzoneManagerProtocolProtos.Type.CommitKey) + .setClientId(UUID.randomUUID().toString()) + .setS3Authentication(s3Authentication) + .build(); + + final OMClientRequest omClientRequest = new OMKeyCommitRequest(omRequest, mock(BucketLayout.class)); + final IOException ex = assertThrows(IOException.class, omClientRequest::getUserInfo); + + assertEquals("Error with STS Token", ex.getMessage()); + assertEquals("Invalid STS Token format - could not find originalAccessKeyId", ex.getCause().getMessage()); + } finally { + OzoneManager.setS3Auth(null); + OzoneManager.setStsTokenIdentifier(null); + } + } + + @Test + public void testPreExecuteRejectsSessionTokenWithoutStsTokenIdentifierWhenSecurityEnabled() { + when(ozoneManager.isSecurityEnabled()).thenReturn(true); + + final String accessId = "ASIA12345"; + final String signature = "Signature"; + final String stringToSign = "StringToSign"; + final String sessionToken = "SessionToken"; + + final S3Authentication s3Authentication = S3Authentication.newBuilder() + .setAccessId(accessId) + .setSignature(signature) + .setStringToSign(stringToSign) + .setSessionToken(sessionToken) + .build(); + + final OMRequest omRequest = OMRequest.newBuilder() + .setCmdType(OzoneManagerProtocolProtos.Type.CommitKey) + .setClientId(UUID.randomUUID().toString()) + .setS3Authentication(s3Authentication) + .build(); + + try { + OzoneManager.setStsTokenIdentifier(null); + final OMClientRequest omClientRequest = new DummyOMClientRequest(omRequest); + + final OMException ex = assertThrows(OMException.class, () -> omClientRequest.preExecute(ozoneManager)); + assertEquals(OMException.ResultCodes.INVALID_REQUEST, ex.getResult()); + assertTrue(ex.getMessage().contains("session token")); + } finally { + OzoneManager.setStsTokenIdentifier(null); + } + } + + private static final class DummyOMClientRequest extends OMClientRequest { + private DummyOMClientRequest(OMRequest omRequest) { + super(omRequest); + } + + @Override + public OzoneManagerProtocolProtos.UserInfo getUserIfNotExists(OzoneManager ozoneManager) { + return OzoneManagerProtocolProtos.UserInfo.newBuilder() + .setUserName("test-user") + .setHostName("localhost") + .setRemoteAddress("127.0.0.1") + .build(); + } + + @Override + public OMClientResponse validateAndUpdateCache(OzoneManager ozoneManager, ExecutionContext context) { + return null; + } + } + } diff --git a/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/om/request/s3/security/S3SecurityTestUtils.java b/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/om/request/s3/security/S3SecurityTestUtils.java new file mode 100644 index 000000000000..9c91864a3fe1 --- /dev/null +++ b/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/om/request/s3/security/S3SecurityTestUtils.java @@ -0,0 +1,44 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.hadoop.ozone.om.request.s3.security; + +/** + * Utility methods for S3 security tests. + */ +public final class S3SecurityTestUtils { + + private S3SecurityTestUtils() { + // Utility class, no instantiation + } + + /** + * Generates a string of length count containing the char c repeated. + * + * @param c the character to repeat + * @param count the number of times to repeat the character + * @return a string with the character repeated count times + */ + public static String repeat(char c, int count) { + final StringBuilder sb = new StringBuilder(count); + for (int i = 0; i < count; i++) { + sb.append(c); + } + return sb.toString(); + } +} + diff --git a/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/om/request/s3/security/TestS3AssumeRoleRequest.java b/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/om/request/s3/security/TestS3AssumeRoleRequest.java new file mode 100644 index 000000000000..f779a349d0fa --- /dev/null +++ b/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/om/request/s3/security/TestS3AssumeRoleRequest.java @@ -0,0 +1,988 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.hadoop.ozone.om.request.s3.security; + +import static java.util.Collections.emptySet; +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.io.IOException; +import java.net.InetAddress; +import java.net.InetSocketAddress; +import java.nio.charset.StandardCharsets; +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.Arrays; +import java.util.Collections; +import java.util.EnumSet; +import java.util.LinkedHashSet; +import java.util.Optional; +import java.util.Set; +import java.util.UUID; +import java.util.regex.Pattern; +import javax.crypto.SecretKey; +import javax.crypto.spec.SecretKeySpec; +import org.apache.commons.lang3.tuple.Pair; +import org.apache.hadoop.hdds.conf.OzoneConfiguration; +import org.apache.hadoop.hdds.security.symmetric.ManagedSecretKey; +import org.apache.hadoop.hdds.security.symmetric.SecretKeySignerClient; +import org.apache.hadoop.ozone.audit.AuditLogger; +import org.apache.hadoop.ozone.audit.AuditMessage; +import org.apache.hadoop.ozone.om.OMMultiTenantManager; +import org.apache.hadoop.ozone.om.OzoneManager; +import org.apache.hadoop.ozone.om.ResolvedBucket; +import org.apache.hadoop.ozone.om.exceptions.OMException; +import org.apache.hadoop.ozone.om.execution.flowcontrol.ExecutionContext; +import org.apache.hadoop.ozone.om.helpers.BucketLayout; +import org.apache.hadoop.ozone.om.helpers.OMAuditLogger; +import org.apache.hadoop.ozone.om.response.OMClientResponse; +import org.apache.hadoop.ozone.protocol.proto.OzoneManagerProtocolProtos.AssumeRoleRequest; +import org.apache.hadoop.ozone.protocol.proto.OzoneManagerProtocolProtos.AssumeRoleResponse; +import org.apache.hadoop.ozone.protocol.proto.OzoneManagerProtocolProtos.OMRequest; +import org.apache.hadoop.ozone.protocol.proto.OzoneManagerProtocolProtos.OMResponse; +import org.apache.hadoop.ozone.protocol.proto.OzoneManagerProtocolProtos.S3Authentication; +import org.apache.hadoop.ozone.protocol.proto.OzoneManagerProtocolProtos.Status; +import org.apache.hadoop.ozone.protocol.proto.OzoneManagerProtocolProtos.Type; +import org.apache.hadoop.ozone.protocol.proto.OzoneManagerProtocolProtos.UpdateAssumeRoleRequest; +import org.apache.hadoop.ozone.security.STSTokenSecretManager; +import org.apache.hadoop.ozone.security.acl.AssumeRoleRequest.OzoneGrant; +import org.apache.hadoop.ozone.security.acl.IAccessAuthorizer; +import org.apache.hadoop.ozone.security.acl.IAccessAuthorizer.ACLType; +import org.apache.hadoop.ozone.security.acl.IOzoneObj; +import org.apache.hadoop.ozone.security.acl.OzoneObj; +import org.apache.hadoop.ozone.security.acl.OzoneObjInfo; +import org.apache.hadoop.ozone.security.acl.iam.IamSessionPolicyResolver; +import org.apache.hadoop.security.UserGroupInformation; +import org.apache.hadoop.security.token.TokenIdentifier; +import org.apache.ozone.test.MockClock; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.MockedStatic; + +/** + * Unit tests for S3AssumeRoleRequest. + */ +public class TestS3AssumeRoleRequest { + + private static final String ROLE_ARN_1 = "arn:aws:iam::123456789012:role/MyRole1"; + private static final String ROLE_ARN_2 = "arn:aws:iam::123456789012:role/MyRole2"; + private static final String SESSION_NAME = "testSessionName"; + private static final String ORIGINAL_ACCESS_KEY_ID = "origAccessKeyId"; + private static final String TARGET_ROLE_NAME = "targetRole"; + private static final String SESSION_POLICY_VALUE = "session-policy"; + private static final String AWS_IAM_POLICY = "{\n" + + " \"Statement\": [{\n" + + " \"Effect\": \"Allow\",\n" + + " \"Action\": \"s3:*\",\n" + + " \"Resource\": \"arn:aws:s3:::*/*\"\n" + + " }]\n" + + "}"; + + private static final MockClock CLOCK = new MockClock(Instant.ofEpochMilli(1764819000), ZoneOffset.UTC); + private static final String OM_HOST = "om-host"; + private static final InetAddress LOOPBACK_IP = InetAddress.getLoopbackAddress(); + private static final Set EMPTY_GRANTS = Collections.singleton(new OzoneGrant(emptySet(), emptySet())); + private static final String REQUEST_ID = UUID.randomUUID().toString(); + + private static final Pattern ABC_PATTERN_32 = Pattern.compile("^[ABC]{32}$"); + private static final Pattern XYZ_PATTERN = Pattern.compile("^[XYZ]$"); + + private OzoneManager ozoneManager; + private ExecutionContext context; + private IAccessAuthorizer accessAuthorizer; + private AuditLogger auditLogger; + + @BeforeEach + public void setup() throws IOException { + ozoneManager = mock(OzoneManager.class); + auditLogger = mock(AuditLogger.class); + when(ozoneManager.getAuditLogger()).thenReturn(auditLogger); + + final OzoneConfiguration configuration = new OzoneConfiguration(); + when(ozoneManager.getConfiguration()).thenReturn(configuration); + when(ozoneManager.isS3MultiTenancyEnabled()).thenReturn(false); + + final SecretKeySignerClient secretKeyClient = mock(SecretKeySignerClient.class); + final ManagedSecretKey managedSecretKey = mock(ManagedSecretKey.class); + final SecretKey secretKey = new SecretKeySpec( + "testSecretKey".getBytes(StandardCharsets.UTF_8), "HmacSHA256"); + final UUID secretKeyId = UUID.randomUUID(); + + when(secretKeyClient.getCurrentSecretKey()).thenReturn(managedSecretKey); + when(managedSecretKey.getSecretKey()).thenReturn(secretKey); + when(managedSecretKey.getId()).thenReturn(secretKeyId); + when(managedSecretKey.sign(any(TokenIdentifier.class))).thenReturn( + "signature".getBytes(StandardCharsets.UTF_8)); + + final STSTokenSecretManager stsTokenSecretManager = new STSTokenSecretManager(secretKeyClient); + + when(ozoneManager.getOmRpcServerAddr()).thenReturn( + new InetSocketAddress("localhost", 9876)); + when(ozoneManager.getSTSTokenSecretManager()).thenReturn(stsTokenSecretManager); + + accessAuthorizer = mock(IAccessAuthorizer.class); + when(ozoneManager.getAccessAuthorizer()).thenReturn(accessAuthorizer); + when(accessAuthorizer.generateAssumeRoleSessionPolicy(any( + org.apache.hadoop.ozone.security.acl.AssumeRoleRequest.class))) + .thenReturn(SESSION_POLICY_VALUE); + + context = ExecutionContext.of(1L, null); + } + + @Test + public void testInvalidDurationTooShort() { + final OMRequest omRequest = baseOmRequestBuilder() + .setAssumeRoleRequest( + AssumeRoleRequest.newBuilder() + .setRoleArn(ROLE_ARN_1) + .setRoleSessionName(SESSION_NAME) + .setDurationSeconds(899) // less than 900 + .setRequestId(REQUEST_ID) + ).build(); + + final S3AssumeRoleRequest request = new S3AssumeRoleRequest(omRequest, CLOCK); + final OMException exception = assertThrows(OMException.class, () -> request.preExecute(ozoneManager)); + + assertThat(exception.getResult()).isEqualTo(OMException.ResultCodes.INVALID_REQUEST); + assertThat(exception.getMessage()).isEqualTo( + "Invalid Value: DurationSeconds must be between 900 and 43200 seconds"); + assertMarkForAuditCalled(request); + } + + @Test + public void testInvalidDurationTooLong() { + final OMRequest omRequest = baseOmRequestBuilder() + .setAssumeRoleRequest( + AssumeRoleRequest.newBuilder() + .setRoleArn(ROLE_ARN_1) + .setRoleSessionName(SESSION_NAME) + .setDurationSeconds(43201) // more than 43200 + .setRequestId(REQUEST_ID) + ).build(); + + final S3AssumeRoleRequest request = new S3AssumeRoleRequest(omRequest, CLOCK); + final OMException exception = assertThrows(OMException.class, () -> request.preExecute(ozoneManager)); + + assertThat(exception.getResult()).isEqualTo(OMException.ResultCodes.INVALID_REQUEST); + assertThat(exception.getMessage()).isEqualTo( + "Invalid Value: DurationSeconds must be between 900 and 43200 seconds"); + assertMarkForAuditCalled(request); + } + + @Test + public void testValidDurationMaxBoundary() throws IOException { + final OMRequest omRequest = baseOmRequestBuilder() + .setAssumeRoleRequest( + AssumeRoleRequest.newBuilder() + .setRoleArn(ROLE_ARN_1) + .setRoleSessionName(SESSION_NAME) + .setDurationSeconds(43200) // exactly max + .setRequestId(REQUEST_ID) + ).build(); + + // Call preExecute first to generate credentials + final S3AssumeRoleRequest request = new S3AssumeRoleRequest(omRequest, CLOCK); + final OMRequest preExecutedRequest = request.preExecute(ozoneManager); + + assertLeaderGeneratedAssumeRoleFields(preExecutedRequest, 43200); + + final S3AssumeRoleRequest requestWithCredentials = new S3AssumeRoleRequest(preExecutedRequest, CLOCK); + final OMClientResponse response = requestWithCredentials.validateAndUpdateCache(ozoneManager, context); + final OMResponse omResponse = response.getOMResponse(); + + assertThat(omResponse.getStatus()).isEqualTo(Status.OK); + assertThat(omResponse.hasAssumeRoleResponse()).isTrue(); + verify(accessAuthorizer).generateAssumeRoleSessionPolicy( + any(org.apache.hadoop.ozone.security.acl.AssumeRoleRequest.class)); + assertMarkForAuditCalled(requestWithCredentials); + } + + @Test + public void testValidDurationMinBoundary() throws IOException { + final OMRequest omRequest = baseOmRequestBuilder() + .setAssumeRoleRequest( + AssumeRoleRequest.newBuilder() + .setRoleArn(ROLE_ARN_1) + .setRoleSessionName(SESSION_NAME) + .setDurationSeconds(900) // exactly min + .setRequestId(REQUEST_ID) + ).build(); + + // Call preExecute first to generate credentials + final S3AssumeRoleRequest request = new S3AssumeRoleRequest(omRequest, CLOCK); + final OMRequest preExecutedRequest = request.preExecute(ozoneManager); + + assertLeaderGeneratedAssumeRoleFields(preExecutedRequest, 900); + + final S3AssumeRoleRequest requestWithCredentials = new S3AssumeRoleRequest(preExecutedRequest, CLOCK); + final OMClientResponse response = requestWithCredentials.validateAndUpdateCache(ozoneManager, context); + final OMResponse omResponse = response.getOMResponse(); + + assertThat(omResponse.getStatus()).isEqualTo(Status.OK); + assertThat(omResponse.hasAssumeRoleResponse()).isTrue(); + verify(accessAuthorizer).generateAssumeRoleSessionPolicy( + any(org.apache.hadoop.ozone.security.acl.AssumeRoleRequest.class)); + assertMarkForAuditCalled(requestWithCredentials); + } + + @Test + public void testMissingS3Authentication() { + final OMRequest omRequest = OMRequest.newBuilder() // note: not using baseOMRequestBuilder that has S3 auth + .setCmdType(Type.AssumeRole) + .setClientId("client-1") + .setAssumeRoleRequest( + AssumeRoleRequest.newBuilder() + .setRoleArn(ROLE_ARN_1) + .setRoleSessionName(SESSION_NAME) + .setDurationSeconds(3600) + .setRequestId(REQUEST_ID) + ).build(); + + final S3AssumeRoleRequest request = new S3AssumeRoleRequest(omRequest, CLOCK); + final OMException exception = assertThrows(OMException.class, () -> request.preExecute(ozoneManager)); + + assertThat(exception.getResult()).isEqualTo(OMException.ResultCodes.INVALID_REQUEST); + assertThat(exception.getMessage()).isEqualTo("S3AssumeRoleRequest does not have S3 authentication"); + assertMarkForAuditCalled(request); + } + + @Test + public void testSuccessfulAssumeRoleGeneratesCredentials() throws IOException { + final int durationSeconds = 3600; + final OMRequest omRequest = baseOmRequestBuilder() + .setAssumeRoleRequest( + AssumeRoleRequest.newBuilder() + .setRoleArn(ROLE_ARN_1) + .setRoleSessionName(SESSION_NAME) + .setDurationSeconds(durationSeconds) + .setRequestId(REQUEST_ID) + ).build(); + + final S3AssumeRoleRequest request = new S3AssumeRoleRequest(omRequest, CLOCK); + // Call preExecute first to generate credentials + final OMRequest preExecutedRequest = request.preExecute(ozoneManager); + + assertLeaderGeneratedAssumeRoleFields(preExecutedRequest, durationSeconds); + + final S3AssumeRoleRequest requestWithCredentials = new S3AssumeRoleRequest(preExecutedRequest, CLOCK); + final OMClientResponse clientResponse = requestWithCredentials.validateAndUpdateCache(ozoneManager, context); + final OMResponse omResponse = clientResponse.getOMResponse(); + + assertThat(omResponse.getStatus()).isEqualTo(Status.OK); + assertThat(omResponse.hasAssumeRoleResponse()).isTrue(); + assertThat(omResponse.getCmdType()).isEqualTo(Type.AssumeRole); + + final AssumeRoleResponse assumeRoleResponse = omResponse.getAssumeRoleResponse(); + assertThat(assumeRoleResponse.getSessionToken()).isEqualTo( + preExecutedRequest.getUpdateAssumeRoleRequest().getSessionToken()); + + // AccessKeyId: prefix ASIA + 20 chars + assertThat(assumeRoleResponse.getAccessKeyId()).startsWith("ASIA"); + assertThat(assumeRoleResponse.getAccessKeyId().length()).isEqualTo(24); // 20 chars + 4 chars from ASIA + + // SecretAccessKey: 40 chars + assertThat(assumeRoleResponse.getSecretAccessKey().length()).isEqualTo(40); + + // AssumedRoleId: prefix AROA + 16 chars, followed by ":" and sessionName + final String expectedRoleId = S3AssumeRoleRequest.generateDeterministicRoleId(ROLE_ARN_1); + assertThat(assumeRoleResponse.getAssumedRoleId()) + .isEqualTo(expectedRoleId + ":" + SESSION_NAME); + + // Verify expiration added durationSeconds + final long expirationEpochSeconds = assumeRoleResponse.getExpirationEpochSeconds(); + assertThat(expirationEpochSeconds).isEqualTo(CLOCK.instant().getEpochSecond() + durationSeconds); + assertMarkForAuditCalled(requestWithCredentials); + } + + @Test + public void testGenerateDeterministicRoleId() { + final String roleId1 = S3AssumeRoleRequest.generateDeterministicRoleId(ROLE_ARN_1); + final String roleId2 = S3AssumeRoleRequest.generateDeterministicRoleId(ROLE_ARN_1); + final String roleId3 = S3AssumeRoleRequest.generateDeterministicRoleId(ROLE_ARN_2); + + assertThat(roleId1).startsWith("AROA").hasSize(4 + 16); + assertThat(roleId1).isEqualTo(roleId2); + assertThat(roleId1).isNotEqualTo(roleId3); + } + + @Test + public void testGenerateSecureRandomStringUsingChars() { + final String chars = "ABC"; + final int length = 32; + final String s = S3AssumeRoleRequest.generateSecureRandomStringUsingChars( + chars, chars.length(), length); + assertThat(s).hasSize(length).matches(ABC_PATTERN_32); + + // Test with length 0 + final String empty = S3AssumeRoleRequest.generateSecureRandomStringUsingChars( + "ABC", 3, 0); + assertThat(empty).isEmpty(); + + // Test with length 1 + final String single = S3AssumeRoleRequest.generateSecureRandomStringUsingChars( + "XYZ", 3, 1); + assertThat(single).hasSize(1).matches(XYZ_PATTERN); + } + + @Test + public void testAssumeRoleCredentialsAreUnique() throws IOException { + // Test that multiple calls generate different credentials + final OMRequest omRequest = baseOmRequestBuilder() + .setAssumeRoleRequest( + AssumeRoleRequest.newBuilder() + .setRoleArn(ROLE_ARN_1) + .setRoleSessionName(SESSION_NAME) + .setDurationSeconds(3600) + .setRequestId(REQUEST_ID) + ).build(); + + final S3AssumeRoleRequest request1 = new S3AssumeRoleRequest(omRequest, CLOCK); + // Call preExecute first to generate credentials + final OMRequest preExecutedRequest1 = request1.preExecute(ozoneManager); + final S3AssumeRoleRequest requestWithCredentials1 = new S3AssumeRoleRequest(preExecutedRequest1, CLOCK); + final OMClientResponse response1 = requestWithCredentials1.validateAndUpdateCache(ozoneManager, context); + + final S3AssumeRoleRequest request2 = new S3AssumeRoleRequest(omRequest, CLOCK); + // Call preExecute again to generate different credentials + final OMRequest preExecutedRequest2 = request2.preExecute(ozoneManager); + final S3AssumeRoleRequest requestWithCredentials2 = new S3AssumeRoleRequest(preExecutedRequest2, CLOCK); + final OMClientResponse response2 = requestWithCredentials2.validateAndUpdateCache(ozoneManager, context); + + final AssumeRoleResponse assumeRoleResponse1 = response1.getOMResponse().getAssumeRoleResponse(); + final AssumeRoleResponse assumeRoleResponse2 = response2.getOMResponse().getAssumeRoleResponse(); + + // Different access keys + assertThat(assumeRoleResponse1.getAccessKeyId()).isNotEqualTo(assumeRoleResponse2.getAccessKeyId()); + + // Different secret keys + assertThat(assumeRoleResponse1.getSecretAccessKey()).isNotEqualTo(assumeRoleResponse2.getSecretAccessKey()); + + // Different session tokens + assertThat(assumeRoleResponse1.getSessionToken()).isNotEqualTo(assumeRoleResponse2.getSessionToken()); + + // Same assumed role ID for the same role and session name + assertThat(assumeRoleResponse1.getAssumedRoleId()).isEqualTo(assumeRoleResponse2.getAssumedRoleId()); + + // Different role ARN yields a different assumed role ID + final OMRequest omRequestDifferentRole = baseOmRequestBuilder() + .setAssumeRoleRequest( + AssumeRoleRequest.newBuilder() + .setRoleArn(ROLE_ARN_2) + .setRoleSessionName(SESSION_NAME) + .setDurationSeconds(3600) + .setRequestId(REQUEST_ID) + ).build(); + final S3AssumeRoleRequest request3 = new S3AssumeRoleRequest(omRequestDifferentRole, CLOCK); + final OMRequest preExecutedRequest3 = request3.preExecute(ozoneManager); + final S3AssumeRoleRequest requestWithCredentials3 = new S3AssumeRoleRequest(preExecutedRequest3, CLOCK); + final OMClientResponse response3 = requestWithCredentials3.validateAndUpdateCache(ozoneManager, context); + final AssumeRoleResponse assumeRoleResponse3 = response3.getOMResponse().getAssumeRoleResponse(); + assertThat(assumeRoleResponse1.getAssumedRoleId()).isNotEqualTo(assumeRoleResponse3.getAssumedRoleId()); + + OMAuditLogger.log(requestWithCredentials1.getAuditBuilder()); + OMAuditLogger.log(requestWithCredentials2.getAuditBuilder()); + OMAuditLogger.log(requestWithCredentials3.getAuditBuilder()); + verify(auditLogger, times(3)).logWrite(any(AuditMessage.class)); + } + + @Test + public void testValidateAndUpdateCacheDoesNotCallAuthorizer() throws IOException { + final OMRequest omRequest = baseOmRequestBuilder() + .setAssumeRoleRequest( + AssumeRoleRequest.newBuilder() + .setRoleArn(ROLE_ARN_1) + .setRoleSessionName(SESSION_NAME) + .setDurationSeconds(3600) + .setRequestId(REQUEST_ID) + ).build(); + + final S3AssumeRoleRequest request = new S3AssumeRoleRequest(omRequest, CLOCK); + final OMRequest preExecutedRequest = request.preExecute(ozoneManager); + verify(accessAuthorizer).generateAssumeRoleSessionPolicy( + any(org.apache.hadoop.ozone.security.acl.AssumeRoleRequest.class)); + + final S3AssumeRoleRequest requestWithCredentials = new S3AssumeRoleRequest(preExecutedRequest, CLOCK); + requestWithCredentials.validateAndUpdateCache(ozoneManager, context); + + verify(accessAuthorizer, times(1)).generateAssumeRoleSessionPolicy( + any(org.apache.hadoop.ozone.security.acl.AssumeRoleRequest.class)); + } + + @Test + public void testAssumeRoleWithEmptySessionName() { + final OMRequest omRequest = baseOmRequestBuilder() + .setAssumeRoleRequest( + AssumeRoleRequest.newBuilder() + .setRoleArn(ROLE_ARN_1) + .setRoleSessionName("") + .setDurationSeconds(3600) + .setRequestId(REQUEST_ID) + ).build(); + + final S3AssumeRoleRequest request = new S3AssumeRoleRequest(omRequest, CLOCK); + final OMException exception = assertThrows(OMException.class, () -> request.preExecute(ozoneManager)); + assertThat(exception.getResult()).isEqualTo(OMException.ResultCodes.INVALID_REQUEST); + assertThat(exception.getMessage()).isEqualTo( + "Value null at 'roleSessionName' failed to satisfy constraint: Member must not be null"); + assertMarkForAuditCalled(request); + } + + @Test + public void testInvalidAssumeRoleSessionNameTooShort() { + final OMRequest omRequest = baseOmRequestBuilder() + .setAssumeRoleRequest( + AssumeRoleRequest.newBuilder() + .setRoleArn(ROLE_ARN_1) + .setRoleSessionName("T") // Less than 2 characters + .setRequestId(REQUEST_ID) + ).build(); + + final S3AssumeRoleRequest request = new S3AssumeRoleRequest(omRequest, CLOCK); + final OMException exception = assertThrows(OMException.class, () -> request.preExecute(ozoneManager)); + + assertThat(exception.getResult()).isEqualTo(OMException.ResultCodes.INVALID_REQUEST); + assertThat(exception.getMessage()).isEqualTo( + "Invalid RoleSessionName length 1: it must be 2-64 characters long and contain only alphanumeric " + + "characters and +, =, ,, ., @, -"); + assertMarkForAuditCalled(request); + } + + @Test + public void testInvalidRoleSessionNameTooLong() { + final String tooLongRoleSessionName = S3SecurityTestUtils.repeat('h', 70); + final OMRequest omRequest = baseOmRequestBuilder() + .setAssumeRoleRequest( + AssumeRoleRequest.newBuilder() + .setRoleArn(ROLE_ARN_1) + .setRoleSessionName(tooLongRoleSessionName) + .setRequestId(REQUEST_ID) + ).build(); + + final S3AssumeRoleRequest request = new S3AssumeRoleRequest(omRequest, CLOCK); + final OMException exception = assertThrows(OMException.class, () -> request.preExecute(ozoneManager)); + + assertThat(exception.getResult()).isEqualTo(OMException.ResultCodes.INVALID_REQUEST); + assertThat(exception.getMessage()).isEqualTo( + "Invalid RoleSessionName length 70: it must be 2-64 characters long and contain only alphanumeric " + + "characters and +, =, ,, ., @, -" + ); + assertMarkForAuditCalled(request); + } + + @Test + public void testValidRoleSessionNameMaxLengthBoundary() throws IOException { + final String roleSessionName = S3SecurityTestUtils.repeat('g', 64); + final OMRequest omRequest = baseOmRequestBuilder() + .setAssumeRoleRequest( + AssumeRoleRequest.newBuilder() + .setRoleArn(ROLE_ARN_1) + .setRoleSessionName(roleSessionName) // exactly max length + .setRequestId(REQUEST_ID) + ).build(); + + // Call preExecute first to generate credentials + final S3AssumeRoleRequest request = new S3AssumeRoleRequest(omRequest, CLOCK); + final OMRequest preExecutedRequest = request.preExecute(ozoneManager); + final S3AssumeRoleRequest requestWithCredentials = new S3AssumeRoleRequest(preExecutedRequest, CLOCK); + final OMClientResponse response = requestWithCredentials.validateAndUpdateCache(ozoneManager, context); + final OMResponse omResponse = response.getOMResponse(); + + assertThat(omResponse.getStatus()).isEqualTo(Status.OK); + assertThat(omResponse.hasAssumeRoleResponse()).isTrue(); + assertMarkForAuditCalled(requestWithCredentials); + } + + @Test + public void testValidRoleSessionNameMinLengthBoundary() throws IOException { + final OMRequest omRequest = baseOmRequestBuilder() + .setAssumeRoleRequest( + AssumeRoleRequest.newBuilder() + .setRoleArn(ROLE_ARN_1) + .setRoleSessionName("TT") // exactly min length + .setRequestId(REQUEST_ID) + ).build(); + + // Call preExecute first to generate credentials + final S3AssumeRoleRequest request = new S3AssumeRoleRequest(omRequest, CLOCK); + final OMRequest preExecutedRequest = request.preExecute(ozoneManager); + final S3AssumeRoleRequest requestWithCredentials = new S3AssumeRoleRequest(preExecutedRequest, CLOCK); + final OMClientResponse response = requestWithCredentials.validateAndUpdateCache(ozoneManager, context); + final OMResponse omResponse = response.getOMResponse(); + + assertThat(omResponse.getStatus()).isEqualTo(Status.OK); + assertThat(omResponse.hasAssumeRoleResponse()).isTrue(); + assertMarkForAuditCalled(requestWithCredentials); + } + + @Test + public void testAssumeRoleWithSessionPolicyPresent() throws IOException { + final OMRequest omRequest = baseOmRequestBuilder() + .setAssumeRoleRequest( + AssumeRoleRequest.newBuilder() + .setRoleArn(ROLE_ARN_1) + .setRoleSessionName(SESSION_NAME) + .setDurationSeconds(3600) + .setAwsIamSessionPolicy(AWS_IAM_POLICY) + .setRequestId(REQUEST_ID) + ).build(); + + // Call preExecute first to generate credentials + final S3AssumeRoleRequest request = new S3AssumeRoleRequest(omRequest, CLOCK); + final OMRequest preExecutedRequest = request.preExecute(ozoneManager); + final S3AssumeRoleRequest requestWithCredentials = new S3AssumeRoleRequest(preExecutedRequest, CLOCK); + final OMClientResponse response = requestWithCredentials.validateAndUpdateCache(ozoneManager, context); + assertThat(response.getOMResponse().getStatus()).isEqualTo(Status.OK); + assertMarkForAuditCalled(requestWithCredentials); + } + + @Test + public void testMalformedSessionPolicyDoesNotIssueCredentials() throws IOException { + final String sessionPolicy = "{\n" + + " \"Statement\": [{\n" + + " \"Effect\": \"Allow\",\n" + + " \"Action\": \"s3:GetObject\",\n" + + " \"Action\": \"s3:*\",\n" + + " \"Resource\": \"arn:aws:s3:::bucket1/*\"\n" + + " }]\n" + + "}"; + final OMRequest omRequest = baseOmRequestBuilder() + .setAssumeRoleRequest( + AssumeRoleRequest.newBuilder() + .setRoleArn(ROLE_ARN_1) + .setRoleSessionName(SESSION_NAME) + .setDurationSeconds(3600) + .setAwsIamSessionPolicy(sessionPolicy) + .setRequestId(REQUEST_ID) + ).build(); + + final S3AssumeRoleRequest request = new S3AssumeRoleRequest(omRequest, CLOCK); + final OMException exception = assertThrows(OMException.class, () -> request.preExecute(ozoneManager)); + + assertThat(exception.getResult()).isEqualTo(OMException.ResultCodes.MALFORMED_POLICY_DOCUMENT); + assertThat(exception.getMessage()).isEqualTo("IAM session policy: Duplicate field 'Action' in session policy"); + verify(accessAuthorizer, never()).generateAssumeRoleSessionPolicy( + any(org.apache.hadoop.ozone.security.acl.AssumeRoleRequest.class)); + assertMarkForAuditCalled(request); + } + + @Test + public void testGetSessionPolicyUsesDefaultVolumeWhenMultiTenantDisabled() throws Exception { + when(ozoneManager.isS3MultiTenancyEnabled()).thenReturn(false); + + // Ensure s3v default volume was captured in the method invocation + final org.apache.hadoop.ozone.security.acl.AssumeRoleRequest capturedAssumeRoleRequest = + captureAssumeRoleRequest("s3v", "userNameA"); + + assertThat(capturedAssumeRoleRequest.getHost()).isEqualTo(OM_HOST); + assertThat(capturedAssumeRoleRequest.getIp()).isEqualTo(LOOPBACK_IP); + assertThat(capturedAssumeRoleRequest.getTargetRoleName()).isEqualTo(TARGET_ROLE_NAME); + assertThat(capturedAssumeRoleRequest.getGrants()).isEqualTo(EMPTY_GRANTS); + } + + @Test + public void testGetSessionPolicyResolvesIamPolicyWithTenantVolume() throws Exception { + when(ozoneManager.isS3MultiTenancyEnabled()).thenReturn(true); + + final OMMultiTenantManager multiTenantManager = mock(OMMultiTenantManager.class); + when(ozoneManager.getMultiTenantManager()).thenReturn(multiTenantManager); + when(multiTenantManager.getTenantForAccessID(ORIGINAL_ACCESS_KEY_ID)).thenReturn(Optional.of("tenant-a")); + when(multiTenantManager.getTenantVolumeName("tenant-a")).thenReturn("tenant-a-volume"); + + // Ensure "tenant-a-volume" was captured in the method invocation + final org.apache.hadoop.ozone.security.acl.AssumeRoleRequest capturedAssumeRoleRequest = + captureAssumeRoleRequest("tenant-a-volume", "userNameA"); + + assertThat(capturedAssumeRoleRequest.getHost()).isEqualTo(OM_HOST); + assertThat(capturedAssumeRoleRequest.getIp()).isEqualTo(LOOPBACK_IP); + assertThat(capturedAssumeRoleRequest.getTargetRoleName()).isEqualTo(TARGET_ROLE_NAME); + assertThat(capturedAssumeRoleRequest.getGrants()).isEqualTo(EMPTY_GRANTS); + } + + @Test + public void testGetSessionPolicyFallsBackToDefaultVolumeWhenTenantMissing() throws Exception { + when(ozoneManager.isS3MultiTenancyEnabled()).thenReturn(true); + + final OMMultiTenantManager multiTenantManager = mock(OMMultiTenantManager.class); + when(ozoneManager.getMultiTenantManager()).thenReturn(multiTenantManager); + when(multiTenantManager.getTenantForAccessID(ORIGINAL_ACCESS_KEY_ID)).thenReturn(Optional.empty()); + + // Ensure s3v default volume was captured in the method invocation since tenant was missing + final org.apache.hadoop.ozone.security.acl.AssumeRoleRequest capturedAssumeRoleRequest = + captureAssumeRoleRequest("s3v", "userNameB"); + + verify(multiTenantManager, never()).getTenantVolumeName(any()); + assertThat(capturedAssumeRoleRequest.getHost()).isEqualTo(OM_HOST); + assertThat(capturedAssumeRoleRequest.getIp()).isEqualTo(LOOPBACK_IP); + assertThat(capturedAssumeRoleRequest.getTargetRoleName()).isEqualTo(TARGET_ROLE_NAME); + assertThat(capturedAssumeRoleRequest.getGrants()).isEqualTo(EMPTY_GRANTS); + } + + @Test + public void testGetSessionPolicyWithBlankAwsPolicyCapturesNullGrants() throws Exception { + when(ozoneManager.isS3MultiTenancyEnabled()).thenReturn(false); + + final String awsIamPolicy = null; + try (MockedStatic resolverMock = mockStatic(IamSessionPolicyResolver.class)) { + final String result = new S3AssumeRoleRequest(baseOmRequestBuilder().build(), CLOCK) + .getSessionPolicy( + ozoneManager, ORIGINAL_ACCESS_KEY_ID, awsIamPolicy, OM_HOST, LOOPBACK_IP, + UserGroupInformation.createRemoteUser("userNameC"), TARGET_ROLE_NAME); + + assertThat(result).isEqualTo(SESSION_POLICY_VALUE); + + // Ensure IamSessionPolicyResolver was never invoked since awsIamPolicy is null + resolverMock.verifyNoInteractions(); + } + + final ArgumentCaptor captor = + ArgumentCaptor.forClass(org.apache.hadoop.ozone.security.acl.AssumeRoleRequest.class); + verify(accessAuthorizer).generateAssumeRoleSessionPolicy(captor.capture()); + + final org.apache.hadoop.ozone.security.acl.AssumeRoleRequest capturedAssumeRoleRequest = captor.getValue(); + assertThat(capturedAssumeRoleRequest.getHost()).isEqualTo(OM_HOST); + assertThat(capturedAssumeRoleRequest.getIp()).isEqualTo(LOOPBACK_IP); + assertThat(capturedAssumeRoleRequest.getTargetRoleName()).isEqualTo(TARGET_ROLE_NAME); + assertThat(capturedAssumeRoleRequest.getGrants()).isNull(); + } + + @Test + public void testResolveGrantsAgainstBucketLinksLeavesNonLinkGrantsUnchanged() throws IOException { + final Set grants = Collections.singleton( + new OzoneGrant( + objectsOf( + obj(OzoneObj.ResourceType.VOLUME, "s3v", null, null), + obj(OzoneObj.ResourceType.BUCKET, "s3v", "mybucket", null), + obj(OzoneObj.ResourceType.KEY, "s3v", "mybucket", "*")), + EnumSet.of(ACLType.READ), Collections.singleton("GetObject"))); + + final Set result = S3AssumeRoleRequest.resolveGrantsAgainstBucketLinks( + grants, (volume, bucket) -> new ResolvedBucket( + volume, bucket, volume, bucket, "owner", BucketLayout.OBJECT_STORE)); + + assertThat(result).isEqualTo(grants); + } + + @Test + public void testResolveGrantsAgainstBucketLinksRewritesToSourceForSameVolumeLink() throws IOException { + final Set grants = Collections.singleton( + new OzoneGrant( + objectsOf( + obj(OzoneObj.ResourceType.VOLUME, "s3v", null, null), + obj(OzoneObj.ResourceType.BUCKET, "s3v", "s3v-iceberg", null), + obj(OzoneObj.ResourceType.KEY, "s3v", "s3v-iceberg", "*")), + EnumSet.of(ACLType.READ), Collections.singleton("GetObject"))); + + final Set result = S3AssumeRoleRequest.resolveGrantsAgainstBucketLinks( + grants, (volume, bucket) -> resolved( + volume, bucket, "s3v", "iceberg", Pair.of("s3v", "s3v-iceberg"))); + + final IOzoneObj linkBucket = obj(OzoneObj.ResourceType.BUCKET, "s3v", "s3v-iceberg", null); + final Set allObjects = allObjectsIn(result); + // Key and bucket are anchored to the link's source. + assertThat(allObjects).contains(obj(OzoneObj.ResourceType.KEY, "s3v", "iceberg", "*")); + assertThat(allObjects).contains(obj(OzoneObj.ResourceType.BUCKET, "s3v", "iceberg", null)); + // The link key is gone; only a READ on the link bucket remains, for following the link. + assertThat(allObjects).doesNotContain(obj(OzoneObj.ResourceType.KEY, "s3v", "s3v-iceberg", "*")); + assertThat(allObjects).contains(linkBucket); + // Same volume, so no extra source-volume grant is added. + assertThat(allObjects).doesNotContain(obj(OzoneObj.ResourceType.VOLUME, "iceberg", null, null)); + + final OzoneGrant followGrant = grantContaining(result, linkBucket); + assertThat(followGrant.getPermissions()).containsExactly(ACLType.READ); + assertThat(followGrant.getS3Actions()).isEmpty(); + } + + @Test + public void testResolveGrantsAgainstBucketLinksRewritesPrefixOnSameVolumeLink() throws IOException { + final Set grants = Collections.singleton( + new OzoneGrant( + objectsOf( + prefixObj("s3v", "s3v-iceberg", "folder/")), + EnumSet.of(ACLType.READ, ACLType.LIST), Collections.singleton("ListBucket"))); + + final Set result = S3AssumeRoleRequest.resolveGrantsAgainstBucketLinks( + grants, (volume, bucket) -> resolved( + volume, bucket, "s3v", "iceberg", Pair.of("s3v", "s3v-iceberg"))); + + final IOzoneObj linkBucket = obj(OzoneObj.ResourceType.BUCKET, "s3v", "s3v-iceberg", null); + final Set allObjects = allObjectsIn(result); + assertThat(allObjects).contains(prefixObj("s3v", "iceberg", "folder/")); + assertThat(allObjects).doesNotContain(prefixObj("s3v", "s3v-iceberg", "folder/")); + assertThat(allObjects).contains(linkBucket); + + final OzoneGrant followGrant = grantContaining(result, linkBucket); + assertThat(followGrant.getPermissions()).containsExactly(ACLType.READ); + assertThat(followGrant.getS3Actions()).isEmpty(); + } + + @Test + public void testResolveGrantsAgainstBucketLinksAddsSourceVolumeReadForCrossVolumeLink() throws IOException { + final Set grants = Collections.singleton( + new OzoneGrant( + objectsOf( + obj(OzoneObj.ResourceType.VOLUME, "s3v", null, null), + obj(OzoneObj.ResourceType.BUCKET, "s3v", "s3v-iceberg", null), + obj(OzoneObj.ResourceType.KEY, "s3v", "s3v-iceberg", "*")), + EnumSet.of(ACLType.READ), Collections.singleton("GetObject"))); + + final Set result = S3AssumeRoleRequest.resolveGrantsAgainstBucketLinks( + grants, (volume, bucket) -> resolved( + volume, bucket, "tenantvol", "iceberg", Pair.of("s3v", "s3v-iceberg"))); + + final IOzoneObj linkBucket = obj(OzoneObj.ResourceType.BUCKET, "s3v", "s3v-iceberg", null); + final IOzoneObj sourceVolume = obj(OzoneObj.ResourceType.VOLUME, "tenantvol", null, null); + final Set allObjects = allObjectsIn(result); + assertThat(allObjects).contains(obj(OzoneObj.ResourceType.KEY, "tenantvol", "iceberg", "*")); + assertThat(allObjects).contains(linkBucket); + assertThat(allObjects).contains(sourceVolume); + + final OzoneGrant followGrant = grantContaining(result, sourceVolume); + assertThat(followGrant.getPermissions()).containsExactly(ACLType.READ); + assertThat(followGrant.getObjects()).contains(linkBucket); + } + + @Test + public void testResolveGrantsAgainstBucketLinksSkipsWildcardBuckets() throws IOException { + final Set grants = Collections.singleton( + new OzoneGrant( + objectsOf( + obj(OzoneObj.ResourceType.VOLUME, "s3v", null, null), + obj(OzoneObj.ResourceType.BUCKET, "s3v", "*", null), + obj(OzoneObj.ResourceType.KEY, "s3v", "*", "*")), + EnumSet.of(ACLType.READ), Collections.singleton("GetObject"))); + + final Set result = S3AssumeRoleRequest.resolveGrantsAgainstBucketLinks( + grants, (volume, bucket) -> { + throw new AssertionError("link resolver must not be called for wildcard buckets"); + }); + + assertThat(result).isEqualTo(grants); + } + + @Test + public void testResolveGrantsAgainstBucketLinksLeavesDanglingBucketsUnchanged() throws IOException { + final Set grants = Collections.singleton( + new OzoneGrant( + objectsOf( + obj(OzoneObj.ResourceType.KEY, "s3v", "danglingBucket", "*")), + EnumSet.of(ACLType.READ), Collections.singleton("GetObject"))); + + final Set result = S3AssumeRoleRequest.resolveGrantsAgainstBucketLinks( + grants, (volume, bucket) -> new ResolvedBucket(volume, bucket, null, null, null, null)); + + assertThat(result).isEqualTo(grants); + } + + @Test + public void testResolveGrantsAgainstBucketLinksRewritesChainedSameVolumeLink() throws IOException { + final Set grants = Collections.singleton( + new OzoneGrant( + objectsOf( + obj(OzoneObj.ResourceType.BUCKET, "s3v", "linkA", null), + obj(OzoneObj.ResourceType.KEY, "s3v", "linkA", "*")), + EnumSet.of(ACLType.READ), Collections.singleton("GetObject"))); + + final Set result = S3AssumeRoleRequest.resolveGrantsAgainstBucketLinks( + grants, (volume, bucket) -> resolved( + volume, bucket, "s3v", "source", Pair.of("s3v", "linkA"), Pair.of("s3v", "linkB"))); + + final IOzoneObj linkA = obj(OzoneObj.ResourceType.BUCKET, "s3v", "linkA", null); + final IOzoneObj linkB = obj(OzoneObj.ResourceType.BUCKET, "s3v", "linkB", null); + final Set allObjects = allObjectsIn(result); + assertThat(allObjects).contains(obj(OzoneObj.ResourceType.KEY, "s3v", "source", "*")); + assertThat(allObjects).contains(linkA); + assertThat(allObjects).contains(linkB); + assertThat(allObjects).doesNotContain(obj(OzoneObj.ResourceType.VOLUME, "s3v", null, null)); + } + + @Test + public void testResolveGrantsAgainstBucketLinksAddsVolumeReadForChainedCrossVolumeHop() throws IOException { + final Set grants = Collections.singleton( + new OzoneGrant( + objectsOf( + obj(OzoneObj.ResourceType.BUCKET, "s3v", "linkA", null), + obj(OzoneObj.ResourceType.KEY, "s3v", "linkA", "*")), + EnumSet.of(ACLType.READ), Collections.singleton("GetObject"))); + + final Set result = S3AssumeRoleRequest.resolveGrantsAgainstBucketLinks( + grants, (volume, bucket) -> resolved( + volume, bucket, "tenant", "source", Pair.of("s3v", "linkA"), Pair.of("tenant", "linkB"))); + + final IOzoneObj linkA = obj(OzoneObj.ResourceType.BUCKET, "s3v", "linkA", null); + final IOzoneObj linkB = obj(OzoneObj.ResourceType.BUCKET, "tenant", "linkB", null); + final IOzoneObj tenantVolume = obj(OzoneObj.ResourceType.VOLUME, "tenant", null, null); + final Set allObjects = allObjectsIn(result); + assertThat(allObjects).contains(obj(OzoneObj.ResourceType.KEY, "tenant", "source", "*")); + assertThat(allObjects).contains(linkA); + assertThat(allObjects).contains(linkB); + assertThat(allObjects).contains(tenantVolume); + assertThat(allObjects).doesNotContain(obj(OzoneObj.ResourceType.VOLUME, "s3v", null, null)); + } + + @Test + public void testGetSessionPolicyRewritesLinkBucketGrantsToSource() throws Exception { + when(ozoneManager.isS3MultiTenancyEnabled()).thenReturn(false); + + final Set resolverGrants = Collections.singleton( + new OzoneGrant( + objectsOf( + obj(OzoneObj.ResourceType.VOLUME, "s3v", null, null), + obj(OzoneObj.ResourceType.BUCKET, "s3v", "s3v-iceberg", null), + obj(OzoneObj.ResourceType.KEY, "s3v", "s3v-iceberg", "*")), + EnumSet.of(ACLType.READ), Collections.singleton("GetObject"))); + + when(ozoneManager.resolveBucketLink(Pair.of("s3v", "s3v-iceberg"), true, false)) + .thenReturn(resolved("s3v", "s3v-iceberg", "s3v", "iceberg", Pair.of("s3v", "s3v-iceberg"))); + + try (MockedStatic resolverMock = mockStatic(IamSessionPolicyResolver.class)) { + resolverMock.when(() -> IamSessionPolicyResolver.resolve( + AWS_IAM_POLICY, "s3v", IamSessionPolicyResolver.AuthorizerType.RANGER)) + .thenReturn(resolverGrants); + + final String result = new S3AssumeRoleRequest(baseOmRequestBuilder().build(), CLOCK) + .getSessionPolicy( + ozoneManager, ORIGINAL_ACCESS_KEY_ID, AWS_IAM_POLICY, OM_HOST, LOOPBACK_IP, + UserGroupInformation.createRemoteUser("userNameLink"), TARGET_ROLE_NAME); + // Ensure no exception was thrown and that the method actually delegated to generateAssumeRoleSessionPolicy + // and returned its value (not null, not something else). + assertThat(result).isEqualTo(SESSION_POLICY_VALUE); + } + + final ArgumentCaptor captor = ArgumentCaptor.forClass( + org.apache.hadoop.ozone.security.acl.AssumeRoleRequest.class); + verify(accessAuthorizer).generateAssumeRoleSessionPolicy(captor.capture()); + + final Set allObjects = allObjectsIn(captor.getValue().getGrants()); + assertThat(allObjects).contains(obj(OzoneObj.ResourceType.KEY, "s3v", "iceberg", "*")); + assertThat(allObjects).contains(obj(OzoneObj.ResourceType.BUCKET, "s3v", "s3v-iceberg", null)); + assertThat(allObjects).doesNotContain(obj(OzoneObj.ResourceType.KEY, "s3v", "s3v-iceberg", "*")); + + verify(ozoneManager).resolveBucketLink(Pair.of("s3v", "s3v-iceberg"), true, false); + } + + @SafeVarargs + private static ResolvedBucket resolved(String requestedVol, String requestedBucket, String realVol, String realBucket, + Pair... linkChain) { + return new ResolvedBucket( + requestedVol, requestedBucket, realVol, realBucket, "owner", + BucketLayout.OBJECT_STORE, Arrays.asList(linkChain)); + } + + private static IOzoneObj obj(OzoneObj.ResourceType type, String volume, String bucket, String key) { + final OzoneObjInfo.Builder builder = OzoneObjInfo.Builder.newBuilder() + .setResType(type) + .setStoreType(OzoneObj.StoreType.OZONE) + .setVolumeName(volume); + if (bucket != null) { + builder.setBucketName(bucket); + } + if (key != null) { + builder.setKeyName(key); + } + return builder.build(); + } + + @SuppressWarnings("SameParameterValue") + private static IOzoneObj prefixObj(String volume, String bucket, String prefix) { + return OzoneObjInfo.Builder.newBuilder() + .setResType(OzoneObj.ResourceType.PREFIX) + .setStoreType(OzoneObj.StoreType.OZONE) + .setVolumeName(volume) + .setBucketName(bucket) + .setPrefixName(prefix) + .build(); + } + + private static Set objectsOf(IOzoneObj... objects) { + return new LinkedHashSet<>(Arrays.asList(objects)); + } + + private static Set allObjectsIn(Set grants) { + final Set all = new LinkedHashSet<>(); + for (OzoneGrant grant : grants) { + all.addAll(grant.getObjects()); + } + return all; + } + + private static OzoneGrant grantContaining(Set grants, IOzoneObj object) { + return grants.stream() + .filter(grant -> grant.getObjects().contains(object)) + .findFirst() + .orElseThrow(() -> new AssertionError("No grant contains " + object)); + } + + private org.apache.hadoop.ozone.security.acl.AssumeRoleRequest captureAssumeRoleRequest(String volumeName, + String userName) throws Exception { + try (MockedStatic resolverMock = mockStatic(IamSessionPolicyResolver.class)) { + resolverMock.when(() -> IamSessionPolicyResolver.resolve( + AWS_IAM_POLICY, volumeName, IamSessionPolicyResolver.AuthorizerType.RANGER)) + .thenReturn(EMPTY_GRANTS); + + final String result = new S3AssumeRoleRequest(baseOmRequestBuilder().build(), CLOCK) + .getSessionPolicy( + ozoneManager, ORIGINAL_ACCESS_KEY_ID, AWS_IAM_POLICY, OM_HOST, LOOPBACK_IP, + UserGroupInformation.createRemoteUser(userName), TARGET_ROLE_NAME); + + assertThat(result).isEqualTo(SESSION_POLICY_VALUE); + resolverMock.verify(() -> IamSessionPolicyResolver.resolve( + AWS_IAM_POLICY, volumeName, IamSessionPolicyResolver.AuthorizerType.RANGER)); + } + + final ArgumentCaptor captor = + ArgumentCaptor.forClass(org.apache.hadoop.ozone.security.acl.AssumeRoleRequest.class); + verify(accessAuthorizer).generateAssumeRoleSessionPolicy(captor.capture()); + return captor.getValue(); + } + + private static OMRequest.Builder baseOmRequestBuilder() { + return OMRequest.newBuilder() + .setCmdType(Type.AssumeRole) + .setClientId("client-1") + .setS3Authentication( + S3Authentication.newBuilder() + .setAccessId(ORIGINAL_ACCESS_KEY_ID) + ); + } + + private void assertLeaderGeneratedAssumeRoleFields(OMRequest preExecutedRequest, int durationSeconds) { + assertThat(preExecutedRequest.hasUpdateAssumeRoleRequest()).isTrue(); + final UpdateAssumeRoleRequest updateAssumeRoleRequest = preExecutedRequest.getUpdateAssumeRoleRequest(); + assertThat(updateAssumeRoleRequest.getTempAccessKeyId()).startsWith("ASIA"); + assertThat(updateAssumeRoleRequest.getSecretAccessKey()).isNotEmpty(); + assertThat(updateAssumeRoleRequest.getRoleId()).startsWith("AROA"); + assertThat(updateAssumeRoleRequest.getSessionToken()).isNotEmpty(); + assertThat(updateAssumeRoleRequest.getExpirationEpochSeconds()) + .isEqualTo(CLOCK.instant().getEpochSecond() + durationSeconds); + } + + private void assertMarkForAuditCalled(S3AssumeRoleRequest request) { + OMAuditLogger.log(request.getAuditBuilder()); + verify(auditLogger).logWrite(any(AuditMessage.class)); + } +} + + diff --git a/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/om/request/s3/security/TestS3GetSecretRequest.java b/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/om/request/s3/security/TestS3GetSecretRequest.java index 0067b2b38047..5f23bfb14e2e 100644 --- a/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/om/request/s3/security/TestS3GetSecretRequest.java +++ b/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/om/request/s3/security/TestS3GetSecretRequest.java @@ -179,8 +179,12 @@ public void setUp() throws Exception { @AfterEach public void tearDown() throws Exception { + if (omMetadataManager != null) { + omMetadataManager.close(); + } omMetrics.unRegister(); framework().clearInlineMocks(); + Server.getCurCall().remove(); } private OMRequest createTenantRequest(String tenantNameStr) { diff --git a/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/om/request/s3/security/TestS3RevokeSTSTokenRequest.java b/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/om/request/s3/security/TestS3RevokeSTSTokenRequest.java new file mode 100644 index 000000000000..1b6caed9cb40 --- /dev/null +++ b/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/om/request/s3/security/TestS3RevokeSTSTokenRequest.java @@ -0,0 +1,387 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.hadoop.ozone.om.request.s3.security; + +import static org.apache.hadoop.security.authentication.util.KerberosName.DEFAULT_MECHANISM; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.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; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.io.IOException; +import java.util.Optional; +import java.util.UUID; +import org.apache.hadoop.hdds.utils.db.Table; +import org.apache.hadoop.hdds.utils.db.cache.CacheKey; +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; +import org.apache.hadoop.ozone.om.response.OMClientResponse; +import org.apache.hadoop.ozone.protocol.proto.OzoneManagerProtocolProtos; +import org.apache.hadoop.ozone.protocol.proto.OzoneManagerProtocolProtos.OMRequest; +import org.apache.hadoop.ozone.protocol.proto.OzoneManagerProtocolProtos.Type; +import org.apache.hadoop.security.UserGroupInformation; +import org.apache.hadoop.security.authentication.util.KerberosName; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +/** + * Tests for {@link S3RevokeSTSTokenRequest}. + */ +public class TestS3RevokeSTSTokenRequest { + + private static final String TEST_KERBEROS_RULES = + "RULE:[2:$1@$0](.*@EXAMPLE.COM)s/@.*//\n" + "RULE:[1:$1@$0](.*@EXAMPLE.COM)s/@.*//\n" + "DEFAULT"; + + 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.setRules(TEST_KERBEROS_RULES); + + // Multi-tenant manager mock used for tests that exercise the S3 multi-tenancy permission branch. + omMultiTenantManager = mock(OMMultiTenantManager.class); + } + + @AfterEach + public void tearDown() { + Server.getCurCall().remove(); + KerberosName.setRuleMechanism(kerberosMechanismBeforeTest); + KerberosName.setRules(kerberosRulesBeforeTest); + } + + @Test + public void testPreExecuteFailsForNonOwnerOfOriginalAccessKey() throws Exception { + // Verify that preExecute enforces permissions based on the request's original access key ID + // and rejects revocation attempts from non-owners. + final String originalAccessKeyId = "original-access-key-id"; + + // An RPC call running another Kerberos identity should NOT be allowed to revoke the token whose original + // access key id is different. + final UserGroupInformation tempUgi = UserGroupInformation.createRemoteUser("another-kerberos-identity"); + Server.getCurCall().set(new StubCall(tempUgi)); + + OMException ex; + try (OzoneManager ozoneManager = mock(OzoneManager.class)) { + 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()); + } + + @Test + public void testPreExecuteSucceedsForOriginalAccessKeyOwner() throws Exception { + // Verify that preExecute allows the owner of the original access key ID from the revoke request + // to revoke the temporary credentials. + final String originalAccessKeyId = "original-access-key-id"; + + // Simulate RPC call running as originalAccessKeyId + final UserGroupInformation originalUgi = UserGroupInformation.createRemoteUser(originalAccessKeyId); + Server.getCurCall().set(new StubCall(originalUgi)); + + final OzoneManager ozoneManager = mock(OzoneManager.class); + configureOzoneManagerForPreExecute(ozoneManager, originalAccessKeyId, true); + when(ozoneManager.isS3Admin(any(UserGroupInformation.class))).thenReturn(false); + + final OMClientRequest omClientRequest = new S3RevokeSTSTokenRequest(buildRevokeOmRequest(originalAccessKeyId)); + final OMRequest result = omClientRequest.preExecute(ozoneManager); + + assertEquals(Type.RevokeSTSToken, result.getCmdType()); + assertTrue(result.getRevokeSTSTokenRequest().hasRevocationTimeMillis()); + assertEquals(originalAccessKeyId, result.getRevokeSTSTokenRequest().getOriginalAccessKeyId()); + assertTrue(result.getRevokeSTSTokenRequest().getRevocationTimeMillis() > 0L); + } + + @Test + public void testPreExecuteSucceedsForTenantAccessIdOwner() throws Exception { + // When S3 multi-tenancy is enabled and the original access key id is assigned to a tenant, verify that + // the tenant access ID owner is allowed to revoke the temporary credentials. + final String tenantId = "finance"; + final String originalAccessKeyId = "alice@EXAMPLE.COM"; + + // 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); + + // Original access key id is assigned to a tenant and owned by "alice". + when(omMultiTenantManager.getTenantForAccessID(originalAccessKeyId)).thenReturn(Optional.of(tenantId)); + when(omMultiTenantManager.getUserNameGivenAccessId(originalAccessKeyId)).thenReturn("alice"); + // Not a tenant admin; ownership should be sufficient. + when(omMultiTenantManager.isTenantAdmin(callerUgi, tenantId, false)).thenReturn(false); + + final OMClientRequest omClientRequest = new S3RevokeSTSTokenRequest(buildRevokeOmRequest(originalAccessKeyId)); + final OMRequest result = omClientRequest.preExecute(ozoneManager); + assertEquals(Type.RevokeSTSToken, result.getCmdType()); + } + + @Test + public void testPreExecuteSucceedsForTenantAdmin() throws Exception { + // When S3 multi-tenancy is enabled and the original access key id is assigned to a tenant, verify that a + // tenant admin (who is not the owner) is allowed to revoke the temporary credentials. + final String tenantId = "finance"; + final String originalAccessKeyId = "alice@EXAMPLE.COM"; + + // 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); + + // Original access key id is assigned to a tenant and owned by "alice". + when(omMultiTenantManager.getTenantForAccessID(originalAccessKeyId)).thenReturn(Optional.of(tenantId)); + when(omMultiTenantManager.getUserNameGivenAccessId(originalAccessKeyId)).thenReturn("alice"); + // Caller is configured as tenant admin so the check should pass. + when(omMultiTenantManager.isTenantAdmin(callerUgi, tenantId, false)).thenReturn(true); + + final OMClientRequest omClientRequest = new S3RevokeSTSTokenRequest(buildRevokeOmRequest(originalAccessKeyId)); + final OMRequest result = omClientRequest.preExecute(ozoneManager); + assertEquals(Type.RevokeSTSToken, result.getCmdType()); + } + + @Test + public void testPreExecuteFailsForNonOwnerNonAdminInTenant() throws Exception { + // When S3 multi-tenancy is enabled and the original access key id is assigned to a tenant, verify that a + // non-owner, non-admin caller is rejected. + final String tenantId = "finance"; + final String originalAccessKeyId = "alice@EXAMPLE.COM"; + + // Caller short name "carol" does not own the access ID and is not + // configured as tenant admin. + final UserGroupInformation callerUgi = UserGroupInformation.createRemoteUser("carol@EXAMPLE.COM"); + Server.getCurCall().set(new StubCall(callerUgi)); + + final OMException ex; + try (OzoneManager ozoneManager = mock(OzoneManager.class)) { + configureOzoneManagerForPreExecute(ozoneManager, originalAccessKeyId, true); + when(ozoneManager.isS3MultiTenancyEnabled()).thenReturn(true); + when(ozoneManager.getMultiTenantManager()).thenReturn(omMultiTenantManager); + // Original access key id is assigned to a tenant and owned by "alice". + when(omMultiTenantManager.getTenantForAccessID(originalAccessKeyId)).thenReturn(Optional.of(tenantId)); + when(omMultiTenantManager.getUserNameGivenAccessId(originalAccessKeyId)).thenReturn("alice"); + // Caller is not a tenant admin. + when(omMultiTenantManager.isTenantAdmin(callerUgi, tenantId, false)).thenReturn(false); + + final OMClientRequest omClientRequest = new S3RevokeSTSTokenRequest(buildRevokeOmRequest(originalAccessKeyId)); + ex = assertThrows(OMException.class, () -> omClientRequest.preExecute(ozoneManager)); + } + assertEquals(OMException.ResultCodes.USER_MISMATCH, ex.getResult()); + } + + @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)); + + try (OzoneManager ozoneManager = mock(OzoneManager.class)) { + final S3SecretManager s3SecretManager = configureOzoneManagerForPreExecute( + ozoneManager, originalAccessKeyId, false); + final OMClientRequest omClientRequest = new S3RevokeSTSTokenRequest(buildRevokeOmRequest(originalAccessKeyId)); + final OMException ex = assertThrows(OMException.class, () -> omClientRequest.preExecute(ozoneManager)); + assertEquals(OMException.ResultCodes.ACCESS_ID_NOT_FOUND, ex.getResult()); + assertTrue(ex.getMessage().contains("does not exist")); + assertTrue(ex.getMessage().contains(originalAccessKeyId)); + verify(s3SecretManager).hasS3Secret(originalAccessKeyId); + } + } + + @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.ACCESS_ID_NOT_FOUND, ex.getResult()); + assertTrue(ex.getMessage().contains("does not exist")); + assertTrue(ex.getMessage().contains(originalAccessKeyId)); + verify(s3SecretManager).hasS3Secret(originalAccessKeyId); + } + } + + @Test + public void testValidateAndUpdateCacheUpdatesCacheImmediately() { + final String originalAccessKeyId = "original-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); + final AuditLogger auditLogger = mock(AuditLogger.class); + + when(ozoneManager.getMetadataManager()).thenReturn(omMetadataManager); + when(omMetadataManager.getS3RevokedStsTokenTable()).thenReturn(s3RevokedStsTokenTable); + when(ozoneManager.getAuditLogger()).thenReturn(auditLogger); + + final OzoneManagerProtocolProtos.RevokeSTSTokenRequest revokeRequest = + OzoneManagerProtocolProtos.RevokeSTSTokenRequest.newBuilder() + .setOriginalAccessKeyId(originalAccessKeyId) + .setRevocationTimeMillis(revocationTimeMillis) + .build(); + + final OMRequest omRequest = OMRequest.newBuilder() + .setClientId(UUID.randomUUID().toString()) + .setCmdType(Type.RevokeSTSToken) + .setRevokeSTSTokenRequest(revokeRequest) + .build(); + + final S3RevokeSTSTokenRequest s3RevokeSTSTokenRequest = new S3RevokeSTSTokenRequest(omRequest); + final OMClientResponse omClientResponse = s3RevokeSTSTokenRequest.validateAndUpdateCache(ozoneManager, context); + + assertEquals(OzoneManagerProtocolProtos.Status.OK, omClientResponse.getOMResponse().getStatus()); + verify(s3RevokedStsTokenTable).addCacheEntry( + eq(new CacheKey<>(originalAccessKeyId)), any()); + assertNotNull(s3RevokeSTSTokenRequest.getAuditBuilder().getAuditMap()); + assertEquals( + originalAccessKeyId, s3RevokeSTSTokenRequest.getAuditBuilder().getAuditMap().get( + OzoneConsts.S3_REVOKESTSTOKEN_USER)); + } + + @Test + public void testValidateAndUpdateCacheRejectsMissingRevocationTimeMillis() { + final String originalAccessKeyId = "original-access-key-id"; + + final OzoneManager ozoneManager = mock(OzoneManager.class); + final OMMetadataManager omMetadataManager = mock(OMMetadataManager.class); + @SuppressWarnings("unchecked") + final Table s3RevokedStsTokenTable = mock(Table.class); + final ExecutionContext context = mock(ExecutionContext.class); + + when(ozoneManager.getMetadataManager()).thenReturn(omMetadataManager); + when(omMetadataManager.getS3RevokedStsTokenTable()).thenReturn(s3RevokedStsTokenTable); + + final OzoneManagerProtocolProtos.RevokeSTSTokenRequest revokeRequest = + OzoneManagerProtocolProtos.RevokeSTSTokenRequest.newBuilder() + .setOriginalAccessKeyId(originalAccessKeyId) + .build(); + + final OMRequest omRequest = OMRequest.newBuilder() + .setClientId(UUID.randomUUID().toString()) + .setCmdType(Type.RevokeSTSToken) + .setRevokeSTSTokenRequest(revokeRequest) + .build(); + + final S3RevokeSTSTokenRequest s3RevokeSTSTokenRequest = new S3RevokeSTSTokenRequest(omRequest); + final OMClientResponse omClientResponse = + s3RevokeSTSTokenRequest.validateAndUpdateCache(ozoneManager, context); + assertEquals(OzoneManagerProtocolProtos.Status.INTERNAL_ERROR, omClientResponse.getOMResponse().getStatus()); + } + + @Test + public void testPreExecuteRejectsClientSuppliedRevocationTimeMillis() throws Exception { + final String originalAccessKeyId = "original-access-key-id"; + final UserGroupInformation callerUgi = UserGroupInformation.createRemoteUser(originalAccessKeyId); + Server.getCurCall().set(new StubCall(callerUgi)); + + final OzoneManagerProtocolProtos.RevokeSTSTokenRequest revokeRequest = + OzoneManagerProtocolProtos.RevokeSTSTokenRequest.newBuilder() + .setOriginalAccessKeyId(originalAccessKeyId) + .setRevocationTimeMillis(1_700_000_000_000L) + .build(); + final OMRequest omRequest = OMRequest.newBuilder() + .setClientId(UUID.randomUUID().toString()) + .setCmdType(Type.RevokeSTSToken) + .setRevokeSTSTokenRequest(revokeRequest) + .build(); + + try (OzoneManager ozoneManager = mock(OzoneManager.class)) { + configureOzoneManagerForPreExecute(ozoneManager, originalAccessKeyId, true); + final OMClientRequest omClientRequest = new S3RevokeSTSTokenRequest(omRequest); + final OMException ex = assertThrows(OMException.class, () -> omClientRequest.preExecute(ozoneManager)); + assertEquals(OMException.ResultCodes.INVALID_REQUEST, ex.getResult()); + } + } + + private static OMRequest buildRevokeOmRequest(String originalAccessKeyId) { + final OzoneManagerProtocolProtos.RevokeSTSTokenRequest revokeRequest = + OzoneManagerProtocolProtos.RevokeSTSTokenRequest.newBuilder() + .setOriginalAccessKeyId(originalAccessKeyId) + .build(); + + return OMRequest.newBuilder() + .setClientId(UUID.randomUUID().toString()) + .setCmdType(Type.RevokeSTSToken) + .setRevokeSTSTokenRequest(revokeRequest) + .build(); + } + + private static S3SecretManager configureOzoneManagerForPreExecute(OzoneManager ozoneManager, + String originalAccessKeyId, boolean hasSecret) throws IOException { + when(ozoneManager.isS3MultiTenancyEnabled()).thenReturn(false); + final S3SecretManager s3SecretManager = mock(S3SecretManager.class); + when(ozoneManager.getS3SecretManager()).thenReturn(s3SecretManager); + when(s3SecretManager.hasS3Secret(originalAccessKeyId)).thenReturn(hasSecret); + return s3SecretManager; + } + + private static final class StubCall extends ExternalCall { + private final UserGroupInformation ugi; + + StubCall(UserGroupInformation ugi) { + super(null); + this.ugi = ugi; + } + + @Override + public UserGroupInformation getRemoteUser() { + return ugi; + } + } +} diff --git a/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/om/request/s3/security/package-info.java b/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/om/request/s3/security/package-info.java new file mode 100644 index 000000000000..7727afbfc68c --- /dev/null +++ b/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/om/request/s3/security/package-info.java @@ -0,0 +1,21 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * Package contains test classes for S3 Security requests. + */ +package org.apache.hadoop.ozone.om.request.s3.security; diff --git a/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/om/response/TestCleanupTableInfo.java b/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/om/response/TestCleanupTableInfo.java index 1a9e20a59859..5de300e2c2be 100644 --- a/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/om/response/TestCleanupTableInfo.java +++ b/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/om/response/TestCleanupTableInfo.java @@ -63,6 +63,7 @@ import org.apache.hadoop.ozone.om.response.file.OMFileCreateResponse; import org.apache.hadoop.ozone.om.response.key.OMKeyCreateResponse; import org.apache.hadoop.ozone.om.response.lifecycle.OMLifecycleSetServiceStatusResponse; +import org.apache.hadoop.ozone.om.response.s3.security.S3AssumeRoleResponse; import org.apache.hadoop.ozone.om.response.util.OMEchoRPCWriteResponse; import org.apache.hadoop.ozone.protocol.proto.OzoneManagerProtocolProtos.CreateFileRequest; import org.apache.hadoop.ozone.protocol.proto.OzoneManagerProtocolProtos.CreateKeyRequest; @@ -140,6 +141,7 @@ public void checkAnnotationAndTableName() { subTypes.remove(OMEchoRPCWriteResponse.class); subTypes.remove(DummyOMClientResponse.class); subTypes.remove(OMLifecycleSetServiceStatusResponse.class); + subTypes.remove(S3AssumeRoleResponse.class); subTypes.forEach(aClass -> { if (Modifier.isAbstract(aClass.getModifiers())) { assertFalse(aClass.isAnnotationPresent(CleanupTableInfo.class), diff --git a/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/om/response/s3/security/TestS3AssumeRoleResponse.java b/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/om/response/s3/security/TestS3AssumeRoleResponse.java new file mode 100644 index 000000000000..879c6d67eb60 --- /dev/null +++ b/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/om/response/s3/security/TestS3AssumeRoleResponse.java @@ -0,0 +1,133 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.hadoop.ozone.om.response.s3.security; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verifyNoInteractions; + +import org.apache.hadoop.hdds.utils.db.BatchOperation; +import org.apache.hadoop.ozone.om.OMMetadataManager; +import org.apache.hadoop.ozone.protocol.proto.OzoneManagerProtocolProtos.AssumeRoleResponse; +import org.apache.hadoop.ozone.protocol.proto.OzoneManagerProtocolProtos.OMResponse; +import org.apache.hadoop.ozone.protocol.proto.OzoneManagerProtocolProtos.Status; +import org.apache.hadoop.ozone.protocol.proto.OzoneManagerProtocolProtos.Type; +import org.junit.jupiter.api.Test; + +/** + * Unit tests for S3AssumeRoleResponse. + */ +public class TestS3AssumeRoleResponse { + + @Test + public void testAddToDBBatchIsNoOpAndResponseIsAccessible() throws Exception { + final AssumeRoleResponse assumeRoleResponse = AssumeRoleResponse.newBuilder() + .setAccessKeyId("ASIA123") + .setSecretAccessKey("secret-xyz") + .setSessionToken("session-token") + .setExpirationEpochSeconds(12345L) + .setAssumedRoleId("AROA123:session") + .build(); + + final OMResponse omResponse = OMResponse.newBuilder() + .setCmdType(Type.AssumeRole) + .setStatus(Status.OK) + .setSuccess(true) + .setAssumeRoleResponse(assumeRoleResponse) + .build(); + + final S3AssumeRoleResponse response = new S3AssumeRoleResponse(omResponse); + + // Should not throw and should not interact with DB tables + final OMMetadataManager omMetadataManager = mock(OMMetadataManager.class); + final BatchOperation batchOperation = mock(BatchOperation.class); + response.addToDBBatch(omMetadataManager, batchOperation); + + // Ensure the wrapped response is present and unchanged + assertThat(response.getOMResponse().getStatus()).isEqualTo(Status.OK); + assertThat(response.getOMResponse().hasAssumeRoleResponse()).isTrue(); + assertThat(response.getOMResponse().getAssumeRoleResponse()).isEqualTo(assumeRoleResponse); + + // Verify that batch operations were never called + verifyNoInteractions(batchOperation); + verifyNoInteractions(omMetadataManager); + } + + @Test + public void testResponseWithErrorStatus() { + final OMResponse errorResponse = OMResponse.newBuilder() + .setCmdType(Type.AssumeRole) + .setStatus(Status.INVALID_REQUEST) + .setSuccess(false) + .build(); + + final S3AssumeRoleResponse response = new S3AssumeRoleResponse(errorResponse); + + assertThat(response.getOMResponse().getStatus()).isEqualTo(Status.INVALID_REQUEST); + assertThat(response.getOMResponse().getSuccess()).isFalse(); + assertThat(response.getOMResponse().hasAssumeRoleResponse()).isFalse(); + } + + @Test + public void testResponsePreservesAllAssumeRoleDetails() { + final String expectedAccessKeyId = "ASIA123"; + final String expectedSecretAccessKey = "secretAccessKey"; + final String expectedSessionToken = "sessionTokenData"; + final long expectedExpiration = 1234567890L; + final String expectedAssumedRoleId = "AROA1234567890:mySession"; + + final AssumeRoleResponse assumeRoleResponse = AssumeRoleResponse.newBuilder() + .setAccessKeyId(expectedAccessKeyId) + .setSecretAccessKey(expectedSecretAccessKey) + .setSessionToken(expectedSessionToken) + .setExpirationEpochSeconds(expectedExpiration) + .setAssumedRoleId(expectedAssumedRoleId) + .build(); + + final OMResponse omResponse = OMResponse.newBuilder() + .setCmdType(Type.AssumeRole) + .setStatus(Status.OK) + .setSuccess(true) + .setAssumeRoleResponse(assumeRoleResponse) + .build(); + + final S3AssumeRoleResponse response = new S3AssumeRoleResponse(omResponse); + + final AssumeRoleResponse retrievedResponse = response.getOMResponse().getAssumeRoleResponse(); + assertThat(retrievedResponse.getAccessKeyId()).isEqualTo(expectedAccessKeyId); + assertThat(retrievedResponse.getSecretAccessKey()).isEqualTo(expectedSecretAccessKey); + assertThat(retrievedResponse.getSessionToken()).isEqualTo(expectedSessionToken); + assertThat(retrievedResponse.getExpirationEpochSeconds()).isEqualTo(expectedExpiration); + assertThat(retrievedResponse.getAssumedRoleId()).isEqualTo(expectedAssumedRoleId); + } + + @Test + public void testResponseWithEmptyAssumeRoleResponse() { + final OMResponse omResponse = OMResponse.newBuilder() + .setCmdType(Type.AssumeRole) + .setStatus(Status.OK) + .setSuccess(true) + .build(); + + final S3AssumeRoleResponse response = new S3AssumeRoleResponse(omResponse); + + assertThat(response.getOMResponse().hasAssumeRoleResponse()).isFalse(); + } +} + + diff --git a/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/om/service/TestRevokedSTSTokenCleanupService.java b/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/om/service/TestRevokedSTSTokenCleanupService.java new file mode 100644 index 000000000000..2b734cea2456 --- /dev/null +++ b/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/om/service/TestRevokedSTSTokenCleanupService.java @@ -0,0 +1,449 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.hadoop.ozone.om.service; + +import static org.apache.hadoop.ozone.protocol.proto.OzoneManagerProtocolProtos.Status.OK; +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.when; + +import com.google.protobuf.ServiceException; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.Consumer; +import org.apache.hadoop.hdds.conf.OzoneConfiguration; +import org.apache.hadoop.hdds.conf.StorageUnit; +import org.apache.hadoop.hdds.utils.db.StringInMemoryTestTable; +import org.apache.hadoop.ozone.om.OMConfigKeys; +import org.apache.hadoop.ozone.om.OMMetadataManager; +import org.apache.hadoop.ozone.om.OzoneManager; +import org.apache.hadoop.ozone.om.ratis.utils.OzoneManagerRatisUtils; +import org.apache.hadoop.ozone.protocol.proto.OzoneManagerProtocolProtos.DeleteRevokedSTSTokensRequest; +import org.apache.hadoop.ozone.protocol.proto.OzoneManagerProtocolProtos.OMRequest; +import org.apache.hadoop.ozone.protocol.proto.OzoneManagerProtocolProtos.OMResponse; +import org.apache.hadoop.ozone.protocol.proto.OzoneManagerProtocolProtos.Status; +import org.apache.hadoop.ozone.protocol.proto.OzoneManagerProtocolProtos.Type; +import org.apache.ozone.test.MockClock; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.MockedStatic; + +/** + * Unit tests for {@link RevokedSTSTokenCleanupService}. + */ +public class TestRevokedSTSTokenCleanupService { + private OzoneManager ozoneManager; + private StringInMemoryTestTable revokedStsTokenTable; + private MockClock testClock; + private OzoneConfiguration ozoneConfiguration; + + @BeforeEach + public void setUp() { + testClock = MockClock.newInstance(); + ozoneManager = mock(OzoneManager.class); + ozoneConfiguration = new OzoneConfiguration(); + final OMMetadataManager omMetadataManager = mock(OMMetadataManager.class); + revokedStsTokenTable = new StringInMemoryTestTable<>(); + + when(ozoneManager.isLeaderReady()).thenReturn(true); + when(ozoneManager.getMetadataManager()).thenReturn(omMetadataManager); + when(ozoneManager.getConfiguration()).thenReturn(ozoneConfiguration); + when(ozoneManager.getThreadNamePrefix()).thenReturn("om-"); + when(omMetadataManager.getS3RevokedStsTokenTable()).thenReturn(revokedStsTokenTable); + } + + @Test + public void submitsCleanupRequestForOnlyExpiredTokens() throws Exception { + // If there are two revoked entries, one expired and one not expired, only the expired + // 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("original-access-key-a", expiredCreationTimeMillis); + revokedStsTokenTable.put("original-access-key-b", validCreationTimeMillis); + + final AtomicReference capturedRequest = new AtomicReference<>(); + + try (MockedStatic ozoneManagerRatisUtilsMock = mockStatic(OzoneManagerRatisUtils.class)) { + mockRatisSubmitAndCapture(ozoneManagerRatisUtilsMock, capturedRequest); + + // Run the cleanup service + final RevokedSTSTokenCleanupService revokedSTSTokenCleanupService = createAndRunCleanupService(); + + assertThat(revokedSTSTokenCleanupService.getRunCount()).isEqualTo(1); + assertThat(revokedSTSTokenCleanupService.getSubmittedDeletedEntryCount()).isEqualTo(1); + + final OMRequest omRequest = capturedRequest.get(); + assertThat(omRequest).isNotNull(); + assertThat(omRequest.getCmdType()).isEqualTo(Type.DeleteRevokedSTSTokens); + + final DeleteRevokedSTSTokensRequest deleteRevokedSTSTokensRequest = + omRequest.getDeleteRevokedSTSTokensRequest(); + assertThat(deleteRevokedSTSTokensRequest.getOriginalAccessKeyIdList()).containsExactly("original-access-key-a"); + } + } + + @Test + public void doesNotSubmitRequestWhenThereAreNoExpiredTokens() throws Exception { + // If only non-expired entries exist in the revoked sts token table, no cleanup request should be submitted and + // no metrics should be updated. + final long nowMillis = testClock.millis(); + revokedStsTokenTable.put("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<>(); + + try (MockedStatic ozoneManagerRatisUtilsMock = mockStatic(OzoneManagerRatisUtils.class)) { + mockRatisSubmitAndCapture(ozoneManagerRatisUtilsMock, capturedRequest); + + // Run the cleanup service + final RevokedSTSTokenCleanupService revokedSTSTokenCleanupService = createAndRunCleanupService(); + + assertThat(revokedSTSTokenCleanupService.getRunCount()).isEqualTo(1); + assertThat(revokedSTSTokenCleanupService.getSubmittedDeletedEntryCount()).isZero(); + assertThat(capturedRequest.get()).isNull(); + } + } + + @Test + public void handlesNoEntriesInRevokedSTSTokenTable() throws Exception { + // If the table is empty (which most of the time it will be), no cleanup requests should be submitted and no metrics + // should be updated. + final AtomicReference capturedRequest = new AtomicReference<>(); + + try (MockedStatic ozoneManagerRatisUtilsMock = mockStatic(OzoneManagerRatisUtils.class)) { + mockRatisSubmitAndCapture(ozoneManagerRatisUtilsMock, capturedRequest); + + // Run the cleanup service + final RevokedSTSTokenCleanupService revokedSTSTokenCleanupService = createAndRunCleanupService(); + + assertThat(revokedSTSTokenCleanupService.getRunCount()).isEqualTo(1); + assertThat(revokedSTSTokenCleanupService.getSubmittedDeletedEntryCount()).isZero(); + assertThat(capturedRequest.get()).isNull(); + } + } + + @Test + public void doesNotUpdateMetricsOnRatisSubmissionServiceExceptionFailure() throws Exception { + // If there are expired tokens in the table but the OM request submission to clean up the entries fails with a + // service exception, the metrics should not be updated + final long nowMillis = testClock.millis(); + revokedStsTokenTable.put("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); + + try (MockedStatic ozoneManagerRatisUtilsMock = mockStatic(OzoneManagerRatisUtils.class)) { + // Simulate Ratis submission failure + mockRatisSubmitToFail(ozoneManagerRatisUtilsMock, submitAttempts); + + // Run the cleanup service + final RevokedSTSTokenCleanupService revokedSTSTokenCleanupService = createAndRunCleanupService(); + + assertThat(revokedSTSTokenCleanupService.getRunCount()).isEqualTo(1); + assertThat(submitAttempts.get()).isEqualTo(1); + assertThat(revokedSTSTokenCleanupService.getSubmittedDeletedEntryCount()).isZero(); + } + } + + @Test + public void doesNotUpdateMetricsOnNonSuccessfulResponse() throws Exception { + // If there is an expired token in the table but the OM request submission to clean up the entries gets a + // non-successful response, the metrics should not be updated + final long nowMillis = testClock.millis(); + revokedStsTokenTable.put("original-access-key-f", nowMillis - TimeUnit.HOURS.toMillis(20)); + + try (MockedStatic ozoneManagerRatisUtilsMock = mockStatic(OzoneManagerRatisUtils.class)) { + // Return a non-successful response + mockRatisSubmitWithInternalErrorResponse(ozoneManagerRatisUtilsMock); + + // Run the cleanup service + final RevokedSTSTokenCleanupService revokedSTSTokenCleanupService = createAndRunCleanupService(); + + assertThat(revokedSTSTokenCleanupService.getRunCount()).isEqualTo(1); + assertThat(revokedSTSTokenCleanupService.getSubmittedDeletedEntryCount()).isZero(); + } + } + + @Test + public void handlesAllExpiredTokens() throws Exception { + // If all the tokens in the table are expired on a particular run, ensure the metrics are updated appropriately + final long nowMillis = testClock.millis(); + revokedStsTokenTable.put("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<>(); + + try (MockedStatic ozoneManagerRatisUtilsMock = mockStatic(OzoneManagerRatisUtils.class)) { + mockRatisSubmitAndCapture(ozoneManagerRatisUtilsMock, capturedRequest); + + // Run the cleanup service + final RevokedSTSTokenCleanupService revokedSTSTokenCleanupService = createAndRunCleanupService(); + + assertThat(revokedSTSTokenCleanupService.getRunCount()).isEqualTo(1); + assertThat(revokedSTSTokenCleanupService.getSubmittedDeletedEntryCount()).isEqualTo(3); + + final OMRequest omRequest = capturedRequest.get(); + assertThat(omRequest).isNotNull(); + assertThat(omRequest.getCmdType()).isEqualTo(Type.DeleteRevokedSTSTokens); + + final DeleteRevokedSTSTokensRequest deleteRevokedSTSTokensRequest = + omRequest.getDeleteRevokedSTSTokensRequest(); + assertThat(deleteRevokedSTSTokensRequest.getOriginalAccessKeyIdList()) + .containsExactlyInAnyOrder("original-access-key-g", "original-access-key-h", "original-access-key-i"); + } + } + + @Test + public void submitsMultipleRequestsWhenBatchSizeIsExceeded() throws Exception { + // If the tokens exceed the configured batch size, multiple requests should be submitted + final long nowMillis = testClock.millis(); + + // Create 10 expired originalAccessKeyIds + for (int i = 0; i < 10; i++) { + 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 + // will exceed this. The effective limit will be 90 bytes (90% of 100). + ozoneConfiguration.setStorageSize( + OMConfigKeys.OZONE_OM_RATIS_LOG_APPENDER_QUEUE_BYTE_LIMIT, 100, StorageUnit.BYTES); + + final List capturedRequests = new ArrayList<>(); + + try (MockedStatic ozoneManagerRatisUtilsMock = mockStatic(OzoneManagerRatisUtils.class)) { + mockRatisSubmitAndCaptureRequests(ozoneManagerRatisUtilsMock, capturedRequests); + + // Run the cleanup service + final RevokedSTSTokenCleanupService revokedSTSTokenCleanupService = createAndRunCleanupService(); + + assertThat(revokedSTSTokenCleanupService.getRunCount()).isEqualTo(1); + // There should be multiple requests + assertThat(capturedRequests.size()).isEqualTo(2); + + // Verify all tokens were included across the requests + final int totalTokens = capturedRequests.stream() + .mapToInt(r -> r.getDeleteRevokedSTSTokensRequest().getOriginalAccessKeyIdList().size()) + .sum(); + assertThat(totalTokens).isEqualTo(10); + assertThat(revokedSTSTokenCleanupService.getSubmittedDeletedEntryCount()).isEqualTo(10); + } + } + + @Test + public void testSingleOversizedExpiredTokenAndItIsTheOnlyExpiredToken() throws Exception { + // 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'); + revokedStsTokenTable.put(largeToken, nowMillis - TimeUnit.HOURS.toMillis(13)); + + ozoneConfiguration.setStorageSize( + OMConfigKeys.OZONE_OM_RATIS_LOG_APPENDER_QUEUE_BYTE_LIMIT, 100, StorageUnit.BYTES); + + final List capturedRequests = new ArrayList<>(); + + try (MockedStatic ozoneManagerRatisUtilsMock = mockStatic(OzoneManagerRatisUtils.class)) { + mockRatisSubmitAndCaptureRequests(ozoneManagerRatisUtilsMock, capturedRequests); + + final RevokedSTSTokenCleanupService revokedSTSTokenCleanupService = createAndRunCleanupService(); + + assertThat(revokedSTSTokenCleanupService.getRunCount()).isEqualTo(1); + // Single token exceeding ratisByteLimit is skipped + assertThat(capturedRequests).isEmpty(); + assertThat(revokedSTSTokenCleanupService.getSubmittedDeletedEntryCount()).isZero(); + } + } + + @Test + public void testSingleOversizedExpiredTokenAndThereAreMultipleExpiredTokens() throws Exception { + // One originalAccessKeyId is larger than the ratisByteLimit, and it is not the only expired entry + final long nowMillis = testClock.millis(); + 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)); + + ozoneConfiguration.setStorageSize( + OMConfigKeys.OZONE_OM_RATIS_LOG_APPENDER_QUEUE_BYTE_LIMIT, 100, StorageUnit.BYTES); + + final List capturedRequests = new ArrayList<>(); + + try (MockedStatic ozoneManagerRatisUtilsMock = mockStatic(OzoneManagerRatisUtils.class)) { + mockRatisSubmitAndCaptureRequests(ozoneManagerRatisUtilsMock, capturedRequests); + + final RevokedSTSTokenCleanupService revokedSTSTokenCleanupService = createAndRunCleanupService(); + + assertThat(revokedSTSTokenCleanupService.getRunCount()).isEqualTo(1); + assertThat(capturedRequests).hasSize(1); + assertThat(revokedSTSTokenCleanupService.getSubmittedDeletedEntryCount()).isEqualTo(1); + } + } + + @Test + public void testExpiredAndNonExpiredTokensWithSmallRatisByteLimit() throws Exception { + // Expired and non-expired entries with ratisByteLimit of 100 + final long nowMillis = testClock.millis(); + + revokedStsTokenTable.put("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); + + final List capturedRequests = new ArrayList<>(); + + try (MockedStatic ozoneManagerRatisUtilsMock = mockStatic(OzoneManagerRatisUtils.class)) { + mockRatisSubmitAndCaptureRequests(ozoneManagerRatisUtilsMock, capturedRequests); + + final RevokedSTSTokenCleanupService revokedSTSTokenCleanupService = createAndRunCleanupService(); + + assertThat(revokedSTSTokenCleanupService.getRunCount()).isEqualTo(1); + // 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().getOriginalAccessKeyIdList()) + .containsExactly("original-access-key-l", "original-access-key-n"); + assertThat(revokedSTSTokenCleanupService.getSubmittedDeletedEntryCount()).isEqualTo(2); + } + } + + @Test + public void testExpiredTokenMatchesRatisByteLimitExactly() throws Exception { + // Force small batch of 100 bytes and test when the batch size is exactly ratisByteLimit + final long nowMillis = testClock.millis(); + final String tokenMatchingRatisByteLimitWhenSerialized = new String(new char[88]).replace('\0', 'a'); + + revokedStsTokenTable.put(tokenMatchingRatisByteLimitWhenSerialized, nowMillis - TimeUnit.HOURS.toMillis(13)); + + ozoneConfiguration.setStorageSize( + OMConfigKeys.OZONE_OM_RATIS_LOG_APPENDER_QUEUE_BYTE_LIMIT, 100, StorageUnit.BYTES); + + final List capturedRequests = new ArrayList<>(); + + try (MockedStatic ozoneManagerRatisUtilsMock = mockStatic(OzoneManagerRatisUtils.class)) { + mockRatisSubmitAndCaptureRequests(ozoneManagerRatisUtilsMock, capturedRequests); + + final RevokedSTSTokenCleanupService revokedSTSTokenCleanupService = createAndRunCleanupService(); + + assertThat(revokedSTSTokenCleanupService.getRunCount()).isEqualTo(1); + assertThat(capturedRequests).hasSize(1); + assertThat(revokedSTSTokenCleanupService.getSubmittedDeletedEntryCount()).isEqualTo(1); + } + } + + @Test + public void testCallIdCountIncreasesAcrossBatches() throws Exception { + // Force small batch of 40 bytes (which should trigger multiple calls to OzoneManagerRatisUtils.submitRequest) + // and ensure the callIdCount increases across each batch + // AKIA0000001 and AKIA0000002 are in first batch, and AKIA0000003 is in second batch. + final long nowMillis = testClock.millis(); + + 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); + + final List capturedCallIdCounts = new ArrayList<>(); + + try (MockedStatic ozoneManagerRatisUtilsMock = mockStatic(OzoneManagerRatisUtils.class)) { + // Capture the callIdCount (4th argument) + ozoneManagerRatisUtilsMock.when( + () -> OzoneManagerRatisUtils.submitRequest(any(), any(), any(), anyLong())) + .thenAnswer(invocation -> { + capturedCallIdCounts.add(invocation.getArgument(3)); + return buildOkResponse(invocation.getArgument(1)); + }); + + final RevokedSTSTokenCleanupService revokedSTSTokenCleanupService = createAndRunCleanupService(); + + assertThat(revokedSTSTokenCleanupService.getRunCount()).isEqualTo(1); + assertThat(revokedSTSTokenCleanupService.getSubmittedDeletedEntryCount()).isEqualTo(3); + assertThat(capturedCallIdCounts).hasSize(2); + assertThat(capturedCallIdCounts.get(1)).isGreaterThan(capturedCallIdCounts.get(0)); + } + } + + private RevokedSTSTokenCleanupService createAndRunCleanupService() throws Exception { + final RevokedSTSTokenCleanupService revokedSTSTokenCleanupService = + new RevokedSTSTokenCleanupService(1, TimeUnit.HOURS, 1_000, ozoneManager); + revokedSTSTokenCleanupService.runPeriodicalTaskNow(); + return revokedSTSTokenCleanupService; + } + + private void mockRatisSubmitAndCapture(MockedStatic ozoneManagerRatisUtilsMock, + AtomicReference capturedRequest) { + mockRatisSubmit(ozoneManagerRatisUtilsMock, capturedRequest::set); + } + + private void mockRatisSubmitAndCaptureRequests(MockedStatic ozoneManagerRatisUtilsMock, + List capturedRequests) { + mockRatisSubmit(ozoneManagerRatisUtilsMock, capturedRequests::add); + } + + private void mockRatisSubmitToFail(MockedStatic ozoneManagerRatisUtilsMock, + AtomicInteger submitAttempts) { + ozoneManagerRatisUtilsMock.when( + () -> OzoneManagerRatisUtils.submitRequest(any(), any(), any(), anyLong())) + .thenAnswer(invocation -> { + submitAttempts.incrementAndGet(); + throw new ServiceException("Simulated Ratis failure"); + }); + } + + private void mockRatisSubmitWithInternalErrorResponse(MockedStatic omRatisUtilsMock) { + omRatisUtilsMock.when( + () -> OzoneManagerRatisUtils.submitRequest(any(), any(), any(), anyLong())) + .thenReturn(OMResponse.newBuilder() + .setCmdType(Type.DeleteRevokedSTSTokens) + .setStatus(Status.INTERNAL_ERROR) + .setSuccess(false) + .build()); + } + + private static OMResponse buildOkResponse(OMRequest omRequest) { + return OMResponse.newBuilder() + .setCmdType(omRequest.getCmdType()) + .setStatus(OK) + .setSuccess(true) + .build(); + } + + private void mockRatisSubmit(MockedStatic ozoneManagerRatisUtilsMock, + Consumer requestConsumer) { + ozoneManagerRatisUtilsMock.when( + () -> OzoneManagerRatisUtils.submitRequest(any(), any(), any(), anyLong())) + .thenAnswer(invocation -> { + final OMRequest omRequest = invocation.getArgument(1); + requestConsumer.accept(omRequest); + return buildOkResponse(omRequest); + }); + } +} + + diff --git a/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/om/service/package-info.java b/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/om/service/package-info.java new file mode 100644 index 000000000000..71dfeed880d0 --- /dev/null +++ b/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/om/service/package-info.java @@ -0,0 +1,21 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * Tests for OM services. + */ +package org.apache.hadoop.ozone.om.service; diff --git a/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/security/TestS3SecurityUtil.java b/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/security/TestS3SecurityUtil.java new file mode 100644 index 000000000000..f9d641f60b75 --- /dev/null +++ b/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/security/TestS3SecurityUtil.java @@ -0,0 +1,350 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.hadoop.ozone.security; + +import static org.apache.hadoop.ozone.om.exceptions.OMException.ResultCodes.INTERNAL_ERROR; +import static org.apache.hadoop.ozone.om.exceptions.OMException.ResultCodes.INVALID_TOKEN; +import static org.apache.hadoop.ozone.om.exceptions.OMException.ResultCodes.REVOKED_TOKEN; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.CALLS_REAL_METHODS; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.when; + +import java.io.IOException; +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.util.UUID; +import javax.crypto.spec.SecretKeySpec; +import org.apache.hadoop.hdds.security.symmetric.ManagedSecretKey; +import org.apache.hadoop.hdds.security.symmetric.SecretKeyClient; +import org.apache.hadoop.hdds.utils.db.InMemoryTestTable; +import org.apache.hadoop.hdds.utils.db.Table; +import org.apache.hadoop.ozone.om.AWSV4AuthValidator; +import org.apache.hadoop.ozone.om.OMMetadataManager; +import org.apache.hadoop.ozone.om.OzoneManager; +import org.apache.hadoop.ozone.om.S3SecretManager; +import org.apache.hadoop.ozone.om.exceptions.OMException; +import org.apache.hadoop.ozone.protocol.proto.OzoneManagerProtocolProtos.OMRequest; +import org.apache.hadoop.ozone.protocol.proto.OzoneManagerProtocolProtos.S3Authentication; +import org.apache.hadoop.ozone.protocol.proto.OzoneManagerProtocolProtos.Type; +import org.apache.ozone.test.MockClock; +import org.junit.jupiter.api.Test; +import org.mockito.MockedStatic; + +/** + * Tests for STS revocation handling in {@link S3SecurityUtil}. + */ +public class TestS3SecurityUtil { + private static final ManagedSecretKey MANAGED_SECRET_KEY = createManagedSecretKey(); + private static final MockClock CLOCK = MockClock.newInstance(); + private static final String TEMP_ACCESS_KEY_ID = "temp-access-key-id"; + + @Test + public void testValidateS3CredentialFailsWhenTokenCreatedBeforeRevocationCutoff() throws Exception { + validateS3CredentialHelper( + new TestConfig() + .setRevocationCutoffOffsetMs(1) + .setExpectedResult(REVOKED_TOKEN) + .setExpectedMessage("STS token has been revoked")); + } + + @Test + public void testValidateS3CredentialWhenMetadataUnavailable() throws Exception { + // If the metadata manager is not available, throws INTERNAL_ERROR + validateS3CredentialHelper( + new TestConfig() + .setMetadataManager(null) + .setExpectedResult(INTERNAL_ERROR) + .setExpectedMessage("Could not determine STS revocation: metadataManager is null")); + } + + @Test + public void testValidateS3CredentialSuccessWhenNotRevoked() throws Exception { + // Normal case: token is NOT revoked and request is accepted + validateS3CredentialHelper(new TestConfig()); + } + + @Test + public void testValidateS3CredentialWhenMetadataManagerAvailableButRevokedTableNull() throws Exception { + // If the revoked STS token table is not available, throws INTERNAL_ERROR + validateS3CredentialHelper( + new TestConfig() + .setRevokedSTSTokenTable(null) + .setExpectedResult(INTERNAL_ERROR) + .setExpectedMessage("Could not determine STS revocation: revokedStsTokenTable is null")); + } + + @Test + public void testValidateS3CredentialWhenTableThrowsException() throws Exception { + // If the revoked STS token table lookup throws, throws INTERNAL_ERROR (wrapped) + final Table revokedSTSTokenTable = spy(new InMemoryTestTable<>()); + doThrow(new RuntimeException("lookup failed")).when(revokedSTSTokenTable).getIfExist(anyString()); + + validateS3CredentialHelper( + new TestConfig() + .setRevokedSTSTokenTable(revokedSTSTokenTable) + .setExpectedResult(INTERNAL_ERROR) + .setExpectedMessage("Could not determine STS revocation because of Exception: lookup failed")); + } + + @Test + public void testValidateS3CredentialFailsWhenOriginalAccessKeyIdPrincipalRevoked() throws Exception { + // If the originalAccessKeyId principal is revoked, throws REVOKED_TOKEN + validateS3CredentialHelper( + new TestConfig() + .setOriginalAccessKeyIdRevoked(true) + .setExpectedResult(REVOKED_TOKEN) + .setExpectedMessage("STS token no longer valid: OriginalAccessKeyId principal revoked")); + } + + @Test + public void testValidateS3CredentialFailsWhenOriginalAccessKeyIdCheckThrows() throws Exception { + // If checking originalAccessKeyId principal revocation fails, throws INTERNAL_ERROR + validateS3CredentialHelper( + new TestConfig() + .setShouldOriginalAccessKeyIdCheckThrowError(true) + .setExpectedResult(INTERNAL_ERROR) + .setExpectedMessage("Could not determine if original principal is revoked")); + } + + @Test + public void testValidateS3CredentialFailsWhenRequestAccessIdDoesNotMatchTokenOwner() throws Exception { + validateS3CredentialHelper( + new TestConfig() + .setRequestAccessId("some-other-access-id") + .setExpectedResult(INVALID_TOKEN) + .setExpectedMessage("STS token validation failed - accessKeyId is invalid for session token")); + } + + @Test + public void testValidateS3CredentialFailsWhenRequestAccessIdMissing() throws Exception { + validateS3CredentialHelper( + new TestConfig() + .setIncludeAccessId(false) + .setExpectedResult(INVALID_TOKEN) + .setExpectedMessage("STS token validation failed - accessKeyId is invalid for session token")); + } + + @Test + public void testValidateS3CredentialFailsWhenRequestAccessIdEmpty() throws Exception { + validateS3CredentialHelper( + new TestConfig() + .setRequestAccessId("") + .setExpectedResult(INVALID_TOKEN) + .setExpectedMessage("STS token validation failed - accessKeyId is invalid for session token")); + } + + @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); + when(ozoneManager.getSecretKeyClient()).thenReturn(mock(SecretKeyClient.class)); + + final OMMetadataManager metadataManager = config.metadataManager; + when(ozoneManager.getMetadataManager()).thenReturn(metadataManager); + if (metadataManager != null) { + when(metadataManager.getS3RevokedStsTokenTable()).thenReturn(config.revokedSTSTokenTable); + } + + // Mock S3SecretManager to handle originalAccessKeyId checks + final S3SecretManager s3SecretManager = mock(S3SecretManager.class); + when(ozoneManager.getS3SecretManager()).thenReturn(s3SecretManager); + if (config.shouldOriginalAccessKeyIdCheckThrowError) { + when(s3SecretManager.hasS3Secret(anyString())).thenThrow( + new IOException("An error occurred while checking if s3Secret exists")); + } else if (config.isOriginalAccessKeyIdRevoked) { + // Returning false means secret does NOT exist -> principal is revoked + when(s3SecretManager.hasS3Secret(anyString())).thenReturn(false); + } else { + // Returning true means secret exists -> principal is valid + when(s3SecretManager.hasS3Secret(anyString())).thenReturn(true); + } + + final String sessionToken = "session-token"; + 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( + AWSV4AuthValidator.class, CALLS_REAL_METHODS)) { + + stsSecurityUtilMock.when( + () -> STSSecurityUtil.constructValidateAndDecryptSTSToken( + eq(sessionToken), any(SecretKeyClient.class), any(Clock.class))) + .thenReturn(stsTokenIdentifier); + + // Mock AWS V4 signature validation + awsV4AuthValidatorMock.when(() -> AWSV4AuthValidator.validateRequest(anyString(), anyString(), anyString())) + .thenReturn(true); + + final OMRequest omRequest = createRequestWithSessionToken( + config.requestAccessId, config.includeAccessId); + + if (config.expectedResult != null) { + final OMException omException = assertThrows( + OMException.class, () -> S3SecurityUtil.validateS3Credential(omRequest, ozoneManager)); + assertEquals(config.expectedResult, omException.getResult()); + if (config.expectedMessage != null) { + assertTrue( + omException.getMessage().contains(config.expectedMessage), + "Expected exception message to contain: '" + config.expectedMessage + "' but was: '" + + omException.getMessage() + "'"); + } + } else { + assertDoesNotThrow(() -> S3SecurityUtil.validateS3Credential(omRequest, ozoneManager)); + } + } + } + } + + private STSTokenIdentifier createSTSTokenIdentifier() { + return new STSTokenIdentifier(STSTokenIdentifier.Params.newBuilder() + .setTempAccessKeyId(TEMP_ACCESS_KEY_ID) + .setOriginalAccessKeyId("original-access-key-id") + .setRoleArn("arn:aws:iam::123456789012:role/test-role") + .setCreationTime(CLOCK.instant()) + .setExpiry(CLOCK.instant().plusSeconds(3600)) + .setSecretAccessKey("secret-access-key") + .setSessionPolicy("session-policy") + .setManagedSecretKey(MANAGED_SECRET_KEY) + .build()); + } + + private static ManagedSecretKey createManagedSecretKey() { + final byte[] keyBytes = new byte[32]; + for (int i = 0; i < keyBytes.length; i++) { + keyBytes[i] = (byte) i; + } + return new ManagedSecretKey( + UUID.randomUUID(), + Instant.EPOCH, + Instant.EPOCH.plus(Duration.ofDays(1)), + new SecretKeySpec(keyBytes, "HmacSHA256")); + } + + private static OMRequest createRequestWithSessionToken(String accessId, boolean includeAccessId) { + final S3Authentication.Builder s3AuthenticationBuilder = S3Authentication.newBuilder() + .setStringToSign("string-to-sign") + .setSignature("signature") + .setSessionToken("session-token"); + if (includeAccessId) { + s3AuthenticationBuilder.setAccessId(accessId); + } + final S3Authentication s3Authentication = s3AuthenticationBuilder.build(); + + return OMRequest.newBuilder() + .setClientId(UUID.randomUUID().toString()) + .setCmdType(Type.CreateVolume) + .setS3Authentication(s3Authentication) + .build(); + } + + /** + * Helper class to create various scenarios for testing. + */ + private static final class TestConfig { + private OMMetadataManager metadataManager = mock(OMMetadataManager.class); + private Table revokedSTSTokenTable = new InMemoryTestTable<>(); + private Long revocationCutoffOffsetMs = null; + private boolean isOriginalAccessKeyIdRevoked = false; + private boolean shouldOriginalAccessKeyIdCheckThrowError = false; + private String requestAccessId = TEMP_ACCESS_KEY_ID; + private boolean includeAccessId = true; + private OMException.ResultCodes expectedResult = null; + private String expectedMessage = null; + + @SuppressWarnings("SameParameterValue") + TestConfig setMetadataManager(OMMetadataManager metadataManager) { + this.metadataManager = metadataManager; + return this; + } + + TestConfig setRevokedSTSTokenTable(Table table) { + this.revokedSTSTokenTable = table; + return this; + } + + TestConfig setRevocationCutoffOffsetMs(long offsetMs) { + this.revocationCutoffOffsetMs = offsetMs; + return this; + } + + @SuppressWarnings("SameParameterValue") + TestConfig setOriginalAccessKeyIdRevoked(boolean isRevoked) { + this.isOriginalAccessKeyIdRevoked = isRevoked; + return this; + } + + @SuppressWarnings("SameParameterValue") + TestConfig setShouldOriginalAccessKeyIdCheckThrowError(boolean isError) { + this.shouldOriginalAccessKeyIdCheckThrowError = isError; + return this; + } + + TestConfig setRequestAccessId(String requestAccessId) { + this.requestAccessId = requestAccessId; + return this; + } + + @SuppressWarnings("SameParameterValue") + TestConfig setIncludeAccessId(boolean includeAccessId) { + this.includeAccessId = includeAccessId; + return this; + } + + TestConfig setExpectedResult(OMException.ResultCodes result) { + this.expectedResult = result; + return this; + } + + TestConfig setExpectedMessage(String message) { + this.expectedMessage = message; + 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 new file mode 100644 index 000000000000..769ae0da5f1c --- /dev/null +++ b/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/security/TestSTSSecurityUtil.java @@ -0,0 +1,512 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.hadoop.ozone.security; + +import static org.apache.hadoop.ozone.om.exceptions.OMException.ResultCodes.INVALID_TOKEN; +import 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; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.UUID; +import org.apache.hadoop.hdds.security.exception.SCMSecurityException; +import org.apache.hadoop.hdds.security.symmetric.ManagedSecretKey; +import org.apache.hadoop.hdds.security.symmetric.SecretKeyClient; +import org.apache.hadoop.io.Text; +import org.apache.hadoop.ozone.om.exceptions.OMException; +import org.apache.hadoop.ozone.protocol.proto.OzoneManagerProtocolProtos.OMRequest; +import org.apache.hadoop.ozone.protocol.proto.OzoneManagerProtocolProtos.OMTokenProto; +import org.apache.hadoop.ozone.protocol.proto.OzoneManagerProtocolProtos.S3Authentication; +import org.apache.hadoop.ozone.protocol.proto.OzoneManagerProtocolProtos.Type; +import org.apache.hadoop.security.token.SecretManager; +import org.apache.hadoop.security.token.Token; +import org.apache.ozone.test.MockClock; +import org.junit.jupiter.api.Test; + +/** + * Unit tests for STSSecurityUtil. + */ +public class TestSTSSecurityUtil { + private static final String TEMP_ACCESS_KEY = "temp-access-key"; + private static final String ORIGINAL_ACCESS_KEY = "original-access-key"; + private static final String ROLE_ARN = "arn:aws:iam::123456789012:role/test-role"; + private static final String SECRET_ACCESS_KEY = "test-secret-access-key"; + private static final String SESSION_POLICY = "test-session-policy"; + private static final String ASSUMED_ROLE_ID = "AROATEST123456789:testsess"; + private static final String ASSUMED_ROLE_USER_ARN = + "arn:aws:sts::123456789012:assumed-role/test-role/testsess"; + private static final int DURATION_SECONDS = 3600; + private static final ManagedSecretKey MANAGED_SECRET_KEY = new SecretKeyTestClient().getCurrentSecretKey(); + private final SecretKeyTestClient secretKeyClient = new SecretKeyTestClient(); + private final STSTokenSecretManager tokenSecretManager = new STSTokenSecretManager(secretKeyClient); + private final UUID secretKeyId = secretKeyClient.getCurrentSecretKey().getId(); + private final MockClock clock = new MockClock(Instant.ofEpochMilli(1764819000), ZoneOffset.UTC); + + @Test + public void testConstructValidateAndDecryptSTSTokenInvalidProtobuf() throws IOException { + // Create a token whose identifier bytes are not a valid OMTokenProto + final Token token = new Token<>( + new byte[] {0x01, 0x02, 0x03}, new byte[] {0x04}, STSTokenIdentifier.KIND_NAME, + new Text(STSTokenIdentifier.STS_SERVICE)); + + final String tokenString = token.encodeToUrlString(); + + assertThatThrownBy(() -> + STSSecurityUtil.constructValidateAndDecryptSTSToken(tokenString, secretKeyClient, clock)) + .isInstanceOf(OMException.class) + .hasMessage( + "Invalid STS token format: Invalid STS token - could not parse protocol buffer: Protocol message " + + "contained an invalid tag (zero)."); + } + + @Test + public void testConstructValidateAndDecryptSTSTokenSuccess() throws IOException { + // Create a valid token + final String tokenString = createStsTokenString(); + + // Validate and decrypt the token + final STSTokenIdentifier result = STSSecurityUtil.constructValidateAndDecryptSTSToken( + tokenString, secretKeyClient, clock); + + // Verify the result + assertThat(result.getOwnerId()).isEqualTo(TEMP_ACCESS_KEY); + assertThat(result.getOriginalAccessKeyId()).isEqualTo(ORIGINAL_ACCESS_KEY); + assertThat(result.getRoleArn()).isEqualTo(ROLE_ARN); + assertThat(result.getSecretAccessKey()).isEqualTo(SECRET_ACCESS_KEY); + assertThat(result.getSessionPolicy()).isEqualTo(SESSION_POLICY); + assertThat(result.getCreationTime()).isEqualTo(clock.instant()); + assertThat(result.isExpired(clock.instant())).isFalse(); + final long expirationEpochMillis = result.getExpiry().toEpochMilli(); + assertThat(expirationEpochMillis).isEqualTo(clock.millis() + (DURATION_SECONDS * 1000)); + } + + @Test + public void testConstructValidateAndDecryptSTSTokenSuccessWithNullSessionPolicy() throws Exception { + // Create a valid token with null session policy + final String tokenString = createStsTokenString(DURATION_SECONDS, null); + + // Validate and decrypt the token + final STSTokenIdentifier result = STSSecurityUtil.constructValidateAndDecryptSTSToken( + tokenString, secretKeyClient, clock); + + // Verify the result + assertThat(result.getSessionPolicy()).isEmpty(); + } + + @Test + public void testConstructValidateAndDecryptSTSTokenInvalidFormat() { + // Try to decrypt an invalid token string + assertThatThrownBy(() -> + STSSecurityUtil.constructValidateAndDecryptSTSToken("invalid-token-format", secretKeyClient, clock)) + .isInstanceOf(OMException.class) + .hasMessageContaining("Invalid STS token format: Failed to decode STS token string"); + } + + @Test + public void 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 + final String validTokenString = createStsTokenString(); + + final Token validToken = new Token<>(); + validToken.decodeFromUrlString(validTokenString); + + // Create token with wrong kind + final Token token = new Token<>( + validToken.getIdentifier(), validToken.getPassword(), new Text("WRONG_KIND"), + new Text(STSTokenIdentifier.STS_SERVICE)); + + final String invalidTokenString = token.encodeToUrlString(); + + // Try to validate the token with wrong kind + assertThatThrownBy(() -> + STSSecurityUtil.constructValidateAndDecryptSTSToken(invalidTokenString, secretKeyClient, clock)) + .isInstanceOf(OMException.class) + .hasMessage("Invalid STS token format: Invalid STS token - kind is incorrect: WRONG_KIND"); + } + + @Test + public void testConstructValidateAndDecryptSTSTokenInvalidService() throws Exception { + // Create a token with incorrect service + final String validTokenString = createStsTokenString(); + + final Token validToken = new Token<>(); + validToken.decodeFromUrlString(validTokenString); + + final Token token = new Token<>( + validToken.getIdentifier(), validToken.getPassword(), validToken.getKind(), new Text("WRONG_SERVICE")); + + final String invalidTokenString = token.encodeToUrlString(); + + // Try to validate the token with wrong service + assertThatThrownBy(() -> + STSSecurityUtil.constructValidateAndDecryptSTSToken(invalidTokenString, secretKeyClient, clock)) + .isInstanceOf(OMException.class) + .hasMessage("Invalid STS token format: Invalid STS token - service is incorrect: WRONG_SERVICE"); + } + + @Test + public void testConstructValidateAndDecryptSTSTokenExpired() throws Exception { + // Create a token that expires immediately (durationSeconds of 0) + final String tokenString = createStsTokenString(0, SESSION_POLICY); + + // Fast-forward time to ensure token is expired + clock.fastForward(100); + + // Try to validate the expired token + assertThatThrownBy(() -> + STSSecurityUtil.constructValidateAndDecryptSTSToken(tokenString, secretKeyClient, clock)) + .isInstanceOf(OMException.class) + .satisfies(exception -> assertThat(((OMException) exception).getResult()).isEqualTo(TOKEN_EXPIRED)) + .hasMessageContaining("Invalid STS token - token expired at"); + } + + @Test + public void testConstructValidateAndDecryptSTSTokenSecretKeyNotFound() throws Exception { + // Create a valid token string + final String validTokenString = createStsTokenString(); + + // Create a mock secret key client that returns null for the key + final SecretKeyClient mockKeyClient = mock(SecretKeyClient.class); + when(mockKeyClient.getSecretKey(any())).thenReturn(null); + + // Try to validate the token when secret key is not found + assertThatThrownBy(() -> + STSSecurityUtil.constructValidateAndDecryptSTSToken(validTokenString, mockKeyClient, clock)) + .isInstanceOf(OMException.class) + .hasMessage( + "Invalid STS token format: Invalid STS token - could not readFromByteArray: Secret key not found for " + + "STS token secretKeyId: " + secretKeyId); + } + + @Test + public void testConstructValidateAndDecryptSTSTokenInvalidSecretKeyId() throws Exception { + // Create a valid identifier to use as base + final String validTokenString = createStsTokenString(); + + final Token validToken = new Token<>(); + validToken.decodeFromUrlString(validTokenString); + + // Rewrite the identifier with an invalid secretKeyId in the protobuf + final byte[] identifierBytes = validToken.getIdentifier(); + final OMTokenProto proto = OMTokenProto.parseFrom(identifierBytes); + + final OMTokenProto invalidProto = proto.toBuilder().setSecretKeyId("not-a-uuid").build(); + + final Token brokenToken = new Token<>( + invalidProto.toByteArray(), validToken.getPassword(), validToken.getKind(), validToken.getService()); + + final String invalidTokenString = brokenToken.encodeToUrlString(); + + assertThatThrownBy(() -> + STSSecurityUtil.constructValidateAndDecryptSTSToken(invalidTokenString, secretKeyClient, clock)) + .isInstanceOf(OMException.class) + .hasMessage("Invalid STS token format: Invalid STS token - secretKeyId was not valid: not-a-uuid"); + } + + @Test + public void testConstructValidateAndDecryptSTSTokenExpiredSecretKey() throws Exception { + // Create a valid token string + final String validTokenString = createStsTokenString(); + + // Create a mock secret key that is expired + final ManagedSecretKey expiredSecretKey = mock(ManagedSecretKey.class); + when(expiredSecretKey.isExpired()).thenReturn(true); + final Instant now = Instant.now(); + when(expiredSecretKey.getExpiryTime()).thenReturn(now); + + final SecretKeyClient mockKeyClient = mock(SecretKeyClient.class); + when(mockKeyClient.getSecretKey(any())).thenReturn(expiredSecretKey); + + // Try to validate the token with expired secret key + assertThatThrownBy(() -> + STSSecurityUtil.constructValidateAndDecryptSTSToken(validTokenString, mockKeyClient, clock)) + .isInstanceOf(OMException.class) + .satisfies(exception -> assertThat(((OMException) exception).getResult()).isEqualTo(TOKEN_EXPIRED)) + .hasMessage("Token cannot be verified due to expired secret key: " + secretKeyId + " Token expired at " + now); + } + + @Test + public void testConstructValidateAndDecryptSTSTokenSecretKeyRetrievalException() throws Exception { + // Create a valid token string + final String validTokenString = createStsTokenString(); + + // Create a mock secret key client that throws an exception + final SecretKeyClient mockKeyClient = mock(SecretKeyClient.class); + when(mockKeyClient.getSecretKey(any())).thenThrow(new SCMSecurityException("something went wrong")); + + // Try to validate the token when secret key retrieval fails + assertThatThrownBy(() -> + STSSecurityUtil.constructValidateAndDecryptSTSToken(validTokenString, mockKeyClient, clock)) + .isInstanceOf(OMException.class) + .hasMessage( + "Invalid STS token format: Invalid STS token - could not readFromByteArray: Failed to retrieve secret " + + "key: something went wrong"); + } + + @Test + public void testConstructValidateAndDecryptSTSTokenInvalidSignature() throws Exception { + // Create a valid token string + final String validTokenString = createStsTokenString(); + + final Token validToken = new Token<>(); + validToken.decodeFromUrlString(validTokenString); + + // Create a token with invalid signature (wrong password) + final Token invalidToken = new Token<>( + validToken.getIdentifier(), "wrong-signature".getBytes(StandardCharsets.UTF_8), validToken.getKind(), + validToken.getService()); + + final String invalidTokenString = invalidToken.encodeToUrlString(); + + // Try to validate the token with invalid signature + assertThatThrownBy(() -> + STSSecurityUtil.constructValidateAndDecryptSTSToken(invalidTokenString, secretKeyClient, clock)) + .isInstanceOf(OMException.class) + .hasMessageContaining("Invalid STS token format: Invalid STS token - signature is not correct for token"); + } + + @Test + public void testConstructValidateAndDecryptSTSTokenRejectsDoubledToken() throws Exception { + final String tokenString = createStsTokenString(); + + assertThatThrownBy(() -> + STSSecurityUtil.constructValidateAndDecryptSTSToken(tokenString + tokenString, secretKeyClient, clock)) + .isInstanceOf(OMException.class) + .satisfies(exception -> assertThat(((OMException) exception).getResult()).isEqualTo(INVALID_TOKEN)) + .hasMessageContaining("non-canonical token encoding"); + } + + @Test + public void testConstructValidateAndDecryptSTSTokenRejectsTokenWithSuffix() throws Exception { + final String tokenString = createStsTokenString(); + + assertThatThrownBy(() -> + STSSecurityUtil.constructValidateAndDecryptSTSToken(tokenString + "garbage", secretKeyClient, clock)) + .isInstanceOf(OMException.class) + .satisfies(exception -> assertThat(((OMException) exception).getResult()).isEqualTo(INVALID_TOKEN)) + .hasMessageContaining("non-canonical token encoding"); + } + + @Test + public void testConstructValidateAndDecryptSTSTokenEmptyString() { + // Try to decrypt an empty token string + assertThatThrownBy(() -> + STSSecurityUtil.constructValidateAndDecryptSTSToken("", secretKeyClient, clock)) + .isInstanceOf(OMException.class) + .hasMessage( + "Invalid STS token format: Failed to decode STS token string: java.io.EOFException"); + } + + @Test + public void testConstructValidateAndDecryptMultipleTokens() throws Exception { + // Create multiple tokens and validate them all + final String token1 = createStsTokenString(DURATION_SECONDS, "secret-key-1", "policy-1", + "temp-key-1", "orig-key-1", "role-arn-1"); + + final String token2 = createStsTokenString(DURATION_SECONDS, "secret-key-2", "policy-2", + "temp-key-2", "orig-key-2", "role-arn-2"); + + final STSTokenIdentifier result1 = STSSecurityUtil.constructValidateAndDecryptSTSToken( + token1, secretKeyClient, clock); + final STSTokenIdentifier result2 = STSSecurityUtil.constructValidateAndDecryptSTSToken( + token2, secretKeyClient, clock); + + assertThat(result1.getOwnerId()).isEqualTo("temp-key-1"); + assertThat(result1.getOriginalAccessKeyId()).isEqualTo("orig-key-1"); + assertThat(result2.getOwnerId()).isEqualTo("temp-key-2"); + assertThat(result2.getOriginalAccessKeyId()).isEqualTo("orig-key-2"); + } + + @Test + public void testEnsureEssentialFieldsArePresentInTokenMissingExpiry() { + final STSTokenIdentifier tokenIdentifier = new STSTokenIdentifier(paramsBuilder().setExpiry(null).build()); + + assertThatThrownBy(() -> STSSecurityUtil.ensureEssentialFieldsArePresentInToken(tokenIdentifier)) + .isInstanceOf(SecretManager.InvalidToken.class) + .hasMessage("Invalid STS token - expiry is null"); + } + + @Test + public void testEnsureEssentialFieldsArePresentInTokenMissingTempAccessKeyId() { + final STSTokenIdentifier tokenIdentifier = new STSTokenIdentifier(paramsBuilder().setTempAccessKeyId(null).build()); + + assertThatThrownBy(() -> STSSecurityUtil.ensureEssentialFieldsArePresentInToken(tokenIdentifier)) + .isInstanceOf(SecretManager.InvalidToken.class) + .hasMessage("Invalid STS token - tempAccessKeyId is null/empty"); + } + + @Test + public void testEnsureEssentialFieldsArePresentInTokenMissingRoleArn() { + final STSTokenIdentifier tokenIdentifier = new STSTokenIdentifier(paramsBuilder().setRoleArn(null).build()); + + assertThatThrownBy(() -> STSSecurityUtil.ensureEssentialFieldsArePresentInToken(tokenIdentifier)) + .isInstanceOf(SecretManager.InvalidToken.class) + .hasMessage("Invalid STS token - roleArn is null/empty"); + } + + @Test + public void testEnsureEssentialFieldsArePresentInTokenMissingOriginalAccessKeyId() { + final STSTokenIdentifier tokenIdentifier = new STSTokenIdentifier( + paramsBuilder().setOriginalAccessKeyId(null).build()); + + assertThatThrownBy(() -> STSSecurityUtil.ensureEssentialFieldsArePresentInToken(tokenIdentifier)) + .isInstanceOf(SecretManager.InvalidToken.class) + .hasMessage("Invalid STS token - originalAccessKeyId is null/empty"); + } + + @Test + public void testEnsureEssentialFieldsArePresentInTokenMissingSecretAccessKey() { + final STSTokenIdentifier tokenIdentifier = new STSTokenIdentifier(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 = createStsTokenString(); + + final S3Authentication s3Auth = S3Authentication.newBuilder() + .setSessionToken(tokenString) + .setResolvedStsSessionPolicy(SESSION_POLICY) + .setResolvedStsRoleArn(ROLE_ARN) + .setResolvedStsOriginalAccessKeyId(ORIGINAL_ACCESS_KEY) + .setResolvedStsTempAccessKeyId(TEMP_ACCESS_KEY) + .setResolvedStsSecretKeyId(secretKeyId.toString()) + .build(); + + final OMRequest request = OMRequest.newBuilder() + .setCmdType(Type.CreateBucket) + .setClientId("client-id") + .setS3Authentication(s3Auth) + .build(); + + STSSecurityUtil.ensureResolvedStsFieldsInvariants(request); + } + + @Test + public void testEnsureResolvedStsFieldsInvariantsMissingSessionToken() { + final S3Authentication s3Auth = S3Authentication.newBuilder() + .setResolvedStsSessionPolicy(SESSION_POLICY) + .build(); + + final OMRequest request = OMRequest.newBuilder() + .setCmdType(Type.CreateBucket) + .setClientId("client-id") + .setS3Authentication(s3Auth) + .build(); + + assertThatThrownBy(() -> STSSecurityUtil.ensureResolvedStsFieldsInvariants(request)) + .isInstanceOf(OMException.class) + .hasMessageContaining("Resolved STS fields must be empty when sessionToken is not present"); + } + + @Test + public void testEnsureResolvedStsFieldsInvariantsMissingResolvedFields() throws Exception { + final String tokenString = createStsTokenString(); + + final S3Authentication s3Auth = S3Authentication.newBuilder() + .setSessionToken(tokenString) + .build(); + + final OMRequest request = OMRequest.newBuilder() + .setCmdType(Type.CreateBucket) + .setClientId("client-id") + .setS3Authentication(s3Auth) + .build(); + + assertThatThrownBy(() -> STSSecurityUtil.ensureResolvedStsFieldsInvariants(request)) + .isInstanceOf(OMException.class) + .hasMessageContaining("Resolved STS fields must be present when sessionToken is present"); + } + + @Test + public void testEnsureResolvedStsFieldsInvariantsNoS3Auth() throws Exception { + final OMRequest request = OMRequest.newBuilder() + .setCmdType(Type.CreateBucket) + .setClientId("client-id") + .build(); + + // Should not throw + STSSecurityUtil.ensureResolvedStsFieldsInvariants(request); + } + + private String createStsTokenString() throws IOException { + return createStsTokenString(DURATION_SECONDS, SECRET_ACCESS_KEY, SESSION_POLICY, + TEMP_ACCESS_KEY, ORIGINAL_ACCESS_KEY, ROLE_ARN); + } + + private String createStsTokenString(int durationSeconds, String sessionPolicy) + throws IOException { + return createStsTokenString(durationSeconds, TestSTSSecurityUtil.SECRET_ACCESS_KEY, sessionPolicy, + TEMP_ACCESS_KEY, ORIGINAL_ACCESS_KEY, ROLE_ARN); + } + + private String createStsTokenString(int durationSeconds, String secretAccessKey, String sessionPolicy, + String tempAccessKey, String originalAccessKey, String roleArn) throws IOException { + return tokenSecretManager.createSTSTokenString(STSTokenSecretManager.CreateSTSTokenParams.newBuilder() + .setTempAccessKeyId(tempAccessKey) + .setOriginalAccessKeyId(originalAccessKey) + .setRoleArn(roleArn) + .setDurationSeconds(durationSeconds) + .setSecretAccessKey(secretAccessKey) + .setSessionPolicy(sessionPolicy) + .setAssumedRoleId(ASSUMED_ROLE_ID) + .setAssumedRoleUserArn(ASSUMED_ROLE_USER_ARN) + .setCreationTime(clock.instant()) + .build()); + } + + private STSTokenIdentifier.Params.Builder paramsBuilder() { + return STSTokenIdentifier.Params.newBuilder() + .setTempAccessKeyId(TEMP_ACCESS_KEY) + .setOriginalAccessKeyId(ORIGINAL_ACCESS_KEY) + .setRoleArn(ROLE_ARN) + .setCreationTime(clock.instant()) + .setExpiry(clock.instant().plusSeconds(DURATION_SECONDS)) + .setSecretAccessKey(SECRET_ACCESS_KEY) + .setSessionPolicy(SESSION_POLICY) + .setManagedSecretKey(MANAGED_SECRET_KEY); + } +} diff --git a/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/security/TestSTSTokenEncryption.java b/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/security/TestSTSTokenEncryption.java new file mode 100644 index 000000000000..268e672a38fb --- /dev/null +++ b/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/security/TestSTSTokenEncryption.java @@ -0,0 +1,215 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.hadoop.ozone.security; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.time.Instant; +import java.util.Base64; +import java.util.UUID; +import javax.crypto.KeyGenerator; +import javax.crypto.SecretKey; +import javax.crypto.spec.SecretKeySpec; +import org.apache.hadoop.hdds.security.symmetric.ManagedSecretKey; +import org.apache.hadoop.ozone.protocol.proto.OzoneManagerProtocolProtos; +import org.apache.hadoop.ozone.security.STSTokenEncryption.STSTokenEncryptionException; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +/** + * Test class for STS token encryption functionality. + */ +public class TestSTSTokenEncryption { + + // These must match the constants in STSTokenEncryption. + private static final int HKDF_SALT_LENGTH = 16; // 128 bits + + private static SecretKey sharedSecretKey; + private static ManagedSecretKey managedSecretKey; + + @BeforeAll + public static void setUpClass() { + final byte[] keyBytes = "01234567890123456789012345678901".getBytes(StandardCharsets.US_ASCII); + sharedSecretKey = new SecretKeySpec(keyBytes, "HmacSHA256"); + managedSecretKey = new ManagedSecretKey( + UUID.randomUUID(), + Instant.EPOCH, + Instant.EPOCH.plus(Duration.ofDays(1)), + sharedSecretKey); + } + + @Test + public void testEncryptDecryptRoundTrip() throws Exception { + final byte[] keyBytes = sharedSecretKey.getEncoded(); + + final String originalSecret = "mySecretAccessKey123456"; + final byte[] aad = "test-aad".getBytes(StandardCharsets.UTF_8); + + // Encrypt the secret + final String encrypted = STSTokenEncryption.encrypt(originalSecret, keyBytes, aad); + assertNotNull(encrypted); + assertNotEquals(originalSecret, encrypted); + + // Decrypt the secret + final String decrypted = STSTokenEncryption.decrypt(encrypted, keyBytes, aad); + assertEquals(originalSecret, decrypted); + } + + @Test + public void testSTSTokenIdentifierEncryption() throws Exception { + final String tempAccessKeyId = "ASIA123TEMPKEY"; + final String originalAccessKeyId = "AKIA123ORIGINAL"; + final String roleArn = "arn:aws:iam::123456789012:role/TestRole"; + final String secretAccessKey = "mySecretAccessKey123456"; + // Use millisecond precision to match serialization format + final Instant 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(STSTokenIdentifier.Params.newBuilder() + .setTempAccessKeyId(tempAccessKeyId) + .setOriginalAccessKeyId(originalAccessKeyId) + .setRoleArn(roleArn) + .setCreationTime(creationTime) + .setExpiry(expiry) + .setSecretAccessKey(secretAccessKey) + .setSessionPolicy(sessionPolicy) + .setManagedSecretKey(managedSecretKey) + .build()); + + // Convert to protobuf + final OzoneManagerProtocolProtos.OMTokenProto omTokenProto = tokenId.toProtoBuf(); + assertNotEquals(secretAccessKey, omTokenProto.getSecretAccessKey()); // ensure secretAccessKey is encrypted + final byte[] protobufBytes = omTokenProto.toByteArray(); + + // Create new token identifier from protobuf with decryption key + final STSTokenIdentifier decodedTokenId = new STSTokenIdentifier(); + decodedTokenId.setManagedSecretKey(managedSecretKey); + decodedTokenId.readFromByteArray(protobufBytes); + + // Verify all fields are correctly decrypted + assertEquals(tempAccessKeyId, decodedTokenId.getTempAccessKeyId()); + assertEquals(originalAccessKeyId, decodedTokenId.getOriginalAccessKeyId()); + assertEquals(roleArn, decodedTokenId.getRoleArn()); + assertEquals(secretAccessKey, decodedTokenId.getSecretAccessKey()); + assertEquals(expiry, decodedTokenId.getExpiry()); + assertEquals(creationTime, decodedTokenId.getCreationTime()); + } + + @Test + public void testDecryptionWithWrongKey() throws Exception { + // Generate two different keys + final KeyGenerator keyGen = KeyGenerator.getInstance("HmacSHA256"); + keyGen.init(256); + final SecretKey key1 = keyGen.generateKey(); + final SecretKey key2 = keyGen.generateKey(); + + final String originalSecret = "mySecretAccessKey123456"; + final byte[] aad = "key-aad".getBytes(StandardCharsets.UTF_8); + + // Encrypt with key1 + final String encrypted = STSTokenEncryption.encrypt(originalSecret, key1.getEncoded(), aad); + + // Try to decrypt with key2 - should fail + assertThrows( + STSTokenEncryptionException.class, () -> STSTokenEncryption.decrypt(encrypted, key2.getEncoded(), aad)); + } + + @Test + public void testDecryptionFailsWhenCiphertextIsCorrupted() throws Exception { + final byte[] keyBytes = sharedSecretKey.getEncoded(); + + final String originalSecret = "mySecretAccessKey123456"; + final byte[] aad = "ciphertext-aad".getBytes(StandardCharsets.UTF_8); + + // Encrypt the secret + final String encrypted = STSTokenEncryption.encrypt(originalSecret, keyBytes, aad); + final byte[] data = Base64.getDecoder().decode(encrypted); + + // Corrupt the last byte of the ciphertext segment + data[data.length - 1] ^= 0x01; + + final String tampered = Base64.getEncoder().encodeToString(data); + + // Decryption must fail with corrupted ciphertext + assertThrows( + STSTokenEncryptionException.class, + () -> STSTokenEncryption.decrypt(tampered, keyBytes, aad)); + } + + @Test + public void testDecryptionFailsWhenSaltIsCorrupted() throws Exception { + final byte[] keyBytes = sharedSecretKey.getEncoded(); + + final String originalSecret = "mySecretAccessKey123456"; + final byte[] aad = "salt-aad".getBytes(StandardCharsets.UTF_8); + + // Encrypt the secret + final String encrypted = STSTokenEncryption.encrypt(originalSecret, keyBytes, aad); + final byte[] data = Base64.getDecoder().decode(encrypted); + + // Corrupt the first byte of the salt segment + data[0] ^= 0x01; + + final String tampered = Base64.getEncoder().encodeToString(data); + + // Decryption must fail with corrupted salt (derives wrong AES key) + assertThrows(STSTokenEncryptionException.class, () -> STSTokenEncryption.decrypt(tampered, keyBytes, aad)); + } + + @Test + public void testDecryptionFailsWhenIvIsCorrupted() throws Exception { + final byte[] keyBytes = sharedSecretKey.getEncoded(); + + final String originalSecret = "mySecretAccessKey123456"; + final byte[] aad = "iv-aad".getBytes(StandardCharsets.UTF_8); + + // Encrypt the secret + final String encrypted = STSTokenEncryption.encrypt(originalSecret, keyBytes, aad); + final byte[] data = Base64.getDecoder().decode(encrypted); + + // Corrupt the first byte of the IV segment + data[HKDF_SALT_LENGTH] ^= 0x01; + + final String tampered = Base64.getEncoder().encodeToString(data); + + // Decryption must fail with corrupted IV + assertThrows(STSTokenEncryptionException.class, () -> STSTokenEncryption.decrypt(tampered, keyBytes, aad)); + } + + @Test + public void testDecryptionFailsWhenAadIsModified() throws Exception { + final byte[] keyBytes = sharedSecretKey.getEncoded(); + + final String originalSecret = "mySecretAccessKey123456"; + final byte[] aadOriginal = "aad-original".getBytes(StandardCharsets.UTF_8); + final byte[] aadModified = "aad-modified".getBytes(StandardCharsets.UTF_8); + + // Encrypt with original AAD + final String encrypted = STSTokenEncryption.encrypt(originalSecret, keyBytes, aadOriginal); + + // Decrypt with modified AAD - authentication must fail + assertThrows(STSTokenEncryptionException.class, () -> STSTokenEncryption.decrypt(encrypted, keyBytes, aadModified)); + } +} diff --git a/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/security/TestSTSTokenIdentifier.java b/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/security/TestSTSTokenIdentifier.java new file mode 100644 index 000000000000..ee2863e6259f --- /dev/null +++ b/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/security/TestSTSTokenIdentifier.java @@ -0,0 +1,633 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.hadoop.ozone.security; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.io.ByteArrayOutputStream; +import java.io.DataOutputStream; +import java.io.IOException; +import java.time.Duration; +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.UUID; +import java.util.concurrent.ThreadLocalRandom; +import javax.crypto.spec.SecretKeySpec; +import org.apache.hadoop.hdds.security.symmetric.ManagedSecretKey; +import org.apache.hadoop.ozone.protocol.proto.OzoneManagerProtocolProtos.OMTokenProto; +import org.junit.jupiter.api.Test; + +/** + * Unit tests for STSTokenIdentifier. + */ +public class TestSTSTokenIdentifier { + + private static final byte[] SECRET_KEY_BYTES = new byte[5]; + private static final ManagedSecretKey MANAGED_SECRET_KEY; + private static final Instant CREATION_TIME = Instant.ofEpochMilli(1_700_000_000_000L); + + static { + ThreadLocalRandom.current().nextBytes(SECRET_KEY_BYTES); + MANAGED_SECRET_KEY = createManagedSecretKey(SECRET_KEY_BYTES); + } + + @Test + public void testKindAndService() { + 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()); + } + + @Test + public void testProtoBufRoundTrip() throws IOException { + // STSTokenIdentifier persists expiry with millisecond precision (via toEpochMilli), + // so use a millisecond-precision Instant to avoid nanos-only differences across + // platforms/JDKs during round-trips. + final Instant expiry = Instant.now().plusSeconds(7200).truncatedTo(ChronoUnit.MILLIS); + final STSTokenIdentifier originalTokenIdentifier = new STSTokenIdentifier(paramsBuilder() + .setTempAccessKeyId("tempAccess") + .setOriginalAccessKeyId("origAccess") + .setRoleArn("arn:aws:iam::123456789012:role/RoleY") + .setExpiry(expiry) + .setSecretAccessKey("secretKey") + .setSessionPolicy("sessionPolicy") + .setManagedSecretKey(MANAGED_SECRET_KEY) + .setAssumedRoleId("AROATEST123456789:testsess") + .setAssumedRoleUserArn("arn:aws:sts::123456789012:assumed-role/RoleY/testsess") + .build()); + final UUID secretKeyId = MANAGED_SECRET_KEY.getId(); + + final OMTokenProto proto = originalTokenIdentifier.toProtoBuf(); + assertThat(proto.getType()).isEqualTo(OMTokenProto.Type.S3_STS_TOKEN); + assertThat(proto.getOwner()).isEqualTo("tempAccess"); + assertThat(proto.getMaxDate()).isEqualTo(expiry.toEpochMilli()); + assertThat(proto.getIssueDate()).isEqualTo(CREATION_TIME.toEpochMilli()); + assertThat(proto.getOriginalAccessKeyId()).isEqualTo("origAccess"); + assertThat(proto.getRoleArn()).isEqualTo("arn:aws:iam::123456789012:role/RoleY"); + assertThat(proto.getSecretAccessKey()).isNotEqualTo("secretKey"); // must be encrypted + assertThat(proto.getSessionPolicy()).isEqualTo("sessionPolicy"); + assertThat(proto.getAssumedRoleId()).isEqualTo("AROATEST123456789:testsess"); + assertThat(proto.getAssumedRoleUserArn()) + .isEqualTo("arn:aws:sts::123456789012:assumed-role/RoleY/testsess"); + assertThat(proto.getSecretKeyId()).isEqualTo(secretKeyId.toString()); + + final STSTokenIdentifier parsedTokenIdentifier = new STSTokenIdentifier(); + parsedTokenIdentifier.setManagedSecretKey(MANAGED_SECRET_KEY); + parsedTokenIdentifier.fromProtoBuf(proto); + + assertThat(parsedTokenIdentifier.getOwnerId()).isEqualTo("tempAccess"); + assertThat(parsedTokenIdentifier.getExpiry()).isEqualTo(expiry); + assertThat(parsedTokenIdentifier.getCreationTime()).isEqualTo(CREATION_TIME); + assertThat(parsedTokenIdentifier.getOriginalAccessKeyId()).isEqualTo("origAccess"); + assertThat(parsedTokenIdentifier.getRoleArn()).isEqualTo("arn:aws:iam::123456789012:role/RoleY"); + assertThat(parsedTokenIdentifier.getSecretAccessKey()).isEqualTo("secretKey"); + assertThat(parsedTokenIdentifier.getSecretKeyId()).isEqualTo(secretKeyId); + assertThat(parsedTokenIdentifier.getSessionPolicy()).isEqualTo("sessionPolicy"); + assertThat(parsedTokenIdentifier.getAssumedRoleId()).isEqualTo("AROATEST123456789:testsess"); + assertThat(parsedTokenIdentifier.getAssumedRoleUserArn()) + .isEqualTo("arn:aws:sts::123456789012:assumed-role/RoleY/testsess"); + assertThat(parsedTokenIdentifier).isEqualTo(originalTokenIdentifier); + assertThat(parsedTokenIdentifier.hashCode()).isEqualTo(originalTokenIdentifier.hashCode()); + } + + @Test + public void testFromProtoBufInvalidSecretKeyId() { + final OMTokenProto invalid = OMTokenProto.newBuilder() + .setType(OMTokenProto.Type.S3_STS_TOKEN) + .setOwner("tempAccessKeyId") + .setMaxDate(Instant.now().toEpochMilli()) + .setSecretKeyId("not-a-uuid") + .build(); + + final STSTokenIdentifier stsTokenIdentifier = new STSTokenIdentifier(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"); + } + + @Test + public void testProtobufRoundTripWithNullSessionPolicy() throws IOException { + final Instant expiry = Instant.now().plusSeconds(7200); + final STSTokenIdentifier stsTokenIdentifier = new STSTokenIdentifier(paramsBuilder() + .setTempAccessKeyId("tempAccess") + .setOriginalAccessKeyId("origAccess") + .setRoleArn("arn:aws:iam::123456789012:role/RoleX") + .setExpiry(expiry) + .setSecretAccessKey("secretKey") + .setManagedSecretKey(MANAGED_SECRET_KEY) + .build()); + + final OMTokenProto proto = stsTokenIdentifier.toProtoBuf(); + assertThat(proto.getSessionPolicy()).isEmpty(); + + final STSTokenIdentifier parsedTokenIdentifier = new STSTokenIdentifier(); + parsedTokenIdentifier.setManagedSecretKey(MANAGED_SECRET_KEY); + parsedTokenIdentifier.fromProtoBuf(proto); + + assertThat(parsedTokenIdentifier.getSessionPolicy()).isEmpty(); + } + + @Test + public void testProtobufRoundTripWithEmptySessionPolicy() throws IOException { + final Instant expiry = Instant.now().plusSeconds(4000); + final STSTokenIdentifier stsTokenIdentifier = new STSTokenIdentifier(paramsBuilder() + .setTempAccessKeyId("tempAccess") + .setOriginalAccessKeyId("origAccess") + .setRoleArn("arn:aws:iam::123456789012:role/RoleZ") + .setExpiry(expiry) + .setSecretAccessKey("secretKey") + .setSessionPolicy("") + .setManagedSecretKey(MANAGED_SECRET_KEY) + .build()); + + final OMTokenProto proto = stsTokenIdentifier.toProtoBuf(); + assertThat(proto.getSessionPolicy()).isEmpty(); + + final STSTokenIdentifier parsedTokenIdentifier = new STSTokenIdentifier(); + parsedTokenIdentifier.setManagedSecretKey(MANAGED_SECRET_KEY); + parsedTokenIdentifier.fromProtoBuf(proto); + + assertThat(parsedTokenIdentifier.getSessionPolicy()).isEmpty(); + } + + @Test + public void testFromProtoBufInvalidTokenType() { + final OMTokenProto invalidType = OMTokenProto.newBuilder() + .setType(OMTokenProto.Type.DELEGATION_TOKEN) // Wrong type + .setOwner("tempAccessKeyId") + .setMaxDate(Instant.now().toEpochMilli()) + .build(); + + final STSTokenIdentifier stsTokenIdentifier = new STSTokenIdentifier(paramsBuilder() + .setTempAccessKeyId("tempAccessKeyId") + .setOriginalAccessKeyId("origAccessKeyId") + .setRoleArn("roleArn") + .setExpiry(Instant.now()) + .setSecretAccessKey("secretAccessKey") + .setSessionPolicy("sessionPolicy") + .build()); + + final IllegalArgumentException ex = assertThrows( + IllegalArgumentException.class, () -> stsTokenIdentifier.fromProtoBuf(invalidType)); + assertThat(ex.getMessage()).isEqualTo("Invalid token type for STSTokenIdentifier: DELEGATION_TOKEN"); + } + + @Test + public void testWriteToAndReadFromByteArray() throws Exception { + // Use millisecond-precision Instant so that the value survives the + // toEpochMilli() / Instant.ofEpochMilli() round-trip without losing precision + // compared to the original object, which is compared using equals(). + final Instant expiry = + Instant.now().plusSeconds(1000).truncatedTo(ChronoUnit.MILLIS); + final STSTokenIdentifier originalTokenIdentifier = new STSTokenIdentifier(paramsBuilder() + .setTempAccessKeyId("tempAccessKeyId") + .setOriginalAccessKeyId("originalAccessKeyId") + .setRoleArn("roleArn") + .setExpiry(expiry) + .setSecretAccessKey("secretAccessKey") + .setSessionPolicy("sessionPolicy") + .setManagedSecretKey(MANAGED_SECRET_KEY) + .setAssumedRoleId("AROATEST123456789:testsess") + .setAssumedRoleUserArn("arn:aws:sts::123456789012:assumed-role/test-role/testsess") + .build()); + + final ByteArrayOutputStream baos = new ByteArrayOutputStream(); + try (DataOutputStream out = new DataOutputStream(baos)) { + originalTokenIdentifier.write(out); + } + + final byte[] bytes = baos.toByteArray(); + final STSTokenIdentifier parsedTokenIdentifier = new STSTokenIdentifier(); + parsedTokenIdentifier.setManagedSecretKey(MANAGED_SECRET_KEY); + parsedTokenIdentifier.readFromByteArray(bytes); + + assertThat(parsedTokenIdentifier).isEqualTo(originalTokenIdentifier); + } + + @Test + public void testWriteToAndReadFromByteArrayWithDifferentSecretKeys() throws Exception { + final Instant expiry = Instant.now().plusSeconds(1500); + final STSTokenIdentifier originalTokenIdentifier = new STSTokenIdentifier(paramsBuilder() + .setTempAccessKeyId("tempAccessKeyId") + .setOriginalAccessKeyId("originalAccessKeyId") + .setRoleArn("roleArn") + .setExpiry(expiry) + .setSecretAccessKey("secretAccessKey") + .setSessionPolicy("sessionPolicy") + .setManagedSecretKey(MANAGED_SECRET_KEY) + .build()); + + final ByteArrayOutputStream baos1 = new ByteArrayOutputStream(); + try (DataOutputStream out = new DataOutputStream(baos1)) { + originalTokenIdentifier.write(out); + } + + byte[] rawBytes = new byte[5]; + ManagedSecretKey managedSecretKey2 = createManagedSecretKey(rawBytes); + final STSTokenIdentifier anotherTokenIdentifier = new STSTokenIdentifier(paramsBuilder() + .setTempAccessKeyId("tempAccessKeyId") + .setOriginalAccessKeyId("originalAccessKeyId") + .setRoleArn("roleArn") + .setExpiry(expiry) + .setSecretAccessKey("secretAccessKey") + .setSessionPolicy("sessionPolicy") + .setManagedSecretKey(managedSecretKey2) + .build()); + + final ByteArrayOutputStream baos2 = new ByteArrayOutputStream(); + try (DataOutputStream out = new DataOutputStream(baos2)) { + anotherTokenIdentifier.write(out); + } + + // The byte arrays will not be the same because the encrypted secretAccessKey cipher for each will differ. + // However, the STSTokenIdentifier derived from each byte array should also not be the same. + assertThat(baos1.toByteArray()).isNotEqualTo(baos2.toByteArray()); + final byte[] byteArr1 = baos1.toByteArray(); + final byte[] byteArr2 = baos2.toByteArray(); + assertThat(byteArr1).isNotEqualTo(byteArr2); + final STSTokenIdentifier tokenFromByteArr1 = new STSTokenIdentifier(); + tokenFromByteArr1.setManagedSecretKey(MANAGED_SECRET_KEY); + tokenFromByteArr1.readFromByteArray(byteArr1); + final STSTokenIdentifier tokenFromByteArr2 = new STSTokenIdentifier(); + tokenFromByteArr2.setManagedSecretKey(managedSecretKey2); + tokenFromByteArr2.readFromByteArray(byteArr2); + assertThat(tokenFromByteArr1).isNotEqualTo(tokenFromByteArr2); + } + + @Test + public void testWriteToAndReadFromByteArrayWithSameSecretKeyIds() throws Exception { + final Instant expiry = Instant.now().plusSeconds(1700); + + final STSTokenIdentifier originalTokenIdentifier = new STSTokenIdentifier(paramsBuilder() + .setTempAccessKeyId("tempAccessKeyId") + .setOriginalAccessKeyId("originalAccessKeyId") + .setRoleArn("roleArn") + .setExpiry(expiry) + .setSecretAccessKey("secretAccessKey") + .setSessionPolicy("sessionPolicy") + .setManagedSecretKey(MANAGED_SECRET_KEY) + .build()); + + final ByteArrayOutputStream baos1 = new ByteArrayOutputStream(); + try (DataOutputStream out = new DataOutputStream(baos1)) { + originalTokenIdentifier.write(out); + } + + final STSTokenIdentifier anotherTokenIdentifier = new STSTokenIdentifier(paramsBuilder() + .setTempAccessKeyId("tempAccessKeyId") + .setOriginalAccessKeyId("originalAccessKeyId") + .setRoleArn("roleArn") + .setExpiry(expiry) + .setSecretAccessKey("secretAccessKey") + .setSessionPolicy("sessionPolicy") + .setManagedSecretKey(MANAGED_SECRET_KEY) + .build()); + + final ByteArrayOutputStream baos2 = new ByteArrayOutputStream(); + try (DataOutputStream out = new DataOutputStream(baos2)) { + anotherTokenIdentifier.write(out); + } + + // The byte arrays should not be the same because the encrypted secretAccessKey cipher for each will differ. + // However, the STSTokenIdentifier derived from each byte array should be the same. + final byte[] byteArr1 = baos1.toByteArray(); + final byte[] byteArr2 = baos2.toByteArray(); + assertThat(byteArr1).isNotEqualTo(byteArr2); + final STSTokenIdentifier tokenFromByteArr1 = new STSTokenIdentifier(); + tokenFromByteArr1.setManagedSecretKey(MANAGED_SECRET_KEY); + tokenFromByteArr1.readFromByteArray(byteArr1); + final STSTokenIdentifier tokenFromByteArr2 = new STSTokenIdentifier(); + tokenFromByteArr2.setManagedSecretKey(MANAGED_SECRET_KEY); + tokenFromByteArr2.readFromByteArray(byteArr2); + assertThat(tokenFromByteArr1).isEqualTo(tokenFromByteArr2); + } + + @Test + public void testGettersReturnCorrectValues() { + final Instant expiry = Instant.now().plusSeconds(3600); + final String tempAccessKeyId = "ASIATEMP123456"; + final String originalAccessKeyId = "AKIAORIGINAL123"; + final String roleArn = "arn:aws:iam::123456789012:role/MyRole"; + final String secretAccessKey = "mySecretKey"; + final String sessionPolicy = "myPolicy"; + + final STSTokenIdentifier stsTokenIdentifier = new STSTokenIdentifier(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); + } + + @Test + public void testEqualsAndHashCode() { + final Instant expiry = Instant.now().plusSeconds(3600); + final UUID uuid = UUID.randomUUID(); + + 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(paramsBuilder() + .setTempAccessKeyId("tempAccessKeyId") + .setOriginalAccessKeyId("originalAccessKeyId") + .setRoleArn("roleArn") + .setExpiry(expiry) + .setSecretAccessKey("secretAccessKey") + .setSessionPolicy("sessionPolicy") + .build()); + stsTokenIdentifier2.setSecretKeyId(uuid); + + assertThat(stsTokenIdentifier).isEqualTo(stsTokenIdentifier2); + assertThat(stsTokenIdentifier.hashCode()).isEqualTo(stsTokenIdentifier2.hashCode()); + } + + @Test + public void testNotEqualsWhenTempAccessKeyIdDiffers() { + final Instant expiry = Instant.now().plusSeconds(3600); + + final STSTokenIdentifier stsTokenIdentifier = new STSTokenIdentifier(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); + } + + @Test + public void testNotEqualsWhenOriginalAccessKeyIdDiffers() { + final Instant expiry = Instant.now().plusSeconds(3600); + + 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); + } + + @Test + public void testNotEqualsWhenRoleArnDiffers() { + final Instant expiry = Instant.now().plusSeconds(3600); + + 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(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); + } + + @Test + public void testNotEqualsWhenSecretAccessKeyDiffers() { + final Instant expiry = Instant.now().plusSeconds(3600); + + 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); + } + + @Test + public void testNotEqualsWhenSessionPolicyDiffers() { + final Instant expiry = Instant.now().plusSeconds(3600); + + 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); + } + + @Test + public void testToString() { + final Instant expiry = Instant.now().plusSeconds(3600); + final UUID uuid = UUID.randomUUID(); + + final STSTokenIdentifier stsTokenIdentifier = new STSTokenIdentifier(paramsBuilder() + .setTempAccessKeyId("tempAccessKeyId") + .setOriginalAccessKeyId("originalAccessKeyId") + .setRoleArn("roleArn") + .setExpiry(expiry) + .setSecretAccessKey("secretAccessKey") + .setSessionPolicy("sessionPolicy") + .setAssumedRoleId("AROATEST123456789:testsess") + .setAssumedRoleUserArn("arn:aws:sts::123456789012:assumed-role/test-role/testsess") + .build()); + stsTokenIdentifier.setSecretKeyId(uuid); + + final String stsTokenIdentifierStr = stsTokenIdentifier.toString(); + final String expectedString = "STSTokenIdentifier{" + "tempAccessKeyId='tempAccessKeyId'" + + ", originalAccessKeyId='originalAccessKeyId'" + ", roleArn='roleArn'" + + ", assumedRoleId='AROATEST123456789:testsess'" + + ", assumedRoleUserArn='arn:aws:sts::123456789012:assumed-role/test-role/testsess'" + + ", creationTime='" + CREATION_TIME + "', expiry='" + expiry + + "', secretKeyId='" + uuid + "', sessionPolicy='sessionPolicy'" + '}'; + + assertEquals(expectedString, stsTokenIdentifierStr); + } + + @Test + public void testNotEqualsWithNull() { + final STSTokenIdentifier stsTokenIdentifier = new STSTokenIdentifier(paramsBuilder() + .setTempAccessKeyId("tempAccessKeyId") + .setOriginalAccessKeyId("originalAccessKeyId") + .setRoleArn("roleArn") + .setExpiry(Instant.now()) + .setSecretAccessKey("secretAccessKey") + .setSessionPolicy("sessionPolicy") + .build()); + + assertThat(stsTokenIdentifier).isNotEqualTo(null); + } + + @Test + public void testEqualsWithDifferentManagedSecretKeys() { + final Instant expiry = Instant.now().plusSeconds(3600).truncatedTo(ChronoUnit.MILLIS); + final UUID uuid = UUID.randomUUID(); + + // Create first identifier with the default key + final STSTokenIdentifier stsTokenIdentifier = new STSTokenIdentifier(paramsBuilder() + .setTempAccessKeyId("tempAccessKeyId") + .setOriginalAccessKeyId("originalAccessKeyId") + .setRoleArn("roleArn") + .setExpiry(expiry) + .setSecretAccessKey("secretAccessKey") + .setSessionPolicy("sessionPolicy") + .build()); + stsTokenIdentifier.setSecretKeyId(uuid); + + // Create second identifier with a different ManagedSecretKey but otherwise same parameters + byte[] differentKeyBytes = new byte[5]; + ThreadLocalRandom.current().nextBytes(differentKeyBytes); + + final STSTokenIdentifier stsTokenIdentifier2 = new STSTokenIdentifier(paramsBuilder() + .setTempAccessKeyId("tempAccessKeyId") + .setOriginalAccessKeyId("originalAccessKeyId") + .setRoleArn("roleArn") + .setExpiry(expiry) + .setSecretAccessKey("secretAccessKey") + .setSessionPolicy("sessionPolicy") + .setManagedSecretKey(createManagedSecretKey(differentKeyBytes)) + .build()); + stsTokenIdentifier2.setSecretKeyId(uuid); + + // They should still be equal because managedSecretKey is transient/ignored for identity + assertThat(stsTokenIdentifier).isEqualTo(stsTokenIdentifier2); + assertThat(stsTokenIdentifier.hashCode()).isEqualTo(stsTokenIdentifier2.hashCode()); + } + + private static ManagedSecretKey createManagedSecretKey(byte[] keyBytes) { + return new ManagedSecretKey( + UUID.randomUUID(), + CREATION_TIME, + CREATION_TIME.plus(Duration.ofDays(1)), + new SecretKeySpec(keyBytes, "HmacSHA256")); + } + + private static STSTokenIdentifier.Params.Builder paramsBuilder() { + return STSTokenIdentifier.Params.newBuilder() + .setCreationTime(CREATION_TIME) + .setManagedSecretKey(MANAGED_SECRET_KEY); + } +} diff --git a/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/security/TestSTSTokenSecretManager.java b/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/security/TestSTSTokenSecretManager.java new file mode 100644 index 000000000000..022dab455080 --- /dev/null +++ b/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/security/TestSTSTokenSecretManager.java @@ -0,0 +1,213 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.hadoop.ozone.security; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.HashMap; +import java.util.Map; +import java.util.UUID; +import javax.crypto.SecretKey; +import javax.crypto.spec.SecretKeySpec; +import org.apache.hadoop.hdds.security.symmetric.ManagedSecretKey; +import org.apache.hadoop.hdds.security.symmetric.SecretKeyClient; +import org.apache.hadoop.hdds.security.symmetric.SecretKeySignerClient; +import org.apache.hadoop.io.Text; +import org.apache.hadoop.security.token.Token; +import org.apache.ozone.test.MockClock; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +/** + * Test for STSTokenSecretManager. + */ +public class TestSTSTokenSecretManager { + private STSTokenSecretManager secretManager; + private MockClock clock; + + private static final String TEMP_ACCESS_KEY = "temp-access-key"; + private static final String ORIGINAL_ACCESS_KEY = "original-access-key"; + private static final String ROLE_ARN = "arn:aws:iam::123456789012:role/test-role"; + private static final String SECRET_ACCESS_KEY = "test-secret-access-key"; + private static final String SESSION_POLICY = "test-session-policy"; + private static final String ASSUMED_ROLE_ID = "AROATEST123456789:testsess"; + private static final String ASSUMED_ROLE_USER_ARN = "arn:aws:sts::123456789012:assumed-role/test-role/testsess"; + private static final int DURATION_SECONDS = 3600; + + private static SecretKey sharedSecretKey; + + @BeforeAll + public static void setUpClass() { + final byte[] keyBytes = "01234567890123456789012345678901".getBytes(StandardCharsets.US_ASCII); + sharedSecretKey = new SecretKeySpec(keyBytes, "HmacSHA256"); + } + + @BeforeEach + public void setUp() throws Exception { + final SecretKeySignerClient mockSecretKeyClient = mock(SecretKeySignerClient.class); + final ManagedSecretKey mockSecretKey = mock(ManagedSecretKey.class); + + final UUID keyId = UUID.fromString("00000000-0000-0000-0000-000000000000"); + when(mockSecretKey.getId()).thenReturn(keyId); + when(mockSecretKey.getSecretKey()).thenReturn(sharedSecretKey); + when(mockSecretKey.sign(any(byte[].class))) + .thenReturn("mock-signature".getBytes(StandardCharsets.UTF_8)); + when(mockSecretKeyClient.getCurrentSecretKey()).thenReturn(mockSecretKey); + + secretManager = new STSTokenSecretManager(mockSecretKeyClient); + clock = new MockClock(Instant.ofEpochMilli(1764819000), ZoneOffset.UTC); + } + + @Test + public void testCreateSTSTokenStringContainsCorrectFields() throws IOException { + final String tokenString = secretManager.createSTSTokenString(createStsTokenParamsBuilder().build()); + + // Decode the token + final Token token = new Token<>(); + token.decodeFromUrlString(tokenString); + + // Verify the token identifier fields + final STSTokenIdentifier identifier = new STSTokenIdentifier(); + identifier.setManagedSecretKey(createManagedSecretKey( + UUID.fromString("00000000-0000-0000-0000-000000000000"), + sharedSecretKey.getEncoded(), Instant.now())); + identifier.readFromByteArray(token.getIdentifier()); + final Instant expiration = identifier.getExpiry(); + + assertEquals(TEMP_ACCESS_KEY, identifier.getTempAccessKeyId()); + assertEquals(ORIGINAL_ACCESS_KEY, identifier.getOriginalAccessKeyId()); + assertEquals(ROLE_ARN, identifier.getRoleArn()); + assertEquals(SECRET_ACCESS_KEY, identifier.getSecretAccessKey()); + assertEquals(SESSION_POLICY, identifier.getSessionPolicy()); + assertEquals(ASSUMED_ROLE_ID, identifier.getAssumedRoleId()); + assertEquals(ASSUMED_ROLE_USER_ARN, identifier.getAssumedRoleUserArn()); + assertEquals(clock.instant(), identifier.getCreationTime()); + assertNotNull(identifier.getSecretKeyId()); + assertEquals(new Text("STSToken"), identifier.getKind()); + assertEquals("STS", identifier.getService()); + assertEquals(clock.millis() + (DURATION_SECONDS * 1000), expiration.toEpochMilli()); + } + + @Test + public void testCreateSTSTokenStringWithNullSessionPolicy() throws IOException { + final String tokenString = secretManager.createSTSTokenString( + createStsTokenParamsBuilder().setSessionPolicy(null).build()); + + // Decode the token + final Token token = new Token<>(); + token.decodeFromUrlString(tokenString); + + final STSTokenIdentifier identifier = new STSTokenIdentifier(); + identifier.setManagedSecretKey(createManagedSecretKey( + UUID.fromString("00000000-0000-0000-0000-000000000000"), + sharedSecretKey.getEncoded(), Instant.now())); + identifier.readFromByteArray(token.getIdentifier()); + assertTrue(identifier.getSessionPolicy().isEmpty()); + } + + /** + * createSTSTokenString() must use a single getCurrentSecretKey() for encryption, secretKeyId, and signing. If a + * second fetch happened during signing, a key rotation between calls would encrypt with the old key but stamp the + * token with the new key id. + */ + @Test + public void testCreateSTSTokenStringValidatesWhenSecretKeyRotatesDuringCreation() throws Exception { + // ManagedSecretKey.isExpired() uses Instant.now(), not the test clock. + final Instant keyCreationTime = Instant.now(); + final ManagedSecretKey encryptionKey = createManagedSecretKey( + UUID.fromString("11111111-1111-1111-1111-111111111111"), + "encryption-key-material-012345678901".getBytes(StandardCharsets.US_ASCII), + keyCreationTime); + final ManagedSecretKey signingKey = createManagedSecretKey( + UUID.fromString("22222222-2222-2222-2222-222222222222"), + "signing-key-material-01234567890123".getBytes(StandardCharsets.US_ASCII), + keyCreationTime); + + final RotatingSecretKeyTestClient rotatingSecretKeyClient = new RotatingSecretKeyTestClient( + encryptionKey, signingKey); + final STSTokenSecretManager rotatingSecretManager = new STSTokenSecretManager(rotatingSecretKeyClient); + + final String tokenString = rotatingSecretManager.createSTSTokenString(createStsTokenParamsBuilder().build()); + + final STSTokenIdentifier result = STSSecurityUtil.constructValidateAndDecryptSTSToken( + tokenString, rotatingSecretKeyClient, clock); + assertEquals(SECRET_ACCESS_KEY, result.getSecretAccessKey()); + assertEquals(encryptionKey.getId(), result.getSecretKeyId()); + assertEquals(1, rotatingSecretKeyClient.getCurrentSecretKeyCallCount()); + } + + private STSTokenSecretManager.CreateSTSTokenParams.Builder createStsTokenParamsBuilder() { + return STSTokenSecretManager.CreateSTSTokenParams.newBuilder() + .setTempAccessKeyId(TEMP_ACCESS_KEY) + .setOriginalAccessKeyId(ORIGINAL_ACCESS_KEY) + .setRoleArn(ROLE_ARN) + .setDurationSeconds(DURATION_SECONDS) + .setSecretAccessKey(SECRET_ACCESS_KEY) + .setSessionPolicy(SESSION_POLICY) + .setAssumedRoleId(ASSUMED_ROLE_ID) + .setAssumedRoleUserArn(ASSUMED_ROLE_USER_ARN) + .setCreationTime(clock.instant()); + } + + private static ManagedSecretKey createManagedSecretKey(UUID id, byte[] keyBytes, Instant creationTime) { + final SecretKey secretKey = new SecretKeySpec(keyBytes, "HmacSHA256"); + return new ManagedSecretKey(id, creationTime, creationTime.plus(Duration.ofHours(1)), secretKey); + } + + /** + * Returns different current keys on consecutive getCurrentSecretKey() calls to simulate rotation. + */ + private static final class RotatingSecretKeyTestClient implements SecretKeyClient { + private final ManagedSecretKey firstKey; + private final ManagedSecretKey secondKey; + private final Map keysById = new HashMap<>(); + private int getCurrentSecretKeyCallCount; + + private RotatingSecretKeyTestClient(ManagedSecretKey firstKey, ManagedSecretKey secondKey) { + this.firstKey = firstKey; + this.secondKey = secondKey; + keysById.put(firstKey.getId(), firstKey); + keysById.put(secondKey.getId(), secondKey); + } + + @Override + public synchronized ManagedSecretKey getCurrentSecretKey() { + return getCurrentSecretKeyCallCount++ == 0 ? firstKey : secondKey; + } + + @Override + public ManagedSecretKey getSecretKey(UUID id) { + return keysById.get(id); + } + + private int getCurrentSecretKeyCallCount() { + return getCurrentSecretKeyCallCount; + } + } +} diff --git a/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/security/acl/package-info.java b/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/security/acl/package-info.java new file mode 100644 index 000000000000..7feb73bd2e67 --- /dev/null +++ b/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/security/acl/package-info.java @@ -0,0 +1,21 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * Ozone security acl tests. + */ +package org.apache.hadoop.ozone.security.acl; diff --git a/hadoop-ozone/s3gateway/pom.xml b/hadoop-ozone/s3gateway/pom.xml index eb3f38023b98..7a27e1d4b53f 100644 --- a/hadoop-ozone/s3gateway/pom.xml +++ b/hadoop-ozone/s3gateway/pom.xml @@ -30,6 +30,10 @@ + + com.fasterxml.jackson.core + jackson-annotations + com.fasterxml.jackson.core jackson-databind diff --git a/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/audit/S3GAction.java b/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/audit/S3GAction.java index 16f5ceb1e4bd..f215cd6cb0ff 100644 --- a/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/audit/S3GAction.java +++ b/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/audit/S3GAction.java @@ -63,8 +63,13 @@ public enum S3GAction implements AuditAction { PUT_BUCKET_TAGGING, DELETE_BUCKET_TAGGING, PUT_OBJECT_ACL, - GET_OBJECT_ATTRIBUTES; + // STS endpoint + ASSUME_ROLE, + GET_CALLER_IDENTITY, + + GET_OBJECT_ATTRIBUTES; + @Override public String getAction() { return this.toString(); diff --git a/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/Gateway.java b/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/Gateway.java index 78b041c839dd..c3788048d0af 100644 --- a/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/Gateway.java +++ b/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/Gateway.java @@ -63,6 +63,7 @@ public class Gateway extends GenericCli implements Callable { private S3GatewayHttpServer httpServer; /** Servlets and static content on separate port. */ private BaseHttpServer contentServer; + private BaseHttpServer stsServer; private S3GatewayMetrics metrics; private NettyMetrics nettyMetrics; @@ -91,6 +92,7 @@ public Void call() throws Exception { setHttpBaseDir(OzoneConfigurationHolder.configuration()); httpServer = new S3GatewayHttpServer(OzoneConfigurationHolder.configuration(), "s3gateway"); contentServer = new S3GatewayWebAdminServer(OzoneConfigurationHolder.configuration(), "s3g-web"); + stsServer = new S3STSHttpServer(OzoneConfigurationHolder.configuration(), "s3g-sts"); metrics = S3GatewayMetrics.create(OzoneConfigurationHolder.configuration()); nettyMetrics = NettyMetrics.create(); start(); @@ -116,11 +118,12 @@ public void start() throws IOException { jvmPauseMonitor.start(); httpServer.start(); contentServer.start(); + stsServer.start(); } public void stop() throws Exception { LOG.info("Stopping Ozone S3 gateway"); - IOUtils.closeQuietly(httpServer, contentServer); + IOUtils.closeQuietly(httpServer, contentServer, stsServer); jvmPauseMonitor.stop(); S3GatewayMetrics.unRegister(); if (nettyMetrics != null) { diff --git a/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/S3STSHeadersResponseFilter.java b/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/S3STSHeadersResponseFilter.java new file mode 100644 index 000000000000..675f3139a299 --- /dev/null +++ b/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/S3STSHeadersResponseFilter.java @@ -0,0 +1,49 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.hadoop.ozone.s3; + +import java.io.IOException; +import javax.inject.Inject; +import javax.ws.rs.container.ContainerRequestContext; +import javax.ws.rs.container.ContainerResponseContext; +import javax.ws.rs.container.ContainerResponseFilter; +import javax.ws.rs.ext.Provider; +import org.apache.hadoop.ozone.s3sts.S3STSEnabled; + +/** + * This class adds common header responses for STS requests. + */ +@Provider +@S3STSEnabled +public class S3STSHeadersResponseFilter implements ContainerResponseFilter { + + @Inject + private RequestIdentifier requestIdentifier; + + @Override + public void filter(ContainerRequestContext containerRequestContext, + ContainerResponseContext containerResponseContext) throws IOException { + + // Add STS-specific headers + containerResponseContext.getHeaders().add("Server", "Ozone"); + containerResponseContext.getHeaders() + .add("X-Amz-Sts-Extended-Request-Id", requestIdentifier.getAmzId()); + containerResponseContext.getHeaders() + .add("x-amzn-RequestId", requestIdentifier.getRequestId()); + } +} diff --git a/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/S3STSHttpServer.java b/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/S3STSHttpServer.java new file mode 100644 index 000000000000..f100e408942c --- /dev/null +++ b/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/S3STSHttpServer.java @@ -0,0 +1,105 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.hadoop.ozone.s3; + +import static org.apache.hadoop.ozone.s3.S3GatewayConfigKeys.OZONE_S3G_HTTP_AUTH_CONFIG_PREFIX; +import static org.apache.hadoop.ozone.s3.S3GatewayConfigKeys.OZONE_S3G_HTTP_AUTH_TYPE; +import static org.apache.hadoop.ozone.s3.S3GatewayConfigKeys.OZONE_S3G_HTTP_BIND_HOST_DEFAULT; +import static org.apache.hadoop.ozone.s3.S3GatewayConfigKeys.OZONE_S3G_KEYTAB_FILE; +import static org.apache.hadoop.ozone.s3.S3GatewayConfigKeys.OZONE_S3G_WEB_AUTHENTICATION_KERBEROS_PRINCIPAL; +import static org.apache.hadoop.ozone.s3sts.S3STSConfigKeys.OZONE_S3G_STS_HTTPS_ADDRESS_KEY; +import static org.apache.hadoop.ozone.s3sts.S3STSConfigKeys.OZONE_S3G_STS_HTTPS_BIND_HOST_KEY; +import static org.apache.hadoop.ozone.s3sts.S3STSConfigKeys.OZONE_S3G_STS_HTTPS_BIND_PORT_DEFAULT; +import static org.apache.hadoop.ozone.s3sts.S3STSConfigKeys.OZONE_S3G_STS_HTTP_ADDRESS_KEY; +import static org.apache.hadoop.ozone.s3sts.S3STSConfigKeys.OZONE_S3G_STS_HTTP_BIND_HOST_KEY; +import static org.apache.hadoop.ozone.s3sts.S3STSConfigKeys.OZONE_S3G_STS_HTTP_BIND_PORT_DEFAULT; +import static org.apache.hadoop.ozone.s3sts.S3STSConfigKeys.OZONE_S3G_STS_HTTP_ENABLED_KEY; + +import java.io.IOException; +import org.apache.hadoop.hdds.conf.MutableConfigurationSource; +import org.apache.hadoop.hdds.server.http.BaseHttpServer; + +/** + * HTTP server for the S3 Gateway STS endpoint. + */ +public class S3STSHttpServer extends BaseHttpServer { + + S3STSHttpServer(MutableConfigurationSource conf, String name) throws IOException { + super(conf, name); + } + + @Override + protected String getHttpAddressKey() { + return OZONE_S3G_STS_HTTP_ADDRESS_KEY; + } + + @Override + protected String getHttpBindHostKey() { + return OZONE_S3G_STS_HTTP_BIND_HOST_KEY; + } + + @Override + protected String getHttpsAddressKey() { + return OZONE_S3G_STS_HTTPS_ADDRESS_KEY; + } + + @Override + protected String getHttpsBindHostKey() { + return OZONE_S3G_STS_HTTPS_BIND_HOST_KEY; + } + + @Override + protected String getBindHostDefault() { + return OZONE_S3G_HTTP_BIND_HOST_DEFAULT; + } + + @Override + protected int getHttpBindPortDefault() { + return OZONE_S3G_STS_HTTP_BIND_PORT_DEFAULT; + } + + @Override + protected int getHttpsBindPortDefault() { + return OZONE_S3G_STS_HTTPS_BIND_PORT_DEFAULT; + } + + @Override + protected String getKeytabFile() { + return OZONE_S3G_KEYTAB_FILE; + } + + @Override + protected String getSpnegoPrincipal() { + return OZONE_S3G_WEB_AUTHENTICATION_KERBEROS_PRINCIPAL; + } + + @Override + protected String getEnabledKey() { + return OZONE_S3G_STS_HTTP_ENABLED_KEY; + } + + @Override + protected String getHttpAuthType() { + return OZONE_S3G_HTTP_AUTH_TYPE; + } + + @Override + protected String getHttpAuthConfigPrefix() { + return OZONE_S3G_HTTP_AUTH_CONFIG_PREFIX; + } +} diff --git a/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/endpoint/BucketEndpoint.java b/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/endpoint/BucketEndpoint.java index aac2c3920647..c833d5f22f43 100644 --- a/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/endpoint/BucketEndpoint.java +++ b/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/endpoint/BucketEndpoint.java @@ -46,6 +46,7 @@ import javax.ws.rs.core.MediaType; import javax.ws.rs.core.Response; import org.apache.commons.lang3.StringUtils; +import org.apache.hadoop.hdds.scm.client.HddsClientUtils; import org.apache.hadoop.ozone.audit.AuditEventStatus; import org.apache.hadoop.ozone.audit.AuditMessage; import org.apache.hadoop.ozone.audit.S3GAction; @@ -65,7 +66,6 @@ import org.apache.hadoop.ozone.s3.util.S3Consts; import org.apache.hadoop.ozone.s3.util.S3Consts.QueryParams; import org.apache.hadoop.ozone.s3.util.S3StorageType; -import org.apache.hadoop.util.Time; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -144,15 +144,9 @@ Response handleGetRequest(S3RequestContext context, String bucketName) throws IO S3Owner.verifyBucketOwnerCondition(getHeaders(), bucketName, bucket.getOwner()); ozoneKeyIterator = bucket.listKeys(prefix, prevKey, shallow); - } catch (OMException ex) { getMetrics().updateGetBucketFailureStats(context.getStartNanos()); - if (ex.getResult() == ResultCodes.FILE_NOT_FOUND) { - // File not found, continue and send normal response with 0 keyCount - LOG.debug("Key Not found prefix: {}", prefix); - } else { - throw ex; - } + handleOMException(ex, bucketName, prefix); } catch (Exception ex) { getMetrics().updateGetBucketFailureStats(context.getStartNanos()); throw ex; @@ -185,50 +179,56 @@ Response handleGetRequest(S3RequestContext context, String bucketName) throws IO String lastKey = null; int count = 0; if (maxKeys > 0) { - while (ozoneKeyIterator != null && ozoneKeyIterator.hasNext()) { - OzoneKey next = ozoneKeyIterator.next(); - if (StringUtils.isNotEmpty(prefix) && !next.getName().startsWith(prefix)) { - continue; - } - if (startAfter != null && count == 0 && Objects.equals(startAfter, next.getName())) { - continue; - } - String relativeKeyName = next.getName().substring(prefix.length()); - - int depth = StringUtils.countMatches(relativeKeyName, delimiter); - if (!StringUtils.isEmpty(delimiter)) { - if (depth > 0) { - // means key has multiple delimiters in its value. - // ex: dir/dir1/dir2, where delimiter is "/" and prefix is dir/ - String dirName = relativeKeyName.substring(0, relativeKeyName - .indexOf(delimiter)); - if (!dirName.equals(prevDir)) { - response.addPrefix(EncodingTypeObject.createNullable( - prefix + dirName + delimiter, encodingType)); - prevDir = dirName; + try { + while (ozoneKeyIterator != null && ozoneKeyIterator.hasNext()) { + OzoneKey next = ozoneKeyIterator.next(); + if (StringUtils.isNotEmpty(prefix) && !next.getName().startsWith(prefix)) { + continue; + } + if (startAfter != null && count == 0 && Objects.equals(startAfter, next.getName())) { + continue; + } + String relativeKeyName = next.getName().substring(prefix.length()); + + int depth = StringUtils.countMatches(relativeKeyName, delimiter); + if (!StringUtils.isEmpty(delimiter)) { + if (depth > 0) { + // means key has multiple delimiters in its value. + // ex: dir/dir1/dir2, where delimiter is "/" and prefix is dir/ + String dirName = relativeKeyName.substring(0, relativeKeyName + .indexOf(delimiter)); + if (!dirName.equals(prevDir)) { + response.addPrefix(EncodingTypeObject.createNullable( + prefix + dirName + delimiter, encodingType)); + prevDir = dirName; + count++; + } + } else if (relativeKeyName.endsWith(delimiter)) { + // means or key is same as prefix with delimiter at end and ends with + // delimiter. ex: dir/, where prefix is dir and delimiter is / + response.addPrefix( + EncodingTypeObject.createNullable(relativeKeyName, encodingType)); + count++; + } else { + // means our key is matched with prefix if prefix is given and it + // does not have any common prefix. + addKey(response, next, includeOwner); count++; } - } else if (relativeKeyName.endsWith(delimiter)) { - // means or key is same as prefix with delimiter at end and ends with - // delimiter. ex: dir/, where prefix is dir and delimiter is / - response.addPrefix( - EncodingTypeObject.createNullable(relativeKeyName, encodingType)); - count++; } else { // means our key is matched with prefix if prefix is given and it // does not have any common prefix. addKey(response, next, includeOwner); count++; } - } else { - addKey(response, next, includeOwner); - count++; - } - if (count == maxKeys) { - lastKey = next.getName(); - break; + if (count == maxKeys) { + lastKey = next.getName(); + break; + } } + } catch (RuntimeException ex) { + handleRuntimeException(ex, context, bucketName, prefix); } } @@ -293,19 +293,19 @@ Response handlePutRequest(S3RequestContext context, String bucketName, InputStre @HEAD public Response head(@PathParam(BUCKET) String bucketName) throws OS3Exception, IOException { - long startNanos = Time.monotonicNowNanos(); - S3GAction s3GAction = S3GAction.HEAD_BUCKET; + S3RequestContext context = new S3RequestContext(this, S3GAction.HEAD_BUCKET); + long startNanos = context.getStartNanos(); try { OzoneBucket bucket = getVolume().getBucket(bucketName); S3Owner.verifyBucketOwnerCondition(getHeaders(), bucketName, bucket.getOwner()); - auditReadSuccess(s3GAction); + auditReadSuccess(context.getAction()); getMetrics().updateHeadBucketSuccessStats(startNanos); return Response.ok().build(); } catch (OMException e) { - auditReadFailure(s3GAction, e); + auditReadFailure(context.getAction(), e); throw newError(bucketName, e); } catch (Exception e) { - auditReadFailure(s3GAction, e); + auditReadFailure(context.getAction(), e); throw e; } } @@ -345,14 +345,21 @@ public MultiDeleteResponse multiDelete( @QueryParam(QueryParams.DELETE) String delete, MultiDeleteRequest request ) throws OS3Exception, IOException { - S3GAction s3GAction = S3GAction.MULTI_DELETE; + S3RequestContext context = new S3RequestContext(this, S3GAction.MULTI_DELETE); if (request.getObjects() != null && request.getObjects().size() > S3Consts.S3_DELETE_OBJECTS_MAX_KEYS) { throw newError(S3ErrorTable.MALFORMED_XML, bucketName); } - OzoneBucket bucket = getVolume().getBucket(bucketName); + final OzoneBucket bucket; + try { + bucket = getVolume().getBucket(bucketName); + } catch (OMException ex) { + throw newError(bucketName, ex); + } catch (IOException ex) { + throw newError(S3ErrorTable.INTERNAL_ERROR, bucketName, ex); + } MultiDeleteResponse result = new MultiDeleteResponse(); List deleteKeys = new ArrayList<>(); @@ -361,7 +368,7 @@ public MultiDeleteResponse multiDelete( for (DeleteObject keyToDelete : request.getObjects()) { deleteKeys.add(keyToDelete.getKey()); } - long startNanos = Time.monotonicNowNanos(); + long startNanos = context.getStartNanos(); try { S3Owner.verifyBucketOwnerCondition(getHeaders(), bucketName, bucket.getOwner()); undeletedKeyResultMap = bucket.deleteKeys(deleteKeys, true); @@ -381,27 +388,37 @@ public MultiDeleteResponse multiDelete( } getMetrics().updateDeleteKeySuccessStats(startNanos); } catch (IOException ex) { - LOG.error("Delete key failed: {}", ex.getMessage()); getMetrics().updateDeleteKeyFailureStats(startNanos); + final OMException omEx = (OMException) HddsClientUtils.containsException(ex, OMException.class); + if (omEx != null) { + auditMultiDeleteFailure(context, deleteKeys, omEx); + throw newError(bucketName, omEx); + } + LOG.error("Delete key failed: {}", ex.getMessage()); result.addError( new Error("ALL", "InternalError", ex.getMessage())); } } - AuditMessage.Builder message = auditMessageFor(s3GAction); - message.getParams().put("failedDeletes", deleteKeys.toString()); - if (!result.getErrors().isEmpty()) { - AUDIT.logWriteFailure(message.withResult(AuditEventStatus.FAILURE) - .withException(new Exception("MultiDelete Exception")).build()); + auditMultiDeleteFailure(context, deleteKeys, new Exception("MultiDelete Exception")); } else { + AuditMessage.Builder message = auditMessageFor(context.getAction()); + message.getParams().put("failedDeletes", deleteKeys.toString()); AUDIT.logWriteSuccess(message.withResult(AuditEventStatus.SUCCESS).build()); } return result; } + void auditMultiDeleteFailure(S3RequestContext context, List deleteKeys, Throwable ex) { + final AuditMessage.Builder message = auditMessageFor(context.getAction()); + message.getParams().put("failedDeletes", deleteKeys.toString()); + AUDIT.logWriteFailure(message.withResult(AuditEventStatus.FAILURE) + .withException(ex).build()); + } + private void addKey(ListObjectResponse response, OzoneKey next, boolean includeOwner) { KeyMetadata keyMetadata = new KeyMetadata(); keyMetadata.setKey(EncodingTypeObject.createNullable(next.getName(), @@ -442,6 +459,33 @@ protected void init() { handler = new AuditingBucketOperationHandler(chain); } + private void handleOMException(OMException ex, String bucketName, String prefix) throws OMException { + if (isExpiredToken(ex)) { + throw newError(S3ErrorTable.EXPIRED_TOKEN, bucketName, ex); + } else if (isAccessDenied(ex)) { + throw newError(S3ErrorTable.ACCESS_DENIED, bucketName, ex); + } else if (ex.getResult() == ResultCodes.FILE_NOT_FOUND) { + // File not found, continue and send normal response with 0 keyCount + LOG.debug("Key Not found prefix: {}", prefix); + } else { + throw ex; + } + } + + private void handleRuntimeException(RuntimeException ex, S3RequestContext context, String bucketName, String prefix) + throws OMException { + getMetrics().updateGetBucketFailureStats(context.getStartNanos()); + if (ex.getCause() instanceof OMException) { + final OMException omException = (OMException) ex.getCause(); + if (omException.getResult() == ResultCodes.FILE_NOT_FOUND) { + throw ex; + } + handleOMException(omException, bucketName, prefix); + } else { + throw ex; + } + } + private boolean shouldIncludeOwnerInListResponse() { int listType = queryParams().getInt(QueryParams.LIST_TYPE, 1); boolean fetchOwner = queryParams().getBoolean(QueryParams.FETCH_OWNER, false); diff --git a/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/endpoint/CopyPartResult.java b/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/endpoint/CopyPartResult.java index f3b8b6e60e60..7f652bad3313 100644 --- a/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/endpoint/CopyPartResult.java +++ b/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/endpoint/CopyPartResult.java @@ -45,9 +45,9 @@ public class CopyPartResult { public CopyPartResult() { } - public CopyPartResult(String eTag) { + public CopyPartResult(String eTag, Instant lastModified) { this.eTag = eTag; - this.lastModified = Instant.now(); + this.lastModified = lastModified; } public Instant getLastModified() { diff --git a/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/endpoint/CopyResult.java b/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/endpoint/CopyResult.java new file mode 100644 index 000000000000..8b67ddbcbaaa --- /dev/null +++ b/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/endpoint/CopyResult.java @@ -0,0 +1,45 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.hadoop.ozone.s3.endpoint; + +/** + * Result of a copy operation. + */ +public class CopyResult { + private final String eTag; + private final long size; + private final long modificationTime; + + public CopyResult(String eTag, long size, long modificationTime) { + this.eTag = eTag; + this.size = size; + this.modificationTime = modificationTime; + } + + public String getETag() { + return eTag; + } + + public long getSize() { + return size; + } + + public long getModificationTime() { + return modificationTime; + } +} diff --git a/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/endpoint/EndpointBase.java b/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/endpoint/EndpointBase.java index 8315b337bb50..d237f577bbeb 100644 --- a/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/endpoint/EndpointBase.java +++ b/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/endpoint/EndpointBase.java @@ -24,6 +24,8 @@ import static org.apache.hadoop.ozone.OzoneConfigKeys.HDDS_CONTAINER_RATIS_DATASTREAM_ENABLED_DEFAULT; import static org.apache.hadoop.ozone.OzoneConfigKeys.OZONE_FS_DATASTREAM_AUTO_THRESHOLD; import static org.apache.hadoop.ozone.OzoneConfigKeys.OZONE_FS_DATASTREAM_AUTO_THRESHOLD_DEFAULT; +import static org.apache.hadoop.ozone.OzoneConfigKeys.OZONE_S3G_STS_HTTP_ENABLED_DEFAULT; +import static org.apache.hadoop.ozone.OzoneConfigKeys.OZONE_S3G_STS_HTTP_ENABLED_KEY; import static org.apache.hadoop.ozone.OzoneConsts.ETAG; import static org.apache.hadoop.ozone.OzoneConsts.KB; import static org.apache.hadoop.ozone.s3.S3GatewayConfigKeys.OZONE_S3G_CLIENT_BUFFER_SIZE_DEFAULT; @@ -96,6 +98,7 @@ import org.apache.hadoop.ozone.audit.AuditLogger.PerformanceStringBuilder; import org.apache.hadoop.ozone.audit.AuditLoggerType; import org.apache.hadoop.ozone.audit.AuditMessage; +import org.apache.hadoop.ozone.audit.S3GAction; import org.apache.hadoop.ozone.client.OzoneBucket; import org.apache.hadoop.ozone.client.OzoneClient; import org.apache.hadoop.ozone.client.OzoneClientUtils; @@ -116,11 +119,13 @@ import org.apache.hadoop.ozone.s3.signature.ChunksValidator; import org.apache.hadoop.ozone.s3.signature.SignatureInfo; import org.apache.hadoop.ozone.s3.util.AuditUtils; +import org.apache.hadoop.ozone.s3.util.S3GActionIamMapper; import org.apache.hadoop.ozone.s3.util.S3Utils; import org.apache.hadoop.security.UserGroupInformation; import org.apache.http.NameValuePair; import org.apache.http.client.utils.URLEncodedUtils; import org.apache.ratis.util.function.CheckedRunnable; +import org.apache.ratis.util.function.CheckedSupplier; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -202,6 +207,7 @@ public abstract class EndpointBase { private int chunkSize; private boolean datastreamEnabled; private long datastreamMinLength; + private boolean s3StsEnabled; @Context private ContainerRequestContext context; @@ -242,6 +248,10 @@ public void initialization() { s3Auth = new S3Auth(signatureInfo.getStringToSign(), signatureInfo.getSignature(), signatureInfo.getAwsAccessId(), signatureInfo.getAwsAccessId()); + if (signatureInfo.getSessionToken() != null && + !signatureInfo.getSessionToken().isEmpty()) { + s3Auth.setSessionToken(signatureInfo.getSessionToken()); + } LOG.debug("S3 access id: {}", s3Auth.getAccessID()); ClientProtocol clientProtocol = getClient().getObjectStore().getClientProxy(); @@ -261,6 +271,10 @@ public void initialization() { OZONE_FS_DATASTREAM_AUTO_THRESHOLD, OZONE_FS_DATASTREAM_AUTO_THRESHOLD_DEFAULT, StorageUnit.BYTES); + s3StsEnabled = getOzoneConfiguration().getBoolean( + OZONE_S3G_STS_HTTP_ENABLED_KEY, + OZONE_S3G_STS_HTTP_ENABLED_DEFAULT); + init(); } @@ -268,6 +282,39 @@ protected void init() { // hook method } + /** + * Sets the IAM S3 action on thread-local {@link S3Auth} for fine-grained STS authorization. + * Called when the handler resolves the {@link S3GAction}. + */ + protected void applyS3Action(S3GAction action) { + if (s3Auth != null && s3StsEnabled) { + s3Auth.setS3Action(S3GActionIamMapper.toS3ActionString(action)); + } + } + + /** + * Temporarily override the S3 action string set on {@link S3Auth} for authorization. + *

+ * This does not change S3G auditing (which is based on {@link S3GAction}). + * The action string is the IAM-style S3 action name without the {@code s3:} prefix (for example + * {@code GetObject}, {@code PutObject}, {@code GetObjectTagging}). + * This is used for special case APIs like CopyObject that don't have a 1-1 s3 action mapping, but + * requires GetObject on the source file and PutObject on the destination file. + */ + protected T runWithS3ActionString(String s3Action, CheckedSupplier checkedSupplier) + throws E { + if (s3Auth == null || !s3StsEnabled) { + return checkedSupplier.get(); + } + final String originalS3Action = s3Auth.getS3Action(); + s3Auth.setS3Action(s3Action); + try { + return checkedSupplier.get(); + } finally { + s3Auth.setS3Action(originalS3Action); + } + } + protected OzoneVolume getVolume() throws IOException { return client.getObjectStore().getS3Volume(); } @@ -338,22 +385,33 @@ private Iterator iterateBuckets( OzoneVolume volume = getVolume(); ownerSetter.accept(volume); return query.apply(volume); - } catch (OMException e) { - if (e.getResult() == ResultCodes.VOLUME_NOT_FOUND) { - return Collections.emptyIterator(); - } else if (e.getResult() == ResultCodes.PERMISSION_DENIED) { - throw newError(S3ErrorTable.ACCESS_DENIED, - "listBuckets", e); - } else if (e.getResult() == ResultCodes.INVALID_TOKEN) { - throw newError(S3ErrorTable.ACCESS_DENIED, - s3Auth.getAccessID(), e); - } else if (e.getResult() == ResultCodes.TIMEOUT || - e.getResult() == ResultCodes.INTERNAL_ERROR) { - throw newError(S3ErrorTable.INTERNAL_ERROR, - "listBuckets", e); - } else { - throw e; + } catch (RuntimeException e) { + if (e.getCause() instanceof OMException) { + return handleOMException((OMException) e.getCause()); } + throw e; + } catch (OMException e) { + return handleOMException(e); + } + } + + private Iterator handleOMException(OMException e) throws OMException { + if (e.getResult() == ResultCodes.VOLUME_NOT_FOUND) { + return Collections.emptyIterator(); + } else if (e.getResult() == ResultCodes.PERMISSION_DENIED) { + throw newError(S3ErrorTable.ACCESS_DENIED, + "listBuckets", e); + } else if (isExpiredToken(e)) { + throw newError(S3ErrorTable.EXPIRED_TOKEN, s3Auth.getAccessID(), e); + } else if (e.getResult() == ResultCodes.INVALID_TOKEN) { + throw newError(S3ErrorTable.ACCESS_DENIED, + s3Auth.getAccessID(), e); + } else if (e.getResult() == ResultCodes.TIMEOUT || + e.getResult() == ResultCodes.INTERNAL_ERROR) { + throw newError(S3ErrorTable.INTERNAL_ERROR, + "listBuckets", e); + } else { + throw e; } } @@ -679,7 +737,12 @@ protected void auditReadFailure(AuditAction action, Exception ex) { protected static boolean isAccessDenied(OMException ex) { ResultCodes result = ex.getResult(); return result == ResultCodes.PERMISSION_DENIED - || result == ResultCodes.INVALID_TOKEN; + || result == ResultCodes.INVALID_TOKEN + || result == ResultCodes.REVOKED_TOKEN; + } + + protected boolean isExpiredToken(OMException ex) { + return ex.getResult() == ResultCodes.TOKEN_EXPIRED; } /** diff --git a/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/endpoint/MultipartKeyHandler.java b/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/endpoint/MultipartKeyHandler.java index 984355d10f85..b9487b0f590a 100644 --- a/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/endpoint/MultipartKeyHandler.java +++ b/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/endpoint/MultipartKeyHandler.java @@ -162,6 +162,8 @@ private Response listParts(OzoneBucket ozoneBucket, String key, String uploadId, } catch (OMException ex) { if (ex.getResult() == ResultCodes.NO_SUCH_MULTIPART_UPLOAD_ERROR) { throw newError(NO_SUCH_UPLOAD, uploadId, ex); + } else if (isExpiredToken(ex)) { + throw newError(S3ErrorTable.EXPIRED_TOKEN, bucketName + "/" + key + "/" + uploadId, ex); } else if (isAccessDenied(ex)) { throw newError(S3ErrorTable.ACCESS_DENIED, bucketName + "/" + key + "/" + uploadId, ex); diff --git a/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/endpoint/ObjectEndpoint.java b/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/endpoint/ObjectEndpoint.java index d2b85bd8ba8d..a04a82e9ebd6 100644 --- a/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/endpoint/ObjectEndpoint.java +++ b/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/endpoint/ObjectEndpoint.java @@ -18,7 +18,6 @@ package org.apache.hadoop.ozone.s3.endpoint; import static org.apache.hadoop.hdds.protocol.proto.HddsProtos.ReplicationType.EC; -import static org.apache.hadoop.ozone.audit.AuditLogger.PerformanceStringBuilder; import static org.apache.hadoop.ozone.s3.S3GatewayConfigKeys.OZONE_S3G_FSO_DIRECTORY_CREATION_ENABLED; import static org.apache.hadoop.ozone.s3.S3GatewayConfigKeys.OZONE_S3G_FSO_DIRECTORY_CREATION_ENABLED_DEFAULT; import static org.apache.hadoop.ozone.s3.exception.S3ErrorTable.INVALID_ARGUMENT; @@ -83,7 +82,9 @@ import org.apache.commons.lang3.tuple.Pair; import org.apache.hadoop.hdds.client.ECReplicationConfig; import org.apache.hadoop.hdds.client.ReplicationConfig; +import org.apache.hadoop.hdds.scm.client.HddsClientUtils; import org.apache.hadoop.ozone.OzoneConsts; +import org.apache.hadoop.ozone.audit.AuditLogger.PerformanceStringBuilder; import org.apache.hadoop.ozone.audit.S3GAction; import org.apache.hadoop.ozone.client.OzoneBucket; import org.apache.hadoop.ozone.client.OzoneKey; @@ -632,8 +633,8 @@ static void addEntityTagHeader(ResponseBuilder responseBuilder, OzoneKey key) { public Response head( @PathParam(BUCKET) String bucketName, @PathParam(PATH) String keyPath) throws IOException, OS3Exception { - long startNanos = Time.monotonicNowNanos(); - S3GAction s3GAction = S3GAction.HEAD_KEY; + ObjectRequestContext context = new ObjectRequestContext(S3GAction.HEAD_KEY, bucketName); + long startNanos = context.getStartNanos(); final int partNumber = queryParams().getInt(QueryParams.PART_NUMBER, 0); // A negative part number is not a valid part; reject it as InvalidArgument. if (partNumber < 0) { @@ -657,21 +658,27 @@ public Response head( getHeaders(), keyPath, key, S3ConditionalRequest.PreconditionContext.READ); if (conditionalResponse != null) { getMetrics().updateHeadKeySuccessStats(startNanos); - auditReadSuccess(s3GAction); + auditReadSuccess(context.getAction()); return conditionalResponse; } // TODO: return the specified range bytes of this object. } catch (OMException ex) { - auditReadFailure(s3GAction, ex); + auditReadFailure(context.getAction(), ex); getMetrics().updateHeadKeyFailureStats(startNanos); if (ex.getResult() == ResultCodes.KEY_NOT_FOUND) { // Just return 404 with no content return Response.status(Status.NOT_FOUND).build(); + } else if (isExpiredToken(ex)) { + throw newError(S3ErrorTable.EXPIRED_TOKEN, keyPath, ex); + } else if (isAccessDenied(ex)) { + throw newError(S3ErrorTable.ACCESS_DENIED, keyPath, ex); + } else if (ex.getResult() == ResultCodes.BUCKET_NOT_FOUND) { + throw newError(S3ErrorTable.NO_SUCH_BUCKET, bucketName, ex); } else { throw newError(bucketName, keyPath, ex); } } catch (Exception ex) { - auditReadFailure(s3GAction, ex); + auditReadFailure(context.getAction(), ex); throw ex; } @@ -690,7 +697,7 @@ public Response head( addTagCountIfAny(response, key); addCustomMetadataHeaders(response, key); getMetrics().updateHeadKeySuccessStats(startNanos); - auditReadSuccess(s3GAction); + auditReadSuccess(context.getAction()); return response.build(); } @@ -772,8 +779,8 @@ public Response initializeMultipartUpload( @PathParam(BUCKET) String bucket, @PathParam(PATH) String key ) throws IOException, OS3Exception { - long startNanos = Time.monotonicNowNanos(); - S3GAction s3GAction = S3GAction.INIT_MULTIPART_UPLOAD; + ObjectRequestContext context = new ObjectRequestContext(S3GAction.INIT_MULTIPART_UPLOAD, bucket); + long startNanos = context.getStartNanos(); try { OzoneBucket ozoneBucket = getVolume().getBucket(bucket); @@ -798,16 +805,16 @@ public Response initializeMultipartUpload( multipartUploadInitiateResponse.setKey(key); multipartUploadInitiateResponse.setUploadID(multipartInfo.getUploadID()); - auditWriteSuccess(s3GAction); + auditWriteSuccess(context.getAction()); getMetrics().updateInitMultipartUploadSuccessStats(startNanos); return Response.status(Status.OK).entity( multipartUploadInitiateResponse).build(); } catch (OMException ex) { - auditWriteFailure(s3GAction, ex); + auditWriteFailure(context.getAction(), ex); getMetrics().updateInitMultipartUploadFailureStats(startNanos); throw newError(bucket, key, ex); } catch (Exception ex) { - auditWriteFailure(s3GAction, ex); + auditWriteFailure(context.getAction(), ex); getMetrics().updateInitMultipartUploadFailureStats(startNanos); throw ex; } @@ -823,9 +830,9 @@ public Response completeMultipartUpload( @PathParam(PATH) String key, CompleteMultipartUploadRequest multipartUploadRequest ) throws IOException, OS3Exception { + ObjectRequestContext context = new ObjectRequestContext(S3GAction.COMPLETE_MULTIPART_UPLOAD, bucket); final String uploadID = queryParams().get(QueryParams.UPLOAD_ID, ""); - long startNanos = Time.monotonicNowNanos(); - S3GAction s3GAction = S3GAction.COMPLETE_MULTIPART_UPLOAD; + long startNanos = context.getStartNanos(); List partList = multipartUploadRequest.getPartList(); // Using LinkedHashMap to preserve ordering of parts list. @@ -870,12 +877,12 @@ public Response completeMultipartUpload( wrapInQuotes(omMultipartUploadCompleteInfo.getHash())); // Location also setting as bucket name. completeMultipartUploadResponse.setLocation(bucket); - auditWriteSuccess(s3GAction); + auditWriteSuccess(context.getAction()); getMetrics().updateCompleteMultipartUploadSuccessStats(startNanos); return Response.status(Status.OK).entity(completeMultipartUploadResponse) .build(); } catch (OMException ex) { - auditWriteFailure(s3GAction, ex); + auditWriteFailure(context.getAction(), ex); getMetrics().updateCompleteMultipartUploadFailureStats(startNanos); if (ex.getResult() == ResultCodes.NO_SUCH_MULTIPART_UPLOAD_ERROR) { throw newError(NO_SUCH_UPLOAD, uploadID, ex); @@ -897,7 +904,7 @@ public Response completeMultipartUpload( } throw newError(bucket, key, ex); } catch (Exception ex) { - auditWriteFailure(s3GAction, ex); + auditWriteFailure(context.getAction(), ex); getMetrics().updateCompleteMultipartUploadFailureStats(startNanos); throw ex; } @@ -941,21 +948,22 @@ uploadID, getChunkSize(), multiDigestInputStream, perf, getHeaders(), derivedKey -> attachChunkValidator(chunkInputStreamInfo, key, derivedKey)); } // OmMultipartCommitUploadPartInfo can only be gotten after the - // OzoneOutputStream is closed, so we need to save the OzoneOutputStream - final OzoneOutputStream outputStream; + // OzoneOutputStream is closed, so we need to get and save the commit info. + final OmMultipartCommitUploadPartInfo omMultipartCommitUploadPartInfo; long metadataLatencyNs; if (copyHeader != null) { Pair result = parseSourceHeader(copyHeader); String sourceBucket = result.getLeft(); String sourceKey = result.getRight(); if (S3Owner.hasBucketOwnershipVerificationConditions(getHeaders())) { - String sourceBucketOwner = volume.getBucket(sourceBucket).getOwner(); + final String sourceBucketOwner = runWithS3ActionString( + "GetObject", () -> volume.getBucket(sourceBucket).getOwner()); S3Owner.verifyBucketOwnerConditionOnCopyOperation(getHeaders(), sourceBucket, sourceBucketOwner, bucketName, ozoneBucket.getOwner()); } - OzoneKeyDetails sourceKeyDetails = getClientProtocol().getKeyDetails( - volume.getName(), sourceBucket, sourceKey); + final OzoneKeyDetails sourceKeyDetails = runWithS3ActionString( + "GetObject", () -> getClientProtocol().getKeyDetails(volume.getName(), sourceBucket, sourceKey)); String range = getHeaders().getHeaderString(COPY_SOURCE_HEADER_RANGE); RangeHeader rangeHeader = null; @@ -991,53 +999,62 @@ uploadID, getChunkSize(), multiDigestInputStream, perf, getHeaders(), throw newError(PRECOND_FAILED, sourceBucket + "/" + sourceKey); } - try (OzoneInputStream sourceObject = sourceKeyDetails.getContent()) { - long copyLength; + final MessageDigest md5Digest = getMD5DigestInstance(); + try (OzoneInputStream sourceObject = sourceKeyDetails.getContent(); + DigestInputStream sourceDigestInputStream = + new DigestInputStream(sourceObject, md5Digest)) { + final long[] copyLengthHolder = new long[1]; + final long[] metadataLatencyHolder = new long[1]; if (range != null) { final long skipped = - sourceObject.skip(rangeHeader.getStartOffset()); + sourceDigestInputStream.skip(rangeHeader.getStartOffset()); if (skipped != rangeHeader.getStartOffset()) { throw new EOFException( "Bytes to skip: " + rangeHeader.getStartOffset() + " actual: " + skipped); } } - final long expectedLength = length; - OzoneOutputStream ozoneOutputStream = getClientProtocol() - .createMultipartKey(volume.getName(), bucketName, key, - expectedLength, partNumber, uploadID); - try (S3ObjectWriteGuard writeGuard = - new S3ObjectWriteGuard(ozoneOutputStream, expectedLength, key)) { - metadataLatencyNs = - getMetrics().updateCopyKeyMetadataStats(startNanos); - copyLength = writeGuard.copyFrom(sourceObject, getIOBufferSize(expectedLength)); - writeGuard.getMetadata().putAll(sourceKeyDetails.getMetadata()); - String raw = writeGuard.getMetadata().get(OzoneConsts.ETAG); - if (raw != null) { - writeGuard.getMetadata().put(OzoneConsts.ETAG, stripQuotes(raw)); + final long finalLength = length; + omMultipartCommitUploadPartInfo = runWithS3ActionString("PutObject", () -> { + final OzoneOutputStream ozoneOutputStream = getClientProtocol().createMultipartKey( + volume.getName(), bucketName, key, finalLength, partNumber, uploadID); + try (S3ObjectWriteGuard writeGuard = + new S3ObjectWriteGuard(ozoneOutputStream, finalLength, key)) { + metadataLatencyHolder[0] = getMetrics().updateCopyKeyMetadataStats(startNanos); + copyLengthHolder[0] = writeGuard.copyFrom( + sourceDigestInputStream, getIOBufferSize(finalLength)); + writeGuard.getMetadata().putAll(sourceKeyDetails.getMetadata()); + final String md5Hash = DatatypeConverter.printHexBinary(md5Digest.digest()).toLowerCase(); + writeGuard.getMetadata().put(OzoneConsts.ETAG, md5Hash); + writeGuard.addPreCommit( + () -> requirePartETag(writeGuard.getMetadata())); } - writeGuard.addPreCommit( - () -> requirePartETag(writeGuard.getMetadata())); - outputStream = ozoneOutputStream; - } - getMetrics().incCopyObjectSuccessLength(copyLength); - perf.appendSizeBytes(copyLength); + return ozoneOutputStream.getCommitUploadPartInfo(); + }); + metadataLatencyNs = metadataLatencyHolder[0]; + getMetrics().incCopyObjectSuccessLength(copyLengthHolder[0]); + perf.appendSizeBytes(copyLengthHolder[0]); } } else { - long putLength; + final long putLength; + // We don't need runWithS3ActionString("PutObject"...) here because the action in this else branch is + // S3GAction.CREATE_MULTIPART_KEY and this has a mapping in S3GActionIamMapper to "PutObject", so it's covered. + // In the if branch of the code, the request action is set to S3GAction.CREATE_MULTIPART_KEY_BY_COPY which is + // mapped to null in S3GActionIamMapper (by design, since it needs "GetObject" on the source and "PutObject" + // on the destination). final long expectedLength = length; - OzoneOutputStream ozoneOutputStream = getClientProtocol().createMultipartKey( + final OzoneOutputStream ozoneOutputStream = getClientProtocol().createMultipartKey( volume.getName(), bucketName, key, expectedLength, partNumber, uploadID, wantDerivedKey); try (S3ObjectWriteGuard writeGuard = new S3ObjectWriteGuard(ozoneOutputStream, expectedLength, key)) { metadataLatencyNs = getMetrics().updatePutKeyMetadataStats(startNanos); writeGuard.onKeyOpened(derivedKey -> attachChunkValidator(chunkInputStreamInfo, key, derivedKey)); putLength = writeGuard.copyFrom(multiDigestInputStream, getIOBufferSize(expectedLength)); - byte[] digest = multiDigestInputStream.getMessageDigest(OzoneConsts.MD5_HASH).digest(); - String md5Hash = DatatypeConverter.printHexBinary(digest).toLowerCase(); - String clientContentMD5 = getHeaders().getHeaderString(S3Consts.CHECKSUM_HEADER); + final byte[] digest = multiDigestInputStream.getMessageDigest(OzoneConsts.MD5_HASH).digest(); + final String md5Hash = DatatypeConverter.printHexBinary(digest).toLowerCase(); + final String clientContentMD5 = getHeaders().getHeaderString(S3Consts.CHECKSUM_HEADER); if (clientContentMD5 != null) { - CheckedRunnable checkContentMD5Hook = () -> { + final CheckedRunnable checkContentMD5Hook = () -> { S3Utils.validateContentMD5(clientContentMD5, md5Hash, key); }; writeGuard.addPreCommit(checkContentMD5Hook); @@ -1045,15 +1062,13 @@ uploadID, getChunkSize(), multiDigestInputStream, perf, getHeaders(), writeGuard.getMetadata().put(OzoneConsts.ETAG, md5Hash); writeGuard.addPreCommit( () -> requirePartETag(writeGuard.getMetadata())); - outputStream = ozoneOutputStream; } + omMultipartCommitUploadPartInfo = ozoneOutputStream.getCommitUploadPartInfo(); getMetrics().incPutKeySuccessLength(putLength); perf.appendSizeBytes(putLength); } perf.appendMetaLatencyNanos(metadataLatencyNs); - OmMultipartCommitUploadPartInfo omMultipartCommitUploadPartInfo = - outputStream.getCommitUploadPartInfo(); String eTag = omMultipartCommitUploadPartInfo.getETag(); // If the OmMultipartCommitUploadPartInfo does not contain eTag, // fall back to MPU part name for compatibility in case the (old) OM @@ -1065,7 +1080,8 @@ uploadID, getChunkSize(), multiDigestInputStream, perf, getHeaders(), if (copyHeader != null) { getMetrics().updateCopyObjectSuccessStats(startNanos); - return Response.ok(new CopyPartResult(eTag)).build(); + final Instant lastModified = Instant.ofEpochMilli(omMultipartCommitUploadPartInfo.getModificationTime()); + return Response.ok(new CopyPartResult(eTag, lastModified)).build(); } else { getMetrics().updateCreateMultipartKeySuccessStats(startNanos); return Response.ok().header(HttpHeaders.ETAG, eTag).build(); @@ -1086,6 +1102,22 @@ uploadID, getChunkSize(), multiDigestInputStream, perf, getHeaders(), throw os3Exception; } throw newError(bucketName, key, ex); + } catch (IOException ex) { + // Ensure we handle permission failures - these can surface as IOException wrapping OMException. + if (copyHeader != null) { + getMetrics().updateCopyObjectFailureStats(startNanos); + } else { + getMetrics().updateCreateMultipartKeyFailureStats(startNanos); + } + final OMException omEx = (OMException) HddsClientUtils.containsException(ex, OMException.class); + if (omEx != null) { + if (omEx.getResult() == ResultCodes.NO_SUCH_MULTIPART_UPLOAD_ERROR) { + throw newError(NO_SUCH_UPLOAD, uploadID, omEx); + } else { + throw newError(bucketName, key, omEx); + } + } + throw ex; } finally { // Reset the thread-local message digest instance in case of exception // and MessageDigest#digest is never called @@ -1096,7 +1128,7 @@ uploadID, getChunkSize(), multiDigestInputStream, perf, getHeaders(), } @SuppressWarnings("checkstyle:ParameterNumber") - void copy(OzoneVolume volume, DigestInputStream src, long srcKeyLen, + CopyResult copy(OzoneVolume volume, DigestInputStream src, long srcKeyLen, String destKey, String destBucket, ReplicationConfig replication, Map metadata, @@ -1105,30 +1137,37 @@ void copy(OzoneVolume volume, DigestInputStream src, long srcKeyLen, S3ConditionalRequest.WriteConditions writeConditions) throws IOException { long copyLength; - + final String eTag; + final long modificationTime; if (isDatastreamEnabled() && !(replication != null && replication.getReplicationType() == EC) && srcKeyLen > getDatastreamMinLength()) { perf.appendStreamMode(); - copyLength = ObjectEndpointStreaming + final CopyResult copyResult = ObjectEndpointStreaming .copyKeyWithStream(volume.getBucket(destBucket), destKey, srcKeyLen, getChunkSize(), replication, metadata, src, perf, startNanos, tags, writeConditions); + eTag = copyResult.getETag(); + copyLength = copyResult.getSize(); + modificationTime = copyResult.getModificationTime(); } else { final long expectedLength = srcKeyLen; - try (S3ObjectWriteGuard dest = new S3ObjectWriteGuard(openKeyForPut( + final OzoneOutputStream destStream = openKeyForPut( volume.getName(), destBucket, destKey, expectedLength, - replication, metadata, tags, writeConditions, false), expectedLength, destKey)) { + replication, metadata, tags, writeConditions, false); + try (S3ObjectWriteGuard dest = new S3ObjectWriteGuard(destStream, expectedLength, destKey)) { long metadataLatencyNs = getMetrics().updateCopyKeyMetadataStats(startNanos); perf.appendMetaLatencyNanos(metadataLatencyNs); copyLength = dest.copyFrom(src, getIOBufferSize(expectedLength)); - String md5Hash = DatatypeConverter.printHexBinary(src.getMessageDigest().digest()).toLowerCase(); - dest.getMetadata().put(OzoneConsts.ETAG, md5Hash); + eTag = DatatypeConverter.printHexBinary(src.getMessageDigest().digest()).toLowerCase(); + dest.getMetadata().put(OzoneConsts.ETAG, eTag); } + modificationTime = destStream.getModificationTime(); } getMetrics().incCopyObjectSuccessLength(copyLength); perf.appendSizeBytes(copyLength); + return new CopyResult(eTag, copyLength, modificationTime); } private CopyObjectResponse copyObject(OzoneVolume volume, @@ -1144,20 +1183,21 @@ private CopyObjectResponse copyObject(OzoneVolume volume, String sourceBucket = result.getLeft(); String sourceKey = result.getRight(); - DigestInputStream sourceDigestInputStream = null; + final MessageDigest md5Digest = getMD5DigestInstance(); if (S3Owner.hasBucketOwnershipVerificationConditions(getHeaders())) { - String sourceBucketOwner = volume.getBucket(sourceBucket).getOwner(); + final String sourceBucketOwner = runWithS3ActionString( + "GetObject", () -> volume.getBucket(sourceBucket).getOwner()); // The destBucket owner has already been checked in the caller method S3Owner.verifyBucketOwnerConditionOnCopyOperation(getHeaders(), sourceBucket, sourceBucketOwner, null, null); } try { - OzoneKeyDetails sourceKeyDetails = getClientProtocol().getKeyDetails( - volume.getName(), sourceBucket, sourceKey); + final OzoneKeyDetails sourceKeyDetails = runWithS3ActionString( + "GetObject", () -> getClientProtocol().getKeyDetails(volume.getName(), sourceBucket, sourceKey)); // Metadata directive is read up front: a self-copy is legal when metadata // is being replaced (x-amz-metadata-directive: REPLACE). - String metadataCopyDirective = getHeaders().getHeaderString(CUSTOM_METADATA_COPY_DIRECTIVE_HEADER); - boolean replacingMetadata = CopyDirective.REPLACE.name().equals(metadataCopyDirective); + final String metadataCopyDirective = getHeaders().getHeaderString(CUSTOM_METADATA_COPY_DIRECTIVE_HEADER); + final boolean replacingMetadata = CopyDirective.REPLACE.name().equals(metadataCopyDirective); // Checking whether we trying to copying to it self. if (sourceBucket.equals(destBucket) && sourceKey.equals(destkey) @@ -1229,22 +1269,20 @@ private CopyObjectResponse copyObject(OzoneVolume volume, throw ex; } - try (OzoneInputStream src = getClientProtocol().getKey(volume.getName(), - sourceBucket, sourceKey)) { + try (OzoneInputStream src = runWithS3ActionString( + "GetObject", () -> getClientProtocol().getKey(volume.getName(), sourceBucket, sourceKey)); + DigestInputStream sourceDigestInputStream = new DigestInputStream(src, md5Digest)) { getMetrics().updateCopyKeyMetadataStats(startNanos); - sourceDigestInputStream = new DigestInputStream(src, getMD5DigestInstance()); - copy(volume, sourceDigestInputStream, sourceKeyLen, destkey, destBucket, replicationConfig, - customMetadata, perf, startNanos, tags, writeConditions); - } - - final OzoneKeyDetails destKeyDetails = getClientProtocol().getKeyDetails( - volume.getName(), destBucket, destkey); + final CopyResult copyResult = runWithS3ActionString("PutObject", () -> + copy(volume, sourceDigestInputStream, sourceKeyLen, destkey, destBucket, + replicationConfig, customMetadata, perf, startNanos, tags, writeConditions)); - getMetrics().updateCopyObjectSuccessStats(startNanos); - CopyObjectResponse copyObjectResponse = new CopyObjectResponse(); - copyObjectResponse.setETag(wrapInQuotes(destKeyDetails.getMetadata().get(OzoneConsts.ETAG))); - copyObjectResponse.setLastModified(destKeyDetails.getModificationTime()); - return copyObjectResponse; + getMetrics().updateCopyObjectSuccessStats(startNanos); + CopyObjectResponse copyObjectResponse = new CopyObjectResponse(); + copyObjectResponse.setETag(wrapInQuotes(copyResult.getETag())); + copyObjectResponse.setLastModified(Instant.ofEpochMilli(copyResult.getModificationTime())); + return copyObjectResponse; + } } catch (OMException ex) { if (ex.getResult() == ResultCodes.KEY_NOT_FOUND) { if (getHeaders().getHeaderString(S3Consts.IF_MATCH_HEADER) != null) { @@ -1263,9 +1301,7 @@ private CopyObjectResponse copyObject(OzoneVolume volume, } finally { // Reset the thread-local message digest instance in case of exception // and MessageDigest#digest is never called - if (sourceDigestInputStream != null) { - sourceDigestInputStream.getMessageDigest().reset(); - } + md5Digest.reset(); } } diff --git a/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/endpoint/ObjectEndpointStreaming.java b/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/endpoint/ObjectEndpointStreaming.java index a8cd87138018..74b2059986e6 100644 --- a/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/endpoint/ObjectEndpointStreaming.java +++ b/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/endpoint/ObjectEndpointStreaming.java @@ -92,6 +92,8 @@ public static Pair put( " considered as Unix Paths. Path has Violated FS Semantics " + "which caused put operation to fail."); throw os3Exception; + } else if ((((OMException) ex).getResult() == OMException.ResultCodes.TOKEN_EXPIRED)) { + throw S3ErrorTable.newError(S3ErrorTable.EXPIRED_TOKEN, keyPath); } else if ((((OMException) ex).getResult() == OMException.ResultCodes.PERMISSION_DENIED)) { throw S3ErrorTable.newError(S3ErrorTable.ACCESS_DENIED, keyPath); @@ -177,7 +179,7 @@ private static OzoneDataStreamOutput openStreamKeyForPut(OzoneBucket bucket, } @SuppressWarnings("checkstyle:ParameterNumber") - public static long copyKeyWithStream( + public static CopyResult copyKeyWithStream( OzoneBucket bucket, String keyPath, long length, @@ -189,19 +191,22 @@ public static long copyKeyWithStream( S3ConditionalRequest.WriteConditions writeConditions) throws IOException { long writeLen; + String eTag; + final OzoneDataStreamOutput streamOutput = openStreamKeyForPut(bucket, + keyPath, length, replicationConfig, keyMetadata, tags, + writeConditions, false); try (S3ObjectStreamingWriteGuard writeGuard = - new S3ObjectStreamingWriteGuard(openStreamKeyForPut(bucket, - keyPath, length, replicationConfig, keyMetadata, tags, - writeConditions, false), length, keyPath)) { + new S3ObjectStreamingWriteGuard(streamOutput, length, keyPath)) { long metadataLatencyNs = METRICS.updateCopyKeyMetadataStats(startNanos); writeLen = writeGuard.copyFrom(body, bufferSize); - String eTag = DatatypeConverter.printHexBinary(body.getMessageDigest().digest()) + eTag = DatatypeConverter.printHexBinary(body.getMessageDigest().digest()) .toLowerCase(); perf.appendMetaLatencyNanos(metadataLatencyNs); writeGuard.getMetadata().put(OzoneConsts.ETAG, eTag); } - return writeLen; + + return new CopyResult(eTag, writeLen, streamOutput.getModificationTime()); } @SuppressWarnings("checkstyle:ParameterNumber") @@ -239,6 +244,8 @@ public static Response createMultipartKey(OzoneBucket ozoneBucket, String key, OMException.ResultCodes.NO_SUCH_MULTIPART_UPLOAD_ERROR) { throw S3ErrorTable.newError(NO_SUCH_UPLOAD, uploadID); + } else if (ex.getResult() == OMException.ResultCodes.TOKEN_EXPIRED) { + throw S3ErrorTable.newError(S3ErrorTable.EXPIRED_TOKEN, ozoneBucket.getName() + "/" + key); } else if (ex.getResult() == OMException.ResultCodes.PERMISSION_DENIED) { throw S3ErrorTable.newError(S3ErrorTable.ACCESS_DENIED, ozoneBucket.getName() + "/" + key); diff --git a/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/endpoint/RootEndpoint.java b/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/endpoint/RootEndpoint.java index e89d09a54328..f5d24fa8de26 100644 --- a/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/endpoint/RootEndpoint.java +++ b/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/endpoint/RootEndpoint.java @@ -33,7 +33,6 @@ import org.apache.hadoop.ozone.s3.util.ContinueToken; import org.apache.hadoop.ozone.s3.util.S3Consts; import org.apache.hadoop.ozone.s3.util.S3Consts.QueryParams; -import org.apache.hadoop.util.Time; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -84,7 +83,8 @@ private boolean isS3ExpressSignedRequest() { */ private Response listDirectoryBuckets() throws OS3Exception, IOException { - long startNanos = Time.monotonicNowNanos(); + S3RequestContext context = new S3RequestContext(this, S3GAction.LIST_DIRECTORY_BUCKETS); + long startNanos = context.getStartNanos(); boolean auditSuccess = true; try { final String continueToken = queryParams().get(QueryParams.CONTINUATION_TOKEN); @@ -139,18 +139,19 @@ private Response listDirectoryBuckets() return Response.ok(response).build(); } catch (Exception ex) { auditSuccess = false; - auditReadFailure(S3GAction.LIST_DIRECTORY_BUCKETS, ex); + auditReadFailure(context.getAction(), ex); throw ex; } finally { if (auditSuccess) { - auditReadSuccess(S3GAction.LIST_DIRECTORY_BUCKETS); + auditReadSuccess(context.getAction()); } } } private Response listAllBuckets() throws OS3Exception, IOException { - long startNanos = Time.monotonicNowNanos(); + S3RequestContext context = new S3RequestContext(this, S3GAction.LIST_S3_BUCKETS); + long startNanos = context.getStartNanos(); boolean auditSuccess = true; try { final String continueToken = queryParams().get(QueryParams.CONTINUATION_TOKEN); @@ -200,11 +201,11 @@ private Response listAllBuckets() return Response.ok(response).build(); } catch (Exception ex) { auditSuccess = false; - auditReadFailure(S3GAction.LIST_S3_BUCKETS, ex); + auditReadFailure(context.getAction(), ex); throw ex; } finally { if (auditSuccess) { - auditReadSuccess(S3GAction.LIST_S3_BUCKETS); + auditReadSuccess(context.getAction()); } } } diff --git a/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/endpoint/S3RequestContext.java b/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/endpoint/S3RequestContext.java index 4130feaf6fdb..ebcb773cef45 100644 --- a/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/endpoint/S3RequestContext.java +++ b/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/endpoint/S3RequestContext.java @@ -36,6 +36,7 @@ class S3RequestContext { this.startNanos = Time.monotonicNowNanos(); this.perf = new PerformanceStringBuilder(); this.action = action; + endpoint.applyS3Action(action); } long getStartNanos() { @@ -59,6 +60,7 @@ S3GAction getAction() { void setAction(S3GAction action) { this.action = action; + endpoint.applyS3Action(action); } /** diff --git a/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/exception/OS3Exception.java b/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/exception/OS3Exception.java index 2b6affd3c03d..f3ff9f494649 100644 --- a/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/exception/OS3Exception.java +++ b/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/exception/OS3Exception.java @@ -17,10 +17,12 @@ package org.apache.hadoop.ozone.s3.exception; +import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.SerializationFeature; import com.fasterxml.jackson.dataformat.xml.XmlMapper; import com.fasterxml.jackson.module.jaxb.JaxbAnnotationModule; +import com.google.common.base.Strings; import javax.xml.bind.annotation.XmlAccessType; import javax.xml.bind.annotation.XmlAccessorType; import javax.xml.bind.annotation.XmlElement; @@ -59,6 +61,14 @@ public class OS3Exception extends RuntimeException { @XmlElement(name = "RequestId") private String requestId; + @JsonInclude(JsonInclude.Include.NON_EMPTY) + @XmlElement(name = "HostId") + private String hostId; + + @JsonInclude(JsonInclude.Include.NON_EMPTY) + @XmlElement(name = "Token-0") + private String token0; + @XmlTransient private int httpCode; @@ -110,6 +120,22 @@ public void setResource(String resource) { this.resource = resource; } + public String getHostId() { + return hostId; + } + + public void setHostId(String hostId) { + this.hostId = hostId; + } + + public String getToken0() { + return token0; + } + + public void setToken0(String token0) { + this.token0 = token0; + } + public int getHttpCode() { return httpCode; } @@ -131,16 +157,20 @@ public String toXml() { //When we get exception log it, and return exception as xml from actual // exception data. So, falling back to construct from exception. - String formatString = "" + - "" + - "%s" + - "%s" + - "%s" + - "%s" + - ""; - return String.format(formatString, this.getCode(), - this.getErrorMessage(), this.getResource(), - this.getRequestId()); + final StringBuilder builder = new StringBuilder("") + .append("") + .append("").append(this.getCode()).append("") + .append("").append(this.getErrorMessage()).append("") + .append("").append(this.getResource()).append("") + .append("").append(this.getRequestId()).append(""); + if (!Strings.isNullOrEmpty(this.getHostId())) { + builder.append("").append(this.getHostId()).append(""); + } + if (!Strings.isNullOrEmpty(this.getToken0())) { + builder.append("").append(this.getToken0()).append(""); + } + builder.append(""); + return builder.toString(); } public OS3Exception withMessage(String message) { diff --git a/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/exception/OS3ExceptionMapper.java b/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/exception/OS3ExceptionMapper.java index 16a529d3f5d6..b576cb69b4c9 100644 --- a/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/exception/OS3ExceptionMapper.java +++ b/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/exception/OS3ExceptionMapper.java @@ -23,6 +23,7 @@ import javax.ws.rs.ext.ExceptionMapper; import javax.ws.rs.ext.Provider; import org.apache.hadoop.ozone.s3.RequestIdentifier; +import org.apache.hadoop.ozone.s3.signature.SignatureInfo; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -33,20 +34,33 @@ @Provider public class OS3ExceptionMapper implements ExceptionMapper { + private static final String EXPIRED_TOKEN = "ExpiredToken"; + private static final Logger LOG = LoggerFactory.getLogger(OS3ExceptionMapper.class); @Inject private RequestIdentifier requestIdentifier; + @Inject + private SignatureInfo signatureInfo; + @Override public Response toResponse(OS3Exception exception) { if (LOG.isDebugEnabled()) { LOG.debug("Returning exception. ex: {}", exception.toString()); } exception.setRequestId(requestIdentifier.getRequestId()); + exception.setHostId(requestIdentifier.getAmzId()); + if (EXPIRED_TOKEN.equals(exception.getCode()) && signatureInfo != null) { + final String sessionToken = signatureInfo.getSessionToken(); + if (sessionToken != null && !sessionToken.isEmpty()) { + exception.setToken0(sessionToken); + } + } return Response.status(exception.getHttpCode()) - .type(MediaType.APPLICATION_XML_TYPE) - .entity(exception.toXml()).build(); + .entity(exception.toXml()) + .type(MediaType.APPLICATION_XML) + .build(); } } diff --git a/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/exception/OSTSException.java b/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/exception/OSTSException.java new file mode 100644 index 000000000000..0604962cf760 --- /dev/null +++ b/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/exception/OSTSException.java @@ -0,0 +1,173 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.hadoop.ozone.s3.exception; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializationFeature; +import com.fasterxml.jackson.dataformat.xml.XmlMapper; +import com.fasterxml.jackson.module.jaxb.JaxbAnnotationModule; +import javax.xml.bind.annotation.XmlAccessType; +import javax.xml.bind.annotation.XmlAccessorType; +import javax.xml.bind.annotation.XmlAttribute; +import javax.xml.bind.annotation.XmlElement; +import javax.xml.bind.annotation.XmlRootElement; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * This class represents exceptions raised from Ozone STS service. + */ +public class OSTSException extends OS3Exception { + private static final Logger LOG = LoggerFactory.getLogger(OSTSException.class); + private static final ObjectMapper MAPPER; + private static final String AWS_FAULT_NS = "http://webservices.amazon.com/AWSFault/2005-15-09"; + private static final String STS_NS = "https://sts.amazonaws.com/doc/2011-06-15/"; + private static final String INVALID_ACTION = "InvalidAction"; + + static { + MAPPER = new XmlMapper(); + MAPPER.registerModule(new JaxbAnnotationModule()); + MAPPER.enable(SerializationFeature.INDENT_OUTPUT); + } + + private String type = "Sender"; + + public OSTSException(S3ErrorTable error) { + super(error, null, null); + } + + public OSTSException(S3ErrorTable error, Exception cause) { + super(error, cause, null); + } + + @Override + public OSTSException withMessage(String message) { + super.withMessage(message); + return this; + } + + /** + * STS fault party (e.g. {@code Sender} vs {@code Receiver}); defaults to {@code Sender}. + */ + public OSTSException withType(String typeVal) { + this.type = typeVal; + return this; + } + + public String getType() { + return type; + } + + public void setType(String type) { + this.type = type; + } + + @Override + public String toXml() { + try { + final ErrorResponse response = new ErrorResponse(this); + final String val = MAPPER.writeValueAsString(response); + LOG.debug("toXml val is {}", val); + return val; // STS error responses don't have prolog + } catch (Exception ex) { + LOG.error("Exception occurred", ex); + // Fallback + final String namespace = INVALID_ACTION.equals(getCode()) ? AWS_FAULT_NS : STS_NS; + + // STS error responses don't have prolog + final StringBuilder builder = new StringBuilder(); + builder.append("\n") + .append(" \n") + .append(" ").append(getType()).append("\n") + .append(" ").append(getCode()).append("\n") + .append(" ").append(getErrorMessage()).append("\n") + .append(" \n") + .append(" ").append(getRequestId()).append("\n") + .append(""); + return builder.toString(); + } + } + + @XmlAccessorType(XmlAccessType.FIELD) + @XmlRootElement(name = "ErrorResponse") + private static class ErrorResponse { + + @XmlAttribute + private String xmlns; + + @XmlElement(name = "Error") + private ErrorDetails error; + + @XmlElement(name = "RequestId") + private String requestId; + + ErrorResponse() { + } + + ErrorResponse(OSTSException ex) { + this.xmlns = INVALID_ACTION.equals(ex.getCode()) ? AWS_FAULT_NS : STS_NS; + this.error = new ErrorDetails(ex.getType(), ex.getCode(), ex.getErrorMessage()); + this.requestId = ex.getRequestId(); + } + + public String getXmlns() { + return xmlns; + } + + public ErrorDetails getError() { + return error; + } + + public String getRequestId() { + return requestId; + } + } + + @XmlAccessorType(XmlAccessType.FIELD) + private static class ErrorDetails { + @XmlElement(name = "Type") + private String type; + + @XmlElement(name = "Code") + private String code; + + @XmlElement(name = "Message") + private String message; + + ErrorDetails() { + } + + ErrorDetails(String type, String code, String message) { + this.type = type; + this.code = code; + this.message = message; + } + + public String getType() { + return type; + } + + public String getCode() { + return code; + } + + public String getMessage() { + return message; + } + } +} diff --git a/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/exception/OSTSExceptionMapper.java b/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/exception/OSTSExceptionMapper.java new file mode 100644 index 000000000000..bb564e0e061b --- /dev/null +++ b/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/exception/OSTSExceptionMapper.java @@ -0,0 +1,49 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.hadoop.ozone.s3.exception; + +import javax.inject.Inject; +import javax.ws.rs.core.Response; +import javax.ws.rs.ext.ExceptionMapper; +import javax.ws.rs.ext.Provider; +import org.apache.hadoop.ozone.s3.RequestIdentifier; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Class that represents various errors returned by the Ozone STS service. + */ +@Provider +public class OSTSExceptionMapper implements ExceptionMapper { + + private static final Logger LOG = LoggerFactory.getLogger(OSTSExceptionMapper.class); + + @Inject + private RequestIdentifier requestIdentifier; + + @Override + public Response toResponse(OSTSException exception) { + if (LOG.isDebugEnabled()) { + LOG.debug("Returning exception. ex: {}", exception.toString()); + } + exception.setRequestId(requestIdentifier.getRequestId()); + return Response.status(exception.getHttpCode()) + .entity(exception.toXml()).build(); + } +} + diff --git a/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/exception/OSTSNotFoundExceptionMapper.java b/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/exception/OSTSNotFoundExceptionMapper.java new file mode 100644 index 000000000000..511ea26547bf --- /dev/null +++ b/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/exception/OSTSNotFoundExceptionMapper.java @@ -0,0 +1,93 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.hadoop.ozone.s3.exception; + +import static org.apache.hadoop.ozone.s3.exception.S3ErrorTable.STS_VALIDATION_ERROR; + +import com.google.common.annotations.VisibleForTesting; +import javax.inject.Inject; +import javax.ws.rs.NotFoundException; +import javax.ws.rs.core.Context; +import javax.ws.rs.core.MediaType; +import javax.ws.rs.core.Response; +import javax.ws.rs.core.UriInfo; +import javax.ws.rs.ext.ExceptionMapper; +import javax.ws.rs.ext.Provider; +import org.apache.commons.lang3.StringUtils; +import org.apache.hadoop.ozone.s3.RequestIdentifier; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Maps unmatched STS endpoint paths to AWS STS compatible XML errors. + *

+ * Without this mapper, Jersey returns Jetty HTML 404 pages for paths such as {@code /sts}, + * which AWS clients report as {@code Unknown}. + */ +@Provider +public class OSTSNotFoundExceptionMapper implements ExceptionMapper { + + private static final Logger LOG = LoggerFactory.getLogger(OSTSNotFoundExceptionMapper.class); + + @Inject + private RequestIdentifier requestIdentifier; + + @Context + private UriInfo uriInfo; + + @Override + public Response toResponse(NotFoundException exception) { + final String requestPath = getRequestPath(); + final String validationMessage = buildValidationMessage(requestPath); + if (LOG.isDebugEnabled()) { + LOG.debug("Returning STS validation error for unmatched path: {}", requestPath); + } + final OSTSException stsException = new OSTSException(STS_VALIDATION_ERROR).withMessage(validationMessage); + stsException.setRequestId(requestIdentifier.getRequestId()); + return Response.status(stsException.getHttpCode()) + .entity(stsException.toXml()) + .type(MediaType.APPLICATION_XML) + .build(); + } + + private String getRequestPath() { + if (uriInfo == null) { + return "/"; + } + final String path = uriInfo.getPath(); + if (StringUtils.isBlank(path)) { + return "/"; + } + return path.startsWith("/") ? path : "/" + path; + } + + private static String buildValidationMessage(String requestPath) { + return "1 validation error detected: Invalid STS endpoint path '" + requestPath + "'. " + + "Ozone STS is served at the root path /."; + } + + @VisibleForTesting + public void setRequestIdentifier(RequestIdentifier requestIdentifier) { + this.requestIdentifier = requestIdentifier; + } + + @VisibleForTesting + public void setUriInfo(UriInfo uriInfo) { + this.uriInfo = uriInfo; + } +} diff --git a/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/exception/S3ErrorTable.java b/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/exception/S3ErrorTable.java index c2768de56edf..ac52cbd851ee 100644 --- a/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/exception/S3ErrorTable.java +++ b/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/exception/S3ErrorTable.java @@ -33,7 +33,8 @@ import org.slf4j.LoggerFactory; /** - * This class represents errors from Ozone S3 service. + * This class represents errors from Ozone S3 service and STS-compatible errors + * raised by the S3 gateway STS endpoint. * This class needs to be updated to add new errors when required. */ public enum S3ErrorTable { @@ -115,6 +116,9 @@ public enum S3ErrorTable { "match the signature you provided. Check your key and signing method.", HTTP_FORBIDDEN), + EXPIRED_TOKEN( + "ExpiredToken", "The provided token has expired.", HTTP_BAD_REQUEST), + PRECOND_FAILED( "PreconditionFailed", "At least one of the pre-conditions you " + "specified did not hold", HTTP_PRECON_FAILED), @@ -169,6 +173,9 @@ public enum S3ErrorTable { "Access Denied", "User doesn't have permission to access this resource due to a " + "bucket ownership mismatch.", HTTP_FORBIDDEN), + PAYLOAD_TOO_LARGE( + "PayloadTooLarge", "Your request body size was too large.", HTTP_BAD_REQUEST), + NO_SUCH_LIFECYCLE_CONFIGURATION("NoSuchLifecycleConfiguration", "The specified lifecycle configurations does not exist", HTTP_NOT_FOUND), @@ -181,7 +188,39 @@ public enum S3ErrorTable { HTTP_BAD_REQUEST), INVALID_DIGEST( - "InvalidDigest", "The Content-MD5 you specified is not valid.", HTTP_BAD_REQUEST); + "InvalidDigest", "The Content-MD5 you specified is not valid.", HTTP_BAD_REQUEST), + + /** STS: Code {@code ValidationError}; message is usually overridden via {@link OS3Exception#withMessage}. */ + STS_VALIDATION_ERROR( + "ValidationError", "A validation error occurred.", HTTP_BAD_REQUEST), + + /** + * STS: Code {@code InvalidAction} with HTTP 400 (unknown action or unsupported API version). + * For HTTP 501 unsupported-but-known operations use {@link #STS_INVALID_ACTION_NOT_IMPLEMENTED}. + */ + STS_INVALID_ACTION( + "InvalidAction", "Could not find operation.", HTTP_BAD_REQUEST), + + /** STS: Code {@code InvalidAction} with HTTP 501 (operation known but not implemented). */ + STS_INVALID_ACTION_NOT_IMPLEMENTED( + "InvalidAction", "Operation is not supported yet.", HTTP_NOT_IMPLEMENTED), + + /** STS: Code {@code InternalFailure}; distinct from {@link #INTERNAL_ERROR} ({@code InternalError}). */ + STS_INTERNAL_FAILURE( + "InternalFailure", "An internal error has occurred.", HTTP_INTERNAL_ERROR), + + /** STS: Code {@code InvalidClientTokenId}. */ + STS_INVALID_CLIENT_TOKEN_ID( + "InvalidClientTokenId", + "The security token included in the request is invalid.", HTTP_FORBIDDEN), + + /** STS: Code {@code UnsupportedOperation}; distinct from {@link #NOT_IMPLEMENTED} ({@code NotImplemented}). */ + STS_UNSUPPORTED_OPERATION( + "UnsupportedOperation", "This operation is not supported.", HTTP_NOT_IMPLEMENTED), + + /** STS: Code {@code MalformedPolicyDocument}; message is usually overridden via {@link OS3Exception#withMessage}. */ + STS_MALFORMED_POLICY_DOCUMENT( + "MalformedPolicyDocument", "Policy document is malformed.", HTTP_BAD_REQUEST); private static final Logger LOG = LoggerFactory.getLogger(S3ErrorTable.class); @@ -214,6 +253,7 @@ public static S3ErrorTable translateResultCode(OMException ex) { case ACCESS_DENIED: case INVALID_TOKEN: case PERMISSION_DENIED: + case REVOKED_TOKEN: return ACCESS_DENIED; case ATOMIC_WRITE_CONFLICT: return CONDITIONAL_REQUEST_CONFLICT; @@ -230,6 +270,8 @@ public static S3ErrorTable translateResultCode(OMException ex) { case ETAG_NOT_AVAILABLE: case KEY_ALREADY_EXISTS: return PRECOND_FAILED; + case TOKEN_EXPIRED: + return EXPIRED_TOKEN; case FILE_ALREADY_EXISTS: return NO_OVERWRITE; case INVALID_BUCKET_NAME: diff --git a/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/signature/AWSSignatureProcessor.java b/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/signature/AWSSignatureProcessor.java index 4533db115307..12ed0266f417 100644 --- a/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/signature/AWSSignatureProcessor.java +++ b/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/signature/AWSSignatureProcessor.java @@ -19,8 +19,20 @@ import static org.apache.hadoop.ozone.s3.exception.S3ErrorTable.ACCESS_DENIED; import static org.apache.hadoop.ozone.s3.exception.S3ErrorTable.MALFORMED_HEADER; +import static org.apache.hadoop.ozone.s3.exception.S3ErrorTable.PAYLOAD_TOO_LARGE; +import static org.apache.hadoop.ozone.s3.exception.S3ErrorTable.S3_AUTHINFO_CREATION_ERROR; +import static org.apache.hadoop.ozone.s3.util.S3Consts.UNSIGNED_PAYLOAD; +import static org.apache.hadoop.ozone.s3.util.S3Consts.X_AMZ_CONTENT_SHA256; +import static org.apache.hadoop.ozone.s3sts.S3STSConfigKeys.OZONE_S3G_STS_PAYLOAD_HASH_MAX_VALUE; import com.google.common.annotations.VisibleForTesting; +import com.google.common.base.Strings; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; import java.util.ArrayList; import java.util.Collection; import java.util.HashMap; @@ -37,9 +49,11 @@ import org.apache.hadoop.ozone.audit.AuditMessage; import org.apache.hadoop.ozone.s3.HeaderPreprocessor; import org.apache.hadoop.ozone.s3.exception.OS3Exception; +import org.apache.hadoop.ozone.s3.exception.OSTSException; import org.apache.hadoop.ozone.s3.exception.S3ErrorTable; import org.apache.hadoop.ozone.s3.signature.SignatureInfo.Version; import org.apache.hadoop.ozone.s3.util.AuditUtils; +import org.apache.kerby.util.Hex; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -57,11 +71,13 @@ public class AWSSignatureProcessor implements SignatureProcessor { private static final AuditLogger AUDIT = new AuditLogger(AuditLoggerType.S3GLOGGER); + private static final String X_AMZ_SECURITY_TOKEN = "x-amz-security-token"; + @Context private ContainerRequestContext context; @Override - public SignatureInfo parseSignature() throws OS3Exception { + public SignatureInfo parseSignature() throws OS3Exception, IOException, NoSuchAlgorithmException { LowerCaseKeyStringMap headers = LowerCaseKeyStringMap.fromHeaderMap(context.getHeaders()); @@ -92,13 +108,93 @@ public SignatureInfo parseSignature() throws OS3Exception { } } if (signatureInfo == null) { - signatureInfo = new SignatureInfo.Builder(Version.NONE).build(); + signatureInfo = new SignatureInfo.Builder(Version.NONE).setService("s3").build(); + } + + // Capture STS session token if present (header-based or query-based). + // - Header-based SigV4: x-amz-security-token + // - Query-based (for presigned URLs): X-Amz-Security-Token + final String sessionToken = extractSessionToken(headers); + if (sessionToken != null && !sessionToken.isEmpty()) { + signatureInfo.setSessionToken(sessionToken); } + + String payloadHash = getPayloadHash(headers, signatureInfo); + signatureInfo.setPayloadHash(payloadHash); signatureInfo.setUnfilteredURI( context.getUriInfo().getRequestUri().getPath()); return signatureInfo; } + private String extractSessionToken(LowerCaseKeyStringMap headers) { + // Header-based token + final String headerToken = headers.get(X_AMZ_SECURITY_TOKEN); + if (headerToken != null && !headerToken.isEmpty()) { + return headerToken; + } + + // Query-based token - this would be used for presigned URLs + final MultivaluedMap queryParams = context.getUriInfo().getQueryParameters(); + if (queryParams == null) { + return null; + } + for (Map.Entry> entry : queryParams.entrySet()) { + final String key = entry.getKey(); + if (Strings.isNullOrEmpty(key)) { + continue; + } + if (key.compareToIgnoreCase(X_AMZ_SECURITY_TOKEN) == 0) { + final List values = entry.getValue(); + if (values != null && !values.isEmpty()) { + return values.get(0); + } + } + } + return null; + } + + private String getPayloadHash(Map headers, SignatureInfo signatureInfo) + throws OS3Exception, NoSuchAlgorithmException, IOException { + if (signatureInfo.getVersion() == Version.V2) { + throw S3ErrorTable.newError(S3_AUTHINFO_CREATION_ERROR); + } + if (signatureInfo.getService().equals("s3")) { + if (!signatureInfo.isSignPayload()) { + // According to AWS Signature V4 documentation using Query Parameters + // https://docs.aws.amazon.com/AmazonS3/latest/API/sigv4-query-string-auth.html + return UNSIGNED_PAYLOAD; + } + String contentSignatureHeaderValue = headers.get(X_AMZ_CONTENT_SHA256); + // According to AWS Signature V4 documentation using Authorization Header + // https://docs.aws.amazon.com/AmazonS3/latest/API/sig-v4-header-based-auth.html + // The x-amz-content-sha256 header is required + // for all AWS Signature Version 4 requests using Authorization header. + if (contentSignatureHeaderValue == null) { + LOG.error("The request must include " + X_AMZ_CONTENT_SHA256 + + " header for signed payload"); + throw S3ErrorTable.newError(S3_AUTHINFO_CREATION_ERROR); + } + // Simply return the header value of x-amz-content-sha256 as the payload hash + // These are the possible cases: + // 1. Actual payload checksum for single chunk upload + // 2. Unsigned payloads for multiple chunks upload + // - UNSIGNED-PAYLOAD + // - STREAMING-UNSIGNED-PAYLOAD-TRAILER + // 3. Signed payloads for multiple chunks upload + // - STREAMING-AWS4-HMAC-SHA256-PAYLOAD + // - STREAMING-AWS4-HMAC-SHA256-PAYLOAD-TRAILER + // - STREAMING-AWS4-ECDSA-P256-SHA256-PAYLOAD + // - STREAMING-AWS4-ECDSA-P256-SHA256-PAYLOAD-TRAILER + return contentSignatureHeaderValue; + } + // For STS payload hash is calculated over the body + InputStream in = context.getEntityStream(); + byte[] body = readAllBytes(in); + String payloadHash = Hex.encode(MessageDigest.getInstance("SHA-256").digest(body)); + context.setEntityStream(new ByteArrayInputStream(body)); + return payloadHash; + } + private AuditMessage buildAuthFailureMessage(MalformedResourceException e) { AuditMessage message = new AuditMessage.Builder() .forOperation(AuthOperation.fromContext(context)) @@ -110,6 +206,21 @@ private AuditMessage buildAuthFailureMessage(MalformedResourceException e) { return message; } + private byte[] readAllBytes(InputStream in) throws OS3Exception, IOException { + ByteArrayOutputStream buffer = new ByteArrayOutputStream(); + byte[] chunk = new byte[8192]; + int totalRead = 0; + int n; + while ((n = in.read(chunk)) != -1) { + if (totalRead + n > OZONE_S3G_STS_PAYLOAD_HASH_MAX_VALUE) { + throw new OSTSException(PAYLOAD_TOO_LARGE); + } + buffer.write(chunk, 0, n); + totalRead += n; + } + return buffer.toByteArray(); + } + @VisibleForTesting public void setContext(ContainerRequestContext context) { this.context = context; diff --git a/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/signature/AuthorizationV4HeaderParser.java b/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/signature/AuthorizationV4HeaderParser.java index b327280d070c..ed4ef4b13dd8 100644 --- a/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/signature/AuthorizationV4HeaderParser.java +++ b/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/signature/AuthorizationV4HeaderParser.java @@ -90,6 +90,7 @@ public SignatureInfo parseSignature() throws MalformedResourceException { .setCredentialScope(credentialObj.createScope()) .setAlgorithm(algorithm) .setSignPayload(true) + .setService(credentialObj.getAwsService()) .build(); } diff --git a/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/signature/AuthorizationV4QueryParser.java b/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/signature/AuthorizationV4QueryParser.java index d8c5a2ce23ee..3dc6d3218d16 100644 --- a/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/signature/AuthorizationV4QueryParser.java +++ b/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/signature/AuthorizationV4QueryParser.java @@ -91,7 +91,9 @@ public SignatureInfo parseSignature() throws MalformedResourceException { .setSignedHeaders(queryParameters.get("X-Amz-SignedHeaders")) .setCredentialScope(credential.createScope()) .setAlgorithm(queryParameters.get("X-Amz-Algorithm")) + .setService(credential.getAwsService()) .setSignPayload(false) + .setPayloadHash("UNSIGNED-PAYLOAD") .build(); } diff --git a/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/signature/SignatureInfo.java b/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/signature/SignatureInfo.java index 730481bce214..52c7b00ccd00 100644 --- a/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/signature/SignatureInfo.java +++ b/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/signature/SignatureInfo.java @@ -55,6 +55,17 @@ public class SignatureInfo { private String stringToSign = null; + private String payloadHash = null; + + private String service = null; + + /** + * Optional AWS session token (x-amz-security-token / X-Amz-Security-Token). + *

+ * This is required for STS temporary credentials when calling S3 APIs. + */ + private String sessionToken = null; + public SignatureInfo() { } private SignatureInfo(Builder b) { @@ -72,7 +83,10 @@ public void initialize(SignatureInfo signatureInfo) { .setAlgorithm(signatureInfo.getAlgorithm()) .setSignPayload(signatureInfo.isSignPayload()) .setUnfilteredURI(signatureInfo.getUnfilteredURI()) - .setStringToSign(signatureInfo.getStringToSign())); + .setStringToSign(signatureInfo.getStringToSign()) + .setPayloadHash(signatureInfo.getPayloadHash()) + .setService(signatureInfo.getService()) + .setSessionToken(signatureInfo.getSessionToken())); } private void initialize(Builder b) { @@ -87,6 +101,9 @@ private void initialize(Builder b) { this.signPayload = b.signPayload; this.unfilteredURI = b.unfilteredURI; this.stringToSign = b.stringToSign; + this.payloadHash = b.payloadHash; + this.service = b.service; + this.sessionToken = b.sessionToken; } public String getAwsAccessId() { @@ -141,6 +158,30 @@ public void setStrToSign(String strToSign) { this.stringToSign = strToSign; } + public String getPayloadHash() { + return this.payloadHash; + } + + public void setPayloadHash(String payloadHash) { + this.payloadHash = payloadHash; + } + + public String getService() { + return service; + } + + public void setService(String service) { + this.service = service; + } + + public String getSessionToken() { + return sessionToken; + } + + public void setSessionToken(String sessionToken) { + this.sessionToken = sessionToken; + } + /** * Signature version. */ @@ -163,6 +204,9 @@ public static class Builder { private boolean signPayload = true; private String unfilteredURI = null; private String stringToSign = null; + private String payloadHash = null; + private String service = null; + private String sessionToken = null; public Builder(Version version) { this.version = version; @@ -218,6 +262,21 @@ public Builder setStringToSign(String stringToSign) { return this; } + public Builder setPayloadHash(String payloadHash) { + this.payloadHash = payloadHash; + return this; + } + + public Builder setService(String service) { + this.service = service; + return this; + } + + public Builder setSessionToken(String sessionToken) { + this.sessionToken = sessionToken; + return this; + } + public SignatureInfo build() { return new SignatureInfo(this); } diff --git a/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/signature/SignatureProcessor.java b/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/signature/SignatureProcessor.java index 32213e41dafb..36788b4e4ea4 100644 --- a/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/signature/SignatureProcessor.java +++ b/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/signature/SignatureProcessor.java @@ -17,6 +17,8 @@ package org.apache.hadoop.ozone.s3.signature; +import java.io.IOException; +import java.security.NoSuchAlgorithmException; import java.time.format.DateTimeFormatter; import org.apache.hadoop.ozone.s3.exception.OS3Exception; @@ -39,5 +41,5 @@ public interface SignatureProcessor { /** * API to return string to sign. */ - SignatureInfo parseSignature() throws OS3Exception; + SignatureInfo parseSignature() throws OS3Exception, IOException, NoSuchAlgorithmException; } diff --git a/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/signature/StringToSignProducer.java b/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/signature/StringToSignProducer.java index 5e1ae4d89cdd..69cc088e184b 100644 --- a/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/signature/StringToSignProducer.java +++ b/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/signature/StringToSignProducer.java @@ -20,7 +20,6 @@ import static java.time.temporal.ChronoUnit.SECONDS; import static org.apache.hadoop.ozone.s3.exception.S3ErrorTable.S3_AUTHINFO_CREATION_ERROR; import static org.apache.hadoop.ozone.s3.exception.S3ErrorTable.newError; -import static org.apache.hadoop.ozone.s3.util.S3Consts.UNSIGNED_PAYLOAD; import static org.apache.hadoop.ozone.s3.util.S3Consts.X_AMZ_CONTENT_SHA256; import com.google.common.annotations.VisibleForTesting; @@ -130,7 +129,7 @@ public static String createSignatureBase( signatureInfo.getSignedHeaders(), headers, queryParams, - !signatureInfo.isSignPayload()); + signatureInfo.getPayloadHash()); strToSign.append(hash(canonicalRequest)); if (LOG.isDebugEnabled()) { LOG.debug("canonicalRequest:[{}]", canonicalRequest); @@ -164,7 +163,7 @@ public static String buildCanonicalRequest( String signedHeaders, Map headers, Map queryParams, - boolean unsignedPayload + String payloadHash ) throws OS3Exception { Iterable parts = split("/", uri); @@ -183,6 +182,9 @@ public static String buildCanonicalRequest( .append(':'); if (headers.containsKey(header)) { String headerValue = headers.get(header); + if (header.equals("content-type")) { + headerValue = headerValue.toLowerCase(); + } canonicalHeaders.append(headerValue) .append(NEWLINE); @@ -201,10 +203,7 @@ public static String buildCanonicalRequest( } } - validateCanonicalHeaders(canonicalHeaders.toString(), headers, - unsignedPayload); - - String payloadHash = getPayloadHash(headers, unsignedPayload); + validateCanonicalHeaders(canonicalHeaders.toString(), headers); return method + NEWLINE + canonicalUri + NEWLINE @@ -213,38 +212,7 @@ public static String buildCanonicalRequest( + signedHeaders + NEWLINE + payloadHash; } - - private static String getPayloadHash(Map headers, boolean isUsingQueryParameter) - throws OS3Exception { - if (isUsingQueryParameter) { - // According to AWS Signature V4 documentation using Query Parameters - // https://docs.aws.amazon.com/AmazonS3/latest/API/sigv4-query-string-auth.html - return UNSIGNED_PAYLOAD; - } - String contentSignatureHeaderValue = headers.get(X_AMZ_CONTENT_SHA256); - // According to AWS Signature V4 documentation using Authorization Header - // https://docs.aws.amazon.com/AmazonS3/latest/API/sig-v4-header-based-auth.html - // The x-amz-content-sha256 header is required - // for all AWS Signature Version 4 requests using Authorization header. - if (contentSignatureHeaderValue == null) { - LOG.error("The request must include " + X_AMZ_CONTENT_SHA256 - + " header for signed payload"); - throw newError(S3_AUTHINFO_CREATION_ERROR); - } - // Simply return the header value of x-amz-content-sha256 as the payload hash - // These are the possible cases: - // 1. Actual payload checksum for single chunk upload - // 2. Unsigned payloads for multiple chunks upload - // - UNSIGNED-PAYLOAD - // - STREAMING-UNSIGNED-PAYLOAD-TRAILER - // 3. Signed payloads for multiple chunks upload - // - STREAMING-AWS4-HMAC-SHA256-PAYLOAD - // - STREAMING-AWS4-HMAC-SHA256-PAYLOAD-TRAILER - // - STREAMING-AWS4-ECDSA-P256-SHA256-PAYLOAD - // - STREAMING-AWS4-ECDSA-P256-SHA256-PAYLOAD-TRAILER - return contentSignatureHeaderValue; - } - + /** * String join that also works with empty strings. * @@ -357,9 +325,8 @@ static void validateSignedHeader( */ private static void validateCanonicalHeaders( String canonicalHeaders, - Map headers, - Boolean unsignedPaylod - ) throws OS3Exception { + Map headers) + throws OS3Exception { if (!canonicalHeaders.contains(HOST + ":")) { LOG.error("The SignedHeaders list must include HTTP Host header"); throw newError(S3_AUTHINFO_CREATION_ERROR); diff --git a/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/util/S3GActionIamMapper.java b/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/util/S3GActionIamMapper.java new file mode 100644 index 000000000000..223b057b5bb3 --- /dev/null +++ b/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/util/S3GActionIamMapper.java @@ -0,0 +1,93 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.hadoop.ozone.s3.util; + +import jakarta.annotation.Nullable; +import org.apache.hadoop.ozone.audit.S3GAction; + +/** + * Maps S3 Gateway operations to AWS IAM S3 action names. Values align with + * {@code org.apache.hadoop.ozone.security.acl.iam.IamSessionPolicyResolver} so STS session + * policies and Ranger policy conditions use the same vocabulary. + */ +public final class S3GActionIamMapper { + + private S3GActionIamMapper() { + } + + /** + * @return S3 action string, or null if not applicable to IAM S3 + */ + public static @Nullable String toS3ActionString(@Nullable S3GAction action) { + if (action == null) { + return null; + } + switch (action) { + case GET_BUCKET: + case HEAD_BUCKET: + return "ListBucket"; + case CREATE_BUCKET: + return "CreateBucket"; + case DELETE_BUCKET: + return "DeleteBucket"; + case GET_ACL: + return "GetBucketAcl"; + case PUT_ACL: + return "PutBucketAcl"; + case LIST_MULTIPART_UPLOAD: + return "ListBucketMultipartUploads"; + case MULTI_DELETE: + case DELETE_KEY: + return "DeleteObject"; + case LIST_S3_BUCKETS: + return "ListAllMyBuckets"; + case CREATE_MULTIPART_KEY: + case CREATE_KEY: + case INIT_MULTIPART_UPLOAD: + case COMPLETE_MULTIPART_UPLOAD: + case CREATE_DIRECTORY: + return "PutObject"; + case LIST_PARTS: + return "ListMultipartUploadParts"; + case GET_KEY: + case HEAD_KEY: + return "GetObject"; + case ABORT_MULTIPART_UPLOAD: + return "AbortMultipartUpload"; + case GET_OBJECT_TAGGING: + return "GetObjectTagging"; + case PUT_OBJECT_TAGGING: + return "PutObjectTagging"; + case DELETE_OBJECT_TAGGING: + return "DeleteObjectTagging"; + case PUT_OBJECT_ACL: + return "PutObjectAcl"; + case COPY_OBJECT: + case CREATE_MULTIPART_KEY_BY_COPY: + // CopyObject / UploadPartCopy require distinct source (GetObject) and destination (PutObject) + // authorization. The endpoint code explicitly sets the IAM action string for each phase. + return null; + case GENERATE_SECRET: + case REVOKE_SECRET: + case ASSUME_ROLE: + case GET_CALLER_IDENTITY: + default: + return null; + } + } +} diff --git a/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3sts/Application.java b/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3sts/Application.java new file mode 100644 index 000000000000..0d6e4b4c4c21 --- /dev/null +++ b/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3sts/Application.java @@ -0,0 +1,37 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.hadoop.ozone.s3sts; + +import org.apache.hadoop.ozone.s3.S3STSHeadersResponseFilter; +import org.apache.hadoop.ozone.s3.exception.OSTSExceptionMapper; +import org.apache.hadoop.ozone.s3.exception.OSTSNotFoundExceptionMapper; +import org.glassfish.jersey.server.ResourceConfig; + +/** + * JAX-RS application for the STS endpoint. + */ +public class Application extends ResourceConfig { + public Application() { + packages("org.apache.hadoop.ozone.s3sts"); + register(org.apache.hadoop.ozone.s3.AuthorizationFilter.class); + register(org.apache.hadoop.ozone.s3.ClientIpFilter.class); + register(OSTSExceptionMapper.class); + register(OSTSNotFoundExceptionMapper.class); + register(S3STSHeadersResponseFilter.class); + } +} diff --git a/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3sts/S3AssumeRoleResponseXml.java b/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3sts/S3AssumeRoleResponseXml.java new file mode 100644 index 000000000000..6c8b73906a76 --- /dev/null +++ b/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3sts/S3AssumeRoleResponseXml.java @@ -0,0 +1,162 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.hadoop.ozone.s3sts; + +import javax.xml.bind.annotation.XmlAccessType; +import javax.xml.bind.annotation.XmlAccessorType; +import javax.xml.bind.annotation.XmlElement; +import javax.xml.bind.annotation.XmlRootElement; + +/** + * JAXB model for AWS STS AssumeRoleResponse. + */ +@XmlAccessorType(XmlAccessType.FIELD) +@XmlRootElement(name = "AssumeRoleResponse", namespace = "https://sts.amazonaws.com/doc/2011-06-15/") +public class S3AssumeRoleResponseXml { + + @XmlElement(name = "AssumeRoleResult") + private AssumeRoleResult assumeRoleResult; + + @XmlElement(name = "ResponseMetadata") + private S3STSResponseMetadata responseMetadata; + + public AssumeRoleResult getAssumeRoleResult() { + return assumeRoleResult; + } + + public void setAssumeRoleResult(AssumeRoleResult assumeRoleResult) { + this.assumeRoleResult = assumeRoleResult; + } + + public S3STSResponseMetadata getResponseMetadata() { + return responseMetadata; + } + + public void setResponseMetadata(S3STSResponseMetadata responseMetadata) { + this.responseMetadata = responseMetadata; + } + + /** + * AssumeRoleResult element. + */ + @XmlAccessorType(XmlAccessType.FIELD) + public static class AssumeRoleResult { + @XmlElement(name = "Credentials") + private Credentials credentials; + + @XmlElement(name = "AssumedRoleUser") + private AssumedRoleUser assumedRoleUser; + + public Credentials getCredentials() { + return credentials; + } + + public void setCredentials(Credentials credentials) { + this.credentials = credentials; + } + + public AssumedRoleUser getAssumedRoleUser() { + return assumedRoleUser; + } + + public void setAssumedRoleUser(AssumedRoleUser assumedRoleUser) { + this.assumedRoleUser = assumedRoleUser; + } + } + + /** + * Credentials element. + */ + @XmlAccessorType(XmlAccessType.FIELD) + public static class Credentials { + @XmlElement(name = "AccessKeyId") + + private String accessKeyId; + @XmlElement(name = "SecretAccessKey") + + private String secretAccessKey; + @XmlElement(name = "SessionToken") + + private String sessionToken; + @XmlElement(name = "Expiration") + + private String expiration; + + public String getAccessKeyId() { + return accessKeyId; + } + + public void setAccessKeyId(String accessKeyId) { + this.accessKeyId = accessKeyId; + } + + public String getSecretAccessKey() { + return secretAccessKey; + } + + public void setSecretAccessKey(String secretAccessKey) { + this.secretAccessKey = secretAccessKey; + } + + public String getSessionToken() { + return sessionToken; + } + + public void setSessionToken(String sessionToken) { + this.sessionToken = sessionToken; + } + + public String getExpiration() { + return expiration; + } + + public void setExpiration(String expiration) { + this.expiration = expiration; + } + } + + /** + * AssumedRoleId element. + */ + @XmlAccessorType(XmlAccessType.FIELD) + public static class AssumedRoleUser { + @XmlElement(name = "AssumedRoleId") + private String assumedRoleId; + + @XmlElement(name = "Arn") + private String arn; + + public String getAssumedRoleId() { + return assumedRoleId; + } + + public void setAssumedRoleId(String assumedRoleId) { + this.assumedRoleId = assumedRoleId; + } + + public String getArn() { + return arn; + } + + public void setArn(String arn) { + this.arn = arn; + } + } +} + + diff --git a/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3sts/S3GetCallerIdentityResponseXml.java b/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3sts/S3GetCallerIdentityResponseXml.java new file mode 100644 index 000000000000..594ed12e55de --- /dev/null +++ b/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3sts/S3GetCallerIdentityResponseXml.java @@ -0,0 +1,92 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.hadoop.ozone.s3sts; + +import javax.xml.bind.annotation.XmlAccessType; +import javax.xml.bind.annotation.XmlAccessorType; +import javax.xml.bind.annotation.XmlElement; +import javax.xml.bind.annotation.XmlRootElement; + +/** + * JAXB model for AWS STS GetCallerIdentityResponse. + */ +@XmlAccessorType(XmlAccessType.FIELD) +@XmlRootElement(name = "GetCallerIdentityResponse", namespace = "https://sts.amazonaws.com/doc/2011-06-15/") +public class S3GetCallerIdentityResponseXml { + + @XmlElement(name = "GetCallerIdentityResult") + private GetCallerIdentityResult getCallerIdentityResult; + + @XmlElement(name = "ResponseMetadata") + private S3STSResponseMetadata responseMetadata; + + public GetCallerIdentityResult getGetCallerIdentityResult() { + return getCallerIdentityResult; + } + + public void setGetCallerIdentityResult(GetCallerIdentityResult getCallerIdentityResult) { + this.getCallerIdentityResult = getCallerIdentityResult; + } + + public S3STSResponseMetadata getResponseMetadata() { + return responseMetadata; + } + + public void setResponseMetadata(S3STSResponseMetadata responseMetadata) { + this.responseMetadata = responseMetadata; + } + + /** + * GetCallerIdentityResult element. + */ + @XmlAccessorType(XmlAccessType.FIELD) + public static class GetCallerIdentityResult { + @XmlElement(name = "Arn") + private String arn; + + @XmlElement(name = "UserId") + private String userId; + + @XmlElement(name = "Account") + private String account; + + public String getArn() { + return arn; + } + + public void setArn(String arn) { + this.arn = arn; + } + + public String getUserId() { + return userId; + } + + public void setUserId(String userId) { + this.userId = userId; + } + + public String getAccount() { + return account; + } + + public void setAccount(String account) { + this.account = account; + } + } +} diff --git a/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3sts/S3STSConfigKeys.java b/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3sts/S3STSConfigKeys.java new file mode 100644 index 000000000000..aca0cbd470bd --- /dev/null +++ b/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3sts/S3STSConfigKeys.java @@ -0,0 +1,53 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.hadoop.ozone.s3sts; + +import org.apache.hadoop.ozone.OzoneConfigKeys; + +/** + * This class contains constants for configuration keys used + * in S3 STS endpoint. + */ +public final class S3STSConfigKeys { + public static final String OZONE_S3G_STS_HTTP_ENABLED_KEY = + OzoneConfigKeys.OZONE_S3G_STS_HTTP_ENABLED_KEY; + public static final String OZONE_S3G_STS_HTTP_BIND_HOST_KEY = + "ozone.s3g.sts.http-bind-host"; + public static final String OZONE_S3G_STS_HTTPS_BIND_HOST_KEY = + "ozone.s3g.sts.https-bind-host"; + public static final String OZONE_S3G_STS_HTTP_ADDRESS_KEY = + "ozone.s3g.sts.http-address"; + public static final String OZONE_S3G_STS_HTTPS_ADDRESS_KEY = + "ozone.s3g.sts.https-address"; + public static final int OZONE_S3G_STS_HTTP_BIND_PORT_DEFAULT = 9880; + public static final int OZONE_S3G_STS_HTTPS_BIND_PORT_DEFAULT = 9881; + // Max payload default size for STS AssumeRole API calls (32 KB) + // as STS AssumeRole has these parameters required in payload: + // Action=AssumeRole&RoleArn=...&RoleSessionName=...&DurationSeconds=... + // where RoleArn max length is 2048 and max bytes per character in UTF-8 encoding is 12 + // (2048 * 12 = 24576) + other parameters and overheads, so setting to 32 KB + // this limit can be adjusted via configuration if needed. + public static final int OZONE_S3G_STS_PAYLOAD_HASH_MAX_VALUE = 32768; + + /** + * Never constructed. + */ + private S3STSConfigKeys() { + + } +} diff --git a/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3sts/S3STSEnabled.java b/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3sts/S3STSEnabled.java new file mode 100644 index 000000000000..84712a5fd1f9 --- /dev/null +++ b/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3sts/S3STSEnabled.java @@ -0,0 +1,35 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.hadoop.ozone.s3sts; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; +import javax.ws.rs.NameBinding; + +/** + * Annotation to disable S3 STS Endpoint. + */ +@NameBinding +@Retention(RetentionPolicy.RUNTIME) +@Target({ElementType.TYPE, ElementType.METHOD}) +public @interface S3STSEnabled { +} + + diff --git a/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3sts/S3STSEnabledEndpointRequestFilter.java b/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3sts/S3STSEnabledEndpointRequestFilter.java new file mode 100644 index 000000000000..08a93aeab3bc --- /dev/null +++ b/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3sts/S3STSEnabledEndpointRequestFilter.java @@ -0,0 +1,53 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.hadoop.ozone.s3sts; + +import static org.apache.hadoop.ozone.s3sts.S3STSConfigKeys.OZONE_S3G_STS_HTTP_ENABLED_KEY; + +import java.io.IOException; +import javax.inject.Inject; +import javax.ws.rs.container.ContainerRequestContext; +import javax.ws.rs.container.ContainerRequestFilter; +import javax.ws.rs.core.MediaType; +import javax.ws.rs.core.Response; +import javax.ws.rs.ext.Provider; +import org.apache.hadoop.hdds.conf.OzoneConfiguration; + +/** + * Filter that disables all endpoints annotated with {@link S3STSEnabled}. + * Condition is based on the value of the configuration key + * ozone.s3g.sts.http.enabled. + */ +@S3STSEnabled +@Provider +public class S3STSEnabledEndpointRequestFilter implements ContainerRequestFilter { + @Inject + private OzoneConfiguration ozoneConfiguration; + + @Override + public void filter(ContainerRequestContext requestContext) throws IOException { + boolean isSTSEnabled = ozoneConfiguration.getBoolean( + OZONE_S3G_STS_HTTP_ENABLED_KEY, false); + if (!isSTSEnabled) { + requestContext.abortWith(Response.status(Response.Status.NOT_IMPLEMENTED) + .entity("STS endpoint is disabled.") + .type(MediaType.APPLICATION_XML_TYPE) + .build()); + } + } +} diff --git a/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3sts/S3STSEndpoint.java b/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3sts/S3STSEndpoint.java new file mode 100644 index 000000000000..2c6200d1e661 --- /dev/null +++ b/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3sts/S3STSEndpoint.java @@ -0,0 +1,531 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.hadoop.ozone.s3sts; + +import static javax.ws.rs.core.Response.Status.BAD_REQUEST; +import static org.apache.hadoop.ozone.s3.exception.S3ErrorTable.ACCESS_DENIED; +import static org.apache.hadoop.ozone.s3.exception.S3ErrorTable.STS_INTERNAL_FAILURE; +import static org.apache.hadoop.ozone.s3.exception.S3ErrorTable.STS_INVALID_ACTION; +import static org.apache.hadoop.ozone.s3.exception.S3ErrorTable.STS_INVALID_ACTION_NOT_IMPLEMENTED; +import static org.apache.hadoop.ozone.s3.exception.S3ErrorTable.STS_INVALID_CLIENT_TOKEN_ID; +import static org.apache.hadoop.ozone.s3.exception.S3ErrorTable.STS_MALFORMED_POLICY_DOCUMENT; +import static org.apache.hadoop.ozone.s3.exception.S3ErrorTable.STS_UNSUPPORTED_OPERATION; +import static org.apache.hadoop.ozone.s3.exception.S3ErrorTable.STS_VALIDATION_ERROR; + +import com.google.common.annotations.VisibleForTesting; +import com.google.common.collect.ImmutableSet; +import java.io.IOException; +import java.io.StringWriter; +import java.time.Instant; +import java.time.ZoneOffset; +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import javax.inject.Inject; +import javax.ws.rs.Consumes; +import javax.ws.rs.GET; +import javax.ws.rs.POST; +import javax.ws.rs.Path; +import javax.ws.rs.Produces; +import javax.ws.rs.QueryParam; +import javax.ws.rs.core.Form; +import javax.ws.rs.core.MediaType; +import javax.ws.rs.core.MultivaluedMap; +import javax.ws.rs.core.Response; +import javax.xml.bind.JAXBContext; +import javax.xml.bind.JAXBException; +import javax.xml.bind.Marshaller; +import org.apache.commons.lang3.StringUtils; +import org.apache.commons.lang3.Strings; +import org.apache.hadoop.ozone.audit.S3GAction; +import org.apache.hadoop.ozone.om.exceptions.OMException; +import org.apache.hadoop.ozone.om.helpers.AssumeRoleResponseInfo; +import org.apache.hadoop.ozone.om.helpers.AwsRoleArnValidator; +import org.apache.hadoop.ozone.om.helpers.CallerIdentityInfo; +import org.apache.hadoop.ozone.om.helpers.S3STSUtils; +import org.apache.hadoop.ozone.s3.RequestIdentifier; +import org.apache.hadoop.ozone.s3.exception.OS3Exception; +import org.apache.hadoop.ozone.s3.exception.OSTSException; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * AWS STS (Security Token Service) compatible endpoint for Ozone S3 Gateway. + *

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

+ * Currently supports AssumeRole and GetCallerIdentity operations. Other STS + * operations will return appropriate error responses. + * + * @see AWS STS API Reference + */ +@Path("/") +@S3STSEnabled +public class S3STSEndpoint extends S3STSEndpointBase { + + private static final Logger LOG = LoggerFactory.getLogger(S3STSEndpoint.class); + + // STS API constants + private static final String ASSUME_ROLE_ACTION = "AssumeRole"; + private static final String GET_SESSION_TOKEN_ACTION = "GetSessionToken"; + private static final String ASSUME_ROLE_WITH_SAML_ACTION = "AssumeRoleWithSAML"; + private static final String ASSUME_ROLE_WITH_WEB_IDENTITY_ACTION = "AssumeRoleWithWebIdentity"; + private static final String GET_CALLER_IDENTITY_ACTION = "GetCallerIdentity"; + private static final String DECODE_AUTHORIZATION_MESSAGE_ACTION = "DecodeAuthorizationMessage"; + private static final String GET_ACCESS_KEY_INFO_ACTION = "GetAccessKeyInfo"; + + private static final String EXPECTED_VERSION = "2011-06-15"; + + private static final String SIGV4_PARAM_PREFIX = "X-Amz-"; + + private static final Set ASSUME_ROLE_ALLOWED_PARAMS = ImmutableSet.of( + "Action", "RoleArn", "RoleSessionName", "DurationSeconds", "Version", "Policy"); + + private static final String POLICY_ARNS_MEMBER_PREFIX = "PolicyArns.member."; + private static final String PROVIDED_CONTEXTS_MEMBER_PREFIX = "ProvidedContexts.member."; + private static final String TAGS_MEMBER_PREFIX = "Tags.member."; + private static final String TRANSITIVE_TAG_KEYS_MEMBER_PREFIX = "TransitiveTagKeys.member."; + + private static final Set AWS_VALID_ASSUME_ROLE_OPTIONAL_PARAMS = ImmutableSet.of( + "ExternalId", "SerialNumber", "SourceIdentity", "TokenCode"); + + // JAXBContext is relatively expensive to create and is threadsafe, so cache and reuse + private static final JAXBContext JAXB_CONTEXT; + + static { + try { + JAXB_CONTEXT = JAXBContext.newInstance( + S3AssumeRoleResponseXml.class, S3GetCallerIdentityResponseXml.class, S3STSResponseMetadata.class); + } catch (JAXBException e) { + throw new RuntimeException("Failed to initialize JAXBContext: " + e, e); + } + } + + @Inject + private RequestIdentifier requestIdentifier; + + @VisibleForTesting + public void setRequestIdentifier(RequestIdentifier requestIdentifier) { + this.requestIdentifier = requestIdentifier; + } + + /** + * STS endpoint that handles GET requests with query parameters. + * AWS STS supports both GET and POST requests. + * + * @param action The STS action to perform (AssumeRole, GetSessionToken, etc.) + * @param roleArn The ARN of the role to assume (for AssumeRole) + * @param roleSessionName Session name for the role (for AssumeRole) + * @param durationSeconds Duration of the token validity in seconds + * @param version AWS STS API version (should be "2011-06-15") + * @return Response containing STS response XML or error + */ + @GET + @Produces(MediaType.APPLICATION_XML) + public Response get( + @QueryParam("Action") String action, + @QueryParam("RoleArn") String roleArn, + @QueryParam("RoleSessionName") String roleSessionName, + @QueryParam("DurationSeconds") Integer durationSeconds, + @QueryParam("Version") String version, + @QueryParam("Policy") String awsIamSessionPolicy) throws OS3Exception { + + return handleSTSRequest( + getQueryParameters().keySet(), action, roleArn, roleSessionName, durationSeconds, version, awsIamSessionPolicy); + } + + /** + * STS endpoint that handles POST requests with form data. + * AWS STS typically uses POST requests with form-encoded parameters. + * + * @param form form-encoded request parameters + * @return Response containing STS response XML or error + */ + @POST + @Consumes(MediaType.APPLICATION_FORM_URLENCODED) + @Produces(MediaType.APPLICATION_XML) + public Response post(Form form) throws OS3Exception { + if (form == null) { + return unknownOperationExceptionResponse(); + } + + final MultivaluedMap formParams = form.asMap(); + final String action = formParams.getFirst("Action"); + final String roleArn = formParams.getFirst("RoleArn"); + final String roleSessionName = formParams.getFirst("RoleSessionName"); + final Integer durationSeconds = parseIntegerOrNull(formParams.getFirst("DurationSeconds")); + final String version = formParams.getFirst("Version"); + final String awsIamSessionPolicy = formParams.getFirst("Policy"); + + return handleSTSRequest( + formParams.keySet(), action, roleArn, roleSessionName, durationSeconds, version, awsIamSessionPolicy); + } + + private Response handleSTSRequest(Set paramNamesToValidate, String action, String roleArn, + String roleSessionName, Integer durationSeconds, String version, String awsIamSessionPolicy) throws OS3Exception { + final String requestId = requestIdentifier.getRequestId(); + // NOTE: invalid, missing or unsupported actions are not added to the audit log + try { + if (action == null) { + // Amazon STS has a different structure for the XML error response when the action is missing + return unknownOperationExceptionResponse(); + } + + switch (action) { + case ASSUME_ROLE_ACTION: + return handleAssumeRole( + paramNamesToValidate, roleArn, roleSessionName, durationSeconds, awsIamSessionPolicy, version, requestId); + case GET_CALLER_IDENTITY_ACTION: + return handleGetCallerIdentity(version, requestId); + // These operations are not supported yet + case GET_SESSION_TOKEN_ACTION: + case ASSUME_ROLE_WITH_SAML_ACTION: + case ASSUME_ROLE_WITH_WEB_IDENTITY_ACTION: + case DECODE_AUTHORIZATION_MESSAGE_ACTION: + case GET_ACCESS_KEY_INFO_ACTION: + throw new OSTSException(STS_INVALID_ACTION_NOT_IMPLEMENTED) + .withMessage("Operation " + action + " is not supported yet."); + default: + throw new OSTSException(STS_INVALID_ACTION) + .withMessage("Could not find operation " + action + " for version " + + (version == null ? "NO_VERSION_SPECIFIED. Expected version is: " + EXPECTED_VERSION : version)); + } + } catch (OSTSException e) { + throw e; + } catch (Exception ex) { + LOG.error("Unexpected error during STS request", ex); + throw new OSTSException(STS_INTERNAL_FAILURE, ex).withType("Receiver"); + } + } + + private Response handleAssumeRole(Set paramNamesToValidate, String roleArn, String roleSessionName, + Integer durationSeconds, String awsIamSessionPolicy, String version, String requestId) throws OSTSException { + final String action = "AssumeRole"; + final Map auditParams = getAuditParameters(); + S3STSUtils.addAssumeRoleAuditParams( + auditParams, roleArn, roleSessionName, awsIamSessionPolicy, + durationSeconds == null ? S3STSUtils.DEFAULT_DURATION_SECONDS : durationSeconds, + requestId); + + // Validate parameters + if (version == null || !version.equals(EXPECTED_VERSION)) { + final OSTSException exception = new OSTSException(STS_INVALID_ACTION) + .withMessage("Could not find operation " + action + " for version " + + (version == null ? "NO_VERSION_SPECIFIED. Expected version is: " + EXPECTED_VERSION : version)); + getAuditLogger().logWriteFailure(buildAuditMessageForFailure(S3GAction.ASSUME_ROLE, auditParams, exception)); + throw exception; + } + + final AssumeRoleParamValidationResult assumeRoleParamValidationResult = validateAssumeRoleParameters( + paramNamesToValidate); + if (!assumeRoleParamValidationResult.getNotImplementedOptionalParams().isEmpty()) { + final OSTSException exception = new OSTSException(STS_UNSUPPORTED_OPERATION).withMessage( + "AssumeRole optional parameter(s) not implemented: " + + String.join(", ", assumeRoleParamValidationResult.getNotImplementedOptionalParams())); + getAuditLogger().logWriteFailure(buildAuditMessageForFailure(S3GAction.ASSUME_ROLE, auditParams, exception)); + throw exception; + } + + final Set validationErrors = new HashSet<>(); + int duration = durationSeconds == null ? S3STSUtils.DEFAULT_DURATION_SECONDS : durationSeconds; + try { + duration = S3STSUtils.validateDuration(durationSeconds); + } catch (OMException e) { + validationErrors.add(e.getMessage()); + } + + try { + AwsRoleArnValidator.validateAndExtractRoleNameFromArn(roleArn); + } catch (OMException e) { + validationErrors.add(e.getMessage()); + } + + try { + S3STSUtils.validateRoleSessionName(roleSessionName); + } catch (OMException e) { + validationErrors.add(e.getMessage()); + } + + try { + if (LOG.isDebugEnabled() && StringUtils.isNotEmpty(awsIamSessionPolicy)) { + LOG.debug( + "AssumeRole requestId={} received Policy(len={}): {}", requestId, awsIamSessionPolicy.length(), + awsIamSessionPolicy); + } + S3STSUtils.validateSessionPolicy(awsIamSessionPolicy); + } catch (OMException e) { + validationErrors.add(e.getMessage()); + } + + if (!assumeRoleParamValidationResult.getUnsupportedParams().isEmpty()) { + validationErrors.add("Unsupported AssumeRole parameter(s): " + String.join(", ", + assumeRoleParamValidationResult.getUnsupportedParams())); + } + + final int numValidationErrors = validationErrors.size(); + if (numValidationErrors > 0) { + //noinspection StringBufferReplaceableByString + final StringBuilder builder = new StringBuilder() + .append(numValidationErrors) + .append(" validation ") + .append(numValidationErrors > 1 ? "errors detected: " : "error detected: ") + .append(String.join(";", validationErrors)); + final String validationMessage = builder.toString(); + final OSTSException exception = new OSTSException(STS_VALIDATION_ERROR).withMessage(validationMessage); + getAuditLogger().logWriteFailure(buildAuditMessageForFailure(S3GAction.ASSUME_ROLE, auditParams, exception)); + throw exception; + } + + final String assumedRoleUserArn = S3STSUtils.toAssumedRoleUserArn(roleArn, roleSessionName); + try { + final AssumeRoleResponseInfo responseInfo = getClient() + .getObjectStore() + .assumeRole(roleArn, roleSessionName, duration, awsIamSessionPolicy, requestId); + // Generate AssumeRole response + final String responseXml = generateAssumeRoleResponse(assumedRoleUserArn, responseInfo, requestId); + + getAuditLogger().logWriteSuccess(buildAuditMessageForSuccess(S3GAction.ASSUME_ROLE, auditParams)); + + return Response.ok(responseXml) + .header("Content-Type", "text/xml") + .build(); + } catch (IOException e) { + throw toStsProcessingException( + S3GAction.ASSUME_ROLE, auditParams, e, action, "User is not authorized to perform: sts:AssumeRole on " + + "resource: " + roleArn); + } catch (Exception e) { + getAuditLogger().logWriteFailure(buildAuditMessageForFailure(S3GAction.ASSUME_ROLE, auditParams, e)); + throw e; + } + } + + private Response handleGetCallerIdentity(String version, String requestId) throws OSTSException { + final String action = GET_CALLER_IDENTITY_ACTION; + final Map auditParams = getAuditParameters(); + auditParams.put("action", action); + auditParams.put("requestId", requestId); + + if (version == null || !version.equals(EXPECTED_VERSION)) { + final OSTSException exception = new OSTSException(STS_INVALID_ACTION) + .withMessage("Could not find operation " + action + " for version " + + (version == null ? "NO_VERSION_SPECIFIED. Expected version is: " + EXPECTED_VERSION : version)); + getAuditLogger().logWriteFailure(buildAuditMessageForFailure( + S3GAction.GET_CALLER_IDENTITY, auditParams, exception)); + throw exception; + } + + try { + final CallerIdentityInfo identityInfo = getClient().getObjectStore().getCallerIdentity(); + final String responseXml = generateGetCallerIdentityResponse(identityInfo, requestId); + getAuditLogger().logWriteSuccess(buildAuditMessageForSuccess(S3GAction.GET_CALLER_IDENTITY, auditParams)); + return Response.ok(responseXml) + .header("Content-Type", "text/xml") + .build(); + } catch (IOException e) { + throw toStsProcessingException( + S3GAction.GET_CALLER_IDENTITY, auditParams, e, action, "User is not authorized to perform: " + + "sts:GetCallerIdentity"); + } catch (Exception e) { + getAuditLogger().logWriteFailure(buildAuditMessageForFailure(S3GAction.GET_CALLER_IDENTITY, auditParams, e)); + throw e; + } + } + + private OSTSException toStsProcessingException(S3GAction auditAction, Map auditParams, IOException e, + String operationName, String accessDeniedMessage) { + LOG.error("Error during {} processing", operationName, e); + getAuditLogger().logWriteFailure(buildAuditMessageForFailure(auditAction, auditParams, e)); + + if (e instanceof OMException) { + final OMException omException = (OMException) e; + if (omException.getResult() == OMException.ResultCodes.ACCESS_DENIED || + omException.getResult() == OMException.ResultCodes.PERMISSION_DENIED || + omException.getResult() == OMException.ResultCodes.TOKEN_EXPIRED) { + return new OSTSException(ACCESS_DENIED).withMessage(accessDeniedMessage); + } + if (omException.getResult() == OMException.ResultCodes.INVALID_TOKEN) { + return new OSTSException(STS_INVALID_CLIENT_TOKEN_ID); + } + if (omException.getResult() == OMException.ResultCodes.NOT_SUPPORTED_OPERATION || + omException.getResult() == OMException.ResultCodes.FEATURE_NOT_ENABLED) { + return new OSTSException(STS_UNSUPPORTED_OPERATION).withMessage(omException.getMessage()); + } + if (omException.getResult() == OMException.ResultCodes.INVALID_REQUEST) { + return new OSTSException(STS_VALIDATION_ERROR).withMessage(omException.getMessage()); + } + if (omException.getResult() == OMException.ResultCodes.MALFORMED_POLICY_DOCUMENT) { + return new OSTSException(STS_MALFORMED_POLICY_DOCUMENT).withMessage(omException.getMessage()); + } + } + return new OSTSException(STS_INTERNAL_FAILURE, e).withType("Receiver"); + } + + private AssumeRoleParamValidationResult validateAssumeRoleParameters(Set paramNamesToValidate) { + if (paramNamesToValidate == null || paramNamesToValidate.isEmpty()) { + return AssumeRoleParamValidationResult.empty(); + } + + final List notImplementedOptionalParams = new ArrayList<>(); + final List unsupportedParams = new ArrayList<>(); + for (String paramName : paramNamesToValidate) { + if (isAllowedAssumeRoleParameter(paramName)) { + continue; + } + + if (isAwsValidButNotImplementedAssumeRoleParameter(paramName)) { + notImplementedOptionalParams.add(paramName); + } else { + unsupportedParams.add(paramName); + } + } + + Collections.sort(notImplementedOptionalParams); + Collections.sort(unsupportedParams); + return new AssumeRoleParamValidationResult(notImplementedOptionalParams, unsupportedParams); + } + + private static boolean isAllowedAssumeRoleParameter(String paramName) { + return ASSUME_ROLE_ALLOWED_PARAMS.contains(paramName) || Strings.CI.startsWith(paramName, SIGV4_PARAM_PREFIX); + } + + private static Response unknownOperationExceptionResponse() { + return Response.status(BAD_REQUEST) + .entity("") + .type(MediaType.APPLICATION_XML) + .build(); + } + + private static boolean isAwsValidButNotImplementedAssumeRoleParameter(String paramName) { + if (StringUtils.isBlank(paramName)) { + return false; + } + if (AWS_VALID_ASSUME_ROLE_OPTIONAL_PARAMS.contains(paramName)) { + return true; + } + + return Strings.CI.startsWith(paramName, POLICY_ARNS_MEMBER_PREFIX) + || Strings.CI.startsWith(paramName, PROVIDED_CONTEXTS_MEMBER_PREFIX) + || Strings.CI.startsWith(paramName, TAGS_MEMBER_PREFIX) + || Strings.CI.startsWith(paramName, TRANSITIVE_TAG_KEYS_MEMBER_PREFIX); + } + + private static Integer parseIntegerOrNull(String value) throws OSTSException { + if (StringUtils.isBlank(value)) { + return null; + } + try { + return Integer.parseInt(value); + } catch (NumberFormatException e) { + throw new OSTSException(STS_VALIDATION_ERROR) + .withMessage("1 validation error detected: Invalid Value: DurationSeconds must be a number"); + } + } + + private static final class AssumeRoleParamValidationResult { + private static final AssumeRoleParamValidationResult EMPTY = new AssumeRoleParamValidationResult( + Collections.emptyList(), Collections.emptyList()); + + private final List notImplementedOptionalParams; + private final List unsupportedParams; + + private AssumeRoleParamValidationResult(List notImplementedOptionalParams, List unsupportedParams) { + this.notImplementedOptionalParams = notImplementedOptionalParams; + this.unsupportedParams = unsupportedParams; + } + + private static AssumeRoleParamValidationResult empty() { + return EMPTY; + } + + private List getNotImplementedOptionalParams() { + return notImplementedOptionalParams; + } + + private List getUnsupportedParams() { + return unsupportedParams; + } + } + + private String generateAssumeRoleResponse(String assumedRoleUserArn, AssumeRoleResponseInfo responseInfo, + String requestId) throws IOException { + final String accessKeyId = responseInfo.getAccessKeyId(); + final String secretAccessKey = responseInfo.getSecretAccessKey(); + final String sessionToken = responseInfo.getSessionToken(); + final String assumedRoleId = responseInfo.getAssumedRoleId(); + + final String expiration = DateTimeFormatter.ISO_INSTANT.format( + Instant.ofEpochSecond(responseInfo.getExpirationEpochSeconds()).atOffset(ZoneOffset.UTC).toInstant()); + + try { + final S3AssumeRoleResponseXml response = new S3AssumeRoleResponseXml(); + final S3AssumeRoleResponseXml.AssumeRoleResult result = new S3AssumeRoleResponseXml.AssumeRoleResult(); + final S3AssumeRoleResponseXml.Credentials credentials = new S3AssumeRoleResponseXml.Credentials(); + credentials.setAccessKeyId(accessKeyId); + credentials.setSecretAccessKey(secretAccessKey); + credentials.setSessionToken(sessionToken); + credentials.setExpiration(expiration); + result.setCredentials(credentials); + final S3AssumeRoleResponseXml.AssumedRoleUser user = new S3AssumeRoleResponseXml.AssumedRoleUser(); + user.setAssumedRoleId(assumedRoleId); + user.setArn(assumedRoleUserArn); + result.setAssumedRoleUser(user); + response.setAssumeRoleResult(result); + final S3STSResponseMetadata meta = new S3STSResponseMetadata(); + meta.setRequestId(requestId); + response.setResponseMetadata(meta); + + final Marshaller marshaller = JAXB_CONTEXT.createMarshaller(); + marshaller.setProperty(Marshaller.JAXB_FORMATTED_OUTPUT, Boolean.TRUE); + final StringWriter stringWriter = new StringWriter(); + marshaller.marshal(response, stringWriter); + return stringWriter.toString(); + } catch (JAXBException e) { + throw new IOException("Failed to marshal AssumeRole response", e); + } + } + + private String generateGetCallerIdentityResponse(CallerIdentityInfo identityInfo, String requestId) + throws IOException { + try { + final S3GetCallerIdentityResponseXml response = new S3GetCallerIdentityResponseXml(); + final S3GetCallerIdentityResponseXml.GetCallerIdentityResult result = + new S3GetCallerIdentityResponseXml.GetCallerIdentityResult(); + result.setAccount(identityInfo.getAccount()); + result.setArn(identityInfo.getArn()); + result.setUserId(identityInfo.getUserId()); + response.setGetCallerIdentityResult(result); + final S3STSResponseMetadata meta = new S3STSResponseMetadata(); + meta.setRequestId(requestId); + response.setResponseMetadata(meta); + + final Marshaller marshaller = JAXB_CONTEXT.createMarshaller(); + marshaller.setProperty(Marshaller.JAXB_FORMATTED_OUTPUT, Boolean.TRUE); + final StringWriter stringWriter = new StringWriter(); + marshaller.marshal(response, stringWriter); + return stringWriter.toString(); + } catch (JAXBException e) { + throw new IOException("Failed to marshal GetCallerIdentity response", e); + } + } +} + diff --git a/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3sts/S3STSEndpointBase.java b/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3sts/S3STSEndpointBase.java new file mode 100644 index 000000000000..eedd9cbe3c3e --- /dev/null +++ b/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3sts/S3STSEndpointBase.java @@ -0,0 +1,145 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.hadoop.ozone.s3sts; + +import com.google.common.annotations.VisibleForTesting; +import java.util.Map; +import javax.annotation.PostConstruct; +import javax.inject.Inject; +import javax.ws.rs.container.ContainerRequestContext; +import javax.ws.rs.core.Context; +import javax.ws.rs.core.MultivaluedHashMap; +import javax.ws.rs.core.MultivaluedMap; +import org.apache.hadoop.ozone.audit.AuditAction; +import org.apache.hadoop.ozone.audit.AuditEventStatus; +import org.apache.hadoop.ozone.audit.AuditLogger; +import org.apache.hadoop.ozone.audit.AuditLoggerType; +import org.apache.hadoop.ozone.audit.AuditMessage; +import org.apache.hadoop.ozone.audit.Auditor; +import org.apache.hadoop.ozone.client.OzoneClient; +import org.apache.hadoop.ozone.client.protocol.ClientProtocol; +import org.apache.hadoop.ozone.om.protocol.S3Auth; +import org.apache.hadoop.ozone.s3.signature.SignatureInfo; +import org.apache.hadoop.ozone.s3.util.AuditUtils; + +/** + * Base class for STS endpoints. + */ +public class S3STSEndpointBase implements Auditor { + + @Context + private ContainerRequestContext context; + + @Inject + private OzoneClient client; + @Inject + private SignatureInfo signatureInfo; + + protected static final AuditLogger DEFAULT_AUDIT = new AuditLogger(AuditLoggerType.S3GLOGGER); + + private AuditLogger auditLogger = DEFAULT_AUDIT; + + protected AuditLogger getAuditLogger() { + return auditLogger; + } + + @VisibleForTesting + public void setAuditLogger(AuditLogger auditLogger) { + this.auditLogger = auditLogger; + } + + @PostConstruct + public void initialization() { + S3Auth s3Auth = new S3Auth(signatureInfo.getStringToSign(), + signatureInfo.getSignature(), + signatureInfo.getAwsAccessId(), signatureInfo.getAwsAccessId()); + if (signatureInfo.getSessionToken() != null && + !signatureInfo.getSessionToken().isEmpty()) { + s3Auth.setSessionToken(signatureInfo.getSessionToken()); + } + ClientProtocol clientProtocol = getClient().getObjectStore().getClientProxy(); + clientProtocol.setThreadLocalS3Auth(s3Auth); + } + + private AuditMessage.Builder auditMessageBaseBuilder(AuditAction op, + Map auditMap) { + AuditMessage.Builder builder = new AuditMessage.Builder() + .forOperation(op) + .withParams(auditMap); + + if (signatureInfo != null) { + String accessId = signatureInfo.getAwsAccessId(); + if (accessId != null && !accessId.isEmpty()) { + builder.setUser(accessId); + } + } + + if (context != null) { + builder.atIp(AuditUtils.getClientIpAddress(context)); + } + return builder; + } + + @Override + public AuditMessage buildAuditMessageForSuccess(AuditAction op, + Map auditMap) { + AuditMessage.Builder builder = auditMessageBaseBuilder(op, auditMap) + .withResult(AuditEventStatus.SUCCESS); + return builder.build(); + } + + @Override + public AuditMessage buildAuditMessageForFailure(AuditAction op, + Map auditMap, Throwable throwable) { + AuditMessage.Builder builder = auditMessageBaseBuilder(op, auditMap) + .withResult(AuditEventStatus.FAILURE) + .withException(throwable); + return builder.build(); + } + + public OzoneClient getClient() { + return client; + } + + @VisibleForTesting + public void setClient(OzoneClient ozoneClient) { + this.client = ozoneClient; + } + + @VisibleForTesting + public void setContext(ContainerRequestContext context) { + this.context = context; + } + + @VisibleForTesting + public void setSignatureInfo(SignatureInfo signatureInfo) { + this.signatureInfo = signatureInfo; + } + + protected Map getAuditParameters() { + return AuditUtils.getAuditParameters(context); + } + + protected MultivaluedMap getQueryParameters() { + if (context == null || context.getUriInfo() == null) { + return new MultivaluedHashMap<>(); + } + final MultivaluedMap params = context.getUriInfo().getQueryParameters(); + return params == null ? new MultivaluedHashMap<>() : params; + } +} diff --git a/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3sts/S3STSResponseMetadata.java b/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3sts/S3STSResponseMetadata.java new file mode 100644 index 000000000000..a43ec76b4433 --- /dev/null +++ b/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3sts/S3STSResponseMetadata.java @@ -0,0 +1,42 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.hadoop.ozone.s3sts; + +import javax.xml.bind.annotation.XmlAccessType; +import javax.xml.bind.annotation.XmlAccessorType; +import javax.xml.bind.annotation.XmlElement; +import javax.xml.bind.annotation.XmlType; + +/** + * JAXB model for AWS STS ResponseMetadata element shared across STS responses. + */ +@XmlAccessorType(XmlAccessType.FIELD) +@XmlType(name = "ResponseMetadata", namespace = "https://sts.amazonaws.com/doc/2011-06-15/") +public class S3STSResponseMetadata { + + @XmlElement(name = "RequestId") + private String requestId; + + public String getRequestId() { + return requestId; + } + + public void setRequestId(String requestId) { + this.requestId = requestId; + } +} diff --git a/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3sts/package-info.java b/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3sts/package-info.java new file mode 100644 index 000000000000..3383580a5eb0 --- /dev/null +++ b/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3sts/package-info.java @@ -0,0 +1,32 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * This package contains the AWS STS (Security Token Service) compatible API for S3 Gateway. + */ +@XmlSchema( + namespace = "https://sts.amazonaws.com/doc/2011-06-15/", + elementFormDefault = XmlNsForm.QUALIFIED, + xmlns = { + @XmlNs(prefix = "", namespaceURI = "https://sts.amazonaws.com/doc/2011-06-15/") + } +) +package org.apache.hadoop.ozone.s3sts; + +import javax.xml.bind.annotation.XmlNs; +import javax.xml.bind.annotation.XmlNsForm; +import javax.xml.bind.annotation.XmlSchema; diff --git a/hadoop-ozone/s3gateway/src/main/resources/webapps/s3g-sts/WEB-INF/web.xml b/hadoop-ozone/s3gateway/src/main/resources/webapps/s3g-sts/WEB-INF/web.xml new file mode 100644 index 000000000000..eff9f149355c --- /dev/null +++ b/hadoop-ozone/s3gateway/src/main/resources/webapps/s3g-sts/WEB-INF/web.xml @@ -0,0 +1,33 @@ + + + + sts-jaxrs + org.glassfish.jersey.servlet.ServletContainer + + javax.ws.rs.Application + org.apache.hadoop.ozone.s3sts.Application + + 1 + + + sts-jaxrs + /* + + + org.jboss.weld.environment.servlet.Listener + + 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 715c7e629678..40e384d2c547 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 @@ -38,6 +38,9 @@ import org.apache.hadoop.ozone.client.io.OzoneInputStream; import org.apache.hadoop.ozone.client.io.OzoneOutputStream; import org.apache.hadoop.ozone.client.protocol.ClientProtocol; +import org.apache.hadoop.ozone.client.protocol.ListStatusLightOptions; +import org.apache.hadoop.ozone.om.helpers.AssumeRoleResponseInfo; +import org.apache.hadoop.ozone.om.helpers.CallerIdentityInfo; import org.apache.hadoop.ozone.om.helpers.DeleteTenantState; import org.apache.hadoop.ozone.om.helpers.ErrorInfo; import org.apache.hadoop.ozone.om.helpers.LeaseKeyInfo; @@ -691,9 +694,7 @@ public List listStatus(String volumeName, String bucketName, } @Override - public List listStatusLight(String volumeName, - String bucketName, String keyName, boolean recursive, String startKey, - long numEntries, boolean allowPartialPrefixes) throws IOException { + public List listStatusLight(ListStatusLightOptions options) throws IOException { return null; } @@ -938,6 +939,21 @@ public void deleteObjectTagging(String volumeName, String bucketName, String key getBucket(volumeName, bucketName).deleteObjectTagging(keyName); } + @Override + public AssumeRoleResponseInfo assumeRole(String roleArn, String roleSessionName, int durationSeconds, + String awsIamSessionPolicy, String requestId) throws IOException { + return null; + } + + @Override + public CallerIdentityInfo getCallerIdentity() throws IOException { + return null; + } + + @Override + public void revokeSTSToken(String originalAccessKeyId) throws IOException { + } + @Override public OzoneLifecycleConfiguration getLifecycleConfiguration(String volumeName, String bucketName) throws IOException { diff --git a/hadoop-ozone/s3gateway/src/test/java/org/apache/hadoop/ozone/client/OzoneBucketStub.java b/hadoop-ozone/s3gateway/src/test/java/org/apache/hadoop/ozone/client/OzoneBucketStub.java index d794eb0a528e..0aef8607c87c 100644 --- a/hadoop-ozone/s3gateway/src/test/java/org/apache/hadoop/ozone/client/OzoneBucketStub.java +++ b/hadoop-ozone/s3gateway/src/test/java/org/apache/hadoop/ozone/client/OzoneBucketStub.java @@ -171,16 +171,17 @@ public OzoneOutputStream createKey(String key, long size, new KeyMetadataAwareOutputStream(metadata) { @Override public void close() throws IOException { - byte[] bytes = toByteArray(); super.close(); + byte[] bytes = toByteArray(); keyContents.put(key, bytes); + final long mtime = getModificationTime(); keyDetails.put(key, new OzoneKeyDetails( getVolumeName(), getName(), key, size, - System.currentTimeMillis(), - System.currentTimeMillis(), + mtime, + mtime, new ArrayList<>(), finalReplicationCon, getMetadata(), null, () -> readKey(key), true, UserGroupInformation.getCurrentUser().getShortUserName(), @@ -213,16 +214,17 @@ public OzoneOutputStream rewriteKey(String keyName, long size, long existingKeyG new KeyMetadataAwareOutputStream(metadata) { @Override public void close() throws IOException { - byte[] bytes = toByteArray(); super.close(); + byte[] bytes = toByteArray(); keyContents.put(keyName, bytes); + final long mtime = getModificationTime(); keyDetails.put(keyName, new OzoneKeyDetails( getVolumeName(), getName(), keyName, size, - System.currentTimeMillis(), - System.currentTimeMillis(), + mtime, + mtime, new ArrayList<>(), finalReplicationCon, metadata, null, () -> readKey(keyName), true, null, null )); @@ -305,13 +307,14 @@ public void close() throws IOException { Map objectMetadata = keyMetadata == null ? new HashMap<>() : keyMetadata; + final long mtime = getModificationTime(); keyDetails.put(key, new OzoneKeyDetails( getVolumeName(), getName(), key, size, - System.currentTimeMillis(), - System.currentTimeMillis(), + mtime, + mtime, new ArrayList<>(), rConfig, objectMetadata, null, null, false, UserGroupInformation.getCurrentUser().getShortUserName(), @@ -428,7 +431,7 @@ public void close() throws IOException { buffer.get(bytes); Part part = new Part(key + size, bytes, - getMetadata().get(ETAG)); + getMetadata().get(ETAG), getModificationTime()); if (partList.get(key) == null) { Map parts = new TreeMap<>(); parts.put(partNumber, part); @@ -695,10 +698,10 @@ public OzoneOutputStream createMultipartKey(String key, long size, new KeyMetadataAwareOutputStream((int) size, new HashMap<>()) { @Override public void close() throws IOException { + super.close(); byte[] bytes = toByteArray(); String eTag = getMetadata().get(ETAG); - super.close(); - Part part = new Part(key + size, bytes, eTag); + Part part = new Part(key + size, bytes, eTag, getModificationTime()); if (partList.get(key) == null) { Map parts = new TreeMap<>(); parts.put(partNumber, part); @@ -836,7 +839,7 @@ public OzoneMultipartUploadPartListParts listParts(String key, if (partEntry.getKey() > partNumberMarker) { PartInfo partInfo = new PartInfo(partEntry.getKey(), partEntry.getValue().getPartName(), - Time.now(), partEntry.getValue().getContent().length, + partEntry.getValue().getModificationTime(), partEntry.getValue().getContent().length, DatatypeConverter.printHexBinary(eTagProvider.digest(partEntry .getValue().getContent())).toLowerCase()); partInfoList.add(partInfo); @@ -937,13 +940,18 @@ public void deleteBucketTagging() throws IOException { public static class Part { private String partName; private byte[] content; - private String eTag; + private long modificationTime; - public Part(String name, byte[] data, String eTag) { + public Part(String name, byte[] data, String eTag, long modificationTime) { this.partName = name; this.content = data.clone(); this.eTag = eTag; + this.modificationTime = modificationTime; + } + + public long getModificationTime() { + return modificationTime; } public String getPartName() { @@ -1067,6 +1075,7 @@ public static class KeyMetadataAwareOutputStream extends KeyOutputStream impleme private final ByteArrayOutputStream buffer = new ByteArrayOutputStream(); private final Map metadata; private List> preCommits = Collections.emptyList(); + private long modificationTime; public KeyMetadataAwareOutputStream(Map metadata) { super(null, null); @@ -1100,9 +1109,15 @@ public void close() throws IOException { for (CheckedRunnable preCommit : preCommits) { preCommit.run(); } + modificationTime = Time.now(); buffer.close(); } + @Override + public long getModificationTime() { + return modificationTime; + } + @Override public void setPreCommits(List> preCommits) { this.preCommits = preCommits != null ? preCommits : Collections.emptyList(); @@ -1128,6 +1143,7 @@ public static class KeyMetadataAwareByteBufferStreamOutput private final Map metadata; private List> preCommits = Collections.emptyList(); + private long modificationTime; public KeyMetadataAwareByteBufferStreamOutput( Map metadata) { @@ -1147,10 +1163,15 @@ public void flush() throws IOException { @Override public void close() throws IOException { - for (CheckedRunnable preCommit : preCommits) { preCommit.run(); } + modificationTime = Time.now(); + } + + @Override + public long getModificationTime() { + return modificationTime; } @Override diff --git a/hadoop-ozone/s3gateway/src/test/java/org/apache/hadoop/ozone/client/OzoneDataStreamOutputStub.java b/hadoop-ozone/s3gateway/src/test/java/org/apache/hadoop/ozone/client/OzoneDataStreamOutputStub.java index c19312692b49..28ebbe04bf8a 100644 --- a/hadoop-ozone/s3gateway/src/test/java/org/apache/hadoop/ozone/client/OzoneDataStreamOutputStub.java +++ b/hadoop-ozone/s3gateway/src/test/java/org/apache/hadoop/ozone/client/OzoneDataStreamOutputStub.java @@ -63,8 +63,22 @@ public synchronized void close() throws IOException { @Override public OmMultipartCommitUploadPartInfo getCommitUploadPartInfo() { - return closed ? new OmMultipartCommitUploadPartInfo(partName, - getMetadata().get(OzoneConsts.ETAG)) : null; + final KeyDataStreamOutput keyDataStreamOutput = getKeyDataStreamOutput(); + if (closed && keyDataStreamOutput != null) { + return new OmMultipartCommitUploadPartInfo( + partName, getMetadata().get(OzoneConsts.ETAG), keyDataStreamOutput.getModificationTime()); + } + return null; + } + + @Override + public long getModificationTime() { + final KeyDataStreamOutput keyDataStreamOutput = getKeyDataStreamOutput(); + if (keyDataStreamOutput != null) { + return keyDataStreamOutput.getModificationTime(); + } + throw new IllegalStateException( + "OutputStream is not a KeyDataStreamOutput: " + getByteBufStreamOutput().getClass()); } @Override diff --git a/hadoop-ozone/s3gateway/src/test/java/org/apache/hadoop/ozone/client/OzoneOutputStreamStub.java b/hadoop-ozone/s3gateway/src/test/java/org/apache/hadoop/ozone/client/OzoneOutputStreamStub.java index ac3dc8b4da17..2661e98edc4f 100644 --- a/hadoop-ozone/s3gateway/src/test/java/org/apache/hadoop/ozone/client/OzoneOutputStreamStub.java +++ b/hadoop-ozone/s3gateway/src/test/java/org/apache/hadoop/ozone/client/OzoneOutputStreamStub.java @@ -21,6 +21,7 @@ import java.io.OutputStream; import org.apache.hadoop.ozone.OzoneConsts; import org.apache.hadoop.ozone.client.io.KeyMetadataAware; +import org.apache.hadoop.ozone.client.io.KeyOutputStream; import org.apache.hadoop.ozone.client.io.OzoneOutputStream; import org.apache.hadoop.ozone.om.helpers.OmMultipartCommitUploadPartInfo; @@ -69,7 +70,22 @@ public synchronized void close() throws IOException { @Override public OmMultipartCommitUploadPartInfo getCommitUploadPartInfo() { - return closed ? new OmMultipartCommitUploadPartInfo(partName, - ((KeyMetadataAware)getOutputStream()).getMetadata().get(OzoneConsts.ETAG)) : null; + final KeyOutputStream keyOutputStream = getKeyOutputStream(); + if (closed && keyOutputStream != null) { + return new OmMultipartCommitUploadPartInfo( + partName, ((KeyMetadataAware) getOutputStream()).getMetadata().get(OzoneConsts.ETAG), + keyOutputStream.getModificationTime()); + } + return null; + } + + @Override + public long getModificationTime() { + final KeyOutputStream keyOutputStream = getKeyOutputStream(); + if (keyOutputStream != null) { + return keyOutputStream.getModificationTime(); + } + throw new IllegalStateException( + "OutputStream is not a KeyOutputStream: " + getOutputStream().getClass()); } } diff --git a/hadoop-ozone/s3gateway/src/test/java/org/apache/hadoop/ozone/s3/TestAuthorizationFilter.java b/hadoop-ozone/s3gateway/src/test/java/org/apache/hadoop/ozone/s3/TestAuthorizationFilter.java index 6df57448cadc..cc9dd813fb9a 100644 --- a/hadoop-ozone/s3gateway/src/test/java/org/apache/hadoop/ozone/s3/TestAuthorizationFilter.java +++ b/hadoop-ozone/s3gateway/src/test/java/org/apache/hadoop/ozone/s3/TestAuthorizationFilter.java @@ -20,6 +20,7 @@ import static java.net.HttpURLConnection.HTTP_BAD_REQUEST; import static java.net.HttpURLConnection.HTTP_FORBIDDEN; import static org.apache.hadoop.ozone.s3.exception.S3ErrorTable.MALFORMED_HEADER; +import static org.apache.hadoop.ozone.s3.exception.S3ErrorTable.PAYLOAD_TOO_LARGE; import static org.apache.hadoop.ozone.s3.exception.S3ErrorTable.S3_AUTHINFO_CREATION_ERROR; import static org.apache.hadoop.ozone.s3.signature.AWSSignatureProcessor.DATE_FORMATTER; import static org.apache.hadoop.ozone.s3.signature.SignatureParser.AUTHORIZATION_HEADER; @@ -28,6 +29,7 @@ import static org.apache.hadoop.ozone.s3.signature.SignatureProcessor.HOST_HEADER; import static org.apache.hadoop.ozone.s3.signature.StringToSignProducer.X_AMAZ_DATE; import static org.apache.hadoop.ozone.s3.util.S3Consts.X_AMZ_CONTENT_SHA256; +import static org.apache.hadoop.ozone.s3sts.S3STSConfigKeys.OZONE_S3G_STS_PAYLOAD_HASH_MAX_VALUE; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.fail; @@ -35,6 +37,8 @@ import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; +import java.io.ByteArrayInputStream; +import java.io.InputStream; import java.net.URI; import java.net.URISyntaxException; import java.nio.charset.StandardCharsets; @@ -117,6 +121,24 @@ public class TestAuthorizationFilter { "application/octet-stream", "/", S3_AUTHINFO_CREATION_ERROR.getErrorMessage() + ), + // Too huge payload for signature V4 of STS request + arguments( + "POST", + "AWS4-HMAC-SHA256 Credential=testuser1/" + CURDATE + + "/us-east-1/sts/aws4_request, " + + "SignedHeaders=content-type;host;" + + "x-amz-date, " + + "Signature" + + "=56ec73ba1974f8feda8365c3caef89c5d4a688d5f9baccf47" + + "65f46a14cd745ad", + "Content-SHA", + "s3g:9880", + "Content-SHA", + DATETIME, + "application/x-www-form-urlencoded; charset=utf-8", + "/", + PAYLOAD_TOO_LARGE.getErrorMessage() ) ); } @@ -133,6 +155,10 @@ void testAuthFilterFailures( ContainerRequestContext context = setupContext(method, authHeader, contentMd5, host, amzContentSha256, date, contentType, path); + byte[] payloadBytes = new byte[OZONE_S3G_STS_PAYLOAD_HASH_MAX_VALUE + 1]; + InputStream payLoadStream = new ByteArrayInputStream(payloadBytes); + when(context.getEntityStream()).thenReturn(payLoadStream); + AWSSignatureProcessor awsSignatureProcessor = new AWSSignatureProcessor(); awsSignatureProcessor.setContext(context); diff --git a/hadoop-ozone/s3gateway/src/test/java/org/apache/hadoop/ozone/s3/endpoint/TestCopyActionsAudit.java b/hadoop-ozone/s3gateway/src/test/java/org/apache/hadoop/ozone/s3/endpoint/TestCopyActionsAudit.java new file mode 100644 index 000000000000..380a7780e9e6 --- /dev/null +++ b/hadoop-ozone/s3gateway/src/test/java/org/apache/hadoop/ozone/s3/endpoint/TestCopyActionsAudit.java @@ -0,0 +1,133 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.hadoop.ozone.s3.endpoint; + +import static java.nio.charset.StandardCharsets.UTF_8; +import static org.apache.hadoop.ozone.s3.util.S3Consts.COPY_SOURCE_HEADER; +import static org.apache.hadoop.ozone.s3.util.S3Consts.STORAGE_CLASS_HEADER; +import static org.apache.hadoop.ozone.s3.util.S3Consts.X_AMZ_CONTENT_SHA256; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.io.ByteArrayInputStream; +import java.io.OutputStream; +import java.util.HashMap; +import javax.ws.rs.core.HttpHeaders; +import org.apache.hadoop.hdds.client.ReplicationConfig; +import org.apache.hadoop.hdds.client.ReplicationFactor; +import org.apache.hadoop.hdds.client.ReplicationType; +import org.apache.hadoop.ozone.OzoneConsts; +import org.apache.hadoop.ozone.audit.AuditLogger.PerformanceStringBuilder; +import org.apache.hadoop.ozone.audit.S3GAction; +import org.apache.hadoop.ozone.client.OzoneBucket; +import org.apache.hadoop.ozone.client.OzoneClient; +import org.apache.hadoop.ozone.client.OzoneClientStub; +import org.apache.hadoop.ozone.s3.endpoint.ObjectEndpoint.ObjectRequestContext; +import org.apache.hadoop.ozone.s3.util.S3Consts; +import org.junit.jupiter.api.Test; + +/** + * Verifies audit logging action for copy operations even if S3 action authorization strings are overridden internally. + * For example, S3G.COPY_OBJECT must use S3G.COPY_OBJECT as the audit action, even though internally the S3 actions + * checked are GetObject and PutObject. + */ +public class TestCopyActionsAudit { + + @Test + public void testCopyObjectAuditActionRemainsCopyObject() throws Exception { + final String bucketName = OzoneConsts.S3_BUCKET; + final String srcKey = "src.txt"; + final String destKey = "dest.txt"; + + final OzoneClient client = new OzoneClientStub(); + client.getObjectStore().createS3Bucket(bucketName); + final OzoneBucket bucket = client.getObjectStore().getS3Bucket(bucketName); + + try (OutputStream out = bucket.createKey( + srcKey, 3, ReplicationConfig.fromTypeAndFactor(ReplicationType.RATIS, ReplicationFactor.ONE), + new HashMap<>())) { + out.write("src".getBytes(UTF_8)); + } + + final HttpHeaders headers = mock(HttpHeaders.class); + when(headers.getHeaderString(STORAGE_CLASS_HEADER)).thenReturn("STANDARD"); + when(headers.getHeaderString(X_AMZ_CONTENT_SHA256)).thenReturn("mockSignature"); + when(headers.getHeaderString(COPY_SOURCE_HEADER)).thenReturn(bucketName + "/" + srcKey); + when(headers.getHeaderString(HttpHeaders.CONTENT_LENGTH)).thenReturn("0"); + + final ObjectEndpoint endpoint = newEndpoint(client, headers); + final AuditingObjectOperationHandler auditing = spy(new AuditingObjectOperationHandler(endpoint)); + + final ObjectRequestContext requestContext = endpoint.new ObjectRequestContext(S3GAction.CREATE_KEY, bucketName); + + auditing.handlePutRequest(requestContext, destKey, new ByteArrayInputStream(new byte[0])); + + verify(auditing).auditWriteSuccess(eq(S3GAction.COPY_OBJECT), any(PerformanceStringBuilder.class)); + } + + @Test + public void testUploadPartCopyAuditActionRemainsCreateMultipartKeyByCopy() throws Exception { + final String bucketName = OzoneConsts.S3_BUCKET; + final String srcKey = "src-part.txt"; + final String destKey = "dest-mpu.txt"; + + final OzoneClient client = new OzoneClientStub(); + client.getObjectStore().createS3Bucket(bucketName); + final OzoneBucket bucket = client.getObjectStore().getS3Bucket(bucketName); + + try (OutputStream out = bucket.createKey( + srcKey, 4, ReplicationConfig.fromTypeAndFactor(ReplicationType.RATIS, ReplicationFactor.ONE), + new HashMap<>())) { + out.write("part".getBytes(UTF_8)); + } + + final HttpHeaders headers = mock(HttpHeaders.class); + when(headers.getHeaderString(STORAGE_CLASS_HEADER)).thenReturn("STANDARD"); + when(headers.getHeaderString(X_AMZ_CONTENT_SHA256)).thenReturn("mockSignature"); + when(headers.getHeaderString(COPY_SOURCE_HEADER)).thenReturn(bucketName + "/" + srcKey); + when(headers.getHeaderString(HttpHeaders.CONTENT_LENGTH)).thenReturn("0"); + + final ObjectEndpoint endpoint = newEndpoint(client, headers); + + final String uploadId = EndpointTestUtils.initiateMultipartUpload(endpoint, bucketName, destKey); + assertNotNull(uploadId); + + endpoint.queryParamsForTest().set(S3Consts.QueryParams.UPLOAD_ID, uploadId); + endpoint.queryParamsForTest().setInt(S3Consts.QueryParams.PART_NUMBER, 1); + + final AuditingObjectOperationHandler auditing = spy(new AuditingObjectOperationHandler(endpoint)); + final ObjectRequestContext requestContext = endpoint.new ObjectRequestContext(S3GAction.CREATE_KEY, bucketName); + + auditing.handlePutRequest(requestContext, destKey, new ByteArrayInputStream(new byte[0])); + + verify(auditing).auditWriteSuccess(eq(S3GAction.CREATE_MULTIPART_KEY_BY_COPY), any(PerformanceStringBuilder.class)); + } + + private static ObjectEndpoint newEndpoint(OzoneClient client, HttpHeaders headers) { + return EndpointBuilder.newObjectEndpointBuilder() + .setClient(client) + .setHeaders(headers) + .build(); + } +} + diff --git a/hadoop-ozone/s3gateway/src/test/java/org/apache/hadoop/ozone/s3/endpoint/TestCopyObjectAndUploadPartCopyLastModified.java b/hadoop-ozone/s3gateway/src/test/java/org/apache/hadoop/ozone/s3/endpoint/TestCopyObjectAndUploadPartCopyLastModified.java new file mode 100644 index 000000000000..8de1e246904e --- /dev/null +++ b/hadoop-ozone/s3gateway/src/test/java/org/apache/hadoop/ozone/s3/endpoint/TestCopyObjectAndUploadPartCopyLastModified.java @@ -0,0 +1,150 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.hadoop.ozone.s3.endpoint; + +import static java.nio.charset.StandardCharsets.UTF_8; +import static org.apache.hadoop.ozone.s3.endpoint.EndpointTestUtils.initiateMultipartUpload; +import static org.apache.hadoop.ozone.s3.endpoint.EndpointTestUtils.put; +import static org.apache.hadoop.ozone.s3.util.S3Consts.COPY_SOURCE_HEADER; +import static org.apache.hadoop.ozone.s3.util.S3Consts.STORAGE_CLASS_HEADER; +import static org.apache.hadoop.ozone.s3.util.S3Consts.X_AMZ_CONTENT_SHA256; +import static org.apache.hadoop.ozone.s3.util.S3Utils.urlEncode; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.io.OutputStream; +import java.util.HashMap; +import javax.ws.rs.core.HttpHeaders; +import javax.ws.rs.core.Response; +import org.apache.hadoop.hdds.client.ReplicationConfig; +import org.apache.hadoop.hdds.client.ReplicationFactor; +import org.apache.hadoop.hdds.client.ReplicationType; +import org.apache.hadoop.ozone.client.OzoneBucket; +import org.apache.hadoop.ozone.client.OzoneClient; +import org.apache.hadoop.ozone.client.OzoneMultipartUploadPartListParts; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +/** + * Verifies CopyObject and UploadPartCopy return LastModified from the commit + * response rather than the source key time or a synthetic gateway timestamp. + */ +public class TestCopyObjectAndUploadPartCopyLastModified { + + private static final String SOURCE_BUCKET = "source-bucket"; + private static final String DEST_BUCKET = "dest-bucket"; + private static final String SOURCE_KEY = "source-key"; + private static final String DEST_KEY = "dest-key"; + private static final String MPU_KEY = "mpu-key"; + private static final String CONTENT = "copy-last-modified-test-content"; + private static final long SOURCE_AGE_MS = 100L; + + private ObjectEndpoint endpoint; + private HttpHeaders headers; + private OzoneBucket sourceBucket; + private OzoneBucket destBucket; + + @BeforeEach + void setUp() throws Exception { + headers = mock(HttpHeaders.class); + when(headers.getHeaderString(X_AMZ_CONTENT_SHA256)).thenReturn("UNSIGNED-PAYLOAD"); + when(headers.getHeaderString(STORAGE_CLASS_HEADER)).thenReturn("STANDARD"); + + final OzoneClient client = EndpointBuilder.newObjectEndpointBuilder() + .setHeaders(headers) + .build() + .getClient(); + client.getObjectStore().createS3Bucket(SOURCE_BUCKET); + client.getObjectStore().createS3Bucket(DEST_BUCKET); + sourceBucket = client.getObjectStore().getS3Bucket(SOURCE_BUCKET); + destBucket = client.getObjectStore().getS3Bucket(DEST_BUCKET); + + createSourceKey(); + + endpoint = EndpointBuilder.newObjectEndpointBuilder() + .setHeaders(headers) + .setClient(client) + .build(); + } + + @Test + void testCopyObjectLastModifiedReflectsDestCommitTime() throws Exception { + final long sourceModificationTime = sourceBucket.getKey(SOURCE_KEY) + .getModificationTime().toEpochMilli(); + Thread.sleep(SOURCE_AGE_MS); + + when(headers.getHeaderString(COPY_SOURCE_HEADER)).thenReturn( + SOURCE_BUCKET + "/" + urlEncode(SOURCE_KEY)); + + try (Response response = put(endpoint, DEST_BUCKET, DEST_KEY, CONTENT)) { + assertEquals(200, response.getStatus()); + + final CopyObjectResponse copyObjectResponse = (CopyObjectResponse) response.getEntity(); + assertNotNull(copyObjectResponse.getLastModified()); + assertNotNull(copyObjectResponse.getETag()); + + final long responseModificationTime = copyObjectResponse.getLastModified().toEpochMilli(); + final long destModificationTime = destBucket.getKey(DEST_KEY).getModificationTime().toEpochMilli(); + + assertEquals(destModificationTime, responseModificationTime); + assertNotEquals(sourceModificationTime, responseModificationTime); + } + } + + @Test + void testUploadPartCopyLastModifiedReflectsPartCommitTime() throws Exception { + final long sourceModificationTime = sourceBucket.getKey(SOURCE_KEY) + .getModificationTime().toEpochMilli(); + Thread.sleep(SOURCE_AGE_MS); + + final String uploadId = initiateMultipartUpload(endpoint, DEST_BUCKET, MPU_KEY); + + when(headers.getHeaderString(COPY_SOURCE_HEADER)).thenReturn(SOURCE_BUCKET + "/" + urlEncode(SOURCE_KEY)); + + try (Response response = put(endpoint, DEST_BUCKET, MPU_KEY, 1, uploadId, "")) { + assertEquals(200, response.getStatus()); + + final CopyPartResult copyPartResult = (CopyPartResult) response.getEntity(); + assertNotNull(copyPartResult.getETag()); + assertNotNull(copyPartResult.getLastModified()); + + final long responseModificationTime = copyPartResult.getLastModified().toEpochMilli(); + + // Retrieve the actual modification time of the newly uploaded part from the bucket + final OzoneMultipartUploadPartListParts parts = destBucket.listParts( + MPU_KEY, uploadId, 0, 100); + final long destPartModificationTime = parts.getPartInfoList().get(0).getModificationTime(); + + // Assert that the response precisely matches the part's actual commit time + assertEquals(destPartModificationTime, responseModificationTime); + assertNotEquals(sourceModificationTime, responseModificationTime); + } + } + + private void createSourceKey() throws Exception { + try (OutputStream stream = sourceBucket.createKey( + SOURCE_KEY, CONTENT.length(), ReplicationConfig.fromTypeAndFactor( + ReplicationType.RATIS, ReplicationFactor.THREE), new HashMap<>())) { + stream.write(CONTENT.getBytes(UTF_8)); + } + Thread.sleep(SOURCE_AGE_MS); + } +} 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 7002fbd09997..9865345a9162 100644 --- a/hadoop-ozone/s3gateway/src/test/java/org/apache/hadoop/ozone/s3/endpoint/TestEndpointBase.java +++ b/hadoop-ozone/s3gateway/src/test/java/org/apache/hadoop/ozone/s3/endpoint/TestEndpointBase.java @@ -17,12 +17,18 @@ package org.apache.hadoop.ozone.s3.endpoint; +import static org.apache.hadoop.ozone.om.exceptions.OMException.ResultCodes; import static org.apache.hadoop.ozone.s3.exception.S3ErrorTable.INVALID_ARGUMENT; import static org.apache.hadoop.ozone.s3.util.S3Consts.CUSTOM_METADATA_HEADER_PREFIX; import static org.apache.hadoop.ozone.s3.util.S3Consts.RESERVED_USER_METADATA_KEY_PREFIX; import static org.assertj.core.api.Assertions.assertThat; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; import java.nio.charset.StandardCharsets; import java.util.Locale; @@ -31,6 +37,8 @@ import javax.ws.rs.core.MultivaluedHashMap; import javax.ws.rs.core.MultivaluedMap; import org.apache.hadoop.ozone.OzoneConsts; +import org.apache.hadoop.ozone.client.OzoneVolume; +import org.apache.hadoop.ozone.om.exceptions.OMException; import org.apache.hadoop.ozone.s3.exception.OS3Exception; import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; @@ -113,6 +121,73 @@ public void testCustomMetadataHeadersWithUpperCaseHeaders() throws OS3Exception assertEquals(value, customMetadata.get(key)); } + @Test + public void testAccessDeniedResultCodes() { + final EndpointBase endpointBase = new EndpointBase() { + @Override + public void init() { } + }; + + assertTrue(endpointBase.isAccessDenied(new OMException(ResultCodes.PERMISSION_DENIED))); + assertTrue(endpointBase.isAccessDenied(new OMException(ResultCodes.INVALID_TOKEN))); + assertTrue(endpointBase.isAccessDenied(new OMException(ResultCodes.REVOKED_TOKEN))); + assertFalse(endpointBase.isAccessDenied(new OMException(ResultCodes.INTERNAL_ERROR))); + assertFalse(endpointBase.isAccessDenied(new OMException(ResultCodes.BUCKET_NOT_FOUND))); + } + + @Test + public void testExpiredTokenResultCode() { + final EndpointBase endpointBase = new EndpointBase() { + @Override + public void init() { } + }; + + assertTrue(endpointBase.isExpiredToken(new OMException(ResultCodes.TOKEN_EXPIRED))); + assertFalse(endpointBase.isExpiredToken(new OMException(ResultCodes.INVALID_TOKEN))); + } + + @Test + public void testListS3BucketsHandlesRuntimeExceptionWrappingOMException() throws Exception { + final EndpointBase endpointBase = new EndpointBase() { + @Override + public void init() { } + + @Override + protected OzoneVolume getVolume() { + final OzoneVolume volume = mock(OzoneVolume.class); + when(volume.listBuckets(anyString())).thenThrow( + new RuntimeException(new OMException("Permission Denied", ResultCodes.PERMISSION_DENIED))); + return volume; + } + }; + + final OS3Exception e = assertThrows( + OS3Exception.class, () -> endpointBase.listS3Buckets( + "prefix", volume -> { }), "listS3Buckets should fail."); + + // Ensure we get the correct code + assertEquals("AccessDenied", e.getCode()); + } + + @Test + public void testListS3BucketsHandlesRuntimeExceptionWrappingOMExceptionVolumeNotFound() throws Exception { + final EndpointBase endpointBase = new EndpointBase() { + @Override + public void init() { } + + @Override + protected OzoneVolume getVolume() { + final OzoneVolume volume = mock(OzoneVolume.class); + when(volume.listBuckets(anyString())).thenThrow( + new RuntimeException(new OMException("Volume Not Found", ResultCodes.VOLUME_NOT_FOUND))); + return volume; + } + }; + + // Ensure we get an empty iterator + assertFalse(endpointBase.listS3Buckets("prefix", volume -> { }).hasNext()); + } + @ParameterizedTest @MethodSource("reservedInternalMetadataKeyPrefixCases") public void testRejectReservedInternalMetadataKeyPrefix(String metadataKey) { diff --git a/hadoop-ozone/s3gateway/src/test/java/org/apache/hadoop/ozone/s3/endpoint/TestPermissionCheck.java b/hadoop-ozone/s3gateway/src/test/java/org/apache/hadoop/ozone/s3/endpoint/TestPermissionCheck.java index b6add6040fd8..291e93bb9eb8 100644 --- a/hadoop-ozone/s3gateway/src/test/java/org/apache/hadoop/ozone/s3/endpoint/TestPermissionCheck.java +++ b/hadoop-ozone/s3gateway/src/test/java/org/apache/hadoop/ozone/s3/endpoint/TestPermissionCheck.java @@ -35,15 +35,17 @@ import static org.mockito.Mockito.anyMap; import static org.mockito.Mockito.anyString; import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.eq; import static org.mockito.Mockito.isNull; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import java.io.IOException; -import java.util.ArrayList; -import java.util.HashMap; -import java.util.List; +import java.util.Collections; import java.util.Map; +import java.util.stream.Stream; import javax.ws.rs.core.HttpHeaders; import org.apache.hadoop.hdds.conf.OzoneConfiguration; import org.apache.hadoop.ozone.OzoneConfigKeys; @@ -53,6 +55,7 @@ import org.apache.hadoop.ozone.client.OzoneVolume; import org.apache.hadoop.ozone.client.protocol.ClientProtocol; import org.apache.hadoop.ozone.om.exceptions.OMException; +import org.apache.hadoop.ozone.om.exceptions.OMException.ResultCodes; import org.apache.hadoop.ozone.om.helpers.ErrorInfo; import org.apache.hadoop.ozone.s3.exception.OS3Exception; import org.apache.hadoop.ozone.s3.exception.S3ErrorTable; @@ -61,6 +64,9 @@ import org.apache.hadoop.ozone.s3.util.S3Consts.QueryParams; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; /** * Test operation permission check result. @@ -87,8 +93,7 @@ public void setup() { bucket = mock(OzoneBucket.class); volume = mock(OzoneVolume.class); when(volume.getName()).thenReturn("s3Volume"); - exception = new OMException("Permission Denied", - OMException.ResultCodes.PERMISSION_DENIED); + exception = new OMException("Permission Denied", ResultCodes.PERMISSION_DENIED); when(client.getObjectStore()).thenReturn(objectStore); when(client.getConfiguration()).thenReturn(conf); headers = mock(HttpHeaders.class); @@ -186,25 +191,42 @@ public void testDeleteKeys() throws IOException, OS3Exception { when(objectStore.getVolume(anyString())).thenReturn(volume); when(objectStore.getS3Volume()).thenReturn(volume); when(volume.getBucket(anyString())).thenReturn(bucket); - Map deleteErrors = new HashMap<>(); - deleteErrors.put("deleteKeyName", new ErrorInfo("ACCESS_DENIED", "ACL check failed")); + final Map deleteErrors = Collections.singletonMap( + "deleteKeyName", new ErrorInfo("ACCESS_DENIED", "ACL check failed")); when(bucket.deleteKeys(any(), anyBoolean())).thenReturn(deleteErrors); - BucketEndpoint bucketEndpoint = EndpointBuilder.newBucketEndpointBuilder() + final BucketEndpoint bucketEndpoint = EndpointBuilder.newBucketEndpointBuilder() .setClient(client) .build(); - MultiDeleteRequest request = new MultiDeleteRequest(); - List objectList = new ArrayList<>(); - objectList.add(new MultiDeleteRequest.DeleteObject("deleteKeyName")); - request.setQuiet(false); - request.setObjects(objectList); + final MultiDeleteRequest request = createMultiDeleteRequest(); - MultiDeleteResponse response = - bucketEndpoint.multiDelete("BucketName", "keyName", request); + final MultiDeleteResponse response = bucketEndpoint.multiDelete("BucketName", "keyName", request); assertEquals(1, response.getErrors().size()); assertEquals("ACCESS_DENIED", response.getErrors().get(0).getCode()); } + @ParameterizedTest + @MethodSource("deleteKeysTranslatedOMFailures") + public void testDeleteKeysTranslatesContainedOMFailures(ResultCodes resultCode, S3ErrorTable expectedError) + throws IOException { + when(objectStore.getVolume(anyString())).thenReturn(volume); + when(objectStore.getS3Volume()).thenReturn(volume); + when(volume.getBucket(anyString())).thenReturn(bucket); + doThrow(new IOException("DeleteObjects failed", new OMException("OM failure", resultCode))) + .when(bucket).deleteKeys(any(), anyBoolean()); + + final BucketEndpoint bucketEndpoint = spy(new BucketEndpoint()); + EndpointBuilder.newBucketEndpointBuilder() + .setBase(bucketEndpoint) + .setClient(client) + .build(); + final MultiDeleteRequest request = createMultiDeleteRequest(); + + assertErrorResponse(expectedError, () -> bucketEndpoint.multiDelete("BucketName", "keyName", request)); + verify(bucketEndpoint).auditMultiDeleteFailure( + any(), eq(Collections.singletonList("deleteKeyName")), any(OMException.class)); + } + @Test public void testGetAcl() throws Exception { when(objectStore.getS3Volume()).thenReturn(volume); @@ -333,4 +355,22 @@ public void testObjectTagging() throws Exception { assertErrorResponse(S3ErrorTable.ACCESS_DENIED, () -> getTagging(objectEndpoint, "bucketName", "keyPath")); } + + private static MultiDeleteRequest createMultiDeleteRequest() { + final MultiDeleteRequest request = new MultiDeleteRequest(); + request.setQuiet(false); + request.setObjects(Collections.singletonList(new MultiDeleteRequest.DeleteObject("deleteKeyName"))); + return request; + } + + private static Stream deleteKeysTranslatedOMFailures() { + return Stream.of( + Arguments.of(ResultCodes.ACCESS_DENIED, S3ErrorTable.ACCESS_DENIED), + Arguments.of(ResultCodes.PERMISSION_DENIED, S3ErrorTable.ACCESS_DENIED), + Arguments.of(ResultCodes.INVALID_TOKEN, S3ErrorTable.ACCESS_DENIED), + Arguments.of(ResultCodes.REVOKED_TOKEN, S3ErrorTable.ACCESS_DENIED), + Arguments.of(ResultCodes.TOKEN_EXPIRED, S3ErrorTable.EXPIRED_TOKEN), + Arguments.of(ResultCodes.BUCKET_NOT_FOUND, S3ErrorTable.NO_SUCH_BUCKET) + ); + } } diff --git a/hadoop-ozone/s3gateway/src/test/java/org/apache/hadoop/ozone/s3/endpoint/TestS3ActionOverrideForOwnerVerification.java b/hadoop-ozone/s3gateway/src/test/java/org/apache/hadoop/ozone/s3/endpoint/TestS3ActionOverrideForOwnerVerification.java new file mode 100644 index 000000000000..c8bf7570cdcd --- /dev/null +++ b/hadoop-ozone/s3gateway/src/test/java/org/apache/hadoop/ozone/s3/endpoint/TestS3ActionOverrideForOwnerVerification.java @@ -0,0 +1,174 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.hadoop.ozone.s3.endpoint; + +import static org.apache.hadoop.ozone.s3.endpoint.EndpointTestUtils.put; +import static org.apache.hadoop.ozone.s3.util.S3Consts.COPY_SOURCE_HEADER; +import static org.apache.hadoop.ozone.s3.util.S3Consts.EXPECTED_BUCKET_OWNER_HEADER; +import static org.apache.hadoop.ozone.s3.util.S3Consts.EXPECTED_SOURCE_BUCKET_OWNER_HEADER; +import static org.apache.hadoop.ozone.s3.util.S3Consts.STORAGE_CLASS_HEADER; +import static org.apache.hadoop.ozone.s3.util.S3Consts.UNSIGNED_PAYLOAD; +import static org.apache.hadoop.ozone.s3.util.S3Consts.X_AMZ_CONTENT_SHA256; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.util.concurrent.atomic.AtomicReference; +import javax.ws.rs.core.HttpHeaders; +import org.apache.hadoop.hdds.conf.OzoneConfiguration; +import org.apache.hadoop.ozone.OzoneConfigKeys; +import org.apache.hadoop.ozone.client.ObjectStore; +import org.apache.hadoop.ozone.client.OzoneBucket; +import org.apache.hadoop.ozone.client.OzoneClient; +import org.apache.hadoop.ozone.client.OzoneVolume; +import org.apache.hadoop.ozone.client.protocol.ClientProtocol; +import org.apache.hadoop.ozone.om.exceptions.OMException; +import org.apache.hadoop.ozone.om.exceptions.OMException.ResultCodes; +import org.apache.hadoop.ozone.om.protocol.S3Auth; +import org.apache.hadoop.ozone.s3.signature.SignatureInfo; +import org.junit.jupiter.api.Test; + +/** + * Verifies bucket-owner-condition verification (source bucket owner lookup) runs under the correct IAM S3 action + * string for copy-style operations. + */ +public class TestS3ActionOverrideForOwnerVerification { + + private static final String DEST_BUCKET = "dest-bucket"; + private static final String DEST_KEY = "dest-key"; + private static final String SOURCE_BUCKET = "source-bucket"; + private static final String SOURCE_KEY = "source-key"; + private static final String SOURCE_OWNER = "source-owner"; + private static final String DEST_OWNER = "dest-owner"; + + @Test + public void testUploadPartCopyUsesGetObjectActionForSourceBucketOwnerLookup() throws Exception { + final AtomicReference actionAtSourceBucketOwnerLookup = new AtomicReference<>(); + final ObjectEndpoint endpoint = newEndpoint(actionAtSourceBucketOwnerLookup, true); + + // Trigger UploadPartCopy (MPU part upload with copy header). + final String uploadId = "upload-id"; + assertThrows(Exception.class, () -> put(endpoint, DEST_BUCKET, DEST_KEY, 1, uploadId, "")); + + assertEquals("GetObject", actionAtSourceBucketOwnerLookup.get()); + } + + @Test + public void testCopyObjectUsesGetObjectActionForSourceBucketOwnerLookup() throws Exception { + final AtomicReference actionAtSourceBucketOwnerLookup = new AtomicReference<>(); + final ObjectEndpoint endpoint = newEndpoint(actionAtSourceBucketOwnerLookup, true); + + // Trigger CopyObject (PUT with copy header, no upload ID). + assertThrows(Exception.class, () -> put(endpoint, DEST_BUCKET, DEST_KEY, "")); + + assertEquals("GetObject", actionAtSourceBucketOwnerLookup.get()); + } + + @Test + public void testUploadPartCopyDoesNotSetActionWhenStsDisabled() throws Exception { + final AtomicReference actionAtSourceBucketOwnerLookup = new AtomicReference<>(); + final ObjectEndpoint endpoint = newEndpoint(actionAtSourceBucketOwnerLookup, false); + + // Trigger UploadPartCopy (MPU part upload with copy header). + final String uploadId = "upload-id"; + assertThrows(Exception.class, () -> put(endpoint, DEST_BUCKET, DEST_KEY, 1, uploadId, "")); + + assertNull(actionAtSourceBucketOwnerLookup.get()); + } + + @Test + public void testCopyObjectDoesNotSetActionWhenStsDisabled() throws Exception { + final AtomicReference actionAtSourceBucketOwnerLookup = new AtomicReference<>(); + final ObjectEndpoint endpoint = newEndpoint(actionAtSourceBucketOwnerLookup, false); + + // Trigger CopyObject (PUT with copy header, no upload ID). + assertThrows(Exception.class, () -> put(endpoint, DEST_BUCKET, DEST_KEY, "")); + + assertNull(actionAtSourceBucketOwnerLookup.get()); + } + + private static ObjectEndpoint newEndpoint(AtomicReference actionAtSourceBucketOwnerLookup, + boolean isStsEnabled) throws Exception { + final HttpHeaders headers = mock(HttpHeaders.class); + when(headers.getHeaderString(X_AMZ_CONTENT_SHA256)).thenReturn(UNSIGNED_PAYLOAD); + when(headers.getHeaderString(STORAGE_CLASS_HEADER)).thenReturn("STANDARD"); + when(headers.getHeaderString(COPY_SOURCE_HEADER)).thenReturn(SOURCE_BUCKET + "/" + SOURCE_KEY); + when(headers.getHeaderString(EXPECTED_SOURCE_BUCKET_OWNER_HEADER)).thenReturn(SOURCE_OWNER); + when(headers.getHeaderString(EXPECTED_BUCKET_OWNER_HEADER)).thenReturn(DEST_OWNER); + + final SignatureInfo signatureInfo = mock(SignatureInfo.class); + when(signatureInfo.isSignPayload()).thenReturn(true); + when(signatureInfo.getStringToSign()).thenReturn("string-to-sign"); + when(signatureInfo.getSignature()).thenReturn("signature"); + when(signatureInfo.getAwsAccessId()).thenReturn("access-id"); + when(signatureInfo.getSessionToken()).thenReturn(null); + + final OzoneClient client = mock(OzoneClient.class); + final ObjectStore objectStore = mock(ObjectStore.class); + final ClientProtocol clientProtocol = mock(ClientProtocol.class); + final OzoneVolume volume = mock(OzoneVolume.class); + final OzoneBucket destBucket = mock(OzoneBucket.class); + final OzoneBucket sourceBucket = mock(OzoneBucket.class); + + final AtomicReference s3AuthRef = new AtomicReference<>(); + doAnswer(invocationOnMock -> { + s3AuthRef.set(invocationOnMock.getArgument(0)); + return null; + }).when(clientProtocol).setThreadLocalS3Auth(any(S3Auth.class)); + + when(client.getObjectStore()).thenReturn(objectStore); + when(client.getProxy()).thenReturn(clientProtocol); + when(objectStore.getClientProxy()).thenReturn(clientProtocol); + when(objectStore.getS3Volume()).thenReturn(volume); + + when(volume.getName()).thenReturn("s3Volume"); + + when(destBucket.getName()).thenReturn(DEST_BUCKET); + when(destBucket.getOwner()).thenReturn(DEST_OWNER); + when(volume.getBucket(DEST_BUCKET)).thenReturn(destBucket); + + when(sourceBucket.getOwner()).thenAnswer(invocationOnMock -> { + final S3Auth s3Auth = s3AuthRef.get(); + assertNotNull(s3Auth, "S3Auth must be initialized before owner lookup"); + actionAtSourceBucketOwnerLookup.set(s3Auth.getS3Action()); + return SOURCE_OWNER; + }); + when(volume.getBucket(SOURCE_BUCKET)).thenReturn(sourceBucket); + + // Stop the request after the source-bucket owner check, without needing to set up full copy behavior. + when(clientProtocol.getKeyDetails(anyString(), eq(SOURCE_BUCKET), eq(SOURCE_KEY))) + .thenThrow(new OMException("stop-after-owner-check", ResultCodes.KEY_NOT_FOUND)); + + final OzoneConfiguration conf = new OzoneConfiguration(); + conf.setBoolean(OzoneConfigKeys.OZONE_S3G_STS_HTTP_ENABLED_KEY, isStsEnabled); + return EndpointBuilder.newObjectEndpointBuilder() + .setClient(client) + .setConfig(conf) + .setHeaders(headers) + .setSignatureInfo(signatureInfo) + .build(); + } +} + diff --git a/hadoop-ozone/s3gateway/src/test/java/org/apache/hadoop/ozone/s3/exception/TestOS3Exceptions.java b/hadoop-ozone/s3gateway/src/test/java/org/apache/hadoop/ozone/s3/exception/TestOS3Exceptions.java index 239eb0791392..b091f93267af 100644 --- a/hadoop-ozone/s3gateway/src/test/java/org/apache/hadoop/ozone/s3/exception/TestOS3Exceptions.java +++ b/hadoop-ozone/s3gateway/src/test/java/org/apache/hadoop/ozone/s3/exception/TestOS3Exceptions.java @@ -17,6 +17,7 @@ package org.apache.hadoop.ozone.s3.exception; +import static java.net.HttpURLConnection.HTTP_BAD_REQUEST; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.mockito.Mockito.when; @@ -70,4 +71,36 @@ public void testResponseContentType() { assertEquals(MediaType.APPLICATION_XML_TYPE, response.getMediaType()); } + + /** + * AWS S3 returns HTTP 400 Bad Request for ExpiredToken (not 403). + */ + @Test + public void testExpiredTokenUsesBadRequestHttpStatus() { + assertEquals(HTTP_BAD_REQUEST, S3ErrorTable.EXPIRED_TOKEN.getHttpCode()); + final OS3Exception fromTable = S3ErrorTable.newError(S3ErrorTable.EXPIRED_TOKEN, "resource"); + assertEquals(HTTP_BAD_REQUEST, fromTable.getHttpCode()); + } + + @Test + public void testOS3ExceptionWithToken0() { + final OS3Exception ex = S3ErrorTable.newError(S3ErrorTable.EXPIRED_TOKEN, "resource"); + ex.setRequestId(OzoneUtils.getRequestID()); + ex.setHostId(OzoneUtils.getRequestID()); + ex.setToken0("token-value"); + + final String val = ex.toXml(); + final String formatString = "%n" + + "%n" + + " %s%n" + + " %s%n" + + " %s%n" + + " %s%n" + + " %s%n" + + " %s%n" + + "%n"; + final String expected = String.format(formatString, ex.getCode(), ex.getErrorMessage(), ex.getResource(), + ex.getRequestId(), ex.getHostId(), ex.getToken0()); + assertEquals(expected, val); + } } diff --git a/hadoop-ozone/s3gateway/src/test/java/org/apache/hadoop/ozone/s3/exception/TestOSTSExceptions.java b/hadoop-ozone/s3gateway/src/test/java/org/apache/hadoop/ozone/s3/exception/TestOSTSExceptions.java new file mode 100644 index 000000000000..184e96f04061 --- /dev/null +++ b/hadoop-ozone/s3gateway/src/test/java/org/apache/hadoop/ozone/s3/exception/TestOSTSExceptions.java @@ -0,0 +1,104 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.hadoop.ozone.s3.exception; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; + +import java.io.StringReader; +import javax.xml.parsers.DocumentBuilder; +import javax.xml.parsers.DocumentBuilderFactory; +import org.apache.hadoop.ozone.web.utils.OzoneUtils; +import org.junit.jupiter.api.Test; +import org.w3c.dom.Document; +import org.w3c.dom.Element; +import org.xml.sax.InputSource; + +/** + * This class tests OSTSException class. It is named TestOSTSExceptions instead of + * TestOSTSException to avoid findbugs rule about classes ending in *Exception must + * extend Exception or Throwable. + */ +public class TestOSTSExceptions { + + @Test + public void testOSTSException() throws Exception { + final OSTSException ex = new OSTSException(S3ErrorTable.STS_VALIDATION_ERROR) + .withMessage("1 validation error detected"); + final String requestId = OzoneUtils.getRequestID(); + ex.setRequestId(requestId); + final String val = ex.toXml(); + + final Document doc = parseXml(val); + final Element root = doc.getDocumentElement(); + assertEquals("ErrorResponse", root.getLocalName()); + // Ensure the response uses the default namespace (no prefix like "ns2:") + assertEquals("ErrorResponse", root.getNodeName()); + assertEquals("https://sts.amazonaws.com/doc/2011-06-15/", root.getNamespaceURI()); + + assertEquals("Sender", doc.getElementsByTagName("Type").item(0).getTextContent()); + assertEquals("ValidationError", doc.getElementsByTagName("Code").item(0).getTextContent()); + assertEquals("1 validation error detected", doc.getElementsByTagName("Message").item(0).getTextContent()); + assertEquals(requestId, doc.getElementsByTagName("RequestId").item(0).getTextContent()); + } + + @Test + public void testOSTSExceptionInvalidAction() throws Exception { + final OSTSException ex = new OSTSException(S3ErrorTable.STS_INVALID_ACTION) + .withMessage("Could not find operation"); + final String requestId = OzoneUtils.getRequestID(); + ex.setRequestId(requestId); + final String val = ex.toXml(); + + final Document doc = parseXml(val); + final Element root = doc.getDocumentElement(); + assertEquals("ErrorResponse", root.getLocalName()); + assertEquals("http://webservices.amazon.com/AWSFault/2005-15-09", root.getNamespaceURI()); + + assertEquals("Sender", doc.getElementsByTagName("Type").item(0).getTextContent()); + assertEquals("InvalidAction", doc.getElementsByTagName("Code").item(0).getTextContent()); + assertEquals("Could not find operation", doc.getElementsByTagName("Message").item(0).getTextContent()); + assertEquals(requestId, doc.getElementsByTagName("RequestId").item(0).getTextContent()); + } + + @Test + public void testOSTSExceptionWithCustomType() throws Exception { + final OSTSException ex = new OSTSException(S3ErrorTable.STS_INTERNAL_FAILURE).withType("Receiver"); + final String requestId = OzoneUtils.getRequestID(); + ex.setRequestId(requestId); + final String val = ex.toXml(); + + final Document doc = parseXml(val); + final Element root = doc.getDocumentElement(); + assertEquals("ErrorResponse", root.getLocalName()); + assertEquals("https://sts.amazonaws.com/doc/2011-06-15/", root.getNamespaceURI()); + + assertEquals("Receiver", doc.getElementsByTagName("Type").item(0).getTextContent()); + assertEquals("InternalFailure", doc.getElementsByTagName("Code").item(0).getTextContent()); + assertEquals("An internal error has occurred.", doc.getElementsByTagName("Message").item(0).getTextContent()); + assertEquals(requestId, doc.getElementsByTagName("RequestId").item(0).getTextContent()); + } + + private static Document parseXml(String xml) throws Exception { + assertNotNull(xml); + final DocumentBuilderFactory documentBuilderFactory = DocumentBuilderFactory.newInstance(); + documentBuilderFactory.setNamespaceAware(true); + final DocumentBuilder documentBuilder = documentBuilderFactory.newDocumentBuilder(); + return documentBuilder.parse(new InputSource(new StringReader(xml))); + } +} diff --git a/hadoop-ozone/s3gateway/src/test/java/org/apache/hadoop/ozone/s3/exception/TestOSTSNotFoundExceptionMapper.java b/hadoop-ozone/s3gateway/src/test/java/org/apache/hadoop/ozone/s3/exception/TestOSTSNotFoundExceptionMapper.java new file mode 100644 index 000000000000..c023d7b2c9c9 --- /dev/null +++ b/hadoop-ozone/s3gateway/src/test/java/org/apache/hadoop/ozone/s3/exception/TestOSTSNotFoundExceptionMapper.java @@ -0,0 +1,120 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.hadoop.ozone.s3.exception; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.io.StringReader; +import javax.ws.rs.NotFoundException; +import javax.ws.rs.core.MediaType; +import javax.ws.rs.core.Response; +import javax.ws.rs.core.UriInfo; +import javax.xml.parsers.DocumentBuilder; +import javax.xml.parsers.DocumentBuilderFactory; +import org.apache.hadoop.ozone.s3.RequestIdentifier; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.w3c.dom.Document; +import org.w3c.dom.Element; +import org.xml.sax.InputSource; + +/** + * Unit tests for {@link OSTSNotFoundExceptionMapper}. + */ +public class TestOSTSNotFoundExceptionMapper { + private static final String REQUEST_ID = "test-request-id"; + private static final String STS_NS = "https://sts.amazonaws.com/doc/2011-06-15/"; + + private OSTSNotFoundExceptionMapper mapper; + + @BeforeEach + public void setup() { + mapper = new OSTSNotFoundExceptionMapper(); + final RequestIdentifier requestIdentifier = mock(RequestIdentifier.class); + when(requestIdentifier.getRequestId()).thenReturn(REQUEST_ID); + mapper.setRequestIdentifier(requestIdentifier); + } + + @Test + public void testMapsNotFoundToStsValidationErrorForStsPath() throws Exception { + mapper.setUriInfo(createUriInfo("sts")); + + try (Response response = mapper.toResponse(new NotFoundException())) { + + assertEquals(Response.Status.BAD_REQUEST.getStatusCode(), response.getStatus()); + assertEquals(MediaType.APPLICATION_XML_TYPE, response.getMediaType()); + assertStsValidationErrorXml((String) response.getEntity(), "/sts"); + } + } + + @Test + public void testMapsNotFoundToStsValidationErrorForUnknownPath() throws Exception { + mapper.setUriInfo(createUriInfo("foo/bar")); + + try (Response response = mapper.toResponse(new NotFoundException())) { + + assertEquals(Response.Status.BAD_REQUEST.getStatusCode(), response.getStatus()); + assertEquals(MediaType.APPLICATION_XML_TYPE, response.getMediaType()); + assertStsValidationErrorXml((String) response.getEntity(), "/foo/bar"); + } + } + + @Test + public void testMapsNotFoundWhenRequestContextIsUnavailable() throws Exception { + try (Response response = mapper.toResponse(new NotFoundException())) { + + assertEquals(Response.Status.BAD_REQUEST.getStatusCode(), response.getStatus()); + assertEquals(MediaType.APPLICATION_XML_TYPE, response.getMediaType()); + assertStsValidationErrorXml((String) response.getEntity(), "/"); + } + } + + private static UriInfo createUriInfo(String path) { + final UriInfo uriInfo = mock(UriInfo.class); + when(uriInfo.getPath()).thenReturn(path); + return uriInfo; + } + + private static void assertStsValidationErrorXml(String xml, String expectedPathInMessage) throws Exception { + final Document doc = parseXml(xml); + final Element root = doc.getDocumentElement(); + assertEquals("ErrorResponse", root.getLocalName()); + assertEquals(STS_NS, root.getNamespaceURI()); + assertEquals("Sender", doc.getElementsByTagName("Type").item(0).getTextContent()); + assertEquals("ValidationError", doc.getElementsByTagName("Code").item(0).getTextContent()); + assertEquals(REQUEST_ID, doc.getElementsByTagName("RequestId").item(0).getTextContent()); + + final String message = doc.getElementsByTagName("Message").item(0).getTextContent(); + assertTrue( + message.contains("Invalid STS endpoint path '" + expectedPathInMessage + "'"), + "Expected message to mention path: " + expectedPathInMessage); + assertTrue(message.contains("root path /"), "Expected message to mention root path"); + } + + private static Document parseXml(String xml) throws Exception { + assertNotNull(xml); + final DocumentBuilderFactory documentBuilderFactory = DocumentBuilderFactory.newInstance(); + documentBuilderFactory.setNamespaceAware(true); + final DocumentBuilder documentBuilder = documentBuilderFactory.newDocumentBuilder(); + return documentBuilder.parse(new InputSource(new StringReader(xml))); + } +} diff --git a/hadoop-ozone/s3gateway/src/test/java/org/apache/hadoop/ozone/s3/signature/TestStringToSignProducer.java b/hadoop-ozone/s3gateway/src/test/java/org/apache/hadoop/ozone/s3/signature/TestStringToSignProducer.java index 9a2ce5bce826..2e3721a0165d 100644 --- a/hadoop-ozone/s3gateway/src/test/java/org/apache/hadoop/ozone/s3/signature/TestStringToSignProducer.java +++ b/hadoop-ozone/s3gateway/src/test/java/org/apache/hadoop/ozone/s3/signature/TestStringToSignProducer.java @@ -95,6 +95,7 @@ public void validateDateRange(Credential credentialObj) { //NOOP } }.parseSignature(); + signatureInfo.setPayloadHash("Content-SHA"); signatureInfo.setUnfilteredURI("/buckets"); headers.fixContentType(); @@ -129,7 +130,7 @@ public void testUrlEncodeInCanonicalRequest() { final String canonicalRequest = StringToSignProducer.buildCanonicalRequest( "https", "GET", "/bucket/a+b*c~d/foo bar", "host;x-amz-content-sha256;x-amz-date", - headers, queryParams, true); + headers, queryParams, UNSIGNED_PAYLOAD); assertEquals( "GET\n" diff --git a/hadoop-ozone/s3gateway/src/test/java/org/apache/hadoop/ozone/s3/util/TestS3GActionIamMapper.java b/hadoop-ozone/s3gateway/src/test/java/org/apache/hadoop/ozone/s3/util/TestS3GActionIamMapper.java new file mode 100644 index 000000000000..82f0134cffec --- /dev/null +++ b/hadoop-ozone/s3gateway/src/test/java/org/apache/hadoop/ozone/s3/util/TestS3GActionIamMapper.java @@ -0,0 +1,69 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.hadoop.ozone.s3.util; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + +import org.apache.hadoop.ozone.audit.S3GAction; +import org.junit.jupiter.api.Test; + +/** Unit tests for {@link S3GActionIamMapper}. */ +public class TestS3GActionIamMapper { + + @Test + public void mapsCoreObjectActions() { + assertEquals("ListBucket", S3GActionIamMapper.toS3ActionString(S3GAction.GET_BUCKET)); + assertEquals("ListBucket", S3GActionIamMapper.toS3ActionString(S3GAction.HEAD_BUCKET)); + assertEquals("CreateBucket", S3GActionIamMapper.toS3ActionString(S3GAction.CREATE_BUCKET)); + assertEquals("DeleteBucket", S3GActionIamMapper.toS3ActionString(S3GAction.DELETE_BUCKET)); + assertEquals("GetBucketAcl", S3GActionIamMapper.toS3ActionString(S3GAction.GET_ACL)); + assertEquals("PutBucketAcl", S3GActionIamMapper.toS3ActionString(S3GAction.PUT_ACL)); + assertEquals("ListBucketMultipartUploads", S3GActionIamMapper.toS3ActionString(S3GAction.LIST_MULTIPART_UPLOAD)); + assertEquals("DeleteObject", S3GActionIamMapper.toS3ActionString(S3GAction.MULTI_DELETE)); + assertEquals("DeleteObject", S3GActionIamMapper.toS3ActionString(S3GAction.DELETE_KEY)); + assertEquals("ListAllMyBuckets", S3GActionIamMapper.toS3ActionString(S3GAction.LIST_S3_BUCKETS)); + assertEquals("PutObject", S3GActionIamMapper.toS3ActionString(S3GAction.CREATE_MULTIPART_KEY)); + assertEquals("PutObject", S3GActionIamMapper.toS3ActionString(S3GAction.CREATE_KEY)); + assertEquals("PutObject", S3GActionIamMapper.toS3ActionString(S3GAction.INIT_MULTIPART_UPLOAD)); + assertEquals("PutObject", S3GActionIamMapper.toS3ActionString(S3GAction.COMPLETE_MULTIPART_UPLOAD)); + assertEquals("PutObject", S3GActionIamMapper.toS3ActionString(S3GAction.CREATE_DIRECTORY)); + assertEquals("ListMultipartUploadParts", S3GActionIamMapper.toS3ActionString(S3GAction.LIST_PARTS)); + assertEquals("GetObject", S3GActionIamMapper.toS3ActionString(S3GAction.GET_KEY)); + assertEquals("GetObject", S3GActionIamMapper.toS3ActionString(S3GAction.HEAD_KEY)); + assertEquals("AbortMultipartUpload", S3GActionIamMapper.toS3ActionString(S3GAction.ABORT_MULTIPART_UPLOAD)); + assertEquals("GetObjectTagging", S3GActionIamMapper.toS3ActionString(S3GAction.GET_OBJECT_TAGGING)); + assertEquals("PutObjectTagging", S3GActionIamMapper.toS3ActionString(S3GAction.PUT_OBJECT_TAGGING)); + assertEquals("DeleteObjectTagging", S3GActionIamMapper.toS3ActionString(S3GAction.DELETE_OBJECT_TAGGING)); + assertEquals("PutObjectAcl", S3GActionIamMapper.toS3ActionString(S3GAction.PUT_OBJECT_ACL)); + } + + @Test + public void copyActionsReturnNull() { + assertNull(S3GActionIamMapper.toS3ActionString(S3GAction.COPY_OBJECT)); + assertNull(S3GActionIamMapper.toS3ActionString(S3GAction.CREATE_MULTIPART_KEY_BY_COPY)); + } + + @Test + public void nonIamActionsReturnNull() { + assertNull(S3GActionIamMapper.toS3ActionString(S3GAction.ASSUME_ROLE)); + assertNull(S3GActionIamMapper.toS3ActionString(S3GAction.GET_CALLER_IDENTITY)); + assertNull(S3GActionIamMapper.toS3ActionString(S3GAction.GENERATE_SECRET)); + assertNull(S3GActionIamMapper.toS3ActionString(S3GAction.REVOKE_SECRET)); + } +} diff --git a/hadoop-ozone/s3gateway/src/test/java/org/apache/hadoop/ozone/s3/util/package-info.java b/hadoop-ozone/s3gateway/src/test/java/org/apache/hadoop/ozone/s3/util/package-info.java new file mode 100644 index 000000000000..4b7b37a574a1 --- /dev/null +++ b/hadoop-ozone/s3gateway/src/test/java/org/apache/hadoop/ozone/s3/util/package-info.java @@ -0,0 +1,21 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * Unit tests for s3 utilities. + */ +package org.apache.hadoop.ozone.s3.util; diff --git a/hadoop-ozone/s3gateway/src/test/java/org/apache/hadoop/ozone/s3sts/TestS3STSEndpoint.java b/hadoop-ozone/s3gateway/src/test/java/org/apache/hadoop/ozone/s3sts/TestS3STSEndpoint.java new file mode 100644 index 000000000000..379bd27eb981 --- /dev/null +++ b/hadoop-ozone/s3gateway/src/test/java/org/apache/hadoop/ozone/s3sts/TestS3STSEndpoint.java @@ -0,0 +1,895 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.hadoop.ozone.s3sts; + +import static org.apache.hadoop.ozone.OzoneConfigKeys.OZONE_S3_ADMINISTRATORS; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +import java.io.IOException; +import java.io.StringReader; +import java.time.Instant; +import javax.ws.rs.container.ContainerRequestContext; +import javax.ws.rs.core.Form; +import javax.ws.rs.core.MultivaluedHashMap; +import javax.ws.rs.core.Response; +import javax.ws.rs.core.UriInfo; +import javax.xml.parsers.DocumentBuilder; +import javax.xml.parsers.DocumentBuilderFactory; +import org.apache.commons.lang3.RandomStringUtils; +import org.apache.hadoop.hdds.conf.OzoneConfiguration; +import org.apache.hadoop.ozone.audit.AuditLogger; +import org.apache.hadoop.ozone.audit.AuditMessage; +import org.apache.hadoop.ozone.client.ObjectStore; +import org.apache.hadoop.ozone.client.OzoneClient; +import org.apache.hadoop.ozone.client.OzoneClientStub; +import org.apache.hadoop.ozone.om.exceptions.OMException; +import org.apache.hadoop.ozone.om.helpers.AssumeRoleResponseInfo; +import org.apache.hadoop.ozone.om.helpers.CallerIdentityInfo; +import org.apache.hadoop.ozone.s3.OzoneConfigurationHolder; +import org.apache.hadoop.ozone.s3.RequestIdentifier; +import org.apache.hadoop.ozone.s3.exception.OSTSException; +import org.apache.hadoop.ozone.s3.signature.SignatureInfo; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.w3c.dom.Document; +import org.w3c.dom.Element; +import org.xml.sax.InputSource; + +/** + * Test for S3 STS endpoint. + */ +public class TestS3STSEndpoint { + private S3STSEndpoint endpoint; + private ObjectStore objectStore; + private AuditLogger auditLogger; + private MultivaluedHashMap queryParameters; + private Form formParameters; + private static final String ROLE_ARN = "arn:aws:iam::123456789012:role/test-role"; + private static final String ROLE_SESSION_NAME = "test-session"; + private static final String ROLE_USER_ARN = "arn:aws:sts::123456789012:assumed-role/test-role/" + ROLE_SESSION_NAME; + private static final String STS_NS = "https://sts.amazonaws.com/doc/2011-06-15/"; + private static final String AWS_FAULT_NS = "http://webservices.amazon.com/AWSFault/2005-15-09"; + private static final String REQUEST_ID = "test-request-id"; + + @BeforeEach + public void setup() throws Exception { + OzoneConfiguration config = new OzoneConfiguration(); + config.set(OZONE_S3_ADMINISTRATORS, "test-user"); + OzoneConfigurationHolder.setConfiguration(config); + OzoneClient clientStub = spy(new OzoneClientStub()); + + final ContainerRequestContext context = mock(ContainerRequestContext.class); + final RequestIdentifier requestIdentifier = mock(RequestIdentifier.class); + final UriInfo uriInfo = mock(UriInfo.class); + when(context.getUriInfo()).thenReturn(uriInfo); + when(uriInfo.getPathParameters()).thenReturn(new MultivaluedHashMap<>()); + queryParameters = new MultivaluedHashMap<>(); + when(uriInfo.getQueryParameters()).thenReturn(queryParameters); + formParameters = new Form(); + + // Stub assumeRole to return deterministic credentials. + objectStore = mock(ObjectStore.class); + when(objectStore.assumeRole(anyString(), anyString(), anyInt(), any(), anyString())) + .thenReturn(new AssumeRoleResponseInfo( + "ASIA1234567890123456", + "mySecretAccessKey", + "session-token", + Instant.now().plusSeconds(3600).getEpochSecond(), + "AROA1234567890123456:test-session")); + when(objectStore.getCallerIdentity()) + .thenReturn(new CallerIdentityInfo( + "123456789012", + "arn:aws:iam::123456789012:user/test-user", + "test-user")); + when(clientStub.getObjectStore()).thenReturn(objectStore); + + endpoint = new S3STSEndpoint(); + endpoint.setClient(clientStub); + endpoint.setContext(context); + auditLogger = mock(AuditLogger.class); + endpoint.setAuditLogger(auditLogger); + + when(requestIdentifier.getRequestId()).thenReturn(REQUEST_ID); + endpoint.setRequestIdentifier(requestIdentifier); + + SignatureInfo signatureInfo = new SignatureInfo.Builder(SignatureInfo.Version.V4) + .setAwsAccessId("test-user") + .setSignature("some-signature") + .setStringToSign("dummy-string") + .build(); + endpoint.setSignatureInfo(signatureInfo); + } + + @Test + public void testStsAssumeRoleRejectsUnsupportedParameterForGetMethod() throws Exception { + setAssumeRoleQueryParameters("PolicyArns.member.1", "arn:aws:iam::123456789012:policy/test-policy"); + + final OSTSException ex = assertThrows( + OSTSException.class, () -> endpoint.get("AssumeRole", ROLE_ARN, ROLE_SESSION_NAME, 3600, "2011-06-15", null)); + + assertEquals(501, ex.getHttpCode()); + verify(auditLogger).logWriteFailure(any(AuditMessage.class)); + verify(auditLogger, never()).logWriteSuccess(any(AuditMessage.class)); + verify(objectStore, never()).assumeRole(anyString(), anyString(), anyInt(), any(), anyString()); + + ex.setRequestId(REQUEST_ID); + assertStsErrorXml( + ex.toXml(), STS_NS, "Sender", "UnsupportedOperation", + "AssumeRole optional parameter(s) not implemented: PolicyArns.member.1"); + } + + @Test + public void testStsAssumeRoleRejectsUnsupportedParameterForPostMethod() throws Exception { + setBaseAssumeRoleFormParameters(); + formParameters.param("ExternalId", "external-id"); + + final OSTSException ex = assertThrows(OSTSException.class, () -> endpoint.post(formParameters).close()); + + assertEquals(501, ex.getHttpCode()); + verify(auditLogger).logWriteFailure(any(AuditMessage.class)); + verify(auditLogger, never()).logWriteSuccess(any(AuditMessage.class)); + verify(objectStore, never()).assumeRole(anyString(), anyString(), anyInt(), any(), anyString()); + + ex.setRequestId(REQUEST_ID); + assertStsErrorXml( + ex.toXml(), STS_NS, "Sender", "UnsupportedOperation", + "AssumeRole optional parameter(s) not implemented: ExternalId"); + } + + @Test + public void testStsAssumeRoleAllowsSupportedParametersForGetMethod() { + setAssumeRoleQueryParameters( + "Action", "AssumeRole", + "RoleArn", ROLE_ARN, + "RoleSessionName", ROLE_SESSION_NAME, + "DurationSeconds", "3600", + "Version", "2011-06-15"); + + final Response response = endpoint.get("AssumeRole", ROLE_ARN, ROLE_SESSION_NAME, 3600, "2011-06-15", null); + + assertEquals(200, response.getStatus()); + verify(auditLogger).logWriteSuccess(any(AuditMessage.class)); + verify(auditLogger, never()).logWriteFailure(any(AuditMessage.class)); + } + + @Test + public void testStsAssumeRoleAllowsSignatureParametersForGetMethod() { + setAssumeRoleQueryParameters( + "Action", "AssumeRole", + "RoleArn", ROLE_ARN, + "RoleSessionName", ROLE_SESSION_NAME, + "DurationSeconds", "3600", + "Version", "2011-06-15", + "X-Amz-Algorithm", "AWS4-HMAC-SHA256", + "X-Amz-Credential", "test-user/20260101/us-east-1/sts/aws4_request", + "X-Amz-Date", "20260101T000000Z", + "X-Amz-Expires", "3600", + "X-Amz-SignedHeaders", "host", + "X-Amz-Signature", "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"); + + final Response response = endpoint.get("AssumeRole", ROLE_ARN, ROLE_SESSION_NAME, 3600, "2011-06-15", null); + + assertEquals(200, response.getStatus()); + verify(auditLogger).logWriteSuccess(any(AuditMessage.class)); + verify(auditLogger, never()).logWriteFailure(any(AuditMessage.class)); + } + + @Test + public void testStsAssumeRoleRejectsUnknownParameterForGetMethod() throws Exception { + setAssumeRoleQueryParameters("TotallyUnknownParam", "x"); + + final OSTSException ex = assertThrows( + OSTSException.class, () -> endpoint.get("AssumeRole", ROLE_ARN, ROLE_SESSION_NAME, 3600, "2011-06-15", null)); + + assertEquals(400, ex.getHttpCode()); + verify(auditLogger).logWriteFailure(any(AuditMessage.class)); + verify(auditLogger, never()).logWriteSuccess(any(AuditMessage.class)); + verify(objectStore, never()).assumeRole(anyString(), anyString(), anyInt(), any(), anyString()); + + ex.setRequestId(REQUEST_ID); + assertStsErrorXml( + ex.toXml(), STS_NS, "Sender", "ValidationError", "Unsupported AssumeRole parameter(s): TotallyUnknownParam"); + } + + @Test + public void testStsAssumeRoleRejectsUnknownParameterForPostMethod() throws Exception { + setBaseAssumeRoleFormParameters(); + formParameters.param("TotallyUnknownParam", "y"); + + final OSTSException ex = assertThrows(OSTSException.class, () -> endpoint.post(formParameters).close()); + + assertEquals(400, ex.getHttpCode()); + verify(auditLogger).logWriteFailure(any(AuditMessage.class)); + verify(auditLogger, never()).logWriteSuccess(any(AuditMessage.class)); + verify(objectStore, never()).assumeRole(anyString(), anyString(), anyInt(), any(), anyString()); + + ex.setRequestId(REQUEST_ID); + assertStsErrorXml( + ex.toXml(), STS_NS, "Sender", "ValidationError", + "Unsupported AssumeRole parameter(s): TotallyUnknownParam"); + } + + @Test + public void testStsAssumeRoleRejectsBlankParameterNameForGetMethod() throws Exception { + setAssumeRoleQueryParameters("", "x"); + + final OSTSException ex = assertThrows( + OSTSException.class, () -> endpoint.get("AssumeRole", ROLE_ARN, ROLE_SESSION_NAME, 3600, "2011-06-15", null)); + + assertEquals(400, ex.getHttpCode()); + verify(auditLogger).logWriteFailure(any(AuditMessage.class)); + verify(auditLogger, never()).logWriteSuccess(any(AuditMessage.class)); + verify(objectStore, never()).assumeRole(anyString(), anyString(), anyInt(), any(), anyString()); + + ex.setRequestId(REQUEST_ID); + assertStsErrorXml( + ex.toXml(), STS_NS, "Sender", "ValidationError", "Unsupported AssumeRole parameter(s): "); + } + + @Test + public void testStsAssumeRoleRejectsBlankParameterNameForPostMethod() throws Exception { + setBaseAssumeRoleFormParameters(); + formParameters.param("", "x"); + + final OSTSException ex = assertThrows(OSTSException.class, () -> endpoint.post(formParameters).close()); + + assertEquals(400, ex.getHttpCode()); + verify(auditLogger).logWriteFailure(any(AuditMessage.class)); + verify(auditLogger, never()).logWriteSuccess(any(AuditMessage.class)); + verify(objectStore, never()).assumeRole(anyString(), anyString(), anyInt(), any(), anyString()); + + ex.setRequestId(REQUEST_ID); + assertStsErrorXml( + ex.toXml(), STS_NS, "Sender", "ValidationError", "Unsupported AssumeRole parameter(s): "); + } + + @Test + public void testStsAssumeRoleIgnoresUnknownQueryStringParameterForPostMethod() { + queryParameters.add("foo", "bar"); + + // For POST requests, only body (form) parameters should be validated. + // Query string parameters should not affect validation results. + setBaseAssumeRoleFormParameters(); + final Response response = endpoint.post(formParameters); + response.close(); + + assertEquals(200, response.getStatus()); + verify(auditLogger).logWriteSuccess(any(AuditMessage.class)); + verify(auditLogger, never()).logWriteFailure(any(AuditMessage.class)); + } + + @Test + public void testStsAssumeRoleRejectsUnsupportedSigningParametersForPostMethod() throws Exception { + setBaseAssumeRoleFormParameters(); + formParameters.param("AWSAccessKeyId", "test-user"); + formParameters.param("Signature", "signature"); + formParameters.param("Expires", "3600"); + + final OSTSException ex = assertThrows(OSTSException.class, () -> endpoint.post(formParameters).close()); + + assertEquals(400, ex.getHttpCode()); + verify(auditLogger).logWriteFailure(any(AuditMessage.class)); + verify(auditLogger, never()).logWriteSuccess(any(AuditMessage.class)); + verify(objectStore, never()).assumeRole(anyString(), anyString(), anyInt(), any(), anyString()); + + ex.setRequestId(REQUEST_ID); + assertStsErrorXml( + ex.toXml(), STS_NS, "Sender", "ValidationError", + "Unsupported AssumeRole parameter(s): AWSAccessKeyId, Expires, Signature"); + } + + @Test + public void testStsAssumeRoleValidForGetMethod() throws Exception { + final Response response = endpoint.get("AssumeRole", ROLE_ARN, ROLE_SESSION_NAME, 3600, "2011-06-15", null); + + assertEquals(200, response.getStatus()); + verify(auditLogger).logWriteSuccess(any(AuditMessage.class)); + verify(auditLogger, never()).logWriteFailure(any(AuditMessage.class)); + + String responseXml = (String) response.getEntity(); + assertNotNull(responseXml); + + // Parse response XML and verify values + final Document doc = parseXml(responseXml); + + final Element root = doc.getDocumentElement(); + assertEquals("AssumeRoleResponse", root.getLocalName()); + assertEquals(STS_NS, root.getNamespaceURI()); + // Ensure the response uses the default namespace (no prefix like "ns2:") + assertEquals("AssumeRoleResponse", root.getNodeName()); + + // Verify some key elements are present in the STS namespace + assertNotNull(doc.getElementsByTagNameNS(STS_NS, "AssumeRoleResult").item(0)); + assertNotNull(doc.getElementsByTagNameNS(STS_NS, "Credentials").item(0)); + assertNotNull(doc.getElementsByTagNameNS(STS_NS, "AccessKeyId").item(0)); + + final String accessKeyId = doc.getElementsByTagName("AccessKeyId").item(0).getTextContent(); + assertEquals("ASIA1234567890123456", accessKeyId); + + final String secretAccessKey = doc.getElementsByTagName("SecretAccessKey").item(0).getTextContent(); + assertEquals("mySecretAccessKey", secretAccessKey); + + final String sessionToken = doc.getElementsByTagName("SessionToken").item(0).getTextContent(); + assertEquals("session-token", sessionToken); + + final String arn = doc.getElementsByTagName("Arn").item(0).getTextContent(); + assertEquals(ROLE_USER_ARN, arn); + } + + @Test + public void testStsAssumeRoleValidForPostMethod() throws Exception { + setBaseAssumeRoleFormParameters(); + //noinspection resource + final Response response = endpoint.post(formParameters); + + assertEquals(200, response.getStatus()); + verify(auditLogger).logWriteSuccess(any(AuditMessage.class)); + verify(auditLogger, never()).logWriteFailure(any(AuditMessage.class)); + final String responseXml = (String) response.getEntity(); + assertNotNull(responseXml); + + final Document doc = parseXml(responseXml); + final Element root = doc.getDocumentElement(); + assertEquals("AssumeRoleResponse", root.getLocalName()); + assertEquals(STS_NS, root.getNamespaceURI()); + // Ensure the response uses the default namespace (no prefix like "ns2:") + assertEquals("AssumeRoleResponse", root.getNodeName()); + + // Verify some key elements are present in the STS namespace + assertNotNull(doc.getElementsByTagNameNS(STS_NS, "AssumeRoleResult").item(0)); + assertNotNull(doc.getElementsByTagNameNS(STS_NS, "Credentials").item(0)); + assertNotNull(doc.getElementsByTagNameNS(STS_NS, "AccessKeyId").item(0)); + + final String accessKeyId = doc.getElementsByTagName("AccessKeyId").item(0).getTextContent(); + assertEquals("ASIA1234567890123456", accessKeyId); + + final String secretAccessKey = doc.getElementsByTagName("SecretAccessKey").item(0).getTextContent(); + assertEquals("mySecretAccessKey", secretAccessKey); + + final String sessionToken = doc.getElementsByTagName("SessionToken").item(0).getTextContent(); + assertEquals("session-token", sessionToken); + + final String arn = doc.getElementsByTagName("Arn").item(0).getTextContent(); + assertEquals(ROLE_USER_ARN, arn); + } + + @Test + public void testStsNullAction() throws Exception { + final Response response = endpoint.get(null, ROLE_ARN, ROLE_SESSION_NAME, 3600, "2011-06-15", null); + + assertEquals(400, response.getStatus()); + verifyNoInteractions(auditLogger); + final String errorMessage = (String) response.getEntity(); + assertEquals("", errorMessage); + + final Document doc = parseXml(errorMessage); + final Element root = doc.getDocumentElement(); + assertEquals("UnknownOperationException", root.getLocalName()); + } + + @Test + public void testStsNullFormForPostMethod() throws Exception { + final Response response = endpoint.post(null); + + assertEquals(400, response.getStatus()); + verifyNoInteractions(auditLogger); + final String errorMessage = (String) response.getEntity(); + assertEquals("", errorMessage); + + final Document doc = parseXml(errorMessage); + final Element root = doc.getDocumentElement(); + assertEquals("UnknownOperationException", root.getLocalName()); + + response.close(); + } + + @Test + public void testStsUnsupportedActionWithVersionSupplied() throws Exception { + final OSTSException ex = assertThrows(OSTSException.class, () -> + endpoint.get("UnsupportedAction", ROLE_ARN, ROLE_SESSION_NAME, 3600, "2011-06-15", null)); + + assertEquals(400, ex.getHttpCode()); + verifyNoInteractions(auditLogger); + + ex.setRequestId(REQUEST_ID); + assertStsErrorXml(ex.toXml(), AWS_FAULT_NS, "Sender", "InvalidAction", + "Could not find operation UnsupportedAction for version 2011-06-15"); + } + + @Test + public void testStsUnsupportedActionWithVersionNotSupplied() throws Exception { + final OSTSException ex = assertThrows(OSTSException.class, () -> + endpoint.get("UnsupportedAction", ROLE_ARN, ROLE_SESSION_NAME, 3600, null, null)); + + assertEquals(400, ex.getHttpCode()); + verifyNoInteractions(auditLogger); + + ex.setRequestId(REQUEST_ID); + assertStsErrorXml(ex.toXml(), AWS_FAULT_NS, "Sender", "InvalidAction", + "Could not find operation UnsupportedAction for version NO_VERSION_SPECIFIED"); + } + + @Test + public void testStsAssumeRoleWithInvalidVersion() throws Exception { + final OSTSException ex = assertThrows(OSTSException.class, () -> + endpoint.get("AssumeRole", ROLE_ARN, ROLE_SESSION_NAME, 3600, "2000-01-01", null)); + + assertEquals(400, ex.getHttpCode()); + verify(auditLogger).logWriteFailure(any(AuditMessage.class)); + verify(auditLogger, never()).logWriteSuccess(any(AuditMessage.class)); + + ex.setRequestId(REQUEST_ID); + assertStsErrorXml(ex.toXml(), AWS_FAULT_NS, "Sender", "InvalidAction", + "Could not find operation AssumeRole for version 2000-01-01"); + } + + @Test + public void testStsInvalidDuration() throws Exception { + final OSTSException ex = assertThrows(OSTSException.class, () -> + endpoint.get("AssumeRole", ROLE_ARN, ROLE_SESSION_NAME, -1, "2011-06-15", null)); + + assertEquals(400, ex.getHttpCode()); + verify(auditLogger).logWriteFailure(any(AuditMessage.class)); + verify(auditLogger, never()).logWriteSuccess(any(AuditMessage.class)); + + ex.setRequestId(REQUEST_ID); + assertStsErrorXml(ex.toXml(), STS_NS, "Sender", "ValidationError", "Invalid Value: DurationSeconds"); + } + + @Test + public void testStsNullDurationUsesDefault3600() throws Exception { + final Response response = endpoint.get( + "AssumeRole", ROLE_ARN, ROLE_SESSION_NAME, null, "2011-06-15", null); + assertEquals(200, response.getStatus()); + verify(auditLogger).logWriteSuccess(any(AuditMessage.class)); + verify(auditLogger, never()).logWriteFailure(any(AuditMessage.class)); + + final ArgumentCaptor durationCaptor = ArgumentCaptor.forClass(Integer.class); + verify(objectStore).assumeRole(anyString(), anyString(), durationCaptor.capture(), any(), anyString()); + assertEquals(3600, durationCaptor.getValue()); + } + + @Test + public void testStsPolicyTooLarge() throws Exception { + final String tooLargePolicy = RandomStringUtils.insecure().nextAlphanumeric(2049); + + final OSTSException ex = assertThrows(OSTSException.class, () -> + endpoint.get("AssumeRole", ROLE_ARN, ROLE_SESSION_NAME, 3600, "2011-06-15", tooLargePolicy)); + + assertEquals(400, ex.getHttpCode()); + verify(auditLogger).logWriteFailure(any(AuditMessage.class)); + verify(auditLogger, never()).logWriteSuccess(any(AuditMessage.class)); + + ex.setRequestId(REQUEST_ID); + assertStsErrorXml(ex.toXml(), STS_NS, "Sender", "ValidationError", + "Value '" + tooLargePolicy + "' at 'policy' failed to satisfy constraint: Member " + + "must have length less than or equal to 2048"); + } + + @Test + public void testStsInvalidRoleArn() throws Exception { + final String invalidRoleArn = "arn:awsNotValid::123456789012:role/test-role"; + final OSTSException ex = assertThrows(OSTSException.class, () -> + endpoint.get("AssumeRole", invalidRoleArn, ROLE_SESSION_NAME, 3600, "2011-06-15", null)); + + assertEquals(400, ex.getHttpCode()); + verify(auditLogger).logWriteFailure(any(AuditMessage.class)); + verify(auditLogger, never()).logWriteSuccess(any(AuditMessage.class)); + + ex.setRequestId(REQUEST_ID); + assertStsErrorXml(ex.toXml(), STS_NS, "Sender", "ValidationError", + "Invalid role ARN (does not start with arn:aws:iam::)"); + } + + @Test + public void testStsMissingRoleArn() throws Exception { + final OSTSException ex = assertThrows(OSTSException.class, () -> + endpoint.get("AssumeRole", null, ROLE_SESSION_NAME, 3600, "2011-06-15", null)); + + assertEquals(400, ex.getHttpCode()); + verify(auditLogger).logWriteFailure(any(AuditMessage.class)); + verify(auditLogger, never()).logWriteSuccess(any(AuditMessage.class)); + + ex.setRequestId(REQUEST_ID); + assertStsErrorXml(ex.toXml(), STS_NS, "Sender", "ValidationError", "Value null at 'roleArn'"); + } + + @Test + public void testStsInvalidRoleArnMissingRoleName() throws Exception { + final String invalidRoleArn = "arn:aws:iam::123456789012:role/"; + final OSTSException ex = assertThrows(OSTSException.class, () -> + endpoint.get("AssumeRole", invalidRoleArn, ROLE_SESSION_NAME, 3600, "2011-06-15", null)); + + assertEquals(400, ex.getHttpCode()); + assertEquals("ValidationError", ex.getCode()); + verify(auditLogger).logWriteFailure(any(AuditMessage.class)); + verify(auditLogger, never()).logWriteSuccess(any(AuditMessage.class)); + + ex.setRequestId(REQUEST_ID); + assertStsErrorXml(ex.toXml(), STS_NS, "Sender", "ValidationError", "Invalid role ARN: missing role name"); + } + + @Test + public void testStsInvalidRoleArnMissingAccountId() throws Exception { + final String invalidRoleArn = "arn:aws:iam:::role/test-role"; + final OSTSException ex = assertThrows(OSTSException.class, () -> + endpoint.get("AssumeRole", invalidRoleArn, ROLE_SESSION_NAME, 3600, "2011-06-15", null)); + + assertEquals(400, ex.getHttpCode()); + assertEquals("ValidationError", ex.getCode()); + verify(auditLogger).logWriteFailure(any(AuditMessage.class)); + verify(auditLogger, never()).logWriteSuccess(any(AuditMessage.class)); + + ex.setRequestId(REQUEST_ID); + assertStsErrorXml(ex.toXml(), STS_NS, "Sender", "ValidationError", "Invalid AWS account ID in ARN" + ); + } + + @Test + public void testStsWhenActionNotImplemented() throws Exception { + final OSTSException ex = assertThrows(OSTSException.class, () -> + endpoint.get("GetSessionToken", ROLE_ARN, ROLE_SESSION_NAME, 3600, "2011-06-15", null)); + + assertEquals(501, ex.getHttpCode()); + verifyNoInteractions(auditLogger); + + ex.setRequestId(REQUEST_ID); + assertStsErrorXml(ex.toXml(), AWS_FAULT_NS, "Sender", "InvalidAction", + "Operation GetSessionToken is not supported yet."); + } + + @Test + public void testStsGetCallerIdentitySuccessForGetMethod() throws Exception { + final Response response = endpoint.get("GetCallerIdentity", null, null, null, "2011-06-15", null); + + assertEquals(200, response.getStatus()); + verify(objectStore).getCallerIdentity(); + verify(auditLogger).logWriteSuccess(any(AuditMessage.class)); + verify(auditLogger, never()).logWriteFailure(any(AuditMessage.class)); + + final Document doc = parseXml((String) response.getEntity()); + assertEquals("GetCallerIdentityResponse", doc.getDocumentElement().getLocalName()); + assertEquals(STS_NS, doc.getDocumentElement().getNamespaceURI()); + assertEquals( + "123456789012", doc.getElementsByTagNameNS(STS_NS, "Account").item(0).getTextContent()); + assertEquals( + "arn:aws:iam::123456789012:user/test-user", doc.getElementsByTagNameNS(STS_NS, "Arn").item(0).getTextContent()); + assertEquals( + "test-user", doc.getElementsByTagNameNS(STS_NS, "UserId").item(0).getTextContent()); + } + + @Test + public void testStsGetCallerIdentityIgnoresExtraParameters() throws Exception { + final Response response = endpoint.get("GetCallerIdentity", ROLE_ARN, ROLE_SESSION_NAME, 3600, "2011-06-15", null); + + assertEquals(200, response.getStatus()); + verify(objectStore).getCallerIdentity(); + } + + @Test + public void testStsGetCallerIdentityIgnoresExtraParametersForPostMethod() throws Exception { + formParameters = new Form(); + formParameters.param("Action", "GetCallerIdentity"); + formParameters.param("Version", "2011-06-15"); + formParameters.param("RoleArn", ROLE_ARN); + formParameters.param("RoleSessionName", ROLE_SESSION_NAME); + formParameters.param("DurationSeconds", "3600"); + + final Response response = endpoint.post(formParameters); + + assertEquals(200, response.getStatus()); + verify(objectStore).getCallerIdentity(); + } + + @Test + public void testStsGetCallerIdentityRejectsMissingVersion() throws Exception { + final OSTSException ex = assertThrows( + OSTSException.class, () -> endpoint.get("GetCallerIdentity", null, null, null, null, null)); + + assertEquals(400, ex.getHttpCode()); + verify(auditLogger).logWriteFailure(any(AuditMessage.class)); + verify(objectStore, never()).getCallerIdentity(); + + ex.setRequestId(REQUEST_ID); + assertStsErrorXml( + ex.toXml(), AWS_FAULT_NS, "Sender", "InvalidAction", + "Could not find operation GetCallerIdentity for version NO_VERSION_SPECIFIED"); + } + + @Test + public void testStsGetCallerIdentityRejectsInvalidVersion() throws Exception { + final OSTSException ex = assertThrows( + OSTSException.class, () -> endpoint.get("GetCallerIdentity", null, null, null, "2020-01-01", null)); + + assertEquals(400, ex.getHttpCode()); + verify(auditLogger).logWriteFailure(any(AuditMessage.class)); + verify(objectStore, never()).getCallerIdentity(); + + ex.setRequestId(REQUEST_ID); + assertStsErrorXml( + ex.toXml(), AWS_FAULT_NS, "Sender", "InvalidAction", + "Could not find operation GetCallerIdentity for version 2020-01-01"); + } + + @Test + public void testStsMissingRoleSessionName() throws Exception { + final OSTSException ex = assertThrows(OSTSException.class, () -> + endpoint.get("AssumeRole", ROLE_ARN, null, 3600, "2011-06-15", null)); + + assertEquals(400, ex.getHttpCode()); + verify(auditLogger).logWriteFailure(any(AuditMessage.class)); + verify(auditLogger, never()).logWriteSuccess(any(AuditMessage.class)); + + ex.setRequestId(REQUEST_ID); + assertStsErrorXml(ex.toXml(), STS_NS, "Sender", "ValidationError", "Value null at 'roleSessionName'"); + } + + @Test + public void testStsInvalidRoleSessionNameWithInvalidCharacter() throws Exception { + final String invalidSession = "test/session"; + final OSTSException ex = assertThrows(OSTSException.class, () -> + endpoint.get("AssumeRole", ROLE_ARN, invalidSession, 3600, "2011-06-15", null)); + + assertEquals(400, ex.getHttpCode()); + verify(auditLogger).logWriteFailure(any(AuditMessage.class)); + verify(auditLogger, never()).logWriteSuccess(any(AuditMessage.class)); + + ex.setRequestId(REQUEST_ID); + assertStsErrorXml( + ex.toXml(), STS_NS, "Sender", "ValidationError", "1 validation error detected: " + + "Invalid character '/' in RoleSessionName: it must be 2-64 characters long and contain only alphanumeric " + + "characters and +, =, ,, ., @, -"); + } + + @Test + public void testStsInvalidRoleSessionNameTooShort() throws Exception { + final String invalidSession = "a"; + final OSTSException ex = assertThrows(OSTSException.class, () -> + endpoint.get("AssumeRole", ROLE_ARN, invalidSession, 3600, "2011-06-15", null)); + + assertEquals(400, ex.getHttpCode()); + verify(auditLogger).logWriteFailure(any(AuditMessage.class)); + verify(auditLogger, never()).logWriteSuccess(any(AuditMessage.class)); + + ex.setRequestId(REQUEST_ID); + assertStsErrorXml( + ex.toXml(), STS_NS, "Sender", "ValidationError", "1 validation error detected: Invalid RoleSessionName " + + "length 1: it must be 2-64 characters long and contain only alphanumeric characters and +, =, ,, ., @, -"); + } + + @Test + public void testStsInvalidRoleArnResourceType() throws Exception { + // Resource type must be role, not user + final String invalidRoleArn = "arn:aws:iam::123456789012:user/test-user"; + final OSTSException ex = assertThrows(OSTSException.class, () -> + endpoint.get("AssumeRole", invalidRoleArn, ROLE_SESSION_NAME, 3600, "2011-06-15", null)); + + assertEquals(400, ex.getHttpCode()); + verify(auditLogger).logWriteFailure(any(AuditMessage.class)); + verify(auditLogger, never()).logWriteSuccess(any(AuditMessage.class)); + + ex.setRequestId(REQUEST_ID); + assertStsErrorXml(ex.toXml(), STS_NS, "Sender", "ValidationError", "Invalid role ARN (unexpected field count)"); + } + + @Test + public void testStsInternalFailureWhenBackendThrows() throws Exception { + when(objectStore.assumeRole(anyString(), anyString(), anyInt(), any(), anyString())) + .thenThrow(new RuntimeException("some unexpected error")); + + final OSTSException ex = assertThrows(OSTSException.class, () -> + endpoint.get("AssumeRole", ROLE_ARN, ROLE_SESSION_NAME, 3600, "2011-06-15", null)); + + assertEquals(500, ex.getHttpCode()); + verify(auditLogger).logWriteFailure(any(AuditMessage.class)); + verify(auditLogger, never()).logWriteSuccess(any(AuditMessage.class)); + + ex.setRequestId(REQUEST_ID); + assertStsErrorXml(ex.toXml(), STS_NS, "Receiver", "InternalFailure", "An internal error has occurred."); + } + + @Test + public void testStsAccessDenied() throws Exception { + when(objectStore.assumeRole(anyString(), anyString(), anyInt(), any(), anyString())) + .thenThrow(new OMException("Permission denied", OMException.ResultCodes.ACCESS_DENIED)); + + final OSTSException ex = assertThrows(OSTSException.class, () -> + endpoint.get("AssumeRole", ROLE_ARN, ROLE_SESSION_NAME, 3600, "2011-06-15", null)); + + assertEquals(403, ex.getHttpCode()); + verify(auditLogger).logWriteFailure(any(AuditMessage.class)); + verify(auditLogger, never()).logWriteSuccess(any(AuditMessage.class)); + + ex.setRequestId(REQUEST_ID); + assertStsErrorXml(ex.toXml(), STS_NS, "Sender", "AccessDenied", + "User is not authorized to perform: sts:AssumeRole on resource: " + ROLE_ARN); + } + + @Test + public void testStsUnsupportedOperationWhenBackendThrowsNotSupportedOperation() throws Exception { + when(objectStore.assumeRole(anyString(), anyString(), anyInt(), any(), anyString())) + .thenThrow(new OMException("Operation is not supported", OMException.ResultCodes.NOT_SUPPORTED_OPERATION)); + + final OSTSException ex = assertThrows(OSTSException.class, () -> + endpoint.get("AssumeRole", ROLE_ARN, ROLE_SESSION_NAME, 3600, "2011-06-15", null)); + + assertEquals(501, ex.getHttpCode()); + verify(auditLogger).logWriteFailure(any(AuditMessage.class)); + verify(auditLogger, never()).logWriteSuccess(any(AuditMessage.class)); + + ex.setRequestId(REQUEST_ID); + assertStsErrorXml(ex.toXml(), STS_NS, "Sender", "UnsupportedOperation", "Operation is not supported"); + } + + @Test + public void testStsUnsupportedOperationWhenBackendThrowsFeatureNotEnabled() throws Exception { + when(objectStore.assumeRole(anyString(), anyString(), anyInt(), any(), anyString())) + .thenThrow(new OMException("Feature is not enabled", OMException.ResultCodes.FEATURE_NOT_ENABLED)); + + final OSTSException ex = assertThrows(OSTSException.class, () -> + endpoint.get("AssumeRole", ROLE_ARN, ROLE_SESSION_NAME, 3600, "2011-06-15", null)); + + assertEquals(501, ex.getHttpCode()); + verify(auditLogger).logWriteFailure(any(AuditMessage.class)); + verify(auditLogger, never()).logWriteSuccess(any(AuditMessage.class)); + + ex.setRequestId(REQUEST_ID); + assertStsErrorXml(ex.toXml(), STS_NS, "Sender", "UnsupportedOperation", "Feature is not enabled"); + } + + @Test + public void testStsValidationErrorWhenBackendThrowsInvalidRequest() throws Exception { + when(objectStore.assumeRole(anyString(), anyString(), anyInt(), any(), anyString())) + .thenThrow(new OMException("Invalid request parameter", OMException.ResultCodes.INVALID_REQUEST)); + + final OSTSException ex = assertThrows(OSTSException.class, () -> + endpoint.get("AssumeRole", ROLE_ARN, ROLE_SESSION_NAME, 3600, "2011-06-15", null)); + + assertEquals(400, ex.getHttpCode()); + verify(auditLogger).logWriteFailure(any(AuditMessage.class)); + verify(auditLogger, never()).logWriteSuccess(any(AuditMessage.class)); + + ex.setRequestId(REQUEST_ID); + assertStsErrorXml(ex.toXml(), STS_NS, "Sender", "ValidationError", "Invalid request parameter"); + } + + @Test + public void testStsMalformedPolicyDocumentWhenBackendThrowsMalformedPolicyDocument() throws Exception { + when(objectStore.assumeRole(anyString(), anyString(), anyInt(), any(), anyString())) + .thenThrow(new OMException("Malformed session policy", OMException.ResultCodes.MALFORMED_POLICY_DOCUMENT)); + + final OSTSException ex = assertThrows(OSTSException.class, () -> + endpoint.get("AssumeRole", ROLE_ARN, ROLE_SESSION_NAME, 3600, "2011-06-15", null)); + + assertEquals(400, ex.getHttpCode()); + verify(auditLogger).logWriteFailure(any(AuditMessage.class)); + verify(auditLogger, never()).logWriteSuccess(any(AuditMessage.class)); + + ex.setRequestId(REQUEST_ID); + assertStsErrorXml( + ex.toXml(), STS_NS, "Sender", "MalformedPolicyDocument", "Malformed session policy"); + } + + @Test + public void testStsIOExceptionWrappedAsInternalFailure() throws Exception { + when(objectStore.assumeRole(anyString(), anyString(), anyInt(), any(), anyString())) + .thenThrow(new IOException("An IO error occurred")); + + final OSTSException ex = assertThrows(OSTSException.class, () -> + endpoint.get("AssumeRole", ROLE_ARN, ROLE_SESSION_NAME, 3600, "2011-06-15", null)); + + assertEquals(500, ex.getHttpCode()); + verify(auditLogger).logWriteFailure(any(AuditMessage.class)); + verify(auditLogger, never()).logWriteSuccess(any(AuditMessage.class)); + + ex.setRequestId(REQUEST_ID); + assertStsErrorXml(ex.toXml(), STS_NS, "Receiver", "InternalFailure", "An internal error has occurred."); + } + + @Test + public void testStsMultipleValidationErrors() throws Exception { + final String invalidRoleSessionName = "test/session"; + final String tooLargePolicy = RandomStringUtils.insecure().nextAlphanumeric(2049); + final int invalidDurationSeconds = -1; + + final OSTSException ex = assertThrows(OSTSException.class, () -> + endpoint.get("AssumeRole", ROLE_ARN, invalidRoleSessionName, invalidDurationSeconds, "2011-06-15", + tooLargePolicy)); + + assertEquals(400, ex.getHttpCode()); + verify(auditLogger).logWriteFailure(any(AuditMessage.class)); + verify(auditLogger, never()).logWriteSuccess(any(AuditMessage.class)); + + ex.setRequestId(REQUEST_ID); + + final String xml = ex.toXml(); + // The order of individual validation errors is not guaranteed because it's a HashSet, so check + // that multiple messages are included + final Document doc = parseXml(xml); + final String message = doc.getElementsByTagName("Message").item(0).getTextContent(); + assertTrue(message.contains("3 validation errors detected")); + assertTrue(message.contains("Invalid Value: DurationSeconds")); + assertTrue(message.contains("Invalid character '/' in RoleSessionName")); + assertTrue(message.contains( + "'policy' failed to satisfy constraint: Member must have length less than or equal to 2048")); + } + + private void setAssumeRoleQueryParameters(String... nameValuePairs) { + queryParameters.clear(); + for (int i = 0; i < nameValuePairs.length; i += 2) { + queryParameters.add(nameValuePairs[i], nameValuePairs[i + 1]); + } + } + + private void setBaseAssumeRoleFormParameters() { + setAssumeRoleFormParameters( + "Action", "AssumeRole", + "RoleArn", ROLE_ARN, + "RoleSessionName", ROLE_SESSION_NAME, + "DurationSeconds", "3600", + "Version", "2011-06-15"); + } + + private void setAssumeRoleFormParameters(String... nameValuePairs) { + formParameters = new Form(); + for (int i = 0; i < nameValuePairs.length; i += 2) { + formParameters.param(nameValuePairs[i], nameValuePairs[i + 1]); + } + } + + private static Document parseXml(String xml) throws Exception { + final DocumentBuilderFactory documentBuilderFactory = DocumentBuilderFactory.newInstance(); + documentBuilderFactory.setNamespaceAware(true); + final DocumentBuilder documentBuilder = documentBuilderFactory.newDocumentBuilder(); + return documentBuilder.parse(new InputSource(new StringReader(xml))); + } + + private static void assertStsErrorXml(String xml, String expectedNamespace, String expectedType, String expectedCode, + String expectedMessageContains) throws Exception { + final Document doc = parseXml(xml); + final Element root = doc.getDocumentElement(); + assertEquals("ErrorResponse", root.getLocalName()); + assertEquals(expectedNamespace, root.getNamespaceURI()); + + final String type = doc.getElementsByTagName("Type").item(0).getTextContent(); + assertEquals(expectedType, type); + + final String code = doc.getElementsByTagName("Code").item(0).getTextContent(); + assertEquals(expectedCode, code); + + final String message = doc.getElementsByTagName("Message").item(0).getTextContent(); + assertNotNull(message); + assertTrue(message.contains(expectedMessageContains), "Expected message to contain: " + expectedMessageContains); + + final String requestId = doc.getElementsByTagName("RequestId").item(0).getTextContent(); + assertEquals(REQUEST_ID, requestId); + } +} diff --git a/hadoop-ozone/s3gateway/src/test/java/org/apache/hadoop/ozone/s3sts/package-info.java b/hadoop-ozone/s3gateway/src/test/java/org/apache/hadoop/ozone/s3sts/package-info.java new file mode 100644 index 000000000000..27318a155206 --- /dev/null +++ b/hadoop-ozone/s3gateway/src/test/java/org/apache/hadoop/ozone/s3sts/package-info.java @@ -0,0 +1,21 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * Unit tests for the s3 sts endpoint. + */ +package org.apache.hadoop.ozone.s3sts;