From 02be060f4b1e915bc77163e09c0abeafe7cd5583 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michal=20Vav=C5=99=C3=ADk?= Date: Thu, 12 Sep 2024 14:42:50 +0200 Subject: [PATCH] Support for @PermissionsAllowed meta-annotations --- ...ity-authorize-web-endpoints-reference.adoc | 23 ++ .../test/security/CreateOrUpdate.java | 16 ++ .../CustomPermissionWithExtraArgs.java | 51 ++++ .../security/PermissionsAllowedResource.java | 27 ++ .../test/security/PermissionsAllowedTest.java | 68 +++++ ...tringPermissionsAllowedMetaAnnotation.java | 15 + .../deployment/ResteasyReactiveProcessor.java | 56 ++-- .../AbstractPermissionsAllowedTestCase.java | 42 +++ .../server/test/security/CreateOrUpdate.java | 16 ++ .../LazyAuthPermissionsAllowedTestCase.java | 3 +- ...NonBlockingPermissionsAllowedResource.java | 7 + .../security/PermissionsAllowedResource.java | 9 + ...oactiveAuthPermissionsAllowedTestCase.java | 3 +- ...tringPermissionsAllowedMetaAnnotation.java | 15 + .../deployment/PermissionSecurityChecks.java | 50 +++- .../deployment/SecurityProcessor.java | 84 +++++- .../PermissionsAllowedMetaAnnotationTest.java | 258 ++++++++++++++++++ ...oArgsPermissionsAllowedMetaAnnotation.java | 14 + ...eniedPermissionsAllowedMetaAnnotation.java | 15 + ...tringPermissionsAllowedMetaAnnotation.java | 15 + ...issionsAllowedMetaAnnotationBuildItem.java | 67 +++++ ...gradePermissionsAllowedAnnotationTest.java | 41 ++- ...gEndpointReadPermissionMetaAnnotation.java | 15 + 23 files changed, 878 insertions(+), 32 deletions(-) create mode 100644 extensions/resteasy-classic/resteasy/deployment/src/test/java/io/quarkus/resteasy/test/security/CreateOrUpdate.java create mode 100644 extensions/resteasy-classic/resteasy/deployment/src/test/java/io/quarkus/resteasy/test/security/CustomPermissionWithExtraArgs.java create mode 100644 extensions/resteasy-classic/resteasy/deployment/src/test/java/io/quarkus/resteasy/test/security/PermissionsAllowedResource.java create mode 100644 extensions/resteasy-classic/resteasy/deployment/src/test/java/io/quarkus/resteasy/test/security/PermissionsAllowedTest.java create mode 100644 extensions/resteasy-classic/resteasy/deployment/src/test/java/io/quarkus/resteasy/test/security/StringPermissionsAllowedMetaAnnotation.java create mode 100644 extensions/resteasy-reactive/rest/deployment/src/test/java/io/quarkus/resteasy/reactive/server/test/security/CreateOrUpdate.java create mode 100644 extensions/resteasy-reactive/rest/deployment/src/test/java/io/quarkus/resteasy/reactive/server/test/security/StringPermissionsAllowedMetaAnnotation.java create mode 100644 extensions/security/deployment/src/test/java/io/quarkus/security/test/permissionsallowed/PermissionsAllowedMetaAnnotationTest.java create mode 100644 extensions/security/deployment/src/test/java/io/quarkus/security/test/permissionsallowed/SingleNoArgsPermissionsAllowedMetaAnnotation.java create mode 100644 extensions/security/deployment/src/test/java/io/quarkus/security/test/permissionsallowed/StringDeniedPermissionsAllowedMetaAnnotation.java create mode 100644 extensions/security/deployment/src/test/java/io/quarkus/security/test/permissionsallowed/StringPermissionsAllowedMetaAnnotation.java create mode 100644 extensions/security/spi/src/main/java/io/quarkus/security/spi/PermissionsAllowedMetaAnnotationBuildItem.java create mode 100644 extensions/websockets-next/deployment/src/test/java/io/quarkus/websockets/next/test/security/StringEndpointReadPermissionMetaAnnotation.java diff --git a/docs/src/main/asciidoc/security-authorize-web-endpoints-reference.adoc b/docs/src/main/asciidoc/security-authorize-web-endpoints-reference.adoc index 24452af2d5d71b..a2fdecab800cac 100644 --- a/docs/src/main/asciidoc/security-authorize-web-endpoints-reference.adoc +++ b/docs/src/main/asciidoc/security-authorize-web-endpoints-reference.adoc @@ -1054,6 +1054,29 @@ Because `MediaLibrary` is the `TvLibrary` class parent, a user with the `admin` CAUTION: Annotation-based permissions do not work with custom xref:security-customization.adoc#jaxrs-security-context[Jakarta REST SecurityContexts] because there are no permissions in `jakarta.ws.rs.core.SecurityContext`. +[[permission-meta-annotation]] +=== Create permission meta-annotations + +`@PermissionsAllowed` can also be used in meta-annotations. Example below creates new `@CanWrite` security annotation: + +[source,java] +---- +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +import io.quarkus.security.PermissionsAllowed; + +@Retention(RetentionPolicy.RUNTIME) +@Target({ ElementType.METHOD, ElementType.TYPE }) +@PermissionsAllowed(value = "write", permission = CustomPermission.class) <1> +public @interface CanWrite { + +} +---- +<1> Any method or class annotated with the `@CanWrite` annotation is secured with this `@PermissionsAllowed` annotation instance. + == References * xref:security-overview.adoc[Quarkus Security overview] diff --git a/extensions/resteasy-classic/resteasy/deployment/src/test/java/io/quarkus/resteasy/test/security/CreateOrUpdate.java b/extensions/resteasy-classic/resteasy/deployment/src/test/java/io/quarkus/resteasy/test/security/CreateOrUpdate.java new file mode 100644 index 00000000000000..6cf65ef1f94477 --- /dev/null +++ b/extensions/resteasy-classic/resteasy/deployment/src/test/java/io/quarkus/resteasy/test/security/CreateOrUpdate.java @@ -0,0 +1,16 @@ +package io.quarkus.resteasy.test.security; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +import io.quarkus.security.PermissionsAllowed; + +@Retention(RetentionPolicy.RUNTIME) +@Target({ ElementType.METHOD, ElementType.TYPE }) +@PermissionsAllowed(value = "farewell", permission = CustomPermissionWithExtraArgs.class, params = { "goodbye", "toWhom", "day", + "place" }) +public @interface CreateOrUpdate { + +} diff --git a/extensions/resteasy-classic/resteasy/deployment/src/test/java/io/quarkus/resteasy/test/security/CustomPermissionWithExtraArgs.java b/extensions/resteasy-classic/resteasy/deployment/src/test/java/io/quarkus/resteasy/test/security/CustomPermissionWithExtraArgs.java new file mode 100644 index 00000000000000..99fb87bbd292ce --- /dev/null +++ b/extensions/resteasy-classic/resteasy/deployment/src/test/java/io/quarkus/resteasy/test/security/CustomPermissionWithExtraArgs.java @@ -0,0 +1,51 @@ +package io.quarkus.resteasy.test.security; + +import java.security.Permission; +import java.util.Objects; + +public class CustomPermissionWithExtraArgs extends Permission { + + private final String permName; + private final String goodbye; + private final String toWhom; + private final int day; + private final String place; + + public CustomPermissionWithExtraArgs(String permName, String goodbye, String toWhom, int day, String place) { + super(permName); + this.permName = permName; + this.goodbye = goodbye; + this.toWhom = toWhom; + this.day = day; + this.place = place; + } + + @Override + public boolean implies(Permission permission) { + if (permission instanceof CustomPermissionWithExtraArgs) { + return permission.equals(this); + } + return false; + } + + @Override + public String getActions() { + return null; + } + + @Override + public boolean equals(Object o) { + if (this == o) + return true; + if (o == null || getClass() != o.getClass()) + return false; + CustomPermissionWithExtraArgs that = (CustomPermissionWithExtraArgs) o; + return day == that.day && Objects.equals(permName, that.permName) && Objects.equals(goodbye, that.goodbye) + && Objects.equals(toWhom, that.toWhom) && Objects.equals(place, that.place); + } + + @Override + public int hashCode() { + return Objects.hash(permName, goodbye, toWhom, day, place); + } +} diff --git a/extensions/resteasy-classic/resteasy/deployment/src/test/java/io/quarkus/resteasy/test/security/PermissionsAllowedResource.java b/extensions/resteasy-classic/resteasy/deployment/src/test/java/io/quarkus/resteasy/test/security/PermissionsAllowedResource.java new file mode 100644 index 00000000000000..470e15e7d041fa --- /dev/null +++ b/extensions/resteasy-classic/resteasy/deployment/src/test/java/io/quarkus/resteasy/test/security/PermissionsAllowedResource.java @@ -0,0 +1,27 @@ +package io.quarkus.resteasy.test.security; + +import jakarta.ws.rs.CookieParam; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.HeaderParam; +import jakarta.ws.rs.POST; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.PathParam; + +@Path("/permissions") +public class PermissionsAllowedResource { + + @Path("/string-meta-annotation") + @StringPermissionsAllowedMetaAnnotation + @GET + public String stringMetaAnnotation() { + return "admin"; + } + + @CreateOrUpdate + @Path("/custom-perm-with-args-meta-annotation/{goodbye}") + @POST + public String farewellMetaAnnotation(@PathParam("goodbye") String goodbye, @HeaderParam("toWhom") String toWhom, + @CookieParam("day") int day, String place) { + return String.join(" ", new String[] { goodbye, toWhom, Integer.toString(day), place }); + } +} diff --git a/extensions/resteasy-classic/resteasy/deployment/src/test/java/io/quarkus/resteasy/test/security/PermissionsAllowedTest.java b/extensions/resteasy-classic/resteasy/deployment/src/test/java/io/quarkus/resteasy/test/security/PermissionsAllowedTest.java new file mode 100644 index 00000000000000..41bf845b36a41f --- /dev/null +++ b/extensions/resteasy-classic/resteasy/deployment/src/test/java/io/quarkus/resteasy/test/security/PermissionsAllowedTest.java @@ -0,0 +1,68 @@ +package io.quarkus.resteasy.test.security; + +import org.hamcrest.Matchers; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import io.quarkus.security.StringPermission; +import io.quarkus.security.test.utils.TestIdentityController; +import io.quarkus.security.test.utils.TestIdentityProvider; +import io.quarkus.test.QuarkusUnitTest; +import io.restassured.RestAssured; +import io.restassured.response.ValidatableResponse; + +public class PermissionsAllowedTest { + @RegisterExtension + static QuarkusUnitTest runner = new QuarkusUnitTest() + .withApplicationRoot((jar) -> jar + .addClasses(PermissionsAllowedResource.class, TestIdentityProvider.class, TestIdentityController.class, + StringPermissionsAllowedMetaAnnotation.class, CustomPermissionWithExtraArgs.class, + CreateOrUpdate.class)); + + @BeforeAll + public static void setupUsers() { + TestIdentityController.resetRoles() + .add("admin", "admin", new StringPermission("create"), new StringPermission("update"), + new CustomPermissionWithExtraArgs("farewell", "so long", "Nelson", 3, "Ostrava")) + .add("user", "user", new StringPermission("create"), + new CustomPermissionWithExtraArgs("farewell", "so long", "Nelson", 3, "Prague")) + .add("viewer", "viewer"); + } + + @Test + public void testPermissionsAllowedMetaAnnotation_StringPermissions() { + RestAssured.get("/permissions/string-meta-annotation").then().statusCode(401); + RestAssured.given().auth().basic("user", "user").get("/permissions/string-meta-annotation").then().statusCode(403); + RestAssured.given().auth().basic("admin", "admin").get("/permissions/string-meta-annotation").then().statusCode(200); + } + + @Test + public void testPermissionsAllowedMetaAnnotation_CustomPermissionsWithArgs() { + // === explicitly marked method params && blocking endpoint + // admin has permission with place 'Ostrava' + reqExplicitlyMarkedExtraArgs_MetaAnnotation("admin", "Ostrava") + .statusCode(200) + .body(Matchers.equalTo("so long Nelson 3 Ostrava")); + // user has permission with place 'Prague' + reqExplicitlyMarkedExtraArgs_MetaAnnotation("user", "Prague") + .statusCode(200) + .body(Matchers.equalTo("so long Nelson 3 Prague")); + // user doesn't have permission with place 'Ostrava' + reqExplicitlyMarkedExtraArgs_MetaAnnotation("user", "Ostrava") + .statusCode(403); + // viewer has no permission + reqExplicitlyMarkedExtraArgs_MetaAnnotation("viewer", "Ostrava") + .statusCode(403); + } + + private static ValidatableResponse reqExplicitlyMarkedExtraArgs_MetaAnnotation(String user, String place) { + return RestAssured.given() + .auth().basic(user, user) + .pathParam("goodbye", "so long") + .header("toWhom", "Nelson") + .cookie("day", 3) + .body(place) + .post("/permissions/custom-perm-with-args-meta-annotation/{goodbye}").then(); + } +} diff --git a/extensions/resteasy-classic/resteasy/deployment/src/test/java/io/quarkus/resteasy/test/security/StringPermissionsAllowedMetaAnnotation.java b/extensions/resteasy-classic/resteasy/deployment/src/test/java/io/quarkus/resteasy/test/security/StringPermissionsAllowedMetaAnnotation.java new file mode 100644 index 00000000000000..bbe8a4c291559f --- /dev/null +++ b/extensions/resteasy-classic/resteasy/deployment/src/test/java/io/quarkus/resteasy/test/security/StringPermissionsAllowedMetaAnnotation.java @@ -0,0 +1,15 @@ +package io.quarkus.resteasy.test.security; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +import io.quarkus.security.PermissionsAllowed; + +@Retention(RetentionPolicy.RUNTIME) +@Target({ ElementType.METHOD, ElementType.TYPE }) +@PermissionsAllowed(value = { "create", "update" }, inclusive = true) +public @interface StringPermissionsAllowedMetaAnnotation { + +} diff --git a/extensions/resteasy-reactive/rest/deployment/src/main/java/io/quarkus/resteasy/reactive/server/deployment/ResteasyReactiveProcessor.java b/extensions/resteasy-reactive/rest/deployment/src/main/java/io/quarkus/resteasy/reactive/server/deployment/ResteasyReactiveProcessor.java index 53c3bb8cfda23c..f88f3ae231d148 100644 --- a/extensions/resteasy-reactive/rest/deployment/src/main/java/io/quarkus/resteasy/reactive/server/deployment/ResteasyReactiveProcessor.java +++ b/extensions/resteasy-reactive/rest/deployment/src/main/java/io/quarkus/resteasy/reactive/server/deployment/ResteasyReactiveProcessor.java @@ -210,6 +210,7 @@ import io.quarkus.security.AuthenticationFailedException; import io.quarkus.security.AuthenticationRedirectException; import io.quarkus.security.ForbiddenException; +import io.quarkus.security.spi.PermissionsAllowedMetaAnnotationBuildItem; import io.quarkus.security.spi.SecurityTransformerUtils; import io.quarkus.vertx.http.deployment.AuthorizationPolicyInstancesBuildItem; import io.quarkus.vertx.http.deployment.EagerSecurityInterceptorMethodsBuildItem; @@ -1581,11 +1582,13 @@ public void securityExceptionMappers(BuildProducer exc @BuildStep MethodScannerBuildItem integrateEagerSecurity(Capabilities capabilities, CombinedIndexBuildItem indexBuildItem, Optional authorizationPolicyInstancesItemOpt, - List eagerSecurityInterceptors, JaxRsSecurityConfig securityConfig) { + List eagerSecurityInterceptors, JaxRsSecurityConfig securityConfig, + Optional permsAllowedMetaAnnotationItemOptional) { if (!capabilities.isPresent(Capability.SECURITY)) { return null; } var authZPolicyInstancesItem = authorizationPolicyInstancesItemOpt.get(); + var permsAllowedMetaAnnotationItem = permsAllowedMetaAnnotationItemOptional.get(); final boolean applySecurityInterceptors = !eagerSecurityInterceptors.isEmpty(); final var interceptedMethods = applySecurityInterceptors ? collectInterceptedMethods(eagerSecurityInterceptors) : null; @@ -1602,17 +1605,17 @@ public List scan(MethodInfo method, ClassInfo actualEndp boolean isMethodIntercepted = interceptedMethods.containsKey(endpointImpl); if (isMethodIntercepted) { return createEagerSecCustomizerWithInterceptor(interceptedMethods, endpointImpl, method, endpointImpl, - withDefaultSecurityCheck, applyAuthorizationPolicy); + withDefaultSecurityCheck, applyAuthorizationPolicy, permsAllowedMetaAnnotationItem); } else { isMethodIntercepted = interceptedMethods.containsKey(method); if (isMethodIntercepted && !endpointImpl.equals(method)) { return createEagerSecCustomizerWithInterceptor(interceptedMethods, method, method, endpointImpl, - withDefaultSecurityCheck, applyAuthorizationPolicy); + withDefaultSecurityCheck, applyAuthorizationPolicy, permsAllowedMetaAnnotationItem); } } } return List.of(newEagerSecurityHandlerCustomizerInstance(method, endpointImpl, withDefaultSecurityCheck, - applyAuthorizationPolicy)); + applyAuthorizationPolicy, permsAllowedMetaAnnotationItem)); } }); } @@ -1624,24 +1627,27 @@ private static boolean shouldApplyAuthZPolicy(MethodInfo method, MethodInfo endp private static List createEagerSecCustomizerWithInterceptor( Map interceptedMethods, MethodInfo method, MethodInfo originalMethod, MethodInfo endpointImpl, - boolean withDefaultSecurityCheck, boolean applyAuthorizationPolicy) { + boolean withDefaultSecurityCheck, boolean applyAuthorizationPolicy, + PermissionsAllowedMetaAnnotationBuildItem permsAllowedMetaAnnotationItem) { var requiresSecurityCheck = interceptedMethods.get(method); final HandlerChainCustomizer eagerSecCustomizer; if (requiresSecurityCheck && !applyAuthorizationPolicy) { eagerSecCustomizer = EagerSecurityHandler.Customizer.newInstance(false); } else { eagerSecCustomizer = newEagerSecurityHandlerCustomizerInstance(originalMethod, endpointImpl, - withDefaultSecurityCheck, applyAuthorizationPolicy); + withDefaultSecurityCheck, applyAuthorizationPolicy, permsAllowedMetaAnnotationItem); } return List.of(EagerSecurityInterceptorHandler.Customizer.newInstance(), eagerSecCustomizer); } private static HandlerChainCustomizer newEagerSecurityHandlerCustomizerInstance(MethodInfo method, MethodInfo endpointImpl, - boolean withDefaultSecurityCheck, boolean applyAuthorizationPolicy) { + boolean withDefaultSecurityCheck, boolean applyAuthorizationPolicy, + PermissionsAllowedMetaAnnotationBuildItem permsAllowedMetaAnnotationItem) { if (applyAuthorizationPolicy) { return EagerSecurityHandler.Customizer.newInstanceWithAuthorizationPolicy(); } - if (withDefaultSecurityCheck || consumesStandardSecurityAnnotations(method, endpointImpl)) { + if (withDefaultSecurityCheck + || consumesStandardSecurityAnnotations(method, endpointImpl, permsAllowedMetaAnnotationItem)) { return EagerSecurityHandler.Customizer.newInstance(false); } return EagerSecurityHandler.Customizer.newInstance(true); @@ -1711,24 +1717,36 @@ void registerSecurityBeans(Capabilities capabilities, } } - private static boolean consumesStandardSecurityAnnotations(MethodInfo methodInfo, MethodInfo endpointImpl) { + private static boolean consumesStandardSecurityAnnotations(MethodInfo methodInfo, MethodInfo endpointImpl, + PermissionsAllowedMetaAnnotationBuildItem permsAllowedMetaAnnotationItem) { // invoked method - if (consumesStandardSecurityAnnotations(endpointImpl)) { + if (consumesStandardSecurityAnnotations(endpointImpl, permsAllowedMetaAnnotationItem)) { return true; } // fallback to original behavior - return !endpointImpl.equals(methodInfo) && consumesStandardSecurityAnnotations(methodInfo); + return !endpointImpl.equals(methodInfo) + && consumesStandardSecurityAnnotations(methodInfo, permsAllowedMetaAnnotationItem); } - private static boolean consumesStandardSecurityAnnotations(MethodInfo methodInfo) { - return SecurityTransformerUtils.hasSecurityAnnotation(methodInfo) - || (SecurityTransformerUtils.hasSecurityAnnotation(methodInfo.declaringClass()) - // security annotations cannot be combined - // and the most specific wins, so if we have both class-level security check - // and the method-level @AuthorizationPolicy, the policy wins as it is more specific - // as would any other security annotation - && !HttpSecurityUtils.hasAuthorizationPolicyAnnotation(methodInfo)); + private static boolean consumesStandardSecurityAnnotations(MethodInfo methodInfo, + PermissionsAllowedMetaAnnotationBuildItem permsAllowedMetaAnnotationItem) { + boolean hasMethodLevelSecurityAnnotation = SecurityTransformerUtils.hasSecurityAnnotation(methodInfo) + || permsAllowedMetaAnnotationItem.hasPermissionsAllowed(methodInfo); + if (hasMethodLevelSecurityAnnotation) { + return true; + } + if (HttpSecurityUtils.hasAuthorizationPolicyAnnotation(methodInfo)) { + // security annotations cannot be combined + // and the most specific wins, so if we have both class-level security check + // and the method-level @AuthorizationPolicy, the policy wins as it is more specific + // as would any other security annotation; + // we know both security annotation and @AuthorizationPolicy are not placed + // on a method level thanks to validation + return false; + } + return SecurityTransformerUtils.hasSecurityAnnotation(methodInfo.declaringClass()) + || permsAllowedMetaAnnotationItem.hasPermissionsAllowed(methodInfo.declaringClass()); } private Optional getAppPath(Optional newPropertyValue) { diff --git a/extensions/resteasy-reactive/rest/deployment/src/test/java/io/quarkus/resteasy/reactive/server/test/security/AbstractPermissionsAllowedTestCase.java b/extensions/resteasy-reactive/rest/deployment/src/test/java/io/quarkus/resteasy/reactive/server/test/security/AbstractPermissionsAllowedTestCase.java index 1ff428bba91601..c462d96f93b247 100644 --- a/extensions/resteasy-reactive/rest/deployment/src/test/java/io/quarkus/resteasy/reactive/server/test/security/AbstractPermissionsAllowedTestCase.java +++ b/extensions/resteasy-reactive/rest/deployment/src/test/java/io/quarkus/resteasy/reactive/server/test/security/AbstractPermissionsAllowedTestCase.java @@ -42,6 +42,20 @@ public void testStringPermission2RequiredPermissionsNonBlocking() { RestAssured.given().auth().basic("user", "user").post("/permissions-non-blocking").then().statusCode(403); } + @Test + public void testStringPermission2RequiredPermissionsNonBlocking_MetaAnnotation() { + // invokes POST /permissions-non-blocking endpoint that requires 2 permissions: create AND update + // meta annotation is used + + // admin must have both 'create' and 'update' in order to succeed + RestAssured.given().auth().basic("admin", "admin").post("/permissions-non-blocking/meta-create-update") + .then().statusCode(200).body(Matchers.equalTo("done")); + + // user has only 'update', therefore should fail + RestAssured.given().auth().basic("user", "user").post("/permissions-non-blocking/meta-create-update") + .then().statusCode(403); + } + @Test public void testStringPermissionOneOfPermissionsAndActions() { // invokes GET /permissions/admin endpoint that requires one of 2 permissions: read:resource-admin, read:resource-user @@ -158,6 +172,24 @@ public void testCustomPermissionWithAdditionalArgs() { .statusCode(403); } + @Test + public void testCustomPermissionWithAdditionalArgs_MetaAnnotation() { + // === explicitly marked method params && blocking endpoint + // admin has permission with place 'Ostrava' + reqExplicitlyMarkedExtraArgs_MetaAnnotation("admin", "Ostrava") + .statusCode(200) + .body(Matchers.equalTo("so long Nelson 3 Ostrava")); + // user has permission with place 'Prague' + reqExplicitlyMarkedExtraArgs_MetaAnnotation("user", "Prague") + .statusCode(200) + .body(Matchers.equalTo("so long Nelson 3 Prague")); + reqExplicitlyMarkedExtraArgs_MetaAnnotation("user", "Ostrava") + .statusCode(403); + // viewer has no permission + reqExplicitlyMarkedExtraArgs_MetaAnnotation("viewer", "Ostrava") + .statusCode(403); + } + private static ValidatableResponse reqAutodetectedExtraArgs(String user, String place) { return RestAssured.given() .auth().basic(user, user) @@ -177,4 +209,14 @@ private static ValidatableResponse reqExplicitlyMarkedExtraArgs(String user, Str .body(place) .post("/permissions/custom-perm-with-args/{goodbye}").then(); } + + private static ValidatableResponse reqExplicitlyMarkedExtraArgs_MetaAnnotation(String user, String place) { + return RestAssured.given() + .auth().basic(user, user) + .pathParam("goodbye", "so long") + .header("toWhom", "Nelson") + .cookie("day", 3) + .body(place) + .post("/permissions/custom-perm-with-args-meta-annotation/{goodbye}").then(); + } } diff --git a/extensions/resteasy-reactive/rest/deployment/src/test/java/io/quarkus/resteasy/reactive/server/test/security/CreateOrUpdate.java b/extensions/resteasy-reactive/rest/deployment/src/test/java/io/quarkus/resteasy/reactive/server/test/security/CreateOrUpdate.java new file mode 100644 index 00000000000000..acb07c44aa3cd5 --- /dev/null +++ b/extensions/resteasy-reactive/rest/deployment/src/test/java/io/quarkus/resteasy/reactive/server/test/security/CreateOrUpdate.java @@ -0,0 +1,16 @@ +package io.quarkus.resteasy.reactive.server.test.security; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +import io.quarkus.security.PermissionsAllowed; + +@Retention(RetentionPolicy.RUNTIME) +@Target({ ElementType.METHOD, ElementType.TYPE }) +@PermissionsAllowed(value = "farewell", permission = CustomPermissionWithExtraArgs.class, params = { "goodbye", "toWhom", "day", + "place" }) +public @interface CreateOrUpdate { + +} diff --git a/extensions/resteasy-reactive/rest/deployment/src/test/java/io/quarkus/resteasy/reactive/server/test/security/LazyAuthPermissionsAllowedTestCase.java b/extensions/resteasy-reactive/rest/deployment/src/test/java/io/quarkus/resteasy/reactive/server/test/security/LazyAuthPermissionsAllowedTestCase.java index 582301287e51c5..75ca39d5992326 100644 --- a/extensions/resteasy-reactive/rest/deployment/src/test/java/io/quarkus/resteasy/reactive/server/test/security/LazyAuthPermissionsAllowedTestCase.java +++ b/extensions/resteasy-reactive/rest/deployment/src/test/java/io/quarkus/resteasy/reactive/server/test/security/LazyAuthPermissionsAllowedTestCase.java @@ -14,7 +14,8 @@ public class LazyAuthPermissionsAllowedTestCase extends AbstractPermissionsAllow .withApplicationRoot((jar) -> jar .addClasses(PermissionsAllowedResource.class, TestIdentityProvider.class, TestIdentityController.class, NonBlockingPermissionsAllowedResource.class, CustomPermission.class, - PermissionsIdentityAugmentor.class, CustomPermissionWithExtraArgs.class) + PermissionsIdentityAugmentor.class, CustomPermissionWithExtraArgs.class, + StringPermissionsAllowedMetaAnnotation.class, CreateOrUpdate.class) .addAsResource(new StringAsset("quarkus.http.auth.proactive=false\n"), "application.properties")); diff --git a/extensions/resteasy-reactive/rest/deployment/src/test/java/io/quarkus/resteasy/reactive/server/test/security/NonBlockingPermissionsAllowedResource.java b/extensions/resteasy-reactive/rest/deployment/src/test/java/io/quarkus/resteasy/reactive/server/test/security/NonBlockingPermissionsAllowedResource.java index ff7321f769b92a..515ad18042d099 100644 --- a/extensions/resteasy-reactive/rest/deployment/src/test/java/io/quarkus/resteasy/reactive/server/test/security/NonBlockingPermissionsAllowedResource.java +++ b/extensions/resteasy-reactive/rest/deployment/src/test/java/io/quarkus/resteasy/reactive/server/test/security/NonBlockingPermissionsAllowedResource.java @@ -29,6 +29,13 @@ public Uni createOrUpdate() { return Uni.createFrom().item("done"); } + @Path("/meta-create-update") + @POST + @StringPermissionsAllowedMetaAnnotation + public Uni createOrUpdate_MetaAnnotation() { + return Uni.createFrom().item("done"); + } + @Path("/admin") @PermissionsAllowed({ "read:resource-admin", "read:resource-user" }) @GET diff --git a/extensions/resteasy-reactive/rest/deployment/src/test/java/io/quarkus/resteasy/reactive/server/test/security/PermissionsAllowedResource.java b/extensions/resteasy-reactive/rest/deployment/src/test/java/io/quarkus/resteasy/reactive/server/test/security/PermissionsAllowedResource.java index 1060318bdff7ab..f409ccfdbdc1d5 100644 --- a/extensions/resteasy-reactive/rest/deployment/src/test/java/io/quarkus/resteasy/reactive/server/test/security/PermissionsAllowedResource.java +++ b/extensions/resteasy-reactive/rest/deployment/src/test/java/io/quarkus/resteasy/reactive/server/test/security/PermissionsAllowedResource.java @@ -57,4 +57,13 @@ public String farewell(@RestPath String goodbye, @RestHeader("toWhom") String to String farewell = String.join(" ", new String[] { goodbye, toWhom, Integer.toString(day), place }); return farewell; } + + @CreateOrUpdate + @Path("/custom-perm-with-args-meta-annotation/{goodbye}") + @POST + public String farewellMetaAnnotation(@RestPath String goodbye, @RestHeader("toWhom") String toWhom, @RestCookie int day, + String place) { + String farewell = String.join(" ", new String[] { goodbye, toWhom, Integer.toString(day), place }); + return farewell; + } } diff --git a/extensions/resteasy-reactive/rest/deployment/src/test/java/io/quarkus/resteasy/reactive/server/test/security/ProactiveAuthPermissionsAllowedTestCase.java b/extensions/resteasy-reactive/rest/deployment/src/test/java/io/quarkus/resteasy/reactive/server/test/security/ProactiveAuthPermissionsAllowedTestCase.java index 5c35810b53ac67..41f80d5cea8760 100644 --- a/extensions/resteasy-reactive/rest/deployment/src/test/java/io/quarkus/resteasy/reactive/server/test/security/ProactiveAuthPermissionsAllowedTestCase.java +++ b/extensions/resteasy-reactive/rest/deployment/src/test/java/io/quarkus/resteasy/reactive/server/test/security/ProactiveAuthPermissionsAllowedTestCase.java @@ -13,6 +13,7 @@ public class ProactiveAuthPermissionsAllowedTestCase extends AbstractPermissions .withApplicationRoot((jar) -> jar .addClasses(PermissionsAllowedResource.class, TestIdentityProvider.class, TestIdentityController.class, NonBlockingPermissionsAllowedResource.class, CustomPermission.class, - PermissionsIdentityAugmentor.class, CustomPermissionWithExtraArgs.class)); + PermissionsIdentityAugmentor.class, CustomPermissionWithExtraArgs.class, + StringPermissionsAllowedMetaAnnotation.class, CreateOrUpdate.class)); } diff --git a/extensions/resteasy-reactive/rest/deployment/src/test/java/io/quarkus/resteasy/reactive/server/test/security/StringPermissionsAllowedMetaAnnotation.java b/extensions/resteasy-reactive/rest/deployment/src/test/java/io/quarkus/resteasy/reactive/server/test/security/StringPermissionsAllowedMetaAnnotation.java new file mode 100644 index 00000000000000..2d38036fafd9c0 --- /dev/null +++ b/extensions/resteasy-reactive/rest/deployment/src/test/java/io/quarkus/resteasy/reactive/server/test/security/StringPermissionsAllowedMetaAnnotation.java @@ -0,0 +1,15 @@ +package io.quarkus.resteasy.reactive.server.test.security; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +import io.quarkus.security.PermissionsAllowed; + +@Retention(RetentionPolicy.RUNTIME) +@Target({ ElementType.METHOD, ElementType.TYPE }) +@PermissionsAllowed(value = { "create", "update" }, inclusive = true) +public @interface StringPermissionsAllowedMetaAnnotation { + +} diff --git a/extensions/security/deployment/src/main/java/io/quarkus/security/deployment/PermissionSecurityChecks.java b/extensions/security/deployment/src/main/java/io/quarkus/security/deployment/PermissionSecurityChecks.java index 5f09bb774210cd..c4a228fd03f7b6 100644 --- a/extensions/security/deployment/src/main/java/io/quarkus/security/deployment/PermissionSecurityChecks.java +++ b/extensions/security/deployment/src/main/java/io/quarkus/security/deployment/PermissionSecurityChecks.java @@ -3,6 +3,7 @@ import static io.quarkus.arc.processor.DotNames.STRING; import static io.quarkus.security.PermissionsAllowed.AUTODETECTED; import static io.quarkus.security.PermissionsAllowed.PERMISSION_TO_ACTION_SEPARATOR; +import static io.quarkus.security.deployment.DotNames.PERMISSIONS_ALLOWED; import static io.quarkus.security.deployment.SecurityProcessor.isPublicNonStaticNonConstructor; import java.security.Permission; @@ -31,6 +32,7 @@ import io.quarkus.security.StringPermission; import io.quarkus.security.runtime.SecurityCheckRecorder; import io.quarkus.security.runtime.interceptor.PermissionsAllowedInterceptor; +import io.quarkus.security.spi.PermissionsAllowedMetaAnnotationBuildItem; import io.quarkus.security.spi.runtime.SecurityCheck; interface PermissionSecurityChecks { @@ -248,8 +250,12 @@ public int compare(AnnotationInstance o1, AnnotationInstance o2) { // ignore PermissionsAllowedInterceptor in security module // we also need to check string as long as duplicate "PermissionsAllowedInterceptor" exists // in RESTEasy Reactive, however this workaround should be removed when the interceptor is dropped - if (PERMISSIONS_ALLOWED_INTERCEPTOR.equals(clazz.name()) - || clazz.name().toString().endsWith("PermissionsAllowedInterceptor")) { + if (isPermissionsAllowedInterceptor(clazz)) { + continue; + } + + if (clazz.isAnnotation()) { + // meta-annotations are handled separately continue; } @@ -312,6 +318,45 @@ public int compare(AnnotationInstance o1, AnnotationInstance o2) { return this; } + static boolean isPermissionsAllowedInterceptor(ClassInfo clazz) { + return PERMISSIONS_ALLOWED_INTERCEPTOR.equals(clazz.name()) + || clazz.name().toString().endsWith("PermissionsAllowedInterceptor"); + } + + static ArrayList getPermissionsAllowedInstances(IndexView index, + PermissionsAllowedMetaAnnotationBuildItem item) { + var instances = getPermissionsAllowedInstances(index); + if (!item.getTransitiveInstances().isEmpty()) { + instances.addAll(item.getTransitiveInstances()); + } + return instances; + } + + static ArrayList getPermissionsAllowedInstances(IndexView index) { + return new ArrayList<>( + index.getAnnotationsWithRepeatable(PERMISSIONS_ALLOWED, index)); + } + + static PermissionsAllowedMetaAnnotationBuildItem movePermFromMetaAnnToMetaTarget(IndexView index) { + var permissionsAllowed = getPermissionsAllowedInstances(index) + .stream() + .filter(ai -> ai.target().kind() == AnnotationTarget.Kind.CLASS) + .filter(ai -> ai.target().asClass().isAnnotation()) + .toList(); + final List metaAnnotationNames = new ArrayList<>(); + var newInstances = permissionsAllowed + .stream() + .flatMap(instanceOnMetaAnn -> { + var metaAnnotationName = instanceOnMetaAnn.target().asClass().name(); + metaAnnotationNames.add(metaAnnotationName); + return index.getAnnotations(metaAnnotationName).stream() + .map(ai -> AnnotationInstance.create(PERMISSIONS_ALLOWED, ai.target(), + instanceOnMetaAnn.values())); + }) + .toList(); + return new PermissionsAllowedMetaAnnotationBuildItem(newInstances, metaAnnotationNames); + } + private static AnnotationInstance getAnnotationInstance(ClassInfo classInfo, List annotationInstances) { return annotationInstances.stream() @@ -801,4 +846,5 @@ private static boolean isStringPermission(PermissionKey permissionKey) { } } + } diff --git a/extensions/security/deployment/src/main/java/io/quarkus/security/deployment/SecurityProcessor.java b/extensions/security/deployment/src/main/java/io/quarkus/security/deployment/SecurityProcessor.java index 6dd1c151184221..594294fe9f05d1 100644 --- a/extensions/security/deployment/src/main/java/io/quarkus/security/deployment/SecurityProcessor.java +++ b/extensions/security/deployment/src/main/java/io/quarkus/security/deployment/SecurityProcessor.java @@ -7,6 +7,8 @@ import static io.quarkus.security.deployment.DotNames.PERMISSIONS_ALLOWED; import static io.quarkus.security.deployment.DotNames.PERMIT_ALL; import static io.quarkus.security.deployment.DotNames.ROLES_ALLOWED; +import static io.quarkus.security.deployment.PermissionSecurityChecks.PermissionSecurityChecksBuilder.getPermissionsAllowedInstances; +import static io.quarkus.security.deployment.PermissionSecurityChecks.PermissionSecurityChecksBuilder.movePermFromMetaAnnToMetaTarget; import static io.quarkus.security.runtime.SecurityProviderUtils.findProviderIndex; import static io.quarkus.security.spi.SecurityTransformerUtils.findFirstStandardSecurityAnnotation; import static io.quarkus.security.spi.SecurityTransformerUtils.hasSecurityAnnotation; @@ -40,6 +42,7 @@ import org.jboss.jandex.AnnotationInstance; import org.jboss.jandex.AnnotationTarget; +import org.jboss.jandex.AnnotationTransformation; import org.jboss.jandex.ClassInfo; import org.jboss.jandex.DotName; import org.jboss.jandex.IndexView; @@ -117,6 +120,7 @@ import io.quarkus.security.spi.ClassSecurityCheckStorageBuildItem; import io.quarkus.security.spi.ClassSecurityCheckStorageBuildItem.ClassStorageBuilder; import io.quarkus.security.spi.DefaultSecurityCheckBuildItem; +import io.quarkus.security.spi.PermissionsAllowedMetaAnnotationBuildItem; import io.quarkus.security.spi.RolesAllowedConfigExpResolverBuildItem; import io.quarkus.security.spi.SecurityTransformerUtils; import io.quarkus.security.spi.runtime.AuthorizationController; @@ -555,6 +559,65 @@ void transformSecurityAnnotations(BuildProducer } } + @BuildStep + PermissionsAllowedMetaAnnotationBuildItem transformPermissionsAllowedMetaAnnotations( + BeanArchiveIndexBuildItem beanArchiveBuildItem, + BuildProducer transformers, + List classAnnotationItems) { + + var index = beanArchiveBuildItem.getIndex(); + var item = movePermFromMetaAnnToMetaTarget(index); + + // add @PermissionsAllowed to meta-annotation method target + item.getTransitiveInstances() + .stream() + .filter(i -> i.target().kind() == AnnotationTarget.Kind.METHOD) + .forEach(i -> { + var method = i.target().asMethod(); + var targetClassName = method.declaringClass().name(); + transformers.produce( + new AnnotationsTransformerBuildItem( + AnnotationTransformation + .forMethods() + .whenMethod(targetClassName, method.name()) + .transform(tc -> tc.add(i)))); + }); + + // extensions WebSockets Next doesn't want CDI interceptors to prevent repeated checks + var additionalClassAnnotations = classAnnotationItems.stream() + .map(ClassSecurityCheckAnnotationBuildItem::getClassAnnotation).collect(Collectors.toSet()); + final Predicate hasNoAdditionalClassAnnotation; + if (additionalClassAnnotations.isEmpty()) { + hasNoAdditionalClassAnnotation = ai -> true; + } else { + hasNoAdditionalClassAnnotation = ai -> { + for (var declaredAnnotation : ai.target().asClass().declaredAnnotations()) { + if (additionalClassAnnotations.contains(declaredAnnotation.name())) { + return false; + } + } + return true; + }; + } + + // add @PermissionsAllowed to meta-annotation class target + item.getTransitiveInstances() + .stream() + .filter(i -> i.target().kind() == AnnotationTarget.Kind.CLASS) + .filter(hasNoAdditionalClassAnnotation) + .forEach(i -> { + var clazz = i.target().asClass(); + transformers.produce( + new AnnotationsTransformerBuildItem( + AnnotationTransformation + .forClasses() + .whenClass(clazz.name()) + .transform(tc -> tc.add(i)))); + }); + + return item; + } + @BuildStep @Record(ExecutionTime.STATIC_INIT) MethodSecurityChecks gatherSecurityChecks( @@ -568,7 +631,8 @@ MethodSecurityChecks gatherSecurityChecks( BuildProducer classSecurityCheckStorageProducer, List registerClassSecurityCheckBuildItems, BuildProducer reflectiveClassBuildItemBuildProducer, - List additionalSecurityChecks, SecurityBuildTimeConfig config) { + List additionalSecurityChecks, SecurityBuildTimeConfig config, + PermissionsAllowedMetaAnnotationBuildItem permissionsAllowedMetaAnnotationItem) { var hasAdditionalSecAnn = hasAdditionalSecurityAnnotation(additionalSecurityAnnotationItems.stream() .map(AdditionalSecurityAnnotationBuildItem::getSecurityAnnotationName).collect(Collectors.toSet())); classPredicate.produce(new ApplicationClassPredicateBuildItem(new SecurityCheckStorageAppPredicate())); @@ -586,7 +650,7 @@ MethodSecurityChecks gatherSecurityChecks( additionalSecured.values(), config.denyUnannotated(), recorder, configBuilderProducer, reflectiveClassBuildItemBuildProducer, rolesAllowedConfigExpResolverBuildItems, registerClassSecurityCheckBuildItems, classSecurityCheckStorageProducer, hasAdditionalSecAnn, - additionalSecurityAnnotationItems); + additionalSecurityAnnotationItems, permissionsAllowedMetaAnnotationItem); for (AdditionalSecurityCheckBuildItem additionalSecurityCheck : additionalSecurityChecks) { securityChecks.put(additionalSecurityCheck.getMethodInfo(), additionalSecurityCheck.getSecurityCheck()); @@ -665,7 +729,8 @@ private static Map gatherSecurityAnnotations(IndexVie List registerClassSecurityCheckBuildItems, BuildProducer classSecurityCheckStorageProducer, Predicate hasAdditionalSecurityAnnotations, - List additionalSecurityAnnotationItems) { + List additionalSecurityAnnotationItems, + PermissionsAllowedMetaAnnotationBuildItem permissionsAllowedMetaAnnotationItem) { Map methodToInstanceCollector = new HashMap<>(); Map classAnnotations = new HashMap<>(); Map result = new HashMap<>(); @@ -691,8 +756,8 @@ private static Map gatherSecurityAnnotations(IndexVie // gather @PermissionsAllowed security checks final Map classNameToPermCheck; - List permissionInstances = new ArrayList<>( - index.getAnnotationsWithRepeatable(PERMISSIONS_ALLOWED, index)); + List permissionInstances = getPermissionsAllowedInstances(index, + permissionsAllowedMetaAnnotationItem); if (!permissionInstances.isEmpty()) { var additionalClassInstances = registerClassSecurityCheckBuildItems .stream() @@ -986,7 +1051,7 @@ void validateStartUpObserversNotSecured(SynthesisFinishedBuildItem synthesisFini @BuildStep void gatherClassSecurityChecks(BuildProducer producer, - BeanArchiveIndexBuildItem indexBuildItem, + BeanArchiveIndexBuildItem indexBuildItem, PermissionsAllowedMetaAnnotationBuildItem permsMetaAnnotationsItem, List classAnnotationItems) { if (!classAnnotationItems.isEmpty()) { var index = indexBuildItem.getIndex(); @@ -997,8 +1062,11 @@ void gatherClassSecurityChecks(BuildProducer ai.target().kind() == AnnotationTarget.Kind.CLASS) .map(ai -> ai.target().asClass()) - .filter(SecurityTransformerUtils::hasSecurityAnnotation) - .map(c -> new RegisterClassSecurityCheckBuildItem(c.name(), findFirstStandardSecurityAnnotation(c).get())) + .filter(cl -> SecurityTransformerUtils.hasSecurityAnnotation(cl) + || permsMetaAnnotationsItem.hasPermissionsAllowed(cl)) + .map(c -> new RegisterClassSecurityCheckBuildItem(c.name(), findFirstStandardSecurityAnnotation(c) + .or(() -> permsMetaAnnotationsItem.findPermissionsAllowedInstance(c)) + .get())) .forEach(producer::produce); } } diff --git a/extensions/security/deployment/src/test/java/io/quarkus/security/test/permissionsallowed/PermissionsAllowedMetaAnnotationTest.java b/extensions/security/deployment/src/test/java/io/quarkus/security/test/permissionsallowed/PermissionsAllowedMetaAnnotationTest.java new file mode 100644 index 00000000000000..c30078b7ea9988 --- /dev/null +++ b/extensions/security/deployment/src/test/java/io/quarkus/security/test/permissionsallowed/PermissionsAllowedMetaAnnotationTest.java @@ -0,0 +1,258 @@ +package io.quarkus.security.test.permissionsallowed; + +import static io.quarkus.security.test.utils.SecurityTestUtils.assertFailureFor; +import static io.quarkus.security.test.utils.SecurityTestUtils.assertSuccess; + +import java.security.Permission; +import java.util.Collections; +import java.util.Objects; +import java.util.Set; +import java.util.function.Function; +import java.util.function.Supplier; + +import jakarta.inject.Inject; +import jakarta.inject.Singleton; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import io.quarkus.security.ForbiddenException; +import io.quarkus.security.PermissionsAllowed; +import io.quarkus.security.StringPermission; +import io.quarkus.security.test.utils.AuthData; +import io.quarkus.security.test.utils.IdentityMock; +import io.quarkus.security.test.utils.SecurityTestUtils; +import io.quarkus.test.QuarkusUnitTest; + +public class PermissionsAllowedMetaAnnotationTest { + + @RegisterExtension + static final QuarkusUnitTest config = new QuarkusUnitTest() + .withApplicationRoot((jar) -> jar + .addClasses(IdentityMock.class, AuthData.class, SecurityTestUtils.class, + SingleNoArgsPermissionsAllowedMetaAnnotation.class, StringPermissionsAllowedMetaAnnotation.class, + StringDeniedPermissionsAllowedMetaAnnotation.class)); + + @Inject + PermissionsAllowedMethodLevelWithoutActions withoutActionsMethodLevelBean; + + @Inject + PermissionsAllowedClassLevelWithoutActions withoutActionsClassLevelBean; + + @Inject + StringPermissionsAllowedClassLevel stringPermissionsAllowedClassLevelBean; + + @Inject + StringPermissionsAllowedMethodLevel stringPermissionsAllowedMethodLevelBean; + + @Inject + MetaAnnotationOnMethodLevelOverridesClassLevel methodLevelMetaOverridesClassLevelBean; + + @Inject + MetaAnnotationOnClassLevelOverriddenByMethodLevel classLevelMetaOverriddenByMethodLevelBean; + + @Inject + RepeatedAnnotation repeatedAnnotation; + + @Test + public void testCustomAnnotationWithoutActions_MethodLevel() { + testWriterPermission(() -> withoutActionsMethodLevelBean.write(), CustomPermissionNameOnly::new); + } + + @Test + public void testCustomAnnotationWithoutActions_ClassLevel() { + testWriterPermission(() -> withoutActionsClassLevelBean.write(), CustomPermissionNameOnly::new); + } + + @Test + public void testStringPermission_MethodLevel() { + testWriterPermission(() -> stringPermissionsAllowedMethodLevelBean.write(), StringPermission::new); + } + + @Test + public void testStringPermission_ClassLevel() { + testWriterPermission(() -> stringPermissionsAllowedClassLevelBean.write(), StringPermission::new); + } + + @Test + public void testMetaAnnotationOnMethodLevelOverridesClassLevel() { + testWriterPermission(() -> methodLevelMetaOverridesClassLevelBean.write(), StringPermission::new); + testDenyAllPermission(() -> methodLevelMetaOverridesClassLevelBean.other()); + } + + @Test + public void testClassLevelMetaOverriddenByMethodLevelBean() { + testWriterPermission(() -> classLevelMetaOverriddenByMethodLevelBean.write(), StringPermission::new); + testDenyAllPermission(() -> classLevelMetaOverriddenByMethodLevelBean.other()); + } + + @Test + public void testRepeatedAnnotation() { + testWriterPermission(() -> repeatedAnnotation.write(), StringPermission::new); + + // 'other requires string permissions 'deny-all' and 'repeated' + AuthData writer = new AuthData(Collections.singleton("admin"), false, "admin", + Set.of(new StringPermission("write"))); + + AuthData denyAll = new AuthData(Collections.singleton("admin"), false, "admin", + Set.of(new StringPermission("deny-all"))); + + assertFailureFor(() -> repeatedAnnotation.other(), ForbiddenException.class, denyAll); + assertFailureFor(() -> repeatedAnnotation.other(), ForbiddenException.class, writer); + + AuthData denyAllRepeated = new AuthData(Collections.singleton("admin"), false, "admin", + Set.of(new StringPermission("deny-all"), new StringPermission("repeated"))); + assertSuccess(() -> repeatedAnnotation.other(), "other", denyAllRepeated); + } + + private void testDenyAllPermission(Supplier supplier) { + AuthData writer = new AuthData(Collections.singleton("admin"), false, "admin", + Set.of(new StringPermission("write"))); + + AuthData denyAll = new AuthData(Collections.singleton("admin"), false, "admin", + Set.of(new StringPermission("deny-all"))); + + assertSuccess(supplier::get, "other", denyAll); + assertFailureFor(supplier::get, ForbiddenException.class, writer); + } + + private static void testWriterPermission(Supplier supplier, Function permissionCreator) { + AuthData writer = new AuthData(Collections.singleton("admin"), false, "admin", + Set.of(permissionCreator.apply("write"))); + + assertSuccess(supplier, "write", writer); + + AuthData reader = new AuthData(Collections.singleton("admin"), false, "admin", + Set.of(permissionCreator.apply("reader"))); + + assertFailureFor(supplier::get, ForbiddenException.class, reader); + } + + /** + * This permission does not accept actions, it is important in order to test class instantiation that differs + * for actions/without actions. + */ + public static class CustomPermissionNameOnly extends Permission { + + private final Permission delegate; + + public CustomPermissionNameOnly(String name) { + super(name); + this.delegate = new StringPermission(name); + } + + @Override + public boolean implies(Permission permission) { + if (permission instanceof CustomPermissionNameOnly) { + return delegate.implies(((CustomPermissionNameOnly) permission).delegate); + } + return false; + } + + @Override + public boolean equals(Object o) { + if (this == o) + return true; + if (o == null || getClass() != o.getClass()) + return false; + CustomPermissionNameOnly that = (CustomPermissionNameOnly) o; + return delegate.equals(that.delegate); + } + + @Override + public int hashCode() { + return Objects.hash(delegate); + } + + @Override + public String getActions() { + return delegate.getActions(); + } + } + + @Singleton + public static class PermissionsAllowedMethodLevelWithoutActions { + + @SingleNoArgsPermissionsAllowedMetaAnnotation + public final String write() { + return "write"; + } + + } + + @SingleNoArgsPermissionsAllowedMetaAnnotation + @Singleton + public static class PermissionsAllowedClassLevelWithoutActions { + + public final String write() { + return "write"; + } + + } + + @StringPermissionsAllowedMetaAnnotation + @Singleton + public static class StringPermissionsAllowedClassLevel { + + public final String write() { + return "write"; + } + + } + + @Singleton + public static class StringPermissionsAllowedMethodLevel { + + @StringPermissionsAllowedMetaAnnotation + public final String write() { + return "write"; + } + + } + + @PermissionsAllowed("deny-all") + @Singleton + public static class MetaAnnotationOnMethodLevelOverridesClassLevel { + + @StringPermissionsAllowedMetaAnnotation + public final String write() { + return "write"; + } + + public final String other() { + return "other"; + } + } + + @StringDeniedPermissionsAllowedMetaAnnotation + @Singleton + public static class MetaAnnotationOnClassLevelOverriddenByMethodLevel { + + @PermissionsAllowed("write") + public final String write() { + return "write"; + } + + public final String other() { + return "other"; + } + + } + + @StringDeniedPermissionsAllowedMetaAnnotation + @PermissionsAllowed("repeated") + @Singleton + public static class RepeatedAnnotation { + + @StringPermissionsAllowedMetaAnnotation + public final String write() { + return "write"; + } + + public final String other() { + return "other"; + } + + } + +} diff --git a/extensions/security/deployment/src/test/java/io/quarkus/security/test/permissionsallowed/SingleNoArgsPermissionsAllowedMetaAnnotation.java b/extensions/security/deployment/src/test/java/io/quarkus/security/test/permissionsallowed/SingleNoArgsPermissionsAllowedMetaAnnotation.java new file mode 100644 index 00000000000000..c4a43187ad65b7 --- /dev/null +++ b/extensions/security/deployment/src/test/java/io/quarkus/security/test/permissionsallowed/SingleNoArgsPermissionsAllowedMetaAnnotation.java @@ -0,0 +1,14 @@ +package io.quarkus.security.test.permissionsallowed; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +import io.quarkus.security.PermissionsAllowed; + +@Retention(RetentionPolicy.RUNTIME) +@Target({ ElementType.METHOD, ElementType.TYPE }) +@PermissionsAllowed(value = "write", permission = PermissionsAllowedMetaAnnotationTest.CustomPermissionNameOnly.class) +public @interface SingleNoArgsPermissionsAllowedMetaAnnotation { +} diff --git a/extensions/security/deployment/src/test/java/io/quarkus/security/test/permissionsallowed/StringDeniedPermissionsAllowedMetaAnnotation.java b/extensions/security/deployment/src/test/java/io/quarkus/security/test/permissionsallowed/StringDeniedPermissionsAllowedMetaAnnotation.java new file mode 100644 index 00000000000000..73f7de72314c87 --- /dev/null +++ b/extensions/security/deployment/src/test/java/io/quarkus/security/test/permissionsallowed/StringDeniedPermissionsAllowedMetaAnnotation.java @@ -0,0 +1,15 @@ +package io.quarkus.security.test.permissionsallowed; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +import io.quarkus.security.PermissionsAllowed; + +@Retention(RetentionPolicy.RUNTIME) +@Target({ ElementType.METHOD, ElementType.TYPE }) +@PermissionsAllowed(value = "deny-all") +public @interface StringDeniedPermissionsAllowedMetaAnnotation { + +} diff --git a/extensions/security/deployment/src/test/java/io/quarkus/security/test/permissionsallowed/StringPermissionsAllowedMetaAnnotation.java b/extensions/security/deployment/src/test/java/io/quarkus/security/test/permissionsallowed/StringPermissionsAllowedMetaAnnotation.java new file mode 100644 index 00000000000000..e4907d0d31ef11 --- /dev/null +++ b/extensions/security/deployment/src/test/java/io/quarkus/security/test/permissionsallowed/StringPermissionsAllowedMetaAnnotation.java @@ -0,0 +1,15 @@ +package io.quarkus.security.test.permissionsallowed; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +import io.quarkus.security.PermissionsAllowed; + +@Retention(RetentionPolicy.RUNTIME) +@Target({ ElementType.METHOD, ElementType.TYPE }) +@PermissionsAllowed(value = "write") +public @interface StringPermissionsAllowedMetaAnnotation { + +} diff --git a/extensions/security/spi/src/main/java/io/quarkus/security/spi/PermissionsAllowedMetaAnnotationBuildItem.java b/extensions/security/spi/src/main/java/io/quarkus/security/spi/PermissionsAllowedMetaAnnotationBuildItem.java new file mode 100644 index 00000000000000..cfefe7aaa9cf33 --- /dev/null +++ b/extensions/security/spi/src/main/java/io/quarkus/security/spi/PermissionsAllowedMetaAnnotationBuildItem.java @@ -0,0 +1,67 @@ +package io.quarkus.security.spi; + +import java.util.List; +import java.util.Optional; + +import org.jboss.jandex.AnnotationInstance; +import org.jboss.jandex.AnnotationTarget; +import org.jboss.jandex.ClassInfo; +import org.jboss.jandex.DotName; +import org.jboss.jandex.MethodInfo; + +import io.quarkus.builder.item.SimpleBuildItem; + +/** + * Contains transitive {@link io.quarkus.security.PermissionsAllowed} instances. + * The {@link io.quarkus.security.PermissionsAllowed} annotation supports meta-annotation + * defined by users. Methods and classes annotated with these meta-annotations are collected + * and new {@link AnnotationInstance}s are created for them. + * Newly created instances are carried in the {@link #transitiveInstances} field. + */ +public final class PermissionsAllowedMetaAnnotationBuildItem extends SimpleBuildItem { + + private final List metaAnnotationNames; + private final boolean empty; + private final List transitiveInstances; + + public PermissionsAllowedMetaAnnotationBuildItem(List transitiveInstances, + List metaAnnotationNames) { + this.transitiveInstances = List.copyOf(transitiveInstances); + this.metaAnnotationNames = List.copyOf(metaAnnotationNames); + this.empty = transitiveInstances.isEmpty(); + } + + public boolean hasPermissionsAllowed(MethodInfo methodInfo) { + if (empty) { + return false; + } + return hasPermissionsAllowed(methodInfo.annotations()); + } + + public boolean hasPermissionsAllowed(ClassInfo classInfo) { + if (empty) { + return false; + } + return hasPermissionsAllowed(classInfo.declaredAnnotations()); + } + + public List getTransitiveInstances() { + return transitiveInstances; + } + + private boolean hasPermissionsAllowed(List instances) { + return instances.stream().anyMatch(ai -> metaAnnotationNames.contains(ai.name())); + } + + public Optional findPermissionsAllowedInstance(ClassInfo classInfo) { + if (empty) { + return Optional.empty(); + } + return transitiveInstances + .stream() + .filter(ai -> ai.target().kind() == AnnotationTarget.Kind.CLASS) + .filter(ai -> ai.target().asClass().name().equals(classInfo.name())) + // not repeatable on class-level, therefore we can just find the first one + .findFirst(); + } +} diff --git a/extensions/websockets-next/deployment/src/test/java/io/quarkus/websockets/next/test/security/HttpUpgradePermissionsAllowedAnnotationTest.java b/extensions/websockets-next/deployment/src/test/java/io/quarkus/websockets/next/test/security/HttpUpgradePermissionsAllowedAnnotationTest.java index 5bb272cc0c414e..e8f5b7790d24cc 100644 --- a/extensions/websockets-next/deployment/src/test/java/io/quarkus/websockets/next/test/security/HttpUpgradePermissionsAllowedAnnotationTest.java +++ b/extensions/websockets-next/deployment/src/test/java/io/quarkus/websockets/next/test/security/HttpUpgradePermissionsAllowedAnnotationTest.java @@ -35,11 +35,15 @@ public class HttpUpgradePermissionsAllowedAnnotationTest extends SecurityTestBas static final QuarkusUnitTest config = new QuarkusUnitTest() .withApplicationRoot((jar) -> jar .addClasses(Endpoint.class, WSClient.class, TestIdentityProvider.class, TestIdentityController.class, - AdminEndpoint.class, InclusiveEndpoint.class)); + AdminEndpoint.class, InclusiveEndpoint.class, MetaAnnotationEndpoint.class, + StringEndpointReadPermissionMetaAnnotation.class)); @TestHTTPResource("admin-end") URI adminEndpointUri; + @TestHTTPResource("meta-annotation") + URI metaAnnotationEndpointUri; + @TestHTTPResource("inclusive-end") URI inclusiveEndpointUri; @@ -62,6 +66,25 @@ public void testInsufficientRights() { } } + @Test + public void testMetaAnnotation() { + try (WSClient client = new WSClient(vertx)) { + CompletionException ce = assertThrows(CompletionException.class, + () -> client.connect(basicAuth("user", "user"), metaAnnotationEndpointUri)); + Throwable root = ExceptionUtil.getRootCause(ce); + assertInstanceOf(UpgradeRejectedException.class, root); + assertTrue(root.getMessage().contains("403")); + } + try (WSClient client = new WSClient(vertx)) { + client.connect(basicAuth("admin", "admin"), metaAnnotationEndpointUri); + client.waitForMessages(1); + assertEquals("ready", client.getMessages().get(0).toString()); + client.sendAndAwait("hello"); + client.waitForMessages(2); + assertEquals("hello", client.getMessages().get(1).toString()); + } + } + @Test public void testInclusivePermissions() { Stream.of("admin", "user").forEach(name -> { @@ -115,6 +138,22 @@ String echo(String message) { } + @StringEndpointReadPermissionMetaAnnotation + @WebSocket(path = "/meta-annotation") + public static class MetaAnnotationEndpoint { + + @OnOpen + String open() { + return "ready"; + } + + @OnTextMessage + String echo(String message) { + return message; + } + + } + @PermissionsAllowed(value = { "endpoint:connect", "endpoint:read" }) @WebSocket(path = "/end") public static class Endpoint { diff --git a/extensions/websockets-next/deployment/src/test/java/io/quarkus/websockets/next/test/security/StringEndpointReadPermissionMetaAnnotation.java b/extensions/websockets-next/deployment/src/test/java/io/quarkus/websockets/next/test/security/StringEndpointReadPermissionMetaAnnotation.java new file mode 100644 index 00000000000000..804ee43a90f8d1 --- /dev/null +++ b/extensions/websockets-next/deployment/src/test/java/io/quarkus/websockets/next/test/security/StringEndpointReadPermissionMetaAnnotation.java @@ -0,0 +1,15 @@ +package io.quarkus.websockets.next.test.security; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +import io.quarkus.security.PermissionsAllowed; + +@Retention(RetentionPolicy.RUNTIME) +@Target({ ElementType.METHOD, ElementType.TYPE }) +@PermissionsAllowed("endpoint:read") +public @interface StringEndpointReadPermissionMetaAnnotation { + +}