This is an automated email from the ASF dual-hosted git repository.
rameeshm pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/ranger.git
The following commit(s) were added to refs/heads/master by this push:
new dcf5947fe RANGER-5628: Support for actions in Ozone Service Definition
(#995)
dcf5947fe is described below
commit dcf5947fe96f71e311e3f8d94f0786496cf8adf4
Author: fmorg-git <[email protected]>
AuthorDate: Fri Jun 26 22:01:24 2026 -0700
RANGER-5628: Support for actions in Ozone Service Definition (#995)
* update Ranger policy logic and Ozone authorizer
Generated-By: Cursor, Claude Code and then with additional manual updates.
* add a new matcher for actions
Generated-By: Cursor, Claude Code and then with additional manual updates.
* temporarily use Ozone 2.1.1 release candidate for compilation purposes
Generated-By: Cursor
* Support actions in UI, populate Ozone action choices based on
permissions, and ensure actions wrap around instead of scrolling horizontally
indefinitely. It also fixes latent React hook violations of having useState
within a conditional (CommonComponents.jsx) and a loop (Editable.jsx). It also
ensures that the action field only shows up in the Policy Conditions section,
not on the overall Resource Policy.
Generated-By: Cursor, Claude Code
* update for Ozone service policy definition
Generated-By: Cursor, Claude Code
* pr review comments for copilot: usePruneStaleConditions.js
* pr review comments for copilot: PolicyPermissionItem.jsx
* remove snapshot repository because Ozone 2.1.1 is now released
* pr review updates for copilot
* make junit tests package private to solve pmd violations
(cherry picked from commit 91dafb06c75566da04350a2cc85a8d665aabd66f)
---------
Co-authored-by: Fabian Morgan <[email protected]>
---
.../conditionevaluator/RangerActionMatcher.java | 52 ++
.../ranger/plugin/model/RangerInlinePolicy.java | 20 +-
.../RangerInlinePolicyEvaluator.java | 17 +-
.../plugin/util/RangerActionListMatcher.java | 122 ++++
.../service-defs/ranger-servicedef-ozone.json | 8 +
.../RangerActionMatcherTest.java | 111 ++++
.../plugin/util/RangerActionListMatcherTest.java | 243 +++++++
.../test_inline_policies_ozone.json | 111 ++++
dev-support/checkstyle-suppressions.xml | 1 +
.../ozone/authorizer/RangerOzoneAuthorizer.java | 7 +-
.../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 | 17 +
.../react-webapp/src/components/Editable.jsx | 511 +++++++++------
.../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/GovernedData/Dataset/AccessGrantForm.jsx | 4 +
.../Datashare/DatashareDetailLayout.jsx | 4 +
.../views/PolicyListing/AddUpdatePolicyForm.jsx | 4 +
.../views/PolicyListing/PolicyConditionsComp.jsx | 34 +-
.../views/PolicyListing/PolicyPermissionItem.jsx | 89 ++-
26 files changed, 2340 insertions(+), 226 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..31d522899
--- /dev/null
+++
b/agents-common/src/main/java/org/apache/ranger/plugin/conditionevaluator/RangerActionMatcher.java
@@ -0,0 +1,52 @@
+/*
+ * 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 = new
RangerActionListMatcher(null);
+
+ @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 boolean ret = actionMatcher.isMatch(request != null ?
request.getAction() : null);
+
+ 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 d33eddbd3..0032a674b 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 8f2b319d6..6702166da 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..507de29cf
--- /dev/null
+++
b/agents-common/src/main/java/org/apache/ranger/plugin/util/RangerActionListMatcher.java
@@ -0,0 +1,122 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.apache.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));
+ }
+ }
+ }
+
+ 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) {
+ boolean ret = allowAnyAction;
+
+ if (!ret) {
+ // if action is not available, don't enforce action restrictions
+ if (StringUtils.isBlank(requestAction)) {
+ ret = true;
+ } else {
+ final String actionLower =
requestAction.toLowerCase(Locale.ROOT);
+
+ if (exactActions.contains(actionLower)) {
+ ret = true;
+ } else {
+ for (String prefix : prefixActions) {
+ if (actionLower.startsWith(prefix)) {
+ ret = true;
+ break;
+ }
+ }
+ }
+ }
+ }
+
+ return ret;
+ }
+}
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..5b617308f
--- /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 testNoRequestActionDoesNotEnforce() {
+ final RangerActionMatcher matcher = createMatcher(new String[]
{"PutObject"});
+
+ assertTrue(matcher.isMatched(createRequest(null)));
+ assertTrue(matcher.isMatched(createRequest("")));
+ assertTrue(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..70ef452e5
--- /dev/null
+++
b/agents-common/src/test/java/org/apache/ranger/plugin/util/RangerActionListMatcherTest.java
@@ -0,0 +1,243 @@
+/*
+ * 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 testBlankRequestActionBypassesRestriction() {
+ final RangerActionListMatcher matcher = new RangerActionListMatcher(
+ Arrays.asList("PutObject"));
+
+ // When no action is provided, action restrictions are not enforced
+ assertTrue(matcher.isMatch(null));
+ assertTrue(matcher.isMatch(""));
+ assertTrue(matcher.isMatch(" "));
+ }
+
+ @Test
+ void testBlankEntriesInActionsIgnored() {
+ final RangerActionListMatcher matcher = new RangerActionListMatcher(
+ Arrays.asList("", " ", "PutObject"));
+
+ assertTrue(matcher.isMatch("PutObject"));
+ assertFalse(matcher.isMatch("GetObject"));
+ }
+
+ @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 bb41f4bbd..4c185453d 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": "ALLOW 'write s3v/iceberg/key2;' for user=svc-etl; no action in
request bypasses 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": true, "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 a95423277..48a87921c 100644
--- a/dev-support/checkstyle-suppressions.xml
+++ b/dev-support/checkstyle-suppressions.xml
@@ -91,4 +91,5 @@
<suppress files="PatchSetAccessTypeCategory_J10061" checks="TypeName"/>
<suppress files="PatchTagModulePermission_J10005" checks="TypeName"/>
<suppress files="PatchForAtlasPolicyUpdateForEntityRead_J10064"
checks="TypeName"/>
+ <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 4414f22ad..f7d61fec2 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
@@ -168,7 +168,7 @@ public boolean checkAccess(IOzoneObj ozoneObject,
RequestContext context) {
rangerResource.setOwnerUser(context.getOwnerName());
rangerRequest.setResource(rangerResource);
rangerRequest.setAccessType(accessType);
- rangerRequest.setAction(accessType);
+ rangerRequest.setAction(context.getS3Action());
rangerRequest.setRequestData(resource);
rangerRequest.setClusterName(clusterName);
@@ -313,6 +313,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..3cfc282a4 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 bypass action restriction (allow
through)
+ RequestContext ctxWriteNoAction = reqCtxBuilder
+ .setAclRights(IAccessAuthorizer.ACLType.WRITE)
+ .setRecursiveAccessCheck(false)
+ .setSessionPolicy(sessionPolicy)
+ .setS3Action(null)
+ .build();
+
+ assertTrue(ozoneAuthorizer.checkAccess(key1, ctxWriteNoAction),
+ "session-policy should allow write on key1 when no S3 action
is specified (bypass)");
+ }
}
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 328a4b921..481e660e1 100755
--- a/pom.xml
+++ b/pom.xml
@@ -190,7 +190,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.airlift.version>0.192</presto.airlift.version>
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 053ced0e2..0830f5e1d 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,19 @@ 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 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 c5754ac95..639b25314 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,183 +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.trim();
- 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)}
- 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 [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}
- value={selectedInputVal || null}
- onChange={(e) => {
- setSelectVal(e);
- handleChange(e, m.name);
- }}
- placeholder=""
- width="500px"
- isClearable={false}
- styles={selectInputCustomStyles}
- 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>
+ />
+ </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}
+ />
+ ))}
</>
);
};
@@ -387,7 +484,8 @@ const Editable = (props) => {
options = [],
conditionDefVal,
servicedefName,
- isGDS
+ isGDS,
+ actionFilterContext
} = props;
const initialLoad = useRef(true);
@@ -401,6 +499,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
@@ -427,7 +530,7 @@ const Editable = (props) => {
const displayValue = () => {
let val = "--";
- const selectVal = value;
+ let selectVal = value;
const policyConditionDisplayValue = () => {
let ipRangVal, uiHintVal;
if (selectVal) {
@@ -437,9 +540,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) {
@@ -502,7 +606,6 @@ const Editable = (props) => {
variant="outline-dark"
size="sm"
type="button"
- s
>
<i className="fa-fw fa fa-plus"></i>
</Button>
@@ -612,11 +715,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>
@@ -677,14 +776,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] != "" &&
@@ -752,6 +878,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..35cdcc8fb
--- /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 ea5f33e07..3002fac88 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
@@ -1611,6 +1611,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/GovernedData/Dataset/AccessGrantForm.jsx
b/security-admin/src/main/webapp/react-webapp/src/views/GovernedData/Dataset/AccessGrantForm.jsx
index 2d20250ee..34dfae608 100755
---
a/security-admin/src/main/webapp/react-webapp/src/views/GovernedData/Dataset/AccessGrantForm.jsx
+++
b/security-admin/src/main/webapp/react-webapp/src/views/GovernedData/Dataset/AccessGrantForm.jsx
@@ -32,6 +32,7 @@ import {
isSystemAdmin
} from "../../../utils/XAUtils";
import { fetchApi } from "Utils/fetchAPI";
+import { isPerRowCondition } from "Utils/policyConditionUtils";
import { maxBy, find, isEmpty, isArray, isEqual, isObject } from "lodash";
import userGreyIcon from "../../../images/user-grey.svg";
import groupGreyIcon from "../../../images/group-grey.svg";
@@ -677,6 +678,9 @@ function AccessGrantForm({
</Button>
</div>
{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/GovernedData/Datashare/DatashareDetailLayout.jsx
b/security-admin/src/main/webapp/react-webapp/src/views/GovernedData/Datashare/DatashareDetailLayout.jsx
index dd0a0d631..207cab88f 100755
---
a/security-admin/src/main/webapp/react-webapp/src/views/GovernedData/Datashare/DatashareDetailLayout.jsx
+++
b/security-admin/src/main/webapp/react-webapp/src/views/GovernedData/Datashare/DatashareDetailLayout.jsx
@@ -49,6 +49,7 @@ import {
policyConditionUpdatedJSON,
capitalizeFirstLetter
} from "Utils/XAUtils";
+import { isPerRowCondition } from "Utils/policyConditionUtils";
import XATableLayout from "Components/XATableLayout";
import moment from "moment-timezone";
import { getServiceDef } from "Utils/appState";
@@ -1340,6 +1341,9 @@ const DatashareDetailLayout = () => {
</div>
{values.conditions !== undefined &&
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/AddUpdatePolicyForm.jsx
b/security-admin/src/main/webapp/react-webapp/src/views/PolicyListing/AddUpdatePolicyForm.jsx
index 98d7d3150..48797a5c0 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
@@ -63,6 +63,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,
@@ -1532,6 +1533,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 8b2240e2e..6e3dbfb94 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
@@ -29,24 +29,39 @@ 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();
};
@@ -132,13 +147,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}>
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 69325c2dd..942cdd674 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
@@ -451,6 +495,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`}
@@ -466,10 +511,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>
)}