Skip to content

Commit

Permalink
Merge pull request quarkusio#42809 from michalvavrik/feature/test-sec…
Browse files Browse the repository at this point in the history
…urity-permissions

Add support for string permissions to @testsecurity annotation
  • Loading branch information
sberyozkin authored Aug 28, 2024
2 parents dc07fa6 + 82ab861 commit 58c2eb5
Show file tree
Hide file tree
Showing 5 changed files with 149 additions and 0 deletions.
23 changes: 23 additions & 0 deletions docs/src/main/asciidoc/security-testing.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,29 @@ The feature is only available for `@QuarkusTest` and will **not** work on a `@Qu
This is particularly useful if the same set of security settings needs to be used in multiple test methods.
====

The `@TestSecurity` annotation also works with the `@PermissionsAllowed` security annotation.
Consider following example:

[source,java]
----
@Test
@TestSecurity(user = "testUser", permissions = "see:detail")
void someTestMethod() {
...
}
----

This will run the test with an identity possessing permission `see` and action `detail`.
Consequently, call to the `getDetail` method declared in the example below will succeed:

[source,java]
----
@PermissionsAllowed("see:detail")
public String getDetail() {
return "detail";
}
----

=== Mixing security tests

If it becomes necessary to test security features using both `@TestSecurity` and Basic Auth (which is the fallback auth
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
package io.quarkus.it.resteasy.elytron;

import jakarta.enterprise.context.ApplicationScoped;

import io.quarkus.security.PermissionsAllowed;

@ApplicationScoped
@PermissionsAllowed({ "see:detail", "see:all" })
public class BeanSecuredWithPermissions {

public String getDetail() {
return "detail";
}

@PermissionsAllowed("create:all")
public String create() {
return "created";
}

@PermissionsAllowed("modify")
public String modify() {
return "modified";
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,16 @@

import java.util.stream.Stream;

import jakarta.inject.Inject;

import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.Arguments;
import org.junit.jupiter.params.provider.MethodSource;
import org.junit.jupiter.params.provider.ValueSource;

import io.quarkus.security.ForbiddenException;
import io.quarkus.test.junit.QuarkusTest;
import io.quarkus.test.security.AttributeType;
import io.quarkus.test.security.SecurityAttribute;
Expand All @@ -23,6 +27,9 @@
@QuarkusTest
class TestSecurityTestCase {

@Inject
BeanSecuredWithPermissions beanSecuredWithPermissions;

@Test
@TestSecurity(authorizationEnabled = false)
void testGet() {
Expand Down Expand Up @@ -142,6 +149,39 @@ void testStringSetAttributes() {
.body(containsString("\"C\""));
}

@Test
@TestSecurity(user = "testUser", permissions = "wrong-permissions")
void testInsufficientPermissions() {
Assertions.assertThrows(ForbiddenException.class, beanSecuredWithPermissions::getDetail);
}

@Test
@TestSecurity(user = "testUser", permissions = { "see:all", "create:all" })
void testPermissionsAndActions_AllAction() {
Assertions.assertEquals("detail", beanSecuredWithPermissions.getDetail());
Assertions.assertEquals("created", beanSecuredWithPermissions.create());
// fails as requires "modify" permission
Assertions.assertThrows(ForbiddenException.class, beanSecuredWithPermissions::modify);
}

@Test
@TestSecurity(user = "testUser", permissions = "see:detail")
void testPermissionsAndActions_DetailAction() { // check both actions are added to possessed permission
Assertions.assertEquals("detail", beanSecuredWithPermissions.getDetail());
// fails as missing "create:all"
Assertions.assertThrows(ForbiddenException.class, beanSecuredWithPermissions::create);
// fails as requires "modify" permission
Assertions.assertThrows(ForbiddenException.class, beanSecuredWithPermissions::modify);
}

@Test
@TestSecurity(user = "testUser", permissions = "modify")
void testPermissionsOnly() {
Assertions.assertEquals("modified", beanSecuredWithPermissions.modify());
// fails as requires "see:all" or "see:detail"
Assertions.assertThrows(ForbiddenException.class, beanSecuredWithPermissions::getDetail);
}

static Stream<Arguments> arrayParams() {
return Stream.of(
arguments(new int[] { 1, 2 }, new String[] { "hello", "world" }));
Expand Down
Original file line number Diff line number Diff line change
@@ -1,15 +1,23 @@
package io.quarkus.test.security;

import static io.quarkus.security.PermissionsAllowed.PERMISSION_TO_ACTION_SEPARATOR;

import java.lang.annotation.Annotation;
import java.lang.reflect.Method;
import java.security.Permission;
import java.util.Arrays;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.function.Function;
import java.util.stream.Collectors;

import jakarta.enterprise.inject.Instance;
import jakarta.enterprise.inject.spi.CDI;

import io.quarkus.security.StringPermission;
import io.quarkus.security.identity.SecurityIdentity;
import io.quarkus.security.runtime.QuarkusPrincipal;
import io.quarkus.security.runtime.QuarkusSecurityIdentity;
Expand All @@ -18,6 +26,7 @@
import io.quarkus.test.junit.callback.QuarkusTestMethodContext;
import io.quarkus.test.util.annotations.AnnotationContainer;
import io.quarkus.test.util.annotations.AnnotationUtils;
import io.smallrye.mutiny.Uni;

public class QuarkusSecurityTestExtension implements QuarkusTestBeforeEachCallback, QuarkusTestAfterEachCallback {

Expand Down Expand Up @@ -55,11 +64,18 @@ public void beforeEach(QuarkusTestMethodContext context) {
if (testSecurity.roles().length != 0) {
throw new RuntimeException("Cannot specify roles without a username in @TestSecurity");
}
if (testSecurity.permissions().length != 0) {
throw new RuntimeException("Cannot specify permissions without a username in @TestSecurity");
}
} else {
QuarkusSecurityIdentity.Builder user = QuarkusSecurityIdentity.builder()
.setPrincipal(new QuarkusPrincipal(testSecurity.user()))
.addRoles(new HashSet<>(Arrays.asList(testSecurity.roles())));

if (testSecurity.permissions().length != 0) {
user.addPermissionChecker(createPermissionChecker(testSecurity.permissions()));
}

if (testSecurity.attributes() != null) {
user.addAttributes(Arrays.stream(testSecurity.attributes())
.collect(Collectors.toMap(s -> s.key(), s -> s.type().convert(s.value()))));
Expand All @@ -80,6 +96,39 @@ public void beforeEach(QuarkusTestMethodContext context) {

}

private static Function<Permission, Uni<Boolean>> createPermissionChecker(String[] permissions) {
record PermissionToAction(String permission, Set<String> actions) {
void addAction(String action) {
if (action != null)
actions.add(action);
}
}
Map<String, PermissionToAction> permissionToActions = new HashMap<>();
for (String perm : permissions) {
if (perm.isEmpty()) {
throw new RuntimeException("Cannot specify empty permissions attribute in @TestSecurity annotation");
}
var actionSeparatorIdx = perm.indexOf(PERMISSION_TO_ACTION_SEPARATOR);
final String permission;
final String action;
if (actionSeparatorIdx < 0) {
// permission only
permission = perm;
action = null;
} else {
permission = perm.substring(0, actionSeparatorIdx);
action = perm.substring(actionSeparatorIdx + 1);
}
// aggregate permission to actions
permissionToActions.computeIfAbsent(permission, k -> new PermissionToAction(permission, new HashSet<>()))
.addAction(action);
}
var possessedPermissions = permissionToActions.values().stream()
.map(pa -> new StringPermission(pa.permission(), pa.actions().toArray(String[]::new))).toList();
return requiredPermission -> Uni.createFrom().item(
possessedPermissions.stream().anyMatch(possessedPermission -> possessedPermission.implies(requiredPermission)));
}

private Optional<AnnotationContainer<TestSecurity>> getAnnotationContainer(QuarkusTestMethodContext context)
throws Exception {
//the usual ClassLoader hacks to get our copy of the TestSecurity annotation
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

import io.quarkus.security.PermissionsAllowed;
import io.quarkus.security.identity.SecurityIdentity;

@Retention(RetentionPolicy.RUNTIME)
Expand All @@ -28,6 +29,17 @@
*/
String[] roles() default {};

/**
* Used in combination with {@link #user()} to specify permissions possessed by the {@link SecurityIdentity}.
* Accepted format corresponds to the {@link PermissionsAllowed#value()} annotation attribute.
* That is, permission is separated from actions with {@link PermissionsAllowed#PERMISSION_TO_ACTION_SEPARATOR}.
* For example, value {@code see:detail} gives permission to {@code see} action {@code detail}.
* All permissions are added as {@link io.quarkus.security.StringPermission}.
* If you need to test custom permissions, you can add them with
* {@link io.quarkus.security.identity.SecurityIdentityAugmentor}.
*/
String[] permissions() default {};

/**
* Adds attributes to a {@link SecurityIdentity} configured by this annotation.
* The attributes can be retrieved by the {@link SecurityIdentity#getAttributes()} method.
Expand Down

0 comments on commit 58c2eb5

Please sign in to comment.