From 393565a5c5a23ae9aed0641605928e4316d6da8c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michal=20Vav=C5=99=C3=ADk?= Date: Tue, 1 Oct 2024 13:29:39 +0200 Subject: [PATCH] Support @PermissionsAllowed with @BeanParam parameters --- ...ity-authorize-web-endpoints-reference.adoc | 136 ++++- .../BeanParamPermissionIdentityAugmentor.java | 34 ++ .../server/test/security/MyBeanParam.java | 11 + .../server/test/security/MyPermission.java | 47 ++ .../server/test/security/OtherBeanParam.java | 30 ++ .../security/OtherBeanParamPermission.java | 60 +++ .../PermissionsAllowedBeanParamTest.java | 166 ++++++ .../server/test/security/SimpleBeanParam.java | 33 ++ .../security/SimpleBeanParamPermission.java | 76 +++ .../deployment/PermissionSecurityChecks.java | 492 +++++++++++++++--- .../deployment/SecurityProcessor.java | 15 +- ...ssLevelComputedPermissionsAllowedTest.java | 4 +- .../CombinedAccessParam.java | 22 + .../permissionsallowed/ComplexFieldParam.java | 20 + .../CustomPermissionWithMultipleArgs.java | 28 + .../CustomPermissionWithStringArg.java | 21 + .../InjectionPermissionsAllowedTest.java | 5 +- ...odLevelComputedPermissionsAllowedTest.java | 20 +- .../NestedMethodsObject.java | 35 ++ .../PermissionsAllowedNestedParamsTest.java | 110 ++++ .../test/permissionsallowed/SecuredBean.java | 47 ++ .../permissionsallowed/SimpleFieldParam.java | 10 + .../test/permissionsallowed/StringRecord.java | 4 + .../permissionsallowed/TopTierRecord.java | 8 + ...rmissionsAllowedValidationFailureTest.java | 48 ++ ...rmissionsAllowedValidationFailureTest.java | 56 ++ .../runtime/SecurityCheckRecorder.java | 42 +- 27 files changed, 1468 insertions(+), 112 deletions(-) create mode 100644 extensions/resteasy-reactive/rest/deployment/src/test/java/io/quarkus/resteasy/reactive/server/test/security/BeanParamPermissionIdentityAugmentor.java create mode 100644 extensions/resteasy-reactive/rest/deployment/src/test/java/io/quarkus/resteasy/reactive/server/test/security/MyBeanParam.java create mode 100644 extensions/resteasy-reactive/rest/deployment/src/test/java/io/quarkus/resteasy/reactive/server/test/security/MyPermission.java create mode 100644 extensions/resteasy-reactive/rest/deployment/src/test/java/io/quarkus/resteasy/reactive/server/test/security/OtherBeanParam.java create mode 100644 extensions/resteasy-reactive/rest/deployment/src/test/java/io/quarkus/resteasy/reactive/server/test/security/OtherBeanParamPermission.java create mode 100644 extensions/resteasy-reactive/rest/deployment/src/test/java/io/quarkus/resteasy/reactive/server/test/security/PermissionsAllowedBeanParamTest.java create mode 100644 extensions/resteasy-reactive/rest/deployment/src/test/java/io/quarkus/resteasy/reactive/server/test/security/SimpleBeanParam.java create mode 100644 extensions/resteasy-reactive/rest/deployment/src/test/java/io/quarkus/resteasy/reactive/server/test/security/SimpleBeanParamPermission.java create mode 100644 extensions/security/deployment/src/test/java/io/quarkus/security/test/permissionsallowed/CombinedAccessParam.java create mode 100644 extensions/security/deployment/src/test/java/io/quarkus/security/test/permissionsallowed/ComplexFieldParam.java create mode 100644 extensions/security/deployment/src/test/java/io/quarkus/security/test/permissionsallowed/CustomPermissionWithMultipleArgs.java create mode 100644 extensions/security/deployment/src/test/java/io/quarkus/security/test/permissionsallowed/CustomPermissionWithStringArg.java create mode 100644 extensions/security/deployment/src/test/java/io/quarkus/security/test/permissionsallowed/NestedMethodsObject.java create mode 100644 extensions/security/deployment/src/test/java/io/quarkus/security/test/permissionsallowed/PermissionsAllowedNestedParamsTest.java create mode 100644 extensions/security/deployment/src/test/java/io/quarkus/security/test/permissionsallowed/SecuredBean.java create mode 100644 extensions/security/deployment/src/test/java/io/quarkus/security/test/permissionsallowed/SimpleFieldParam.java create mode 100644 extensions/security/deployment/src/test/java/io/quarkus/security/test/permissionsallowed/StringRecord.java create mode 100644 extensions/security/deployment/src/test/java/io/quarkus/security/test/permissionsallowed/TopTierRecord.java create mode 100644 extensions/security/deployment/src/test/java/io/quarkus/security/test/permissionsallowed/UnknownParamPermissionsAllowedValidationFailureTest.java create mode 100644 extensions/security/deployment/src/test/java/io/quarkus/security/test/permissionsallowed/UnusedParamPermissionsAllowedValidationFailureTest.java diff --git a/docs/src/main/asciidoc/security-authorize-web-endpoints-reference.adoc b/docs/src/main/asciidoc/security-authorize-web-endpoints-reference.adoc index 41b2994caeb5a..f666e782d89ee 100644 --- a/docs/src/main/asciidoc/security-authorize-web-endpoints-reference.adoc +++ b/docs/src/main/asciidoc/security-authorize-web-endpoints-reference.adoc @@ -830,7 +830,7 @@ Your custom class must define exactly one constructor that accepts the permissio In this scenario, the permission `list` is added to the `SecurityIdentity` instance as `new CustomPermission("list")`. You can also create a custom `java.security.Permission` class with additional constructor parameters. -These additional parameters get matched with arguments of the method annotated with the `@PermissionsAllowed` annotation. +These additional parameters names get matched with arguments names of the method annotated with the `@PermissionsAllowed` annotation. Later, Quarkus instantiates your custom permission with actual arguments, with which the method annotated with the `@PermissionsAllowed` has been invoked. .Example of a custom `java.security.Permission` class that accepts additional arguments @@ -910,12 +910,12 @@ import org.acme.library.LibraryPermission.Library; public class LibraryService { @PermissionsAllowed(value = "tv:write", permission = LibraryPermission.class) <1> - public Library updateLibrary(String newDesc, Library update) { - update.description = newDesc; - return update; + public Library updateLibrary(String newDesc, Library library) { + library.description = newDesc; + return library; } - @PermissionsAllowed(value = "tv:write", permission = LibraryPermission.class, params = "library") <2> + @PermissionsAllowed(value = "tv:write", permission = LibraryPermission.class) <2> @PermissionsAllowed(value = {"tv:read", "tv:list"}, permission = LibraryPermission.class) public Library migrateLibrary(Library migrate, Library library) { // migrate libraries @@ -924,10 +924,11 @@ public class LibraryService { } ---- -<1> The formal parameter `update` is identified as the first `Library` parameter and gets passed to the `LibraryPermission` class. +<1> The formal parameter `library` is identified as the parameter matching same-named `LibraryPermission` constructor parameter. +Therefore, Quarkus will pass the `library` parameter to the `LibraryPermission` class constructor. However, the `LibraryPermission` must be instantiated each time the `updateLibrary` method is invoked. -<2> Here, the first `Library` parameter is `migrate`; therefore, the `library` parameter gets marked explicitly through `PermissionsAllowed#params`. -The permission constructor and the annotated method must have the parameter `library` set; otherwise, validation fails. +<2> Here, the second `Library` parameter matches the name `library`, +while the `migrate` parameter is ignored during the `LibraryPermission` permission instantiation. .Example of a resource secured with the `LibraryPermission` @@ -1078,6 +1079,125 @@ 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 + +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. +Let's consider following Jakarta REST resource: + +[source,java] +---- +package org.acme.security.rest.resource; + +import io.quarkus.security.PermissionsAllowed; +import jakarta.ws.rs.BeanParam; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.Path; + +@Path("/hello") +public class SimpleResource { + + @PermissionsAllowed(value = "say:hello", permission = BeanParamPermission.class, + params = "beanParam.securityContext.userPrincipal.name") <1> + @GET + public String sayHello(@BeanParam SimpleBeanParam beanParam) { + return "Hello from " + beanParam.uriInfo.getPath(); + } + +} +---- +<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. +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. + +Where the `SimpleBeanParam` class is declared like in the example below: + +[source,java] +---- +package org.acme.security.rest.dto; + +import java.util.List; + +import jakarta.ws.rs.HeaderParam; +import jakarta.ws.rs.QueryParam; +import jakarta.ws.rs.core.Context; +import jakarta.ws.rs.core.SecurityContext; +import jakarta.ws.rs.core.UriInfo; + +public class SimpleBeanParam { + + @HeaderParam("CustomAuthorization") + private String customAuthorizationHeader; + + @Context + SecurityContext securityContext; + + @Context + public UriInfo uriInfo; + + @QueryParam("query") + public String query; <1> + + public SecurityContext getSecurityContext() { <2> + return securityContext; + } + + public String customAuthorizationHeader() { <3> + return customAuthorizationHeader; + } +} +---- +<1> Quarkus Security can only pass public fields to a custom permission constructor. +<2> Quarkus Security automatically uses public getter methods if they are available. +<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: + +[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); + } + + @Override + public boolean implies(Permission p) { + boolean nameMatches = getName().equals(p.getName()); + boolean actionMatches = actions.equals(p.getActions()); + return nameMatches && actionMatches; + } + + 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"; + } + } + + ... +} +---- + +NOTE: You can pass `@BeanParam` directly into a custom permission constructor and access its fields programmatically in the constructor instead. +Ability to reference `@BeanParam` fields with the `@PermissionsAllowed#params` attribute is useful when you have multiple differently structured `@BeanParam` classes. + == References * xref:security-overview.adoc[Quarkus Security overview] 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 new file mode 100644 index 0000000000000..f1bab3d31aafe --- /dev/null +++ b/extensions/resteasy-reactive/rest/deployment/src/test/java/io/quarkus/resteasy/reactive/server/test/security/BeanParamPermissionIdentityAugmentor.java @@ -0,0 +1,34 @@ +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/MyBeanParam.java b/extensions/resteasy-reactive/rest/deployment/src/test/java/io/quarkus/resteasy/reactive/server/test/security/MyBeanParam.java new file mode 100644 index 0000000000000..7c94a30354950 --- /dev/null +++ b/extensions/resteasy-reactive/rest/deployment/src/test/java/io/quarkus/resteasy/reactive/server/test/security/MyBeanParam.java @@ -0,0 +1,11 @@ +package io.quarkus.resteasy.reactive.server.test.security; + +import jakarta.ws.rs.BeanParam; + +import org.jboss.resteasy.reactive.RestHeader; +import org.jboss.resteasy.reactive.RestQuery; + +public record MyBeanParam(@RestQuery String queryParam, @BeanParam Headers headers) { + public record Headers(@RestHeader String authorization) { + } +} diff --git a/extensions/resteasy-reactive/rest/deployment/src/test/java/io/quarkus/resteasy/reactive/server/test/security/MyPermission.java b/extensions/resteasy-reactive/rest/deployment/src/test/java/io/quarkus/resteasy/reactive/server/test/security/MyPermission.java new file mode 100644 index 0000000000000..4b1b727d324c5 --- /dev/null +++ b/extensions/resteasy-reactive/rest/deployment/src/test/java/io/quarkus/resteasy/reactive/server/test/security/MyPermission.java @@ -0,0 +1,47 @@ +package io.quarkus.resteasy.reactive.server.test.security; + +import java.security.Permission; +import java.util.Objects; + +public class MyPermission extends Permission { + + static final MyPermission EMPTY = new MyPermission("my-perm", null, null); + + private final String authorization; + private final String queryParam; + + public MyPermission(String permissionName, String authorization, String queryParam) { + super(permissionName); + this.authorization = authorization; + this.queryParam = queryParam; + } + + @Override + public boolean implies(Permission permission) { + if (permission instanceof MyPermission myPermission) { + return myPermission.authorization != null && "query1".equals(myPermission.queryParam); + } + return false; + } + + @Override + public boolean equals(Object o) { + if (this == o) + return true; + if (o == null || getClass() != o.getClass()) + return false; + MyPermission that = (MyPermission) o; + return Objects.equals(authorization, that.authorization) + && Objects.equals(queryParam, that.queryParam); + } + + @Override + public int hashCode() { + return Objects.hash(authorization, queryParam); + } + + @Override + public String getActions() { + return ""; + } +} diff --git a/extensions/resteasy-reactive/rest/deployment/src/test/java/io/quarkus/resteasy/reactive/server/test/security/OtherBeanParam.java b/extensions/resteasy-reactive/rest/deployment/src/test/java/io/quarkus/resteasy/reactive/server/test/security/OtherBeanParam.java new file mode 100644 index 0000000000000..546a4ffa8e90d --- /dev/null +++ b/extensions/resteasy-reactive/rest/deployment/src/test/java/io/quarkus/resteasy/reactive/server/test/security/OtherBeanParam.java @@ -0,0 +1,30 @@ +package io.quarkus.resteasy.reactive.server.test.security; + +import jakarta.ws.rs.HeaderParam; +import jakarta.ws.rs.QueryParam; +import jakarta.ws.rs.core.Context; +import jakarta.ws.rs.core.SecurityContext; +import jakarta.ws.rs.core.UriInfo; + +public class OtherBeanParam { + + @HeaderParam("CustomAuthorization") + private String customAuthorizationHeader; + + @Context + SecurityContext securityContext; + + @Context + public UriInfo uriInfo; + + @QueryParam("query") + public String query; + + public SecurityContext getSecurityContext() { + return securityContext; + } + + public String customAuthorizationHeader() { + return customAuthorizationHeader; + } +} 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 new file mode 100644 index 0000000000000..76f97c4cba97f --- /dev/null +++ b/extensions/resteasy-reactive/rest/deployment/src/test/java/io/quarkus/resteasy/reactive/server/test/security/OtherBeanParamPermission.java @@ -0,0 +1,60 @@ +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/PermissionsAllowedBeanParamTest.java b/extensions/resteasy-reactive/rest/deployment/src/test/java/io/quarkus/resteasy/reactive/server/test/security/PermissionsAllowedBeanParamTest.java new file mode 100644 index 0000000000000..bcec207dd16ca --- /dev/null +++ b/extensions/resteasy-reactive/rest/deployment/src/test/java/io/quarkus/resteasy/reactive/server/test/security/PermissionsAllowedBeanParamTest.java @@ -0,0 +1,166 @@ +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; +import jakarta.ws.rs.Path; + +import org.hamcrest.Matchers; +import org.jboss.resteasy.reactive.RestCookie; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import io.quarkus.security.PermissionsAllowed; +import io.quarkus.security.test.utils.TestIdentityController; +import io.quarkus.security.test.utils.TestIdentityProvider; +import io.quarkus.test.QuarkusUnitTest; +import io.restassured.RestAssured; +import io.restassured.specification.RequestSpecification; + +public class PermissionsAllowedBeanParamTest { + + @RegisterExtension + static QuarkusUnitTest runner = new QuarkusUnitTest() + .withApplicationRoot((jar) -> jar + .addClasses(TestIdentityProvider.class, TestIdentityController.class, SimpleBeanParam.class, + SimpleResource.class, SimpleBeanParamPermission.class, MyPermission.class, MyBeanParam.class, + OtherBeanParamPermission.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); + } + + @Test + public void testSimpleBeanParam() { + getSimpleBeanParamReq() + .post("/simple/param") + .then().statusCode(401); + getSimpleBeanParamReq() + .auth().preemptive().basic("user", "user") + .post("/simple/param") + .then().statusCode(403); + getSimpleBeanParamReq() + .auth().preemptive().basic("admin", "admin") + .post("/simple/param") + .then().statusCode(200).body(Matchers.equalTo("OK")); + } + + @Test + public void testRecordBeanParam() { + RestAssured + .given() + .auth().preemptive().basic("user", "user") + .queryParam("queryParam", "query1") + .get("/simple/record-param") + .then().statusCode(403); + RestAssured + .given() + .auth().preemptive().basic("admin", "admin") + .queryParam("queryParam", "query1") + .get("/simple/record-param") + .then().statusCode(200) + .body(Matchers.equalTo("OK")); + RestAssured + .given() + .auth().preemptive().basic("admin", "admin") + .queryParam("queryParam", "wrong-query-param") + .get("/simple/record-param") + .then().statusCode(403); + } + + @Test + public void testAutodetectedParams() { + RestAssured + .given() + .body("autodetected") + .auth().preemptive().basic("admin", "admin") + .header("CustomAuthorization", "customAuthorization") + .queryParam("query", "myQueryParam") + .get("/simple/autodetect-params") + .then().statusCode(200).body(Matchers.equalTo("autodetected")); + // wrong custom authorization + RestAssured + .given() + .auth().preemptive().basic("admin", "admin") + .header("CustomAuthorization", "wrongAuthorization") + .queryParam("query", "myQueryParam") + .get("/simple/autodetect-params") + .then().statusCode(403); + // wrong query param + RestAssured + .given() + .body("autodetected") + .auth().preemptive().basic("admin", "admin") + .header("CustomAuthorization", "customAuthorization") + .queryParam("query", "wrongQueryParam") + .get("/simple/autodetect-params") + .then().statusCode(403); + // wrong principal + RestAssured + .given() + .body("autodetected") + .auth().preemptive().basic("user", "user") + .header("CustomAuthorization", "customAuthorization") + .queryParam("query", "myQueryParam") + .get("/simple/autodetect-params") + .then().statusCode(403); + } + + private static RequestSpecification getSimpleBeanParamReq() { + return RestAssured + .with() + .header("header", "one-header") + .queryParam("query", "one-query") + .queryParam("queryList", "one") + .queryParam("queryList", "two") + .queryParam("int", "666") + .cookie("cookie", "cookie") + .body("OK"); + } + + @Path("/simple") + public static class SimpleResource { + + @PermissionsAllowed(value = "perm1", permission = SimpleBeanParamPermission.class, params = { "cookie", + "beanParam.header", "beanParam.publicQuery", "beanParam.queryList", "beanParam.securityContext", + "beanParam.uriInfo", "beanParam.privateQuery" }) + @Path("/param") + @POST + public String simpleBeanParam(@BeanParam SimpleBeanParam beanParam, String payload, @RestCookie String cookie) { + return payload; + } + + @PermissionsAllowed(value = "perm2", permission = MyPermission.class, params = { "beanParam.queryParam", + "beanParam.headers.authorization" }) + @Path("/record-param") + @GET + public String recordBeanParam(@BeanParam MyBeanParam beanParam) { + return "OK"; + } + + @PermissionsAllowed(value = "say:hello", permission = OtherBeanParamPermission.class, params = "otherBeanParam.securityContext.userPrincipal.name") + @Path("/autodetect-params") + @GET + public String autodetectedParams(String payload, @BeanParam OtherBeanParam otherBeanParam) { + return payload; + } + } +} diff --git a/extensions/resteasy-reactive/rest/deployment/src/test/java/io/quarkus/resteasy/reactive/server/test/security/SimpleBeanParam.java b/extensions/resteasy-reactive/rest/deployment/src/test/java/io/quarkus/resteasy/reactive/server/test/security/SimpleBeanParam.java new file mode 100644 index 0000000000000..ac069611d87c8 --- /dev/null +++ b/extensions/resteasy-reactive/rest/deployment/src/test/java/io/quarkus/resteasy/reactive/server/test/security/SimpleBeanParam.java @@ -0,0 +1,33 @@ +package io.quarkus.resteasy.reactive.server.test.security; + +import java.util.List; + +import jakarta.ws.rs.HeaderParam; +import jakarta.ws.rs.QueryParam; +import jakarta.ws.rs.core.Context; +import jakarta.ws.rs.core.SecurityContext; +import jakarta.ws.rs.core.UriInfo; + +public class SimpleBeanParam { + @QueryParam("query") + private String privateQuery; + + @QueryParam("query") + public String publicQuery; + + @HeaderParam("header") + public String header; + + @QueryParam("queryList") + public List queryList; + + @Context + public SecurityContext securityContext; + + @Context + public UriInfo uriInfo; + + public String getPrivateQuery() { + return privateQuery; + } +} diff --git a/extensions/resteasy-reactive/rest/deployment/src/test/java/io/quarkus/resteasy/reactive/server/test/security/SimpleBeanParamPermission.java b/extensions/resteasy-reactive/rest/deployment/src/test/java/io/quarkus/resteasy/reactive/server/test/security/SimpleBeanParamPermission.java new file mode 100644 index 0000000000000..84f81ec1d46f4 --- /dev/null +++ b/extensions/resteasy-reactive/rest/deployment/src/test/java/io/quarkus/resteasy/reactive/server/test/security/SimpleBeanParamPermission.java @@ -0,0 +1,76 @@ +package io.quarkus.resteasy.reactive.server.test.security; + +import java.security.Permission; +import java.util.List; +import java.util.Objects; + +import jakarta.ws.rs.core.SecurityContext; +import jakarta.ws.rs.core.UriInfo; + +import org.junit.jupiter.api.Assertions; + +public class SimpleBeanParamPermission extends Permission { + + static final SimpleBeanParamPermission EMPTY = new SimpleBeanParamPermission(null, null, null, null, null, null, null, + null); + + private final String publicQuery; + private final String header; + private final List queryList; + private final SecurityContext securityContext; + private final UriInfo uriInfo; + private final String privateQuery; + private final String cookie; + + public SimpleBeanParamPermission(String name, String publicQuery, String header, List queryList, + SecurityContext securityContext, UriInfo uriInfo, String privateQuery, String cookie) { + super(name); + this.publicQuery = publicQuery; + this.header = header; + this.queryList = queryList; + this.securityContext = securityContext; + this.uriInfo = uriInfo; + this.privateQuery = privateQuery; + this.cookie = cookie; + } + + @Override + public boolean implies(Permission p) { + if (p instanceof SimpleBeanParamPermission simplePermission) { + Assertions.assertEquals("perm1", simplePermission.getName()); + Assertions.assertEquals("one-query", simplePermission.privateQuery); + Assertions.assertEquals("one-query", simplePermission.publicQuery); + Assertions.assertEquals("one-header", simplePermission.header); + Assertions.assertEquals("admin", simplePermission.securityContext.getUserPrincipal().getName()); + Assertions.assertNotNull(simplePermission.securityContext); + Assertions.assertEquals("/simple/param", simplePermission.uriInfo.getPath()); + Assertions.assertEquals("one", simplePermission.queryList.get(0)); + Assertions.assertEquals("two", simplePermission.queryList.get(1)); + Assertions.assertEquals("cookie", simplePermission.cookie); + return true; + } + return false; + } + + @Override + public boolean equals(Object o) { + if (this == o) + return true; + if (o == null || getClass() != o.getClass()) + return false; + SimpleBeanParamPermission that = (SimpleBeanParamPermission) o; + return Objects.equals(publicQuery, that.publicQuery) && Objects.equals(header, that.header) + && Objects.equals(queryList, that.queryList) && Objects.equals(securityContext, that.securityContext) + && Objects.equals(uriInfo, that.uriInfo); + } + + @Override + public int hashCode() { + return Objects.hash(publicQuery, header, queryList, securityContext, uriInfo); + } + + @Override + public String getActions() { + return ""; + } +} 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 c4a228fd03f7b..4a329999fb2d6 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 @@ -6,6 +6,8 @@ import static io.quarkus.security.deployment.DotNames.PERMISSIONS_ALLOWED; import static io.quarkus.security.deployment.SecurityProcessor.isPublicNonStaticNonConstructor; +import java.lang.invoke.MethodHandle; +import java.lang.reflect.Modifier; import java.security.Permission; import java.util.ArrayList; import java.util.Arrays; @@ -27,6 +29,13 @@ import org.jboss.jandex.MethodInfo; import org.jboss.jandex.Type; +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.MethodCreator; +import io.quarkus.gizmo.ResultHandle; import io.quarkus.runtime.RuntimeValue; import io.quarkus.security.PermissionsAllowed; import io.quarkus.security.StringPermission; @@ -52,23 +61,23 @@ final class PermissionSecurityChecksBuilder { private final Map targetToPredicate = new HashMap<>(); private final Map classSignatureToConstructor = new HashMap<>(); private final SecurityCheckRecorder recorder; + private final PermissionConverterGenerator paramConverterGenerator; - public PermissionSecurityChecksBuilder(SecurityCheckRecorder recorder) { + public PermissionSecurityChecksBuilder(SecurityCheckRecorder recorder, + BuildProducer generatedClassesProducer, + BuildProducer reflectiveClassesProducer, IndexView index) { this.recorder = recorder; + this.paramConverterGenerator = new PermissionConverterGenerator(generatedClassesProducer, reflectiveClassesProducer, + recorder, index); } PermissionSecurityChecks build() { + paramConverterGenerator.close(); final Map cache = new HashMap<>(); final Map methodToCheck = new HashMap<>(); final Map classNameToCheck = new HashMap<>(); for (var targetToPredicate : targetToPredicate.entrySet()) { - SecurityCheck check = cache.computeIfAbsent(targetToPredicate.getValue(), - new Function() { - @Override - public SecurityCheck apply(LogicalAndPermissionPredicate predicate) { - return createSecurityCheck(predicate); - } - }); + SecurityCheck check = cache.computeIfAbsent(targetToPredicate.getValue(), this::createSecurityCheck); var annotationTarget = targetToPredicate.getKey(); if (annotationTarget.kind() == AnnotationTarget.Kind.CLASS) { @@ -492,7 +501,8 @@ private SecurityCheck createSecurityCheck(LogicalAndPermissionPredicate andPredi private PermissionWrapper createPermission(PermissionKey permissionKey, AnnotationTarget securedTarget, Map cache) { var constructor = classSignatureToConstructor.get(permissionKey.classSignature()); - return cache.computeIfAbsent(new PermissionCacheKey(permissionKey, securedTarget, constructor), + return cache.computeIfAbsent( + new PermissionCacheKey(permissionKey, securedTarget, constructor, paramConverterGenerator), new Function() { @Override public PermissionWrapper apply(PermissionCacheKey permissionCacheKey) { @@ -514,7 +524,8 @@ public PermissionWrapper apply(PermissionCacheKey permissionCacheKey) { private Function createComputedPermission(PermissionCacheKey permissionCacheKey) { return recorder.createComputedPermission(permissionCacheKey.permissionKey.name, permissionCacheKey.permissionKey.classSignature(), permissionCacheKey.permissionKey.actions(), - permissionCacheKey.passActionsToConstructor, permissionCacheKey.methodParamIndexes()); + permissionCacheKey.passActionsToConstructor, permissionCacheKey.methodParamIndexes(), + permissionCacheKey.methodParamConverters, paramConverterGenerator.getConverterNameToMethodHandle()); } private RuntimeValue createCustomPermission(PermissionCacheKey permissionCacheKey) { @@ -627,6 +638,7 @@ private static final class PermissionKey { private final String name; private final Set actions; private final String[] params; + private final String[] paramsRemainder; private final Type clazz; private final boolean inclusive; @@ -639,7 +651,31 @@ private PermissionKey(String name, Set actions, String[] params, Type cl } else { this.actions = null; } - this.params = params; + + if (params == null || params.length == 0) { + this.params = new String[] {}; + this.paramsRemainder = null; + } else { + this.params = new String[params.length]; + var remainder = new String[params.length]; + boolean requiresConverter = false; + for (int i = 0; i < params.length; i++) { + int firstDot = params[i].indexOf('.'); + if (firstDot == -1) { + this.params[i] = params[i]; + } else { + requiresConverter = true; + var securedMethodParamName = params[i].substring(0, firstDot); + this.params[i] = securedMethodParamName; + remainder[i] = params[i].substring(firstDot + 1); + } + } + if (requiresConverter) { + this.paramsRemainder = remainder; + } else { + this.paramsRemainder = null; + } + } } private String classSignature() { @@ -662,13 +698,17 @@ public boolean equals(Object o) { return false; 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; + && clazz.equals(that.clazz) && inclusive == that.inclusive + && Arrays.equals(paramsRemainder, that.paramsRemainder); } @Override public int hashCode() { int result = Objects.hash(name, actions, clazz, inclusive); result = 31 * result + Arrays.hashCode(params); + if (paramsRemainder != null) { + result = 67 * result + Arrays.hashCode(paramsRemainder); + } return result; } } @@ -678,8 +718,10 @@ private static final class PermissionCacheKey { private final PermissionKey permissionKey; private final boolean computed; private final boolean passActionsToConstructor; + private final String[] methodParamConverters; - private PermissionCacheKey(PermissionKey permissionKey, AnnotationTarget securedTarget, MethodInfo constructor) { + private PermissionCacheKey(PermissionKey permissionKey, AnnotationTarget securedTarget, MethodInfo constructor, + PermissionConverterGenerator paramConverterGenerator) { if (isComputed(permissionKey, constructor)) { if (securedTarget.kind() != AnnotationTarget.Kind.METHOD) { throw new IllegalArgumentException( @@ -692,106 +734,189 @@ private PermissionCacheKey(PermissionKey permissionKey, AnnotationTarget secured this.computed = true; final boolean isSecondParamStringArr = !secondParamIsNotStringArr(constructor); - if (permissionKey.notAutodetectParams()) { - // explicitly assigned match between constructor params and method params - // by user via 'PermissionsAllowed#params' attribute - - // determine if we want to pass actions param to Permission constructor - if (isSecondParamStringArr) { - int foundIx = findSecuredMethodParamIndex(securedMethod, constructor, 1); - // if (foundIx == -1) is false then user assigned second constructor param to a method param - this.passActionsToConstructor = foundIx == -1; - } else { - this.passActionsToConstructor = false; - } - - this.methodParamIndexes = userDefinedConstructorParamIndexes(securedMethod, constructor, - this.passActionsToConstructor); + // determine if we want to pass actions param to Permission constructor + if (isSecondParamStringArr) { + int foundIx = findSecuredMethodParamIndex(securedMethod, constructor, 1, + permissionKey.paramsRemainder, permissionKey.params, -1, paramConverterGenerator.index) + .methodParamIdx(); + // if (foundIx == -1) is false then user assigned second constructor param to a method param + this.passActionsToConstructor = foundIx == -1; } else { - // autodetect params path + this.passActionsToConstructor = false; + } - this.passActionsToConstructor = isSecondParamStringArr; - this.methodParamIndexes = autodetectConstructorParamIndexes(permissionKey, securedMethod, - constructor, isSecondParamStringArr); + var matches = matchPermCtorParamIdxBasedOnNameMatch(securedMethod, constructor, + this.passActionsToConstructor, permissionKey.params, permissionKey.paramsRemainder, + paramConverterGenerator.index); + this.methodParamIndexes = getMethodParamIndexes(matches); + this.methodParamConverters = getMethodParamConverters(paramConverterGenerator, matches, securedMethod, + this.methodParamIndexes); + // make sure all @PermissionsAllowed(param = { expression.one.two, expression.one.three } + // params are mapped to Permission constructor parameters + if (permissionKey.notAutodetectParams()) { + validateParamsDeclaredByUserMatched(matches, permissionKey.params, permissionKey.paramsRemainder, + securedMethod, constructor); } } else { // plain permission this.methodParamIndexes = null; + this.methodParamConverters = null; this.permissionKey = permissionKey; this.computed = false; this.passActionsToConstructor = constructor.parametersCount() == 2; } } - private static int[] userDefinedConstructorParamIndexes(MethodInfo securedMethod, MethodInfo constructor, - boolean passActionsToConstructor) { + private static void validateParamsDeclaredByUserMatched(SecMethodAndPermCtorIdx[] matches, String[] params, + String[] nestedParamExpressions, MethodInfo securedMethod, MethodInfo constructor) { + for (int i = 0; i < params.length; i++) { + int aI = i; + boolean paramMapped = Arrays.stream(matches) + .map(SecMethodAndPermCtorIdx::requiredParamIdx) + .filter(Objects::nonNull) + .anyMatch(mIdx -> mIdx == aI); + if (!paramMapped) { + var paramName = nestedParamExpressions == null || nestedParamExpressions[aI] == null ? params[i] + : params[i] + "." + nestedParamExpressions[aI]; + 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 + secured method and constructor has formal parameter with name '%1$s'. + """ + .formatted(paramName, securedMethod.declaringClass().name(), securedMethod.name(), + constructor.declaringClass().name().toString())); + } + } + if (nestedParamExpressions != null) { + outer: for (int i = 0; i < nestedParamExpressions.length; i++) { + if (nestedParamExpressions[i] != null) { + var nestedParamExp = nestedParamExpressions[i]; + for (SecMethodAndPermCtorIdx match : matches) { + if (nestedParamExp.equals(match.nestedParamExpression())) { + continue outer; + } + } + 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())); + } + } + } + } + + private static String[] getMethodParamConverters(PermissionConverterGenerator paramConverterGenerator, + SecMethodAndPermCtorIdx[] matches, MethodInfo securedMethod, int[] methodParamIndexes) { + var converters = new String[methodParamIndexes.length]; + boolean requireConverter = false; + for (SecMethodAndPermCtorIdx match : matches) { + if (match.nestedParamExpression() != null) { + requireConverter = true; + converters[match.constructorParamIdx()] = paramConverterGenerator + .createConverter(match.nestedParamExpression(), securedMethod, match.methodParamIdx()); + } + } + if (requireConverter) { + return converters; + } + return null; + } + + private static SecMethodAndPermCtorIdx[] matchPermCtorParamIdxBasedOnNameMatch(MethodInfo securedMethod, + MethodInfo constructor, boolean passActionsToConstructor, String[] requiredMethodParams, + String[] requiredParamsRemainder, IndexView index) { // assign method param to each constructor param; it's not one-to-one function (AKA injection) final int nonMethodParams = (passActionsToConstructor ? 2 : 1); - final int[] methodParamIndexes = new int[constructor.parametersCount() - nonMethodParams]; + 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 - int foundIx = findSecuredMethodParamIndex(securedMethod, constructor, i); - // here we could check whether it is possible to assign method param to constructor - // param, but parametrized types and inheritance makes it complex task, so let's trust - // user has done a good job for moment being - if (foundIx == -1) { + var match = findSecuredMethodParamIndex(securedMethod, constructor, i, + requiredParamsRemainder, + requiredMethodParams, nonMethodParams, index); + matches[i - nonMethodParams] = match; + if (match.methodParamIdx() == -1) { final String constructorParamName = constructor.parameterName(i); throw new RuntimeException(String.format( - "No '%s' formal parameter name matches '%s' constructor parameter name '%s' specified via '@PermissionsAllowed#params'", + "No '%s' formal parameter name matches '%s' Permission constructor parameter name '%s'", securedMethod.name(), constructor.declaringClass().name().toString(), constructorParamName)); } - methodParamIndexes[i - nonMethodParams] = foundIx; - } - return methodParamIndexes; - } - - private static int[] autodetectConstructorParamIndexes(PermissionKey permissionKey, MethodInfo securedMethod, - MethodInfo constructor, boolean isSecondParamStringArr) { - // first constructor param is always permission name, second (might be) actions - final int nonMethodParams = (isSecondParamStringArr ? 2 : 1); - final int[] methodParamIndexes = new int[constructor.parametersCount() - nonMethodParams]; - // here we just try to find exact type match for constructor parameters from method parameters - for (int i = 0; i < methodParamIndexes.length; i++) { - var seekedParamType = constructor.parameterType(i + nonMethodParams); - int foundIndex = -1; - securedMethodIxBlock: for (int j = 0; j < securedMethod.parameterTypes().size(); j++) { - // currently, we only support exact data type matches - if (seekedParamType.equals(securedMethod.parameterType(j))) { - // we don't want to assign same method param to more than one constructor param - for (int k = 0; k < i; k++) { - if (methodParamIndexes[k] == j) { - continue securedMethodIxBlock; + } + return matches; + } + + private static SecMethodAndPermCtorIdx findSecuredMethodParamIndex(MethodInfo securedMethod, MethodInfo constructor, + int constructorIx, String[] requiredParamsRemainder, String[] requiredParams, int nonMethodParams, + IndexView index) { + final String constructorParamName = constructor.parameterName(constructorIx); + final int constructorParamIdx = constructorIx - nonMethodParams; + + if (requiredParams != null && requiredParams.length != 0) { + // user specified explicitly parameter names with @PermissionsAllowed(params = "some.name") + for (int i = 0; i < securedMethod.parametersCount(); i++) { + var methodParamName = securedMethod.parameterName(i); + boolean constructorParamNameMatches = constructorParamName.equals(methodParamName); + + // here we deal with @PermissionsAllowed(params = "someParam") + for (int i1 = 0; i1 < requiredParams.length; i1++) { + boolean methodParamNameMatches = methodParamName.equals(requiredParams[i1]); + if (methodParamNameMatches) { + if (constructorParamNameMatches) { + // user specified @PermissionsAllowed(params = "x") + // and the 'x' matches both secured method param and constructor method param + return new SecMethodAndPermCtorIdx(i, constructorParamIdx, null, i1); + } else if (requiredParamsRemainder != null) { + // constructor name shall match name of actually passed parameter expression + // so: method param name == start of the parameter expression (before the first dot) + // constructor param name == end of the parameter expression (after the last dot) + var requiredParamRemainder = requiredParamsRemainder[i1]; + if (requiredParamRemainder != null) { + int lastDotIdx = requiredParamRemainder.lastIndexOf('.'); + final String lastExpression; + if (lastDotIdx == -1) { + lastExpression = requiredParamRemainder; + } else { + lastExpression = requiredParamRemainder.substring(lastDotIdx + 1); + } + if (constructorParamName.equals(lastExpression)) { + return new SecMethodAndPermCtorIdx(i, constructorParamIdx, requiredParamRemainder, + i1); + } + } } } - foundIndex = j; - break; } } - if (foundIndex == -1) { - throw new RuntimeException(String.format( - "Failed to identify matching data type for '%s' param of '%s' constructor for method '%s' annotated with @PermissionsAllowed", - constructor.parameterName(i + nonMethodParams), permissionKey.classSignature(), - securedMethod.name())); + } + + for (int i = 0; i < securedMethod.parametersCount(); i++) { + // find exact name match between method annotated with the @PermissionsAllowed parameter + // and the Permission constructor + var methodParamName = securedMethod.parameterName(i); + boolean constructorParamNameMatches = constructorParamName.equals(methodParamName); + if (constructorParamNameMatches) { + return new SecMethodAndPermCtorIdx(i, constructorParamIdx); } - methodParamIndexes[i] = foundIndex; } - return methodParamIndexes; - } - private static int findSecuredMethodParamIndex(MethodInfo securedMethod, MethodInfo constructor, - int constructorIx) { - // find exact formal parameter name match between constructor parameter in place 'constructorIx' - // and any method parameter name - final String constructorParamName = constructor.parameterName(constructorIx); - int foundIx = -1; + // try to autodetect nested param name for (int i = 0; i < securedMethod.parametersCount(); i++) { - boolean paramNamesMatch = constructorParamName.equals(securedMethod.parameterName(i)); - if (paramNamesMatch) { - foundIx = i; - break; + var methodParamName = securedMethod.parameterName(i); + + var paramType = securedMethod.parameterType(i); + if (paramType.kind() == Type.Kind.CLASS) { + var clazz = index.getClassByName(paramType.name()); + if (clazz != null) { + String nestedParamName = matchNestedParamByName(clazz, constructorParamName); + if (nestedParamName != null) { + return new SecMethodAndPermCtorIdx(i, constructorParamIdx, nestedParamName, null); + } + } } } - return foundIx; + + return new SecMethodAndPermCtorIdx(-1, constructorParamIdx); } @Override @@ -803,13 +928,17 @@ public boolean equals(Object o) { PermissionCacheKey that = (PermissionCacheKey) o; return computed == that.computed && passActionsToConstructor == that.passActionsToConstructor && Arrays.equals(methodParamIndexes, that.methodParamIndexes) - && permissionKey.equals(that.permissionKey); + && permissionKey.equals(that.permissionKey) + && Arrays.equals(methodParamConverters, that.methodParamConverters); } @Override public int hashCode() { int result = Objects.hash(permissionKey, computed, passActionsToConstructor); result = 31 * result + Arrays.hashCode(methodParamIndexes); + if (methodParamConverters != null) { + result = 65 + result + Arrays.hashCode(methodParamConverters); + } return result; } @@ -845,6 +974,201 @@ private static boolean isStringPermission(PermissionKey permissionKey) { } } + + private static String matchNestedParamByName(ClassInfo clazz, String constructorParamName) { + var method = clazz.method(constructorParamName); + if (method != null && Modifier.isPublic(method.flags())) { + return constructorParamName; + } + var getter = toFieldGetter(constructorParamName); + method = clazz.method(getter); + if (method != null && Modifier.isPublic(method.flags())) { + return getter; + } + var field = clazz.field(constructorParamName); + if (field != null && Modifier.isPublic(field.flags())) { + return field.name(); + } + return null; + } + + private static int[] getMethodParamIndexes(SecMethodAndPermCtorIdx[] matches) { + int[] result = new int[matches.length]; + for (int i = 0; i < matches.length; i++) { + result[i] = matches[i].methodParamIdx(); + } + return result; + } } + final class PermissionConverterGenerator { + private static final String GENERATED_CLASS_NAME = "io.quarkus.security.runtime.PermissionMethodConverter"; + private final BuildProducer generatedClassesProducer; + private final BuildProducer reflectiveClassesProducer; + private final SecurityCheckRecorder recorder; + private final Map> converterNameToMethodHandle = new HashMap<>(); + private final IndexView index; + private ClassCreator classCreator; + private boolean closed; + private RuntimeValue> clazz; + + private PermissionConverterGenerator(BuildProducer generatedClassesProducer, + BuildProducer reflectiveClassesProducer, SecurityCheckRecorder recorder, + IndexView index) { + this.generatedClassesProducer = generatedClassesProducer; + this.reflectiveClassesProducer = reflectiveClassesProducer; + this.recorder = recorder; + this.index = index; + this.classCreator = null; + this.closed = true; + this.clazz = null; + } + + private ClassCreator getOrCreateClass() { + if (classCreator == null) { + classCreator = ClassCreator.builder() + .classOutput(new GeneratedClassGizmoAdaptor(generatedClassesProducer, true)) + .className(GENERATED_CLASS_NAME) + .setFinal(true) + .build(); + closed = false; + reflectiveClassesProducer.produce(ReflectiveClassBuildItem.builder(GENERATED_CLASS_NAME).methods().build()); + } + return classCreator; + } + + private RuntimeValue> getClazz() { + if (clazz == null) { + clazz = recorder.loadClassRuntimeVal(GENERATED_CLASS_NAME); + } + return clazz; + } + + private void close() { + if (!closed) { + closed = true; + classCreator.close(); + } + } + + private Map> getConverterNameToMethodHandle() { + return converterNameToMethodHandle.isEmpty() ? null : Map.copyOf(converterNameToMethodHandle); + } + + private String createConverter(String paramRemainder, MethodInfo securedMethod, int methodParamIdx) { + String[] nestedParams = paramRemainder.split("\\."); + var converterName = createConverterName(securedMethod); + try (MethodCreator methodCreator = getOrCreateClass().getMethodCreator(converterName, Object.class, Object.class)) { + methodCreator.setModifiers(Modifier.PUBLIC | Modifier.STATIC); + var paramToConvert = methodCreator.getMethodParam(0); + var paramType = securedMethod.parameterType(methodParamIdx); + ResultHandle result = getNestedParam(nestedParams, 0, paramToConvert, methodCreator, paramType, + securedMethod, methodParamIdx); + methodCreator.returnValue(result); + } + var methodHandleRuntimeVal = recorder.createPermissionMethodConverter(converterName, getClazz()); + converterNameToMethodHandle.put(converterName, methodHandleRuntimeVal); + return converterName; + } + + private ResultHandle getNestedParam(String[] nestedParams, int nestedParamIdx, ResultHandle outer, + MethodCreator methodCreator, Type outerType, MethodInfo securedMethod, int methodParamIdx) { + if (nestedParamIdx == nestedParams.length) { + return outer; + } + + // param name or getter name + var paramExpression = nestedParams[nestedParamIdx]; + var outerClass = index.getClassByName(outerType.name()); + if (outerClass == null) { + throw new IllegalArgumentException(""" + Method '%s#%s' parameter '%s' cannot be converted to a Permission constructor parameter + as required by the '@PermissionsAllowed#params' attribute. Parameter expression references '%s' + that has type '%s' which is not a class. Only class methods or fields can be mapped + to a Permission constructor parameter. + """.formatted(securedMethod.declaringClass().name(), securedMethod.name(), + securedMethod.parameterName(methodParamIdx), paramExpression, outerType.name())); + } + + // try exact method name match + var method = outerClass.method(paramExpression); + if (method == null) { + // try getter + method = outerClass.method(toFieldGetter(paramExpression)); + } + final ResultHandle newOuter; + final Type newOuterType; + if (method != null) { + if (!Modifier.isPublic(method.flags())) { + throw new IllegalArgumentException(""" + Method '%s#%s' parameter '%s' cannot be mapped to a Permission constructor parameter, + because expression '%s' specified in the '@PermissionsAllowed#params' attribute is + accessible from method '%s#%s' which is not a public method. + """.formatted(securedMethod.declaringClass().name(), securedMethod.name(), + securedMethod.parameterName(methodParamIdx), paramExpression, method.declaringClass().name(), + method.name())); + } + if (outerClass.isInterface()) { + newOuter = methodCreator.invokeInterfaceMethod(method, outer); + } else { + newOuter = methodCreator.invokeVirtualMethod(method, outer); + } + newOuterType = method.returnType(); + } else { + // fallback to a field access + var field = outerClass.field(paramExpression); + if (field == null) { + throw new IllegalArgumentException(""" + Method '%s#%s' parameter '%s' cannot be mapped to a Permission constructor parameter, + because expression '%s' specified in the '@PermissionsAllowed#params' attribute does not + match any method or field of the class '%s'. + """.formatted(securedMethod.declaringClass().name(), securedMethod.name(), + securedMethod.parameterName(methodParamIdx), paramExpression, outerClass.name())); + } + if (!Modifier.isPublic(field.flags())) { + throw new IllegalArgumentException(""" + Method '%s#%s' parameter '%s' cannot be mapped to a Permission constructor parameter, + because expression '%s' specified in the '@PermissionsAllowed#params' attribute is only + accessible from field '%s#%s' which is not a public field. Please declare a getter method. + """.formatted(securedMethod.declaringClass().name(), securedMethod.name(), + securedMethod.parameterName(methodParamIdx), paramExpression, field.declaringClass().name(), + field.name())); + } + + newOuter = methodCreator.readInstanceField(field, outer); + newOuterType = field.type(); + } + return getNestedParam(nestedParams, nestedParamIdx + 1, newOuter, methodCreator, newOuterType, securedMethod, + methodParamIdx); + } + + private String createConverterName(MethodInfo securedMethod) { + return createConverterName(securedMethod, 0); + } + + private String createConverterName(MethodInfo securedMethod, int idx) { + // postfix enumeration is required because same secured method may require multiple converters + var converterName = hashCodeToString(securedMethod.hashCode()) + "_" + idx; + if (converterNameToMethodHandle.containsKey(converterName)) { + return createConverterName(securedMethod, idx + 1); + } + return converterName; + } + + } + + private static String hashCodeToString(Object object) { + return (object.hashCode() + "").replace('-', '_'); + } + + private static String toFieldGetter(String paramExpression) { + return "get" + paramExpression.substring(0, 1).toUpperCase() + paramExpression.substring(1); + } + + record SecMethodAndPermCtorIdx(int methodParamIdx, int constructorParamIdx, String nestedParamExpression, + Integer requiredParamIdx) { + SecMethodAndPermCtorIdx(int methodParamIdx, int constructorParamIdx) { + this(methodParamIdx, constructorParamIdx, null, null); + } + } } 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 5c91e16e5f099..27f0688e3ab0c 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 @@ -72,6 +72,7 @@ import io.quarkus.deployment.annotations.Record; import io.quarkus.deployment.builditem.ApplicationClassPredicateBuildItem; import io.quarkus.deployment.builditem.FeatureBuildItem; +import io.quarkus.deployment.builditem.GeneratedClassBuildItem; import io.quarkus.deployment.builditem.GeneratedNativeImageClassBuildItem; import io.quarkus.deployment.builditem.LaunchModeBuildItem; import io.quarkus.deployment.builditem.NativeImageFeatureBuildItem; @@ -644,7 +645,9 @@ MethodSecurityChecks gatherSecurityChecks( List registerClassSecurityCheckBuildItems, BuildProducer reflectiveClassBuildItemBuildProducer, List additionalSecurityChecks, SecurityBuildTimeConfig config, - PermissionsAllowedMetaAnnotationBuildItem permissionsAllowedMetaAnnotationItem) { + PermissionsAllowedMetaAnnotationBuildItem permissionsAllowedMetaAnnotationItem, + BuildProducer generatedClassesProducer, + BuildProducer reflectiveClassesProducer) { var hasAdditionalSecAnn = hasAdditionalSecurityAnnotation(additionalSecurityAnnotationItems.stream() .map(AdditionalSecurityAnnotationBuildItem::getSecurityAnnotationName).collect(Collectors.toSet())); classPredicate.produce(new ApplicationClassPredicateBuildItem(new SecurityCheckStorageAppPredicate())); @@ -662,7 +665,8 @@ MethodSecurityChecks gatherSecurityChecks( additionalSecured.values(), config.denyUnannotated(), recorder, configBuilderProducer, reflectiveClassBuildItemBuildProducer, rolesAllowedConfigExpResolverBuildItems, registerClassSecurityCheckBuildItems, classSecurityCheckStorageProducer, hasAdditionalSecAnn, - additionalSecurityAnnotationItems, permissionsAllowedMetaAnnotationItem); + additionalSecurityAnnotationItems, permissionsAllowedMetaAnnotationItem, generatedClassesProducer, + reflectiveClassesProducer); for (AdditionalSecurityCheckBuildItem additionalSecurityCheck : additionalSecurityChecks) { securityChecks.put(additionalSecurityCheck.getMethodInfo(), additionalSecurityCheck.getSecurityCheck()); @@ -742,7 +746,9 @@ private static Map gatherSecurityAnnotations(IndexVie BuildProducer classSecurityCheckStorageProducer, Predicate hasAdditionalSecurityAnnotations, List additionalSecurityAnnotationItems, - PermissionsAllowedMetaAnnotationBuildItem permissionsAllowedMetaAnnotationItem) { + PermissionsAllowedMetaAnnotationBuildItem permissionsAllowedMetaAnnotationItem, + BuildProducer generatedClassesProducer, + BuildProducer reflectiveClassesProducer) { Map methodToInstanceCollector = new HashMap<>(); Map classAnnotations = new HashMap<>(); Map result = new HashMap<>(); @@ -776,7 +782,8 @@ private static Map gatherSecurityAnnotations(IndexVie .filter(i -> PERMISSIONS_ALLOWED.equals(i.securityAnnotationInstance.name())) .map(i -> i.securityAnnotationInstance) .toList(); - var securityChecks = new PermissionSecurityChecksBuilder(recorder) + var securityChecks = new PermissionSecurityChecksBuilder(recorder, generatedClassesProducer, + reflectiveClassesProducer, index) .gatherPermissionsAllowedAnnotations(permissionInstances, methodToInstanceCollector, classAnnotations, additionalClassInstances, hasAdditionalSecurityAnnotations) .validatePermissionClasses(index) diff --git a/extensions/security/deployment/src/test/java/io/quarkus/security/test/permissionsallowed/ClassLevelComputedPermissionsAllowedTest.java b/extensions/security/deployment/src/test/java/io/quarkus/security/test/permissionsallowed/ClassLevelComputedPermissionsAllowedTest.java index 5b73fcafaafd5..2b934aaab60e9 100644 --- a/extensions/security/deployment/src/test/java/io/quarkus/security/test/permissionsallowed/ClassLevelComputedPermissionsAllowedTest.java +++ b/extensions/security/deployment/src/test/java/io/quarkus/security/test/permissionsallowed/ClassLevelComputedPermissionsAllowedTest.java @@ -132,9 +132,9 @@ public Uni autodetectNonBlocking(String world) { public static class AllStrAutodetectedPermission extends Permission { private final boolean pass; - public AllStrAutodetectedPermission(String name, String[] actions, String str1, String str2, String str3) { + public AllStrAutodetectedPermission(String name, String[] actions, String exclamationMark, String world, String hello) { super(name); - this.pass = "hello".equals(str1) && "world".equals(str2) && "!".equals(str3); + this.pass = "hello".equals(hello) && "world".equals(world) && "!".equals(exclamationMark); } @Override diff --git a/extensions/security/deployment/src/test/java/io/quarkus/security/test/permissionsallowed/CombinedAccessParam.java b/extensions/security/deployment/src/test/java/io/quarkus/security/test/permissionsallowed/CombinedAccessParam.java new file mode 100644 index 0000000000000..e1b0ad05196c1 --- /dev/null +++ b/extensions/security/deployment/src/test/java/io/quarkus/security/test/permissionsallowed/CombinedAccessParam.java @@ -0,0 +1,22 @@ +package io.quarkus.security.test.permissionsallowed; + +public class CombinedAccessParam { + + public final ParamField paramField; + + CombinedAccessParam(ParamField paramField) { + this.paramField = paramField; + } + + public static final class ParamField { + private final String value; + + ParamField(String value) { + this.value = value; + } + + public SimpleFieldParam myVal() { + return new SimpleFieldParam(value); + } + } +} diff --git a/extensions/security/deployment/src/test/java/io/quarkus/security/test/permissionsallowed/ComplexFieldParam.java b/extensions/security/deployment/src/test/java/io/quarkus/security/test/permissionsallowed/ComplexFieldParam.java new file mode 100644 index 0000000000000..ab35533c5588a --- /dev/null +++ b/extensions/security/deployment/src/test/java/io/quarkus/security/test/permissionsallowed/ComplexFieldParam.java @@ -0,0 +1,20 @@ +package io.quarkus.security.test.permissionsallowed; + +public final class ComplexFieldParam { + + public final NestedFieldParam nestedFieldParam; + + ComplexFieldParam(NestedFieldParam nestedFieldParam) { + this.nestedFieldParam = nestedFieldParam; + } + + public static final class NestedFieldParam { + + public final SimpleFieldParam simpleFieldParam; + + public NestedFieldParam(SimpleFieldParam simpleFieldParam) { + this.simpleFieldParam = simpleFieldParam; + } + } + +} diff --git a/extensions/security/deployment/src/test/java/io/quarkus/security/test/permissionsallowed/CustomPermissionWithMultipleArgs.java b/extensions/security/deployment/src/test/java/io/quarkus/security/test/permissionsallowed/CustomPermissionWithMultipleArgs.java new file mode 100644 index 0000000000000..e3a666b7b974d --- /dev/null +++ b/extensions/security/deployment/src/test/java/io/quarkus/security/test/permissionsallowed/CustomPermissionWithMultipleArgs.java @@ -0,0 +1,28 @@ +package io.quarkus.security.test.permissionsallowed; + +import java.security.BasicPermission; +import java.security.Permission; + +public class CustomPermissionWithMultipleArgs extends BasicPermission { + + public static final String EXPECTED_FIELD_STRING_ARGUMENT = "expectedFieldStringArgument"; + public static final int EXPECTED_FIELD_INT_ARGUMENT = 100; + public static final long EXPECTED_FIELD_LONG_ARGUMENT = 357; + + private final String arg; + private final int fourth; + private final long first; + + public CustomPermissionWithMultipleArgs(String name, String propertyOne, int fourth, long first) { + super(name); + this.arg = propertyOne; + this.first = first; + this.fourth = fourth; + } + + @Override + public boolean implies(Permission p) { + return EXPECTED_FIELD_STRING_ARGUMENT.equals(arg) && EXPECTED_FIELD_INT_ARGUMENT == fourth + && EXPECTED_FIELD_LONG_ARGUMENT == first; + } +} diff --git a/extensions/security/deployment/src/test/java/io/quarkus/security/test/permissionsallowed/CustomPermissionWithStringArg.java b/extensions/security/deployment/src/test/java/io/quarkus/security/test/permissionsallowed/CustomPermissionWithStringArg.java new file mode 100644 index 0000000000000..b9b023d47a9fd --- /dev/null +++ b/extensions/security/deployment/src/test/java/io/quarkus/security/test/permissionsallowed/CustomPermissionWithStringArg.java @@ -0,0 +1,21 @@ +package io.quarkus.security.test.permissionsallowed; + +import java.security.BasicPermission; +import java.security.Permission; + +public class CustomPermissionWithStringArg extends BasicPermission { + + public static final String EXPECTED_FIELD_STRING_ARGUMENT = "expectedFieldStringArgument"; + + private final String arg; + + public CustomPermissionWithStringArg(String name, String propertyOne) { + super(name); + this.arg = propertyOne; + } + + @Override + public boolean implies(Permission p) { + return EXPECTED_FIELD_STRING_ARGUMENT.equals(arg); + } +} diff --git a/extensions/security/deployment/src/test/java/io/quarkus/security/test/permissionsallowed/InjectionPermissionsAllowedTest.java b/extensions/security/deployment/src/test/java/io/quarkus/security/test/permissionsallowed/InjectionPermissionsAllowedTest.java index e64b9a2edba6c..fa782febd6dfd 100644 --- a/extensions/security/deployment/src/test/java/io/quarkus/security/test/permissionsallowed/InjectionPermissionsAllowedTest.java +++ b/extensions/security/deployment/src/test/java/io/quarkus/security/test/permissionsallowed/InjectionPermissionsAllowedTest.java @@ -91,10 +91,11 @@ public Uni injectionNonBlocking(String hello, String world, String excla public static class AllStrAutodetectedPermission extends Permission { private final boolean pass; - public AllStrAutodetectedPermission(String name, String[] actions, String str1, String str2, String str3) { + public AllStrAutodetectedPermission(String name, String[] actions, String hello, String world, String exclamationMark) { super(name); var sourceOfTruth = Arc.container().instance(SourceOfTruth.class).get(); - this.pass = "hello".equals(str1) && "world".equals(str2) && "!".equals(str3) && sourceOfTruth.shouldPass(); + this.pass = "hello".equals(hello) && "world".equals(world) && "!".equals(exclamationMark) + && sourceOfTruth.shouldPass(); } @Override diff --git a/extensions/security/deployment/src/test/java/io/quarkus/security/test/permissionsallowed/MethodLevelComputedPermissionsAllowedTest.java b/extensions/security/deployment/src/test/java/io/quarkus/security/test/permissionsallowed/MethodLevelComputedPermissionsAllowedTest.java index 7242dcd18748e..cded6a4075da9 100644 --- a/extensions/security/deployment/src/test/java/io/quarkus/security/test/permissionsallowed/MethodLevelComputedPermissionsAllowedTest.java +++ b/extensions/security/deployment/src/test/java/io/quarkus/security/test/permissionsallowed/MethodLevelComputedPermissionsAllowedTest.java @@ -168,24 +168,24 @@ public String autodetect(int one, String world, int two, int three, Object obj1, } @PermissionsAllowed(value = "permissionName:action1234", permission = InheritanceWithActionsPermission.class) - public String autodetect(Parent parent) { + public String autodetect(Parent obj) { return SUCCESS; } @PermissionsAllowed(value = { "permissionName:action1234", "permission1:action1" }, permission = InheritanceWithActionsPermission.class) - public String autodetectMultiplePermissions(Parent parent) { + public String autodetectMultiplePermissions(Parent obj) { return SUCCESS; } @PermissionsAllowed(value = "permissionName:action1234", permission = InheritanceWithActionsPermission.class) - public Uni autodetectNonBlocking(Parent parent) { + public Uni autodetectNonBlocking(Parent obj) { return Uni.createFrom().item(SUCCESS); } @PermissionsAllowed(value = { "permissionName:action1234", "permission1:action1" }, permission = InheritanceWithActionsPermission.class) - public Uni autodetectMultiplePermissionsNonBlocking(Parent parent) { + public Uni autodetectMultiplePermissionsNonBlocking(Parent obj) { return Uni.createFrom().item(SUCCESS); } @@ -227,13 +227,13 @@ public Uni explicitlyDeclaredParamsInheritanceNonBlocking(String somethi @PermissionsAllowed("read") @PermissionsAllowed(value = "permissionName:action1234", permission = InheritanceWithActionsPermission.class) - public Uni combinationNonBlocking(Parent parent) { + public Uni combinationNonBlocking(Parent obj) { return Uni.createFrom().item(SUCCESS); } @PermissionsAllowed("read") @PermissionsAllowed(value = "permissionName:action1234", permission = InheritanceWithActionsPermission.class) - public String combination(Parent parent) { + public String combination(Parent obj) { return SUCCESS; } } @@ -332,9 +332,9 @@ public String getActions() { public static class AllStrAutodetectedPermission extends Permission { private final boolean pass; - public AllStrAutodetectedPermission(String name, String[] actions, String str1, String str2, String str3) { + public AllStrAutodetectedPermission(String name, String[] actions, String hello, String exclamationMark, String world) { super(name); - this.pass = "hello".equals(str1) && "world".equals(str2) && "!".equals(str3); + this.pass = "hello".equals(hello) && "world".equals(world) && "!".equals(exclamationMark); } @Override @@ -367,11 +367,11 @@ public String getActions() { public static class AllIntAutodetectedPermission extends Permission { private final boolean pass; - public AllIntAutodetectedPermission(String name, String[] actions, int i, int j, int k) { + public AllIntAutodetectedPermission(String name, String[] actions, int three, int two, int one) { super(name); // here we expect to match 'int' secured method parameters and have them passed here // considering secured method has also 'Object' and 'String' parameters, the task is more complex - this.pass = i == 1 && j == 2 && k == 3; + this.pass = one == 1 && two == 2 && three == 3; } @Override diff --git a/extensions/security/deployment/src/test/java/io/quarkus/security/test/permissionsallowed/NestedMethodsObject.java b/extensions/security/deployment/src/test/java/io/quarkus/security/test/permissionsallowed/NestedMethodsObject.java new file mode 100644 index 0000000000000..866da66c40505 --- /dev/null +++ b/extensions/security/deployment/src/test/java/io/quarkus/security/test/permissionsallowed/NestedMethodsObject.java @@ -0,0 +1,35 @@ +package io.quarkus.security.test.permissionsallowed; + +public class NestedMethodsObject { + + private final String property; + + public NestedMethodsObject(String property) { + this.property = property; + } + + public SecondTier second() { + return new SecondTier(); + } + + public final class SecondTier { + + public ThirdTier third() { + return new ThirdTier(); + } + + } + + public final class ThirdTier { + public FourthTier fourth() { + return new FourthTier(); + } + } + + public final class FourthTier { + public String getPropertyOne() { + return property; + } + } + +} 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 new file mode 100644 index 0000000000000..2790bbc79cc70 --- /dev/null +++ b/extensions/security/deployment/src/test/java/io/quarkus/security/test/permissionsallowed/PermissionsAllowedNestedParamsTest.java @@ -0,0 +1,110 @@ +package io.quarkus.security.test.permissionsallowed; + +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.util.Set; + +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.StringPermission; +import io.quarkus.security.test.utils.AuthData; +import io.quarkus.security.test.utils.IdentityMock; +import io.quarkus.security.test.utils.SecurityTestUtils; +import io.quarkus.test.QuarkusUnitTest; + +public class PermissionsAllowedNestedParamsTest { + + @RegisterExtension + static final QuarkusUnitTest config = new QuarkusUnitTest() + .withApplicationRoot((jar) -> jar + .addClasses(IdentityMock.class, AuthData.class, SecurityTestUtils.class, StringRecord.class, + SecuredBean.class, CustomPermissionWithStringArg.class, TopTierRecord.class, SimpleFieldParam.class, + ComplexFieldParam.class, NestedMethodsObject.class, CombinedAccessParam.class, + CustomPermissionWithMultipleArgs.class)); + + @Inject + SecuredBean securedBean; + + @Test + public void testNestedRecordParam_NestingLevelOne() { + assertSuccess(() -> securedBean.nestedRecordParam_OneTier(new StringRecord(EXPECTED_FIELD_STRING_ARGUMENT)), USER); + assertFailureFor(() -> securedBean.nestedRecordParam_OneTier(new StringRecord("unexpected_value")), + ForbiddenException.class, USER); + } + + @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); + var invalidTopTierRecord = new TopTierRecord( + new TopTierRecord.SecondTierRecord(null, new StringRecord("unexpected_value")), -1); + assertFailureFor(() -> securedBean.nestedRecordParam_ThreeTiers(invalidTopTierRecord), ForbiddenException.class, USER); + } + + @Test + public void testNestedFieldParam_NestingLevelOne() { + assertSuccess(() -> securedBean.nestedFieldParam_OneTier(new SimpleFieldParam(EXPECTED_FIELD_STRING_ARGUMENT)), USER); + assertFailureFor(() -> securedBean.nestedFieldParam_OneTier(new SimpleFieldParam("unexpected_value")), + ForbiddenException.class, USER); + } + + @Test + public void testNestedFieldParam_NestingLevelThree() { + var validComplexParam = new ComplexFieldParam( + new ComplexFieldParam.NestedFieldParam(new SimpleFieldParam(EXPECTED_FIELD_STRING_ARGUMENT))); + assertSuccess(() -> securedBean.nestedFieldParam_ThreeTiers(validComplexParam), USER); + var invalidComplexParam = new ComplexFieldParam( + new ComplexFieldParam.NestedFieldParam(new SimpleFieldParam("unexpected_value"))); + assertFailureFor(() -> securedBean.nestedFieldParam_ThreeTiers(invalidComplexParam), + ForbiddenException.class, USER); + } + + @Test + public void multipleNestedMethods() { + var validNestedMethods = new NestedMethodsObject(EXPECTED_FIELD_STRING_ARGUMENT); + assertSuccess(() -> securedBean.multipleNestedMethods(validNestedMethods), USER); + var invalidNestedMethods = new NestedMethodsObject("unexpected_value"); + assertFailureFor(() -> securedBean.multipleNestedMethods(invalidNestedMethods), ForbiddenException.class, USER); + } + + @Test + public void combinedFieldAndMethodAccess() { + var validCombinedParam = new CombinedAccessParam(new CombinedAccessParam.ParamField(EXPECTED_FIELD_STRING_ARGUMENT)); + assertSuccess(() -> securedBean.combinedParam(validCombinedParam), USER); + var invalidCombinedParam = new CombinedAccessParam(new CombinedAccessParam.ParamField("unexpected_value")); + assertFailureFor(() -> securedBean.combinedParam(invalidCombinedParam), ForbiddenException.class, USER); + } + + @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 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); + // fail as String permission is wrong + assertFailureFor(() -> securedBean.simpleAndNested(EXPECTED_FIELD_LONG_ARGUMENT, -1, validCombinedParam, -2, + EXPECTED_FIELD_INT_ARGUMENT, -3), ForbiddenException.class, noReadPerm); + // fail as long param is wrong + assertFailureFor(() -> securedBean.simpleAndNested(0, -1, validCombinedParam, -2, EXPECTED_FIELD_INT_ARGUMENT, -3), + ForbiddenException.class, readPerm); + // fail as int param is wrong + assertFailureFor(() -> securedBean.simpleAndNested(EXPECTED_FIELD_LONG_ARGUMENT, -1, validCombinedParam, -2, -9, -3), + ForbiddenException.class, readPerm); + // fail as combined param is wrong + var invalidCombinedParam = new CombinedAccessParam(new CombinedAccessParam.ParamField("unexpected_value")); + assertFailureFor(() -> securedBean.simpleAndNested(EXPECTED_FIELD_LONG_ARGUMENT, -1, invalidCombinedParam, -2, + EXPECTED_FIELD_INT_ARGUMENT, -3), ForbiddenException.class, readPerm); + } +} diff --git a/extensions/security/deployment/src/test/java/io/quarkus/security/test/permissionsallowed/SecuredBean.java b/extensions/security/deployment/src/test/java/io/quarkus/security/test/permissionsallowed/SecuredBean.java new file mode 100644 index 0000000000000..e871e5107ea7f --- /dev/null +++ b/extensions/security/deployment/src/test/java/io/quarkus/security/test/permissionsallowed/SecuredBean.java @@ -0,0 +1,47 @@ +package io.quarkus.security.test.permissionsallowed; + +import jakarta.enterprise.context.ApplicationScoped; + +import io.quarkus.security.PermissionsAllowed; + +@ApplicationScoped +public class SecuredBean { + + @PermissionsAllowed(value = "ignored", permission = CustomPermissionWithStringArg.class, params = "record.propertyOne") + public String nestedRecordParam_OneTier(StringRecord record) { + return record.propertyOne(); + } + + @PermissionsAllowed(value = "ignored", permission = CustomPermissionWithStringArg.class, params = "record.secondTier.thirdTier.propertyOne") + public String nestedRecordParam_ThreeTiers(TopTierRecord record) { + return record.secondTier().thirdTier().propertyOne(); + } + + @PermissionsAllowed(value = "ignored", permission = CustomPermissionWithStringArg.class, params = "simpleParam.propertyOne") + public String nestedFieldParam_OneTier(SimpleFieldParam simpleParam) { + return simpleParam.propertyOne; + } + + @PermissionsAllowed(value = "ignored", permission = CustomPermissionWithStringArg.class, params = "complexParam.nestedFieldParam.simpleFieldParam.propertyOne") + public String nestedFieldParam_ThreeTiers(ComplexFieldParam complexParam) { + return complexParam.nestedFieldParam.simpleFieldParam.propertyOne; + } + + @PermissionsAllowed(value = "ignored", permission = CustomPermissionWithStringArg.class, params = "obj.second.third.fourth.propertyOne") + public String multipleNestedMethods(NestedMethodsObject obj) { + return obj.second().third().fourth().getPropertyOne(); + } + + @PermissionsAllowed(value = "ignored", permission = CustomPermissionWithStringArg.class, params = "obj.paramField.myVal.propertyOne") + public String combinedParam(CombinedAccessParam obj) { + return obj.paramField.myVal().propertyOne; + } + + @PermissionsAllowed("read") + @PermissionsAllowed(value = "ignored", permission = CustomPermissionWithMultipleArgs.class, params = { "fourth", + "obj.paramField.myVal.propertyOne", "first" }) + public String simpleAndNested(long first, long second, CombinedAccessParam obj, int third, int fourth, int fifth) { + return first + "" + first; + } + +} diff --git a/extensions/security/deployment/src/test/java/io/quarkus/security/test/permissionsallowed/SimpleFieldParam.java b/extensions/security/deployment/src/test/java/io/quarkus/security/test/permissionsallowed/SimpleFieldParam.java new file mode 100644 index 0000000000000..4c251cb74f5c4 --- /dev/null +++ b/extensions/security/deployment/src/test/java/io/quarkus/security/test/permissionsallowed/SimpleFieldParam.java @@ -0,0 +1,10 @@ +package io.quarkus.security.test.permissionsallowed; + +public class SimpleFieldParam { + + public final String propertyOne; + + public SimpleFieldParam(String propertyOne) { + this.propertyOne = propertyOne; + } +} diff --git a/extensions/security/deployment/src/test/java/io/quarkus/security/test/permissionsallowed/StringRecord.java b/extensions/security/deployment/src/test/java/io/quarkus/security/test/permissionsallowed/StringRecord.java new file mode 100644 index 0000000000000..08d62c90fdc5d --- /dev/null +++ b/extensions/security/deployment/src/test/java/io/quarkus/security/test/permissionsallowed/StringRecord.java @@ -0,0 +1,4 @@ +package io.quarkus.security.test.permissionsallowed; + +public record StringRecord(String propertyOne) { +} diff --git a/extensions/security/deployment/src/test/java/io/quarkus/security/test/permissionsallowed/TopTierRecord.java b/extensions/security/deployment/src/test/java/io/quarkus/security/test/permissionsallowed/TopTierRecord.java new file mode 100644 index 0000000000000..17ca8411f3b37 --- /dev/null +++ b/extensions/security/deployment/src/test/java/io/quarkus/security/test/permissionsallowed/TopTierRecord.java @@ -0,0 +1,8 @@ +package io.quarkus.security.test.permissionsallowed; + +public record TopTierRecord(SecondTierRecord secondTier, int ignored) { + + public record SecondTierRecord(String ignored, StringRecord thirdTier) { + } + +} diff --git a/extensions/security/deployment/src/test/java/io/quarkus/security/test/permissionsallowed/UnknownParamPermissionsAllowedValidationFailureTest.java b/extensions/security/deployment/src/test/java/io/quarkus/security/test/permissionsallowed/UnknownParamPermissionsAllowedValidationFailureTest.java new file mode 100644 index 0000000000000..ca697829861fb --- /dev/null +++ b/extensions/security/deployment/src/test/java/io/quarkus/security/test/permissionsallowed/UnknownParamPermissionsAllowedValidationFailureTest.java @@ -0,0 +1,48 @@ +package io.quarkus.security.test.permissionsallowed; + +import java.security.BasicPermission; +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.PermissionsAllowed; +import io.quarkus.test.QuarkusUnitTest; + +public class UnknownParamPermissionsAllowedValidationFailureTest { + + @RegisterExtension + static final QuarkusUnitTest config = new QuarkusUnitTest() + .assertException(t -> { + Assertions.assertEquals(RuntimeException.class, t.getClass(), t.getMessage()); + Assertions.assertTrue(t.getMessage().contains("Parameter 'id' specified via @PermissionsAllowed#params")); + Assertions.assertTrue(t.getMessage().contains("SecuredBean#securedBean")); + Assertions.assertTrue(t.getMessage().contains("cannot be matched to any constructor")); + Assertions.assertTrue(t.getMessage().contains("OrganizationUnitIdPermission' parameter")); + }); + + @Test + public void test() { + Assertions.fail(); + } + + @Singleton + public static class SecuredBean { + + @PermissionsAllowed(value = "ignored", params = "id", permission = OrganizationUnitIdPermission.class) + public void securedBean(UUID aOrganizationUnitId) { + // EMPTY + } + + } + + public static class OrganizationUnitIdPermission extends BasicPermission { + + public OrganizationUnitIdPermission(String name, UUID aOrganizationUnitId) { + super(name); + } + } +} diff --git a/extensions/security/deployment/src/test/java/io/quarkus/security/test/permissionsallowed/UnusedParamPermissionsAllowedValidationFailureTest.java b/extensions/security/deployment/src/test/java/io/quarkus/security/test/permissionsallowed/UnusedParamPermissionsAllowedValidationFailureTest.java new file mode 100644 index 0000000000000..4e938af5c6ecb --- /dev/null +++ b/extensions/security/deployment/src/test/java/io/quarkus/security/test/permissionsallowed/UnusedParamPermissionsAllowedValidationFailureTest.java @@ -0,0 +1,56 @@ +package io.quarkus.security.test.permissionsallowed; + +import java.security.BasicPermission; +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.PermissionsAllowed; +import io.quarkus.test.QuarkusUnitTest; + +public class UnusedParamPermissionsAllowedValidationFailureTest { + + @RegisterExtension + static final QuarkusUnitTest config = new QuarkusUnitTest() + .assertException(t -> { + Assertions.assertEquals(RuntimeException.class, t.getClass(), t.getMessage()); + Assertions.assertTrue(t.getMessage().contains("nestedParam1.something")); + Assertions.assertTrue(t.getMessage().contains("cannot be matched to any constructor")); + Assertions.assertTrue(t.getMessage().contains("OrganizationUnitIdPermission' parameter")); + }); + + @Test + public void test() { + Assertions.fail(); + } + + @Singleton + public static class SecuredBean { + + @PermissionsAllowed(value = "ignored", params = { "aOrganizationUnitId", + "nestedParam1.something" }, permission = OrganizationUnitIdPermission.class) + public void securedBean(UUID aOrganizationUnitId, NestedParam1 nestedParam1) { + // EMPTY + } + + } + + public static class NestedParam1 { + final String something; + + public NestedParam1(String something) { + this.something = something; + } + } + + public static class OrganizationUnitIdPermission extends BasicPermission { + + public OrganizationUnitIdPermission(String name, UUID aOrganizationUnitId) { + super(name); + } + } +} 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 732bda8773e80..f6bbc41587cc6 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 @@ -2,6 +2,9 @@ import static io.quarkus.security.runtime.QuarkusSecurityRolesAllowedConfigBuilder.transformToKey; +import java.lang.invoke.MethodHandle; +import java.lang.invoke.MethodHandles; +import java.lang.invoke.MethodType; import java.lang.reflect.InvocationTargetException; import java.security.Permission; import java.util.ArrayList; @@ -290,10 +293,13 @@ public RuntimeValue createPermission(String name, String clazz, Stri * @param actions permission actions * @param passActionsToConstructor flag signals whether Permission constructor accepts (name) or (name, actions) * @param formalParamIndexes indexes of secured method params that should be passed to permission constructor + * @param formalParamConverters converts method parameter to constructor parameter; most of the time, this will be + * either identity function or a method calling method parameter getter * @return computed permission */ public Function createComputedPermission(String permissionName, String clazz, String[] actions, - boolean passActionsToConstructor, int[] formalParamIndexes) { + boolean passActionsToConstructor, int[] formalParamIndexes, String[] formalParamConverters, + Map> converterNameToMethodHandle) { final int addActions = (passActionsToConstructor ? 1 : 0); final int argsCount = 1 + addActions + formalParamIndexes.length; final int methodArgsStart = 1 + addActions; @@ -320,7 +326,14 @@ private Object[] initArgs(Object[] methodArgs) { initArgs[1] = actions; } for (int i = 0; i < formalParamIndexes.length; i++) { - initArgs[methodArgsStart + i] = methodArgs[formalParamIndexes[i]]; + var methodArg = methodArgs[formalParamIndexes[i]]; + if (formalParamConverters == null || formalParamConverters[i] == null) { + initArgs[methodArgsStart + i] = methodArg; + } else { + var convertedValue = convertMethodParamToPermParam(i, methodArg, converterNameToMethodHandle, + formalParamConverters); + initArgs[methodArgsStart + i] = convertedValue; + } } return initArgs; } @@ -395,4 +408,29 @@ public void run() { } }); } + + public RuntimeValue createPermissionMethodConverter(String methodName, RuntimeValue> clazz) { + try { + var handle = MethodHandles.publicLookup().findStatic(clazz.getValue(), methodName, + MethodType.methodType(Object.class, Object.class)); + return new RuntimeValue<>(handle); + } catch (NoSuchMethodException | IllegalAccessException e) { + throw new RuntimeException("Failed to create Permission constructor method parameter converter", e); + } + } + + public RuntimeValue> loadClassRuntimeVal(String className) { + return new RuntimeValue<>(loadClass(className)); + } + + private static Object convertMethodParamToPermParam(int i, Object methodArg, + Map> converterNameToMethodHandle, String[] formalParamConverters) { + var converter = converterNameToMethodHandle.get(formalParamConverters[i]).getValue(); + try { + return converter.invokeExact(methodArg); + } catch (Throwable e) { + throw new RuntimeException( + "Failed to convert method argument '%s' to Permission constructor parameter".formatted(methodArg), e); + } + } }