From 4ec75781b7dd3fce52aebb9f25921a81f8444cdb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9=20Pfeuffer?= Date: Thu, 1 Oct 2020 09:39:51 +0200 Subject: [PATCH] Add scope from role for api token realm --- .../lifecycle/modules/ScmSecurityModule.java | 9 +- .../java/sonia/scm/security/ApiKeyRealm.java | 23 +++- .../java/sonia/scm/security/DefaultRealm.java | 32 ++++-- .../scm/security/ScmPermissionResolver.java | 34 ++++++ .../scm/security/ScmWildcardPermission.java | 105 +++++++++++++++++ .../main/java/sonia/scm/security/Scopes.java | 80 ++++++------- .../sonia/scm/security/ApiKeyRealmTest.java | 105 +++++++++++++++++ .../security/ScmWildcardPermissionTest.java | 106 ++++++++++++++++++ .../java/sonia/scm/security/ScopesTest.java | 67 ++++++----- 9 files changed, 469 insertions(+), 92 deletions(-) create mode 100644 scm-webapp/src/main/java/sonia/scm/security/ScmPermissionResolver.java create mode 100644 scm-webapp/src/main/java/sonia/scm/security/ScmWildcardPermission.java create mode 100644 scm-webapp/src/test/java/sonia/scm/security/ApiKeyRealmTest.java create mode 100644 scm-webapp/src/test/java/sonia/scm/security/ScmWildcardPermissionTest.java diff --git a/scm-webapp/src/main/java/sonia/scm/lifecycle/modules/ScmSecurityModule.java b/scm-webapp/src/main/java/sonia/scm/lifecycle/modules/ScmSecurityModule.java index cfa5b10f6e..985c53ddbd 100644 --- a/scm-webapp/src/main/java/sonia/scm/lifecycle/modules/ScmSecurityModule.java +++ b/scm-webapp/src/main/java/sonia/scm/lifecycle/modules/ScmSecurityModule.java @@ -21,7 +21,7 @@ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE * SOFTWARE. */ - + package sonia.scm.lifecycle.modules; //~--- non-JDK imports -------------------------------------------------------- @@ -33,6 +33,7 @@ import org.apache.shiro.authc.credential.DefaultPasswordService; import org.apache.shiro.authc.credential.PasswordService; import org.apache.shiro.authc.pam.AuthenticationStrategy; import org.apache.shiro.authc.pam.ModularRealmAuthenticator; +import org.apache.shiro.authz.permission.PermissionResolver; import org.apache.shiro.crypto.hash.DefaultHashService; import org.apache.shiro.guice.web.ShiroWebModule; import org.apache.shiro.realm.Realm; @@ -48,6 +49,7 @@ import javax.servlet.ServletContext; import org.apache.shiro.mgt.RememberMeManager; import sonia.scm.security.DisabledRememberMeManager; import sonia.scm.security.ScmAtLeastOneSuccessfulStrategy; +import sonia.scm.security.ScmPermissionResolver; /** * @@ -94,7 +96,7 @@ public class ScmSecurityModule extends ShiroWebModule // expose password service to global injector expose(PasswordService.class); - + // disable remember me cookie generation bind(RememberMeManager.class).to(DisabledRememberMeManager.class); @@ -102,6 +104,7 @@ public class ScmSecurityModule extends ShiroWebModule bind(ModularRealmAuthenticator.class); bind(Authenticator.class).to(ModularRealmAuthenticator.class); bind(AuthenticationStrategy.class).to(ScmAtLeastOneSuccessfulStrategy.class); + bind(PermissionResolver.class).to(ScmPermissionResolver.class); // bind realm for (Class realm : extensionProcessor.byExtensionPoint(Realm.class)) @@ -116,7 +119,7 @@ public class ScmSecurityModule extends ShiroWebModule // disable access to mustache resources addFilterChain("/**.mustache", filterConfig(ROLES, "nobody")); - + // disable session addFilterChain("/**", NO_SESSION_CREATION); } diff --git a/scm-webapp/src/main/java/sonia/scm/security/ApiKeyRealm.java b/scm-webapp/src/main/java/sonia/scm/security/ApiKeyRealm.java index 4cbe9abdcc..6ce29e91fb 100644 --- a/scm-webapp/src/main/java/sonia/scm/security/ApiKeyRealm.java +++ b/scm-webapp/src/main/java/sonia/scm/security/ApiKeyRealm.java @@ -24,18 +24,19 @@ package sonia.scm.security; -import org.apache.shiro.authc.AuthenticationException; import org.apache.shiro.authc.AuthenticationInfo; import org.apache.shiro.authc.AuthenticationToken; +import org.apache.shiro.authc.UsernamePasswordToken; import org.apache.shiro.authc.credential.AllowAllCredentialsMatcher; +import org.apache.shiro.authz.AuthorizationException; import org.apache.shiro.realm.AuthenticatingRealm; import sonia.scm.plugin.Extension; +import sonia.scm.repository.RepositoryRole; +import sonia.scm.repository.RepositoryRoleManager; import javax.inject.Inject; import javax.inject.Singleton; -import java.util.Optional; - import static com.google.common.base.Preconditions.checkArgument; @Singleton @@ -44,24 +45,36 @@ public class ApiKeyRealm extends AuthenticatingRealm { private final ApiKeyService apiKeyService; private final DAORealmHelper helper; + private final RepositoryRoleManager repositoryRoleManager; @Inject - public ApiKeyRealm(ApiKeyService apiKeyService, DAORealmHelperFactory helperFactory) { + public ApiKeyRealm(ApiKeyService apiKeyService, DAORealmHelperFactory helperFactory, RepositoryRoleManager repositoryRoleManager) { this.apiKeyService = apiKeyService; this.helper = helperFactory.create("ApiTokenRealm"); + this.repositoryRoleManager = repositoryRoleManager; setAuthenticationTokenClass(BearerToken.class); setCredentialsMatcher(new AllowAllCredentialsMatcher()); } + @Override + public boolean supports(AuthenticationToken token) { + return token instanceof UsernamePasswordToken || token instanceof BearerToken; + } + @Override protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken token) { checkArgument(token instanceof BearerToken, "%s is required", BearerToken.class); BearerToken bt = (BearerToken) token; ApiKeyService.CheckResult check = apiKeyService.check(bt.getCredentials()); + RepositoryRole repositoryRole = repositoryRoleManager.get(check.getRole()); + if (repositoryRole == null) { + throw new AuthorizationException("api key has unknown role: " + check.getRole()); + } + String scope = "repository:" + String.join(",", repositoryRole.getVerbs()) + ":*"; return helper .authenticationInfoBuilder(check.getUser()) .withSessionId(bt.getPrincipal()) -// .withScope() + .withScope(Scope.valueOf(scope)) .build(); } } diff --git a/scm-webapp/src/main/java/sonia/scm/security/DefaultRealm.java b/scm-webapp/src/main/java/sonia/scm/security/DefaultRealm.java index d7306a6247..864aac3c98 100644 --- a/scm-webapp/src/main/java/sonia/scm/security/DefaultRealm.java +++ b/scm-webapp/src/main/java/sonia/scm/security/DefaultRealm.java @@ -21,7 +21,7 @@ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE * SOFTWARE. */ - + package sonia.scm.security; //~--- non-JDK imports -------------------------------------------------------- @@ -57,9 +57,9 @@ import java.util.Set; @Singleton public class DefaultRealm extends AuthorizingRealm { - + private static final String SEPARATOR = System.getProperty("line.separator", "\n"); - + /** * the logger for DefaultRealm */ @@ -68,6 +68,7 @@ public class DefaultRealm extends AuthorizingRealm /** Field description */ @VisibleForTesting static final String REALM = "DefaultRealm"; + private final ScmPermissionResolver permissionResolver; //~--- constructors --------------------------------------------------------- @@ -90,11 +91,18 @@ public class DefaultRealm extends AuthorizingRealm matcher.setPasswordService(service); setCredentialsMatcher(helper.wrapCredentialsMatcher(matcher)); setAuthenticationTokenClass(UsernamePasswordToken.class); + permissionResolver = new ScmPermissionResolver(); + setPermissionResolver(permissionResolver); // we cache in the AuthorizationCollector setCachingEnabled(false); } + @Override + public ScmPermissionResolver getPermissionResolver() { + return permissionResolver; + } + //~--- methods -------------------------------------------------------------- /** @@ -168,13 +176,13 @@ public class DefaultRealm extends AuthorizingRealm private void log( PrincipalCollection collection, AuthorizationInfo original, AuthorizationInfo filtered ) { StringBuilder buffer = new StringBuilder("authorization summary: "); - + buffer.append(SEPARATOR).append("username : ").append(collection.getPrimaryPrincipal()); buffer.append(SEPARATOR).append("roles : "); - append(buffer, original.getRoles()); + append(buffer, original.getRoles()); buffer.append(SEPARATOR).append("scope : "); - append(buffer, collection.oneByType(Scope.class)); - + append(buffer, collection.oneByType(Scope.class)); + if ( filtered != null ) { buffer.append(SEPARATOR).append("permissions (filtered by scope): "); append(buffer, filtered); @@ -183,21 +191,21 @@ public class DefaultRealm extends AuthorizingRealm buffer.append(SEPARATOR).append("permissions: "); } append(buffer, original); - + LOG.trace(buffer.toString()); } - + private void append(StringBuilder buffer, AuthorizationInfo authz) { append(buffer, authz.getStringPermissions()); - append(buffer, authz.getObjectPermissions()); + append(buffer, authz.getObjectPermissions()); } - + private void append(StringBuilder buffer, Iterable iterable){ if (iterable != null){ for ( Object item : iterable ) { buffer.append(SEPARATOR).append(" - ").append(item); - } + } } } diff --git a/scm-webapp/src/main/java/sonia/scm/security/ScmPermissionResolver.java b/scm-webapp/src/main/java/sonia/scm/security/ScmPermissionResolver.java new file mode 100644 index 0000000000..f9689b5d7a --- /dev/null +++ b/scm-webapp/src/main/java/sonia/scm/security/ScmPermissionResolver.java @@ -0,0 +1,34 @@ +/* + * MIT License + * + * Copyright (c) 2020-present Cloudogu GmbH and Contributors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package sonia.scm.security; + +import org.apache.shiro.authz.permission.PermissionResolver; + +public class ScmPermissionResolver implements PermissionResolver { + @Override + public ScmWildcardPermission resolvePermission(String permissionString) { + return new ScmWildcardPermission(permissionString); + } +} diff --git a/scm-webapp/src/main/java/sonia/scm/security/ScmWildcardPermission.java b/scm-webapp/src/main/java/sonia/scm/security/ScmWildcardPermission.java new file mode 100644 index 0000000000..2b9193e14e --- /dev/null +++ b/scm-webapp/src/main/java/sonia/scm/security/ScmWildcardPermission.java @@ -0,0 +1,105 @@ +/* + * MIT License + * + * Copyright (c) 2020-present Cloudogu GmbH and Contributors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package sonia.scm.security; + +import org.apache.commons.collections.CollectionUtils; +import org.apache.shiro.authz.permission.WildcardPermission; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; +import java.util.Optional; +import java.util.Set; + +import static java.util.Collections.singleton; +import static java.util.Optional.empty; +import static java.util.Optional.of; + +public class ScmWildcardPermission extends WildcardPermission { + public ScmWildcardPermission(String permissionString) { + super(permissionString); + } + + Collection limit(Scope scope) { + Collection result = new ArrayList<>(); + for (String s : scope) { + limit(s).ifPresent(result::add); + } + return result; + } + + Optional limit(String scope) { + return limit(new ScmWildcardPermission(scope)); + } + + Optional limit(ScmWildcardPermission scope) { + if (this.implies(scope)) { + return of(scope); + } + if (scope.implies(this)) { + return of(this); + } + + final List> theseParts = getParts(); + final List> scopeParts = scope.getParts(); + + if (!getEntries(theseParts, 0).equals(getEntries(scopeParts, 0))) { + return empty(); + } + + String type = getEntries(scopeParts, 0).iterator().next(); + Collection verbs = intersect(theseParts, scopeParts, 1); + Collection ids = intersect(theseParts, scopeParts, 2); + + if (verbs.isEmpty() || ids.isEmpty()) { + return empty(); + } + + return of(new ScmWildcardPermission(type + ":" + String.join(",", verbs) + ":" + String.join(",", ids))); + } + + private Collection intersect(List> theseParts, List> scopeParts, int position) { + final Set theseEntries = getEntries(theseParts, position); + final Set scopeEntries = getEntries(scopeParts, position); + if (isWildcard(theseEntries)) { + return scopeEntries; + } + if (isWildcard(scopeEntries)) { + return theseEntries; + } + return CollectionUtils.intersection(theseEntries, scopeEntries); + } + + private Set getEntries(List> theseParts, int position) { + if (position >= theseParts.size()) { + return singleton(WILDCARD_TOKEN); + } + return theseParts.get(position); + } + + private boolean isWildcard(Set entries) { + return entries.size() == 1 && entries.contains(WILDCARD_TOKEN); + } +} diff --git a/scm-webapp/src/main/java/sonia/scm/security/Scopes.java b/scm-webapp/src/main/java/sonia/scm/security/Scopes.java index 81207b06fa..8e4abbbd7d 100644 --- a/scm-webapp/src/main/java/sonia/scm/security/Scopes.java +++ b/scm-webapp/src/main/java/sonia/scm/security/Scopes.java @@ -21,28 +21,26 @@ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE * SOFTWARE. */ - + package sonia.scm.security; import com.google.common.collect.Collections2; -import com.google.common.collect.ImmutableList; import com.google.common.collect.ImmutableSet; import com.google.common.collect.Lists; +import org.apache.shiro.authz.AuthorizationInfo; +import org.apache.shiro.authz.Permission; +import org.apache.shiro.authz.SimpleAuthorizationInfo; + import java.util.Collection; import java.util.Collections; import java.util.List; import java.util.Map; import java.util.Set; -import java.util.function.Predicate; import java.util.stream.Collectors; -import org.apache.shiro.authz.AuthorizationInfo; -import org.apache.shiro.authz.Permission; -import org.apache.shiro.authz.SimpleAuthorizationInfo; -import org.apache.shiro.authz.permission.PermissionResolver; /** * Util methods for {@link Scope}. - * + * * @author Sebastian Sdorra * @since 2.0.0 */ @@ -50,16 +48,16 @@ public final class Scopes { /** Key of scope in the claims of a token **/ public final static String CLAIMS_KEY = "scope"; - + private Scopes() { } - + /** * Returns scope from a token claims. If the claims does not contain a scope object, the method will return an empty * scope. - * + * * @param claims token claims - * + * * @return scope of claims */ @SuppressWarnings("unchecked") @@ -70,11 +68,11 @@ public final class Scopes { } return scope; } - + /** - * Adds a scope to a token claims. The method will add the scope to the claims, if the scope is non null and not + * Adds a scope to a token claims. The method will add the scope to the claims, if the scope is non null and not * empty. - * + * * @param claims token claims * @param scope scope */ @@ -83,55 +81,51 @@ public final class Scopes { claims.put(CLAIMS_KEY, ImmutableSet.copyOf(scope)); } } - + /** - * Filter permissions from {@link AuthorizationInfo} by scope values. Only permission definitions from the scope will - * be returned and only if a permission from the {@link AuthorizationInfo} implies the requested scope permission. - * + * Limit permissions from {@link AuthorizationInfo} by scope values. Permission definitions from the + * {@link AuthorizationInfo} will be returned, if a permission from the scope implies the original permission. + * If a permission from the {@link AuthorizationInfo} exceeds the permissions defined by the scope, it will + * be reduced. If the latter computation results in an empty permission, it will be omitted. + * * @param resolver permission resolver * @param authz authorization info * @param scope scope - * - * @return filtered {@link AuthorizationInfo} + * + * @return limited {@link AuthorizationInfo} */ - public static AuthorizationInfo filter(PermissionResolver resolver, AuthorizationInfo authz, Scope scope) { + public static AuthorizationInfo filter(ScmPermissionResolver resolver, AuthorizationInfo authz, Scope scope) { List authzPermissions = authzPermissions(resolver, authz); - Predicate predicate = implies(authzPermissions); - Set filteredPermissions = resolve(resolver, ImmutableList.copyOf(scope)) + Set filteredPermissions = authzPermissions .stream() - .filter(predicate) + .map(p -> asScmWildcardPermission(p)) + .map(p -> p.limit(scope)) + .flatMap(Collection::stream) .collect(Collectors.toSet()); - + Set roles = ImmutableSet.copyOf(nullToEmpty(authz.getRoles())); SimpleAuthorizationInfo authzFiltered = new SimpleAuthorizationInfo(roles); authzFiltered.setObjectPermissions(filteredPermissions); return authzFiltered; } - + + public static ScmWildcardPermission asScmWildcardPermission(Permission p) { + return p instanceof ScmWildcardPermission ? (ScmWildcardPermission) p : new ScmWildcardPermission(p.toString()); + } + private static Collection nullToEmpty(Collection collection) { return collection != null ? collection : Collections.emptySet(); } - - private static Collection resolve(PermissionResolver resolver, Collection permissions) { + + private static Collection resolve(ScmPermissionResolver resolver, Collection permissions) { return Collections2.transform(nullToEmpty(permissions), resolver::resolvePermission); } - - private static Predicate implies(Iterable authzPermissions){ - return (scopePermission) -> { - for ( Permission authzPermission : authzPermissions ) { - if (authzPermission.implies(scopePermission)) { - return true; - } - } - return false; - }; - } - - private static List authzPermissions(PermissionResolver resolver, AuthorizationInfo authz){ + + private static List authzPermissions(ScmPermissionResolver resolver, AuthorizationInfo authz){ List authzPermissions = Lists.newArrayList(); authzPermissions.addAll(nullToEmpty(authz.getObjectPermissions())); authzPermissions.addAll(resolve(resolver, authz.getStringPermissions())); return authzPermissions; } - + } diff --git a/scm-webapp/src/test/java/sonia/scm/security/ApiKeyRealmTest.java b/scm-webapp/src/test/java/sonia/scm/security/ApiKeyRealmTest.java new file mode 100644 index 0000000000..48a014dfb2 --- /dev/null +++ b/scm-webapp/src/test/java/sonia/scm/security/ApiKeyRealmTest.java @@ -0,0 +1,105 @@ +/* + * MIT License + * + * Copyright (c) 2020-present Cloudogu GmbH and Contributors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package sonia.scm.security; + +import org.apache.shiro.authc.AuthenticationToken; +import org.apache.shiro.authz.AuthorizationException; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Answers; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import sonia.scm.repository.RepositoryRole; +import sonia.scm.repository.RepositoryRoleManager; + +import static java.util.Collections.singleton; +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.argThat; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static sonia.scm.security.BearerToken.valueOf; + +@ExtendWith(MockitoExtension.class) +class ApiKeyRealmTest { + + @Mock + ApiKeyService apiKeyService; + @Mock + DAORealmHelperFactory helperFactory; + @Mock + DAORealmHelper helper; + @Mock(answer = Answers.RETURNS_SELF) + DAORealmHelper.AuthenticationInfoBuilder authenticationInfoBuilder; + @Mock + RepositoryRoleManager repositoryRoleManager; + + ApiKeyRealm realm; + + @BeforeEach + void initRealmHelper() { + lenient().when(helperFactory.create("ApiTokenRealm")).thenReturn(helper); + lenient().when(helper.authenticationInfoBuilder(any())).thenReturn(authenticationInfoBuilder); + realm = new ApiKeyRealm(apiKeyService, helperFactory, repositoryRoleManager); + } + + @Test + void shouldCreateAuthenticationWithScope() { + when(apiKeyService.check("towel")).thenReturn(new ApiKeyService.CheckResult("ford", "READ")); + when(repositoryRoleManager.get("READ")).thenReturn(new RepositoryRole("guide", singleton("read"), "system")); + + realm.doGetAuthenticationInfo(valueOf("towel")); + + verify(helper).authenticationInfoBuilder("ford"); + verifyScopeSet("repository:read:*"); + verify(authenticationInfoBuilder).withSessionId(null); + } + + @Test + void shouldFailWithoutBearerToken() { + AuthenticationToken otherToken = mock(AuthenticationToken.class); + assertThrows(IllegalArgumentException.class, () -> realm.doGetAuthenticationInfo(otherToken)); + } + + @Test + void shouldFailWithUnknownRole() { + when(apiKeyService.check("towel")).thenReturn(new ApiKeyService.CheckResult("ford", "READ")); + when(repositoryRoleManager.get("READ")).thenReturn(null); + + BearerToken token = valueOf("towel"); + assertThrows(AuthorizationException.class, () -> realm.doGetAuthenticationInfo(token)); + } + + void verifyScopeSet(String... permissions) { + verify(authenticationInfoBuilder).withScope(argThat(scope -> { + assertThat(scope).containsExactly(permissions); + return true; + })); + } +} diff --git a/scm-webapp/src/test/java/sonia/scm/security/ScmWildcardPermissionTest.java b/scm-webapp/src/test/java/sonia/scm/security/ScmWildcardPermissionTest.java new file mode 100644 index 0000000000..4977c26914 --- /dev/null +++ b/scm-webapp/src/test/java/sonia/scm/security/ScmWildcardPermissionTest.java @@ -0,0 +1,106 @@ +/* + * MIT License + * + * Copyright (c) 2020-present Cloudogu GmbH and Contributors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package sonia.scm.security; + +import org.junit.jupiter.api.Test; + +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; + +class ScmWildcardPermissionTest { + + @Test + void shouldEliminatePermissionsWithDifferentSubject() { + ScmWildcardPermission permission = new ScmWildcardPermission("user:write:*"); + + Optional limitedPermissions = permission.limit("repository:write:*"); + + assertThat(limitedPermissions).isEmpty(); + } + + @Test + void shouldReturnScopeIfPermissionImpliesScope() { + ScmWildcardPermission permission = new ScmWildcardPermission("*"); + + Optional limitedPermission = permission.limit("repository:read:42"); + + assertThat(limitedPermission).get().hasToString("repository:read:42"); + } + + @Test + void shouldReturnPermissionIfScopeImpliesPermission() { + ScmWildcardPermission permission = new ScmWildcardPermission("repository:read:42"); + + Optional limitedPermission = permission.limit("repository:*:42"); + + assertThat(limitedPermission).get().hasToString("repository:read:42"); + } + + @Test + void shouldLimitExplicitParts() { + ScmWildcardPermission permission = new ScmWildcardPermission("repository:read,write:42,43,44"); + + Optional limitedPermission = permission.limit("repository:read,write,pull:42"); + + assertThat(limitedPermission).get().hasToString("repository:read,write:42"); + } + + @Test + void shouldDetectWildcard() { + ScmWildcardPermission permission = new ScmWildcardPermission("repository:read,write:*"); + + Optional limitedPermission = permission.limit("repository:*:42"); + + assertThat(limitedPermission).get().hasToString("repository:read,write:42"); + } + + @Test + void shouldHandleMissingEntriesAsWildcard() { + ScmWildcardPermission permission = new ScmWildcardPermission("repository:read,write"); + + Optional limitedPermission = permission.limit("repository:*:42"); + + assertThat(limitedPermission).get().hasToString("repository:read,write:42"); + } + + @Test + void shouldEliminateEmptyVerbs() { + ScmWildcardPermission permission = new ScmWildcardPermission("repository:read:42"); + + Optional limitedPermission = permission.limit("repository:pull:42"); + + assertThat(limitedPermission).isEmpty(); + } + + @Test + void shouldEliminateEmptyId() { + ScmWildcardPermission permission = new ScmWildcardPermission("repository:read:42"); + + Optional limitedPermission = permission.limit("repository:read:23"); + + assertThat(limitedPermission).isEmpty(); + } +} diff --git a/scm-webapp/src/test/java/sonia/scm/security/ScopesTest.java b/scm-webapp/src/test/java/sonia/scm/security/ScopesTest.java index b0a98fbf9e..695dd46641 100644 --- a/scm-webapp/src/test/java/sonia/scm/security/ScopesTest.java +++ b/scm-webapp/src/test/java/sonia/scm/security/ScopesTest.java @@ -21,29 +21,32 @@ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE * SOFTWARE. */ - + package sonia.scm.security; import com.google.common.collect.Collections2; import com.google.common.collect.Sets; -import java.util.Set; import org.apache.shiro.authz.AuthorizationInfo; import org.apache.shiro.authz.Permission; import org.apache.shiro.authz.SimpleAuthorizationInfo; -import org.apache.shiro.authz.permission.WildcardPermission; -import org.apache.shiro.authz.permission.WildcardPermissionResolver; import org.junit.Test; -import static org.junit.Assert.*; -import static org.hamcrest.Matchers.*; + +import java.util.Set; + +import static org.hamcrest.Matchers.containsInAnyOrder; +import static org.hamcrest.Matchers.emptyCollectionOf; +import static org.hamcrest.Matchers.is; +import static org.hamcrest.Matchers.nullValue; +import static org.junit.Assert.assertThat; /** * Unit tests for {@link Scopes}. - * + * * @author Sebastian Sdorra */ public class ScopesTest { - private final WildcardPermissionResolver resolver = new WildcardPermissionResolver(); + private final ScmPermissionResolver resolver = new ScmPermissionResolver(); /** * Tests that filter keep roles. @@ -51,11 +54,11 @@ public class ScopesTest { @Test public void testFilterKeepRoles(){ AuthorizationInfo authz = authz("repository:read:123"); - + AuthorizationInfo filtered = Scopes.filter(resolver, authz, Scope.empty()); assertThat(filtered.getRoles(), containsInAnyOrder("unit", "test")); } - + /** * Tests filter with a simple allow. */ @@ -63,10 +66,18 @@ public class ScopesTest { public void testFilterSimpleAllow() { Scope scope = Scope.valueOf("repository:read:123"); AuthorizationInfo authz = authz("repository:*", "user:*:me"); - + assertPermissions(Scopes.filter(resolver, authz, scope), "repository:read:123"); } - + + @Test + public void testFilterX() { + Scope scope = Scope.valueOf("repository:read,write:*"); + AuthorizationInfo authz = authz("repository:*:123"); + + assertPermissions(Scopes.filter(resolver, authz, scope), "repository:read,write:123"); + } + /** * Tests filter with a simple deny. */ @@ -74,12 +85,12 @@ public class ScopesTest { public void testFilterSimpleDeny() { Scope scope = Scope.valueOf("repository:read:123"); AuthorizationInfo authz = authz("user:*:me"); - + AuthorizationInfo filtered = Scopes.filter(resolver, authz, scope); assertThat(filtered.getStringPermissions(), is(nullValue())); assertThat(filtered.getObjectPermissions(), is(emptyCollectionOf(Permission.class))); } - + /** * Tests filter with a multiple scope entries. */ @@ -87,10 +98,10 @@ public class ScopesTest { public void testFilterMultiple() { Scope scope = Scope.valueOf("repo:read,modify:1", "repo:read:2", "repo:*:3", "repo:modify:4"); AuthorizationInfo authz = authz("repo:read:*"); - - assertPermissions(Scopes.filter(resolver, authz, scope), "repo:read:2"); + + assertPermissions(Scopes.filter(resolver, authz, scope), "repo:read:1", "repo:read:2", "repo:read:3"); } - + /** * Tests filter with admin permissions. */ @@ -98,10 +109,10 @@ public class ScopesTest { public void testFilterAdmin(){ Scope scope = Scope.valueOf("repository:*", "user:*:me"); AuthorizationInfo authz = authz("*"); - + assertPermissions(Scopes.filter(resolver, authz, scope), "repository:*", "user:*:me"); } - + /** * Tests filter with requested admin permissions from a non admin. */ @@ -109,29 +120,27 @@ public class ScopesTest { public void testFilterRequestAdmin(){ Scope scope = Scope.valueOf("*"); AuthorizationInfo authz = authz("repository:*"); - - assertThat( - Scopes.filter(resolver, authz, scope).getObjectPermissions(), - is(emptyCollectionOf(Permission.class)) - ); + + assertPermissions(Scopes.filter(resolver, authz, scope), + "repository:*"); } - + private void assertPermissions(AuthorizationInfo authz, Object... permissions) { assertThat(authz.getStringPermissions(), is(nullValue())); assertThat( - Collections2.transform(authz.getObjectPermissions(), Permission::toString), + Collections2.transform(authz.getObjectPermissions(), Permission::toString), containsInAnyOrder(permissions) ); } - + private AuthorizationInfo authz( String... values ) { SimpleAuthorizationInfo info = new SimpleAuthorizationInfo(Sets.newHashSet("unit", "test")); Set permissions = Sets.newLinkedHashSet(); for ( String value : values ) { - permissions.add(new WildcardPermission(value)); + permissions.add(new ScmWildcardPermission(value)); } info.setObjectPermissions(permissions); return info; } -} \ No newline at end of file +}