diff --git a/bom/application/pom.xml b/bom/application/pom.xml index ee11e618901341..4550cf8b7ef322 100644 --- a/bom/application/pom.xml +++ b/bom/application/pom.xml @@ -182,7 +182,7 @@ 3.2 5.14.1 5.8.0 - 2.1.0 + 2.1.1-SNAPSHOT 25.0.6 1.15.1 3.48.1 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 f666e782d89eef..0c05cc8dc6aa67 100644 --- a/docs/src/main/asciidoc/security-authorize-web-endpoints-reference.adoc +++ b/docs/src/main/asciidoc/security-authorize-web-endpoints-reference.adoc @@ -1055,8 +1055,83 @@ 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-checker]] +==== Create permission checkers + +Previous examples required that the `SecurityIdentity` possessed required permissions. +We added these permissions in the `SecurityIdentityAugmentor` and used role-to-permission mapping. +Another way to check permissions required by the `@PermissionsAllowed` security annotation is a permission checker. +The permission checkers are CDI bean methods annotated with a `@PermissionChecker`. +For example, a permission checker can be created like this: + +[source,java] +---- +package org.acme.security.rest.resource; + +import io.quarkus.security.PermissionChecker; +import io.quarkus.security.PermissionsAllowed; +import io.quarkus.security.identity.SecurityIdentity; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.Path; + +import org.jboss.resteasy.reactive.RestQuery; + +@Path("/hello") +public class HelloResource { + + @PermissionsAllowed("say-hello") <1> + @GET + public String sayHello(@RestQuery String toWhom) { + return "Hello " + toWhom; + } + + @PermissionChecker("say-hello") <2> + boolean canSayHello(SecurityIdentity identity) { <3> + var principalName = identity.getPrincipal().getName(); + return "alice".equals(principalName); + } +} +---- +<1> Permission required to access the `HelloResource#sayHello` is the `say-hello` permission. +<2> The `HelloResource#canSayHello` method authorize access to the `HelloResource#sayHello` endpoint. +<3> The `SecurityIdentity` instance can be injected into any permission checker method. + +The permission checker above requires the `SecurityIdentity` instance to authorize the `sayHello` endpoint. +Instead of declaring the `say-hello` permission checker directly on the resource, you can declare it on any CDI bean. +Sometimes it might be useful to check secured method parameters. +For example, a permission checker that accepts the `toWhom` argument can be created like this: + +[source,java] +---- +package org.acme.security.rest.resource; + +import io.quarkus.security.PermissionChecker; +import io.vertx.ext.web.RoutingContext; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; + +@ApplicationScoped +public class HelloPermissionChecker { + + @Inject + RoutingContext routingContext; <1> + + @PermissionChecker("say-hello") + boolean canSayHello(String toWhom) { + // replace following lines with your business logic + boolean sayHelloToBob = "bob".equals(toWhom); + return !sayHelloToBob; + } + +} +---- +<1> You can inject another CDI beans and use them inside a checker method. + +TIP: Permission checks are run on event loops whenever possible. +Annotate a permission checker method with the `io.smallrye.common.annotation.Blocking` annotation if you want to run the check on a worker thread. + [[permission-meta-annotation]] -=== Create permission meta-annotations +==== Create permission meta-annotations `@PermissionsAllowed` can also be used in meta-annotations. For example, a new `@CanWrite` security annotation can be created like this: @@ -1080,7 +1155,7 @@ public @interface CanWrite { <1> Any method or class annotated with the `@CanWrite` annotation is secured with this `@PermissionsAllowed` annotation instance. [[permission-bean-params]] -=== Pass `@BeanParam` parameters into a custom permission +==== Pass `@BeanParam` parameters into a custom permission Quarkus can map fields of a secured method parameters to a custom permission constructor parameters. You can use this feature to pass `jakarta.ws.rs.BeanParam` parameters into your custom permission. @@ -1096,10 +1171,9 @@ import jakarta.ws.rs.GET; import jakarta.ws.rs.Path; @Path("/hello") -public class SimpleResource { +public class HelloResource { - @PermissionsAllowed(value = "say:hello", permission = BeanParamPermission.class, - params = "beanParam.securityContext.userPrincipal.name") <1> + @PermissionsAllowed(value = "say-hello", params = "beanParam.securityContext.userPrincipal.name") <1> @GET public String sayHello(@BeanParam SimpleBeanParam beanParam) { return "Hello from " + beanParam.uriInfo.getPath(); @@ -1107,9 +1181,9 @@ public class SimpleResource { } ---- -<1> The `params` annotation attribute specifies that user principal name should be passed to the `BeanParamPermission` constructor. -Other `BeanParamPermission` constructor parameters like `customAuthorizationHeader` and `query` are matched automatically. -Quarkus identifies the `BeanParamPermission` constructor parameters among `beanParam` fields and their public accessors. +<1> The `params` annotation attribute specifies that user principal name should be passed to the `BeanParamPermissionChecker#canSayHello` method. +Other `BeanParamPermissionChecker#canSayHello` method parameters like `customAuthorizationHeader` and `query` are matched automatically. +Quarkus identifies the `BeanParamPermissionChecker#canSayHello` method parameters among `beanParam` fields and their public accessors. To avoid ambiguous resolution, automatic detection only works for the `beanParam` fields. For that reason, we had to specify path to the user principal name explicitly. @@ -1155,47 +1229,31 @@ public class SimpleBeanParam { <3> The `customAuthorizationHeader` field is not public, therefore Quarkus access this field with the `customAuthorizationHeader` accessor. That is particularly useful with Java records, where generated accessors are not prefixed with `get`. -Here is an example of the `BeanParamPermission` permission that checks user principal, custom header and query parameter: +Here is an example of a `@PermissionChecker` method that checks `say-hello` permission based on a user principal, custom header and query parameter: [source,java] ---- package org.acme.security.permission; -import java.security.Permission; - -public class BeanParamPermission extends Permission { - - private final String actions; - - public BeanParamPermission(String permissionName, String customAuthorizationHeader, String name, String query) { - super(permissionName); - this.actions = computeActions(customAuthorizationHeader, name, query); - } +import jakarta.enterprise.context.ApplicationScoped; +import io.quarkus.security.PermissionChecker; - @Override - public boolean implies(Permission p) { - boolean nameMatches = getName().equals(p.getName()); - boolean actionMatches = actions.equals(p.getActions()); - return nameMatches && actionMatches; - } +@ApplicationScoped +public class BeanParamPermissionChecker { - private static String computeActions(String customAuthorizationHeader, String name, String query) { + @PermissionChecker("say-hello") + boolean canSayHello(String customAuthorizationHeader, String name, String query) { boolean queryParamAllowedForPermissionName = checkQueryParams(query); boolean usernameWhitelisted = isUserNameWhitelisted(name); boolean customAuthorizationMatches = checkCustomAuthorization(customAuthorizationHeader); - var isAuthorized = queryParamAllowedForPermissionName && usernameWhitelisted && customAuthorizationMatches; - if (isAuthorized) { - return "hello"; - } else { - return "goodbye"; - } + return queryParamAllowedForPermissionName && usernameWhitelisted && customAuthorizationMatches; } ... } ---- -NOTE: You can pass `@BeanParam` directly into a custom permission constructor and access its fields programmatically in the constructor instead. +NOTE: You can pass `@BeanParam` directly into a `@PermissionChecker` method and access its fields programmatically. Ability to reference `@BeanParam` fields with the `@PermissionsAllowed#params` attribute is useful when you have multiple differently structured `@BeanParam` classes. == References 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 c462d96f93b247..61e42badc74ecf 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 @@ -13,7 +13,7 @@ public abstract class AbstractPermissionsAllowedTestCase { @BeforeAll public static void setupUsers() { TestIdentityController.resetRoles() - .add("admin", "admin") + .add("admin", "admin", "admin") .add("user", "user") .add("viewer", "viewer"); } @@ -190,6 +190,40 @@ public void testCustomPermissionWithAdditionalArgs_MetaAnnotation() { .statusCode(403); } + @Test + public void testPermissionCheckerDeclaredInsideResource() { + reqPermChecker("checker-inside-resource", null, false).statusCode(401); + reqPermChecker("checker-inside-resource", "user", false).statusCode(403); + reqPermChecker("checker-inside-resource", "admin", false).statusCode(200).body(Matchers.is("admin")); + } + + @Test + public void testPermissionRunOnCorrectThread() { + testPermissionRunOnCorrectThread("worker-thread"); + testPermissionRunOnCorrectThread("io-thread"); + testPermissionRunOnCorrectThread("io-thread-uni"); + testPermissionRunOnCorrectThread("worker-thread-method-args"); + testPermissionRunOnCorrectThread("io-thread-method-args"); + } + + private static void testPermissionRunOnCorrectThread(String subPath) { + reqPermChecker(subPath, "user", false).statusCode(403); + reqPermChecker(subPath, "admin", false).statusCode(200).body(Matchers.is("admin")); + reqPermChecker(subPath, "admin", true).statusCode(403); + } + + private static ValidatableResponse reqPermChecker(String path, String user, boolean addFailHeader) { + var req = RestAssured.given(); + if (user != null) { + req.auth().basic(user, user); + } + if (addFailHeader) { + // this "fail" header is about checking that we have RoutingContext available + req.header("fail", "true"); + } + return req.get("/permission-checkers/" + path).then(); + } + private static ValidatableResponse reqAutodetectedExtraArgs(String user, String place) { return RestAssured.given() .auth().basic(user, user) diff --git a/extensions/resteasy-reactive/rest/deployment/src/test/java/io/quarkus/resteasy/reactive/server/test/security/BeanParamPermissionChecker.java b/extensions/resteasy-reactive/rest/deployment/src/test/java/io/quarkus/resteasy/reactive/server/test/security/BeanParamPermissionChecker.java new file mode 100644 index 00000000000000..0c94e2fa6cd190 --- /dev/null +++ b/extensions/resteasy-reactive/rest/deployment/src/test/java/io/quarkus/resteasy/reactive/server/test/security/BeanParamPermissionChecker.java @@ -0,0 +1,29 @@ +package io.quarkus.resteasy.reactive.server.test.security; + +import jakarta.enterprise.context.RequestScoped; + +import io.quarkus.security.PermissionChecker; + +@RequestScoped +public class BeanParamPermissionChecker { + + @PermissionChecker("say-hello") + boolean canSayHello(String customAuthorizationHeader, String name, String query) { + boolean queryParamAllowedForPermissionName = checkQueryParams(query); + boolean usernameWhitelisted = isUserNameWhitelisted(name); + boolean customAuthorizationMatches = checkCustomAuthorization(customAuthorizationHeader); + return queryParamAllowedForPermissionName && usernameWhitelisted && customAuthorizationMatches; + } + + static boolean checkCustomAuthorization(String customAuthorization) { + return "customAuthorization".equals(customAuthorization); + } + + static boolean isUserNameWhitelisted(String userName) { + return "admin".equals(userName); + } + + static boolean checkQueryParams(String queryParam) { + return "myQueryParam".equals(queryParam); + } +} diff --git a/extensions/resteasy-reactive/rest/deployment/src/test/java/io/quarkus/resteasy/reactive/server/test/security/BeanParamPermissionIdentityAugmentor.java b/extensions/resteasy-reactive/rest/deployment/src/test/java/io/quarkus/resteasy/reactive/server/test/security/BeanParamPermissionIdentityAugmentor.java deleted file mode 100644 index f1bab3d31aafe3..00000000000000 --- a/extensions/resteasy-reactive/rest/deployment/src/test/java/io/quarkus/resteasy/reactive/server/test/security/BeanParamPermissionIdentityAugmentor.java +++ /dev/null @@ -1,34 +0,0 @@ -package io.quarkus.resteasy.reactive.server.test.security; - -import java.security.Permission; - -import jakarta.enterprise.context.ApplicationScoped; - -import io.quarkus.security.StringPermission; -import io.quarkus.security.identity.AuthenticationRequestContext; -import io.quarkus.security.identity.SecurityIdentity; -import io.quarkus.security.identity.SecurityIdentityAugmentor; -import io.quarkus.security.runtime.QuarkusSecurityIdentity; -import io.smallrye.mutiny.Uni; - -@ApplicationScoped -public class BeanParamPermissionIdentityAugmentor implements SecurityIdentityAugmentor { - - @Override - public Uni augment(SecurityIdentity securityIdentity, - AuthenticationRequestContext authenticationRequestContext) { - var possessedPermission = createPossessedPermission(securityIdentity); - var augmentedIdentity = QuarkusSecurityIdentity - .builder(securityIdentity) - .addPermissionChecker(requiredPermission -> Uni - .createFrom() - .item(requiredPermission.implies(possessedPermission))) - .build(); - return Uni.createFrom().item(augmentedIdentity); - } - - private Permission createPossessedPermission(SecurityIdentity securityIdentity) { - // here comes your business logic - return securityIdentity.isAnonymous() ? new StringPermission("list") : new StringPermission("read"); - } -} 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 75ca39d5992326..95197bac1bc6f4 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 @@ -15,7 +15,8 @@ public class LazyAuthPermissionsAllowedTestCase extends AbstractPermissionsAllow .addClasses(PermissionsAllowedResource.class, TestIdentityProvider.class, TestIdentityController.class, NonBlockingPermissionsAllowedResource.class, CustomPermission.class, PermissionsIdentityAugmentor.class, CustomPermissionWithExtraArgs.class, - StringPermissionsAllowedMetaAnnotation.class, CreateOrUpdate.class) + StringPermissionsAllowedMetaAnnotation.class, CreateOrUpdate.class, PermissionCheckers.class, + PermissionCheckersResource.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/OtherBeanParamPermission.java b/extensions/resteasy-reactive/rest/deployment/src/test/java/io/quarkus/resteasy/reactive/server/test/security/OtherBeanParamPermission.java deleted file mode 100644 index 76f97c4cba97fa..00000000000000 --- a/extensions/resteasy-reactive/rest/deployment/src/test/java/io/quarkus/resteasy/reactive/server/test/security/OtherBeanParamPermission.java +++ /dev/null @@ -1,60 +0,0 @@ -package io.quarkus.resteasy.reactive.server.test.security; - -import java.security.Permission; - -public class OtherBeanParamPermission extends Permission { - - private final String actions; - - public OtherBeanParamPermission(String permissionName, String customAuthorizationHeader, String name, String query) { - super(permissionName); - this.actions = computeActions(customAuthorizationHeader, name, query); - } - - @Override - public String getActions() { - return actions; - } - - @Override - public boolean implies(Permission p) { - boolean nameMatches = getName().equals(p.getName()); - boolean actionMatches = getActions().equals(p.getActions()); - return nameMatches && actionMatches; - } - - @Override - public boolean equals(Object obj) { - return false; - } - - @Override - public int hashCode() { - return 0; - } - - private static String computeActions(String customAuthorizationHeader, String name, String query) { - boolean queryParamAllowedForPermissionName = checkQueryParams(query); - boolean usernameWhitelisted = isUserNameWhitelisted(name); - boolean customAuthorizationMatches = checkCustomAuthorization(customAuthorizationHeader); - var isAuthorized = queryParamAllowedForPermissionName && usernameWhitelisted && customAuthorizationMatches; - if (isAuthorized) { - return "hello"; - } else { - return "goodbye"; - } - } - - private static boolean checkCustomAuthorization(String customAuthorization) { - return "customAuthorization".equals(customAuthorization); - } - - private static boolean isUserNameWhitelisted(String userName) { - return "admin".equals(userName); - } - - private static boolean checkQueryParams(String queryParam) { - return "myQueryParam".equals(queryParam); - } - -} diff --git a/extensions/resteasy-reactive/rest/deployment/src/test/java/io/quarkus/resteasy/reactive/server/test/security/PermissionCheckers.java b/extensions/resteasy-reactive/rest/deployment/src/test/java/io/quarkus/resteasy/reactive/server/test/security/PermissionCheckers.java new file mode 100644 index 00000000000000..ac833c634b6b5d --- /dev/null +++ b/extensions/resteasy-reactive/rest/deployment/src/test/java/io/quarkus/resteasy/reactive/server/test/security/PermissionCheckers.java @@ -0,0 +1,72 @@ +package io.quarkus.resteasy.reactive.server.test.security; + +import jakarta.enterprise.context.RequestScoped; +import jakarta.inject.Inject; +import jakarta.ws.rs.core.SecurityContext; + +import io.quarkus.runtime.BlockingOperationControl; +import io.quarkus.security.PermissionChecker; +import io.quarkus.security.identity.SecurityIdentity; +import io.smallrye.common.annotation.Blocking; +import io.smallrye.mutiny.Uni; +import io.vertx.ext.web.RoutingContext; + +@RequestScoped +public class PermissionCheckers { + + @Inject + RoutingContext routingContext; + + @Blocking + @PermissionChecker("worker-thread") + boolean canRead_WorkerThread(SecurityIdentity identity) { + if (failIfRequiredByHeader()) { + return false; + } + boolean isAdmin = identity.hasRole("admin"); + return BlockingOperationControl.isBlockingAllowed() && isAdmin; + } + + @PermissionChecker("io-thread") + boolean canRead_IOThread(SecurityIdentity identity) { + if (failIfRequiredByHeader()) { + return false; + } + boolean isAdmin = identity.hasRole("admin"); + if (!isAdmin) { + return false; + } + return !BlockingOperationControl.isBlockingAllowed(); + } + + @PermissionChecker("io-thread-uni") + Uni canRead_IOThread_Uni(SecurityIdentity identity) { + if (failIfRequiredByHeader()) { + return Uni.createFrom().item(false); + } + return Uni.createFrom().item(canRead_IOThread(identity)); + } + + @Blocking + @PermissionChecker("worker-thread-method-args") + boolean canRead_WorkerThread_SecuredMethodArg(SecurityContext securityContext) { + if (failIfRequiredByHeader()) { + return false; + } + boolean isAdmin = securityContext.isUserInRole("admin"); + return BlockingOperationControl.isBlockingAllowed() && isAdmin; + } + + @PermissionChecker("io-thread-method-args") + boolean canRead_IOThread_SecuredMethodArg(SecurityContext securityContext) { + if (failIfRequiredByHeader()) { + return false; + } + boolean isAdmin = securityContext.isUserInRole("admin"); + return !BlockingOperationControl.isBlockingAllowed() && isAdmin; + } + + private boolean failIfRequiredByHeader() { + return Boolean.parseBoolean(routingContext.request().getHeader("fail")); + } +} diff --git a/extensions/resteasy-reactive/rest/deployment/src/test/java/io/quarkus/resteasy/reactive/server/test/security/PermissionCheckersResource.java b/extensions/resteasy-reactive/rest/deployment/src/test/java/io/quarkus/resteasy/reactive/server/test/security/PermissionCheckersResource.java new file mode 100644 index 00000000000000..98cdc06d261174 --- /dev/null +++ b/extensions/resteasy-reactive/rest/deployment/src/test/java/io/quarkus/resteasy/reactive/server/test/security/PermissionCheckersResource.java @@ -0,0 +1,60 @@ +package io.quarkus.resteasy.reactive.server.test.security; + +import jakarta.ws.rs.GET; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.core.SecurityContext; + +import io.quarkus.security.PermissionChecker; +import io.quarkus.security.PermissionsAllowed; +import io.smallrye.mutiny.Uni; + +@Path("permission-checkers") +public class PermissionCheckersResource { + + @PermissionsAllowed("worker-thread") + @GET + @Path("worker-thread") + public String workerThread(SecurityContext securityContext) { + return securityContext.getUserPrincipal().getName(); + } + + @PermissionsAllowed("io-thread") + @GET + @Path("io-thread") + public String ioThread(SecurityContext securityContext) { + return securityContext.getUserPrincipal().getName(); + } + + @PermissionsAllowed("io-thread-uni") + @GET + @Path("io-thread-uni") + public String ioThreadUni(SecurityContext securityContext) { + return securityContext.getUserPrincipal().getName(); + } + + @PermissionsAllowed("worker-thread-method-args") + @GET + @Path("worker-thread-method-args") + public String workerThreadMethodArgs(SecurityContext securityContext) { + return securityContext.getUserPrincipal().getName(); + } + + @PermissionsAllowed("io-thread-method-args") + @GET + @Path("io-thread-method-args") + public Uni ioThreadMethodArgs(SecurityContext securityContext) { + return Uni.createFrom().item(securityContext.getUserPrincipal().getName()); + } + + @PermissionsAllowed("checker-inside-resource") + @GET + @Path("checker-inside-resource") + public String checkerInsideResource(SecurityContext securityContext) { + return securityContext.getUserPrincipal().getName(); + } + + @PermissionChecker("checker-inside-resource") + boolean hasAdminRole(SecurityContext securityContext) { + return securityContext.isUserInRole("admin"); + } +} diff --git a/extensions/resteasy-reactive/rest/deployment/src/test/java/io/quarkus/resteasy/reactive/server/test/security/PermissionsAllowedBeanParamTest.java b/extensions/resteasy-reactive/rest/deployment/src/test/java/io/quarkus/resteasy/reactive/server/test/security/PermissionsAllowedBeanParamTest.java index bcec207dd16ca6..0c1504a4216747 100644 --- a/extensions/resteasy-reactive/rest/deployment/src/test/java/io/quarkus/resteasy/reactive/server/test/security/PermissionsAllowedBeanParamTest.java +++ b/extensions/resteasy-reactive/rest/deployment/src/test/java/io/quarkus/resteasy/reactive/server/test/security/PermissionsAllowedBeanParamTest.java @@ -1,8 +1,5 @@ package io.quarkus.resteasy.reactive.server.test.security; -import java.security.BasicPermission; -import java.security.Permission; - import jakarta.ws.rs.BeanParam; import jakarta.ws.rs.GET; import jakarta.ws.rs.POST; @@ -28,24 +25,13 @@ public class PermissionsAllowedBeanParamTest { .withApplicationRoot((jar) -> jar .addClasses(TestIdentityProvider.class, TestIdentityController.class, SimpleBeanParam.class, SimpleResource.class, SimpleBeanParamPermission.class, MyPermission.class, MyBeanParam.class, - OtherBeanParamPermission.class, OtherBeanParam.class)); + BeanParamPermissionChecker.class, OtherBeanParam.class)); @BeforeAll public static void setupUsers() { - var sayHelloPossessedPerm = new BasicPermission("say", "hello") { - @Override - public boolean implies(Permission p) { - return getName().equals(p.getName()) && getActions().equals(p.getActions()); - } - - @Override - public String getActions() { - return "hello"; - } - }; TestIdentityController.resetRoles() - .add("admin", "admin", SimpleBeanParamPermission.EMPTY, MyPermission.EMPTY, sayHelloPossessedPerm) - .add("user", "user", sayHelloPossessedPerm); + .add("admin", "admin", SimpleBeanParamPermission.EMPTY, MyPermission.EMPTY) + .add("user", "user"); } @Test @@ -156,7 +142,7 @@ public String recordBeanParam(@BeanParam MyBeanParam beanParam) { return "OK"; } - @PermissionsAllowed(value = "say:hello", permission = OtherBeanParamPermission.class, params = "otherBeanParam.securityContext.userPrincipal.name") + @PermissionsAllowed(value = "say-hello", params = "otherBeanParam.securityContext.userPrincipal.name") @Path("/autodetect-params") @GET public String autodetectedParams(String payload, @BeanParam OtherBeanParam otherBeanParam) { 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 41f80d5cea8760..f7f2d3c8d306f6 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 @@ -14,6 +14,7 @@ public class ProactiveAuthPermissionsAllowedTestCase extends AbstractPermissions .addClasses(PermissionsAllowedResource.class, TestIdentityProvider.class, TestIdentityController.class, NonBlockingPermissionsAllowedResource.class, CustomPermission.class, PermissionsIdentityAugmentor.class, CustomPermissionWithExtraArgs.class, - StringPermissionsAllowedMetaAnnotation.class, CreateOrUpdate.class)); + StringPermissionsAllowedMetaAnnotation.class, CreateOrUpdate.class, PermissionCheckers.class, + PermissionCheckersResource.class)); } 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 4a329999fb2d6a..b7f2950502ed74 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 @@ -1,16 +1,22 @@ package io.quarkus.security.deployment; +import static io.quarkus.arc.processor.DotNames.BOOLEAN; import static io.quarkus.arc.processor.DotNames.STRING; +import static io.quarkus.arc.processor.DotNames.UNI; +import static io.quarkus.gizmo.Type.classType; +import static io.quarkus.gizmo.Type.parameterizedType; 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.lang.annotation.RetentionPolicy; import java.lang.invoke.MethodHandle; import java.lang.reflect.Modifier; import java.security.Permission; import java.util.ArrayList; import java.util.Arrays; +import java.util.Collections; import java.util.Comparator; import java.util.HashMap; import java.util.HashSet; @@ -20,6 +26,7 @@ import java.util.Set; import java.util.function.Function; import java.util.function.Predicate; +import java.util.stream.Stream; import org.jboss.jandex.AnnotationInstance; import org.jboss.jandex.AnnotationTarget; @@ -27,25 +34,38 @@ import org.jboss.jandex.DotName; import org.jboss.jandex.IndexView; import org.jboss.jandex.MethodInfo; +import org.jboss.jandex.PrimitiveType.Primitive; import org.jboss.jandex.Type; +import org.jboss.jandex.VoidType; import io.quarkus.deployment.GeneratedClassGizmoAdaptor; import io.quarkus.deployment.annotations.BuildProducer; import io.quarkus.deployment.builditem.GeneratedClassBuildItem; import io.quarkus.deployment.builditem.nativeimage.ReflectiveClassBuildItem; import io.quarkus.gizmo.ClassCreator; +import io.quarkus.gizmo.DescriptorUtils; +import io.quarkus.gizmo.FieldDescriptor; import io.quarkus.gizmo.MethodCreator; +import io.quarkus.gizmo.MethodDescriptor; import io.quarkus.gizmo.ResultHandle; +import io.quarkus.gizmo.SignatureBuilder; import io.quarkus.runtime.RuntimeValue; +import io.quarkus.security.PermissionChecker; import io.quarkus.security.PermissionsAllowed; import io.quarkus.security.StringPermission; +import io.quarkus.security.identity.SecurityIdentity; +import io.quarkus.security.runtime.QuarkusPermission; 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; +import io.smallrye.common.annotation.Blocking; interface PermissionSecurityChecks { + DotName PERMISSION_CHECKER_NAME = DotName.createSimple(PermissionChecker.class); + DotName BLOCKING = DotName.createSimple(Blocking.class); + Map getMethodSecurityChecks(); Map getClassNameSecurityChecks(); @@ -57,18 +77,184 @@ final class PermissionSecurityChecksBuilder { private static final DotName STRING_PERMISSION = DotName.createSimple(StringPermission.class); private static final DotName PERMISSIONS_ALLOWED_INTERCEPTOR = DotName .createSimple(PermissionsAllowedInterceptor.class); + private static final String PERMISSION_ATTR = "permission"; + private static final String IS_GRANTED_UNI = "isGrantedUni"; + private static final String IS_GRANTED = "isGranted"; + private static final DotName SECURITY_IDENTITY_NAME = DotName.createSimple(SecurityIdentity.class); + private static final String SECURED_METHOD_PARAMETER = "securedMethodParameter"; private final Map>> targetToPermissionKeys = new HashMap<>(); private final Map targetToPredicate = new HashMap<>(); private final Map classSignatureToConstructor = new HashMap<>(); - private final SecurityCheckRecorder recorder; - private final PermissionConverterGenerator paramConverterGenerator; + private final IndexView index; + private final List permissionInstances; + private final Map permissionNameToChecker; + private volatile SecurityCheckRecorder recorder; + private volatile PermissionConverterGenerator paramConverterGenerator; - public PermissionSecurityChecksBuilder(SecurityCheckRecorder recorder, + PermissionSecurityChecksBuilder(IndexView index, PermissionsAllowedMetaAnnotationBuildItem metaAnnotationItem) { + this.index = index; + var instances = getPermissionsAllowedInstances(index, metaAnnotationItem); + // make sure we process annotations on methods first + instances.sort(new Comparator() { + @Override + public int compare(AnnotationInstance o1, AnnotationInstance o2) { + if (o1.target().kind() != o2.target().kind()) { + return o1.target().kind() == AnnotationTarget.Kind.METHOD ? -1 : 1; + } + // variable 'instances' won't be modified + return 0; + } + }); + // this needs to be immutable as build steps that gather security checks + // and produce permission augmenter can and did in past run concurrently + this.permissionInstances = Collections.unmodifiableList(instances); + this.permissionNameToChecker = Collections.unmodifiableMap(getPermissionCheckers(index)); + } + + private static Map getPermissionCheckers(IndexView index) { + int permissionCheckerIndex = 0; // this ensures generated QuarkusPermission name is unique + var permissionCheckers = new HashMap(); + for (var annotationInstance : index.getAnnotations(PERMISSION_CHECKER_NAME)) { + var checkerMethod = annotationInstance.target().asMethod(); + if (Modifier.isPrivate(checkerMethod.flags())) { + // we generate QuarkusPermission in the same package as where the @PermissionChecker is detected + // so the checker method must be either public or package-private + throw new RuntimeException("Private method '" + toString(checkerMethod) + + "' cannot be annotated with the @PermissionChecker annotation"); + } + if (Modifier.isStatic(checkerMethod.flags())) { + // checkers must be CDI bean member methods for now, so the checker method must not be static + throw new RuntimeException("Static method '" + toString(checkerMethod) + + "' cannot be annotated with the @PermissionChecker annotation"); + } + boolean isReactive = isUniBoolean(checkerMethod); + if (!isReactive && !isPrimitiveBoolean(checkerMethod)) { + throw new RuntimeException(("@PermissionChecker method '%s' has return type '%s', but only " + + "supported return types are 'boolean' and 'Uni'. ") + .formatted(toString(checkerMethod), checkerMethod.returnType().name())); + } + var permissionToActions = parsePermissionToActions(annotationInstance.value().asString(), new HashMap<>()) + .entrySet().iterator().next(); + + var permissionName = permissionToActions.getKey(); + if (permissionName.isBlank()) { + throw new IllegalArgumentException( + "@PermissionChecker annotation placed on the '%s' attribute 'value' must not be blank" + .formatted(toString(checkerMethod))); + } + var permissionActions = permissionToActions.getValue(); + if (permissionActions != null && !permissionActions.isEmpty()) { + throw new IllegalArgumentException(""" + @PermissionChecker annotation instance placed on the '%s' has attribute 'value' with + permission name '%s' and actions '%s', however actions are currently not supported + """.formatted(toString(checkerMethod), permissionName, permissionActions)); + } + boolean isBlocking = checkerMethod.hasDeclaredAnnotation(BLOCKING); + if (isBlocking && isReactive) { + throw new IllegalArgumentException(""" + @PermissionChecker annotation instance placed on the '%s' returns 'Uni' and is + annotated with the @Blocking annotation; if you need to block, please return 'boolean' + """.formatted(toString(checkerMethod))); + } + + var generatedPermissionClassName = getGeneratedPermissionName(checkerMethod, permissionCheckerIndex++); + var methodParamMappers = new MethodParameterMapper[checkerMethod.parametersCount()]; + var generatedPermissionConstructor = getGeneratedPermissionConstructor(checkerMethod, methodParamMappers); + var checkerMetadata = new PermissionCheckerMetadata(checkerMethod, generatedPermissionClassName, + isReactive, generatedPermissionConstructor, methodParamMappers, isBlocking); + + if (permissionCheckers.containsKey(permissionName)) { + throw new IllegalArgumentException(""" + Detected two @PermissionChecker annotations with same value '%s', annotated methods are: + - %s + - %s + """ + .formatted(annotationInstance.value().asString(), toString(checkerMethod), + toString(permissionCheckers.get(permissionName).checkerMethod()))); + } + + permissionCheckers.put(permissionName, checkerMetadata); + } + return permissionCheckers; + } + + private static boolean isUniBoolean(MethodInfo checkerMethod) { + if (checkerMethod.returnType().kind() == Type.Kind.PARAMETERIZED_TYPE) { + var parametrizedType = checkerMethod.returnType().asParameterizedType(); + boolean returnsUni = UNI.equals(parametrizedType.name()); + boolean booleanArg = parametrizedType.arguments().size() == 1 + && BOOLEAN.equals(parametrizedType.arguments().get(0).name()); + return returnsUni && booleanArg; + } + return false; + } + + private static boolean isPrimitiveBoolean(MethodInfo checkerMethod) { + return checkerMethod.returnType().kind() == Type.Kind.PRIMITIVE + && Primitive.BOOLEAN.equals(checkerMethod.returnType().asPrimitiveType().primitive()); + } + + private static MethodInfo getGeneratedPermissionConstructor(MethodInfo checkerMethod, + MethodParameterMapper[] paramMappers) { + if (!checkerMethod.exceptions().isEmpty()) { + throw new RuntimeException("@PermissionChecker method '%s' declares checked exceptions which is not allowed" + .formatted(toString(checkerMethod))); + } + if (checkerMethod.parametersCount() == 0) { + throw new RuntimeException( + "@PermissionChecker method '%s' must have at least one parameter".formatted(toString(checkerMethod))); + } + + // Permission constructor: permission name, <>... + // Permission checker method: [optionally at any place SecurityIdentity], <>... + // that is constructor param length great or equal to checker method param length + int constructorParameterCount = checkerMethod.parametersCount() + (hasSecurityIdentityParam(checkerMethod) ? 0 : 1); + final Type[] constructorParameterTypes = new Type[constructorParameterCount]; + final String[] constructorParameterNames = new String[constructorParameterCount]; + + constructorParameterNames[0] = "permissionName"; + constructorParameterTypes[0] = Type.create(String.class); + + for (int i = 0, j = 1; i < checkerMethod.parametersCount(); i++) { + var parameterType = checkerMethod.parameterType(i); + if (SECURITY_IDENTITY_NAME.equals(parameterType.name())) { + paramMappers[i] = new MethodParameterMapper(i, MethodParameterMapper.SECURITY_IDENTITY_IDX); + } else { + constructorParameterTypes[j] = parameterType; + constructorParameterNames[j] = checkerMethod.parameterName(i); + paramMappers[i] = new MethodParameterMapper(i, j); + j++; + } + } + + return MethodInfo.create(checkerMethod.declaringClass(), "", constructorParameterNames, + constructorParameterTypes, VoidType.VOID, (short) Modifier.PUBLIC, null, null); + } + + private static boolean hasSecurityIdentityParam(MethodInfo checkerMethod) { + return checkerMethod + .parameterTypes() + .stream() + .filter(t -> t.kind() == Type.Kind.CLASS) + .map(Type::name) + .anyMatch(SECURITY_IDENTITY_NAME::equals); + } + + private static String getGeneratedPermissionName(MethodInfo checkerMethod, int i) { + return checkerMethod.declaringClass() + "_QuarkusPermission_" + checkerMethod.name() + "_" + i; + } + + boolean foundPermissionsAllowedInstances() { + return !permissionInstances.isEmpty(); + } + + PermissionSecurityChecksBuilder prepareParamConverterGenerator(SecurityCheckRecorder recorder, BuildProducer generatedClassesProducer, - BuildProducer reflectiveClassesProducer, IndexView index) { + BuildProducer reflectiveClassesProducer) { this.recorder = recorder; this.paramConverterGenerator = new PermissionConverterGenerator(generatedClassesProducer, reflectiveClassesProducer, recorder, index); + return this; } PermissionSecurityChecks build() { @@ -111,7 +297,7 @@ public Set permissionClasses() { * Creates predicate for each secured method. Predicates are cached if possible. * What we call predicate here is combination of (possibly computed) {@link Permission}s joined with * logical operators 'AND' or 'OR'. - * + *

* For example, combination of following 2 annotation instances: * *

@@ -181,12 +367,21 @@ private boolean isInclusive(List permissionKeys) {
             return permissionKeys.get(0).inclusive;
         }
 
-        PermissionSecurityChecksBuilder validatePermissionClasses(IndexView index) {
+        PermissionSecurityChecksBuilder validatePermissionClasses() {
             for (List> keyLists : targetToPermissionKeys.values()) {
                 for (List keyList : keyLists) {
                     for (PermissionKey key : keyList) {
                         if (!classSignatureToConstructor.containsKey(key.classSignature())) {
 
+                            if (key.permissionChecker != null) {
+                                // QuarkusPermission we generated for the @PermissionChecker
+                                // won't be in the index and as we generated it, we don't need
+                                // to validate it
+                                classSignatureToConstructor.put(key.classSignature(),
+                                        key.permissionChecker.quarkusPermissionConstructor());
+                                continue;
+                            }
+
                             // validate permission class
                             final ClassInfo clazz = index.getClassByName(key.clazz.name());
                             Objects.requireNonNull(clazz);
@@ -214,27 +409,15 @@ PermissionSecurityChecksBuilder validatePermissionClasses(IndexView index) {
             return this;
         }
 
-        PermissionSecurityChecksBuilder gatherPermissionsAllowedAnnotations(List instances,
+        PermissionSecurityChecksBuilder gatherPermissionsAllowedAnnotations(
                 Map alreadyCheckedMethods,
                 Map alreadyCheckedClasses,
                 List additionalClassInstances,
                 Predicate hasAdditionalSecurityAnnotations) {
 
-            // make sure we process annotations on methods first
-            instances.sort(new Comparator() {
-                @Override
-                public int compare(AnnotationInstance o1, AnnotationInstance o2) {
-                    if (o1.target().kind() != o2.target().kind()) {
-                        return o1.target().kind() == AnnotationTarget.Kind.METHOD ? -1 : 1;
-                    }
-                    // variable 'instances' won't be modified
-                    return 0;
-                }
-            });
-
             List cache = new ArrayList<>();
             Map>> classMethodToPermissionKeys = new HashMap<>();
-            for (AnnotationInstance instance : instances) {
+            for (AnnotationInstance instance : permissionInstances) {
 
                 AnnotationTarget target = instance.target();
                 if (target.kind() == AnnotationTarget.Kind.METHOD) {
@@ -306,7 +489,7 @@ public int compare(AnnotationInstance o1, AnnotationInstance o2) {
             }
 
             // for validation purposes, so that we detect correctly combinations with other security annotations
-            var targetInstances = new ArrayList<>(instances);
+            var targetInstances = new ArrayList<>(permissionInstances);
             targetInstances.addAll(additionalClassInstances);
             targetToPermissionKeys.keySet().forEach(at -> {
                 if (at.kind() == AnnotationTarget.Kind.CLASS) {
@@ -332,7 +515,7 @@ static boolean isPermissionsAllowedInterceptor(ClassInfo clazz) {
                     || clazz.name().toString().endsWith("PermissionsAllowedInterceptor");
         }
 
-        static ArrayList getPermissionsAllowedInstances(IndexView index,
+        private static ArrayList getPermissionsAllowedInstances(IndexView index,
                 PermissionsAllowedMetaAnnotationBuildItem item) {
             var instances = getPermissionsAllowedInstances(index);
             if (!item.getTransitiveInstances().isEmpty()) {
@@ -383,38 +566,13 @@ private static AnnotationInstance getAnnotationInstance(MethodInfo methodInfo,
                     .orElse(null);
         }
 
-        private static  void gatherPermissionKeys(AnnotationInstance instance, T annotationTarget,
-                List cache,
-                Map>> targetToPermissionKeys) {
+        private  void gatherPermissionKeys(AnnotationInstance instance, T annotationTarget,
+                List cache, Map>> targetToPermissionKeys) {
             // @PermissionsAllowed value is in format permission:action, permission2:action, permission:action2, permission3
             // here we transform it to permission -> actions
             final var permissionToActions = new HashMap>();
             for (String permissionToAction : instance.value().asStringArray()) {
-                if (permissionToAction.contains(PERMISSION_TO_ACTION_SEPARATOR)) {
-
-                    // expected format: permission:action
-                    final String[] permissionToActionArr = permissionToAction.split(PERMISSION_TO_ACTION_SEPARATOR);
-                    if (permissionToActionArr.length != 2) {
-                        throw new RuntimeException(String.format(
-                                "PermissionsAllowed value '%s' contains more than one separator '%2$s', expected format is 'permissionName%2$saction'",
-                                permissionToAction, PERMISSION_TO_ACTION_SEPARATOR));
-                    }
-                    final String permissionName = permissionToActionArr[0];
-                    final String action = permissionToActionArr[1];
-                    if (permissionToActions.containsKey(permissionName)) {
-                        permissionToActions.get(permissionName).add(action);
-                    } else {
-                        final Set actions = new HashSet<>();
-                        actions.add(action);
-                        permissionToActions.put(permissionName, actions);
-                    }
-                } else {
-
-                    // expected format: permission
-                    if (!permissionToActions.containsKey(permissionToAction)) {
-                        permissionToActions.put(permissionToAction, new HashSet<>());
-                    }
-                }
+                parsePermissionToActions(permissionToAction, permissionToActions);
             }
 
             if (permissionToActions.isEmpty()) {
@@ -433,12 +591,14 @@ private static  void gatherPermissionKeys(Annotation
             final List orPermissions = new ArrayList<>();
             final String[] params = instance.value("params") == null ? new String[] { PermissionsAllowed.AUTODETECTED }
                     : instance.value("params").asStringArray();
-            final Type classType = instance.value("permission") == null ? Type.create(STRING_PERMISSION, Type.Kind.CLASS)
-                    : instance.value("permission").asClass();
+            final Type classType = getPermissionClass(instance);
             final boolean inclusive = instance.value("inclusive") != null && instance.value("inclusive").asBoolean();
             for (var permissionToAction : permissionToActions.entrySet()) {
-                final var key = new PermissionKey(permissionToAction.getKey(), permissionToAction.getValue(), params,
-                        classType, inclusive);
+                final var permissionName = permissionToAction.getKey();
+                final var permissionActions = permissionToAction.getValue();
+                final var permissionChecker = findPermissionChecker(permissionName, permissionActions);
+                final var key = new PermissionKey(permissionName, permissionActions, params, classType, inclusive,
+                        permissionChecker, annotationTarget);
                 final int i = cache.indexOf(key);
                 if (i == -1) {
                     orPermissions.add(key);
@@ -454,6 +614,296 @@ private static  void gatherPermissionKeys(Annotation
                     .add(List.copyOf(orPermissions));
         }
 
+        private static HashMap> parsePermissionToActions(String permissionToAction,
+                HashMap> permissionToActions) {
+            if (permissionToAction.contains(PERMISSION_TO_ACTION_SEPARATOR)) {
+
+                // expected format: permission:action
+                final String[] permissionToActionArr = permissionToAction.split(PERMISSION_TO_ACTION_SEPARATOR);
+                if (permissionToActionArr.length != 2) {
+                    throw new RuntimeException(String.format(
+                            "PermissionsAllowed value '%s' contains more than one separator '%2$s', expected format is 'permissionName%2$saction'",
+                            permissionToAction, PERMISSION_TO_ACTION_SEPARATOR));
+                }
+                final String permissionName = permissionToActionArr[0];
+                final String action = permissionToActionArr[1];
+                if (permissionToActions.containsKey(permissionName)) {
+                    permissionToActions.get(permissionName).add(action);
+                } else {
+                    final Set actions = new HashSet<>();
+                    actions.add(action);
+                    permissionToActions.put(permissionName, actions);
+                }
+            } else {
+
+                // expected format: permission
+                if (!permissionToActions.containsKey(permissionToAction)) {
+                    permissionToActions.put(permissionToAction, new HashSet<>());
+                }
+            }
+            return permissionToActions;
+        }
+
+        private PermissionCheckerMetadata findPermissionChecker(String permissionName, Set permissionActions) {
+            if (permissionActions != null && !permissionActions.isEmpty()) {
+                // only permission name is supported for now
+                return null;
+            }
+            return permissionNameToChecker.get(permissionName);
+        }
+
+        private static Type getPermissionClass(AnnotationInstance instance) {
+            return instance.value(PERMISSION_ATTR) == null ? Type.create(STRING_PERMISSION, Type.Kind.CLASS)
+                    : instance.value(PERMISSION_ATTR).asClass();
+        }
+
+        boolean foundPermissionChecker() {
+            return !permissionNameToChecker.isEmpty();
+        }
+
+        List getPermissionChecker() {
+            return permissionNameToChecker.values().stream().map(PermissionCheckerMetadata::checkerMethod).toList();
+        }
+
+        /**
+         * This method for each detected {@link PermissionChecker} annotation instance generate following class:
+         *
+         * 
+         * {@code
+         * public final class GeneratedQuarkusPermission extends QuarkusPermission {
+         *
+         *     private final SomeDto securedMethodParameter1;
+         *
+         *     public GeneratedQuarkusPermission(String permissionName, SomeDto securedMethodParameter1) {
+         *         super("io.quarkus.security.runtime.GeneratedQuarkusPermission");
+         *         this.securedMethodParameter1 = securedMethodParameter1;
+         *     }
+         *
+         *     @Override
+         *     protected final boolean isGranted(SecurityIdentity securityIdentity) {
+         *         return getBean().hasPermission(securityIdentity, securedMethodParameter1);
+         *     }
+         *
+         *     // or same method with Uni depending on the 'hasPermission' return type
+         *     @Override
+         *     protected final Uni isGrantedUni(SecurityIdentity securityIdentity) {
+         *         return getBean().hasPermission(securityIdentity, securedMethodParameter1);
+         *     }
+         *
+         *     @Override
+         *     protected final Class getBeanClass() {
+         *         return io.quarkus.security.runtime.GeneratedQuarkusPermission.class;
+         *     }
+         *
+         *     @Override
+         *     protected final boolean isBlocking() {
+         *         return false; // true when checker method annotated with @Blocking
+         *     }
+         *
+         *     @Override
+         *     protected final boolean isReactive() {
+         *         return false; // true when checker method returns Uni
+         *     }
+         *
+         * }
+         * }
+         * 
+ * + * The {@code CheckerBean} in question can look like this: + * + *
+         * {@code
+         * @Singleton
+         * public class CheckerBean {
+         *
+         *     @PermissionChecker("permission-name")
+         *     boolean isGranted(SecurityIdentity securityIdentity, SomeDto someDto) {
+         *         return false;
+         *     }
+         *
+         * }
+         * }
+         * 
+ */ + void generatePermissionCheckers(BuildProducer generatedClassProducer) { + permissionNameToChecker.values().forEach(checkerMetadata -> { + var declaringCdiBean = checkerMetadata.checkerMethod().declaringClass(); + var declaringCdiBeanType = classType(declaringCdiBean.name()); + var generatedClassName = checkerMetadata.generatedClassName(); + try (var classCreator = ClassCreator.builder() + .classOutput(new GeneratedClassGizmoAdaptor(generatedClassProducer, true)) + .setFinal(true) + .className(generatedClassName) + .signature(SignatureBuilder + .forClass() + // extends QuarkusPermission + // XYZ == @PermissionChecker declaring class + .setSuperClass(parameterizedType(classType(QuarkusPermission.class), declaringCdiBeanType))) + .build()) { + + record SecuredMethodParamDesc(FieldDescriptor fieldDescriptor, int ctorParamIdx) { + SecuredMethodParamDesc() { + this(null, -1); + } + + boolean isNotSecurityIdentity() { + return fieldDescriptor != null; + } + } + SecuredMethodParamDesc[] securedMethodParams = new SecuredMethodParamDesc[checkerMetadata + .methodParamMappers().length]; + for (int i = 0; i < checkerMetadata.methodParamMappers.length; i++) { + var paramMapper = checkerMetadata.methodParamMappers[i]; + if (paramMapper.isSecurityIdentity()) { + securedMethodParams[i] = new SecuredMethodParamDesc(); + } else { + // GENERATED CODE: private final SomeDto securedMethodParameter1; + var fieldName = SECURED_METHOD_PARAMETER + paramMapper.securedMethodIdx(); + var ctorParamIdx = paramMapper.permConstructorIdx(); + var fieldTypeName = checkerMetadata.quarkusPermissionConstructor().parameterType(ctorParamIdx) + .name(); + var fieldCreator = classCreator.getFieldCreator(fieldName, fieldTypeName.toString()); + fieldCreator.setModifiers(Modifier.PRIVATE | Modifier.FINAL); + securedMethodParams[i] = new SecuredMethodParamDesc(fieldCreator.getFieldDescriptor(), + ctorParamIdx); + } + } + + // public GeneratedQuarkusPermission(String permissionName, SomeDto securedMethodParameter1) { + // super("io.quarkus.security.runtime.GeneratedQuarkusPermission"); + // this.securedMethodParameter1 = securedMethodParameter1; + // } + // How many 'securedMethodParameterXYZ' are there depends on the secured method + var ctorParams = Stream.concat(Stream.of(String.class.getName()), Arrays + .stream(securedMethodParams) + .filter(SecuredMethodParamDesc::isNotSecurityIdentity) + .map(SecuredMethodParamDesc::fieldDescriptor) + .map(FieldDescriptor::getType)).toArray(String[]::new); + try (var ctor = classCreator.getConstructorCreator(ctorParams)) { + ctor.setModifiers(Modifier.PUBLIC); + + // GENERATED CODE: super("io.quarkus.security.runtime.GeneratedQuarkusPermission"); + // why not to propagate permission name to the java.security.Permission ? + // if someone declares @PermissionChecker("permission-name-1") we expect that required permission + // @PermissionAllowed("permission-name-1") is only granted by the checker method and accidentally some + // user-defined augmentor won't grant it based on permission name match in case they misunderstand docs + var superCtorDesc = MethodDescriptor.ofConstructor(classCreator.getSuperClass(), String.class); + ctor.invokeSpecialMethod(superCtorDesc, ctor.getThis(), ctor.load(generatedClassName)); + + // GENERATED CODE: this.securedMethodParameterXYZ = securedMethodParameterXYZ; + for (var securedMethodParamDesc : securedMethodParams) { + if (securedMethodParamDesc.isNotSecurityIdentity()) { + var field = securedMethodParamDesc.fieldDescriptor(); + var constructorParameter = ctor.getMethodParam(securedMethodParamDesc.ctorParamIdx()); + ctor.writeInstanceField(field, ctor.getThis(), constructorParameter); + } + } + + ctor.returnVoid(); + } + + // @Override + // protected final boolean isGranted(SecurityIdentity securityIdentity) { + // return getBean().hasPermission(securityIdentity, securedMethodParameter1); + // } + // or when user-defined permission checker returns Uni: + // @Override + // protected final Uni isGrantedUni(SecurityIdentity securityIdentity) { + // return getBean().hasPermission(securityIdentity, securedMethodParameter1); + // } + var isGrantedName = checkerMetadata.reactive() ? IS_GRANTED_UNI : IS_GRANTED; + var isGrantedReturn = DescriptorUtils.typeToString(checkerMetadata.checkerMethod().returnType()); + try (var methodCreator = classCreator.getMethodCreator(isGrantedName, isGrantedReturn, + SecurityIdentity.class)) { + methodCreator.setModifiers(Modifier.PROTECTED | Modifier.FINAL); + methodCreator.addAnnotation(Override.class.getName(), RetentionPolicy.CLASS); + + // getBean() + var getBeanDescriptor = MethodDescriptor.ofMethod(generatedClassName, "getBean", Object.class); + var cdiBean = methodCreator.invokeVirtualMethod(getBeanDescriptor, methodCreator.getThis()); + + // <>.hasPermission(securityIdentity, securedMethodParameter1) + var isGrantedDescriptor = MethodDescriptor.of(checkerMetadata.checkerMethod()); + var securedMethodParamHandles = new ResultHandle[securedMethodParams.length]; + for (int i = 0; i < securedMethodParams.length; i++) { + var securedMethodParam = securedMethodParams[i]; + if (securedMethodParam.isNotSecurityIdentity()) { + // QuarkusPermission field assigned in the permission constructor + // for example: this.securedMethodParameter1 + securedMethodParamHandles[i] = methodCreator + .readInstanceField(securedMethodParam.fieldDescriptor(), methodCreator.getThis()); + } else { + // SecurityIdentity from QuarkusPermission#isGranted method parameter + securedMethodParamHandles[i] = methodCreator.getMethodParam(0); + } + } + final ResultHandle result; + if (checkerMetadata.checkerMethod.isDefault()) { + result = methodCreator.invokeInterfaceMethod(isGrantedDescriptor, cdiBean, + securedMethodParamHandles); + } else { + result = methodCreator.invokeVirtualMethod(isGrantedDescriptor, cdiBean, securedMethodParamHandles); + } + + // return 'hasPermission' result + methodCreator.returnValue(result); + } + var alwaysFalseName = checkerMetadata.reactive() ? IS_GRANTED : IS_GRANTED_UNI; + var alwaysFalseType = checkerMetadata.reactive() ? boolean.class.getName() : UNI.toString(); + try (var methodCreator = classCreator.getMethodCreator(alwaysFalseName, alwaysFalseType, + SecurityIdentity.class)) { + methodCreator.setModifiers(Modifier.PROTECTED | Modifier.FINAL); + methodCreator.addAnnotation(Override.class.getName(), RetentionPolicy.CLASS); + if (checkerMetadata.reactive()) { + methodCreator.returnValue(methodCreator.load(false)); + } else { + var accessDenied = methodCreator.invokeStaticMethod( + MethodDescriptor.ofMethod(QuarkusPermission.class, "accessDenied", UNI.toString())); + methodCreator.returnValue(accessDenied); + } + } + + // @Override + // protected final Class getBeanClass() { + // return io.quarkus.security.runtime.GeneratedQuarkusPermission.class; + // } + try (var methodCreator = classCreator.getMethodCreator("getBeanClass", Class.class)) { + methodCreator.setModifiers(Modifier.PROTECTED | Modifier.FINAL); + methodCreator.addAnnotation(Override.class.getName(), RetentionPolicy.CLASS); + methodCreator.returnValue(methodCreator.loadClassFromTCCL(declaringCdiBean.name().toString())); + } + + // @Override + // protected final boolean isBlocking() { + // return false; // or true + // } + try (var methodCreator = classCreator.getMethodCreator("isBlocking", boolean.class)) { + methodCreator.setModifiers(Modifier.PROTECTED | Modifier.FINAL); + methodCreator.addAnnotation(Override.class.getName(), RetentionPolicy.CLASS); + methodCreator.returnValue(methodCreator.load(checkerMetadata.blocking())); + } + + // @Override + // protected final boolean isReactive() { + // return false; // true when checker method returns Uni + // } + try (var methodCreator = classCreator.getMethodCreator("isReactive", boolean.class)) { + methodCreator.setModifiers(Modifier.PROTECTED | Modifier.FINAL); + methodCreator.addAnnotation(Override.class.getName(), RetentionPolicy.CLASS); + methodCreator.returnValue(methodCreator.load(checkerMetadata.reactive())); + } + } + }); + } + + private static String toString(AnnotationTarget annotationTarget) { + if (annotationTarget.kind() == AnnotationTarget.Kind.METHOD) { + var method = annotationTarget.asMethod(); + return method.declaringClass().toString() + "#" + method.name(); + } + return annotationTarget.asClass().name().toString(); + } + private SecurityCheck createSecurityCheck(LogicalAndPermissionPredicate andPredicate) { final SecurityCheck securityCheck; final boolean isSinglePermissionGroup = andPredicate.operands.size() == 1; @@ -633,6 +1083,19 @@ private boolean isComputed() { } } + private record PermissionCheckerMetadata(MethodInfo checkerMethod, String generatedClassName, boolean reactive, + MethodInfo quarkusPermissionConstructor, MethodParameterMapper[] methodParamMappers, boolean blocking) { + } + + private record MethodParameterMapper(int securedMethodIdx, int permConstructorIdx) { + + private static final int SECURITY_IDENTITY_IDX = -1; + + private boolean isSecurityIdentity() { + return permConstructorIdx == SECURITY_IDENTITY_IDX; + } + } + private static final class PermissionKey { private final String name; @@ -641,10 +1104,25 @@ private static final class PermissionKey { private final String[] paramsRemainder; private final Type clazz; private final boolean inclusive; + private final PermissionCheckerMetadata permissionChecker; - private PermissionKey(String name, Set actions, String[] params, Type clazz, boolean inclusive) { + private PermissionKey(String name, Set actions, String[] params, Type clazz, boolean inclusive, + PermissionCheckerMetadata permissionChecker, AnnotationTarget permsAllowedTarget) { + this.permissionChecker = permissionChecker; this.name = name; - this.clazz = clazz; + if (permissionChecker != null) { + if (isNotDefaultStringPermission(clazz)) { + throw new IllegalArgumentException(""" + @PermissionChecker '%s' matches permission '%s' and actions '%s' on secured method '%s', but + the @PermissionsAllowed instance specified custom permission '%s'. Both cannot be supported. + Please choose one. + """.formatted(PermissionSecurityChecksBuilder.toString(permissionChecker.checkerMethod()), name, + actions, PermissionSecurityChecksBuilder.toString(permsAllowedTarget), clazz.name())); + } + this.clazz = Type.create(DotName.createSimple(permissionChecker.generatedClassName()), Type.Kind.CLASS); + } else { + this.clazz = clazz; + } this.inclusive = inclusive; if (!actions.isEmpty()) { this.actions = actions; @@ -686,6 +1164,14 @@ private boolean notAutodetectParams() { return !(params.length == 1 && AUTODETECTED.equals(params[0])); } + private boolean isQuarkusPermission() { + return permissionChecker != null; + } + + private MethodInfo getPermissionCheckerMethod() { + return isQuarkusPermission() ? permissionChecker.checkerMethod() : null; + } + private String[] actions() { return actions == null ? null : actions.toArray(new String[0]); } @@ -699,18 +1185,23 @@ public boolean equals(Object o) { PermissionKey that = (PermissionKey) o; return name.equals(that.name) && Objects.equals(actions, that.actions) && Arrays.equals(params, that.params) && clazz.equals(that.clazz) && inclusive == that.inclusive - && Arrays.equals(paramsRemainder, that.paramsRemainder); + && Arrays.equals(paramsRemainder, that.paramsRemainder) + && Objects.equals(permissionChecker, that.permissionChecker); } @Override public int hashCode() { - int result = Objects.hash(name, actions, clazz, inclusive); + int result = Objects.hash(name, actions, clazz, inclusive, permissionChecker); result = 31 * result + Arrays.hashCode(params); if (paramsRemainder != null) { result = 67 * result + Arrays.hashCode(paramsRemainder); } return result; } + + private static boolean isNotDefaultStringPermission(Type classType) { + return !STRING_PERMISSION.equals(classType.name()); + } } private static final class PermissionCacheKey { @@ -747,7 +1238,8 @@ private PermissionCacheKey(PermissionKey permissionKey, AnnotationTarget secured var matches = matchPermCtorParamIdxBasedOnNameMatch(securedMethod, constructor, this.passActionsToConstructor, permissionKey.params, permissionKey.paramsRemainder, - paramConverterGenerator.index); + paramConverterGenerator.index, permissionKey.isQuarkusPermission(), + permissionKey.getPermissionCheckerMethod()); this.methodParamIndexes = getMethodParamIndexes(matches); this.methodParamConverters = getMethodParamConverters(paramConverterGenerator, matches, securedMethod, this.methodParamIndexes); @@ -755,7 +1247,8 @@ private PermissionCacheKey(PermissionKey permissionKey, AnnotationTarget secured // params are mapped to Permission constructor parameters if (permissionKey.notAutodetectParams()) { validateParamsDeclaredByUserMatched(matches, permissionKey.params, permissionKey.paramsRemainder, - securedMethod, constructor); + securedMethod, constructor, permissionKey.isQuarkusPermission(), + permissionKey.getPermissionCheckerMethod()); } } else { // plain permission @@ -768,7 +1261,8 @@ private PermissionCacheKey(PermissionKey permissionKey, AnnotationTarget secured } private static void validateParamsDeclaredByUserMatched(SecMethodAndPermCtorIdx[] matches, String[] params, - String[] nestedParamExpressions, MethodInfo securedMethod, MethodInfo constructor) { + String[] nestedParamExpressions, MethodInfo securedMethod, MethodInfo constructor, + boolean quarkusPermission, MethodInfo permissionCheckerMethod) { for (int i = 0; i < params.length; i++) { int aI = i; boolean paramMapped = Arrays.stream(matches) @@ -778,14 +1272,16 @@ private static void validateParamsDeclaredByUserMatched(SecMethodAndPermCtorIdx[ if (!paramMapped) { var paramName = nestedParamExpressions == null || nestedParamExpressions[aI] == null ? params[i] : params[i] + "." + nestedParamExpressions[aI]; + var matchTarget = quarkusPermission ? PermissionSecurityChecksBuilder.toString(permissionCheckerMethod) + : constructor.declaringClass().name().toString(); throw new RuntimeException( """ - Parameter '%s' specified via @PermissionsAllowed#params on secured method '%s#%s' - cannot be matched to any constructor '%s' parameter. Please make sure that both + Parameter '%s' specified via @PermissionsAllowed#params on secured method '%s' + cannot be matched to any %s '%s' parameter. Please make sure that both secured method and constructor has formal parameter with name '%1$s'. """ - .formatted(paramName, securedMethod.declaringClass().name(), securedMethod.name(), - constructor.declaringClass().name().toString())); + .formatted(paramName, PermissionSecurityChecksBuilder.toString(securedMethod), + quarkusPermission ? "checker" : "constructor", matchTarget)); } } if (nestedParamExpressions != null) { @@ -797,11 +1293,15 @@ private static void validateParamsDeclaredByUserMatched(SecMethodAndPermCtorIdx[ continue outer; } } + var matchTarget = quarkusPermission + ? PermissionSecurityChecksBuilder.toString(permissionCheckerMethod) + : constructor.declaringClass().name().toString(); throw new IllegalArgumentException(""" - @PermissionsAllowed annotation placed on method '%s#%s' has 'params' attribute - '%s' that cannot be matched to any Permission '%s' constructor parameter - """.formatted(securedMethod.declaringClass().name(), securedMethod.name(), - params[i] + "." + nestedParamExp, constructor.declaringClass().name())); + @PermissionsAllowed annotation placed on method '%s' has 'params' attribute + '%s' that cannot be matched to any Permission %s '%s' parameter + """.formatted(PermissionSecurityChecksBuilder.toString(securedMethod), + params[i] + "." + nestedParamExp, quarkusPermission ? "checker" : "constructor", + matchTarget)); } } } @@ -826,21 +1326,25 @@ private static String[] getMethodParamConverters(PermissionConverterGenerator pa private static SecMethodAndPermCtorIdx[] matchPermCtorParamIdxBasedOnNameMatch(MethodInfo securedMethod, MethodInfo constructor, boolean passActionsToConstructor, String[] requiredMethodParams, - String[] requiredParamsRemainder, IndexView index) { + String[] requiredParamsRemainder, IndexView index, boolean isQuarkusPermission, + MethodInfo permissionChecker) { // assign method param to each constructor param; it's not one-to-one function (AKA injection) final int nonMethodParams = (passActionsToConstructor ? 2 : 1); final var matches = new SecMethodAndPermCtorIdx[constructor.parametersCount() - nonMethodParams]; for (int i = nonMethodParams; i < constructor.parametersCount(); i++) { // find index for exact name match between constructor and method param var match = findSecuredMethodParamIndex(securedMethod, constructor, i, - requiredParamsRemainder, - requiredMethodParams, nonMethodParams, index); + requiredParamsRemainder, requiredMethodParams, nonMethodParams, index); matches[i - nonMethodParams] = match; if (match.methodParamIdx() == -1) { final String constructorParamName = constructor.parameterName(i); + final String matchTarget = isQuarkusPermission + ? PermissionSecurityChecksBuilder.toString(permissionChecker) + : constructor.declaringClass().name().toString(); throw new RuntimeException(String.format( - "No '%s' formal parameter name matches '%s' Permission constructor parameter name '%s'", - securedMethod.name(), constructor.declaringClass().name().toString(), constructorParamName)); + "No '%s' formal parameter name matches '%s' Permission %s parameter name '%s'", + securedMethod.name(), matchTarget, isQuarkusPermission ? "checker" : "constructor", + constructorParamName)); } } return matches; diff --git a/extensions/security/deployment/src/main/java/io/quarkus/security/deployment/PermissionSecurityChecksBuildItem.java b/extensions/security/deployment/src/main/java/io/quarkus/security/deployment/PermissionSecurityChecksBuildItem.java new file mode 100644 index 00000000000000..0b9425b51c3d04 --- /dev/null +++ b/extensions/security/deployment/src/main/java/io/quarkus/security/deployment/PermissionSecurityChecksBuildItem.java @@ -0,0 +1,17 @@ +package io.quarkus.security.deployment; + +import io.quarkus.builder.item.SimpleBuildItem; +import io.quarkus.security.deployment.PermissionSecurityChecks.PermissionSecurityChecksBuilder; + +/** + * Carries instance of the {@link PermissionSecurityChecksBuilder} that needs to be used by different build steps + * inside Quarkus Security deployment module. This is internal build item only required within the security module. + */ +final class PermissionSecurityChecksBuilderBuildItem extends SimpleBuildItem { + + final PermissionSecurityChecksBuilder instance; + + PermissionSecurityChecksBuilderBuildItem(PermissionSecurityChecksBuilder builder) { + this.instance = builder; + } +} 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 27f0688e3ab0c9..13c56321514db8 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 @@ -8,7 +8,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.BLOCKING; +import static io.quarkus.security.deployment.PermissionSecurityChecks.PERMISSION_CHECKER_NAME; 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; @@ -39,6 +40,7 @@ import java.util.stream.Collectors; import jakarta.enterprise.context.ApplicationScoped; +import jakarta.enterprise.context.Dependent; import jakarta.inject.Singleton; import org.jboss.jandex.AnnotationInstance; @@ -48,18 +50,22 @@ import org.jboss.jandex.DotName; import org.jboss.jandex.IndexView; import org.jboss.jandex.MethodInfo; +import org.jboss.jandex.Type; import org.jboss.logging.Logger; import io.quarkus.arc.deployment.AdditionalBeanBuildItem; import io.quarkus.arc.deployment.AnnotationsTransformerBuildItem; import io.quarkus.arc.deployment.BeanArchiveIndexBuildItem; +import io.quarkus.arc.deployment.BeanDiscoveryFinishedBuildItem; import io.quarkus.arc.deployment.InterceptorBindingRegistrarBuildItem; import io.quarkus.arc.deployment.SynthesisFinishedBuildItem; import io.quarkus.arc.deployment.SyntheticBeanBuildItem; +import io.quarkus.arc.deployment.UnremovableBeanBuildItem; import io.quarkus.arc.deployment.ValidationPhaseBuildItem; import io.quarkus.arc.deployment.ValidationPhaseBuildItem.ValidationErrorBuildItem; import io.quarkus.arc.processor.AnnotationStore; import io.quarkus.arc.processor.BuildExtension; +import io.quarkus.arc.processor.BuiltinScope; import io.quarkus.arc.processor.ObserverInfo; import io.quarkus.builder.item.MultiBuildItem; import io.quarkus.builder.item.SimpleBuildItem; @@ -83,6 +89,7 @@ import io.quarkus.deployment.builditem.nativeimage.NativeImageSecurityProviderBuildItem; import io.quarkus.deployment.builditem.nativeimage.ReflectiveClassBuildItem; import io.quarkus.deployment.builditem.nativeimage.RuntimeReinitializedClassBuildItem; +import io.quarkus.deployment.execannotations.ExecutionModelAnnotationsAllowedBuildItem; import io.quarkus.deployment.pkg.builditem.CurateOutcomeBuildItem; import io.quarkus.deployment.pkg.steps.NativeOrNativeSourcesBuild; import io.quarkus.gizmo.CatchBlockCreator; @@ -97,6 +104,7 @@ import io.quarkus.runtime.StartupEvent; import io.quarkus.runtime.configuration.ConfigurationException; import io.quarkus.security.deployment.PermissionSecurityChecks.PermissionSecurityChecksBuilder; +import io.quarkus.security.identity.SecurityIdentityAugmentor; import io.quarkus.security.runtime.IdentityProviderManagerCreator; import io.quarkus.security.runtime.QuarkusSecurityRolesAllowedConfigBuilder; import io.quarkus.security.runtime.SecurityBuildTimeConfig; @@ -126,6 +134,7 @@ import io.quarkus.security.spi.RolesAllowedConfigExpResolverBuildItem; import io.quarkus.security.spi.SecurityTransformerUtils; import io.quarkus.security.spi.runtime.AuthorizationController; +import io.quarkus.security.spi.runtime.BlockingSecurityExecutor; import io.quarkus.security.spi.runtime.DevModeDisabledAuthorizationController; import io.quarkus.security.spi.runtime.MethodDescription; import io.quarkus.security.spi.runtime.SecurityCheck; @@ -631,6 +640,85 @@ PermissionsAllowedMetaAnnotationBuildItem transformPermissionsAllowedMetaAnnotat return item; } + @BuildStep + PermissionSecurityChecksBuilderBuildItem createPermissionSecurityChecksBuilder( + BeanArchiveIndexBuildItem beanArchiveBuildItem, + PermissionsAllowedMetaAnnotationBuildItem metaAnnotationItem) { + return new PermissionSecurityChecksBuilderBuildItem( + new PermissionSecurityChecksBuilder(beanArchiveBuildItem.getIndex(), metaAnnotationItem)); + } + + @BuildStep + UnremovableBeanBuildItem makePermissionCheckerClassBeansUnremovable() { + // this won't do the trick for checkers on abstract classes or beans producer fields and methods + return new UnremovableBeanBuildItem(bi -> { + if (bi.isRemovable() && bi.isClassBean()) { + return bi.getTarget().map(t -> t.hasAnnotation(PERMISSION_CHECKER_NAME)).orElse(false); + } + return false; + }); + } + + @BuildStep + ExecutionModelAnnotationsAllowedBuildItem supportBlockingExecutionOfPermissionChecks() { + return new ExecutionModelAnnotationsAllowedBuildItem(mi -> mi.hasDeclaredAnnotation(PERMISSION_CHECKER_NAME) + && mi.hasDeclaredAnnotation(BLOCKING)); + } + + @Record(ExecutionTime.STATIC_INIT) + @BuildStep + void configurePermissionCheckers(PermissionSecurityChecksBuilderBuildItem checkerBuilder, + BuildProducer syntheticBeanProducer, SecurityCheckRecorder recorder, + BeanDiscoveryFinishedBuildItem beanDiscoveryFinishedBuildItem, + BuildProducer generatedClassProducer) { + if (checkerBuilder.instance.foundPermissionChecker()) { + + // ==== produce SecurityIdentityAugmentor that checks QuarkusPermissions + // why do we use synthetic bean? + // - this processor relies on the bean archive index (cycle: idx -> additional bean -> idx) + // - we have injection points (=> better validation from Arc) as checker beans are only requested from this augmentor + var syntheticBeanConfigurator = SyntheticBeanBuildItem + .configure(SecurityIdentityAugmentor.class) + // ATM we do get augmentors from CDI once, no need to keep the instance in the CDI container + .scope(Dependent.class) + .unremovable() + .addInjectionPoint(Type.create(BlockingSecurityExecutor.class)) + .createWith(recorder.createPermissionAugmentor()); + + checkerBuilder.instance.getPermissionChecker().stream().forEach(checkerMethod -> { + var checkerClassType = Type.create(checkerMethod.declaringClass().name(), Type.Kind.CLASS); + + // validate permission checker method's declaring class is a CDI bean + // synthetic beans are not taken into consideration which makes them not supported + var matchingBeans = beanDiscoveryFinishedBuildItem.beanStream().assignableTo(checkerClassType).collect(); + if (matchingBeans.isEmpty()) { + throw new RuntimeException( + """ + @PermissionChecker declared on method '%s', but no matching CDI bean could be found for the declaring class '%s'. + """ + .formatted(checkerMethod.name(), checkerClassType.name())); + } + // Using @Dependent is problematic because we would have to destroy beans manually at some point (which?) + matchingBeans.stream().filter(b -> BuiltinScope.DEPENDENT.getInfo().equals(b.getScope())).findFirst() + .ifPresent(bi -> { + throw new RuntimeException( + """ + Found @PermissionChecker annotation instance declared on the CDI bean method '%s#%s'. + The CDI bean is a dependent scoped bean, but only the '@Singleton' bean or normal scoped beans are supported + """ + .formatted(checkerMethod.name(), checkerClassType.name())); + }); + + syntheticBeanConfigurator.addInjectionPoint(checkerClassType); + }); + + syntheticBeanProducer.produce(syntheticBeanConfigurator.done()); + + // ==== Generate QuarkusPermission for each @PermissionChecker annotation instance + checkerBuilder.instance.generatePermissionCheckers(generatedClassProducer); + } + } + @BuildStep @Record(ExecutionTime.STATIC_INIT) MethodSecurityChecks gatherSecurityChecks( @@ -645,7 +733,7 @@ MethodSecurityChecks gatherSecurityChecks( List registerClassSecurityCheckBuildItems, BuildProducer reflectiveClassBuildItemBuildProducer, List additionalSecurityChecks, SecurityBuildTimeConfig config, - PermissionsAllowedMetaAnnotationBuildItem permissionsAllowedMetaAnnotationItem, + PermissionSecurityChecksBuilderBuildItem permissionSecurityChecksBuilderBuildItem, BuildProducer generatedClassesProducer, BuildProducer reflectiveClassesProducer) { var hasAdditionalSecAnn = hasAdditionalSecurityAnnotation(additionalSecurityAnnotationItems.stream() @@ -665,8 +753,8 @@ MethodSecurityChecks gatherSecurityChecks( additionalSecured.values(), config.denyUnannotated(), recorder, configBuilderProducer, reflectiveClassBuildItemBuildProducer, rolesAllowedConfigExpResolverBuildItems, registerClassSecurityCheckBuildItems, classSecurityCheckStorageProducer, hasAdditionalSecAnn, - additionalSecurityAnnotationItems, permissionsAllowedMetaAnnotationItem, generatedClassesProducer, - reflectiveClassesProducer); + additionalSecurityAnnotationItems, permissionSecurityChecksBuilderBuildItem.instance, + generatedClassesProducer, reflectiveClassesProducer); for (AdditionalSecurityCheckBuildItem additionalSecurityCheck : additionalSecurityChecks) { securityChecks.put(additionalSecurityCheck.getMethodInfo(), additionalSecurityCheck.getSecurityCheck()); @@ -746,7 +834,7 @@ private static Map gatherSecurityAnnotations(IndexVie BuildProducer classSecurityCheckStorageProducer, Predicate hasAdditionalSecurityAnnotations, List additionalSecurityAnnotationItems, - PermissionsAllowedMetaAnnotationBuildItem permissionsAllowedMetaAnnotationItem, + PermissionSecurityChecksBuilder permissionCheckBuilder, BuildProducer generatedClassesProducer, BuildProducer reflectiveClassesProducer) { Map methodToInstanceCollector = new HashMap<>(); @@ -774,19 +862,17 @@ private static Map gatherSecurityAnnotations(IndexVie // gather @PermissionsAllowed security checks final Map classNameToPermCheck; - List permissionInstances = getPermissionsAllowedInstances(index, - permissionsAllowedMetaAnnotationItem); - if (!permissionInstances.isEmpty()) { + if (permissionCheckBuilder.foundPermissionsAllowedInstances()) { var additionalClassInstances = registerClassSecurityCheckBuildItems .stream() .filter(i -> PERMISSIONS_ALLOWED.equals(i.securityAnnotationInstance.name())) .map(i -> i.securityAnnotationInstance) .toList(); - var securityChecks = new PermissionSecurityChecksBuilder(recorder, generatedClassesProducer, - reflectiveClassesProducer, index) - .gatherPermissionsAllowedAnnotations(permissionInstances, methodToInstanceCollector, classAnnotations, - additionalClassInstances, hasAdditionalSecurityAnnotations) - .validatePermissionClasses(index) + var securityChecks = permissionCheckBuilder + .prepareParamConverterGenerator(recorder, generatedClassesProducer, reflectiveClassesProducer) + .gatherPermissionsAllowedAnnotations(methodToInstanceCollector, classAnnotations, additionalClassInstances, + hasAdditionalSecurityAnnotations) + .validatePermissionClasses() .createPermissionPredicates() .build(); result.putAll(securityChecks.getMethodSecurityChecks()); diff --git a/extensions/security/deployment/src/test/java/io/quarkus/security/test/permissionsallowed/PermissionsAllowedNestedParamsTest.java b/extensions/security/deployment/src/test/java/io/quarkus/security/test/permissionsallowed/PermissionsAllowedNestedParamsTest.java index 2790bbc79cc704..bba1472d04e54f 100644 --- a/extensions/security/deployment/src/test/java/io/quarkus/security/test/permissionsallowed/PermissionsAllowedNestedParamsTest.java +++ b/extensions/security/deployment/src/test/java/io/quarkus/security/test/permissionsallowed/PermissionsAllowedNestedParamsTest.java @@ -3,10 +3,11 @@ import static io.quarkus.security.test.permissionsallowed.CustomPermissionWithMultipleArgs.EXPECTED_FIELD_INT_ARGUMENT; import static io.quarkus.security.test.permissionsallowed.CustomPermissionWithMultipleArgs.EXPECTED_FIELD_LONG_ARGUMENT; import static io.quarkus.security.test.permissionsallowed.CustomPermissionWithStringArg.EXPECTED_FIELD_STRING_ARGUMENT; -import static io.quarkus.security.test.utils.IdentityMock.USER; import static io.quarkus.security.test.utils.SecurityTestUtils.assertFailureFor; import static io.quarkus.security.test.utils.SecurityTestUtils.assertSuccess; +import java.security.BasicPermission; +import java.security.Permission; import java.util.Set; import jakarta.inject.Inject; @@ -23,6 +24,23 @@ public class PermissionsAllowedNestedParamsTest { + private static final class EqualTestPermissions extends BasicPermission { + + private EqualTestPermissions() { + super("ignored"); + } + + @Override + public boolean implies(Permission p) { + // purpose here is just to test required permission + // not to give users guidance to follow this example + return p.implies(this); + } + } + + private static final AuthData USER_WITH_TEST_PERM = new AuthData(Set.of(), false, "test", + Set.of(new EqualTestPermissions())); + @RegisterExtension static final QuarkusUnitTest config = new QuarkusUnitTest() .withApplicationRoot((jar) -> jar @@ -36,63 +54,72 @@ public class PermissionsAllowedNestedParamsTest { @Test public void testNestedRecordParam_NestingLevelOne() { - assertSuccess(() -> securedBean.nestedRecordParam_OneTier(new StringRecord(EXPECTED_FIELD_STRING_ARGUMENT)), USER); + assertSuccess(() -> securedBean.nestedRecordParam_OneTier(new StringRecord(EXPECTED_FIELD_STRING_ARGUMENT)), + EXPECTED_FIELD_STRING_ARGUMENT, USER_WITH_TEST_PERM); assertFailureFor(() -> securedBean.nestedRecordParam_OneTier(new StringRecord("unexpected_value")), - ForbiddenException.class, USER); + ForbiddenException.class, USER_WITH_TEST_PERM); } @Test public void testNestedRecordParam_NestingLevelThree() { var validTopTierRecord = new TopTierRecord( new TopTierRecord.SecondTierRecord(null, new StringRecord(EXPECTED_FIELD_STRING_ARGUMENT)), -1); - assertSuccess(() -> securedBean.nestedRecordParam_ThreeTiers(validTopTierRecord), USER); + assertSuccess(() -> securedBean.nestedRecordParam_ThreeTiers(validTopTierRecord), EXPECTED_FIELD_STRING_ARGUMENT, + USER_WITH_TEST_PERM); var invalidTopTierRecord = new TopTierRecord( new TopTierRecord.SecondTierRecord(null, new StringRecord("unexpected_value")), -1); - assertFailureFor(() -> securedBean.nestedRecordParam_ThreeTiers(invalidTopTierRecord), ForbiddenException.class, USER); + assertFailureFor(() -> securedBean.nestedRecordParam_ThreeTiers(invalidTopTierRecord), ForbiddenException.class, + USER_WITH_TEST_PERM); } @Test public void testNestedFieldParam_NestingLevelOne() { - assertSuccess(() -> securedBean.nestedFieldParam_OneTier(new SimpleFieldParam(EXPECTED_FIELD_STRING_ARGUMENT)), USER); + assertSuccess(() -> securedBean.nestedFieldParam_OneTier(new SimpleFieldParam(EXPECTED_FIELD_STRING_ARGUMENT)), + EXPECTED_FIELD_STRING_ARGUMENT, USER_WITH_TEST_PERM); assertFailureFor(() -> securedBean.nestedFieldParam_OneTier(new SimpleFieldParam("unexpected_value")), - ForbiddenException.class, USER); + ForbiddenException.class, USER_WITH_TEST_PERM); } @Test public void testNestedFieldParam_NestingLevelThree() { var validComplexParam = new ComplexFieldParam( new ComplexFieldParam.NestedFieldParam(new SimpleFieldParam(EXPECTED_FIELD_STRING_ARGUMENT))); - assertSuccess(() -> securedBean.nestedFieldParam_ThreeTiers(validComplexParam), USER); + assertSuccess(() -> securedBean.nestedFieldParam_ThreeTiers(validComplexParam), EXPECTED_FIELD_STRING_ARGUMENT, + USER_WITH_TEST_PERM); var invalidComplexParam = new ComplexFieldParam( new ComplexFieldParam.NestedFieldParam(new SimpleFieldParam("unexpected_value"))); assertFailureFor(() -> securedBean.nestedFieldParam_ThreeTiers(invalidComplexParam), - ForbiddenException.class, USER); + ForbiddenException.class, USER_WITH_TEST_PERM); } @Test public void multipleNestedMethods() { var validNestedMethods = new NestedMethodsObject(EXPECTED_FIELD_STRING_ARGUMENT); - assertSuccess(() -> securedBean.multipleNestedMethods(validNestedMethods), USER); + assertSuccess(() -> securedBean.multipleNestedMethods(validNestedMethods), EXPECTED_FIELD_STRING_ARGUMENT, + USER_WITH_TEST_PERM); var invalidNestedMethods = new NestedMethodsObject("unexpected_value"); - assertFailureFor(() -> securedBean.multipleNestedMethods(invalidNestedMethods), ForbiddenException.class, USER); + assertFailureFor(() -> securedBean.multipleNestedMethods(invalidNestedMethods), ForbiddenException.class, + USER_WITH_TEST_PERM); } @Test public void combinedFieldAndMethodAccess() { var validCombinedParam = new CombinedAccessParam(new CombinedAccessParam.ParamField(EXPECTED_FIELD_STRING_ARGUMENT)); - assertSuccess(() -> securedBean.combinedParam(validCombinedParam), USER); + assertSuccess(() -> securedBean.combinedParam(validCombinedParam), EXPECTED_FIELD_STRING_ARGUMENT, USER_WITH_TEST_PERM); var invalidCombinedParam = new CombinedAccessParam(new CombinedAccessParam.ParamField("unexpected_value")); - assertFailureFor(() -> securedBean.combinedParam(invalidCombinedParam), ForbiddenException.class, USER); + assertFailureFor(() -> securedBean.combinedParam(invalidCombinedParam), ForbiddenException.class, USER_WITH_TEST_PERM); } @Test public void simpleAndNestedParamCombination() { - var readPerm = new AuthData(Set.of(), false, "ignored", Set.of(new StringPermission("read"))); - var noReadPerm = new AuthData(Set.of(), false, "ignored", Set.of(new StringPermission("write"))); + var readPerm = new AuthData(Set.of(), false, "ignored", + Set.of(new StringPermission("read"), new EqualTestPermissions())); + var noReadPerm = new AuthData(Set.of(), false, "ignored", + Set.of(new StringPermission("write"), new EqualTestPermissions())); var validCombinedParam = new CombinedAccessParam(new CombinedAccessParam.ParamField(EXPECTED_FIELD_STRING_ARGUMENT)); // succeed as all params are correct assertSuccess(() -> securedBean.simpleAndNested(EXPECTED_FIELD_LONG_ARGUMENT, -1, validCombinedParam, -2, - EXPECTED_FIELD_INT_ARGUMENT, -3), readPerm); + EXPECTED_FIELD_INT_ARGUMENT, -3), EXPECTED_FIELD_LONG_ARGUMENT + "" + EXPECTED_FIELD_LONG_ARGUMENT, readPerm); // fail as String permission is wrong assertFailureFor(() -> securedBean.simpleAndNested(EXPECTED_FIELD_LONG_ARGUMENT, -1, validCombinedParam, -2, EXPECTED_FIELD_INT_ARGUMENT, -3), ForbiddenException.class, noReadPerm); diff --git a/extensions/security/deployment/src/test/java/io/quarkus/security/test/permissionsallowed/checker/AbstractNthMethodArgChecker.java b/extensions/security/deployment/src/test/java/io/quarkus/security/test/permissionsallowed/checker/AbstractNthMethodArgChecker.java new file mode 100644 index 00000000000000..d8335e0cf7d2cc --- /dev/null +++ b/extensions/security/deployment/src/test/java/io/quarkus/security/test/permissionsallowed/checker/AbstractNthMethodArgChecker.java @@ -0,0 +1,11 @@ +package io.quarkus.security.test.permissionsallowed.checker; + +import io.quarkus.security.identity.SecurityIdentity; + +abstract class AbstractNthMethodArgChecker { + + protected boolean argsOk(int expected, Object arg, SecurityIdentity identity) { + return Integer.parseInt(arg.toString()) == expected && identity.hasRole("admin"); + } + +} diff --git a/extensions/security/deployment/src/test/java/io/quarkus/security/test/permissionsallowed/checker/CheckerNotCdiBeanValidationFailureTest.java b/extensions/security/deployment/src/test/java/io/quarkus/security/test/permissionsallowed/checker/CheckerNotCdiBeanValidationFailureTest.java new file mode 100644 index 00000000000000..39abf51b45d39c --- /dev/null +++ b/extensions/security/deployment/src/test/java/io/quarkus/security/test/permissionsallowed/checker/CheckerNotCdiBeanValidationFailureTest.java @@ -0,0 +1,48 @@ +package io.quarkus.security.test.permissionsallowed.checker; + +import jakarta.inject.Singleton; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import io.quarkus.security.PermissionChecker; +import io.quarkus.security.PermissionsAllowed; +import io.quarkus.security.identity.SecurityIdentity; +import io.quarkus.test.QuarkusUnitTest; + +public class CheckerNotCdiBeanValidationFailureTest { + + @RegisterExtension + static final QuarkusUnitTest config = new QuarkusUnitTest() + .assertException(t -> { + Assertions.assertEquals(RuntimeException.class, t.getClass(), t.getMessage()); + Assertions.assertTrue(t.getMessage().contains("@PermissionChecker declared on method 'checkSomeValue'")); + Assertions.assertTrue(t.getMessage().contains("no matching CDI bean could be found")); + }); + + @Test + public void test() { + Assertions.fail(); + } + + @Singleton + public static class SecuredBean { + + @PermissionsAllowed("some-value") + void securedBean() { + // EMPTY + } + + } + + public static class Checker { + + @PermissionChecker("some-value") + boolean checkSomeValue(SecurityIdentity identity) { + return false; + } + + } + +} diff --git a/extensions/security/deployment/src/test/java/io/quarkus/security/test/permissionsallowed/checker/CombinePermissionCheckerWithPossessedPermissionTest.java b/extensions/security/deployment/src/test/java/io/quarkus/security/test/permissionsallowed/checker/CombinePermissionCheckerWithPossessedPermissionTest.java new file mode 100644 index 00000000000000..0d17e4cd8fb72a --- /dev/null +++ b/extensions/security/deployment/src/test/java/io/quarkus/security/test/permissionsallowed/checker/CombinePermissionCheckerWithPossessedPermissionTest.java @@ -0,0 +1,183 @@ +package io.quarkus.security.test.permissionsallowed.checker; + +import static io.quarkus.security.test.utils.IdentityMock.ADMIN; +import static io.quarkus.security.test.utils.IdentityMock.USER; +import static io.quarkus.security.test.utils.SecurityTestUtils.assertFailureFor; +import static io.quarkus.security.test.utils.SecurityTestUtils.assertSuccess; + +import jakarta.enterprise.context.ApplicationScoped; +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.PermissionChecker; +import io.quarkus.security.PermissionsAllowed; +import io.quarkus.security.StringPermission; +import io.quarkus.security.identity.SecurityIdentity; +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 CombinePermissionCheckerWithPossessedPermissionTest { + + private static final AuthData USER_WITH_AUGMENTORS = new AuthData(USER, true); + private static final AuthData ADMIN_WITH_AUGMENTORS = new AuthData(ADMIN, true); + + @RegisterExtension + static final QuarkusUnitTest config = new QuarkusUnitTest() + .withApplicationRoot((jar) -> jar + .addClasses(IdentityMock.class, AuthData.class, SecurityTestUtils.class)); + + @Inject + AdminOnlyMethodArgSecuredBean securedBean; + + @Test + public void testAccessGrantedByPossessedPermissionAndChecker_allOf() { + var adminWithSecuredPerm = new AuthData(ADMIN, true, new StringPermission("read", "secured")); + var adminWithSecured2Perm = new AuthData(ADMIN, true, new StringPermission("read", "secured2")); + + assertSuccess(() -> securedBean.noSecurity("1", "2", 3, 4, 5), "noSecurity", USER_WITH_AUGMENTORS); + assertSuccess(() -> securedBean.noSecurity("1", "2", 3, 4, 5), "noSecurity", ADMIN_WITH_AUGMENTORS); + assertFailureFor(() -> securedBean.secured_allOf(1, 2, 3, 4, 5), ForbiddenException.class, + USER_WITH_AUGMENTORS); + assertFailureFor(() -> securedBean.secured2_allOf("1", "2", 3, 4, 5), ForbiddenException.class, + USER_WITH_AUGMENTORS); + assertFailureFor(() -> securedBean.secured_allOf(1, 2, 3, 4, 5), ForbiddenException.class, + ADMIN_WITH_AUGMENTORS); + assertFailureFor(() -> securedBean.secured2_allOf("1", "2", 3, 4, 5), ForbiddenException.class, + ADMIN_WITH_AUGMENTORS); + + assertSuccess(() -> securedBean.secured_allOf(1, 2, 3, 4, 5), "secured", adminWithSecuredPerm); + assertSuccess(() -> securedBean.secured2_allOf("1", "2", 3, 4, 5), "secured2", adminWithSecured2Perm); + // wrong value of the param 'one' + assertFailureFor(() -> securedBean.secured2_allOf("9", "2", 3, 4, 5), ForbiddenException.class, + adminWithSecured2Perm); + // wrong value of the param 'five' + assertFailureFor(() -> securedBean.secured2_allOf("1", "2", 3, 4, 6), ForbiddenException.class, + adminWithSecured2Perm); + // missing string permission "read:secured" + assertFailureFor(() -> securedBean.secured_allOf(1, 2, 3, 4, 5), ForbiddenException.class, + adminWithSecured2Perm); + // missing string permission "read:secured2" + assertFailureFor(() -> securedBean.secured2_allOf("1", "2", 3, 4, 5), ForbiddenException.class, + adminWithSecuredPerm); + } + + @Test + public void testAccessGrantedByPossessedPermissionAndChecker_inclusiveAllOf() { + var adminWithSecuredPerm = new AuthData(ADMIN, true, new StringPermission("read", "secured")); + var adminWithSecured2Perm = new AuthData(ADMIN, true, new StringPermission("read", "secured2")); + + assertFailureFor(() -> securedBean.secured_inclusiveAllOf(1, 2, 3, 4, 5), ForbiddenException.class, + USER_WITH_AUGMENTORS); + assertFailureFor(() -> securedBean.secured2_inclusiveAllOf("1", "2", 3, 4, 5), ForbiddenException.class, + USER_WITH_AUGMENTORS); + assertFailureFor(() -> securedBean.secured_inclusiveAllOf(1, 2, 3, 4, 5), ForbiddenException.class, + ADMIN_WITH_AUGMENTORS); + assertFailureFor(() -> securedBean.secured2_inclusiveAllOf("1", "2", 3, 4, 5), ForbiddenException.class, + ADMIN_WITH_AUGMENTORS); + + assertSuccess(() -> securedBean.secured_inclusiveAllOf(1, 2, 3, 4, 5), "secured", adminWithSecuredPerm); + assertSuccess(() -> securedBean.secured2_inclusiveAllOf("1", "2", 3, 4, 5), "secured2", + adminWithSecured2Perm); + // wrong value of the param 'one' + assertFailureFor(() -> securedBean.secured2_inclusiveAllOf("9", "2", 3, 4, 5), ForbiddenException.class, + adminWithSecured2Perm); + // wrong value of the param 'five' + assertFailureFor(() -> securedBean.secured2_inclusiveAllOf("1", "2", 3, 4, 6), ForbiddenException.class, + adminWithSecured2Perm); + // missing string permission "read:secured" + assertFailureFor(() -> securedBean.secured_inclusiveAllOf(1, 2, 3, 4, 5), ForbiddenException.class, + adminWithSecured2Perm); + // missing string permission "read:secured2" + assertFailureFor(() -> securedBean.secured2_inclusiveAllOf("1", "2", 3, 4, 5), ForbiddenException.class, + adminWithSecuredPerm); + } + + @Test + public void testAccessGrantedByPossessedPermissionAndChecker_oneOf() { + var adminWithSecuredPerm = new AuthData(ADMIN, true, new StringPermission("read", "secured")); + var adminWithSecured2Perm = new AuthData(ADMIN, true, new StringPermission("read", "secured2")); + + assertFailureFor(() -> securedBean.secured_oneOf(1, 2, 3, 4, 5), ForbiddenException.class, + USER_WITH_AUGMENTORS); + assertFailureFor(() -> securedBean.secured2_oneOf("1", "2", 3, 4, 5), ForbiddenException.class, + USER_WITH_AUGMENTORS); + + assertSuccess(() -> securedBean.secured_oneOf(1, 2, 3, 4, 5), "secured", adminWithSecuredPerm); + assertSuccess(() -> securedBean.secured2_oneOf("1", "2", 3, 4, 5), "secured2", adminWithSecured2Perm); + + // wrong value of the param 'one', but has 'read:secured2' + assertSuccess(() -> securedBean.secured2_oneOf("9", "2", 3, 4, 5), "secured2", adminWithSecured2Perm); + // wrong value of the param 'five', but has 'read:secured2' + assertSuccess(() -> securedBean.secured2_oneOf("1", "2", 3, 4, 6), "secured2", adminWithSecured2Perm); + // wrong value of the param 'five' and no 'read:secured2' + assertFailureFor(() -> securedBean.secured2_oneOf("1", "2", 3, 4, 16), ForbiddenException.class, + adminWithSecuredPerm); + + // missing string permission "read:secured" and wrong param 'two' + assertFailureFor(() -> securedBean.secured_oneOf(1, 4, 3, 4, 5), ForbiddenException.class, + adminWithSecured2Perm); + // has 'read:secured' but param '3' is wrong + assertSuccess(() -> securedBean.secured_oneOf(1, 4, 6, 4, 5), "secured", adminWithSecuredPerm); + } + + @ApplicationScoped + public static class AdminOnlyMethodArgSecuredBean { + + @PermissionsAllowed(value = { "read:secured", "admin-only-method-arg-checker" }, inclusive = true) + public String secured_inclusiveAllOf(int one, int two, int three, int ignored, int five) { + return "secured"; + } + + @PermissionsAllowed(value = { "read:secured", "admin-only-method-arg-checker" }) + public String secured_oneOf(int one, int two, int three, int ignored, int five) { + return "secured"; + } + + @PermissionsAllowed("read:secured") + @PermissionsAllowed("admin-only-method-arg-checker") + public String secured_allOf(int one, int two, int three, int ignored, int five) { + return "secured"; + } + + public String noSecurity(String one, String two, int three, int ignored, int five) { + return "noSecurity"; + } + + @PermissionsAllowed("read:secured2") + @PermissionsAllowed("admin-only-method-arg-checker") + public String secured2_allOf(String one, String two, int three, int ignored, int five) { + return "secured2"; + } + + @PermissionsAllowed(value = { "read:secured2", "admin-only-method-arg-checker" }, inclusive = true) + public String secured2_inclusiveAllOf(String one, String two, int three, int ignored, int five) { + return "secured2"; + } + + @PermissionsAllowed(value = { "read:secured2", "admin-only-method-arg-checker" }) + public String secured2_oneOf(String one, String two, int three, int ignored, int five) { + return "secured2"; + } + } + + @Singleton + public static class AdminOnlyMethodArgPermissionChecker { + + @PermissionChecker("admin-only-method-arg-checker") + boolean canAccess(SecurityIdentity securityIdentity, Object three, Object one, + Object five, Object two) { + boolean methodArgsOk = equals(1, one) && equals(2, two) && equals(3, three) && equals(5, five); + return methodArgsOk && !securityIdentity.isAnonymous() && "admin".equals(securityIdentity.getPrincipal().getName()); + } + + private static boolean equals(int expected, Object actual) { + return expected == Integer.parseInt(actual.toString()); + } + } +} diff --git a/extensions/security/deployment/src/test/java/io/quarkus/security/test/permissionsallowed/checker/MultipleCheckersForSamePermissionValidationFailureTest.java b/extensions/security/deployment/src/test/java/io/quarkus/security/test/permissionsallowed/checker/MultipleCheckersForSamePermissionValidationFailureTest.java new file mode 100644 index 00000000000000..b781c26443581c --- /dev/null +++ b/extensions/security/deployment/src/test/java/io/quarkus/security/test/permissionsallowed/checker/MultipleCheckersForSamePermissionValidationFailureTest.java @@ -0,0 +1,48 @@ +package io.quarkus.security.test.permissionsallowed.checker; + +import jakarta.inject.Singleton; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import io.quarkus.security.PermissionChecker; +import io.quarkus.security.PermissionsAllowed; +import io.quarkus.security.identity.SecurityIdentity; +import io.quarkus.test.QuarkusUnitTest; + +public class MultipleCheckersForSamePermissionValidationFailureTest { + + @RegisterExtension + static final QuarkusUnitTest config = new QuarkusUnitTest() + .assertException(t -> { + Assertions.assertEquals(IllegalArgumentException.class, t.getClass(), t.getMessage()); + Assertions.assertTrue( + t.getMessage().contains("Detected two @PermissionChecker annotations with same value 'some-value'")); + }); + + @Test + public void test() { + Assertions.fail(); + } + + @Singleton + public static class SecuredBean { + + @PermissionsAllowed("some-value") + void securedBean() { + // EMPTY + } + + @PermissionChecker("some-value") + boolean checkSomeValue(SecurityIdentity identity) { + return false; + } + + @PermissionChecker("some-value") + boolean alsoCheckSomeValue(SecurityIdentity identity) { + return false; + } + } + +} diff --git a/extensions/security/deployment/src/test/java/io/quarkus/security/test/permissionsallowed/checker/NoArgsPermissionCheckerValidationFailureTest.java b/extensions/security/deployment/src/test/java/io/quarkus/security/test/permissionsallowed/checker/NoArgsPermissionCheckerValidationFailureTest.java new file mode 100644 index 00000000000000..63ecd13e953c1e --- /dev/null +++ b/extensions/security/deployment/src/test/java/io/quarkus/security/test/permissionsallowed/checker/NoArgsPermissionCheckerValidationFailureTest.java @@ -0,0 +1,48 @@ +package io.quarkus.security.test.permissionsallowed.checker; + +import jakarta.inject.Singleton; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import io.quarkus.security.PermissionChecker; +import io.quarkus.security.PermissionsAllowed; +import io.quarkus.test.QuarkusUnitTest; + +public class NoArgsPermissionCheckerValidationFailureTest { + + @RegisterExtension + static final QuarkusUnitTest config = new QuarkusUnitTest() + .assertException(t -> { + Assertions.assertEquals(RuntimeException.class, t.getClass(), t.getMessage()); + Assertions.assertTrue(t.getMessage().contains("Checker#checkSomeValue")); + Assertions.assertTrue(t.getMessage().contains("must have at least one parameter")); + }); + + @Test + public void test() { + Assertions.fail(); + } + + @Singleton + public static class SecuredBean { + + @PermissionsAllowed("some-value") + void securedBean() { + // EMPTY + } + + } + + @Singleton + public static class Checker { + + @PermissionChecker("some-value") + boolean checkSomeValue() { + return false; + } + + } + +} diff --git a/extensions/security/deployment/src/test/java/io/quarkus/security/test/permissionsallowed/checker/PermissionChecker1stMethodArg.java b/extensions/security/deployment/src/test/java/io/quarkus/security/test/permissionsallowed/checker/PermissionChecker1stMethodArg.java new file mode 100644 index 00000000000000..12ff98e8fc4875 --- /dev/null +++ b/extensions/security/deployment/src/test/java/io/quarkus/security/test/permissionsallowed/checker/PermissionChecker1stMethodArg.java @@ -0,0 +1,16 @@ +package io.quarkus.security.test.permissionsallowed.checker; + +import jakarta.enterprise.context.ApplicationScoped; + +import io.quarkus.security.PermissionChecker; +import io.quarkus.security.identity.SecurityIdentity; + +@ApplicationScoped +public class PermissionChecker1stMethodArg extends AbstractNthMethodArgChecker { + + @PermissionChecker("1st-arg") + boolean is1stMethodArgOk(Object one, SecurityIdentity identity) { + return this.argsOk(1, one, identity); + } + +} diff --git a/extensions/security/deployment/src/test/java/io/quarkus/security/test/permissionsallowed/checker/PermissionChecker2ndMethodArg.java b/extensions/security/deployment/src/test/java/io/quarkus/security/test/permissionsallowed/checker/PermissionChecker2ndMethodArg.java new file mode 100644 index 00000000000000..4593451b3882c0 --- /dev/null +++ b/extensions/security/deployment/src/test/java/io/quarkus/security/test/permissionsallowed/checker/PermissionChecker2ndMethodArg.java @@ -0,0 +1,16 @@ +package io.quarkus.security.test.permissionsallowed.checker; + +import jakarta.inject.Singleton; + +import io.quarkus.security.PermissionChecker; +import io.quarkus.security.identity.SecurityIdentity; + +@Singleton +public class PermissionChecker2ndMethodArg extends AbstractNthMethodArgChecker { + + @PermissionChecker("2nd-arg") + boolean is2ndMethodArgOk(Object two, SecurityIdentity identity) { + return this.argsOk(2, two, identity); + } + +} diff --git a/extensions/security/deployment/src/test/java/io/quarkus/security/test/permissionsallowed/checker/PermissionChecker3rdMethodArg.java b/extensions/security/deployment/src/test/java/io/quarkus/security/test/permissionsallowed/checker/PermissionChecker3rdMethodArg.java new file mode 100644 index 00000000000000..6ee3378338f5a6 --- /dev/null +++ b/extensions/security/deployment/src/test/java/io/quarkus/security/test/permissionsallowed/checker/PermissionChecker3rdMethodArg.java @@ -0,0 +1,15 @@ +package io.quarkus.security.test.permissionsallowed.checker; + +import jakarta.enterprise.context.ApplicationScoped; + +import io.quarkus.security.PermissionChecker; +import io.quarkus.security.identity.SecurityIdentity; + +@ApplicationScoped +public class PermissionChecker3rdMethodArg extends AbstractNthMethodArgChecker { + + @PermissionChecker("3rd-arg") + boolean is3rdMethodArgOk(Object three, SecurityIdentity identity) { + return this.argsOk(3, three, identity); + } +} diff --git a/extensions/security/deployment/src/test/java/io/quarkus/security/test/permissionsallowed/checker/PermissionChecker4thMethodArg.java b/extensions/security/deployment/src/test/java/io/quarkus/security/test/permissionsallowed/checker/PermissionChecker4thMethodArg.java new file mode 100644 index 00000000000000..5bb94a4baee9b6 --- /dev/null +++ b/extensions/security/deployment/src/test/java/io/quarkus/security/test/permissionsallowed/checker/PermissionChecker4thMethodArg.java @@ -0,0 +1,19 @@ +package io.quarkus.security.test.permissionsallowed.checker; + +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; + +import io.quarkus.security.PermissionChecker; +import io.quarkus.security.identity.SecurityIdentity; + +@ApplicationScoped +public class PermissionChecker4thMethodArg extends AbstractNthMethodArgChecker { + + @Inject + SecurityIdentity identity; + + @PermissionChecker("4th-arg") + boolean is4thMethodArgOk(Object four) { + return this.argsOk(4, four, identity); + } +} diff --git a/extensions/security/deployment/src/test/java/io/quarkus/security/test/permissionsallowed/checker/PermissionChecker5thMethodArg.java b/extensions/security/deployment/src/test/java/io/quarkus/security/test/permissionsallowed/checker/PermissionChecker5thMethodArg.java new file mode 100644 index 00000000000000..08062f23736c76 --- /dev/null +++ b/extensions/security/deployment/src/test/java/io/quarkus/security/test/permissionsallowed/checker/PermissionChecker5thMethodArg.java @@ -0,0 +1,15 @@ +package io.quarkus.security.test.permissionsallowed.checker; + +import jakarta.inject.Singleton; + +import io.quarkus.security.PermissionChecker; +import io.quarkus.security.identity.SecurityIdentity; + +@Singleton +public class PermissionChecker5thMethodArg extends AbstractNthMethodArgChecker { + + @PermissionChecker("5th-arg") + boolean is5thMethodArgOk(SecurityIdentity identity, Object five) { + return this.argsOk(5, five, identity); + } +} diff --git a/extensions/security/deployment/src/test/java/io/quarkus/security/test/permissionsallowed/checker/PermissionChecker6thMethodArg.java b/extensions/security/deployment/src/test/java/io/quarkus/security/test/permissionsallowed/checker/PermissionChecker6thMethodArg.java new file mode 100644 index 00000000000000..c8d7181f9b4fbf --- /dev/null +++ b/extensions/security/deployment/src/test/java/io/quarkus/security/test/permissionsallowed/checker/PermissionChecker6thMethodArg.java @@ -0,0 +1,27 @@ +package io.quarkus.security.test.permissionsallowed.checker; + +import jakarta.inject.Inject; +import jakarta.inject.Singleton; + +import io.quarkus.security.PermissionChecker; +import io.quarkus.security.identity.SecurityIdentity; + +@Singleton +public class PermissionChecker6thMethodArg extends AbstractNthMethodArgChecker { + + @Inject + SecurityIdentity identity; + + @PermissionChecker("6th-arg") + boolean is6thMethodArgOk(Object six) { + return this.argsOk(6, six, identity); + } + + @PermissionChecker("another-6th-arg") + boolean is6thMethodArgOk_Another(Object six) { + if (six instanceof Long) { + return false; + } + return this.argsOk(6, six, identity); + } +} diff --git a/extensions/security/deployment/src/test/java/io/quarkus/security/test/permissionsallowed/checker/PermissionChecker7thMethodArg.java b/extensions/security/deployment/src/test/java/io/quarkus/security/test/permissionsallowed/checker/PermissionChecker7thMethodArg.java new file mode 100644 index 00000000000000..bb37e56838525f --- /dev/null +++ b/extensions/security/deployment/src/test/java/io/quarkus/security/test/permissionsallowed/checker/PermissionChecker7thMethodArg.java @@ -0,0 +1,23 @@ +package io.quarkus.security.test.permissionsallowed.checker; + +import jakarta.inject.Singleton; + +import io.quarkus.security.PermissionChecker; +import io.quarkus.security.identity.SecurityIdentity; + +@Singleton +public class PermissionChecker7thMethodArg extends AbstractNthMethodArgChecker { + + @PermissionChecker("7th-arg") + boolean is7thMethodArgOk(Object seven, SecurityIdentity securityIdentity) { + return this.argsOk(7, seven, securityIdentity); + } + + @PermissionChecker("another-7th-arg") + boolean is7thMethodArgOk_Another(SecurityIdentity securityIdentity, Object seven) { + if (seven instanceof String) { + return false; + } + return this.argsOk(7, seven, securityIdentity); + } +} diff --git a/extensions/security/deployment/src/test/java/io/quarkus/security/test/permissionsallowed/checker/PermissionCheckerAssignabilityTest.java b/extensions/security/deployment/src/test/java/io/quarkus/security/test/permissionsallowed/checker/PermissionCheckerAssignabilityTest.java new file mode 100644 index 00000000000000..50c747e7ff7e50 --- /dev/null +++ b/extensions/security/deployment/src/test/java/io/quarkus/security/test/permissionsallowed/checker/PermissionCheckerAssignabilityTest.java @@ -0,0 +1,194 @@ +package io.quarkus.security.test.permissionsallowed.checker; + +import static io.quarkus.security.test.utils.IdentityMock.ADMIN; +import static io.quarkus.security.test.utils.IdentityMock.USER; +import static io.quarkus.security.test.utils.SecurityTestUtils.assertFailureFor; +import static io.quarkus.security.test.utils.SecurityTestUtils.assertSuccess; + +import jakarta.enterprise.context.ApplicationScoped; +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.PermissionChecker; +import io.quarkus.security.PermissionsAllowed; +import io.quarkus.security.identity.SecurityIdentity; +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 PermissionCheckerAssignabilityTest { + + private static final AuthData USER_WITH_AUGMENTORS = new AuthData(USER, true); + private static final AuthData ADMIN_WITH_AUGMENTORS = new AuthData(ADMIN, true); + + @RegisterExtension + static final QuarkusUnitTest config = new QuarkusUnitTest() + .withApplicationRoot((jar) -> jar + .addClasses(IdentityMock.class, AuthData.class, SecurityTestUtils.class)); + + @Inject + AssignabilitySecuredBean securedBean; + + @Test + public void testAssignabilityFromTopLevelInterface() { + var recordCorrectVal = new Second_Record("top"); + assertSuccess(() -> securedBean.top(recordCorrectVal), "top", ADMIN_WITH_AUGMENTORS); + assertFailureFor(() -> securedBean.top(recordCorrectVal), ForbiddenException.class, USER_WITH_AUGMENTORS); + var recordWrongVal = new Second_Record("wrong-value"); + assertFailureFor(() -> securedBean.top(recordWrongVal), ForbiddenException.class, ADMIN_WITH_AUGMENTORS); + assertFailureFor(() -> securedBean.top(recordWrongVal), ForbiddenException.class, USER_WITH_AUGMENTORS); + + var classCorrectVal = new Third("top"); + assertSuccess(() -> securedBean.top(classCorrectVal), "top", ADMIN_WITH_AUGMENTORS); + assertFailureFor(() -> securedBean.top(classCorrectVal), ForbiddenException.class, USER_WITH_AUGMENTORS); + var classWrongVal = new Third("wrong-value"); + assertFailureFor(() -> securedBean.top(classWrongVal), ForbiddenException.class, ADMIN_WITH_AUGMENTORS); + assertFailureFor(() -> securedBean.top(classWrongVal), ForbiddenException.class, USER_WITH_AUGMENTORS); + } + + @Test + public void testAssignabilityFromAbstractClass() { + var classCorrectVal = new Third("abstract"); + assertSuccess(() -> securedBean.secondAbstract(classCorrectVal), "abstract", ADMIN_WITH_AUGMENTORS); + assertFailureFor(() -> securedBean.secondAbstract(classCorrectVal), ForbiddenException.class, USER_WITH_AUGMENTORS); + var classWrongVal = new Third("wrong-value"); + assertFailureFor(() -> securedBean.secondAbstract(classWrongVal), ForbiddenException.class, ADMIN_WITH_AUGMENTORS); + assertFailureFor(() -> securedBean.secondAbstract(classWrongVal), ForbiddenException.class, USER_WITH_AUGMENTORS); + } + + @Test + public void testAssignabilityFromImplementation() { + var classCorrectVal = new Third("class"); + assertSuccess(() -> securedBean.thirdImplementation(classCorrectVal), "class", ADMIN_WITH_AUGMENTORS); + assertFailureFor(() -> securedBean.thirdImplementation(classCorrectVal), ForbiddenException.class, + USER_WITH_AUGMENTORS); + var classWrongVal = new Third("wrong-value"); + assertFailureFor(() -> securedBean.thirdImplementation(classWrongVal), ForbiddenException.class, ADMIN_WITH_AUGMENTORS); + assertFailureFor(() -> securedBean.thirdImplementation(classWrongVal), ForbiddenException.class, USER_WITH_AUGMENTORS); + } + + @Test + public void testAllThreeLevels() { + // secured method accepts interface, abstract class and implementation + // checker accepts implementation thrice (once for each secured method param) + var theInterface = new Third("interface"); + var theAbstract = new Third("abstract"); + var implementation = new Third("implementation"); + assertSuccess(() -> securedBean.allThree(implementation, theAbstract, theInterface), "allThree", ADMIN_WITH_AUGMENTORS); + assertFailureFor(() -> securedBean.allThree(theInterface, theAbstract, implementation), ForbiddenException.class, + ADMIN_WITH_AUGMENTORS); + assertFailureFor(() -> securedBean.allThree(theAbstract, theInterface, implementation), ForbiddenException.class, + ADMIN_WITH_AUGMENTORS); + } + + @Test + public void testGenericChecker() { + var wrongValue = new Third("wrong-value"); + var rightValue = new Third("generic"); + var anotherRightValue = new Second_Record("generic"); + assertSuccess(() -> securedBean.genericChecker(rightValue), "generic", ADMIN_WITH_AUGMENTORS); + assertSuccess(() -> securedBean.genericChecker(anotherRightValue), "generic", ADMIN_WITH_AUGMENTORS); + assertFailureFor(() -> securedBean.genericChecker(wrongValue), ForbiddenException.class, ADMIN_WITH_AUGMENTORS); + } + + @Test + public void testNotAssignableCheckerParam() { + var theInterface = new Second_Record("interface"); // not assignable + var theAbstract = new Third("abstract"); + var implementation = new Third("implementation"); + assertFailureFor(() -> securedBean.allThree(implementation, theAbstract, theInterface), ForbiddenException.class, + ADMIN_WITH_AUGMENTORS); + } + + @ApplicationScoped + public static class AssignabilitySecuredBean { + + @PermissionsAllowed("top") + String top(Top top) { + return top.value(); + } + + @PermissionsAllowed("abstract") + String secondAbstract(Second_Abstract secondAbstract) { + return secondAbstract.value(); + } + + @PermissionsAllowed("class") + String thirdImplementation(Third third) { + return third.value(); + } + + @PermissionsAllowed("thriceThird") + String allThree(Third implementation, Second_Abstract theAbstract, Top theInterface) { + return "allThree"; + } + + @PermissionsAllowed("generic-checker") + String genericChecker(Top top) { + return top.value(); + } + } + + interface Top { + + String value(); + + } + + record Second_Record(String value) implements Top { + } + + static abstract class Second_Abstract implements Top { + } + + static class Third extends Second_Abstract { + + private final String value; + + Third(String value) { + this.value = value; + } + + public String value() { + return value; + } + } + + @Singleton + static class Checkers { + + @Inject + SecurityIdentity identity; + + @PermissionChecker("top") + boolean isTopAllowed(Top top) { + return identity.hasRole("admin") && top.value().equals("top"); + } + + @PermissionChecker("abstract") + boolean isAbstractAllowed(Second_Abstract secondAbstract) { + return identity.hasRole("admin") && secondAbstract.value().equals("abstract"); + } + + @PermissionChecker("class") + boolean isThirdImplementationAllowed(Third third) { + return identity.hasRole("admin") && third.value().equals("class"); + } + + @PermissionChecker("thriceThird") + boolean areAllThreeOk(Third theAbstract, Third theInterface, Third implementation) { + return theAbstract.value.equals("abstract") && theInterface.value.equals("interface") + && implementation.value.equals("implementation"); + } + + @PermissionChecker("generic-checker") + boolean genericChecker(T top) { + return top.value().equals("generic"); + } + } +} diff --git a/extensions/security/deployment/src/test/java/io/quarkus/security/test/permissionsallowed/checker/PermissionCheckerFourSecuredMethodArgs.java b/extensions/security/deployment/src/test/java/io/quarkus/security/test/permissionsallowed/checker/PermissionCheckerFourSecuredMethodArgs.java new file mode 100644 index 00000000000000..aded6ba1173569 --- /dev/null +++ b/extensions/security/deployment/src/test/java/io/quarkus/security/test/permissionsallowed/checker/PermissionCheckerFourSecuredMethodArgs.java @@ -0,0 +1,17 @@ +package io.quarkus.security.test.permissionsallowed.checker; + +import jakarta.enterprise.context.ApplicationScoped; + +import io.quarkus.security.PermissionChecker; +import io.quarkus.security.identity.SecurityIdentity; + +@ApplicationScoped +public class PermissionCheckerFourSecuredMethodArgs { + + @PermissionChecker("four-args") + public boolean isGranted(Object one, int two, String three, SecurityIdentity securityIdentity, Object four) { + boolean methodArgsOk = Integer.parseInt(one.toString()) == 1 && two == 2 && Integer.parseInt(three) == 3 + && Integer.parseInt(four.toString()) == 4; + return methodArgsOk && securityIdentity.hasRole("admin"); + } +} diff --git a/extensions/security/deployment/src/test/java/io/quarkus/security/test/permissionsallowed/checker/PermissionCheckerInheritanceTest.java b/extensions/security/deployment/src/test/java/io/quarkus/security/test/permissionsallowed/checker/PermissionCheckerInheritanceTest.java new file mode 100644 index 00000000000000..32490ca2559af6 --- /dev/null +++ b/extensions/security/deployment/src/test/java/io/quarkus/security/test/permissionsallowed/checker/PermissionCheckerInheritanceTest.java @@ -0,0 +1,171 @@ +package io.quarkus.security.test.permissionsallowed.checker; + +import static io.quarkus.security.test.utils.IdentityMock.ADMIN; +import static io.quarkus.security.test.utils.IdentityMock.USER; +import static io.quarkus.security.test.utils.SecurityTestUtils.assertFailureFor; +import static io.quarkus.security.test.utils.SecurityTestUtils.assertSuccess; + +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import jakarta.inject.Singleton; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import io.quarkus.arc.Unremovable; +import io.quarkus.security.ForbiddenException; +import io.quarkus.security.PermissionChecker; +import io.quarkus.security.PermissionsAllowed; +import io.quarkus.security.identity.SecurityIdentity; +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 PermissionCheckerInheritanceTest { + + private static final AuthData USER_WITH_AUGMENTORS = new AuthData(USER, true); + private static final AuthData ADMIN_WITH_AUGMENTORS = new AuthData(ADMIN, true); + + @RegisterExtension + static final QuarkusUnitTest config = new QuarkusUnitTest() + .withApplicationRoot((jar) -> jar + .addClasses(IdentityMock.class, AuthData.class, SecurityTestUtils.class)); + + @Inject + InheritanceSecuredBean securedBean; + + @Test + public void testCheckerOnAbstractParent() { + assertSuccess(() -> securedBean.secured_checkerOnParent("parent"), "secured_checkerOnParent", ADMIN_WITH_AUGMENTORS); + assertFailureFor(() -> securedBean.secured_checkerOnParent("parent"), ForbiddenException.class, USER_WITH_AUGMENTORS); + assertFailureFor(() -> securedBean.secured_checkerOnParent("wrong-value"), ForbiddenException.class, + ADMIN_WITH_AUGMENTORS); + } + + @Test + public void testCheckerOnDefaultInterface() { + assertSuccess(() -> securedBean.secured_checkerOnInterface("interface"), "secured_checkerOnInterface", + ADMIN_WITH_AUGMENTORS); + assertFailureFor(() -> securedBean.secured_checkerOnInterface("interface"), ForbiddenException.class, + USER_WITH_AUGMENTORS); + assertFailureFor(() -> securedBean.secured_checkerOnInterface("wrong-value"), ForbiddenException.class, + ADMIN_WITH_AUGMENTORS); + } + + @Test + public void testOverloadedMethod() { + assertSuccess(() -> securedBean.secured_overloadedBaseOne("overloaded_base"), "secured_overloadedBaseOne", + ADMIN_WITH_AUGMENTORS); + assertFailureFor(() -> securedBean.secured_overloadedBaseOne("wrong-value"), ForbiddenException.class, + ADMIN_WITH_AUGMENTORS); + assertSuccess(() -> securedBean.secured_overloadedBaseTwo("overloaded_base_two"), "secured_overloadedBaseTwo", + ADMIN_WITH_AUGMENTORS); + assertFailureFor(() -> securedBean.secured_overloadedBaseTwo("wrong-value"), ForbiddenException.class, + ADMIN_WITH_AUGMENTORS); + assertSuccess(() -> securedBean.secured_overloadedParentOne("overloaded_parent"), "secured_overloadedParentOne", + ADMIN_WITH_AUGMENTORS); + assertFailureFor(() -> securedBean.secured_overloadedParentOne("wrong-value"), ForbiddenException.class, + ADMIN_WITH_AUGMENTORS); + assertSuccess(() -> securedBean.secured_overloadedParentTwo("overloaded_parent_two"), "secured_overloadedParentTwo", + ADMIN_WITH_AUGMENTORS); + assertFailureFor(() -> securedBean.secured_overloadedParentTwo("wrong-value"), ForbiddenException.class, + ADMIN_WITH_AUGMENTORS); + } + + @ApplicationScoped + public static class InheritanceSecuredBean { + + @PermissionsAllowed("parent") + public String secured_checkerOnParent(String param) { + return "secured_checkerOnParent"; + } + + @PermissionsAllowed("interface") + public String secured_checkerOnInterface(String param) { + return "secured_checkerOnInterface"; + } + + @PermissionsAllowed("overloaded_base") + public String secured_overloadedBaseOne(String param) { + return "secured_overloadedBaseOne"; + } + + @PermissionsAllowed("overloaded_base_two") + public String secured_overloadedBaseTwo(String param) { + return "secured_overloadedBaseTwo"; + } + + @PermissionsAllowed("overloaded_parent") + public String secured_overloadedParentOne(String param) { + return "secured_overloadedParentOne"; + } + + @PermissionsAllowed("overloaded_parent_two") + public String secured_overloadedParentTwo(String param) { + return "secured_overloadedParentTwo"; + } + + } + + @Unremovable + @Singleton + public static class CheckerOnParent_base extends CheckerOnParent_parent { + + } + + @ApplicationScoped + public static class CheckerMethodOverloaded_base extends CheckerMethodOverloaded_parent { + + @PermissionChecker("overloaded_base") + boolean overloaded_base(SecurityIdentity identity, String param) { + return "overloaded_base".equals(param) && identity.hasRole("admin"); + } + + @PermissionChecker("overloaded_base_two") + boolean overloaded_base(String param, SecurityIdentity identity) { + return "overloaded_base_two".equals(param) && identity.hasRole("admin"); + } + + } + + public static abstract class CheckerMethodOverloaded_parent { + + @PermissionChecker("overloaded_parent") + boolean overloaded_parent(String param, SecurityIdentity identity) { + return "overloaded_parent".equals(param) && identity.hasRole("admin"); + } + + @PermissionChecker("overloaded_parent_two") + boolean overloaded_parent(SecurityIdentity identity, String param) { + return "overloaded_parent_two".equals(param) && identity.hasRole("admin"); + } + } + + public static abstract class CheckerOnParent_parent { + + @Inject + SecurityIdentity identity; + + @PermissionChecker("parent") + boolean canAccess(String param) { + return "parent".equals(param) && identity.hasRole("admin"); + } + + } + + @Unremovable + @Singleton + public static class CheckerOnInterface_base implements CheckerOnInterface_interface { + + } + + public interface CheckerOnInterface_interface { + + @PermissionChecker("interface") + default boolean canAccess(String param, SecurityIdentity identity) { + return "interface".equals(param) && identity.hasRole("admin"); + } + + } +} diff --git a/extensions/security/deployment/src/test/java/io/quarkus/security/test/permissionsallowed/checker/PermissionCheckerMethodArgsTest.java b/extensions/security/deployment/src/test/java/io/quarkus/security/test/permissionsallowed/checker/PermissionCheckerMethodArgsTest.java new file mode 100644 index 00000000000000..9de94c14a6dce1 --- /dev/null +++ b/extensions/security/deployment/src/test/java/io/quarkus/security/test/permissionsallowed/checker/PermissionCheckerMethodArgsTest.java @@ -0,0 +1,349 @@ +package io.quarkus.security.test.permissionsallowed.checker; + +import static io.quarkus.security.test.utils.IdentityMock.ADMIN; +import static io.quarkus.security.test.utils.IdentityMock.USER; +import static io.quarkus.security.test.utils.SecurityTestUtils.assertFailureFor; +import static io.quarkus.security.test.utils.SecurityTestUtils.assertSuccess; + +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; + +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.test.utils.AuthData; +import io.quarkus.security.test.utils.IdentityMock; +import io.quarkus.security.test.utils.SecurityTestUtils; +import io.quarkus.test.QuarkusUnitTest; + +public class PermissionCheckerMethodArgsTest { + + private static final AuthData USER_WITH_AUGMENTORS = new AuthData(USER, true); + private static final AuthData ADMIN_WITH_AUGMENTORS = new AuthData(ADMIN, true); + + @RegisterExtension + static final QuarkusUnitTest config = new QuarkusUnitTest() + .withApplicationRoot((jar) -> jar + .addClasses(IdentityMock.class, AuthData.class, SecurityTestUtils.class, + PermissionCheckerOnlySecurityIdentity.class, + PermissionCheckerOneSecuredMethodArg.class, PermissionCheckerTwoSecuredMethodArgs.class, + PermissionCheckerThreeSecuredMethodArgs.class, + PermissionCheckerFourSecuredMethodArgs.class, AbstractNthMethodArgChecker.class, + PermissionChecker1stMethodArg.class, PermissionChecker2ndMethodArg.class, + PermissionChecker3rdMethodArg.class, PermissionChecker4thMethodArg.class, + PermissionChecker5thMethodArg.class, PermissionChecker6thMethodArg.class, + PermissionChecker7thMethodArg.class)); + + @Inject + MethodArgsBean bean; + + @Test + public void testOnlySecurityIdentityCheckerArg() { + assertSuccess(() -> bean.zeroSecuredMethodArguments(), "zeroSecuredMethodArguments", ADMIN_WITH_AUGMENTORS); + assertFailureFor(() -> bean.zeroSecuredMethodArguments(), ForbiddenException.class, ADMIN); + assertFailureFor(() -> bean.zeroSecuredMethodArguments(), ForbiddenException.class, USER_WITH_AUGMENTORS); + + assertSuccess(() -> bean.oneSecuredMethodArgument_2(1), "oneSecuredMethodArgument_2", ADMIN_WITH_AUGMENTORS); + assertFailureFor(() -> bean.oneSecuredMethodArgument_2(1), ForbiddenException.class, USER_WITH_AUGMENTORS); + } + + @Test + public void testOneCheckerArgument() { + assertSuccess(() -> bean.oneSecuredMethodArgument("1"), "oneSecuredMethodArgument", ADMIN_WITH_AUGMENTORS); + assertFailureFor(() -> bean.oneSecuredMethodArgument("1"), ForbiddenException.class, ADMIN); + assertFailureFor(() -> bean.oneSecuredMethodArgument("1"), ForbiddenException.class, USER_WITH_AUGMENTORS); + + assertSuccess(() -> bean.twoSecuredMethodArgument_2(1, 2), "twoSecuredMethodArgument_2", ADMIN_WITH_AUGMENTORS); + assertFailureFor(() -> bean.twoSecuredMethodArgument_2(1, 2), ForbiddenException.class, USER_WITH_AUGMENTORS); + + // wrong value of 'one' + assertFailureFor(() -> bean.twoSecuredMethodArgument_2(9, 2), ForbiddenException.class, ADMIN_WITH_AUGMENTORS); + } + + @Test + public void testTwoCheckerArguments() { + assertSuccess(() -> bean.twoSecuredMethodArgument(1, 2), "twoSecuredMethodArgument", ADMIN_WITH_AUGMENTORS); + assertFailureFor(() -> bean.twoSecuredMethodArgument(1, 2), ForbiddenException.class, ADMIN); + assertFailureFor(() -> bean.twoSecuredMethodArgument(1, 2), ForbiddenException.class, USER_WITH_AUGMENTORS); + + assertSuccess(() -> bean.threeSecuredMethodArguments_2(1, 2, "3"), "threeSecuredMethodArguments_2", + ADMIN_WITH_AUGMENTORS); + assertFailureFor(() -> bean.threeSecuredMethodArguments_2(1, 2, "3"), ForbiddenException.class, USER_WITH_AUGMENTORS); + + // wrong value of 'two' + assertFailureFor(() -> bean.threeSecuredMethodArguments_2(1, 4, "3"), ForbiddenException.class, ADMIN_WITH_AUGMENTORS); + } + + @Test + public void testThreeCheckerArguments() { + assertSuccess(() -> bean.threeSecuredMethodArguments("1", "2", "3"), "threeSecuredMethodArguments", + ADMIN_WITH_AUGMENTORS); + assertFailureFor(() -> bean.threeSecuredMethodArguments("1", "2", "3"), ForbiddenException.class, ADMIN); + assertFailureFor(() -> bean.threeSecuredMethodArguments("1", "2", "3"), ForbiddenException.class, USER_WITH_AUGMENTORS); + + assertSuccess(() -> bean.fourSecuredMethodArguments_2("1", 2, "3", 4), "fourSecuredMethodArguments_2", + ADMIN_WITH_AUGMENTORS); + assertFailureFor(() -> bean.fourSecuredMethodArguments_2("1", 2, "3", 4), ForbiddenException.class, + USER_WITH_AUGMENTORS); + + // wrong value of 'one' + assertFailureFor(() -> bean.fourSecuredMethodArguments_2("987", 2, "3", 4), ForbiddenException.class, + ADMIN_WITH_AUGMENTORS); + } + + @Test + public void testFourCheckerArguments() { + assertSuccess(() -> bean.fourSecuredMethodArguments(1, 2, "3", "4"), "fourSecuredMethodArguments", + ADMIN_WITH_AUGMENTORS); + assertFailureFor(() -> bean.fourSecuredMethodArguments(1, 2, "3", "4"), ForbiddenException.class, ADMIN); + assertFailureFor(() -> bean.fourSecuredMethodArguments(1, 2, "3", "4"), ForbiddenException.class, USER_WITH_AUGMENTORS); + + assertSuccess(() -> bean.fiveSecuredMethodArguments_2("1", 2, "3", 4, "5"), "fiveSecuredMethodArguments_2", + ADMIN_WITH_AUGMENTORS); + assertFailureFor(() -> bean.fiveSecuredMethodArguments_2("1", 2, "3", 4, "5"), ForbiddenException.class, + USER_WITH_AUGMENTORS); + + // wrong value of 'four' + assertFailureFor(() -> bean.fiveSecuredMethodArguments_2("1", 2, "3", 8, "5"), ForbiddenException.class, + ADMIN_WITH_AUGMENTORS); + } + + @Test + public void testMultipleCheckersForOneSecMethod_annotationRepeated() { + // === all 5 arguments required by 5 different permissions + assertSuccess(() -> bean.fiveSecuredMethodArguments(1, 2, "3", "4", "5"), "fiveSecuredMethodArguments", + ADMIN_WITH_AUGMENTORS); + assertFailureFor(() -> bean.fiveSecuredMethodArguments(1, 2, "3", "4", "5"), ForbiddenException.class, ADMIN); + assertFailureFor(() -> bean.fiveSecuredMethodArguments(1, 2, "3", "4", "5"), ForbiddenException.class, + USER_WITH_AUGMENTORS); + // 1st is wrong + assertFailureFor(() -> bean.fiveSecuredMethodArguments(7, 2, "3", "4", "5"), ForbiddenException.class, + ADMIN_WITH_AUGMENTORS); + // 2nd is wrong + assertFailureFor(() -> bean.fiveSecuredMethodArguments(1, 3, "3", "4", "5"), ForbiddenException.class, + ADMIN_WITH_AUGMENTORS); + // 3rd is wrong + assertFailureFor(() -> bean.fiveSecuredMethodArguments(1, 2, "2", "4", "5"), ForbiddenException.class, + ADMIN_WITH_AUGMENTORS); + // 4th is wrong + assertFailureFor(() -> bean.fiveSecuredMethodArguments(1, 2, "3", "5", "5"), ForbiddenException.class, + ADMIN_WITH_AUGMENTORS); + // 5th is wrong + assertFailureFor(() -> bean.fiveSecuredMethodArguments(1, 2, "3", "4", "6"), ForbiddenException.class, + ADMIN_WITH_AUGMENTORS); + + // === all 6 arguments required by 6 different permissions + assertSuccess(() -> bean.sixSecuredMethodArguments_2("1", 2, "3", 4, "5", 6), "sixSecuredMethodArguments_2", + ADMIN_WITH_AUGMENTORS); + assertFailureFor(() -> bean.sixSecuredMethodArguments_2("1", 2, "3", 4, "5", 6), ForbiddenException.class, + USER_WITH_AUGMENTORS); + // 5th is wrong + assertFailureFor(() -> bean.sixSecuredMethodArguments_2("1", 2, "3", 4, "6", 6), ForbiddenException.class, + ADMIN_WITH_AUGMENTORS); + // 6th is wrong + assertFailureFor(() -> bean.sixSecuredMethodArguments_2("1", 2, "3", 4, "5", 7), ForbiddenException.class, + ADMIN_WITH_AUGMENTORS); + + // === 5th and 6th argument required by 2 different permissions + assertSuccess(() -> bean.sixSecuredMethodArguments(1, 2, "3", "4", "5", 6), "sixSecuredMethodArguments", + ADMIN_WITH_AUGMENTORS); + assertFailureFor(() -> bean.sixSecuredMethodArguments(1, 2, "3", "4", "5", 6), ForbiddenException.class, + USER_WITH_AUGMENTORS); + // 5th is wrong + assertFailureFor(() -> bean.sixSecuredMethodArguments(1, 2, "3", "4", "6", 6), ForbiddenException.class, + ADMIN_WITH_AUGMENTORS); + // 6th is wrong + assertFailureFor(() -> bean.sixSecuredMethodArguments(1, 2, "3", "4", "5", 7), ForbiddenException.class, + ADMIN_WITH_AUGMENTORS); + + // === all 7 arguments required by 7 different permissions + assertSuccess(() -> bean.sevenSecuredMethodArguments_2("1", 2, "3", 4, "5", 6, 7), "sevenSecuredMethodArguments_2", + ADMIN_WITH_AUGMENTORS); + assertFailureFor(() -> bean.sevenSecuredMethodArguments_2("1", 2, "3", 4, "5", 6, 7), ForbiddenException.class, + USER_WITH_AUGMENTORS); + // 5th is wrong + assertFailureFor(() -> bean.sevenSecuredMethodArguments_2("1", 2, "3", 4, "5", 5, 7), ForbiddenException.class, + ADMIN_WITH_AUGMENTORS); + // 6th is wrong + assertFailureFor(() -> bean.sevenSecuredMethodArguments_2("1", 2, "3", 4, "5", 6, 8), ForbiddenException.class, + ADMIN_WITH_AUGMENTORS); + + // === 6th and 7th argument required by 2 different permissions + assertSuccess(() -> bean.sevenSecuredMethodArguments(1, 2, "3", "4", "5", 6, 7), "sevenSecuredMethodArguments", + ADMIN_WITH_AUGMENTORS); + assertFailureFor(() -> bean.sevenSecuredMethodArguments(1, 2, "3", "4", "5", 6, 7), ForbiddenException.class, + USER_WITH_AUGMENTORS); + // 1st is wrong + assertFailureFor(() -> bean.sevenSecuredMethodArguments(0, 2, "3", "4", "5", 6, 7), ForbiddenException.class, + ADMIN_WITH_AUGMENTORS); + // 7th is wrong + assertFailureFor(() -> bean.sevenSecuredMethodArguments(1, 2, "3", "4", "5", 6, 8), ForbiddenException.class, + ADMIN_WITH_AUGMENTORS); + } + + @Test + public void testMultipleCheckersForOneSecMethod_inclusive() { + // === all 7 arguments required by 7 different permissions inside single @PermissionsAllowed annotation instance + // another 2 permissions ("another-6th-arg", "another-7th-arg") are required by second @PermissionsAllowed instance + // therefore user needs all 9 permissions + assertSuccess(() -> bean.sevenSecuredMethodArguments_2_inclusive("1", 2, "3", 4, "5", 6, 7), + "sevenSecuredMethodArguments_2_inclusive", + ADMIN_WITH_AUGMENTORS); + assertFailureFor(() -> bean.sevenSecuredMethodArguments_2_inclusive("1", 2, "3", 4, "5", 6, 7), + ForbiddenException.class, + USER_WITH_AUGMENTORS); + // 5th is wrong + assertFailureFor(() -> bean.sevenSecuredMethodArguments_2_inclusive("1", 2, "3", 4, "5", 5, 7), + ForbiddenException.class, + ADMIN_WITH_AUGMENTORS); + // 6th is wrong + assertFailureFor(() -> bean.sevenSecuredMethodArguments_2_inclusive("1", 2, "3", 4, "5", 6, 8), + ForbiddenException.class, + ADMIN_WITH_AUGMENTORS); + // permission checker "another-6th-arg" accepts either int or String, but never long + // pass: 6th param is string + assertSuccess(() -> bean.sevenSecuredMethodArguments_2_inclusive("1", 2, "3", 4, "5", "6", 7), + "sevenSecuredMethodArguments_2_inclusive", + ADMIN_WITH_AUGMENTORS); + // fail: 6th param is long + assertFailureFor(() -> bean.sevenSecuredMethodArguments_2_inclusive("1", 2, "3", 4, "5", 6L, 7), + ForbiddenException.class, + ADMIN_WITH_AUGMENTORS); + // permission checker "another-7th-arg" accepts either int or long, but never String + // pass: 7th param is long + assertSuccess(() -> bean.sevenSecuredMethodArguments_2_inclusive("1", 2, "3", 4, "5", 6, 7L), + "sevenSecuredMethodArguments_2_inclusive", + ADMIN_WITH_AUGMENTORS); + // fail: 7th param is string + assertFailureFor(() -> bean.sevenSecuredMethodArguments_2_inclusive("1", 2, "3", 4, "5", 6, "7"), + ForbiddenException.class, + ADMIN_WITH_AUGMENTORS); + + // === all 6 arguments required by 6 different permissions inside one @PermissionsAllowed annotation instance + assertSuccess(() -> bean.sixSecuredMethodArguments_2("1", 2, "3", 4, "5", 6), "sixSecuredMethodArguments_2", + ADMIN_WITH_AUGMENTORS); + assertFailureFor(() -> bean.sixSecuredMethodArguments_2("1", 2, "3", 4, "5", 6), ForbiddenException.class, + USER_WITH_AUGMENTORS); + // 5th is wrong + assertFailureFor(() -> bean.sixSecuredMethodArguments_2("1", 2, "3", 4, "6", 6), ForbiddenException.class, + ADMIN_WITH_AUGMENTORS); + // 6th is wrong + assertFailureFor(() -> bean.sixSecuredMethodArguments_2("1", 2, "3", 4, "5", 7), ForbiddenException.class, + ADMIN_WITH_AUGMENTORS); + } + + @ApplicationScoped + public static class MethodArgsBean { + + @PermissionsAllowed("only-security-identity") + public String zeroSecuredMethodArguments() { + return "zeroSecuredMethodArguments"; + } + + @PermissionsAllowed("one-arg") + public String oneSecuredMethodArgument(String one) { + return "oneSecuredMethodArgument"; + } + + @PermissionsAllowed("only-security-identity") + public String oneSecuredMethodArgument_2(int one) { + return "oneSecuredMethodArgument_2"; + } + + @PermissionsAllowed("two-args") + public String twoSecuredMethodArgument(long one, long two) { + return "twoSecuredMethodArgument"; + } + + @PermissionsAllowed("one-arg") + public String twoSecuredMethodArgument_2(long one, long two) { + return "twoSecuredMethodArgument_2"; + } + + @PermissionsAllowed("three-args") + public String threeSecuredMethodArguments(String one, String two, String three) { + return "threeSecuredMethodArguments"; + } + + @PermissionsAllowed("two-args") + public String threeSecuredMethodArguments_2(int one, int two, String three) { + return "threeSecuredMethodArguments_2"; + } + + @PermissionsAllowed("four-args") + public String fourSecuredMethodArguments(int one, int two, String three, String four) { + return "fourSecuredMethodArguments"; + } + + @PermissionsAllowed("three-args") + public String fourSecuredMethodArguments_2(String one, int two, String three, int four) { + return "fourSecuredMethodArguments_2"; + } + + @PermissionsAllowed("1st-arg") + @PermissionsAllowed("2nd-arg") + @PermissionsAllowed("3rd-arg") + @PermissionsAllowed("4th-arg") + @PermissionsAllowed("5th-arg") + public String fiveSecuredMethodArguments(int one, int two, String three, String four, String five) { + return "fiveSecuredMethodArguments"; + } + + @PermissionsAllowed("four-args") + public String fiveSecuredMethodArguments_2(String one, int two, String three, int four, String five) { + return "fiveSecuredMethodArguments_2"; + } + + @PermissionsAllowed("5th-arg") + @PermissionsAllowed("6th-arg") + public String sixSecuredMethodArguments(int one, int two, String three, String four, String five, Object six) { + return "sixSecuredMethodArguments"; + } + + @PermissionsAllowed("1st-arg") + @PermissionsAllowed("2nd-arg") + @PermissionsAllowed("3rd-arg") + @PermissionsAllowed("4th-arg") + @PermissionsAllowed("5th-arg") + @PermissionsAllowed("6th-arg") + public String sixSecuredMethodArguments_2(String one, int two, String three, int four, String five, Object six) { + return "sixSecuredMethodArguments_2"; + } + + @PermissionsAllowed(value = { "1st-arg", "2nd-arg", "3rd-arg", "4th-arg", "5th-arg", "6th-arg" }, inclusive = true) + public String sixSecuredMethodArguments_2_inclusive(String one, int two, String three, int four, String five, + Object six) { + return "sixSecuredMethodArguments_2_inclusive"; + } + + @PermissionsAllowed("1st-arg") + @PermissionsAllowed("7th-arg") + public String sevenSecuredMethodArguments(int one, int two, String three, String four, String five, Object six, + Object seven) { + return "sevenSecuredMethodArguments"; + } + + @PermissionsAllowed("1st-arg") + @PermissionsAllowed("2nd-arg") + @PermissionsAllowed("3rd-arg") + @PermissionsAllowed("4th-arg") + @PermissionsAllowed("5th-arg") + @PermissionsAllowed("6th-arg") + @PermissionsAllowed("7th-arg") + public String sevenSecuredMethodArguments_2(String one, int two, String three, int four, String five, Object six, + Object seven) { + return "sevenSecuredMethodArguments_2"; + } + + @PermissionsAllowed(value = { "another-6th-arg", "another-7th-arg" }, inclusive = true) + @PermissionsAllowed(value = { "1st-arg", "2nd-arg", "3rd-arg", "4th-arg", "5th-arg", "6th-arg", + "7th-arg" }, inclusive = true) + public String sevenSecuredMethodArguments_2_inclusive(String one, int two, String three, int four, String five, + Object six, + Object seven) { + return "sevenSecuredMethodArguments_2_inclusive"; + } + } +} diff --git a/extensions/security/deployment/src/test/java/io/quarkus/security/test/permissionsallowed/checker/PermissionCheckerOneSecuredMethodArg.java b/extensions/security/deployment/src/test/java/io/quarkus/security/test/permissionsallowed/checker/PermissionCheckerOneSecuredMethodArg.java new file mode 100644 index 00000000000000..4351a5c9042537 --- /dev/null +++ b/extensions/security/deployment/src/test/java/io/quarkus/security/test/permissionsallowed/checker/PermissionCheckerOneSecuredMethodArg.java @@ -0,0 +1,15 @@ +package io.quarkus.security.test.permissionsallowed.checker; + +import jakarta.inject.Singleton; + +import io.quarkus.security.PermissionChecker; +import io.quarkus.security.identity.SecurityIdentity; + +@Singleton +public class PermissionCheckerOneSecuredMethodArg { + + @PermissionChecker("one-arg") + public boolean isGranted(SecurityIdentity securityIdentity, Object one) { + return Integer.parseInt(one.toString()) == 1 && securityIdentity.hasRole("admin"); + } +} diff --git a/extensions/security/deployment/src/test/java/io/quarkus/security/test/permissionsallowed/checker/PermissionCheckerOnlySecurityIdentity.java b/extensions/security/deployment/src/test/java/io/quarkus/security/test/permissionsallowed/checker/PermissionCheckerOnlySecurityIdentity.java new file mode 100644 index 00000000000000..b7d1c864cf3d90 --- /dev/null +++ b/extensions/security/deployment/src/test/java/io/quarkus/security/test/permissionsallowed/checker/PermissionCheckerOnlySecurityIdentity.java @@ -0,0 +1,15 @@ +package io.quarkus.security.test.permissionsallowed.checker; + +import jakarta.enterprise.context.ApplicationScoped; + +import io.quarkus.security.PermissionChecker; +import io.quarkus.security.identity.SecurityIdentity; + +@ApplicationScoped +public class PermissionCheckerOnlySecurityIdentity { + + @PermissionChecker("only-security-identity") + boolean isAdmin(SecurityIdentity securityIdentity) { + return securityIdentity.hasRole("admin"); + } +} diff --git a/extensions/security/deployment/src/test/java/io/quarkus/security/test/permissionsallowed/checker/PermissionCheckerThreeSecuredMethodArgs.java b/extensions/security/deployment/src/test/java/io/quarkus/security/test/permissionsallowed/checker/PermissionCheckerThreeSecuredMethodArgs.java new file mode 100644 index 00000000000000..d9f600f5e7a401 --- /dev/null +++ b/extensions/security/deployment/src/test/java/io/quarkus/security/test/permissionsallowed/checker/PermissionCheckerThreeSecuredMethodArgs.java @@ -0,0 +1,16 @@ +package io.quarkus.security.test.permissionsallowed.checker; + +import jakarta.enterprise.context.ApplicationScoped; + +import io.quarkus.security.PermissionChecker; +import io.quarkus.security.identity.SecurityIdentity; + +@ApplicationScoped +public class PermissionCheckerThreeSecuredMethodArgs { + + @PermissionChecker("three-args") + boolean areThreeArgsOk(String three, SecurityIdentity securityIdentity, String one, Object two) { + return Integer.parseInt(one) == 1 && Integer.parseInt(two.toString()) == 2 && Integer.parseInt(three) == 3 + && securityIdentity.hasRole("admin"); + } +} diff --git a/extensions/security/deployment/src/test/java/io/quarkus/security/test/permissionsallowed/checker/PermissionCheckerTwoSecuredMethodArgs.java b/extensions/security/deployment/src/test/java/io/quarkus/security/test/permissionsallowed/checker/PermissionCheckerTwoSecuredMethodArgs.java new file mode 100644 index 00000000000000..e65842ac565482 --- /dev/null +++ b/extensions/security/deployment/src/test/java/io/quarkus/security/test/permissionsallowed/checker/PermissionCheckerTwoSecuredMethodArgs.java @@ -0,0 +1,15 @@ +package io.quarkus.security.test.permissionsallowed.checker; + +import jakarta.inject.Singleton; + +import io.quarkus.security.PermissionChecker; +import io.quarkus.security.identity.SecurityIdentity; + +@Singleton +public class PermissionCheckerTwoSecuredMethodArgs { + + @PermissionChecker("two-args") + boolean areTwoArgsOk(long two, long one, SecurityIdentity securityIdentity) { + return one == 1 && two == 2 && securityIdentity.hasRole("admin"); + } +} diff --git a/extensions/security/deployment/src/test/java/io/quarkus/security/test/permissionsallowed/checker/ReactivePermissionCheckerTest.java b/extensions/security/deployment/src/test/java/io/quarkus/security/test/permissionsallowed/checker/ReactivePermissionCheckerTest.java new file mode 100644 index 00000000000000..79aa5e8c64d964 --- /dev/null +++ b/extensions/security/deployment/src/test/java/io/quarkus/security/test/permissionsallowed/checker/ReactivePermissionCheckerTest.java @@ -0,0 +1,108 @@ +package io.quarkus.security.test.permissionsallowed.checker; + +import static io.quarkus.security.test.utils.IdentityMock.ADMIN; +import static io.quarkus.security.test.utils.IdentityMock.ANONYMOUS; +import static io.quarkus.security.test.utils.IdentityMock.USER; +import static io.quarkus.security.test.utils.SecurityTestUtils.assertFailureFor; +import static io.quarkus.security.test.utils.SecurityTestUtils.assertSuccess; + +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import io.quarkus.security.ForbiddenException; +import io.quarkus.security.PermissionChecker; +import io.quarkus.security.PermissionsAllowed; +import io.quarkus.security.UnauthorizedException; +import io.quarkus.security.identity.SecurityIdentity; +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; +import io.smallrye.mutiny.Uni; + +public class ReactivePermissionCheckerTest { + + private static final AuthData USER_WITH_AUGMENTORS = new AuthData(USER, true); + private static final AuthData ADMIN_WITH_AUGMENTORS = new AuthData(ADMIN, true); + + @RegisterExtension + static final QuarkusUnitTest config = new QuarkusUnitTest() + .withApplicationRoot((jar) -> jar + .addClasses(IdentityMock.class, AuthData.class, SecurityTestUtils.class)); + + @Inject + ReactivePermissionCheckerSecuredBean bean; + + @Test + public void testCheckerAcceptingOnlySecurityIdentity() { + assertSuccess(() -> bean.securityIdentityOnly(), "securityIdentityOnly", ADMIN_WITH_AUGMENTORS); + assertFailureFor(() -> bean.securityIdentityOnly(), ForbiddenException.class, USER_WITH_AUGMENTORS); + assertFailureFor(() -> bean.securityIdentityOnly(), ForbiddenException.class, ADMIN); + } + + @Test + public void testCheckerAcceptingSecuredMethodArguments() { + assertSuccess(() -> bean.securedMethodArguments(1, 2, 3), "securedMethodArguments", ADMIN_WITH_AUGMENTORS); + assertFailureFor(() -> bean.securedMethodArguments(1, 2, 3), UnauthorizedException.class, ANONYMOUS); + assertFailureFor(() -> bean.securedMethodArguments(1, 2, 3), ForbiddenException.class, USER_WITH_AUGMENTORS); + assertFailureFor(() -> bean.securedMethodArguments(1, 2, 3), ForbiddenException.class, ADMIN); + assertFailureFor(() -> bean.securedMethodArguments(9, 2, 3), ForbiddenException.class, ADMIN_WITH_AUGMENTORS); + assertFailureFor(() -> bean.securedMethodArguments(1, 9, 3), ForbiddenException.class, ADMIN_WITH_AUGMENTORS); + assertFailureFor(() -> bean.securedMethodArguments(1, 2, 9), ForbiddenException.class, ADMIN_WITH_AUGMENTORS); + } + + @Test + public void testPermissionCheckerRuntimeExceptionHandling() { + assertSuccess(() -> bean.permissionCheckFailingForUser(), "permissionCheckFailingForUser", ADMIN_WITH_AUGMENTORS); + assertFailureFor(() -> bean.permissionCheckFailingForUser(), UnauthorizedException.class, ANONYMOUS); + assertFailureFor(() -> bean.permissionCheckFailingForUser(), ForbiddenException.class, USER_WITH_AUGMENTORS); + } + + @ApplicationScoped + public static class ReactivePermissionCheckerSecuredBean { + + @PermissionsAllowed("security-identity-only") + String securityIdentityOnly() { + return "securityIdentityOnly"; + } + + @PermissionsAllowed("secured-method-args") + String securedMethodArguments(int one, int two, int three) { + return "securedMethodArguments"; + } + + @PermissionsAllowed("runtime-exception-for-user") + String permissionCheckFailingForUser() { + return "permissionCheckFailingForUser"; + } + + } + + @ApplicationScoped + public static class ReactivePermissionChecker { + + @PermissionChecker("security-identity-only") + Uni canAccess(SecurityIdentity identity) { + return Uni.createFrom().item(identity.hasRole("admin")); + } + + @PermissionChecker("secured-method-args") + Uni canAccessWithArguments(SecurityIdentity identity, int one, int two, int three) { + boolean isAdmin = identity.hasRole("admin"); + boolean argsOk = one == 1 && two == 2 && three == 3; + return Uni.createFrom().item(isAdmin && argsOk); + } + + @PermissionChecker("runtime-exception-for-user") + Uni canAccessWithRuntimeException(SecurityIdentity identity) { + if (identity.getPrincipal().getName().equals("user")) { + return Uni.createFrom().failure(new RuntimeException("Expected runtime exception!")); + } + return Uni.createFrom().item(true); + } + } + +} diff --git a/extensions/security/deployment/src/test/java/io/quarkus/security/test/permissionsallowed/checker/SecurityIdentityOnlyPermissionChecker.java b/extensions/security/deployment/src/test/java/io/quarkus/security/test/permissionsallowed/checker/SecurityIdentityOnlyPermissionChecker.java new file mode 100644 index 00000000000000..e44ef501d4e2c7 --- /dev/null +++ b/extensions/security/deployment/src/test/java/io/quarkus/security/test/permissionsallowed/checker/SecurityIdentityOnlyPermissionChecker.java @@ -0,0 +1,16 @@ +package io.quarkus.security.test.permissionsallowed.checker; + +import jakarta.enterprise.context.ApplicationScoped; + +import io.quarkus.security.PermissionChecker; +import io.quarkus.security.identity.SecurityIdentity; + +@ApplicationScoped +public class SecurityIdentityOnlyPermissionChecker { + + @PermissionChecker("security-identity-only") + public boolean hasAdminPrincipalName(SecurityIdentity securityIdentity) { + return !securityIdentity.isAnonymous() && "admin".equals(securityIdentity.getPrincipal().getName()); + } + +} diff --git a/extensions/security/deployment/src/test/java/io/quarkus/security/test/permissionsallowed/checker/SecurityIdentityOnlyPermissionCheckerTest.java b/extensions/security/deployment/src/test/java/io/quarkus/security/test/permissionsallowed/checker/SecurityIdentityOnlyPermissionCheckerTest.java new file mode 100644 index 00000000000000..36b30e8b2a664c --- /dev/null +++ b/extensions/security/deployment/src/test/java/io/quarkus/security/test/permissionsallowed/checker/SecurityIdentityOnlyPermissionCheckerTest.java @@ -0,0 +1,77 @@ +package io.quarkus.security.test.permissionsallowed.checker; + +import static io.quarkus.security.test.utils.IdentityMock.ADMIN; +import static io.quarkus.security.test.utils.IdentityMock.USER; +import static io.quarkus.security.test.utils.SecurityTestUtils.assertFailureFor; +import static io.quarkus.security.test.utils.SecurityTestUtils.assertSuccess; + +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; + +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.test.utils.AuthData; +import io.quarkus.security.test.utils.IdentityMock; +import io.quarkus.security.test.utils.SecurityTestUtils; +import io.quarkus.test.QuarkusUnitTest; + +public class SecurityIdentityOnlyPermissionCheckerTest { + + private static final AuthData USER_WITH_AUGMENTORS = new AuthData(USER, true); + private static final AuthData ADMIN_WITH_AUGMENTORS = new AuthData(ADMIN, true); + + @RegisterExtension + static final QuarkusUnitTest config = new QuarkusUnitTest() + .withApplicationRoot((jar) -> jar + .addClasses(IdentityMock.class, AuthData.class, SecurityTestUtils.class, + SinglePermissionCheckerTargetBean.class, SecurityIdentityOnlyPermissionChecker.class)); + + @Inject + SinglePermissionCheckerTargetBean bean; + + @Test + public void testSinglePermissionChecker() { + assertSuccess(() -> bean.noSecurity(), "noSecurity", USER_WITH_AUGMENTORS); + assertFailureFor(() -> bean.secured(), ForbiddenException.class, USER_WITH_AUGMENTORS); + assertFailureFor(() -> bean.secured2(), ForbiddenException.class, USER_WITH_AUGMENTORS); + assertSuccess(() -> bean.noSecurity(), "noSecurity", USER); + assertFailureFor(() -> bean.secured(), ForbiddenException.class, USER); + assertFailureFor(() -> bean.secured2(), ForbiddenException.class, USER); + assertSuccess(() -> bean.noSecurity(), "noSecurity", ADMIN); + assertFailureFor(() -> bean.secured(), ForbiddenException.class, ADMIN); + assertFailureFor(() -> bean.secured2(), ForbiddenException.class, ADMIN); + assertFailureFor(() -> bean.permissionWithoutChecker(), ForbiddenException.class, ADMIN); + + assertSuccess(() -> bean.noSecurity(), "noSecurity", ADMIN_WITH_AUGMENTORS); + assertSuccess(() -> bean.secured(), "secured", ADMIN_WITH_AUGMENTORS); + assertSuccess(() -> bean.secured2(), "secured2", ADMIN_WITH_AUGMENTORS); + assertFailureFor(() -> bean.permissionWithoutChecker(), ForbiddenException.class, ADMIN_WITH_AUGMENTORS); + } + + @ApplicationScoped + public static class SinglePermissionCheckerTargetBean { + + @PermissionsAllowed("security-identity-only") + public String secured() { + return "secured"; + } + + public String noSecurity() { + return "noSecurity"; + } + + @PermissionsAllowed("security-identity-only") + public String secured2() { + return "secured2"; + } + + @PermissionsAllowed("permission-without-checker") + public String permissionWithoutChecker() { + return "permissionWithoutChecker"; + } + } + +} diff --git a/extensions/security/deployment/src/test/java/io/quarkus/security/test/permissionsallowed/checker/UnknownCheckerParamValidationFailureTest.java b/extensions/security/deployment/src/test/java/io/quarkus/security/test/permissionsallowed/checker/UnknownCheckerParamValidationFailureTest.java new file mode 100644 index 00000000000000..55fa1603bd4c76 --- /dev/null +++ b/extensions/security/deployment/src/test/java/io/quarkus/security/test/permissionsallowed/checker/UnknownCheckerParamValidationFailureTest.java @@ -0,0 +1,44 @@ +package io.quarkus.security.test.permissionsallowed.checker; + +import java.util.UUID; + +import jakarta.inject.Singleton; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import io.quarkus.security.PermissionChecker; +import io.quarkus.security.PermissionsAllowed; +import io.quarkus.test.QuarkusUnitTest; + +public class UnknownCheckerParamValidationFailureTest { + + @RegisterExtension + static final QuarkusUnitTest config = new QuarkusUnitTest() + .assertException(t -> { + Assertions.assertEquals(RuntimeException.class, t.getClass(), t.getMessage()); + Assertions.assertTrue(t.getMessage().contains("No 'securedBean' formal parameter name matches")); + Assertions.assertTrue(t.getMessage().contains("SecuredBean#check")); + Assertions.assertTrue(t.getMessage().contains("parameter name 'unknownParameter'")); + }); + + @Test + public void test() { + Assertions.fail(); + } + + @Singleton + public static class SecuredBean { + + @PermissionsAllowed("checker") + public void securedBean(UUID aOrganizationUnitId) { + // EMPTY + } + + @PermissionChecker("checker") + public boolean check(String unknownParameter) { + return false; + } + } +} diff --git a/extensions/security/runtime/src/main/java/io/quarkus/security/runtime/QuarkusPermission.java b/extensions/security/runtime/src/main/java/io/quarkus/security/runtime/QuarkusPermission.java new file mode 100644 index 00000000000000..1649df594a3eea --- /dev/null +++ b/extensions/security/runtime/src/main/java/io/quarkus/security/runtime/QuarkusPermission.java @@ -0,0 +1,147 @@ +package io.quarkus.security.runtime; + +import java.security.Permission; +import java.util.Objects; +import java.util.function.Supplier; + +import io.quarkus.arc.Arc; +import io.quarkus.arc.InstanceHandle; +import io.quarkus.security.PermissionChecker; +import io.quarkus.security.PermissionsAllowed; +import io.quarkus.security.identity.SecurityIdentity; +import io.quarkus.security.spi.runtime.BlockingSecurityExecutor; +import io.smallrye.mutiny.Uni; + +/** + * Special type of the {@link PermissionsAllowed#permission()} that does not require {@link SecurityIdentity} + * augmentation. When this permission is set to the {@link PermissionsAllowed#permission()}, Quarkus Security augments + * all the {@link SecurityIdentity} with a permission checker for you. This way, access to methods secured with + * the {@link PermissionsAllowed} annotation will only be granted when {@link #isGranted(SecurityIdentity)} returns true. + * Currently, all the {@link io.quarkus.security.PermissionChecker} are generated as subclasses of this permission. + *

+ * IMPORTANT: this class is public as generated subclasses are placed in different packages, but users should not use it; + * {@link QuarkusPermissionSecurityIdentityAugmentor} is only added when the {@link io.quarkus.security.PermissionChecker} + * is detected. + */ +public abstract class QuarkusPermission extends Permission { + + private volatile InstanceHandle bean = null; + + /** + * Subclasses can declare constructors that accept permission name and/or arguments of a secured method. + * + * @param permissionName permission name, this matches {@link PermissionChecker#value()} + * @see PermissionsAllowed#params() for more information about additional Permission arguments + */ + protected QuarkusPermission(String permissionName) { + super(permissionName); + } + + /** + * @return declaring class of the method annotated with the {@link PermissionChecker} + */ + protected abstract Class getBeanClass(); + + /** + * @return true if {@link #isGranted(SecurityIdentity)} must be executed on a worker thread + */ + protected abstract boolean isBlocking(); + + /** + * Whether user-defined permission checker returns {@link Uni}. + * + * @return true if {@link #isGrantedUni(SecurityIdentity)} should be used instead of the + * {@link #isGranted(SecurityIdentity)} + */ + protected abstract boolean isReactive(); + + /** + * @return CDI bean that declares the method annotated with the {@link PermissionChecker} + */ + protected final T getBean() { + return getBeanInstanceHandle().get(); + } + + /** + * Determines whether access to secured resource should be granted in a synchronous manner. + * Subclasses should override this method unless they need to perform permission check in an asynchronous manner. + * + * @param securityIdentity {@link SecurityIdentity} + * @return true if access should be granted and false otherwise + */ + protected abstract boolean isGranted(SecurityIdentity securityIdentity); + + /** + * Determines whether access to secured resource should be granted in an asynchronous manner. + * Subclasses can override this method, however it is only called when {@link #isReactive()} returns {@code true}. + * + * @param securityIdentity {@link SecurityIdentity} + * @return Uni with {@code true} if access should be granted and Uni with {@code false} otherwise + */ + protected abstract Uni isGrantedUni(SecurityIdentity securityIdentity); + + final Uni isGranted(SecurityIdentity identity, BlockingSecurityExecutor blockingExecutor) { + if (isBlocking()) { + return blockingExecutor.executeBlocking(new Supplier() { + @Override + public Boolean get() { + return isGranted(identity); + } + }); + } + + try { + if (isReactive()) { + return isGrantedUni(identity); + } else { + return Uni.createFrom().item(isGranted(identity)); + } + } catch (Throwable throwable) { + return Uni.createFrom().failure(throwable); + } + } + + /** + * @throws IllegalStateException for this permission can only be set to the {@link PermissionsAllowed#permission()} + */ + @Override + public final boolean implies(Permission requiredPermission) { + // possessed permission implies required permission + // this is required permission, not the possessed one + throw new IllegalStateException("QuarkusPermission should never be assigned to a SecurityIdentity. " + + "This permission can only be set to the @PermissionsAllowed#permission attribute by Quarkus itself."); + } + + @Override + public final String getActions() { + return ""; + } + + @Override + public final boolean equals(Object object) { + return this == object; + } + + @Override + public final int hashCode() { + return Objects.hash(toString()); + } + + private InstanceHandle getBeanInstanceHandle() { + if (bean == null) { + // this is done lazily because permissions without extra constructor arguments are created before Arc is ready + bean = Arc.container().instance(getBeanClass()); + if (!bean.isAvailable()) { + throw new IllegalStateException( + "CDI bean '%s' is not available, but it is required by the @PermissionChecker method" + .formatted(getBeanClass())); + } + } + return bean; + } + + // used by generated subclasses + protected static Uni accessDenied() { + return Uni.createFrom().item(false); + } +} diff --git a/extensions/security/runtime/src/main/java/io/quarkus/security/runtime/QuarkusPermissionSecurityIdentityAugmentor.java b/extensions/security/runtime/src/main/java/io/quarkus/security/runtime/QuarkusPermissionSecurityIdentityAugmentor.java new file mode 100644 index 00000000000000..300cd5e8302047 --- /dev/null +++ b/extensions/security/runtime/src/main/java/io/quarkus/security/runtime/QuarkusPermissionSecurityIdentityAugmentor.java @@ -0,0 +1,64 @@ +package io.quarkus.security.runtime; + +import java.security.Permission; +import java.util.function.Function; +import java.util.function.Predicate; + +import io.quarkus.security.ForbiddenException; +import io.quarkus.security.identity.AuthenticationRequestContext; +import io.quarkus.security.identity.SecurityIdentity; +import io.quarkus.security.identity.SecurityIdentityAugmentor; +import io.quarkus.security.spi.runtime.BlockingSecurityExecutor; +import io.smallrye.mutiny.Uni; + +/** + * Adds a permission checker that grants access to the {@link QuarkusPermission} + * when {@link QuarkusPermission#isGranted(SecurityIdentity)} is true. + */ +final class QuarkusPermissionSecurityIdentityAugmentor implements SecurityIdentityAugmentor { + + /** + * Permission checker only authorizes authenticated users and checkers shouldn't throw a security exception. + * However, it can happen than runtime exception occur, and we shouldn't leak that something wrong with response status. + */ + private static final Predicate NOT_A_FORBIDDEN_EXCEPTION = new Predicate<>() { + @Override + public boolean test(Throwable throwable) { + return !(throwable instanceof ForbiddenException); + } + }; + private static final Function WRAP_WITH_FORBIDDEN_EXCEPTION = new Function<>() { + @Override + public Throwable apply(Throwable throwable) { + return new ForbiddenException(throwable); + } + }; + + private final BlockingSecurityExecutor blockingExecutor; + + QuarkusPermissionSecurityIdentityAugmentor(BlockingSecurityExecutor blockingExecutor) { + this.blockingExecutor = blockingExecutor; + } + + @Override + public Uni augment(SecurityIdentity identity, AuthenticationRequestContext context) { + if (identity.isAnonymous()) { + return Uni.createFrom().item(identity); + } + + return Uni.createFrom().item(QuarkusSecurityIdentity + .builder(identity) + .addPermissionChecker(new Function<>() { + @Override + public Uni apply(Permission requiredpermission) { + if (requiredpermission instanceof QuarkusPermission quarkusPermission) { + return quarkusPermission + .isGranted(identity, blockingExecutor) + .onFailure(NOT_A_FORBIDDEN_EXCEPTION).transform(WRAP_WITH_FORBIDDEN_EXCEPTION); + } + return Uni.createFrom().item(false); + } + }) + .build()); + } +} diff --git a/extensions/security/runtime/src/main/java/io/quarkus/security/runtime/SecurityCheckRecorder.java b/extensions/security/runtime/src/main/java/io/quarkus/security/runtime/SecurityCheckRecorder.java index f6bbc41587cc6e..b68221ad2c0c78 100644 --- a/extensions/security/runtime/src/main/java/io/quarkus/security/runtime/SecurityCheckRecorder.java +++ b/extensions/security/runtime/src/main/java/io/quarkus/security/runtime/SecurityCheckRecorder.java @@ -20,12 +20,16 @@ import org.eclipse.microprofile.config.Config; import org.eclipse.microprofile.config.spi.ConfigProviderResolver; +import org.jboss.logging.Logger; import io.quarkus.arc.Arc; +import io.quarkus.arc.SyntheticCreationalContext; import io.quarkus.runtime.RuntimeValue; import io.quarkus.runtime.ShutdownContext; import io.quarkus.runtime.annotations.Recorder; +import io.quarkus.security.ForbiddenException; import io.quarkus.security.StringPermission; +import io.quarkus.security.identity.SecurityIdentityAugmentor; import io.quarkus.security.runtime.interceptor.SecurityCheckStorageBuilder; import io.quarkus.security.runtime.interceptor.SecurityConstrainer; import io.quarkus.security.runtime.interceptor.check.AuthenticatedCheck; @@ -36,6 +40,7 @@ import io.quarkus.security.runtime.interceptor.check.SupplierRolesAllowedCheck; import io.quarkus.security.spi.runtime.AuthorizationFailureEvent; import io.quarkus.security.spi.runtime.AuthorizationSuccessEvent; +import io.quarkus.security.spi.runtime.BlockingSecurityExecutor; import io.quarkus.security.spi.runtime.SecurityCheck; import io.quarkus.security.spi.runtime.SecurityCheckStorage; import io.smallrye.config.Expressions; @@ -44,6 +49,7 @@ @Recorder public class SecurityCheckRecorder { + private static final Logger LOGGER = Logger.getLogger(SecurityCheckRecorder.class); private static volatile SecurityCheckStorage storage; private static final Set configExpRolesAllowedChecks = ConcurrentHashMap.newKeySet(); private static volatile boolean runtimeConfigReady = false; @@ -277,9 +283,10 @@ public RuntimeValue createPermission(String name, String clazz, Stri } else { permission = (Permission) loadClass(clazz).getConstructors()[0].newInstance(name); } - } catch (InstantiationException | IllegalAccessException | InvocationTargetException e) { - throw new RuntimeException(String.format("Failed to create Permission - class '%s', name '%s', actions '%s'", clazz, - name, Arrays.toString(actions)), e); + } catch (InstantiationException | IllegalAccessException | InvocationTargetException | RuntimeException e) { + LOGGER.errorf(e, "Failed to create Permission - class '%s', name '%s', actions '%s', access will be denied", + clazz, name, Arrays.toString(actions)); + throw new ForbiddenException(); } return new RuntimeValue<>(permission); } @@ -310,11 +317,11 @@ public Permission apply(Object[] securedMethodArgs) { try { final Object[] initArgs = initArgs(securedMethodArgs); return (Permission) permissionClassConstructor.newInstance(initArgs); - } catch (InstantiationException | IllegalAccessException | InvocationTargetException e) { - throw new RuntimeException( - String.format("Failed to create computed Permission - class '%s', name '%s', actions '%s', ", clazz, - permissionName, Arrays.toString(actions)), - e); + } catch (InstantiationException | IllegalAccessException | InvocationTargetException | RuntimeException e) { + LOGGER.errorf(e, + "Failed to create computed Permission - class '%s', name '%s', actions '%s', access will be denied", + clazz, permissionName, Arrays.toString(actions)); + throw new ForbiddenException(); } } @@ -433,4 +440,13 @@ private static Object convertMethodParamToPermParam(int i, Object methodArg, "Failed to convert method argument '%s' to Permission constructor parameter".formatted(methodArg), e); } } + + public Function, SecurityIdentityAugmentor> createPermissionAugmentor() { + return new Function, SecurityIdentityAugmentor>() { + @Override + public SecurityIdentityAugmentor apply(SyntheticCreationalContext ctx) { + return new QuarkusPermissionSecurityIdentityAugmentor(ctx.getInjectedReference(BlockingSecurityExecutor.class)); + } + }; + } } diff --git a/extensions/security/runtime/src/main/java/io/quarkus/security/runtime/interceptor/check/PermissionSecurityCheck.java b/extensions/security/runtime/src/main/java/io/quarkus/security/runtime/interceptor/check/PermissionSecurityCheck.java index 680c5326a1f666..2a1fabc3364d29 100644 --- a/extensions/security/runtime/src/main/java/io/quarkus/security/runtime/interceptor/check/PermissionSecurityCheck.java +++ b/extensions/security/runtime/src/main/java/io/quarkus/security/runtime/interceptor/check/PermissionSecurityCheck.java @@ -1,6 +1,5 @@ package io.quarkus.security.runtime.interceptor.check; -import static java.lang.Boolean.FALSE; import static java.lang.Boolean.TRUE; import java.lang.reflect.Method; @@ -105,12 +104,12 @@ protected Uni checkPermissions(SecurityIdentity identity, Permission permissi .transformToUni(new Function<>() { @Override public Uni apply(Boolean hasPermission) { - if (FALSE.equals(hasPermission)) { - // check failed - return Uni.createFrom().failure(getException(identity)); + if (TRUE.equals(hasPermission)) { + return SUCCESSFUL_CHECK; } - return SUCCESSFUL_CHECK; + // check failed + return Uni.createFrom().failure(getException(identity)); } }); } diff --git a/extensions/security/test-utils/src/main/java/io/quarkus/security/test/utils/AuthData.java b/extensions/security/test-utils/src/main/java/io/quarkus/security/test/utils/AuthData.java index d0d9bf1306acd8..27b6f5d0385a32 100644 --- a/extensions/security/test-utils/src/main/java/io/quarkus/security/test/utils/AuthData.java +++ b/extensions/security/test-utils/src/main/java/io/quarkus/security/test/utils/AuthData.java @@ -1,6 +1,8 @@ package io.quarkus.security.test.utils; import java.security.Permission; +import java.util.Arrays; +import java.util.HashSet; import java.util.Set; /** @@ -11,12 +13,14 @@ public class AuthData { public final boolean anonymous; public final String name; public final Set permissions; + final boolean applyAugmentors; public AuthData(Set roles, boolean anonymous, String name) { this.roles = roles; this.anonymous = anonymous; this.name = name; this.permissions = null; + this.applyAugmentors = false; } public AuthData(Set roles, boolean anonymous, String name, Set permissions) { @@ -24,5 +28,22 @@ public AuthData(Set roles, boolean anonymous, String name, Set roles, boolean anonymous, String name, Set permissions, boolean applyAugmentors) { + this.roles = roles; + this.anonymous = anonymous; + this.name = name; + this.permissions = permissions; + this.applyAugmentors = applyAugmentors; + } + + public AuthData(AuthData authData, boolean applyAugmentors) { + this(authData.roles, authData.anonymous, authData.name, authData.permissions, applyAugmentors); + } + + public AuthData(AuthData authData, boolean applyAugmentors, Permission... permissions) { + this(authData.roles, authData.anonymous, authData.name, new HashSet<>(Arrays.asList(permissions)), applyAugmentors); } } diff --git a/extensions/security/test-utils/src/main/java/io/quarkus/security/test/utils/IdentityMock.java b/extensions/security/test-utils/src/main/java/io/quarkus/security/test/utils/IdentityMock.java index 5c5bb79c213578..78240d4fd25f81 100644 --- a/extensions/security/test-utils/src/main/java/io/quarkus/security/test/utils/IdentityMock.java +++ b/extensions/security/test-utils/src/main/java/io/quarkus/security/test/utils/IdentityMock.java @@ -6,15 +6,21 @@ import java.util.HashSet; import java.util.Map; import java.util.Set; +import java.util.function.Supplier; import jakarta.annotation.Priority; import jakarta.enterprise.context.ApplicationScoped; import jakarta.enterprise.inject.Alternative; +import jakarta.enterprise.inject.Instance; import jakarta.inject.Inject; +import io.quarkus.arc.Arc; import io.quarkus.security.credential.Credential; +import io.quarkus.security.identity.AuthenticationRequestContext; import io.quarkus.security.identity.SecurityIdentity; +import io.quarkus.security.identity.SecurityIdentityAugmentor; import io.quarkus.security.runtime.SecurityIdentityAssociation; +import io.quarkus.security.spi.runtime.BlockingSecurityExecutor; import io.smallrye.mutiny.Uni; /** @@ -25,7 +31,7 @@ @Priority(1) public class IdentityMock implements SecurityIdentity { - public static final AuthData ANONYMOUS = new AuthData(null, true, null); + public static final AuthData ANONYMOUS = new AuthData(null, true, null, null); public static final AuthData USER = new AuthData(Collections.singleton("user"), false, "user", Set.of()); public static final AuthData ADMIN = new AuthData(Collections.singleton("admin"), false, "admin", Set.of()); @@ -33,12 +39,14 @@ public class IdentityMock implements SecurityIdentity { private static volatile Set roles; private static volatile Set permissions = new HashSet<>(); private static volatile String name; + private static volatile boolean applyAugmentors; public static void setUpAuth(AuthData auth) { IdentityMock.anonymous = auth.anonymous; IdentityMock.roles = auth.roles; IdentityMock.name = auth.name; IdentityMock.permissions = auth.permissions == null ? Set.of() : auth.permissions; + IdentityMock.applyAugmentors = auth.applyAugmentors; } @Override @@ -76,7 +84,7 @@ public T getCredential(Class aClass) { @Override public Set getCredentials() { - return null; + return Set.of(); } @Override @@ -86,7 +94,7 @@ public T getAttribute(String s) { @Override public Map getAttributes() { - return null; + return Map.of(); } @Override @@ -102,14 +110,39 @@ public static class IdentityAssociationMock extends SecurityIdentityAssociation @Inject IdentityMock identity; + @Inject + Instance augmentors; + @Override public Uni getDeferredIdentity() { + if (applyAugmentors) { + return augmentIdentity(identity); + } return Uni.createFrom().item(identity); } @Override public SecurityIdentity getIdentity() { + if (applyAugmentors) { + return augmentIdentity(identity).await().indefinitely(); + } return identity; } + + private Uni augmentIdentity(SecurityIdentity identity) { + var authReqContexts = new TestAuthenticationRequestContext(); + Uni result = Uni.createFrom().item(identity); + for (SecurityIdentityAugmentor augmentor : augmentors) { + result = result.flatMap(si -> augmentor.augment(si, authReqContexts, Map.of())); + } + return result; + } + + private static final class TestAuthenticationRequestContext implements AuthenticationRequestContext { + @Override + public Uni runBlocking(Supplier function) { + return Arc.container().instance(BlockingSecurityExecutor.class).get().executeBlocking(function); + } + } } } diff --git a/extensions/security/test-utils/src/main/java/io/quarkus/security/test/utils/SecurityTestUtils.java b/extensions/security/test-utils/src/main/java/io/quarkus/security/test/utils/SecurityTestUtils.java index e0fa8642b5389e..7332113b9500a6 100644 --- a/extensions/security/test-utils/src/main/java/io/quarkus/security/test/utils/SecurityTestUtils.java +++ b/extensions/security/test-utils/src/main/java/io/quarkus/security/test/utils/SecurityTestUtils.java @@ -19,7 +19,9 @@ public static void assertSuccess(Supplier action, T expectedResult, AuthD setUpAuth(authData); Assertions.assertEquals(expectedResult, action.get()); } - + if (auth.length == 0) { + throw new IllegalStateException("No tests were executed as AuthData are missing"); + } } public static void assertSuccess(Uni action, T expectedResult, AuthData authData) { @@ -43,6 +45,9 @@ public static void assertFailureFor(Executable action, Class void assertFailureFor(Uni action, Class expectedException, AuthData authData) { diff --git a/integration-tests/elytron-security-jdbc/pom.xml b/integration-tests/elytron-security-jdbc/pom.xml index 5333808d6dc563..0e9177d12d9e63 100644 --- a/integration-tests/elytron-security-jdbc/pom.xml +++ b/integration-tests/elytron-security-jdbc/pom.xml @@ -26,6 +26,10 @@ io.quarkus quarkus-jdbc-h2 + + io.quarkus + quarkus-narayana-jta + diff --git a/integration-tests/elytron-security-jdbc/src/main/java/io/quarkus/elytron/security/jdbc/it/ElytronSecurityJdbcResource.java b/integration-tests/elytron-security-jdbc/src/main/java/io/quarkus/elytron/security/jdbc/it/ElytronSecurityJdbcResource.java index aafabeb43cefe4..52bff54235c8b2 100644 --- a/integration-tests/elytron-security-jdbc/src/main/java/io/quarkus/elytron/security/jdbc/it/ElytronSecurityJdbcResource.java +++ b/integration-tests/elytron-security-jdbc/src/main/java/io/quarkus/elytron/security/jdbc/it/ElytronSecurityJdbcResource.java @@ -19,6 +19,7 @@ import jakarta.annotation.security.RolesAllowed; import jakarta.enterprise.context.ApplicationScoped; import jakarta.ws.rs.GET; +import jakarta.ws.rs.HeaderParam; import jakarta.ws.rs.Path; import io.quarkus.security.PermissionsAllowed; @@ -61,4 +62,10 @@ public String forbidden() { return "forbidden"; } + @GET + @Path("/permission-checker") + @PermissionsAllowed("admin-role-in-db") + public String permissionChecker(@HeaderParam("username") String usernameHeader) { + return "permission-checker"; + } } diff --git a/integration-tests/elytron-security-jdbc/src/main/java/io/quarkus/elytron/security/jdbc/it/JdbcPermissionChecker.java b/integration-tests/elytron-security-jdbc/src/main/java/io/quarkus/elytron/security/jdbc/it/JdbcPermissionChecker.java new file mode 100644 index 00000000000000..f8acfdab1f0309 --- /dev/null +++ b/integration-tests/elytron-security-jdbc/src/main/java/io/quarkus/elytron/security/jdbc/it/JdbcPermissionChecker.java @@ -0,0 +1,42 @@ +package io.quarkus.elytron.security.jdbc.it; + +import java.sql.Connection; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.sql.Statement; + +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import jakarta.transaction.Transactional; + +import io.agroal.api.AgroalDataSource; +import io.quarkus.security.PermissionChecker; + +@ApplicationScoped +public class JdbcPermissionChecker { + + @Inject + AgroalDataSource defaultDataSource; + + @Transactional + @PermissionChecker("admin-role-in-db") + boolean hasAdminRole(String usernameHeader) { + String username = switch (usernameHeader) { + case "admin" -> "admin"; + case "user" -> "user"; + default -> throw new IllegalArgumentException("Invalid username: " + usernameHeader); + }; + try (Connection connection = defaultDataSource.getConnection(); Statement stat = connection.createStatement()) { + try (ResultSet roleQuery = stat + .executeQuery("select u.role from test_user u where u.username='" + username + "'")) { + if (!roleQuery.first()) { + throw new IllegalStateException("Username '%s' not in the 'test_user' table".formatted(username)); + } + var role = roleQuery.getString(1); + return "admin".equals(role); + } + } catch (SQLException e) { + throw new RuntimeException(e); + } + } +} diff --git a/integration-tests/elytron-security-jdbc/src/test/java/io/quarkus/elytron/security/jdbc/it/ElytronSecurityJdbcTest.java b/integration-tests/elytron-security-jdbc/src/test/java/io/quarkus/elytron/security/jdbc/it/ElytronSecurityJdbcTest.java index 126bf391c1b1dd..d4017e701dde4c 100644 --- a/integration-tests/elytron-security-jdbc/src/test/java/io/quarkus/elytron/security/jdbc/it/ElytronSecurityJdbcTest.java +++ b/integration-tests/elytron-security-jdbc/src/test/java/io/quarkus/elytron/security/jdbc/it/ElytronSecurityJdbcTest.java @@ -188,4 +188,51 @@ void forbidden_not_authenticated() { .statusCode(302); } + @Test + void testJdbcPermissionChecker() { + CookieFilter userCookies = new CookieFilter(); + RestAssured + .given() + .filter(userCookies) + .redirects().follow(false) + .when() + .formParam("j_username", "user") + .formParam("j_password", "user") + .post("/j_security_check") + .then() + .assertThat() + .statusCode(302); + + RestAssured.given() + .header("username", "user") + .redirects().follow(false) + .filter(userCookies) + .when() + .get("/api/permission-checker") + .then() + .statusCode(403); + + CookieFilter adminCookies = new CookieFilter(); + RestAssured + .given() + .filter(adminCookies) + .redirects().follow(false) + .when() + .formParam("j_username", "user") + .formParam("j_password", "user") + .post("/j_security_check") + .then() + .assertThat() + .statusCode(302); + + RestAssured.given() + .header("username", "admin") + .redirects().follow(false) + .filter(adminCookies) + .when() + .get("/api/permission-checker") + .then() + .statusCode(200) + .body(containsString("permission-checker")); + } } diff --git a/integration-tests/oidc-wiremock/src/main/java/io/quarkus/it/keycloak/AdminResource.java b/integration-tests/oidc-wiremock/src/main/java/io/quarkus/it/keycloak/AdminResource.java index 0d11fb5bd819f8..314e644788d84f 100644 --- a/integration-tests/oidc-wiremock/src/main/java/io/quarkus/it/keycloak/AdminResource.java +++ b/integration-tests/oidc-wiremock/src/main/java/io/quarkus/it/keycloak/AdminResource.java @@ -8,8 +8,10 @@ import jakarta.ws.rs.core.MediaType; import org.eclipse.microprofile.jwt.JsonWebToken; +import org.jboss.resteasy.reactive.RestQuery; import io.quarkus.security.Authenticated; +import io.quarkus.security.PermissionsAllowed; import io.quarkus.security.identity.SecurityIdentity; import io.vertx.ext.web.RoutingContext; @@ -120,4 +122,12 @@ public String adminNoKidandThumprint() { public String adminWrongRolePath() { return "granted:" + identity.getRoles(); } + + @Path("bearer-permission-checker") + @GET + @PermissionsAllowed("admin-preferred-username") + @Produces(MediaType.APPLICATION_JSON) + public String bearerPermissionChecker(@RestQuery String fail) { + return "granted:" + identity.getRoles(); + } } diff --git a/integration-tests/oidc-wiremock/src/main/java/io/quarkus/it/keycloak/JwtClaimPermissionChecker.java b/integration-tests/oidc-wiremock/src/main/java/io/quarkus/it/keycloak/JwtClaimPermissionChecker.java new file mode 100644 index 00000000000000..8c78fcf3774c31 --- /dev/null +++ b/integration-tests/oidc-wiremock/src/main/java/io/quarkus/it/keycloak/JwtClaimPermissionChecker.java @@ -0,0 +1,23 @@ +package io.quarkus.it.keycloak; + +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; + +import org.eclipse.microprofile.jwt.JsonWebToken; + +import io.quarkus.security.PermissionChecker; + +@ApplicationScoped +public class JwtClaimPermissionChecker { + + @Inject + JsonWebToken jwtAccessToken; + + @PermissionChecker("admin-preferred-username") + boolean preferredUsernameIsAdmin(String fail) { + if (Boolean.parseBoolean(fail)) { + return false; + } + return "admin".equals(jwtAccessToken.getClaim("preferred_username")); + } +} diff --git a/integration-tests/oidc-wiremock/src/main/resources/application.properties b/integration-tests/oidc-wiremock/src/main/resources/application.properties index edeeaceebf8423..30405ae3b83de4 100644 --- a/integration-tests/oidc-wiremock/src/main/resources/application.properties +++ b/integration-tests/oidc-wiremock/src/main/resources/application.properties @@ -186,6 +186,11 @@ quarkus.oidc.bearer-required-algorithm.client-id=quarkus-app quarkus.oidc.bearer-required-algorithm.credentials.secret=secret quarkus.oidc.bearer-required-algorithm.token.signature-algorithm=PS256 +quarkus.oidc.bearer-permission-checker.auth-server-url=${keycloak.url}/realms/quarkus/ +quarkus.oidc.bearer-permission-checker.client-id=quarkus-app +quarkus.oidc.bearer-permission-checker.credentials.secret=secret +quarkus.oidc.bearer-permission-checker.token.signature-algorithm=PS256 + quarkus.oidc.bearer-azure.provider=microsoft quarkus.oidc.bearer-azure.authentication.user-info-required=false quarkus.oidc.bearer-azure.application-type=service diff --git a/integration-tests/oidc-wiremock/src/test/java/io/quarkus/it/keycloak/BearerTokenAuthorizationTest.java b/integration-tests/oidc-wiremock/src/test/java/io/quarkus/it/keycloak/BearerTokenAuthorizationTest.java index 6977986b7d99ff..1f6f6ee9d13f0d 100644 --- a/integration-tests/oidc-wiremock/src/test/java/io/quarkus/it/keycloak/BearerTokenAuthorizationTest.java +++ b/integration-tests/oidc-wiremock/src/test/java/io/quarkus/it/keycloak/BearerTokenAuthorizationTest.java @@ -697,6 +697,27 @@ public void testInvalidBearerToken() { .header("WWW-Authenticate", equalTo("Bearer")); } + // point of this test method mainly to test native mode + @Test + public void testJwtClaimPermissionChecker() { + RestAssured.given().auth().oauth2(getAccessToken("admin", Set.of("admin"), SignatureAlgorithm.PS256)) + .when().get("/api/admin/bearer-permission-checker") + .then() + .statusCode(200) + .body(Matchers.containsString("admin")); + // permission checker deny access as query param signals "fail" + RestAssured.given().auth().oauth2(getAccessToken("admin", Set.of("admin"), SignatureAlgorithm.PS256)) + .queryParam("fail", "true") + .when().get("/api/admin/bearer-permission-checker") + .then() + .statusCode(403); + // permission checker deny access as preferred name is 'other-admin' and not 'admin' + RestAssured.given().auth().oauth2(getAccessToken("other-admin", Set.of("admin"), SignatureAlgorithm.PS256)) + .when().get("/api/admin/bearer-permission-checker") + .then() + .statusCode(403); + } + private String getAccessToken(String userName, Set groups) { return getAccessToken(userName, groups, SignatureAlgorithm.RS256); }