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>
                                   )}

Reply via email to