From 0a730f43fc2db6bafdbf88a4f258165241e63717 Mon Sep 17 00:00:00 2001 From: Marco Schaub Date: Wed, 27 Jun 2018 09:34:15 +0200 Subject: [PATCH] ADD PermissionVoter for wildcard permission The permissionVoter allows to use hirachical wildcard permissions as test.*.sub1:read,write for method security with @Secured Fixes gh-4611 --- .../security/access/vote/Permission.java | 89 ++++++++++ .../security/access/vote/PermissionVoter.java | 89 ++++++++++ .../access/vote/PermissionVoterTests.java | 152 ++++++++++++++++++ 3 files changed, 330 insertions(+) create mode 100644 core/src/main/java/org/springframework/security/access/vote/Permission.java create mode 100644 core/src/main/java/org/springframework/security/access/vote/PermissionVoter.java create mode 100644 core/src/test/java/org/springframework/security/access/vote/PermissionVoterTests.java diff --git a/core/src/main/java/org/springframework/security/access/vote/Permission.java b/core/src/main/java/org/springframework/security/access/vote/Permission.java new file mode 100644 index 00000000000..06d8d350658 --- /dev/null +++ b/core/src/main/java/org/springframework/security/access/vote/Permission.java @@ -0,0 +1,89 @@ +/* + * Copyright 2002-2018 the original author or authors. + * + * Licensed 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.springframework.security.access.vote; + +import java.util.*; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.util.AntPathMatcher; + +/** + * A {@link GrantedAuthority} which represents a hirachical security permission based on a permission string. A + * permission consists at least of a path, can contain permission tokens and instance object identifiers. The three + * token groups (path, permission object identifier) are seperated with a ":". The levels of the path are seperated with + * a ".". Multiple permission token or object identifiers are seperated with a ",". * Example: + * + * + * @author Marco Schaub + */ +class Permission implements GrantedAuthority { + + public static final String PERMISSION_SEPERATOR = ":"; + public static final String PATH_SEPERATOR = "."; + public static final String WILDCARD = "*"; + private static final AntPathMatcher PATH_MATCHER = new AntPathMatcher("."); + + private final String permissionString; + private Set instanceObjects = new HashSet(Arrays.asList(WILDCARD)); + private Set permissionTokens = new HashSet(Arrays.asList(WILDCARD)); + private final String path; + + /** + * Creates a {@code Permission} using the given permissionString + * + * @param permissionString + */ + public Permission(String permissionString) { + this.permissionString = permissionString; + String[] permissionParts = permissionString.split(PERMISSION_SEPERATOR); + switch (permissionParts.length) { + case 3: + instanceObjects = new HashSet(Arrays.asList(permissionParts[2].split(","))); + case 2: + permissionTokens = new HashSet(Arrays.asList(permissionParts[1].split(","))); + case 1: + path = permissionParts[0]; + break; + default: + throw new IllegalArgumentException(); + } + } + + @Override + public String getAuthority() { + return permissionString; + } + + /** + * Compares the current {@code Permission} to another given {@code Permission}. + * + * @param requiredPermission The other {@code Permission} which should be implied by the current {@code Permission}. + * @return true if the current {@code Permission} implies the given other {@code Permission} + */ + public boolean implies(Permission requiredPermission) { + if (PATH_MATCHER.matchStart(path, requiredPermission.path)) { + if (requiredPermission.permissionTokens.contains(WILDCARD) || permissionTokens.contains(WILDCARD) || permissionTokens.containsAll(requiredPermission.permissionTokens)) { + if (requiredPermission.instanceObjects.contains(WILDCARD) || instanceObjects.contains(WILDCARD) || instanceObjects.containsAll(requiredPermission.instanceObjects)) { + return true; + } + } + } + return false; + } +} diff --git a/core/src/main/java/org/springframework/security/access/vote/PermissionVoter.java b/core/src/main/java/org/springframework/security/access/vote/PermissionVoter.java new file mode 100644 index 00000000000..0f25747158b --- /dev/null +++ b/core/src/main/java/org/springframework/security/access/vote/PermissionVoter.java @@ -0,0 +1,89 @@ +/* + * Copyright 2002-2018 the original author or authors. + * + * Licensed 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.springframework.security.access.vote; + +import java.util.Arrays; +import java.util.Collection; +import java.util.HashMap; +import java.util.List; +import java.util.stream.Collectors; +import org.springframework.security.access.AccessDecisionVoter; +import org.springframework.security.access.ConfigAttribute; +import org.springframework.security.access.SecurityConfig; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.GrantedAuthority; + +/** + * Votes if a given given user permission string implies a defined permission string. This voter can be used instead of + * a {@link org.springframework.security.access.vote.RoleVoter} when working with permissions. The voter turns and keeps + * given permissions strings into {@link Permission} for easier processing. + * + * @author Marco Schaub + */ +public class PermissionVoter implements AccessDecisionVoter { + + private static final HashMap PERMISSION_MAP = new HashMap(); + + @Override + public boolean supports(ConfigAttribute attribute) { + return attribute.getAttribute() != null && attribute instanceof SecurityConfig; + } + + @Override + public boolean supports(Class clazz) { + return true; + } + + @Override + public int vote(Authentication authentication, Object object, Collection attributes) { + if (authentication != null) { + for (ConfigAttribute requiredPermissionString : attributes) { + Permission requiredPermission = getPermissionForString(requiredPermissionString.getAttribute()); + for (GrantedAuthority grantedAuthority : authentication.getAuthorities()) { + Permission grantedPermission = getPermissionForString(grantedAuthority.getAuthority()); + if (grantedPermission.implies(requiredPermission)) { + return ACCESS_GRANTED; + } + } + } + } + return ACCESS_DENIED; + } + + /** + * A static method to verify a permission based access programmatically. If multiple permissions Strings are + * supplied, an OR evaluation will be made. + * + * @param authentication A {@link Authentication} object. + * @param requiredPermissions One or multiple permissions to check for. + * @return true if the {@code Authentication} contains a {@code GrantedAuthority} which implies one of the given + * permission strings. + */ + public static boolean vote(Authentication authentication, String... requiredPermissions) { + List attributes = Arrays.stream(requiredPermissions).map(permissionString -> new SecurityConfig(permissionString)).collect(Collectors.toList()); + int decision = new PermissionVoter().vote(authentication, null, attributes); + return decision > 0; + } + + private static Permission getPermissionForString(String permissionString) { + Permission permission = PERMISSION_MAP.get(permissionString); + if (permission == null) { + permission = new Permission(permissionString); + PERMISSION_MAP.put(permissionString, permission); + } + return permission; + } +} diff --git a/core/src/test/java/org/springframework/security/access/vote/PermissionVoterTests.java b/core/src/test/java/org/springframework/security/access/vote/PermissionVoterTests.java new file mode 100644 index 00000000000..00c688cb501 --- /dev/null +++ b/core/src/test/java/org/springframework/security/access/vote/PermissionVoterTests.java @@ -0,0 +1,152 @@ +/* + * Copyright 2002-2018 the original author or authors. + * + * Licensed 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.springframework.security.access.vote; + +import static org.assertj.core.api.Assertions.assertThat; +import org.junit.Test; +import org.springframework.security.access.AccessDecisionVoter; +import org.springframework.security.access.SecurityConfig; +import org.springframework.security.authentication.TestingAuthenticationToken; +import org.springframework.security.core.Authentication; + +/** + * + * @author Marco Schaub + */ +public class PermissionVoterTests { + + @Test + public void nullAuthenticationDenies() { + PermissionVoter voter = new PermissionVoter(); + Authentication userAB = null; + assertThat(voter.vote(userAB, this, SecurityConfig.createList("test"))).isEqualTo(AccessDecisionVoter.ACCESS_DENIED); + } + + @Test + public void matchingSinglePathPermission() { + PermissionVoter voter = new PermissionVoter(); + Authentication userAB = new TestingAuthenticationToken("user", "pass", "test", "B"); + assertThat(voter.vote(userAB, this, SecurityConfig.createList("test"))).isEqualTo(AccessDecisionVoter.ACCESS_GRANTED); + } + + @Test + public void nonMatchingSinglePathPermission() { + PermissionVoter voter = new PermissionVoter(); + Authentication userAB = new TestingAuthenticationToken("user", "pass", "A", "B"); + assertThat(voter.vote(userAB, this, SecurityConfig.createList("test"))).isEqualTo(AccessDecisionVoter.ACCESS_DENIED); + } + + @Test + public void matchingMultiPathPermission() { + PermissionVoter voter = new PermissionVoter(); + Authentication userAB = new TestingAuthenticationToken("user", "pass", "test.module1.sub1", "B"); + assertThat(voter.vote(userAB, this, SecurityConfig.createList("test.module1.sub1"))).isEqualTo(AccessDecisionVoter.ACCESS_GRANTED); + } + + @Test + public void nonMatchingMultiPathPermission() { + PermissionVoter voter = new PermissionVoter(); + Authentication userAB = new TestingAuthenticationToken("user", "pass", "test.module1.sub1", "B"); + assertThat(voter.vote(userAB, this, SecurityConfig.createList("test.module1.sub2"))).isEqualTo(AccessDecisionVoter.ACCESS_DENIED); + } + + @Test + public void matchingPathPermissionSingleLevelWildcard() { + PermissionVoter voter = new PermissionVoter(); + Authentication userAB = new TestingAuthenticationToken("user", "pass", "test.*.sub1", "B"); + assertThat(voter.vote(userAB, this, SecurityConfig.createList("test.modul1.sub1"))).isEqualTo(AccessDecisionVoter.ACCESS_GRANTED); + assertThat(voter.vote(userAB, this, SecurityConfig.createList("test.modul2.sub1"))).isEqualTo(AccessDecisionVoter.ACCESS_GRANTED); + } + + @Test + public void nonMatchingPathPermissionSingleLevelWildcard() { + PermissionVoter voter = new PermissionVoter(); + Authentication userAB = new TestingAuthenticationToken("user", "pass", "test.*.sub1", "B"); + assertThat(voter.vote(userAB, this, SecurityConfig.createList("test.modul1.sub2"))).isEqualTo(AccessDecisionVoter.ACCESS_DENIED); + assertThat(voter.vote(userAB, this, SecurityConfig.createList("test.modul2.sub2"))).isEqualTo(AccessDecisionVoter.ACCESS_DENIED); + } + + @Test + public void matchingPathPermissionMultiLevelWildcard() { + PermissionVoter voter = new PermissionVoter(); + Authentication userAB = new TestingAuthenticationToken("user", "pass", "test.**", "B"); + assertThat(voter.vote(userAB, this, SecurityConfig.createList("test.modul1.sub1.config1"))).isEqualTo(AccessDecisionVoter.ACCESS_GRANTED); + assertThat(voter.vote(userAB, this, SecurityConfig.createList("test.modul2.sub2.config1"))).isEqualTo(AccessDecisionVoter.ACCESS_GRANTED); + } + + @Test + public void matchingPermissionToken() { + PermissionVoter voter = new PermissionVoter(); + Authentication userAB = new TestingAuthenticationToken("user", "pass", "test:read,write", "B"); + assertThat(voter.vote(userAB, this, SecurityConfig.createList("test:read"))).isEqualTo(AccessDecisionVoter.ACCESS_GRANTED); + assertThat(voter.vote(userAB, this, SecurityConfig.createList("test:write"))).isEqualTo(AccessDecisionVoter.ACCESS_GRANTED); + } + + @Test + public void nonMatchingPermissionToken() { + PermissionVoter voter = new PermissionVoter(); + Authentication userAB = new TestingAuthenticationToken("user", "pass", "test:read,write", "B"); + assertThat(voter.vote(userAB, this, SecurityConfig.createList("test:delete"))).isEqualTo(AccessDecisionVoter.ACCESS_DENIED); + } + + @Test + public void matchingImpliedWildcardPermissionToken() { + PermissionVoter voter = new PermissionVoter(); + Authentication userAB = new TestingAuthenticationToken("user", "pass", "test", "B"); + assertThat(voter.vote(userAB, this, SecurityConfig.createList("test:read"))).isEqualTo(AccessDecisionVoter.ACCESS_GRANTED); + assertThat(voter.vote(userAB, this, SecurityConfig.createList("test:write"))).isEqualTo(AccessDecisionVoter.ACCESS_GRANTED); + } + + @Test + public void matchingWildcardPermissionToken() { + PermissionVoter voter = new PermissionVoter(); + Authentication userAB = new TestingAuthenticationToken("user", "pass", "test:*", "B"); + assertThat(voter.vote(userAB, this, SecurityConfig.createList("test:read"))).isEqualTo(AccessDecisionVoter.ACCESS_GRANTED); + assertThat(voter.vote(userAB, this, SecurityConfig.createList("test:write"))).isEqualTo(AccessDecisionVoter.ACCESS_GRANTED); + } + + @Test + public void matchingInstanceObjectToken() { + PermissionVoter voter = new PermissionVoter(); + Authentication userAB = new TestingAuthenticationToken("user", "pass", "test:*:500", "B"); + assertThat(voter.vote(userAB, this, SecurityConfig.createList("test:read:500"))).isEqualTo(AccessDecisionVoter.ACCESS_GRANTED); + assertThat(voter.vote(userAB, this, SecurityConfig.createList("test:write:500"))).isEqualTo(AccessDecisionVoter.ACCESS_GRANTED); + } + + @Test + public void matchingImpliedWildcardInstanceObjectToken() { + PermissionVoter voter = new PermissionVoter(); + Authentication userAB = new TestingAuthenticationToken("user", "pass", "test:*", "B"); + assertThat(voter.vote(userAB, this, SecurityConfig.createList("test:read:500"))).isEqualTo(AccessDecisionVoter.ACCESS_GRANTED); + assertThat(voter.vote(userAB, this, SecurityConfig.createList("test:write:500"))).isEqualTo(AccessDecisionVoter.ACCESS_GRANTED); + } + + @Test + public void nonMatchingInstanceObjectToken() { + PermissionVoter voter = new PermissionVoter(); + Authentication userAB = new TestingAuthenticationToken("user", "pass", "test:*:500", "B"); + assertThat(voter.vote(userAB, this, SecurityConfig.createList("test:read:501"))).isEqualTo(AccessDecisionVoter.ACCESS_DENIED); + } + + @Test + public void matchingCombinations() { + PermissionVoter voter = new PermissionVoter(); + Authentication userAB = new TestingAuthenticationToken("user", "pass", "test.*.sub1.**:write,read:500", "B"); + assertThat(voter.vote(userAB, this, SecurityConfig.createList("test.module1.sub1:read"))).isEqualTo(AccessDecisionVoter.ACCESS_GRANTED); + assertThat(voter.vote(userAB, this, SecurityConfig.createList("test.module2.sub1.config1:write:500"))).isEqualTo(AccessDecisionVoter.ACCESS_GRANTED); + } + +}