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