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..eb192d44e46d7 100644 --- a/docs/src/main/asciidoc/security-authorize-web-endpoints-reference.adoc +++ b/docs/src/main/asciidoc/security-authorize-web-endpoints-reference.adoc @@ -1078,6 +1078,112 @@ 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 + +The `@PermissionsAllowed` annotation attribute `params` can refer to methods or fields of the secured method parameter. +You can use this feature to pass `jakarta.ws.rs.BeanParam` parameters into your custom permission. +Consider following `jakarta.ws.rs.BeanParam` class: + +[source,java] +---- +package org.acme.crud; + +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; + + @HeaderParam("Authorization") + String authorizationHeader; + + @Context + SecurityContext securityContext; + + @Context + UriInfo uriInfo; + + String getPrivateQuery() { + return privateQuery; + } +} +---- + +And let's say we have `SimplePermission` where we need to check `Authorization` header and principal name. +That can be done like this: + +[source,java] +---- +package org.acme.crud; + +import java.security.Permission; +import java.util.Objects; + +public class SimplePermission extends Permission { + + private final String authorizationHeader; + private final String principalName; + + public SimpleBeanParamPermission(String permissionName, String authorizationHeader, String name, String privateQuery) { <1> <2> + super(permissionName); + this.authorizationHeader = authorizationHeader; + this.principalName = name; + if ("private-query".equals(privateQuery)) { + // your business logic + } + } + + @Override + public boolean implies(Permission requiredPermission) { + if (requiredPermission instanceof SimpleBeanParamPermission that) { + return "MyAuthorization".equals(that.authorizationHeader) + && "admin".equals(that.principalName); + } + return false; + } + + ... +} +---- +<1> Constructor parameters `authorizationHeader` and `principalName` corresponds to `SimpleBeanParam` fields with same names. +<2> Constructor parameter `privateQuery` is accessed with a getter method. +Quarkus always prefer method access, therefore, first we look for the `privateQuery` method, then for the `getPrivateQuery` method. +If neither method can be found on the `SimpleBeanParam` class, Quarkus tries to find the `privateQuery` field. + +The `SimplePermission` can be used on your Jakarta REST resource like in the example below: + +[source,java] +---- +package org.acme.crud; + +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 = "read", permission = SimplePermission.class, + params = { "param.authorizationHeader", "param.privateQuery", "param.securityContext.getUserPrincipal.name" }) <1> + @GET + public String sayHello(@BeanParam SimpleBeanParam param) { + return "Hello path is " + param.uriInfo.getPath(); + } + +} +---- +<1> `SecurityIdentity` principal is accessed from the `jakarta.ws.rs.core.SecurityContext`. +Please note that the `name` part of the `param.securityContext.getUserPrincipal.name` expression matches the `SimplePermission` parameter `name`. + == 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/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..5c45549ef125a --- /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) { + 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/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..ff78d71a63264 --- /dev/null +++ b/extensions/resteasy-reactive/rest/deployment/src/test/java/io/quarkus/resteasy/reactive/server/test/security/PermissionsAllowedBeanParamTest.java @@ -0,0 +1,107 @@ +package io.quarkus.resteasy.reactive.server.test.security; + +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)); + + @BeforeAll + public static void setupUsers() { + TestIdentityController.resetRoles() + .add("admin", "admin", SimpleBeanParamPermission.EMPTY, MyPermission.EMPTY) + .add("user", "user"); + } + + @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); + } + + 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.query", + "beanParam.protectedQuery", "beanParam.publicQuery", "beanParam.header", "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"; + } + } +} 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..0049f103ccb88 --- /dev/null +++ b/extensions/resteasy-reactive/rest/deployment/src/test/java/io/quarkus/resteasy/reactive/server/test/security/SimpleBeanParam.java @@ -0,0 +1,39 @@ +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") + String query; + + @QueryParam("query") + private String privateQuery; + + @QueryParam("query") + protected String protectedQuery; + + @QueryParam("query") + public String publicQuery; + + @HeaderParam("header") + String header; + + @QueryParam("queryList") + List queryList; + + @Context + SecurityContext securityContext; + + @Context + UriInfo uriInfo; + + 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..9377b7f97a67c --- /dev/null +++ b/extensions/resteasy-reactive/rest/deployment/src/test/java/io/quarkus/resteasy/reactive/server/test/security/SimpleBeanParamPermission.java @@ -0,0 +1,83 @@ +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, null, null); + + private final String query; + private final String protectedQuery; + 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 query, String protectedQuery, String publicQuery, String header, + List queryList, SecurityContext securityContext, UriInfo uriInfo, String privateQuery, String cookie) { + super(name); + this.query = query; + this.protectedQuery = protectedQuery; + 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.query); + Assertions.assertEquals("one-query", simplePermission.privateQuery); + Assertions.assertEquals("one-query", simplePermission.protectedQuery); + 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(query, that.query) && Objects.equals(protectedQuery, that.protectedQuery) + && 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(query, protectedQuery, 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..f5238453aa6f6 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 @@ -27,9 +27,17 @@ 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; +import io.quarkus.security.runtime.PermissionMethodConverter; import io.quarkus.security.runtime.SecurityCheckRecorder; import io.quarkus.security.runtime.interceptor.PermissionsAllowedInterceptor; import io.quarkus.security.spi.PermissionsAllowedMetaAnnotationBuildItem; @@ -52,9 +60,14 @@ 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() { @@ -492,7 +505,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 +528,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.getConverterNameToInstance()); } private RuntimeValue createCustomPermission(PermissionCacheKey permissionCacheKey) { @@ -627,6 +642,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 +655,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 +702,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 +722,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( @@ -698,25 +744,60 @@ private PermissionCacheKey(PermissionKey permissionKey, AnnotationTarget secured // determine if we want to pass actions param to Permission constructor if (isSecondParamStringArr) { - int foundIx = findSecuredMethodParamIndex(securedMethod, constructor, 1); + int foundIx = findSecuredMethodParamIndex(securedMethod, constructor, 1, + permissionKey.paramsRemainder, permissionKey.params, null, -1); // if (foundIx == -1) is false then user assigned second constructor param to a method param this.passActionsToConstructor = foundIx == -1; } else { this.passActionsToConstructor = false; } + Map remainderIdxToMethodParamIdx = new HashMap<>(); this.methodParamIndexes = userDefinedConstructorParamIndexes(securedMethod, constructor, - this.passActionsToConstructor); + this.passActionsToConstructor, permissionKey.params, permissionKey.paramsRemainder, + remainderIdxToMethodParamIdx); + if (permissionKey.paramsRemainder == null) { + this.methodParamConverters = null; + } else { + this.methodParamConverters = new String[this.methodParamIndexes.length]; + for (int i = 0; i < permissionKey.paramsRemainder.length; i++) { + if (remainderIdxToMethodParamIdx.containsKey(i)) { + var secMethodAndCtorPermIdx = remainderIdxToMethodParamIdx.get(i); + var paramRemainder = permissionKey.paramsRemainder[i]; + this.methodParamConverters[secMethodAndCtorPermIdx + .constructorParamIdx()] = paramConverterGenerator + .createConverter(paramRemainder, securedMethod, + secMethodAndCtorPermIdx.methodParamIdx()); + } + } + // make sure all @PermissionsAllowed(param = { expression.one.two, expression.one.three } + // params are mapped to Permission constructor parameters + if (remainderIdxToMethodParamIdx.size() != permissionKey.paramsRemainder.length) { + for (int i = 0; i < permissionKey.paramsRemainder.length; i++) { + if (permissionKey.paramsRemainder[i] != null + && !remainderIdxToMethodParamIdx.containsKey(i)) { + 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(), + permissionKey.params[i] + "." + permissionKey.paramsRemainder[i], + constructor.declaringClass().name())); + } + } + } + } } else { // autodetect params path this.passActionsToConstructor = isSecondParamStringArr; this.methodParamIndexes = autodetectConstructorParamIndexes(permissionKey, securedMethod, constructor, isSecondParamStringArr); + this.methodParamConverters = null; } } else { // plain permission this.methodParamIndexes = null; + this.methodParamConverters = null; this.permissionKey = permissionKey; this.computed = false; this.passActionsToConstructor = constructor.parametersCount() == 2; @@ -724,21 +805,33 @@ private PermissionCacheKey(PermissionKey permissionKey, AnnotationTarget secured } private static int[] userDefinedConstructorParamIndexes(MethodInfo securedMethod, MethodInfo constructor, - boolean passActionsToConstructor) { + boolean passActionsToConstructor, String[] requiredMethodParams, String[] requiredParamsRemainder, + Map remainderIdxToMethodParamIdx) { // 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]; 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); + int foundIx = findSecuredMethodParamIndex(securedMethod, constructor, i, requiredParamsRemainder, + requiredMethodParams, remainderIdxToMethodParamIdx, nonMethodParams); // 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 + final String constructorParamName = constructor.parameterName(i); + if (foundIx == -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'", securedMethod.name(), constructor.declaringClass().name().toString(), constructorParamName)); + } else if (requiredParamsRemainder == null + && Arrays.stream(requiredMethodParams).noneMatch(constructorParamName::equalsIgnoreCase)) { + throw new RuntimeException( + """ + Permission '%s' constructor parameter '%s' cannot be mapped to secured method '%s' parameters as none + of its parameter names matches any of the parameters specified via '@PermissionsAllowed#params': %s + """ + .formatted(constructor.declaringClass().name().toString(), constructorParamName, + securedMethod.name(), Arrays.toString(requiredMethodParams))); } methodParamIndexes[i - nonMethodParams] = foundIx; } @@ -779,17 +872,45 @@ private static int[] autodetectConstructorParamIndexes(PermissionKey permissionK } private static int findSecuredMethodParamIndex(MethodInfo securedMethod, MethodInfo constructor, - int constructorIx) { + int constructorIx, String[] requiredParamsRemainder, String[] requiredParams, + Map remainderIdxToMethodParamIdx, int nonMethodParams) { // 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; - for (int i = 0; i < securedMethod.parametersCount(); i++) { - boolean paramNamesMatch = constructorParamName.equals(securedMethod.parameterName(i)); + outer: for (int i = 0; i < securedMethod.parametersCount(); i++) { + var methodParamName = securedMethod.parameterName(i); + boolean paramNamesMatch = constructorParamName.equals(methodParamName); if (paramNamesMatch) { foundIx = i; break; } + // 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) + if (requiredParamsRemainder != null) { + for (int i1 = 0; i1 < requiredParams.length; i1++) { + boolean methodNameMatches = methodParamName.equals(requiredParams[i1]); + var requiredParamRemainder = requiredParamsRemainder[i1]; + if (methodNameMatches && requiredParamRemainder != null) { + int lastDotIdx = requiredParamRemainder.lastIndexOf('.'); + final String lastExpression; + if (lastDotIdx == -1) { + lastExpression = requiredParamRemainder; + } else { + lastExpression = requiredParamRemainder.substring(lastDotIdx + 1); + } + if (constructorParamName.equals(lastExpression)) { + foundIx = i; + if (remainderIdxToMethodParamIdx != null) { + remainderIdxToMethodParamIdx.put(i1, + new SecMethodAndPermCtorIdx(foundIx, constructorIx - nonMethodParams)); + } + break outer; + } + } + } + } } return foundIx; } @@ -803,13 +924,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; } @@ -847,4 +972,110 @@ private static boolean isStringPermission(PermissionKey permissionKey) { } } + final class PermissionConverterGenerator { + private final BuildProducer generatedClassesProducer; + private final BuildProducer reflectiveClassesProducer; + private final SecurityCheckRecorder recorder; + private final Map converterNameToInstance = new HashMap<>(); + private final IndexView index; + + private PermissionConverterGenerator(BuildProducer generatedClassesProducer, + BuildProducer reflectiveClassesProducer, SecurityCheckRecorder recorder, + IndexView index) { + this.generatedClassesProducer = generatedClassesProducer; + this.reflectiveClassesProducer = reflectiveClassesProducer; + this.recorder = recorder; + this.index = index; + } + + private Map getConverterNameToInstance() { + return converterNameToInstance.isEmpty() ? null : Map.copyOf(converterNameToInstance); + } + + private String createConverter(String paramRemainder, MethodInfo securedMethod, int methodParamIdx) { + String[] nestedParams = paramRemainder.split("\\."); + var converterName = createConverterClassName(securedMethod, methodParamIdx, 0); + try (ClassCreator classCreator = ClassCreator.builder() + .classOutput(new GeneratedClassGizmoAdaptor(generatedClassesProducer, true)) + .className(converterName) + .interfaces(PermissionMethodConverter.class) + .setFinal(true) + .build()) { + try (MethodCreator methodCreator = classCreator.getMethodCreator("convertParam", Object.class, Object.class)) { + var paramToConvert = methodCreator.getMethodParam(0); + var paramType = securedMethod.parameterType(methodParamIdx); + ResultHandle result = getNestedParam(nestedParams, 0, paramToConvert, methodCreator, paramType, + securedMethod, methodParamIdx); + methodCreator.returnValue(result); + } + } + reflectiveClassesProducer.produce(ReflectiveClassBuildItem.builder(converterName).constructors().methods().build()); + converterNameToInstance.put(converterName, recorder.createPermMethodParamConverter(converterName)); + 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) { + 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())); + } + newOuter = methodCreator.readInstanceField(field, outer); + newOuterType = field.type(); + } + return getNestedParam(nestedParams, nestedParamIdx + 1, newOuter, methodCreator, newOuterType, securedMethod, + methodParamIdx); + } + + private String createConverterClassName(MethodInfo securedMethod, int methodParamIdx, int classNameSuffix) { + var className = "%s_%s_%s_PermParamConverter_%d".formatted(securedMethod.declaringClass().name(), + securedMethod.name(), securedMethod.parameterName(methodParamIdx), classNameSuffix); + if (converterNameToInstance.containsKey(className)) { + return createConverterClassName(securedMethod, methodParamIdx, classNameSuffix + 1); + } + return className; + } + } + + private static String toFieldGetter(String paramExpression) { + return "get" + paramExpression.substring(0, 1).toUpperCase() + paramExpression.substring(1); + } + + record SecMethodAndPermCtorIdx(int methodParamIdx, int constructorParamIdx) { + } } 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 594294fe9f05d..c9d7817f21bf9 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 @@ -71,6 +71,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; @@ -632,7 +633,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())); @@ -650,7 +653,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()); @@ -730,7 +734,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<>(); @@ -764,7 +770,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/CombinedAccessParam.java b/extensions/security/deployment/src/test/java/io/quarkus/security/test/permissionsallowed/CombinedAccessParam.java new file mode 100644 index 0000000000000..1f254b8bcc4e8 --- /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 { + + final ParamField paramField; + + CombinedAccessParam(ParamField paramField) { + this.paramField = paramField; + } + + 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..3efce45c8b299 --- /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 { + + final NestedFieldParam nestedFieldParam; + + ComplexFieldParam(NestedFieldParam nestedFieldParam) { + this.nestedFieldParam = nestedFieldParam; + } + + public static final class NestedFieldParam { + + 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/NestedMethodsObject.java b/extensions/security/deployment/src/test/java/io/quarkus/security/test/permissionsallowed/NestedMethodsObject.java new file mode 100644 index 0000000000000..680bf13283f22 --- /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 { + 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..35b12dd6d47f5 --- /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 = { "third", + "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..41b893e1e9212 --- /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 { + + 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..1534a1f74ffeb --- /dev/null +++ b/extensions/security/deployment/src/test/java/io/quarkus/security/test/permissionsallowed/UnknownParamPermissionsAllowedValidationFailureTest.java @@ -0,0 +1,42 @@ +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() + .setExpectedException(RuntimeException.class); + + @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..47871cf3c1fde --- /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(IllegalArgumentException.class, t.getClass()); + Assertions.assertTrue(t.getMessage().contains("nestedParam1.something")); + Assertions.assertTrue(t.getMessage().contains("cannot be matched to any Permission")); + Assertions.assertTrue(t.getMessage().contains("constructor 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/PermissionMethodConverter.java b/extensions/security/runtime/src/main/java/io/quarkus/security/runtime/PermissionMethodConverter.java new file mode 100644 index 0000000000000..c86099bd3aff3 --- /dev/null +++ b/extensions/security/runtime/src/main/java/io/quarkus/security/runtime/PermissionMethodConverter.java @@ -0,0 +1,10 @@ +package io.quarkus.security.runtime; + +/** + * Interface implemented by generated method converters for parameters of methods passes to permission security check. + */ +public interface PermissionMethodConverter { + + Object convertParam(Object param); + +} 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..940d7ab58a2e6 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 @@ -290,10 +290,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 converterNameToInstance) { final int addActions = (passActionsToConstructor ? 1 : 0); final int argsCount = 1 + addActions + formalParamIndexes.length; final int methodArgsStart = 1 + addActions; @@ -320,7 +323,13 @@ 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 converter = converterNameToInstance.get(formalParamConverters[i]); + initArgs[methodArgsStart + i] = converter.convertParam(methodArg); + } } return initArgs; } @@ -395,4 +404,12 @@ public void run() { } }); } + + public PermissionMethodConverter createPermMethodParamConverter(String converterName) { + try { + return (PermissionMethodConverter) loadClass(converterName).getConstructors()[0].newInstance(); + } catch (InstantiationException | IllegalAccessException | InvocationTargetException e) { + throw new RuntimeException("Failed to instantiate generated class '%s'".formatted(converterName), e); + } + } }