From 82ab8613d85bcfe4c5ecd76419f968f0a8ab1ebf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michal=20Vav=C5=99=C3=ADk?= Date: Tue, 27 Aug 2024 17:24:14 +0200 Subject: [PATCH] Add support for string permissions to TestSecurity --- docs/src/main/asciidoc/security-testing.adoc | 23 +++++++++ .../elytron/BeanSecuredWithPermissions.java | 25 ++++++++++ .../elytron/TestSecurityTestCase.java | 40 +++++++++++++++ .../QuarkusSecurityTestExtension.java | 49 +++++++++++++++++++ .../quarkus/test/security/TestSecurity.java | 12 +++++ 5 files changed, 149 insertions(+) create mode 100644 integration-tests/elytron-resteasy/src/main/java/io/quarkus/it/resteasy/elytron/BeanSecuredWithPermissions.java diff --git a/docs/src/main/asciidoc/security-testing.adoc b/docs/src/main/asciidoc/security-testing.adoc index 7c0a1cd435a44..bed66779c75e3 100644 --- a/docs/src/main/asciidoc/security-testing.adoc +++ b/docs/src/main/asciidoc/security-testing.adoc @@ -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 diff --git a/integration-tests/elytron-resteasy/src/main/java/io/quarkus/it/resteasy/elytron/BeanSecuredWithPermissions.java b/integration-tests/elytron-resteasy/src/main/java/io/quarkus/it/resteasy/elytron/BeanSecuredWithPermissions.java new file mode 100644 index 0000000000000..a4322acf55f39 --- /dev/null +++ b/integration-tests/elytron-resteasy/src/main/java/io/quarkus/it/resteasy/elytron/BeanSecuredWithPermissions.java @@ -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"; + } + +} diff --git a/integration-tests/elytron-resteasy/src/test/java/io/quarkus/it/resteasy/elytron/TestSecurityTestCase.java b/integration-tests/elytron-resteasy/src/test/java/io/quarkus/it/resteasy/elytron/TestSecurityTestCase.java index 3616a21458374..25017f7014fc4 100644 --- a/integration-tests/elytron-resteasy/src/test/java/io/quarkus/it/resteasy/elytron/TestSecurityTestCase.java +++ b/integration-tests/elytron-resteasy/src/test/java/io/quarkus/it/resteasy/elytron/TestSecurityTestCase.java @@ -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; @@ -23,6 +27,9 @@ @QuarkusTest class TestSecurityTestCase { + @Inject + BeanSecuredWithPermissions beanSecuredWithPermissions; + @Test @TestSecurity(authorizationEnabled = false) void testGet() { @@ -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 arrayParams() { return Stream.of( arguments(new int[] { 1, 2 }, new String[] { "hello", "world" })); diff --git a/test-framework/security/src/main/java/io/quarkus/test/security/QuarkusSecurityTestExtension.java b/test-framework/security/src/main/java/io/quarkus/test/security/QuarkusSecurityTestExtension.java index b574d1286b43e..ef556bad11fc8 100644 --- a/test-framework/security/src/main/java/io/quarkus/test/security/QuarkusSecurityTestExtension.java +++ b/test-framework/security/src/main/java/io/quarkus/test/security/QuarkusSecurityTestExtension.java @@ -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; @@ -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 { @@ -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())))); @@ -80,6 +96,39 @@ public void beforeEach(QuarkusTestMethodContext context) { } + private static Function> createPermissionChecker(String[] permissions) { + record PermissionToAction(String permission, Set actions) { + void addAction(String action) { + if (action != null) + actions.add(action); + } + } + Map 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> getAnnotationContainer(QuarkusTestMethodContext context) throws Exception { //the usual ClassLoader hacks to get our copy of the TestSecurity annotation diff --git a/test-framework/security/src/main/java/io/quarkus/test/security/TestSecurity.java b/test-framework/security/src/main/java/io/quarkus/test/security/TestSecurity.java index 58bfe5cf94332..36543039989e4 100644 --- a/test-framework/security/src/main/java/io/quarkus/test/security/TestSecurity.java +++ b/test-framework/security/src/main/java/io/quarkus/test/security/TestSecurity.java @@ -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) @@ -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.