Skip to content

Commit

Permalink
Support for @PermissionsAllowed meta-annotations
Browse files Browse the repository at this point in the history
  • Loading branch information
michalvavrik committed Sep 12, 2024
1 parent 4636861 commit 02be060
Show file tree
Hide file tree
Showing 23 changed files with 878 additions and 32 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
Original file line number Diff line number Diff line change
@@ -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 {

}
Original file line number Diff line number Diff line change
@@ -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);
}
}
Original file line number Diff line number Diff line change
@@ -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 });
}
}
Original file line number Diff line number Diff line change
@@ -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();
}
}
Original file line number Diff line number Diff line change
@@ -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 {

}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -1581,11 +1582,13 @@ public void securityExceptionMappers(BuildProducer<ExceptionMapperBuildItem> exc
@BuildStep
MethodScannerBuildItem integrateEagerSecurity(Capabilities capabilities, CombinedIndexBuildItem indexBuildItem,
Optional<AuthorizationPolicyInstancesBuildItem> authorizationPolicyInstancesItemOpt,
List<EagerSecurityInterceptorMethodsBuildItem> eagerSecurityInterceptors, JaxRsSecurityConfig securityConfig) {
List<EagerSecurityInterceptorMethodsBuildItem> eagerSecurityInterceptors, JaxRsSecurityConfig securityConfig,
Optional<PermissionsAllowedMetaAnnotationBuildItem> 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;
Expand All @@ -1602,17 +1605,17 @@ public List<HandlerChainCustomizer> 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));
}
});
}
Expand All @@ -1624,24 +1627,27 @@ private static boolean shouldApplyAuthZPolicy(MethodInfo method, MethodInfo endp

private static List<HandlerChainCustomizer> createEagerSecCustomizerWithInterceptor(
Map<MethodInfo, Boolean> 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);
Expand Down Expand Up @@ -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<String> getAppPath(Optional<String> newPropertyValue) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand All @@ -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();
}
}
Loading

0 comments on commit 02be060

Please sign in to comment.