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