This is an automated email from the ASF dual-hosted git repository.
pradeepagrawal8184 pushed a commit to branch ranger-2.9
in repository https://gitbox.apache.org/repos/asf/ranger.git
The following commit(s) were added to refs/heads/ranger-2.9 by this push:
new 8603cbb36 RANGER-5628/RANGER-5667: Backport Ozone action policy
support and follow-on fixes to ranger-2.9 (#1054)
8603cbb36 is described below
commit 8603cbb362344085db57f771d83bd3b630020291
Author: Ramachandran Krishnan <[email protected]>
AuthorDate: Fri Jul 10 10:26:30 2026 +0530
RANGER-5628/RANGER-5667: Backport Ozone action policy support and follow-on
fixes to ranger-2.9 (#1054)
* RANGER-5628: Backport support for actions in Ozone Service Definition to
ranger-2.9 (#995)
Backport of https://github.com/apache/ranger/pull/995 for the ranger-2.9
release branch.
* RANGER-5667: fixes in support for actions in Ozone policies (#1053)
---------
Co-authored-by: fmorg-git <[email protected]>
---
.../conditionevaluator/RangerActionMatcher.java | 54 ++
.../ranger/plugin/model/RangerInlinePolicy.java | 20 +-
.../RangerInlinePolicyEvaluator.java | 17 +-
.../plugin/util/RangerActionListMatcher.java | 125 ++++
.../service-defs/ranger-servicedef-ozone.json | 8 +
.../RangerActionMatcherTest.java | 111 ++++
.../plugin/util/RangerActionListMatcherTest.java | 265 ++++++++
.../test_inline_policies_ozone.json | 111 ++++
dev-support/checkstyle-suppressions.xml | 1 +
.../ozone/authorizer/RangerOzoneAuthorizer.java | 8 +-
.../authorizer/TestRangerOzoneAuthorizer.java | 72 ++
plugin-ozone/src/test/resources/om_dev_ozone.json | 6 +
pom.xml | 2 +-
...zoneServiceDefPolicyConditionUpdate_J10065.java | 167 +++++
.../src/components/CommonComponents.jsx | 26 +
.../react-webapp/src/components/Editable.jsx | 486 +++++++++-----
.../src/hooks/usePruneStaleConditions.js | 153 +++++
.../main/webapp/react-webapp/src/styles/style.css | 12 +
.../src/utils/actionRequirements/ozone.json | 47 ++
.../src/utils/actionRequirements/registry.js | 30 +
.../react-webapp/src/utils/policyConditionUtils.js | 722 +++++++++++++++++++++
.../views/PolicyListing/AddUpdatePolicyForm.jsx | 4 +
.../views/PolicyListing/PolicyConditionsComp.jsx | 42 +-
.../views/PolicyListing/PolicyPermissionItem.jsx | 89 ++-
24 files changed, 2375 insertions(+), 203 deletions(-)
diff --git
a/agents-common/src/main/java/org/apache/ranger/plugin/conditionevaluator/RangerActionMatcher.java
b/agents-common/src/main/java/org/apache/ranger/plugin/conditionevaluator/RangerActionMatcher.java
new file mode 100644
index 000000000..9342ea68b
--- /dev/null
+++
b/agents-common/src/main/java/org/apache/ranger/plugin/conditionevaluator/RangerActionMatcher.java
@@ -0,0 +1,54 @@
+/*
+ * 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.ranger.plugin.conditionevaluator;
+
+import org.apache.ranger.plugin.policyengine.RangerAccessRequest;
+import org.apache.ranger.plugin.util.RangerActionListMatcher;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+public class RangerActionMatcher extends RangerAbstractConditionEvaluator {
+ private static final Logger LOG =
LoggerFactory.getLogger(RangerActionMatcher.class);
+
+ private RangerActionListMatcher actionMatcher;
+
+ @Override
+ public void init() {
+ LOG.debug("==> RangerActionMatcher.init({})", condition);
+
+ super.init();
+
+ this.actionMatcher = new RangerActionListMatcher(condition == null ?
null : condition.getValues());
+
+ LOG.debug("<== RangerActionMatcher.init({})", condition);
+ }
+
+ @Override
+ public boolean isMatched(final RangerAccessRequest request) {
+ LOG.debug("==> RangerActionMatcher.isMatched({})", request);
+
+ final RangerActionListMatcher matcher = actionMatcher;
+ final String action = request != null ?
request.getAction() : null;
+ final boolean ret = matcher == null ||
matcher.isMatch(action);
+
+ LOG.debug("<== RangerActionMatcher.isMatched({}): {}", request, ret);
+
+ return ret;
+ }
+}
diff --git
a/agents-common/src/main/java/org/apache/ranger/plugin/model/RangerInlinePolicy.java
b/agents-common/src/main/java/org/apache/ranger/plugin/model/RangerInlinePolicy.java
index f9144d265..cd894119c 100644
---
a/agents-common/src/main/java/org/apache/ranger/plugin/model/RangerInlinePolicy.java
+++
b/agents-common/src/main/java/org/apache/ranger/plugin/model/RangerInlinePolicy.java
@@ -112,14 +112,20 @@ public static class Grant {
private Set<String> principals; // example: [ "u:user1, "g:group1",
"r:role1" ]; if empty, means public grant
private Set<String> resources; // example: [
"key:vol1/bucket1/db1/tbl1/*", "key:vol1/bucket1/db1/tbl2/*" ]; if empty, means
all resources
private Set<String> permissions; // example: [ "read", "write" ]; if
empty, means no permission
+ private Set<String> actions; // optional: [ "GetObject", "Put*",
"*" ]; if empty or null, means all actions
public Grant() {
}
public Grant(Set<String> principals, Set<String> resources,
Set<String> permissions) {
+ this(principals, resources, permissions, null);
+ }
+
+ public Grant(Set<String> principals, Set<String> resources,
Set<String> permissions, Set<String> actions) {
this.principals = principals;
this.resources = resources;
this.permissions = permissions;
+ this.actions = actions;
}
public Set<String> getPrincipals() {
@@ -146,6 +152,14 @@ public void setPermissions(Set<String> permissions) {
this.permissions = permissions;
}
+ public Set<String> getActions() {
+ return actions;
+ }
+
+ public void setActions(Set<String> actions) {
+ this.actions = actions;
+ }
+
@Override
public boolean equals(Object o) {
if (this == o) {
@@ -158,12 +172,13 @@ public boolean equals(Object o) {
return Objects.equals(principals, that.principals) &&
Objects.equals(resources, that.resources) &&
- Objects.equals(permissions, that.permissions);
+ Objects.equals(permissions, that.permissions) &&
+ Objects.equals(actions, that.actions);
}
@Override
public int hashCode() {
- return Objects.hash(principals, resources, permissions);
+ return Objects.hash(principals, resources, permissions, actions);
}
@Override
@@ -172,6 +187,7 @@ public String toString() {
"principals=" + principals +
", resources=" + resources +
", permissions=" + permissions +
+ ", actions=" + actions +
'}';
}
}
diff --git
a/agents-common/src/main/java/org/apache/ranger/plugin/policyevaluator/RangerInlinePolicyEvaluator.java
b/agents-common/src/main/java/org/apache/ranger/plugin/policyevaluator/RangerInlinePolicyEvaluator.java
index 05e372c48..d230bc17a 100644
---
a/agents-common/src/main/java/org/apache/ranger/plugin/policyevaluator/RangerInlinePolicyEvaluator.java
+++
b/agents-common/src/main/java/org/apache/ranger/plugin/policyevaluator/RangerInlinePolicyEvaluator.java
@@ -33,6 +33,7 @@
import
org.apache.ranger.plugin.policyresourcematcher.RangerDefaultPolicyResourceMatcher;
import
org.apache.ranger.plugin.policyresourcematcher.RangerPolicyResourceMatcher;
import org.apache.ranger.plugin.util.RangerAccessRequestUtil;
+import org.apache.ranger.plugin.util.RangerActionListMatcher;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -156,11 +157,13 @@ private List<GrantEvaluator>
toGrantEvaluators(RangerInlinePolicy policy) {
private class GrantEvaluator {
private final RangerInlinePolicy.Grant grant;
private final Set<String> permissions;
+ private final RangerActionListMatcher actionMatcher;
private final Set<RangerPolicyResourceMatcher> resourceMatchers = new
HashSet<>();
public GrantEvaluator(RangerInlinePolicy.Grant grant) {
- this.grant = grant;
- this.permissions =
policyEngine.getServiceDefHelper().expandImpliedAccessGrants(grant.getPermissions());
+ this.grant = grant;
+ this.permissions =
policyEngine.getServiceDefHelper().expandImpliedAccessGrants(grant.getPermissions());
+ this.actionMatcher = new
RangerActionListMatcher(grant.getActions());
if (grant.getResources() != null) {
for (String resource : grant.getResources()) {
@@ -187,7 +190,7 @@ public GrantEvaluator(RangerInlinePolicy.Grant grant) {
}
public boolean isAllowed(RangerAccessRequest request) {
- boolean ret = isPrincipalMatch(request) &&
isPermissionMatch(request) && isResourceMatch(request);
+ boolean ret = isPrincipalMatch(request) &&
isPermissionMatch(request) && isActionMatch(request) &&
isResourceMatch(request);
LOG.debug("isAllowed(grant={}, request={}): ret={}", grant,
request, ret);
@@ -238,6 +241,14 @@ private boolean isPermissionMatch(RangerAccessRequest
request) {
return ret;
}
+ private boolean isActionMatch(RangerAccessRequest request) {
+ boolean ret = actionMatcher.isMatch(request != null ?
request.getAction() : null);
+
+ LOG.debug("isActionMatch(grant={}, request={}): ret={}", grant,
request, ret);
+
+ return ret;
+ }
+
private boolean isResourceMatch(RangerAccessRequest request) {
boolean ret = CollectionUtils.isEmpty(grant.getResources()); //
match all resources
diff --git
a/agents-common/src/main/java/org/apache/ranger/plugin/util/RangerActionListMatcher.java
b/agents-common/src/main/java/org/apache/ranger/plugin/util/RangerActionListMatcher.java
new file mode 100644
index 000000000..db7807b70
--- /dev/null
+++
b/agents-common/src/main/java/org/apache/ranger/plugin/util/RangerActionListMatcher.java
@@ -0,0 +1,125 @@
+/*
+ * 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.ranger.plugin.util;
+
+import org.apache.commons.collections.CollectionUtils;
+import org.apache.commons.lang3.StringUtils;
+
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Locale;
+import java.util.Set;
+
+public class RangerActionListMatcher {
+ private final boolean allowAnyAction;
+ private final Set<String> exactActions = new HashSet<>();
+ private final String[] prefixActions;
+
+ public RangerActionListMatcher(Collection<String> actions) {
+ boolean allowAny = CollectionUtils.isEmpty(actions);
+ final List<String> tempPrefixList = new ArrayList<>();
+
+ if (!allowAny) {
+ for (String a : actions) {
+ final String action = StringUtils.trimToEmpty(a);
+
+ if (action.isEmpty()) {
+ continue;
+ }
+
+ if (action.equals("*")) {
+ allowAny = true;
+ exactActions.clear();
+ tempPrefixList.clear();
+ break;
+ }
+
+ if (action.endsWith("*")) {
+ final String prefix =
StringUtils.trimToEmpty(action.substring(0, action.length() -
1)).toLowerCase(Locale.ROOT);
+
+ if (prefix.isEmpty()) {
+ allowAny = true;
+ exactActions.clear();
+ tempPrefixList.clear();
+ break;
+ }
+
+ tempPrefixList.add(prefix);
+ } else {
+ exactActions.add(action.toLowerCase(Locale.ROOT));
+ }
+ }
+
+ if (!allowAny && exactActions.isEmpty() &&
tempPrefixList.isEmpty()) {
+ allowAny = true;
+ }
+ }
+
+ this.allowAnyAction = allowAny;
+
+ if (!tempPrefixList.isEmpty()) {
+ tempPrefixList.sort((s1, s2) -> Integer.compare(s1.length(),
s2.length()));
+
+ List<String> optimizedPrefixes = new ArrayList<>();
+ for (String p : tempPrefixList) {
+ boolean isCovered = false;
+ for (String optPrefix : optimizedPrefixes) {
+ if (p.startsWith(optPrefix)) {
+ isCovered = true;
+ break;
+ }
+ }
+ if (!isCovered) {
+ optimizedPrefixes.add(p);
+ }
+ }
+ this.prefixActions = optimizedPrefixes.toArray(new String[0]);
+ } else {
+ this.prefixActions = new String[0];
+ }
+ }
+
+ public boolean isMatch(String requestAction) {
+ if (allowAnyAction) {
+ return true;
+ }
+
+ // Action restrictions exist; missing request action is not a match.
+ if (StringUtils.isBlank(requestAction)) {
+ return false;
+ }
+
+ final String actionLower = requestAction.toLowerCase(Locale.ROOT);
+
+ if (exactActions.contains(actionLower)) {
+ return true;
+ }
+
+ for (String prefix : prefixActions) {
+ if (actionLower.startsWith(prefix)) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+}
diff --git
a/agents-common/src/main/resources/service-defs/ranger-servicedef-ozone.json
b/agents-common/src/main/resources/service-defs/ranger-servicedef-ozone.json
index 9f67ae4c6..bef99877c 100755
--- a/agents-common/src/main/resources/service-defs/ranger-servicedef-ozone.json
+++ b/agents-common/src/main/resources/service-defs/ranger-servicedef-ozone.json
@@ -117,6 +117,14 @@
"label": "IP Address Range",
"description": "IP Address Range",
"uiHint" : "{ \"isMultiValue\":true }"
+ },
+ {
+ "itemId": 2,
+ "name": "action-matches",
+ "evaluator":
"org.apache.ranger.plugin.conditionevaluator.RangerActionMatcher",
+ "label": "Action",
+ "description": "Action",
+ "uiHint": "{ \"isMultiValue\":true, \"options\": [\"*\", \"Get*\",
\"List*\", \"Put*\", \"Delete*\", \"Create*\", \"GetObject\",
\"GetObjectTagging\", \"PutObject\", \"PutObjectTagging\", \"ListBucket\",
\"CreateBucket\", \"DeleteBucket\", \"GetBucketAcl\", \"PutBucketAcl\",
\"ListBucketMultipartUploads\", \"DeleteObject\", \"ListAllMyBuckets\",
\"ListMultipartUploadParts\", \"AbortMultipartUpload\",
\"DeleteObjectTagging\"], \"actionRequirementsFile\": \"ozone\" }"
}
]
}
diff --git
a/agents-common/src/test/java/org/apache/ranger/plugin/conditionevaluator/RangerActionMatcherTest.java
b/agents-common/src/test/java/org/apache/ranger/plugin/conditionevaluator/RangerActionMatcherTest.java
new file mode 100644
index 000000000..5e23c0f55
--- /dev/null
+++
b/agents-common/src/test/java/org/apache/ranger/plugin/conditionevaluator/RangerActionMatcherTest.java
@@ -0,0 +1,111 @@
+/*
+ * 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.ranger.plugin.conditionevaluator;
+
+import org.apache.ranger.plugin.model.RangerPolicy.RangerPolicyItemCondition;
+import org.apache.ranger.plugin.policyengine.RangerAccessRequest;
+import org.junit.jupiter.api.Test;
+
+import java.util.Arrays;
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+class RangerActionMatcherTest {
+ @Test
+ void testNullOrEmptyConditionMatchesAll() {
+ final RangerActionMatcher matcherNull = createMatcher(null);
+
+ assertTrue(matcherNull.isMatched(createRequest("PutObject")));
+ assertTrue(matcherNull.isMatched(createRequest(null)));
+
+ final RangerActionMatcher matcherEmpty = createMatcher(new String[]
{});
+
+ assertTrue(matcherEmpty.isMatched(createRequest("PutObject")));
+ assertTrue(matcherEmpty.isMatched(createRequest(null)));
+ }
+
+ @Test
+ void testWildcardMatchesAll() {
+ final RangerActionMatcher matcher = createMatcher(new String[] {"*"});
+
+ assertTrue(matcher.isMatched(createRequest("GetObject")));
+ assertTrue(matcher.isMatched(createRequest("PutObject")));
+ assertTrue(matcher.isMatched(createRequest(null)));
+ }
+
+ @Test
+ void testNoRequestActionDoesNotMatchWhenActionsSpecified() {
+ final RangerActionMatcher matcher = createMatcher(new String[]
{"PutObject"});
+
+ assertFalse(matcher.isMatched(createRequest(null)));
+ assertFalse(matcher.isMatched(createRequest("")));
+ assertFalse(matcher.isMatched(createRequest(" ")));
+ }
+
+ @Test
+ void testExactMatchCaseInsensitive() {
+ final RangerActionMatcher matcher = createMatcher(new String[]
{"GetObject"});
+
+ assertTrue(matcher.isMatched(createRequest("GetObject")));
+ assertTrue(matcher.isMatched(createRequest("getobject")));
+ assertFalse(matcher.isMatched(createRequest("PutObject")));
+ }
+
+ @Test
+ void testTrailingWildcardPrefixMatchCaseInsensitive() {
+ final RangerActionMatcher matcher = createMatcher(new String[]
{"Put*"});
+
+ assertTrue(matcher.isMatched(createRequest("PutObject")));
+ assertTrue(matcher.isMatched(createRequest("putobjecttagging")));
+ assertFalse(matcher.isMatched(createRequest("GetObject")));
+ }
+
+ private RangerActionMatcher createMatcher(final String[] actionsArray) {
+ final RangerActionMatcher matcher = new RangerActionMatcher();
+
+ if (actionsArray == null) {
+ matcher.setConditionDef(null);
+ matcher.setPolicyItemCondition(null);
+ } else {
+ final RangerPolicyItemCondition condition =
mock(RangerPolicyItemCondition.class);
+ final List<String> actions =
Arrays.asList(actionsArray);
+
+ when(condition.getValues()).thenReturn(actions);
+ matcher.setConditionDef(null);
+ matcher.setPolicyItemCondition(condition);
+ }
+
+ matcher.init();
+
+ return matcher;
+ }
+
+ private RangerAccessRequest createRequest(final String action) {
+ final RangerAccessRequest request = mock(RangerAccessRequest.class);
+
+ when(request.getAction()).thenReturn(action);
+
+ return request;
+ }
+}
diff --git
a/agents-common/src/test/java/org/apache/ranger/plugin/util/RangerActionListMatcherTest.java
b/agents-common/src/test/java/org/apache/ranger/plugin/util/RangerActionListMatcherTest.java
new file mode 100644
index 000000000..b6bdfebf9
--- /dev/null
+++
b/agents-common/src/test/java/org/apache/ranger/plugin/util/RangerActionListMatcherTest.java
@@ -0,0 +1,265 @@
+/*
+ * 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.ranger.plugin.util;
+
+import org.junit.jupiter.api.Test;
+
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+class RangerActionListMatcherTest {
+ @Test
+ void testNullActionsMatchAll() {
+ final RangerActionListMatcher matcher = new
RangerActionListMatcher(null);
+
+ assertTrue(matcher.isMatch("PutObject"));
+ assertTrue(matcher.isMatch("GetObject"));
+ assertTrue(matcher.isMatch(null));
+ assertTrue(matcher.isMatch(""));
+ }
+
+ @Test
+ void testEmptyActionsMatchAll() {
+ final RangerActionListMatcher matcher = new
RangerActionListMatcher(Collections.emptyList());
+
+ assertTrue(matcher.isMatch("PutObject"));
+ assertTrue(matcher.isMatch("GetObject"));
+ assertTrue(matcher.isMatch(null));
+ }
+
+ @Test
+ void testUniversalWildcard() {
+ final RangerActionListMatcher matcher = new
RangerActionListMatcher(Arrays.asList("*"));
+
+ assertTrue(matcher.isMatch("PutObject"));
+ assertTrue(matcher.isMatch("GetObject"));
+ assertTrue(matcher.isMatch("DeleteBucket"));
+ assertTrue(matcher.isMatch(null));
+ }
+
+ @Test
+ void testBareWildcardWithSpaces() {
+ // " * " after trim becomes "*" which should be treated as allow-all
+ final RangerActionListMatcher matcher = new
RangerActionListMatcher(Arrays.asList(" * "));
+
+ assertTrue(matcher.isMatch("PutObject"));
+ assertTrue(matcher.isMatch("GetObject"));
+ }
+
+ @Test
+ void testExactMatchCaseInsensitive() {
+ final RangerActionListMatcher matcher = new
RangerActionListMatcher(Arrays.asList("GetObject"));
+
+ assertTrue(matcher.isMatch("GetObject"));
+ assertTrue(matcher.isMatch("getobject"));
+ assertTrue(matcher.isMatch("GETOBJECT"));
+ assertFalse(matcher.isMatch("PutObject"));
+ assertFalse(matcher.isMatch("GetObjectTagging"));
+ }
+
+ @Test
+ void testMultipleExactActions() {
+ final RangerActionListMatcher matcher = new RangerActionListMatcher(
+ Arrays.asList("GetObject", "PutObject", "DeleteObject"));
+
+ assertTrue(matcher.isMatch("GetObject"));
+ assertTrue(matcher.isMatch("PutObject"));
+ assertTrue(matcher.isMatch("DeleteObject"));
+ assertFalse(matcher.isMatch("ListBucket"));
+ assertFalse(matcher.isMatch("CreateBucket"));
+ }
+
+ @Test
+ void testSinglePrefixWildcard() {
+ final RangerActionListMatcher matcher = new
RangerActionListMatcher(Arrays.asList("Put*"));
+
+ assertTrue(matcher.isMatch("PutObject"));
+ assertTrue(matcher.isMatch("PutObjectTagging"));
+ assertTrue(matcher.isMatch("PutBucketAcl"));
+ assertFalse(matcher.isMatch("GetObject"));
+ assertFalse(matcher.isMatch("DeleteObject"));
+ }
+
+ @Test
+ void testMultiplePrefixWildcards() {
+ final RangerActionListMatcher matcher = new RangerActionListMatcher(
+ Arrays.asList("Put*", "Get*"));
+
+ assertTrue(matcher.isMatch("PutObject"));
+ assertTrue(matcher.isMatch("GetObject"));
+ assertTrue(matcher.isMatch("GetObjectTagging"));
+ assertTrue(matcher.isMatch("PutBucketAcl"));
+ assertFalse(matcher.isMatch("DeleteObject"));
+ assertFalse(matcher.isMatch("ListBucket"));
+ }
+
+ @Test
+ void testMixedExactAndPrefix() {
+ final RangerActionListMatcher matcher = new RangerActionListMatcher(
+ Arrays.asList("GetObject", "Put*", "ListBucket"));
+
+ assertTrue(matcher.isMatch("GetObject"));
+ assertTrue(matcher.isMatch("PutObject"));
+ assertTrue(matcher.isMatch("PutObjectTagging"));
+ assertTrue(matcher.isMatch("ListBucket"));
+ assertFalse(matcher.isMatch("GetObjectTagging"));
+ assertFalse(matcher.isMatch("DeleteObject"));
+ assertFalse(matcher.isMatch("ListAllMyBuckets"));
+ }
+
+ @Test
+ void testPrefixOptimizationRedundantPrefixes() {
+ // The constructor optimizes prefix patterns by removing any longer
prefix that is
+ // already covered by a shorter one. Here "Put*" matches anything
starting with "put"
+ // (lowercased), which is a superset of what "PutObject*" matches
(only things starting
+ // with "putobject"). So "PutObject*" is redundant and gets removed
from the prefix
+ // array to avoid unnecessary iteration at match time. This test
proves the optimization
+ // doesn't break matching — "PutBucketAcl" still matches via the
surviving "Put*" prefix.
+ final RangerActionListMatcher matcher = new RangerActionListMatcher(
+ Arrays.asList("PutObject*", "Put*"));
+
+ assertTrue(matcher.isMatch("PutObject"));
+ assertTrue(matcher.isMatch("PutObjectTagging"));
+ assertTrue(matcher.isMatch("PutBucketAcl"));
+ }
+
+ @Test
+ void testPrefixOptimizationNonRedundant() {
+ // "Put*" and "Get*" are independent — neither covers the other
+ final RangerActionListMatcher matcher = new RangerActionListMatcher(
+ Arrays.asList("Put*", "Get*"));
+
+ assertTrue(matcher.isMatch("PutObject"));
+ assertTrue(matcher.isMatch("GetObject"));
+ assertFalse(matcher.isMatch("DeleteObject"));
+ }
+
+ @Test
+ void testDuplicatePrefixes() {
+ // Duplicate "Put*" entries should be handled gracefully
+ final RangerActionListMatcher matcher = new RangerActionListMatcher(
+ Arrays.asList("Put*", "Put*", "Put*"));
+
+ assertTrue(matcher.isMatch("PutObject"));
+ assertFalse(matcher.isMatch("GetObject"));
+ }
+
+ @Test
+ void testBlankRequestActionDoesNotMatchWhenActionsSpecified() {
+ final RangerActionListMatcher matcher = new RangerActionListMatcher(
+ Arrays.asList("PutObject"));
+
+ // When action restrictions exist, missing action is not a match
+ assertFalse(matcher.isMatch(null));
+ assertFalse(matcher.isMatch(""));
+ assertFalse(matcher.isMatch(" "));
+ }
+
+ @Test
+ void testBlankRequestActionStillMatchesWhenNoActionsSpecified() {
+ final RangerActionListMatcher matcherNull = new
RangerActionListMatcher(null);
+ assertTrue(matcherNull.isMatch(null));
+ assertTrue(matcherNull.isMatch(""));
+
+ final RangerActionListMatcher matcherEmpty = new
RangerActionListMatcher(Collections.emptyList());
+ assertTrue(matcherEmpty.isMatch(null));
+ assertTrue(matcherEmpty.isMatch(" "));
+ }
+
+ @Test
+ void testBlankEntriesInActionsIgnored() {
+ final RangerActionListMatcher matcher = new RangerActionListMatcher(
+ Arrays.asList("", " ", "PutObject"));
+
+ assertTrue(matcher.isMatch("PutObject"));
+ assertFalse(matcher.isMatch("GetObject"));
+ }
+
+ @Test
+ void testAllBlankActionsMatchAll() {
+ final RangerActionListMatcher matcher = new RangerActionListMatcher(
+ Arrays.asList("", " ", " "));
+
+ assertTrue(matcher.isMatch("PutObject"));
+ assertTrue(matcher.isMatch("GetObject"));
+ assertTrue(matcher.isMatch(null));
+ assertTrue(matcher.isMatch(" "));
+ }
+
+ @Test
+ void testPrefixWildcardCaseInsensitive() {
+ final RangerActionListMatcher matcher = new
RangerActionListMatcher(Arrays.asList("Put*"));
+
+ assertTrue(matcher.isMatch("PUTOBJECT"));
+ assertTrue(matcher.isMatch("putobject"));
+ assertTrue(matcher.isMatch("PuToBjEcT"));
+ }
+
+ @Test
+ void testWildcardAmongOtherActions() {
+ // If "*" appears alongside other actions, it should still match all
+ final RangerActionListMatcher matcher = new RangerActionListMatcher(
+ Arrays.asList("PutObject", "*", "GetObject"));
+
+ assertTrue(matcher.isMatch("DeleteBucket"));
+ assertTrue(matcher.isMatch("ListAllMyBuckets"));
+ assertTrue(matcher.isMatch("PutObject"));
+ }
+
+ @Test
+ void testSingleCharacterPrefix() {
+ // "G*" should match anything starting with G
+ final RangerActionListMatcher matcher = new
RangerActionListMatcher(Arrays.asList("G*"));
+
+ assertTrue(matcher.isMatch("GetObject"));
+ assertTrue(matcher.isMatch("GetBucketAcl"));
+ assertFalse(matcher.isMatch("PutObject"));
+ }
+
+ @Test
+ void testAllOzoneS3Actions() {
+ // Grant with both wildcards and exact actions matching Ozone S3
patterns
+ final List<String> grantActions = Arrays.asList("Get*", "List*",
"PutObject");
+ final RangerActionListMatcher matcher = new
RangerActionListMatcher(grantActions);
+
+ // Should match
+ assertTrue(matcher.isMatch("GetObject"));
+ assertTrue(matcher.isMatch("GetObjectTagging"));
+ assertTrue(matcher.isMatch("GetBucketAcl"));
+ assertTrue(matcher.isMatch("ListBucket"));
+ assertTrue(matcher.isMatch("ListAllMyBuckets"));
+ assertTrue(matcher.isMatch("ListBucketMultipartUploads"));
+ assertTrue(matcher.isMatch("ListMultipartUploadParts"));
+ assertTrue(matcher.isMatch("PutObject"));
+
+ // Should not match
+ assertFalse(matcher.isMatch("PutObjectTagging"));
+ assertFalse(matcher.isMatch("PutBucketAcl"));
+ assertFalse(matcher.isMatch("DeleteObject"));
+ assertFalse(matcher.isMatch("DeleteObjectTagging"));
+ assertFalse(matcher.isMatch("DeleteBucket"));
+ assertFalse(matcher.isMatch("CreateBucket"));
+ assertFalse(matcher.isMatch("AbortMultipartUpload"));
+ }
+}
diff --git
a/agents-common/src/test/resources/policyevaluator/test_inline_policies_ozone.json
b/agents-common/src/test/resources/policyevaluator/test_inline_policies_ozone.json
index 809c791a3..845ae8c36 100644
---
a/agents-common/src/test/resources/policyevaluator/test_inline_policies_ozone.json
+++
b/agents-common/src/test/resources/policyevaluator/test_inline_policies_ozone.json
@@ -227,6 +227,117 @@
}
},
"result": { "isAudited": true, "isAllowed": true, "policyId": -1 }
+ },
+ { "name": "ALLOW 'write s3v/iceberg/key2;' for user=svc-etl; inline-policy
restricts action=PutObject",
+ "request": {
+ "resource": { "elements": { "volume": "s3v", "bucket": "iceberg",
"key": "key2" } }, "user": "svc-etl", "accessType": "write", "action":
"PutObject",
+ "inlinePolicy": { "grantor": "r:data-all-access", "mode": "INLINE",
+ "grants": [
+ { "principals": [ "u:svc-etl" ], "resources": [
"key:s3v/iceberg/key2" ], "permissions": [ "all" ], "actions": [ "PutObject" ] }
+ ]
+ }
+ },
+ "result": { "isAudited": true, "isAllowed": true, "policyId": -1 }
+ },
+ { "name": "ALLOW 'create s3v/iceberg/key2;' for user=svc-etl;
inline-policy restricts action=PutObject",
+ "request": {
+ "resource": { "elements": { "volume": "s3v", "bucket": "iceberg",
"key": "key2" } }, "user": "svc-etl", "accessType": "create", "action":
"PutObject",
+ "inlinePolicy": { "grantor": "r:data-all-access", "mode": "INLINE",
+ "grants": [
+ { "principals": [ "u:svc-etl" ], "resources": [
"key:s3v/iceberg/key2" ], "permissions": [ "all" ], "actions": [ "PutObject" ] }
+ ]
+ }
+ },
+ "result": { "isAudited": true, "isAllowed": true, "policyId": -1 }
+ },
+ { "name": "DENY 'read s3v/iceberg/key2;' for user=svc-etl; inline-policy
restricts action=PutObject but request action=GetObject",
+ "request": {
+ "resource": { "elements": { "volume": "s3v", "bucket": "iceberg",
"key": "key2" } }, "user": "svc-etl", "accessType": "read", "action":
"GetObject",
+ "inlinePolicy": { "grantor": "r:data-all-access", "mode": "INLINE",
+ "grants": [
+ { "principals": [ "u:svc-etl" ], "resources": [
"key:s3v/iceberg/key2" ], "permissions": [ "all" ], "actions": [ "PutObject" ] }
+ ]
+ }
+ },
+ "result": { "isAudited": true, "isAllowed": false, "policyId": -1 }
+ },
+
+ { "name": "ALLOW 'write s3v/iceberg/key2;' for user=svc-etl;
action=PutObjectTagging matches wildcard 'Put*'",
+ "request": {
+ "resource": { "elements": { "volume": "s3v", "bucket": "iceberg",
"key": "key2" } }, "user": "svc-etl", "accessType": "write", "action":
"PutObjectTagging",
+ "inlinePolicy": { "grantor": "r:data-all-access", "mode": "INLINE",
+ "grants": [
+ { "principals": [ "u:svc-etl" ], "resources": [
"key:s3v/iceberg/key2" ], "permissions": [ "all" ], "actions": [ "Put*" ] }
+ ]
+ }
+ },
+ "result": { "isAudited": true, "isAllowed": true, "policyId": -1 }
+ },
+ { "name": "DENY 'read s3v/iceberg/key2;' for user=svc-etl;
action=GetObject does NOT match wildcard 'Put*'",
+ "request": {
+ "resource": { "elements": { "volume": "s3v", "bucket": "iceberg",
"key": "key2" } }, "user": "svc-etl", "accessType": "read", "action":
"GetObject",
+ "inlinePolicy": { "grantor": "r:data-all-access", "mode": "INLINE",
+ "grants": [
+ { "principals": [ "u:svc-etl" ], "resources": [
"key:s3v/iceberg/key2" ], "permissions": [ "all" ], "actions": [ "Put*" ] }
+ ]
+ }
+ },
+ "result": { "isAudited": true, "isAllowed": false, "policyId": -1 }
+ },
+ { "name": "ALLOW 'delete s3v/iceberg/key2;' for user=svc-etl;
action=DeleteObject matches universal wildcard '*'",
+ "request": {
+ "resource": { "elements": { "volume": "s3v", "bucket": "iceberg",
"key": "key2" } }, "user": "svc-etl", "accessType": "delete", "action":
"DeleteObject",
+ "inlinePolicy": { "grantor": "r:data-all-access", "mode": "INLINE",
+ "grants": [
+ { "principals": [ "u:svc-etl" ], "resources": [
"key:s3v/iceberg/key2" ], "permissions": [ "all" ], "actions": [ "*" ] }
+ ]
+ }
+ },
+ "result": { "isAudited": true, "isAllowed": true, "policyId": -1 }
+ },
+ { "name": "DENY 'write s3v/iceberg/key2;' for user=svc-etl; no action in
request does not satisfy action restriction",
+ "request": {
+ "resource": { "elements": { "volume": "s3v", "bucket": "iceberg",
"key": "key2" } }, "user": "svc-etl", "accessType": "write",
+ "inlinePolicy": { "grantor": "r:data-all-access", "mode": "INLINE",
+ "grants": [
+ { "principals": [ "u:svc-etl" ], "resources": [
"key:s3v/iceberg/key2" ], "permissions": [ "all" ], "actions": [ "PutObject" ] }
+ ]
+ }
+ },
+ "result": { "isAudited": true, "isAllowed": false, "policyId": -1 }
+ },
+ { "name": "ALLOW 'read s3v/iceberg/key2;' for user=svc-etl;
action=GetObject matches second entry in multi-action grant",
+ "request": {
+ "resource": { "elements": { "volume": "s3v", "bucket": "iceberg",
"key": "key2" } }, "user": "svc-etl", "accessType": "read", "action":
"GetObject",
+ "inlinePolicy": { "grantor": "r:data-all-access", "mode": "INLINE",
+ "grants": [
+ { "principals": [ "u:svc-etl" ], "resources": [
"key:s3v/iceberg/key2" ], "permissions": [ "all" ], "actions": [ "PutObject",
"GetObject" ] }
+ ]
+ }
+ },
+ "result": { "isAudited": true, "isAllowed": true, "policyId": -1 }
+ },
+ { "name": "DENY 'read s3v/iceberg/key2;' for user=svc-etl; permission
matches but action=ListBucket not in grant actions [Put*, Get*]",
+ "request": {
+ "resource": { "elements": { "volume": "s3v", "bucket": "iceberg",
"key": "key2" } }, "user": "svc-etl", "accessType": "read", "action":
"ListBucket",
+ "inlinePolicy": { "grantor": "r:data-all-access", "mode": "INLINE",
+ "grants": [
+ { "principals": [ "u:svc-etl" ], "resources": [
"key:s3v/iceberg/key2" ], "permissions": [ "all" ], "actions": [ "Put*", "Get*"
] }
+ ]
+ }
+ },
+ "result": { "isAudited": true, "isAllowed": false, "policyId": -1 }
+ },
+ { "name": "ALLOW 'write s3v/iceberg/key2;' for user=svc-etl; no actions
field in grant means all actions allowed",
+ "request": {
+ "resource": { "elements": { "volume": "s3v", "bucket": "iceberg",
"key": "key2" } }, "user": "svc-etl", "accessType": "write", "action":
"PutObject",
+ "inlinePolicy": { "grantor": "r:data-all-access", "mode": "INLINE",
+ "grants": [
+ { "principals": [ "u:svc-etl" ], "resources": [
"key:s3v/iceberg/key2" ], "permissions": [ "all" ] }
+ ]
+ }
+ },
+ "result": { "isAudited": true, "isAllowed": true, "policyId": -1 }
}
]
}
diff --git a/dev-support/checkstyle-suppressions.xml
b/dev-support/checkstyle-suppressions.xml
index 8fa908165..59d1a77af 100644
--- a/dev-support/checkstyle-suppressions.xml
+++ b/dev-support/checkstyle-suppressions.xml
@@ -22,4 +22,5 @@
<suppressions>
<suppress files="[\\/]generated-sources[\\/]" checks="[a-zA-Z0-9]*"/>
<suppress files="[\\/]surefire-reports[\\/]" checks="[a-zA-Z0-9]*"/>
+ <suppress files="PatchForOzoneServiceDefPolicyConditionUpdate_J10065"
checks="TypeName"/>
</suppressions>
diff --git
a/plugin-ozone/src/main/java/org/apache/ranger/authorization/ozone/authorizer/RangerOzoneAuthorizer.java
b/plugin-ozone/src/main/java/org/apache/ranger/authorization/ozone/authorizer/RangerOzoneAuthorizer.java
index 03ac8d67a..44159ce92 100644
---
a/plugin-ozone/src/main/java/org/apache/ranger/authorization/ozone/authorizer/RangerOzoneAuthorizer.java
+++
b/plugin-ozone/src/main/java/org/apache/ranger/authorization/ozone/authorizer/RangerOzoneAuthorizer.java
@@ -150,7 +150,6 @@ public boolean checkAccess(IOzoneObj ozoneObject,
RequestContext context) {
LOG.error("Unsupported access type. operation=" +
operation + ", resource=" + resource);
return returnValue;
}
- String action = accessType;
String clusterName = plugin.getClusterName();
RangerAccessRequestImpl rangerRequest = new
RangerAccessRequestImpl();
@@ -165,7 +164,7 @@ public boolean checkAccess(IOzoneObj ozoneObject,
RequestContext context) {
rangerRequest.setResource(rangerResource);
rangerRequest.setAccessType(accessType);
- rangerRequest.setAction(action);
+ rangerRequest.setAction(context.getS3Action());
rangerRequest.setRequestData(resource);
rangerRequest.setClusterName(clusterName);
@@ -303,6 +302,11 @@ private static RangerInlinePolicy.Grant
toRangerGrant(OzoneGrant ozoneGrant, Ran
ret.setPermissions(ozoneGrant.getPermissions().stream().map(RangerOzoneAuthorizer::toRangerPermission).filter(Objects::nonNull).collect(Collectors.toSet()));
}
+ final Set<String> s3Actions = ozoneGrant.getS3Actions();
+ if (s3Actions != null && !s3Actions.isEmpty()) {
+ ret.setActions(s3Actions);
+ }
+
LOG.debug("toRangerGrant(ozoneGrant={}): ret={}", ozoneGrant, ret);
return ret;
diff --git
a/plugin-ozone/src/test/java/org/apache/ranger/authorization/ozone/authorizer/TestRangerOzoneAuthorizer.java
b/plugin-ozone/src/test/java/org/apache/ranger/authorization/ozone/authorizer/TestRangerOzoneAuthorizer.java
index 1f365dd61..a715e9c6f 100644
---
a/plugin-ozone/src/test/java/org/apache/ranger/authorization/ozone/authorizer/TestRangerOzoneAuthorizer.java
+++
b/plugin-ozone/src/test/java/org/apache/ranger/authorization/ozone/authorizer/TestRangerOzoneAuthorizer.java
@@ -68,6 +68,7 @@ public class TestRangerOzoneAuthorizer {
private final OzoneObj vol2 = new
OzoneObjInfo.Builder().setResType(OzoneObj.ResourceType.VOLUME).setStoreType(OzoneObj.StoreType.OZONE).setVolumeName("vol2").build();
private final OzoneGrant grantList = new OzoneGrant(new
HashSet<>(Arrays.asList(vol1, buck1)),
Collections.singleton(IAccessAuthorizer.ACLType.LIST));
private final OzoneGrant grantRead = new
OzoneGrant(Collections.singleton(key1),
Collections.singleton(IAccessAuthorizer.ACLType.READ));
+ private final OzoneGrant grantWriteWithS3Actions = new
OzoneGrant(Collections.singleton(key1),
Collections.singleton(IAccessAuthorizer.ACLType.WRITE), new
HashSet<>(Arrays.asList("AbortMultipartUpload", "PutObjectTagging")));
private final RequestContext.Builder reqCtxBuilder =
RequestContext.newBuilder()
.setHost(hostname)
@@ -201,4 +202,75 @@ public void testAssumeRoleWithGrants() throws Exception {
assertTrue(ozoneAuthorizer.checkAccess(buck1,
ctxListWithSessionPolicy), "session-policy should allow list on bucket
vol1/buck1");
assertTrue(ozoneAuthorizer.checkAccess(key1,
ctxReadWithSessionPolicy), "session-policy should allow read on key
vol1/buck1/key1");
}
+
+ @Test
+ public void testAssumeRoleWithS3ActionsInGrant() throws Exception {
+ // Verify that S3 actions from OzoneGrant are propagated to
RangerInlinePolicy.Grant
+ // Note - these tests use policy 103 in om_dev_ozone.json
+ Set<OzoneGrant> grants =
Collections.singleton(grantWriteWithS3Actions);
+ AssumeRoleRequest request = new AssumeRoleRequest(hostname, ipAddress,
user1, role1, grants);
+
+ String sessionPolicy =
ozoneAuthorizer.generateAssumeRoleSessionPolicy(request);
+
+ assertNotNull(sessionPolicy);
+
+ RangerInlinePolicy inlinePolicy = JsonUtilsV2.jsonToObj(sessionPolicy,
RangerInlinePolicy.class);
+
+ assertNotNull(inlinePolicy.getGrants());
+ assertEquals(1, inlinePolicy.getGrants().size());
+
+ RangerInlinePolicy.Grant grant =
inlinePolicy.getGrants().iterator().next();
+
+ // Verify S3 actions are set on the grant
+ assertNotNull(grant.getActions(), "S3 actions should be propagated to
the inline policy grant");
+ assertEquals(new HashSet<>(Arrays.asList("AbortMultipartUpload",
"PutObjectTagging")), grant.getActions());
+ assertEquals(Collections.singleton("write"), grant.getPermissions());
+ assertTrue(grant.getResources().contains("key:vol1/buck1/key1"));
+ }
+
+ @Test
+ public void testCheckAccessWithS3Action() throws Exception {
+ // Verify that checkAccess passes the S3 action from RequestContext to
the Ranger request,
+ // allowing action-based inline policy evaluation
+ // Note - these tests use policy 103 in om_dev_ozone.json
+ Set<OzoneGrant> grants =
Collections.singleton(grantWriteWithS3Actions);
+ AssumeRoleRequest request = new AssumeRoleRequest(hostname, ipAddress,
user1, role1, grants);
+
+ String sessionPolicy =
ozoneAuthorizer.generateAssumeRoleSessionPolicy(request);
+
+ assertNotNull(sessionPolicy);
+
+ // AbortMultipartUpload should be allowed - it matches the grant's S3
actions
+ RequestContext ctxWriteAbortMultipartUpload = reqCtxBuilder
+ .setAclRights(IAccessAuthorizer.ACLType.WRITE)
+ .setRecursiveAccessCheck(false)
+ .setSessionPolicy(sessionPolicy)
+ .setS3Action("AbortMultipartUpload")
+ .build();
+
+ assertTrue(ozoneAuthorizer.checkAccess(key1,
ctxWriteAbortMultipartUpload),
+ "session-policy should allow write on key1 when S3 action is
AbortMultipartUpload");
+
+ // GetObject should be denied - it doesn't match the grant's S3
actions [AbortMultipartUpload, PutObjectTagging]
+ RequestContext ctxWriteGetObject = reqCtxBuilder
+ .setAclRights(IAccessAuthorizer.ACLType.WRITE)
+ .setRecursiveAccessCheck(false)
+ .setSessionPolicy(sessionPolicy)
+ .setS3Action("GetObject")
+ .build();
+
+ assertFalse(ozoneAuthorizer.checkAccess(key1, ctxWriteGetObject),
+ "session-policy should deny write on key1 when S3 action is
GetObject (not in grant)");
+
+ // No S3 action specified should be denied when the grant has
S3-action restrictions
+ RequestContext ctxWriteNoAction = reqCtxBuilder
+ .setAclRights(IAccessAuthorizer.ACLType.WRITE)
+ .setRecursiveAccessCheck(false)
+ .setSessionPolicy(sessionPolicy)
+ .setS3Action(null)
+ .build();
+
+ assertFalse(ozoneAuthorizer.checkAccess(key1, ctxWriteNoAction),
+ "session-policy should deny write on key1 when no S3 action is
specified and grant has S3 actions");
+ }
}
diff --git a/plugin-ozone/src/test/resources/om_dev_ozone.json
b/plugin-ozone/src/test/resources/om_dev_ozone.json
index e8d761801..d131df82c 100644
--- a/plugin-ozone/src/test/resources/om_dev_ozone.json
+++ b/plugin-ozone/src/test/resources/om_dev_ozone.json
@@ -41,6 +41,12 @@
"policyItems":[
{ "accesses": [ { "type": "read" } ], "roles": [ "role1" ] }
]
+ },
+ { "id": 103, "name": "key: vol1/buck1/key1 - write", "isEnabled": true,
"isAuditEnabled": true,
+ "resources": { "volume": { "values": [ "vol1" ] }, "bucket": { "values":
[ "buck1" ] }, "key": { "values": [ "key1" ] } },
+ "policyItems":[
+ { "accesses": [ { "type": "write" } ], "roles": [ "role1" ] }
+ ]
}
]
}
\ No newline at end of file
diff --git a/pom.xml b/pom.xml
index d380e460a..bc91e77bb 100755
--- a/pom.xml
+++ b/pom.xml
@@ -175,7 +175,7 @@
<org.bouncycastle.bcpkix-jdk18on>1.84</org.bouncycastle.bcpkix-jdk18on>
<org.bouncycastle.bcprov-jdk18on>1.84</org.bouncycastle.bcprov-jdk18on>
<owasp-java-html-sanitizer.version>20211018.2</owasp-java-html-sanitizer.version>
- <ozone.version>2.1.0</ozone.version>
+ <ozone.version>2.1.1</ozone.version>
<paranamer.version>2.3</paranamer.version>
<poi.version>5.2.2</poi.version>
<!-- presto plugin deps -->
diff --git
a/security-admin/src/main/java/org/apache/ranger/patch/PatchForOzoneServiceDefPolicyConditionUpdate_J10065.java
b/security-admin/src/main/java/org/apache/ranger/patch/PatchForOzoneServiceDefPolicyConditionUpdate_J10065.java
new file mode 100644
index 000000000..62e9846a0
--- /dev/null
+++
b/security-admin/src/main/java/org/apache/ranger/patch/PatchForOzoneServiceDefPolicyConditionUpdate_J10065.java
@@ -0,0 +1,167 @@
+/*
+ * 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.ranger.patch;
+
+import org.apache.commons.lang3.StringUtils;
+import org.apache.ranger.biz.ServiceDBStore;
+import org.apache.ranger.common.JSONUtil;
+import org.apache.ranger.common.RangerValidatorFactory;
+import org.apache.ranger.db.RangerDaoManager;
+import org.apache.ranger.entity.XXServiceDef;
+import org.apache.ranger.plugin.model.RangerServiceDef;
+import org.apache.ranger.plugin.model.validation.RangerServiceDefValidator;
+import org.apache.ranger.plugin.model.validation.RangerValidator.Action;
+import org.apache.ranger.plugin.store.EmbeddedServiceDefsUtil;
+import org.apache.ranger.util.CLIUtil;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Component;
+
+import java.util.List;
+import java.util.Map;
+
+@Component
+public class PatchForOzoneServiceDefPolicyConditionUpdate_J10065 extends
BaseLoader {
+ private static final Logger logger =
LoggerFactory.getLogger(PatchForOzoneServiceDefPolicyConditionUpdate_J10065.class);
+
+ @Autowired
+ RangerDaoManager daoMgr;
+
+ @Autowired
+ ServiceDBStore svcDBStore;
+
+ @Autowired
+ JSONUtil jsonUtil;
+
+ @Autowired
+ RangerValidatorFactory validatorFactory;
+
+ @Autowired
+ ServiceDBStore svcStore;
+
+ public static void main(String[] args) {
+ logger.info("main()");
+ try {
+ PatchForOzoneServiceDefPolicyConditionUpdate_J10065 loader =
(PatchForOzoneServiceDefPolicyConditionUpdate_J10065)
CLIUtil.getBean(PatchForOzoneServiceDefPolicyConditionUpdate_J10065.class);
+ loader.init();
+ while (loader.isMoreToProcess()) {
+ loader.load();
+ }
+ logger.info("Load complete. Exiting!!!");
+ System.exit(0);
+ } catch (Exception e) {
+ logger.error("Error loading", e);
+ System.exit(1);
+ }
+ }
+
+ @Override
+ public void init() throws Exception {
+ // Do Nothing
+ }
+
+ @Override
+ public void printStats() {
+ logger.info("PatchForOzoneServiceDefPolicyConditionUpdate_J10065");
+ }
+
+ @Override
+ public void execLoad() {
+ logger.info("==>
PatchForOzoneServiceDefPolicyConditionUpdate_J10065.execLoad()");
+ try {
+ updateOzoneServiceDef();
+ } catch (Exception e) {
+ logger.error("Error while applying
PatchForOzoneServiceDefPolicyConditionUpdate_J10065", e);
+ throw new
RuntimeException("PatchForOzoneServiceDefPolicyConditionUpdate_J10065 failed",
e);
+ }
+ logger.info("<==
PatchForOzoneServiceDefPolicyConditionUpdate_J10065.execLoad()");
+ }
+
+ private void updateOzoneServiceDef() {
+ try {
+ final String ozoneServiceDefName =
EmbeddedServiceDefsUtil.EMBEDDED_SERVICEDEF_OZONE_NAME;
+ final RangerServiceDef embeddedOzoneServiceDef =
EmbeddedServiceDefsUtil.instance().getEmbeddedServiceDef(ozoneServiceDefName);
+
+ if (embeddedOzoneServiceDef == null) {
+ logger.error("Embedded service-def for {} not found",
ozoneServiceDefName);
+ return;
+ }
+
+ final List<RangerServiceDef.RangerPolicyConditionDef>
embeddedPolicyConditions = embeddedOzoneServiceDef.getPolicyConditions();
+
+ if (embeddedPolicyConditions == null) {
+ logger.error("Policy conditions are empty in embedded {}
service-def", ozoneServiceDefName);
+ return;
+ }
+
+ XXServiceDef xXServiceDefObj =
daoMgr.getXXServiceDef().findByName(ozoneServiceDefName);
+
+ if (xXServiceDefObj == null) {
+ logger.error("Service def for {} is not found in DB",
ozoneServiceDefName);
+ return;
+ }
+
+ Map<String, String> serviceDefOptionsPreUpdate = null;
+ final String jsonStrPreUpdate =
xXServiceDefObj.getDefOptions();
+
+ if (StringUtils.isNotEmpty(jsonStrPreUpdate)) {
+ serviceDefOptionsPreUpdate =
jsonUtil.jsonToMap(jsonStrPreUpdate);
+ }
+
+ final RangerServiceDef dbOzoneServiceDef =
svcDBStore.getServiceDefByName(ozoneServiceDefName);
+
+ if (dbOzoneServiceDef == null) {
+ logger.error("Service def for {} is not found in
ServiceDBStore", ozoneServiceDefName);
+ return;
+ }
+
+ dbOzoneServiceDef.setPolicyConditions(embeddedPolicyConditions);
+
+ final RangerServiceDefValidator validator =
validatorFactory.getServiceDefValidator(svcStore);
+
+ validator.validate(dbOzoneServiceDef, Action.UPDATE);
+
+ svcStore.updateServiceDef(dbOzoneServiceDef);
+
+ xXServiceDefObj =
daoMgr.getXXServiceDef().findByName(ozoneServiceDefName);
+
+ if (xXServiceDefObj != null) {
+ final String jsonStrPostUpdate =
xXServiceDefObj.getDefOptions();
+ Map<String, String> serviceDefOptionsPostUpdate = null;
+
+ if (StringUtils.isNotEmpty(jsonStrPostUpdate)) {
+ serviceDefOptionsPostUpdate =
jsonUtil.jsonToMap(jsonStrPostUpdate);
+ }
+
+ if (serviceDefOptionsPostUpdate != null &&
serviceDefOptionsPostUpdate.containsKey(RangerServiceDef.OPTION_ENABLE_DENY_AND_EXCEPTIONS_IN_POLICIES))
{
+ if (serviceDefOptionsPreUpdate == null ||
!serviceDefOptionsPreUpdate.containsKey(RangerServiceDef.OPTION_ENABLE_DENY_AND_EXCEPTIONS_IN_POLICIES))
{
+
serviceDefOptionsPostUpdate.remove(RangerServiceDef.OPTION_ENABLE_DENY_AND_EXCEPTIONS_IN_POLICIES);
+
+
xXServiceDefObj.setDefOptions(jsonUtil.readMapToString(serviceDefOptionsPostUpdate));
+
+ daoMgr.getXXServiceDef().update(xXServiceDefObj);
+ }
+ }
+ }
+ } catch (Exception e) {
+ logger.error("Error while updating ozone service-def policy
conditions", e);
+ throw new RuntimeException("Failed to update ozone service-def
policy conditions", e);
+ }
+ }
+}
diff --git
a/security-admin/src/main/webapp/react-webapp/src/components/CommonComponents.jsx
b/security-admin/src/main/webapp/react-webapp/src/components/CommonComponents.jsx
index 15b8569f8..7b7319838 100644
---
a/security-admin/src/main/webapp/react-webapp/src/components/CommonComponents.jsx
+++
b/security-admin/src/main/webapp/react-webapp/src/components/CommonComponents.jsx
@@ -492,6 +492,10 @@ export const scrollToError = (selector) => {
};
export const selectInputCustomStyles = {
+ multiValue: (base) => ({
+ ...base,
+ maxWidth: "100%"
+ }),
option: (base) => ({
...base,
textOverflow: "unset",
@@ -512,6 +516,28 @@ export const selectInputCustomStyles = {
})
};
+/** Multi-value select styles that wrap chips (e.g. action-matches in
permission rows). */
+export const selectInputWrappingCustomStyles = {
+ ...selectInputCustomStyles,
+ control: (base) => ({
+ ...base,
+ flexWrap: "wrap"
+ }),
+ valueContainer: (base) => ({
+ ...base,
+ flexWrap: "wrap"
+ })
+};
+
+export const trimInputValue = (e, input) => {
+ const value = e.target.value;
+ const trimmed = value.trim();
+ if (value !== trimmed) {
+ input.onChange(trimmed);
+ }
+ input.onBlur(e);
+};
+
export const selectInputCustomErrorStyles = {
...selectInputCustomStyles,
control: () => {
diff --git
a/security-admin/src/main/webapp/react-webapp/src/components/Editable.jsx
b/security-admin/src/main/webapp/react-webapp/src/components/Editable.jsx
index 34c8c3a87..8aabb9244 100644
--- a/security-admin/src/main/webapp/react-webapp/src/components/Editable.jsx
+++ b/security-admin/src/main/webapp/react-webapp/src/components/Editable.jsx
@@ -33,7 +33,15 @@ import CreatableSelect from "react-select/creatable";
import Select from "react-select";
import { InfoIcon } from "Utils/XAUtils";
import { RegexMessage } from "Utils/XAMessages";
-import { selectInputCustomStyles } from "Components/CommonComponents";
+import { selectInputWrappingCustomStyles } from "Components/CommonComponents";
+import {
+ sortPolicyConditions,
+ buildActionReqsMapFromConditionDef,
+ isPerRowCondition,
+ getAllowedActionMatchesForCondition,
+ getCleanConditions,
+ parseConditionUiHint
+} from "Utils/policyConditionUtils";
const esprima = require("esprima");
const TYPE_SELECT = "select";
@@ -42,6 +50,28 @@ const TYPE_INPUT = "input";
const TYPE_RADIO = "radio";
const TYPE_CUSTOM = "custom";
+/**
+ * Default react-select props for condition fields rendered inside the
Bootstrap
+ * OverlayTrigger popover (e.g. Action / action-matches on a permission row).
+ * Portaling the menu to document.body with fixed positioning prevents a long
+ * option list from resizing the popover or scrolling the policy form when the
+ * menu opens. menuShouldScrollIntoView is disabled for the same reason.
+ * Callers may pass selectProps to merge or override (PolicyPermissionItem
does).
+ */
+const CONDITION_POPOVER_SELECT_PROPS = {
+ menuPortalTarget:
+ typeof document !== "undefined" ? document.body : undefined,
+ menuPosition: "fixed",
+ menuPlacement: "auto",
+ menuShouldScrollIntoView: false
+};
+
+/** Above .table-editable popover overlay (z-index 1060 in style.css). */
+const conditionPopoverSelectStyles = {
+ ...selectInputWrappingCustomStyles,
+ menuPortal: (base) => ({ ...base, zIndex: 1061 })
+};
+
const CheckboxComp = (props) => {
const { options, value = [], valRef, showSelectAll, selectAllLabel } = props;
const [selectedVal, setVal] = useState(value);
@@ -148,9 +178,20 @@ const InputBoxComp = (props) => {
);
};
-const CustomCondition = (props) => {
- const { value, valRef, conditionDefVal, selectProps, validExpression } =
- props;
+/**
+ * One policy condition field inside the per-row popover (ip-range,
_expression, or action-matches).
+ * uiHint shape selects control: singleValue | isMultiline | isMultiValue
(fixed or creatable select).
+ */
+const ConditionRow = ({
+ m,
+ value,
+ valRef,
+ selectProps,
+ validExpression,
+ servicedefName,
+ actionFilterContext,
+ actionReqsMap
+}) => {
const tagAccessData = (val, key) => {
if (!isObject(valRef.current)) {
valRef.current = {};
@@ -158,158 +199,239 @@ const CustomCondition = (props) => {
valRef.current[key] = val;
};
- return (
- <>
- {conditionDefVal?.length > 0 &&
- conditionDefVal.map((m) => {
- let uiHintAttb =
- m.uiHint != undefined && m.uiHint != "" ? JSON.parse(m.uiHint) :
"";
- if (uiHintAttb != "") {
- if (uiHintAttb?.singleValue) {
- const [selectedCondVal, setCondSelect] = useState(
- value?.[m.name] || value
- );
- const accessedOpt = [
- { value: "yes", label: "Yes" },
- { value: "no", label: "No" }
- ];
- const accessedVal = (val) => {
- let value = null;
- if (val) {
- let opObj = accessedOpt?.filter((m) => {
- if (m.value == (val[0]?.value || val)) {
- return m;
- }
- });
- if (opObj) {
- value = opObj;
- }
- }
- return value;
- };
- const selectHandleChange = (e, name) => {
- let filterVal = accessedOpt?.filter((m) => {
- if (m.value != e[0]?.value) {
- return m;
- }
- });
- setCondSelect(
- !isEmpty(e) ? (e?.length > 1 ? filterVal : e) : null
- );
- tagAccessData(
- !isEmpty(e)
- ? e?.length > 1
- ? filterVal[0].value
- : e[0].value
- : null,
- name
- );
- };
- return (
- <div key={m.name}>
- <Form.Group className="mb-3">
- <b>{m.label}:</b>
- <Select
- options={accessedOpt}
- onChange={(e) => selectHandleChange(e, m.name)}
- value={
- selectedCondVal?.value
- ? accessedVal(selectedCondVal.value)
- : accessedVal(selectedCondVal)
- }
- isMulti={true}
- isClearable={false}
- />
- </Form.Group>
- </div>
- );
+ const uiHintAttb = parseConditionUiHint(m.uiHint);
+ const conditionValue = value?.[m.name];
+
+ const [selectedCondVal, setCondSelect] = useState(conditionValue);
+ const [selectedJSCondVal, setJSCondVal] = useState(
+ typeof conditionValue === "string" ? conditionValue : ""
+ );
+ const [selectedInputVal, setSelectVal] = useState(
+ Array.isArray(conditionValue) ? conditionValue : []
+ );
+
+ useEffect(() => {
+ setCondSelect(conditionValue);
+ setJSCondVal(typeof conditionValue === "string" ? conditionValue : "");
+ setSelectVal(Array.isArray(conditionValue) ? conditionValue : []);
+ }, [conditionValue]);
+
+ if (!uiHintAttb) return null;
+
+ if (uiHintAttb?.singleValue) {
+ const accessedOpt = [
+ { value: "yes", label: "Yes" },
+ { value: "no", label: "No" }
+ ];
+ const accessedVal = (val) => {
+ let value = null;
+ if (val) {
+ let opObj = accessedOpt?.filter((mOp) => {
+ if (mOp.value == (val[0]?.value || val)) {
+ return mOp;
+ }
+ });
+ if (opObj) {
+ value = opObj;
+ }
+ }
+ return value;
+ };
+ const selectHandleChange = (e, name) => {
+ let filterVal = accessedOpt?.filter((mOp) => {
+ if (mOp.value != e[0]?.value) {
+ return mOp;
+ }
+ });
+ setCondSelect(
+ !isEmpty(e) ? (e?.length > 1 ? filterVal : e) : null
+ );
+ tagAccessData(
+ !isEmpty(e)
+ ? e?.length > 1
+ ? filterVal[0].value
+ : e[0].value
+ : null,
+ name
+ );
+ };
+ return (
+ <div key={m.name}>
+ <Form.Group className="mb-3">
+ <b>{m.label}:</b>
+ <Select
+ options={accessedOpt}
+ onChange={(e) => selectHandleChange(e, m.name)}
+ value={
+ selectedCondVal?.value
+ ? accessedVal(selectedCondVal.value)
+ : accessedVal(selectedCondVal)
}
- if (uiHintAttb?.isMultiline) {
- const [selectedJSCondVal, setJSCondVal] = useState(
- value?.[m.name] || value
- );
- const expressionVal = (val) => {
- let value = null;
- if (val != "" && typeof val != "object") {
- valRef.current[m.name] = val;
- return (value = val);
+ isMulti={true}
+ isClearable={false}
+ />
+ </Form.Group>
+ </div>
+ );
+ }
+
+ if (uiHintAttb?.isMultiline) {
+ const expressionVal = (val) => {
+ let value = null;
+ if (val != "" && typeof val != "object") {
+ valRef.current[m.name] = val.trim();
+ return (value = val);
+ }
+ return value !== null ? value : "";
+ };
+ const textAreaHandleChange = (e, name) => {
+ setJSCondVal(e.target.value);
+ tagAccessData(e.target.value, name);
+ };
+ return (
+ <div key={m.name}>
+ <Form.Group className="mb-3">
+ <Row>
+ <Col>
+ <b>{m.label}:</b>
+ <InfoIcon
+ position="right"
+ message={
+ <p className="pd-10">
+ {RegexMessage.MESSAGE.policyConditionInfoIcon}
+ </p>
}
- return value !== null ? value : "";
- };
- const textAreaHandleChange = (e, name) => {
- setJSCondVal(e.target.value);
- tagAccessData(e.target.value, name);
- };
- return (
- <div key={m.name}>
- <Form.Group className="mb-3">
- <Row>
- <Col>
- <b>{m.label}:</b>
- <InfoIcon
- position="right"
- message={
- <p className="pd-10">
- {RegexMessage.MESSAGE.policyConditionInfoIcon}
- </p>
- }
- />
- </Col>
- </Row>
- <Row>
- <Col>
- <Form.Control
- as="textarea"
- rows={3}
- key={m.name}
- value={expressionVal(selectedJSCondVal)}
- onChange={(e) => textAreaHandleChange(e, m.name)}
- isInvalid={validExpression.state}
- />
- {validExpression.state && (
- <div className="text-danger">
- {validExpression.errorMSG}
- </div>
- )}
- </Col>
- </Row>
- </Form.Group>
- </div>
- );
- }
- if (uiHintAttb?.isMultiValue) {
- const [selectedInputVal, setSelectVal] = useState(
- value?.[m.name] || []
- );
- const handleChange = (e, name) => {
- setSelectVal(e);
- tagAccessData(e, name);
- };
- return (
- <div key={m.name}>
- <Form.Group
- className="mb-3"
- controlId="Ip-range"
- key={m.name}
- >
- <b>{m.label}:</b>
- <CreatableSelect
- {...selectProps}
- defaultValue={
- selectedInputVal == "" ? null : selectedInputVal
- }
- onChange={(e) => handleChange(e, m.name)}
- placeholder=""
- width="500px"
- isClearable={false}
- styles={selectInputCustomStyles}
- />
- </Form.Group>
+ />
+ </Col>
+ </Row>
+ <Row>
+ <Col>
+ <Form.Control
+ as="textarea"
+ rows={3}
+ key={m.name}
+ value={expressionVal(selectedJSCondVal)}
+ onChange={(e) => textAreaHandleChange(e, m.name)}
+ onBlur={(e) => {
+ textAreaHandleChange(
+ { target: { value: e.target.value.trim() } },
+ m.name
+ );
+ }}
+ isInvalid={validExpression.state}
+ />
+ {validExpression.state && (
+ <div className="text-danger">
+ {validExpression.errorMSG}
</div>
- );
- }
- }
- })}
+ )}
+ </Col>
+ </Row>
+ </Form.Group>
+ </div>
+ );
+ }
+
+ if (uiHintAttb?.isMultiValue) {
+ const { dropdownOptions, prunedSelection: displayedValue } =
+ getAllowedActionMatchesForCondition({
+ conditionName: m.name,
+ actionFilterContext,
+ actionReqsMap,
+ servicedefName,
+ uiHintAttb,
+ currentSelection: selectedInputVal
+ });
+
+ const handleChange = (e, name) => {
+ setSelectVal(e);
+ tagAccessData(e, name);
+ };
+ return (
+ <div key={m.name}>
+ <Form.Group className="mb-3" controlId={m.name} key={m.name}>
+ <b>{m.label}:</b>
+ {dropdownOptions ? (
+ <Select
+ {...CONDITION_POPOVER_SELECT_PROPS}
+ {...selectProps}
+ value={displayedValue || null}
+ onChange={(e) => handleChange(e, m.name)}
+ options={dropdownOptions}
+ placeholder=""
+ isClearable={false}
+ styles={conditionPopoverSelectStyles}
+ />
+ ) : (
+ <CreatableSelect
+ {...CONDITION_POPOVER_SELECT_PROPS}
+ {...selectProps}
+ value={displayedValue || null}
+ onChange={(e) => handleChange(e, m.name)}
+ placeholder=""
+ width="500px"
+ isClearable={false}
+ styles={conditionPopoverSelectStyles}
+ formatCreateLabel={(inputValue) =>
+ `Create "${inputValue.trim()}"`
+ }
+ onCreateOption={(inputValue) => {
+ const trimmedValue = inputValue.trim();
+ if (trimmedValue) {
+ const newOption = {
+ label: trimmedValue,
+ value: trimmedValue
+ };
+ const currentValues = selectedInputVal || [];
+ const newValues = Array.isArray(currentValues)
+ ? [...currentValues, newOption]
+ : [newOption];
+ setSelectVal(newValues);
+ tagAccessData(newValues, m.name);
+ }
+ }}
+ />
+ )}
+ </Form.Group>
+ </div>
+ );
+ }
+
+ return null;
+};
+
+/**
+ * Per permission-row conditions popover (TYPE_CUSTOM in PolicyPermissionItem).
+ * Renders all service-def conditions; action-matches is filtered on save at
policy level only.
+ */
+const CustomCondition = (props) => {
+ const {
+ value,
+ valRef,
+ conditionDefVal,
+ selectProps,
+ validExpression,
+ servicedefName,
+ actionFilterContext,
+ actionReqsMap = {}
+ } = props;
+
+ return (
+ <>
+ {conditionDefVal?.length > 0 &&
+ sortPolicyConditions(conditionDefVal).map((m) => (
+ <ConditionRow
+ key={m.name}
+ m={m}
+ value={value}
+ valRef={valRef}
+ selectProps={selectProps}
+ validExpression={validExpression}
+ servicedefName={servicedefName}
+ actionFilterContext={actionFilterContext}
+ actionReqsMap={actionReqsMap}
+ />
+ ))}
</>
);
};
@@ -361,7 +483,8 @@ const Editable = (props) => {
onChange,
options = [],
conditionDefVal,
- servicedefName
+ servicedefName,
+ actionFilterContext
} = props;
const initialLoad = useRef(true);
@@ -375,6 +498,11 @@ const Editable = (props) => {
const { show, value, target } = state;
let isListenerAttached = false;
+ const actionReqsMap = React.useMemo(
+ () => buildActionReqsMapFromConditionDef(conditionDefVal),
+ [conditionDefVal]
+ );
+
const handleClickOutside = (e) => {
if (
document.getElementById("popover-basic")?.contains(e?.target) == false
@@ -401,7 +529,7 @@ const Editable = (props) => {
const displayValue = () => {
let val = "--";
- const selectVal = value;
+ let selectVal = value;
const policyConditionDisplayValue = () => {
let ipRangVal, uiHintVal;
if (selectVal) {
@@ -411,9 +539,10 @@ const Editable = (props) => {
return m;
}
});
- if (conditionObj?.uiHint && conditionObj?.uiHint != "") {
- uiHintVal = JSON.parse(conditionObj.uiHint);
+ if (!conditionObj) {
+ return null;
}
+ uiHintVal = parseConditionUiHint(conditionObj.uiHint);
if (isArray(selectVal[property])) {
ipRangVal = selectVal[property]
?.map(function (m) {
@@ -476,7 +605,6 @@ const Editable = (props) => {
variant="outline-dark"
size="sm"
type="button"
- s
>
<i className="fa-fw fa fa-plus"></i>
</Button>
@@ -586,11 +714,7 @@ const Editable = (props) => {
</div>
);
} else if (type === TYPE_CUSTOM) {
- for (const key in selectVal) {
- if (selectVal[key] == null || selectVal[key] == "") {
- delete selectVal[key];
- }
- }
+ selectVal = getCleanConditions(selectVal);
if (Object.keys(selectVal).length != 0) {
val = (
<h6>
@@ -647,14 +771,41 @@ const Editable = (props) => {
const handleApply = () => {
let errors, uiHintVal;
if (selectValRef?.current) {
- sortBy(Object.keys(selectValRef.current)).map((property) => {
+ // Re-validate action-matches against current row permissions before
persisting.
+ sortBy(Object.keys(selectValRef.current)).forEach((property) => {
let conditionObj = find(conditionDefVal, function (m) {
if (m.name == property) {
return m;
}
});
- if (conditionObj != undefined && conditionObj?.uiHint != "") {
- uiHintVal = JSON.parse(conditionObj.uiHint);
+ uiHintVal = parseConditionUiHint(conditionObj?.uiHint);
+ if (conditionObj != undefined && uiHintVal) {
+ if (
+ isPerRowCondition(conditionObj.name) &&
+ uiHintVal?.isMultiValue &&
+ Array.isArray(uiHintVal?.options) &&
+ actionFilterContext?.selectedAccessTypes?.length > 0
+ ) {
+ const current = selectValRef.current[conditionObj.name];
+
+ if (Array.isArray(current)) {
+ const { prunedSelection } = getAllowedActionMatchesForCondition({
+ conditionName: conditionObj.name,
+ actionFilterContext,
+ actionReqsMap,
+ servicedefName,
+ uiHintAttb: uiHintVal,
+ currentSelection: current
+ });
+
+ if (prunedSelection && prunedSelection.length > 0) {
+ selectValRef.current[conditionObj.name] = prunedSelection;
+ } else {
+ delete selectValRef.current[conditionObj.name];
+ }
+ }
+ }
+
if (
uiHintVal?.isMultiline &&
selectValRef.current[conditionObj.name] != "" &&
@@ -722,6 +873,9 @@ const Editable = (props) => {
conditionDefVal={props.conditionDefVal}
selectProps={props.selectProps}
validExpression={validExpression}
+ servicedefName={servicedefName}
+ actionFilterContext={actionFilterContext}
+ actionReqsMap={actionReqsMap}
/>
) : null}
</Popover.Body>
diff --git
a/security-admin/src/main/webapp/react-webapp/src/hooks/usePruneStaleConditions.js
b/security-admin/src/main/webapp/react-webapp/src/hooks/usePruneStaleConditions.js
new file mode 100644
index 000000000..6cb1cb0ed
--- /dev/null
+++
b/security-admin/src/main/webapp/react-webapp/src/hooks/usePruneStaleConditions.js
@@ -0,0 +1,153 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+/**
+ * Keeps policyItems[].conditions["action-matches"] in sync when permissions or
+ * resources change. Mounted from PolicyPermissionItem; does not touch ip-range
+ * or other per-row conditions.
+ */
+
+import { useEffect } from "react";
+import { isArray, find, isEqual } from "lodash";
+import {
+ getSelectedAccessTypesForRow,
+ getAllowedActionMatchesForCondition,
+ isPerRowCondition,
+ parseConditionUiHint
+} from "Utils/policyConditionUtils";
+
+/**
+ * Stable dependency string: only permission accesses and per-row condition
values.
+ * Avoids re-running when unrelated form fields (user/group names, etc.)
change.
+ */
+const serializePruneDeps = (formValues, attrName) => {
+ const items = formValues?.[attrName];
+ if (!Array.isArray(items)) {
+ return "[]";
+ }
+ return JSON.stringify(
+ items.map((item, index) => ({
+ accesses: getSelectedAccessTypesForRow(formValues, attrName, index),
+ actionMatches: item?.conditions?.["action-matches"] ?? null
+ }))
+ );
+};
+
+export const usePruneStaleConditions = ({
+ formValues,
+ attrName,
+ form,
+ leafResourceTypes,
+ serviceCompDetails,
+ conditionDefVal,
+ actionReqsMap
+}) => {
+ const serializedPruneDeps = serializePruneDeps(formValues, attrName);
+
+ useEffect(() => {
+ const items = formValues?.[attrName];
+ if (!items || !isArray(items)) {
+ return;
+ }
+
+ let hasChanges = false;
+ const newItems = [...items];
+
+ newItems.forEach((item, index) => {
+ if (!item?.conditions) {
+ return;
+ }
+
+ const accesses = getSelectedAccessTypesForRow(formValues, attrName,
index);
+ if (accesses.length === 0) {
+ const actionMatches = item.conditions?.["action-matches"];
+ if (Array.isArray(actionMatches) && actionMatches.length > 0) {
+ const newConditions = { ...item.conditions };
+ delete newConditions["action-matches"];
+ newItems[index] = { ...item, conditions: newConditions };
+ hasChanges = true;
+ }
+ return;
+ }
+
+ const actionFilterContext = {
+ selectedAccessTypes: accesses,
+ leafResourceTypes,
+ accessTypeDefs: serviceCompDetails?.accessTypes
+ };
+
+ let itemChanged = false;
+ const newConditions = { ...item.conditions };
+
+ for (const conditionName in newConditions) {
+ if (!isPerRowCondition(conditionName)) {
+ continue;
+ }
+
+ const conditionDef = find(conditionDefVal, { name: conditionName });
+ if (!conditionDef) {
+ continue;
+ }
+
+ const uiHintVal = parseConditionUiHint(conditionDef.uiHint);
+
+ if (
+ uiHintVal?.isMultiValue &&
+ Array.isArray(newConditions[conditionName])
+ ) {
+ const current = newConditions[conditionName];
+ const { prunedSelection } = getAllowedActionMatchesForCondition({
+ conditionName,
+ actionFilterContext,
+ actionReqsMap,
+ servicedefName: serviceCompDetails?.name,
+ uiHintAttb: uiHintVal,
+ currentSelection: current
+ });
+
+ if (!isEqual(current, prunedSelection)) {
+ if (prunedSelection && prunedSelection.length > 0) {
+ newConditions[conditionName] = prunedSelection;
+ } else {
+ delete newConditions[conditionName];
+ }
+ itemChanged = true;
+ hasChanges = true;
+ }
+ }
+ }
+
+ if (itemChanged) {
+ newItems[index] = { ...item, conditions: newConditions };
+ }
+ });
+
+ if (hasChanges) {
+ form.change(attrName, newItems);
+ }
+ }, [
+ serializedPruneDeps,
+ leafResourceTypes,
+ actionReqsMap,
+ attrName,
+ form,
+ conditionDefVal,
+ serviceCompDetails
+ ]);
+};
diff --git a/security-admin/src/main/webapp/react-webapp/src/styles/style.css
b/security-admin/src/main/webapp/react-webapp/src/styles/style.css
index 57adcf354..0ec43d82c 100644
--- a/security-admin/src/main/webapp/react-webapp/src/styles/style.css
+++ b/security-admin/src/main/webapp/react-webapp/src/styles/style.css
@@ -1603,6 +1603,18 @@ header {
text-align: center;
}
+.table-responsive > .table.policy-permission-table > tbody > tr > td {
+ white-space: normal;
+}
+
+.policy-permission-table .badge,
+.policy-permission-table .editable-label {
+ white-space: normal;
+ overflow-wrap: anywhere;
+ word-break: break-word;
+ max-width: 100%;
+}
+
/* Button toggle tag*/
.switch.btn-xs {
border-radius: 15px;
diff --git
a/security-admin/src/main/webapp/react-webapp/src/utils/actionRequirements/ozone.json
b/security-admin/src/main/webapp/react-webapp/src/utils/actionRequirements/ozone.json
new file mode 100644
index 000000000..edc14a5d5
--- /dev/null
+++
b/security-admin/src/main/webapp/react-webapp/src/utils/actionRequirements/ozone.json
@@ -0,0 +1,47 @@
+{
+ "volume": {
+ "ListAllMyBuckets": ["read", "list"],
+ "CreateBucket": ["read"],
+ "DeleteBucket": ["read"],
+ "GetBucketAcl": ["read"],
+ "ListBucket": ["read"],
+ "ListBucketMultipartUploads": ["read"],
+ "PutBucketAcl": ["read"],
+ "AbortMultipartUpload": ["read"],
+ "DeleteObject": ["read"],
+ "DeleteObjectTagging": ["read"],
+ "GetObject": ["read"],
+ "GetObjectTagging": ["read"],
+ "ListMultipartUploadParts": ["read"],
+ "PutObject": ["read"],
+ "PutObjectTagging": ["read"]
+ },
+ "bucket": {
+ "ListBucket": ["read", "list"],
+ "ListBucketMultipartUploads": ["read", "list"],
+ "CreateBucket": ["create"],
+ "DeleteBucket": ["delete"],
+ "GetBucketAcl": ["read", "read_acl"],
+ "PutBucketAcl": ["read", "read_acl", "write_acl"],
+ "AbortMultipartUpload": ["read"],
+ "DeleteObject": ["read"],
+ "DeleteObjectTagging": ["read"],
+ "GetObject": ["read"],
+ "GetObjectTagging": ["read"],
+ "ListMultipartUploadParts": ["read"],
+ "PutObject": ["read"],
+ "PutObjectTagging": ["read"]
+ },
+ "key": {
+ "GetObject": ["read"],
+ "GetObjectTagging": ["read"],
+ "ListBucket": ["read"],
+ "PutObject": ["create", "write"],
+ "PutObjectTagging": ["write"],
+ "DeleteObjectTagging": ["write"],
+ "AbortMultipartUpload": ["write"],
+ "DeleteObject": ["delete"],
+ "ListMultipartUploadParts": ["read"]
+ },
+ "role": {}
+}
diff --git
a/security-admin/src/main/webapp/react-webapp/src/utils/actionRequirements/registry.js
b/security-admin/src/main/webapp/react-webapp/src/utils/actionRequirements/registry.js
new file mode 100644
index 000000000..b0462fc4f
--- /dev/null
+++
b/security-admin/src/main/webapp/react-webapp/src/utils/actionRequirements/registry.js
@@ -0,0 +1,30 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+/**
+ * Bundled action-to-permission maps referenced by service-def uiHint
+ * (actionRequirementsFile). Loaded once at build time; add new services here
+ * and a matching JSON file alongside ozone.json.
+ */
+
+import ozoneReqs from "./ozone.json";
+
+export const actionRequirementsRegistry = {
+ ozone: ozoneReqs
+};
diff --git
a/security-admin/src/main/webapp/react-webapp/src/utils/policyConditionUtils.js
b/security-admin/src/main/webapp/react-webapp/src/utils/policyConditionUtils.js
new file mode 100644
index 000000000..e8c1c3c00
--- /dev/null
+++
b/security-admin/src/main/webapp/react-webapp/src/utils/policyConditionUtils.js
@@ -0,0 +1,722 @@
+/*
+ * 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.
+ */
+
+/**
+ * Policy condition helpers for the React policy form.
+ *
+ * UI data flow (Ozone action-matches example):
+ *
+ * PolicyPermissionItem
+ * ├─ getSelectedLeafResourceTypes() → deepest resource type(s) in the
form
+ * ├─ buildActionReqsMapFromConditionDef() → loads ozone.json via registry
+ * ├─ usePruneStaleConditions() → drops invalid action-matches on
change
+ * └─ Editable (type=custom) per row
+ * ├─ CustomCondition popover → ip-range, _expression,
action-matches
+ * └─ getAllowedActionMatchesForCondition()
+ * └─ getActionMatchesOptions() → filter + sort dropdown
+ *
+ * Storage split:
+ * - policy.conditions (header modal via PolicyConditionsComp): all defs
EXCEPT action-matches
+ * - policyItems[i].conditions (permission row popover): all defs including
action-matches
+ *
+ * action-matches filtering uses uiHint.options (service def) plus
actionRequirements
+ * (ozone.json keyed by volume | bucket | key) and the row's selected access
types.
+ */
+
+import { actionRequirementsRegistry } from "./actionRequirements/registry";
+
+/**
+ * Sorts action options for the dropdown, placing wildcard patterns first
+ * for Ozone (e.g. *, Create*, Get*, etc.) followed by concrete actions
+ * alphabetically. For other services, sorts all options alphabetically.
+ *
+ * @param {string[]} options - Raw action option strings from the service def
uiHint.
+ * @param {string} servicedefName - The service definition name (e.g. "ozone").
+ * @returns {string[]} Sorted and trimmed action options.
+ */
+const sortActionOptions = (options, servicedefName) => {
+ if (!Array.isArray(options)) {
+ return options;
+ }
+
+ const normalized = options
+ .map((v) => (typeof v === "string" ? v.trim() : ""))
+ .filter((v) => v.length > 0);
+
+ if (servicedefName === "ozone") {
+ // Ensure wildcard options are shown first for usability
+ const pinnedOrder = ["*", "Create*", "Delete*", "Get*", "List*", "Put*"];
+ const pinned = pinnedOrder.filter((v) => normalized.includes(v));
+ const pinnedSet = new Set(pinned);
+ const rest = normalized
+ .filter((v) => !pinnedSet.has(v))
+ .sort((a, b) => a.localeCompare(b, undefined, { sensitivity: "base" }));
+
+ return [...pinned, ...rest];
+ }
+
+ return normalized.sort((a, b) =>
+ a.localeCompare(b, undefined, { sensitivity: "base" })
+ );
+};
+
+const NONE_RESOURCE_VALUE = "none";
+
+/** Trims and filters an array of strings, removing blanks. */
+const normalizeStringArray = (values) => {
+ if (!Array.isArray(values)) {
+ return [];
+ }
+
+ return values
+ .map((v) => (typeof v === "string" ? v.trim() : ""))
+ .filter((v) => v.length > 0);
+};
+
+/** Converts a string array to a Set of lowercased, trimmed values. */
+const toLowerSet = (values) => {
+ const set = new Set();
+ for (const v of normalizeStringArray(values)) {
+ set.add(v.toLowerCase());
+ }
+ return set;
+};
+
+/**
+ * Returns true if every entry in `required` is present in `grantedSet`.
+ * An empty or null `required` array is considered satisfied.
+ */
+const hasAll = (required, grantedSet) => {
+ if (!Array.isArray(required) || required.length === 0) {
+ return true;
+ }
+ for (const r of required) {
+ if (!grantedSet.has(r)) {
+ return false;
+ }
+ }
+ return true;
+};
+
+/**
+ * Expands a set of selected access types by following impliedGrants chains.
+ * For example, selecting "all" expands to include "read", "write", "create",
etc.
+ * Uses a fixpoint loop to transitively resolve implied grants.
+ *
+ * @param {string[]} selectedAccessTypes - Currently selected access type
names.
+ * @param {Array<{name: string, impliedGrants?: string[]}>} accessTypeDefs -
Service def access types.
+ * @returns {string[]} Expanded set of access type names (lowercased).
+ */
+const expandImpliedAccessTypes = (selectedAccessTypes, accessTypeDefs) => {
+ const selected = toLowerSet(selectedAccessTypes);
+
+ if (!Array.isArray(accessTypeDefs) || accessTypeDefs.length === 0) {
+ return [...selected];
+ }
+
+ let changed = true;
+ while (changed) {
+ changed = false;
+ for (const def of accessTypeDefs) {
+ const name = def?.name;
+ const implied = def?.impliedGrants;
+ if (!name || !selected.has(String(name).toLowerCase())) {
+ continue;
+ }
+ if (!Array.isArray(implied) || implied.length === 0) {
+ continue;
+ }
+ for (const g of implied) {
+ const grant = typeof g === "string" ? g.trim().toLowerCase() : "";
+ if (grant && !selected.has(grant)) {
+ selected.add(grant);
+ changed = true;
+ }
+ }
+ }
+ }
+
+ return [...selected];
+};
+
+/**
+ * Extracts the currently selected access type names for a specific permission
row
+ * in the policy form. Used to determine which action options should be
available.
+ *
+ * @param {object} formValues - The form state object.
+ * @param {string} attrName - The field array attribute name (e.g.
"policyItems").
+ * @param {number} index - The row index within the field array.
+ * @returns {string[]} Selected access type values for that row.
+ */
+export const getSelectedAccessTypesForRow = (formValues, attrName, index) => {
+ const accesses = formValues?.[attrName]?.[index]?.accesses;
+ if (!Array.isArray(accesses)) {
+ return [];
+ }
+ return accesses
+ .map((a) => (typeof a?.value === "string" ? a.value.trim() : ""))
+ .filter((v) => v.length > 0);
+};
+
+/**
+ * True when the condition is stored on policyItems[].conditions rather than
+ * policy.conditions. Today only "action-matches" uses that storage path.
+ *
+ * Used to exclude action-matches from the policy-level PolicyConditionsComp
modal
+ * and to scope prune/apply logic. ip-range and _expression still render in the
+ * per-row Editable popover (CustomCondition) and are evaluated on the policy
item.
+ */
+export const isPerRowCondition = (name) => name === "action-matches";
+
+/**
+ * Safely parses a condition definition uiHint JSON string.
+ * @returns {object|null} Parsed uiHint object, or null if missing or invalid.
+ */
+export const parseConditionUiHint = (uiHint) => {
+ if (uiHint == null || uiHint === "") {
+ return null;
+ }
+ try {
+ const parsed = JSON.parse(uiHint);
+ return parsed && typeof parsed === "object" ? parsed : null;
+ } catch (e) {
+ return null;
+ }
+};
+
+/** Desired render order for policy conditions in the UI. */
+const CONDITION_ORDER = ["ip-range", "_expression", "action-matches"];
+
+/**
+ * Sorts policy condition definitions into a consistent render order.
+ * Known conditions (ip-range, _expression, action-matches) are placed first
+ * in the order defined by CONDITION_ORDER; unknown conditions follow.
+ *
+ * @param {Array<{name: string}>} conditions - Condition definitions from the
service def.
+ * @returns {Array} Sorted copy of the conditions array.
+ */
+export const sortPolicyConditions = (conditions) => {
+ if (!Array.isArray(conditions)) {
+ return conditions;
+ }
+
+ return [...conditions].sort((a, b) => {
+ const indexA = CONDITION_ORDER.indexOf(a?.name);
+ const indexB = CONDITION_ORDER.indexOf(b?.name);
+
+ const rankA = indexA === -1 ? 999 : indexA;
+ const rankB = indexB === -1 ? 999 : indexB;
+
+ return rankA - rankB;
+ });
+};
+
+/**
+ * Determines the deepest (leaf) resource types currently selected in the
policy form.
+ * For Ozone, this might return {"key"} or {"bucket"} depending on which
resource
+ * levels the user has filled in. Used to scope which action requirements
apply —
+ * e.g. key-level actions differ from bucket-level actions.
+ *
+ * Walks the resource hierarchy (volume → bucket → key) and finds the deepest
+ * non-"none" resource the user has selected in each resource block.
+ *
+ * @param {object} serviceCompDetails - Service definition details with
resources array.
+ * @param {object} formValues - Form state (may contain additionalResources
for multi-resource policies).
+ * @returns {Set<string>} Set of leaf resource type names currently in use.
+ */
+export const getSelectedLeafResourceTypes = (serviceCompDetails, formValues)
=> {
+ const result = new Set();
+ const resources = serviceCompDetails?.resources;
+ if (!Array.isArray(resources) || resources.length === 0) {
+ return result;
+ }
+
+ const levels = [...new Set(resources.map((r) =>
r?.level).filter(Number.isFinite))]
+ .sort((a, b) => a - b);
+
+ const blocks = Array.isArray(formValues?.additionalResources)
+ ? formValues.additionalResources
+ : [formValues];
+
+ for (const block of blocks) {
+ if (!block) {
+ continue;
+ }
+
+ let leaf = null;
+ for (const level of levels) {
+ const sel = block[`resourceName-${level}`];
+ if (!sel) {
+ continue;
+ }
+ if (sel?.value === NONE_RESOURCE_VALUE) {
+ break;
+ }
+ if (typeof sel?.name === "string" && sel.name.trim().length > 0) {
+ leaf = sel.name.trim();
+ }
+ }
+
+ if (leaf) {
+ result.add(leaf);
+ }
+ }
+
+ return result;
+};
+
+/**
+ * Determines if actionRequirements is nested (keyed by resource type like
Ozone)
+ * or flat (single-level mapping).
+ *
+ * Nested example (Ozone): { volume: {ListAllMyBuckets: ["read","list"], ...},
bucket: {...}, key: {...} }
+ * Flat example: { open: ["read"], create: ["write"], ... }
+ */
+const isNestedActionRequirements = (actionRequirements) =>
+ Object.values(actionRequirements).some(
+ (v) => v && typeof v === "object" && !Array.isArray(v)
+ );
+
+/**
+ * Builds a case-insensitive lookup map from the original keys of an object.
+ * Used to match resource type names regardless of casing.
+ * @returns {Map<string, string>} Map from lowercased key to original key.
+ */
+const buildKeyLookup = (obj) => {
+ const map = new Map();
+ for (const key of Object.keys(obj)) {
+ map.set(key.toLowerCase(), key);
+ }
+ return map;
+};
+
+/**
+ * Resolves which action requirements apply given the currently selected leaf
resource types.
+ *
+ * For nested requirements (like Ozone's ozone.json which is keyed by resource
type):
+ * - If no leaf types selected: returns all non-role sections (so all
actions are visible).
+ * - If only "role" is selected: returns only the role section (which is
typically empty).
+ * - Otherwise: returns only the sections matching the selected leaf types.
+ * - Fallback: if no match found, returns all non-role sections.
+ *
+ * For flat requirements: wraps them under a "*" key (applies regardless of
resource type).
+ *
+ * @param {object} actionRequirements - The action-to-permission mapping from
ozone.json.
+ * @param {Set<string>} leafResourceTypes - Currently selected leaf resource
types.
+ * @returns {object} Filtered requirements keyed by resource type (or "*" for
flat).
+ */
+const getActionRequirements = (actionRequirements, leafResourceTypes) => {
+ if (!actionRequirements || typeof actionRequirements !== "object") {
+ return {};
+ }
+
+ if (!isNestedActionRequirements(actionRequirements)) {
+ return { "*": actionRequirements };
+ }
+
+ const keyLookup = buildKeyLookup(actionRequirements);
+ const result = {};
+
+ if (!leafResourceTypes || leafResourceTypes.size === 0) {
+ for (const [lowerKey, originalKey] of keyLookup) {
+ if (lowerKey !== "role") {
+ result[originalKey] = actionRequirements[originalKey];
+ }
+ }
+ return Object.keys(result).length > 0 ? result : actionRequirements;
+ }
+
+ const loweredLeaves = new Set(
+ [...leafResourceTypes].map((l) => l.trim().toLowerCase())
+ );
+ const nonRoleKeys = [...keyLookup].filter(([lk]) => lk !== "role");
+
+ const hasNonRole = nonRoleKeys.some(([lk]) => loweredLeaves.has(lk));
+ if (!hasNonRole && loweredLeaves.has("role")) {
+ const roleOriginal = keyLookup.get("role");
+ result.role = roleOriginal ? actionRequirements[roleOriginal] : {};
+ return result;
+ }
+
+ for (const [lowerKey, originalKey] of nonRoleKeys) {
+ if (loweredLeaves.has(lowerKey)) {
+ result[originalKey] = actionRequirements[originalKey];
+ }
+ }
+
+ if (Object.keys(result).length === 0) {
+ for (const [, originalKey] of nonRoleKeys) {
+ result[originalKey] = actionRequirements[originalKey];
+ }
+ }
+
+ return result;
+};
+
+/**
+ * Checks whether a concrete (non-wildcard) action is allowed given the granted
+ * access types and the permission requirements from ozone.json.
+ *
+ * An action is allowed if ANY leaf resource type's requirements for that
action
+ * are fully satisfied by the granted permission set. This means if PutObject
+ * requires ["create", "write"] at the key level, and the user has selected
both
+ * "create" and "write" permissions, this returns true.
+ *
+ * @param {string} actionLower - Lowercased concrete action name (e.g.
"putobject").
+ * @param {Set<string>} grantedAccessTypes - Lowercased set of granted
permissions (expanded).
+ * @param {Map<string, string[]>[]} normalizedReqsByLeaf - Output of
buildNormalizedReqs().
+ * @returns {boolean} True if the action's requirements are met.
+ */
+const isConcreteActionAllowed = (
+ actionLower,
+ grantedAccessTypes,
+ normalizedReqsByLeaf
+) => {
+ if (!actionLower) {
+ return false;
+ }
+
+ for (const leafReqs of normalizedReqsByLeaf) {
+ const required = leafReqs.get(actionLower);
+
+ if (required && hasAll(required, grantedAccessTypes)) {
+ return true;
+ }
+ }
+
+ return false;
+};
+
+/**
+ * Pre-builds a normalized (lowercased keys) lookup structure from
requirementsByLeaf
+ * so that isConcreteActionAllowed can do O(1) lookups instead of linear scans.
+ */
+const buildNormalizedReqs = (requirementsByLeaf) => {
+ const result = [];
+ for (const leaf of Object.keys(requirementsByLeaf)) {
+ const reqsByAction = requirementsByLeaf[leaf];
+ if (!reqsByAction) {
+ continue;
+ }
+ const map = new Map();
+ for (const key of Object.keys(reqsByAction)) {
+ map.set(key.toLowerCase(), reqsByAction[key]);
+ }
+ result.push(map);
+ }
+ return result;
+};
+
+/**
+ * Filters the full list of action options down to only those that are
achievable
+ * given the currently selected permissions. This is the core filtering logic
that
+ * drives the action-matches dropdown.
+ *
+ * Logic by option type:
+ * - "*" (universal wildcard): shown only if "all" is granted, or if every
concrete
+ * action in the requirements is satisfied by the selected permissions.
+ * - "Put*" (prefix wildcard): shown only if ALL concrete actions starting
with
+ * that prefix are satisfied (e.g. Put* requires PutObject,
PutObjectTagging,
+ * PutBucketAcl all to be achievable).
+ * - "PutObject" (concrete action): shown only if its permission requirements
+ * are met by the selected access types.
+ *
+ * @param {object} params
+ * @param {string[]} params.baseOptions - All possible action options from
uiHint.
+ * @param {string[]} params.selectedAccessTypes - Currently selected access
types for this row.
+ * @param {Set<string>} params.leafResourceTypes - Currently selected leaf
resource types.
+ * @param {Array} params.accessTypeDefs - Service def access type definitions
(for implied grants).
+ * @param {object} params.actionRequirements - The action-to-permission map
(from ozone.json).
+ * @returns {string[]} Filtered action options that are achievable with
selected permissions.
+ * Returns an empty array when no access types are selected for the row.
+ */
+const filterActionOptions = ({
+ baseOptions,
+ selectedAccessTypes,
+ leafResourceTypes,
+ accessTypeDefs,
+ actionRequirements
+}) => {
+ if (!Array.isArray(baseOptions)) {
+ return baseOptions;
+ }
+
+ const normalizedBase = normalizeStringArray(baseOptions);
+ const selectedExpanded = expandImpliedAccessTypes(
+ selectedAccessTypes,
+ accessTypeDefs
+ );
+ const granted = toLowerSet(selectedExpanded);
+
+ if (granted.size === 0) {
+ return [];
+ }
+
+ if (!actionRequirements || Object.keys(actionRequirements).length === 0) {
+ return normalizedBase;
+ }
+
+ const requirementsByLeaf = getActionRequirements(
+ actionRequirements,
+ leafResourceTypes
+ );
+ const normalizedReqs = buildNormalizedReqs(requirementsByLeaf);
+ const scopedToLeaves =
+ leafResourceTypes &&
+ leafResourceTypes.size > 0 &&
+ isNestedActionRequirements(actionRequirements);
+
+ // Collect all concrete action names (lowercased) from the requirements data
+ const concreteActionsLower = new Set();
+ for (const leafMap of normalizedReqs) {
+ for (const key of leafMap.keys()) {
+ concreteActionsLower.add(key);
+ }
+ }
+
+ return normalizedBase.filter((opt) => {
+ // Universal wildcard: only show if all concrete actions are achievable
+ if (opt === "*") {
+ if (granted.has("all")) {
+ return true;
+ }
+
+ if (concreteActionsLower.size === 0) {
+ return false;
+ }
+
+ for (const a of concreteActionsLower) {
+ if (!isConcreteActionAllowed(a, granted, normalizedReqs)) {
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+ // Prefix wildcard (e.g. "Put*"): show only if ALL matching concrete
actions are achievable
+ if (opt.endsWith("*")) {
+ const prefixLower = opt.slice(0, -1).toLowerCase();
+ if (!prefixLower) {
+ return false;
+ }
+
+ const candidates = [...concreteActionsLower].filter((a) =>
+ a.startsWith(prefixLower)
+ );
+ if (candidates.length === 0) {
+ return false;
+ }
+
+ return candidates.every((a) =>
+ isConcreteActionAllowed(a, granted, normalizedReqs)
+ );
+ }
+
+ // Mid-string wildcards are not supported
+ if (opt.includes("*")) {
+ return false;
+ }
+
+ // Concrete action: check if its requirements are met
+ const optLower = opt.toLowerCase();
+
+ if (!concreteActionsLower.has(optLower)) {
+ // When scoped to a leaf resource (e.g. key), hide actions that do not
apply
+ // at that depth. When unscoped, keep uiHint-only options for
forward-compat.
+ return !scopedToLeaves;
+ }
+
+ return isConcreteActionAllowed(optLower, granted, normalizedReqs);
+ });
+};
+
+/**
+ * Entry point for computing the action-matches dropdown options for a
permission row.
+ * Filters the base options using the current row's access types and resource
context,
+ * then sorts the result.
+ *
+ * @param {object} params
+ * @param {string} params.servicedefName - Service definition name.
+ * @param {string[]} params.baseOptions - All possible action options from
uiHint.
+ * @param {object} params.actionFilterContext - Contains selectedAccessTypes,
leafResourceTypes, accessTypeDefs.
+ * @param {object} params.actionRequirements - Action-to-permission map from
ozone.json.
+ * @returns {string[]} Filtered and sorted action options, or [] when the row
has no permissions.
+ */
+const getActionMatchesOptions = ({
+ servicedefName,
+ baseOptions,
+ actionFilterContext,
+ actionRequirements
+}) => {
+ const selectedAccessTypes = actionFilterContext?.selectedAccessTypes;
+ if (!Array.isArray(selectedAccessTypes) || selectedAccessTypes.length === 0)
{
+ return [];
+ }
+
+ const filtered = filterActionOptions({
+ baseOptions,
+ selectedAccessTypes,
+ leafResourceTypes: actionFilterContext.leafResourceTypes,
+ accessTypeDefs: actionFilterContext.accessTypeDefs,
+ actionRequirements
+ });
+ return sortActionOptions(filtered, servicedefName);
+};
+
+/**
+ * Returns a new object with any null or empty string values removed.
+ * Useful to prevent React state mutation and to get accurate keys count.
+ */
+export const getCleanConditions = (conditions) => {
+ if (!conditions) {
+ return {};
+ }
+ const cleaned = { ...conditions };
+ for (const key in cleaned) {
+ if (cleaned[key] == null || cleaned[key] === "") {
+ delete cleaned[key];
+ }
+ }
+ return cleaned;
+};
+
+/**
+ * Removes any previously selected action-matches values that are no longer
+ * in the allowed options set. Called when the user changes permissions or
+ * resources, potentially invalidating prior action selections.
+ *
+ * Handles both string arrays and react-select option objects ({label, value}).
+ *
+ * @param {object} params
+ * @param {Array} params.selected - Currently selected values (strings or
{label, value} objects).
+ * @param {string[]|Set} params.allowedOptions - The currently valid action
options.
+ * @returns {Array} Filtered selection with only still-valid entries.
+ */
+export const pruneSelectedActionMatches = ({ selected, allowedOptions }) => {
+ if (!Array.isArray(selected)) {
+ return selected;
+ }
+ const allowedSet =
+ allowedOptions instanceof Set
+ ? allowedOptions
+ : new Set(
+ (allowedOptions || []).map((a) =>
+ typeof a === "string" ? a.trim() : ""
+ )
+ );
+
+ return selected.filter((o) => {
+ // selected might be an array of strings or objects {label, value}
+ const v = typeof o === "object" ? o?.value : o;
+ const normalized = typeof v === "string" ? v.trim() : "";
+ return normalized && allowedSet.has(normalized);
+ });
+};
+
+/**
+ * Shared helper to compute allowed action matches and prune the selection.
+ * Used by ConditionRow (popover) and usePruneStaleConditions (background
sync).
+ *
+ * Only action-matches runs getActionMatchesOptions; ip-range uses
CreatableSelect
+ * when uiHint has no fixed options array.
+ *
+ * @param {object} params
+ * @param {string} params.conditionName - Condition def name (e.g.
"action-matches").
+ * @param {object} params.actionFilterContext - Row permissions + leaf
resources + accessTypeDefs.
+ * @param {object} params.actionReqsMap - From
buildActionReqsMapFromConditionDef().
+ * @param {string} params.servicedefName - Service definition name.
+ * @param {object} params.uiHintAttb - Parsed uiHint for this condition.
+ * @param {Array} params.currentSelection - Current react-select value for the
field.
+ * @returns {{ dropdownOptions: Array|null, prunedSelection: Array }}
+ */
+export const getAllowedActionMatchesForCondition = ({
+ conditionName,
+ actionFilterContext,
+ actionReqsMap,
+ servicedefName,
+ uiHintAttb,
+ currentSelection
+}) => {
+ const fixedOptions = Array.isArray(uiHintAttb?.options)
+ ? uiHintAttb.options
+ : null;
+ const isActionMatches = isPerRowCondition(conditionName);
+
+ const dropdownOptions = fixedOptions
+ ? (isActionMatches
+ ? getActionMatchesOptions({
+ servicedefName,
+ baseOptions: fixedOptions,
+ actionFilterContext,
+ actionRequirements:
+ actionReqsMap[conditionName] || uiHintAttb?.actionRequirements
+ })
+ : fixedOptions
+ ).map((v) => ({ label: v, value: v }))
+ : null;
+
+ const allowedValueSet = dropdownOptions
+ ? new Set(dropdownOptions.map((o) => o.value))
+ : null;
+
+ const prunedSelection =
+ allowedValueSet && Array.isArray(currentSelection)
+ ? pruneSelectedActionMatches({
+ selected: currentSelection,
+ allowedOptions: allowedValueSet
+ })
+ : currentSelection;
+
+ return { dropdownOptions, prunedSelection };
+};
+
+/**
+ * Pre-builds the action requirements map from the condition definitions.
+ * Scans each condition's uiHint for an `actionRequirementsFile` reference
+ * (which points to a JSON file in the actionRequirements registry, e.g.
"ozone")
+ * or an inline `actionRequirements` object.
+ *
+ * This is memoized in PolicyPermissionItem and Editable so we don't re-parse
+ * uiHint JSON on every render.
+ *
+ * @param {Array<{name: string, uiHint: string}>} conditionDefVal - Condition
definitions from service def.
+ * @returns {object} Map of condition name → action requirements object.
+ */
+export const buildActionReqsMapFromConditionDef = (conditionDefVal) => {
+ const map = {};
+ if (!Array.isArray(conditionDefVal) || conditionDefVal.length === 0) {
+ return map;
+ }
+
+ conditionDefVal.forEach((m) => {
+ const uiHintAttb = parseConditionUiHint(m.uiHint);
+
+ if (uiHintAttb) {
+ const fileToLoad = uiHintAttb?.actionRequirementsFile;
+ if (fileToLoad && actionRequirementsRegistry[fileToLoad]) {
+ map[m.name] = actionRequirementsRegistry[fileToLoad];
+ } else if (uiHintAttb?.actionRequirements) {
+ map[m.name] = uiHintAttb.actionRequirements;
+ }
+ }
+ });
+
+ return map;
+};
diff --git
a/security-admin/src/main/webapp/react-webapp/src/views/PolicyListing/AddUpdatePolicyForm.jsx
b/security-admin/src/main/webapp/react-webapp/src/views/PolicyListing/AddUpdatePolicyForm.jsx
index 515c2b0d0..139592264 100644
---
a/security-admin/src/main/webapp/react-webapp/src/views/PolicyListing/AddUpdatePolicyForm.jsx
+++
b/security-admin/src/main/webapp/react-webapp/src/views/PolicyListing/AddUpdatePolicyForm.jsx
@@ -62,6 +62,7 @@ import PolicyPermissionItem from
"../PolicyListing/PolicyPermissionItem";
import { useParams, useNavigate, useLocation } from "react-router-dom";
import PolicyValidityPeriodComp from "./PolicyValidityPeriodComp";
import PolicyConditionsComp from "./PolicyConditionsComp";
+import { isPerRowCondition } from "Utils/policyConditionUtils";
import moment from "moment";
import {
InfoIcon,
@@ -1510,6 +1511,9 @@ export default function AddUpdatePolicyForm() {
!isEmpty(values.conditions) ? (
Object.keys(values.conditions).map(
(keyName) => {
+ if (isPerRowCondition(keyName)) {
+ return null;
+ }
if (
values.conditions[keyName] != "" &&
values.conditions[keyName] != null
diff --git
a/security-admin/src/main/webapp/react-webapp/src/views/PolicyListing/PolicyConditionsComp.jsx
b/security-admin/src/main/webapp/react-webapp/src/views/PolicyListing/PolicyConditionsComp.jsx
index 7c8aba953..678b43840 100644
---
a/security-admin/src/main/webapp/react-webapp/src/views/PolicyListing/PolicyConditionsComp.jsx
+++
b/security-admin/src/main/webapp/react-webapp/src/views/PolicyListing/PolicyConditionsComp.jsx
@@ -25,25 +25,43 @@ import CreatableSelect from "react-select/creatable";
import { find, isEmpty } from "lodash";
import { InfoIcon } from "Utils/XAUtils";
import { RegexMessage } from "Utils/XAMessages";
-import { selectInputCustomStyles } from "Components/CommonComponents";
+import {
+ selectInputCustomStyles,
+ trimInputValue
+} from "Components/CommonComponents";
+import {
+ isPerRowCondition,
+ getCleanConditions,
+ parseConditionUiHint
+} from "Utils/policyConditionUtils";
const esprima = require("esprima");
export default function PolicyConditionsComp(props) {
const { policyConditionDetails, inputVal, showModal, handleCloseModal } =
props;
+ // Policy-level modal only; action-matches is edited on each permission row
(Editable).
+ const filteredPolicyConditionDetails = Array.isArray(policyConditionDetails)
+ ? policyConditionDetails.filter((c) => !isPerRowCondition(c?.name))
+ : policyConditionDetails;
+
const accessedOpt = [
{ value: "yes", label: "Yes" },
{ value: "no", label: "No" }
];
const handleSubmit = (values) => {
- for (let val in values.conditions) {
- if (values.conditions[val] == null || values.conditions[val] == "") {
- delete values.conditions[val];
+ if (values?.conditions) {
+ const newConditions = getCleanConditions(values.conditions);
+ for (let val in newConditions) {
+ if (isPerRowCondition(val)) {
+ delete newConditions[val];
+ }
}
+ inputVal.onChange(newConditions);
+ } else {
+ inputVal.onChange(values?.conditions);
}
- inputVal.onChange(values.conditions);
handleClose();
};
@@ -125,13 +143,10 @@ export default function PolicyConditionsComp(props) {
<Modal.Title>Policy Condition</Modal.Title>
</Modal.Header>
<Modal.Body>
- {policyConditionDetails?.length > 0 &&
- policyConditionDetails.map((m) => {
- let uiHintAttb =
- m.uiHint != undefined && m.uiHint != ""
- ? JSON.parse(m.uiHint)
- : "";
- if (uiHintAttb != "") {
+ {filteredPolicyConditionDetails?.length > 0 &&
+ filteredPolicyConditionDetails.map((m) => {
+ const uiHintAttb = parseConditionUiHint(m.uiHint);
+ if (uiHintAttb) {
if (uiHintAttb?.singleValue) {
return (
<div key={m.name}>
@@ -193,6 +208,9 @@ export default function PolicyConditionsComp(props) {
}
as="textarea"
rows={3}
+ onBlur={(e) =>
+ trimInputValue(e, input)
+ }
/>
{meta.error && (
<span className="invalid-field">
diff --git
a/security-admin/src/main/webapp/react-webapp/src/views/PolicyListing/PolicyPermissionItem.jsx
b/security-admin/src/main/webapp/react-webapp/src/views/PolicyListing/PolicyPermissionItem.jsx
index 4496f7541..342034f6f 100644
---
a/security-admin/src/main/webapp/react-webapp/src/views/PolicyListing/PolicyPermissionItem.jsx
+++
b/security-admin/src/main/webapp/react-webapp/src/views/PolicyListing/PolicyPermissionItem.jsx
@@ -21,7 +21,7 @@ import React, { useMemo, useRef, useState } from "react";
import { Table, Button, Form } from "react-bootstrap";
import { FieldArray } from "react-final-form-arrays";
import { Col } from "react-bootstrap";
-import { Field } from "react-final-form";
+import { Field, useForm } from "react-final-form";
import AsyncSelect from "react-select/async";
import {
find,
@@ -45,6 +45,8 @@ import {
policyConditionUpdatedJSON
} from "Utils/XAUtils";
import { selectInputCustomStyles } from "Components/CommonComponents";
+import { getSelectedLeafResourceTypes, getSelectedAccessTypesForRow,
buildActionReqsMapFromConditionDef, getCleanConditions } from
"Utils/policyConditionUtils";
+import { usePruneStaleConditions } from "../../hooks/usePruneStaleConditions";
const noneOptions = {
label: "None",
@@ -105,15 +107,64 @@ export default function PolicyPermissionItem(props) {
};
const grpResourcesKeys = useMemo(() => {
- const { resources = [] } = serviceCompDetails;
+ const { resources = [] } = serviceCompDetails || {};
const grpResources = groupBy(resources, "level");
let grpResourcesKeys = [];
for (const resourceKey in grpResources) {
grpResourcesKeys.push(+resourceKey);
}
- grpResourcesKeys = grpResourcesKeys.sort();
+ grpResourcesKeys = grpResourcesKeys.sort((a, b) => a - b);
return grpResourcesKeys;
- }, []);
+ }, [serviceCompDetails?.resources]);
+
+ // Narrow dependency: only recompute leafResourceTypes when resource
selection
+ // fields change, not on every keystroke in user/group/permission fields.
+ const resourceSelectionPart = (sel) => {
+ if (!sel) {
+ return "";
+ }
+ return `${sel.name ?? ""}:${sel.value ?? ""}`;
+ };
+
+ const resourceSelectionSignature =
Array.isArray(formValues?.additionalResources)
+ ? formValues.additionalResources
+ .map((b) =>
+ grpResourcesKeys
+ .map((level) =>
resourceSelectionPart(b?.[`resourceName-${level}`]))
+ .join("|")
+ )
+ .join(";")
+ : grpResourcesKeys
+ .map((level) =>
+ resourceSelectionPart(formValues?.[`resourceName-${level}`])
+ )
+ .join("|");
+
+ const leafResourceTypes = useMemo(() => {
+ return getSelectedLeafResourceTypes(serviceCompDetails, formValues);
+ }, [serviceCompDetails, resourceSelectionSignature]);
+
+ const conditionDefVal = useMemo(
+ () => policyConditionUpdatedJSON(serviceCompDetails.policyConditions),
+ [serviceCompDetails.policyConditions]
+ );
+
+ const form = useForm();
+
+ const actionReqsMap = useMemo(
+ () => buildActionReqsMapFromConditionDef(conditionDefVal),
+ [conditionDefVal]
+ );
+
+ usePruneStaleConditions({
+ formValues,
+ attrName,
+ form,
+ leafResourceTypes,
+ serviceCompDetails,
+ conditionDefVal,
+ actionReqsMap
+ });
const getAccessTypeOptions = () => {
let srcOp = [],
@@ -263,16 +314,9 @@ export default function PolicyPermissionItem(props) {
error = "Please select user/group/role for the selected delegate
Admin";
}
if (policyConditionVal) {
- for (const key in policyConditionVal) {
- if (
- policyConditionVal[key] == null ||
- policyConditionVal[key] == ""
- ) {
- delete policyConditionVal[key];
- }
- }
+ const newConditions = getCleanConditions(policyConditionVal);
if (
- Object.keys(policyConditionVal).length != 0 &&
+ Object.keys(newConditions).length != 0 &&
!users &&
!grps &&
!roles
@@ -445,6 +489,7 @@ export default function PolicyPermissionItem(props) {
serviceCompDetails?.policyConditions?.length >
0 && (
<td key={colName} className="align-middle">
+ {/* Per-row conditions: Editable +
actionFilterContext for action-matches */}
<Field
className="form-control"
name={`${name}.conditions`}
@@ -460,10 +505,20 @@ export default function PolicyPermissionItem(props) {
{...input}
placement="auto"
type="custom"
-
conditionDefVal={policyConditionUpdatedJSON(
- serviceCompDetails.policyConditions
- )}
- selectProps={{ isMulti: true }}
+ conditionDefVal={conditionDefVal}
+
servicedefName={serviceCompDetails?.name}
+ actionFilterContext={{
+ selectedAccessTypes:
+
getSelectedAccessTypesForRow(formValues, attrName, index),
+ leafResourceTypes,
+ accessTypeDefs:
+ serviceCompDetails?.accessTypes
+ }}
+ selectProps={{
+ isMulti: true,
+ // Portal menus to body (see
Editable CONDITION_POPOVER_SELECT_PROPS)
+ // so a long Action list with ALL
permission does not jump the page.
+ }}
/>
</div>
)}