From ffe7e7b5966625dc8ad21982310f47cf9a244cfb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michal=20Vav=C5=99=C3=ADk?= Date: Wed, 22 Feb 2023 16:14:28 +0100 Subject: [PATCH] Allow permission checks via `@PermissionsAllowed` security annotation --- bom/application/pom.xml | 2 +- ...ity-authorize-web-endpoints-reference.adoc | 180 +++++ .../deployment/ReactiveRoutesProcessor.java | 3 + .../runtime/SecurityContextFilter.java | 8 + .../deployment/ResteasyReactiveProcessor.java | 3 +- .../deployment/SecurityTransformerUtils.java | 6 +- .../AbstractPermissionsAllowedTestCase.java | 129 ++++ .../test/security/CustomPermission.java | 34 + .../LazyAuthPermissionsAllowedTestCase.java | 20 + ...NonBlockingPermissionsAllowedResource.java | 48 ++ .../security/PermissionsAllowedResource.java | 48 ++ ...oactiveAuthPermissionsAllowedTestCase.java | 17 + .../StandardSecurityCheckInterceptor.java | 11 + .../security/EagerSecurityHandler.java | 8 +- .../SecurityContextOverrideHandler.java | 6 + .../quarkus/security/deployment/DotNames.java | 2 + .../deployment/PermissionSecurityChecks.java | 697 ++++++++++++++++++ .../SecurityAnnotationsRegistrar.java | 3 + .../deployment/SecurityProcessor.java | 33 +- .../deployment/SecurityTransformerUtils.java | 4 +- ...ractMethodLevelPermissionsAllowedTest.java | 427 +++++++++++ ...ssLevelComputedPermissionsAllowedTest.java | 202 +++++ ...lassLevelCustomPermissionsAllowedTest.java | 226 ++++++ ...lassLevelStringPermissionsAllowedTest.java | 187 +++++ .../InjectionPermissionsAllowedTest.java | 137 ++++ ...odLevelComputedPermissionsAllowedTest.java | 440 +++++++++++ ...thodLevelCustomPermissionsAllowedTest.java | 373 ++++++++++ ...thodLevelStringPermissionsAllowedTest.java | 233 ++++++ .../PermissionsIdentityAugmentor.java | 45 ++ .../security/spi/runtime/SecurityCheck.java | 11 + .../runtime/AnonymousIdentityProvider.java | 7 + .../runtime/QuarkusSecurityIdentity.java | 23 + .../runtime/SecurityCheckRecorder.java | 210 ++++++ .../runtime/SecurityIdentityProxy.java | 7 + .../PermissionsAllowedInterceptor.java | 31 + .../interceptor/SecurityConstrainer.java | 9 +- .../check/PermissionSecurityCheck.java | 236 ++++++ .../spi/SecurityTransformerUtils.java | 4 +- .../quarkus/security/test/utils/AuthData.java | 10 + .../security/test/utils/IdentityMock.java | 21 +- .../test/utils/SecurityTestUtils.java | 33 + .../test/utils/TestIdentityController.java | 29 + .../test/utils/TestIdentityProvider.java | 1 + .../jdbc/it/ElytronSecurityJdbcResource.java | 16 + .../jdbc/it/PermissionsIdentityAugmentor.java | 55 ++ .../security/jdbc/it/WorkdayEvaluator.java | 19 + .../security/jdbc/it/WorkdayPermission.java | 72 ++ .../src/main/resources/import.sql | 2 + .../jdbc/it/ElytronSecurityJdbcTest.java | 91 +++ 49 files changed, 4399 insertions(+), 20 deletions(-) create mode 100644 extensions/resteasy-reactive/quarkus-resteasy-reactive/deployment/src/test/java/io/quarkus/resteasy/reactive/server/test/security/AbstractPermissionsAllowedTestCase.java create mode 100644 extensions/resteasy-reactive/quarkus-resteasy-reactive/deployment/src/test/java/io/quarkus/resteasy/reactive/server/test/security/CustomPermission.java create mode 100644 extensions/resteasy-reactive/quarkus-resteasy-reactive/deployment/src/test/java/io/quarkus/resteasy/reactive/server/test/security/LazyAuthPermissionsAllowedTestCase.java create mode 100644 extensions/resteasy-reactive/quarkus-resteasy-reactive/deployment/src/test/java/io/quarkus/resteasy/reactive/server/test/security/NonBlockingPermissionsAllowedResource.java create mode 100644 extensions/resteasy-reactive/quarkus-resteasy-reactive/deployment/src/test/java/io/quarkus/resteasy/reactive/server/test/security/PermissionsAllowedResource.java create mode 100644 extensions/resteasy-reactive/quarkus-resteasy-reactive/deployment/src/test/java/io/quarkus/resteasy/reactive/server/test/security/ProactiveAuthPermissionsAllowedTestCase.java create mode 100644 extensions/security/deployment/src/main/java/io/quarkus/security/deployment/PermissionSecurityChecks.java create mode 100644 extensions/security/deployment/src/test/java/io/quarkus/security/test/permissionsallowed/AbstractMethodLevelPermissionsAllowedTest.java create mode 100644 extensions/security/deployment/src/test/java/io/quarkus/security/test/permissionsallowed/ClassLevelComputedPermissionsAllowedTest.java create mode 100644 extensions/security/deployment/src/test/java/io/quarkus/security/test/permissionsallowed/ClassLevelCustomPermissionsAllowedTest.java create mode 100644 extensions/security/deployment/src/test/java/io/quarkus/security/test/permissionsallowed/ClassLevelStringPermissionsAllowedTest.java create mode 100644 extensions/security/deployment/src/test/java/io/quarkus/security/test/permissionsallowed/InjectionPermissionsAllowedTest.java create mode 100644 extensions/security/deployment/src/test/java/io/quarkus/security/test/permissionsallowed/MethodLevelComputedPermissionsAllowedTest.java create mode 100644 extensions/security/deployment/src/test/java/io/quarkus/security/test/permissionsallowed/MethodLevelCustomPermissionsAllowedTest.java create mode 100644 extensions/security/deployment/src/test/java/io/quarkus/security/test/permissionsallowed/MethodLevelStringPermissionsAllowedTest.java create mode 100644 extensions/security/deployment/src/test/java/io/quarkus/security/test/permissionsallowed/PermissionsIdentityAugmentor.java create mode 100644 extensions/security/runtime/src/main/java/io/quarkus/security/runtime/interceptor/PermissionsAllowedInterceptor.java create mode 100644 extensions/security/runtime/src/main/java/io/quarkus/security/runtime/interceptor/check/PermissionSecurityCheck.java create mode 100644 integration-tests/elytron-security-jdbc/src/main/java/io/quarkus/elytron/security/jdbc/it/PermissionsIdentityAugmentor.java create mode 100644 integration-tests/elytron-security-jdbc/src/main/java/io/quarkus/elytron/security/jdbc/it/WorkdayEvaluator.java create mode 100644 integration-tests/elytron-security-jdbc/src/main/java/io/quarkus/elytron/security/jdbc/it/WorkdayPermission.java diff --git a/bom/application/pom.xml b/bom/application/pom.xml index 07f36d5988e291..c91f93f9a22959 100644 --- a/bom/application/pom.xml +++ b/bom/application/pom.xml @@ -185,7 +185,7 @@ 5.1.1 5.8.0 4.10.1 - 2.0.1.Final + 2.0.2.Final-SNAPSHOT 20.0.3 1.15.0 3.31.0 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 b61e49214e6d34..eaa4f31088ab52 100644 --- a/docs/src/main/asciidoc/security-authorize-web-endpoints-reference.adoc +++ b/docs/src/main/asciidoc/security-authorize-web-endpoints-reference.adoc @@ -386,6 +386,186 @@ It is possible to use multiple expressions in the role definition. <3> This `/subject/user` endpoint requires an authenticated user that has been granted the role "User" through the use of the `@RolesAllowed("${customer:User}")` annotation, as we did not set the configuration property `customer`. <4> This `/subject/secured` endpoint requires an authenticated user that has been granted the role `User` in production but allows any authenticated user in development mode. +=== Permission annotation + +Quarkus also provides the `io.quarkus.security.PermissionsAllowed` annotation that will permit any authenticated user with given permission to access the resource. +The annotation is extension of the common security annotations and there is no relation to configuration permissions defined with the configuration property `quarkus.http.auth.permission`. + +.Example of endpoints secured with a `@PermissionsAllowed` + +[source,java] +---- +import io.quarkus.arc.Arc; +import io.vertx.ext.web.RoutingContext; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.POST; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.QueryParam; + +import io.quarkus.security.PermissionsAllowed; + +import java.security.Permission; +import java.util.Collection; +import java.util.Collections; + +@Path("/crud") +public class CRUDResource { + + @PermissionsAllowed("create") <1> + @PermissionsAllowed("update") + @POST + public String createOrUpdate() { + return "modified"; + } + + @PermissionsAllowed({"see:detail", "see:all", "admin"}) <2> + @GET + @Path("/id/{id}") + public String getItem(String id) { + return "item-detail-" + id; + } + + @PermissionsAllowed(value = "permission-1", permission = CustomPermission.class) <3> + @GET + public Collection list(@QueryParam("query-options") String queryOptions) { + // your business logic comes here + return Collections.emptySet(); + } + + public static class CustomPermission extends Permission { + + public CustomPermission(String name) { + super(name); + } + + @Override + public boolean implies(Permission permission) { + var event = Arc.container().instance(RoutingContext.class).get(); <4> + return "show-first-page".equals(event.request().params().get("query-options")); + } + + ... + } +} +---- +<1> Resource method `createOrUpdate` is only accessible by user with both `create` and `update` permissions. +The `@PermissionsAllowed` is repeatable and all annotation instances are conjuncts. +<2> User is granted access to `getItem` if he possess either `admin` permission or `see` permission and one of actions (`all`, `detail`). +Relation between permissions specified through `PermissionsAllowed#value` is disjunctive. +<3> You can use any `java.security.Permission` implementation of your choice. +By default, string-based permission is performed by the `io.quarkus.security.StringPermission`. +<4> Permissions are not beans, therefore only way to obtain bean instances is programmatically via the `Arc.container()`. + +CAUTION: If you plan to use the `@PermissionsAllowed` on the IO thread, review the information in xref:security-proactive-authentication-concept.adoc[Proactive Authentication]. +NOTE: The `@PermissionsAllowed` is not repeatable on class-level due to limitations of Quarkus interceptors. +Please find well-argued explanation in the xref:cdi-reference.adoc#repeatable-interceptor-bindings[Repeatable interceptor bindings] section of the CDI reference. + +On CDI beans, it is also possible to take custom `java.security.Permission` much further. +Let's image that in your application, you have following service: + +[source,java] +---- +import io.quarkus.security.PermissionsAllowed; +import io.smallrye.mutiny.Uni; +import jakarta.enterprise.context.ApplicationScoped; + +import java.security.Permission; +import java.util.Objects; + +@ApplicationScoped +public class GreetingsService { + + @PermissionsAllowed(value = "ignored", permission = HelloPermission.class) <1> + public Uni greetings(int index, Object obj, String hello) { + return Uni.createFrom().item("Welcome!"); + } + + @PermissionsAllowed(value = "ignored", permission = HelloPermission.class, params = "greetings") <2> + public Uni greetings(String farewell, String greetings) { + return Uni.createFrom().item("Welcome!"); + } + + public static class HelloPermission extends Permission { + private final boolean pass; + + public HelloPermission(String name, String[] actions, String greetings) { <3> + super(name); + this.pass = "hello".equals(greetings); + } + + @Override + public boolean implies(Permission permission) { + return pass; + } + + ... + } + +} +---- +<1> Formal parameter `hello` is identified as the first `String` parameter and passed to `HelloPermission`. +However this option comes with a prize, as the `HelloPermission` must be instantiated every single time `greetings` method is invoked. +<2> Here, the first `String` parameter is `farewell`, therefore we marked `greetings` parameter explicitly via `PermissionsAllowed#params`. +Please note that both constructor and annotated method must have parameter `greetings`, otherwise validation will fail. +<3> There must be exactly one constructor of a custom `Permission` class, also first parameter is always considered a permission name (must be `String`). +Optionally, Quarkus may pass Permission actions to the constructor. Just declare the second parameter as `String[]`. + +CAUTION: Passing method parameters to a custom `Permission` constructor is not supported on RESTEasy Reactive endpoints, +because there, security checks are done before serialization. + +Currently, there is only one way to add permissions, and that is xref:security-customization.adoc#security-identity-customization[Security Identity Customization]. + +[source,java] +---- +import java.security.Permission; +import java.util.function.Function; + +import io.quarkus.security.StringPermission; +import jakarta.enterprise.context.ApplicationScoped; + +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 PermissionsAugmentor implements SecurityIdentityAugmentor { + + @Override + public Uni augment(SecurityIdentity identity, AuthenticationRequestContext context) { + if (identity.isAnonymous()) { + return Uni.createFrom().item(identity); + } + return Uni.createFrom().item(build(identity)); + } + + SecurityIdentity build(SecurityIdentity identity) { + QuarkusSecurityIdentity.Builder builder = QuarkusSecurityIdentity.builder(identity); + // grant permission 'read' to 'user' + if ("user".equals(identity.getPrincipal().getName())) { + builder.addPermissionChecker(createPermission("read")); <1> + } + return builder.build(); + } + + private Function> createPermission(String permissionName) { + return new Function>() { + @Override + public Uni apply(Permission requiredPermission) { + final var possessedPermission = new StringPermission(permissionName); + return Uni.createFrom().item(possessedPermission.implies(requiredPermission)); <2> + } + }; + } + +} +---- +<1> You can add a permission checks via `io.quarkus.security.runtime.QuarkusSecurityIdentity.Builder.addPermissionChecker`. +<2> The permission check will succeed only if incoming request posses permission `read`. + +CAUTION: Annotation permissions does not work with the custom xref:security-customization.adoc#jaxrs-security-context[JAX-RS SecurityContext], for there are no permission in `jakarta.ws.rs.core.SecurityContext`. + == References * xref:security-overview-concept.adoc[Quarkus Security overview] diff --git a/extensions/reactive-routes/deployment/src/main/java/io/quarkus/vertx/web/deployment/ReactiveRoutesProcessor.java b/extensions/reactive-routes/deployment/src/main/java/io/quarkus/vertx/web/deployment/ReactiveRoutesProcessor.java index 56787171c3742f..8094b8d5af31a1 100644 --- a/extensions/reactive-routes/deployment/src/main/java/io/quarkus/vertx/web/deployment/ReactiveRoutesProcessor.java +++ b/extensions/reactive-routes/deployment/src/main/java/io/quarkus/vertx/web/deployment/ReactiveRoutesProcessor.java @@ -97,6 +97,7 @@ import io.quarkus.runtime.TemplateHtmlBuilder; import io.quarkus.runtime.util.HashUtil; import io.quarkus.security.Authenticated; +import io.quarkus.security.PermissionsAllowed; import io.quarkus.vertx.http.deployment.FilterBuildItem; import io.quarkus.vertx.http.deployment.HttpRootPathBuildItem; import io.quarkus.vertx.http.deployment.RequireBodyHandlerBuildItem; @@ -141,6 +142,7 @@ class ReactiveRoutesProcessor { private static final DotName ROLES_ALLOWED = DotName.createSimple(RolesAllowed.class.getName()); private static final DotName AUTHENTICATED = DotName.createSimple(Authenticated.class.getName()); private static final DotName DENY_ALL = DotName.createSimple(DenyAll.class.getName()); + private static final DotName PERMISSIONS_ALLOWED = DotName.createSimple(PermissionsAllowed.class.getName()); private static final List PARAM_INJECTORS = initParamInjectors(); @@ -232,6 +234,7 @@ void validateBeanDeployment( && !returnTypeName.equals(DotNames.MULTI) && !returnTypeName.equals(DotNames.COMPLETION_STAGE); final boolean hasRbacAnnotationThatRequiresAuth = annotationStore.hasAnnotation(method, ROLES_ALLOWED) || annotationStore.hasAnnotation(method, AUTHENTICATED) + || annotationStore.hasAnnotation(method, PERMISSIONS_ALLOWED) || annotationStore.hasAnnotation(method, DENY_ALL); alwaysAuthenticateRoute = possiblySynchronousResponse && hasRbacAnnotationThatRequiresAuth; } else { diff --git a/extensions/resteasy-classic/resteasy/runtime/src/main/java/io/quarkus/resteasy/runtime/SecurityContextFilter.java b/extensions/resteasy-classic/resteasy/runtime/src/main/java/io/quarkus/resteasy/runtime/SecurityContextFilter.java index 50b9e743f7c243..d33d9faaaf7913 100644 --- a/extensions/resteasy-classic/resteasy/runtime/src/main/java/io/quarkus/resteasy/runtime/SecurityContextFilter.java +++ b/extensions/resteasy-classic/resteasy/runtime/src/main/java/io/quarkus/resteasy/runtime/SecurityContextFilter.java @@ -3,8 +3,10 @@ import java.io.IOException; import java.security.Permission; import java.security.Principal; +import java.util.List; import java.util.Map; import java.util.Set; +import java.util.function.Function; import jakarta.annotation.Priority; import jakarta.inject.Inject; @@ -90,6 +92,12 @@ public Map getAttributes() { return oldAttributes; } + @Override + public List>> getPermissionCheckers() { + throw new UnsupportedOperationException( + "retrieving all permission checkers not supported when JAX-RS security context has been replaced"); + } + @Override public Uni checkPermission(Permission permission) { return Uni.createFrom().nullItem(); diff --git a/extensions/resteasy-reactive/quarkus-resteasy-reactive/deployment/src/main/java/io/quarkus/resteasy/reactive/server/deployment/ResteasyReactiveProcessor.java b/extensions/resteasy-reactive/quarkus-resteasy-reactive/deployment/src/main/java/io/quarkus/resteasy/reactive/server/deployment/ResteasyReactiveProcessor.java index a3b5dc98d6f2d9..1ece502506e663 100644 --- a/extensions/resteasy-reactive/quarkus-resteasy-reactive/deployment/src/main/java/io/quarkus/resteasy/reactive/server/deployment/ResteasyReactiveProcessor.java +++ b/extensions/resteasy-reactive/quarkus-resteasy-reactive/deployment/src/main/java/io/quarkus/resteasy/reactive/server/deployment/ResteasyReactiveProcessor.java @@ -1509,7 +1509,8 @@ void registerSecurityInterceptors(Capabilities capabilities, // Register interceptors for standard security annotations to prevent repeated security checks beans.produce(new AdditionalBeanBuildItem(StandardSecurityCheckInterceptor.RolesAllowedInterceptor.class, StandardSecurityCheckInterceptor.AuthenticatedInterceptor.class, - StandardSecurityCheckInterceptor.PermitAllInterceptor.class)); + StandardSecurityCheckInterceptor.PermitAllInterceptor.class, + StandardSecurityCheckInterceptor.PermissionsAllowedInterceptor.class)); } } diff --git a/extensions/resteasy-reactive/quarkus-resteasy-reactive/deployment/src/main/java/io/quarkus/resteasy/reactive/server/deployment/SecurityTransformerUtils.java b/extensions/resteasy-reactive/quarkus-resteasy-reactive/deployment/src/main/java/io/quarkus/resteasy/reactive/server/deployment/SecurityTransformerUtils.java index df1fd4b448a320..4f1de827ac5a2a 100644 --- a/extensions/resteasy-reactive/quarkus-resteasy-reactive/deployment/src/main/java/io/quarkus/resteasy/reactive/server/deployment/SecurityTransformerUtils.java +++ b/extensions/resteasy-reactive/quarkus-resteasy-reactive/deployment/src/main/java/io/quarkus/resteasy/reactive/server/deployment/SecurityTransformerUtils.java @@ -15,6 +15,7 @@ import org.jboss.jandex.MethodInfo; import io.quarkus.security.Authenticated; +import io.quarkus.security.PermissionsAllowed; public class SecurityTransformerUtils { public static final Set SECURITY_BINDINGS = new HashSet<>(); @@ -22,6 +23,7 @@ public class SecurityTransformerUtils { static { // keep the contents the same as in io.quarkus.resteasy.deployment.SecurityTransformerUtils SECURITY_BINDINGS.add(DotName.createSimple(RolesAllowed.class.getName())); + SECURITY_BINDINGS.add(DotName.createSimple(PermissionsAllowed.class.getName())); SECURITY_BINDINGS.add(DotName.createSimple(Authenticated.class.getName())); SECURITY_BINDINGS.add(DotName.createSimple(DenyAll.class.getName())); SECURITY_BINDINGS.add(DotName.createSimple(PermitAll.class.getName())); @@ -32,7 +34,7 @@ public static boolean hasStandardSecurityAnnotation(MethodInfo methodInfo) { } public static boolean hasStandardSecurityAnnotation(ClassInfo classInfo) { - return hasStandardSecurityAnnotation(classInfo.classAnnotations()); + return hasStandardSecurityAnnotation(classInfo.declaredAnnotations()); } private static boolean hasStandardSecurityAnnotation(Collection instances) { @@ -49,7 +51,7 @@ public static Optional findFirstStandardSecurityAnnotation(M } public static Optional findFirstStandardSecurityAnnotation(ClassInfo classInfo) { - return findFirstStandardSecurityAnnotation(classInfo.classAnnotations()); + return findFirstStandardSecurityAnnotation(classInfo.declaredAnnotations()); } private static Optional findFirstStandardSecurityAnnotation(Collection instances) { diff --git a/extensions/resteasy-reactive/quarkus-resteasy-reactive/deployment/src/test/java/io/quarkus/resteasy/reactive/server/test/security/AbstractPermissionsAllowedTestCase.java b/extensions/resteasy-reactive/quarkus-resteasy-reactive/deployment/src/test/java/io/quarkus/resteasy/reactive/server/test/security/AbstractPermissionsAllowedTestCase.java new file mode 100644 index 00000000000000..b8c08362bb1080 --- /dev/null +++ b/extensions/resteasy-reactive/quarkus-resteasy-reactive/deployment/src/test/java/io/quarkus/resteasy/reactive/server/test/security/AbstractPermissionsAllowedTestCase.java @@ -0,0 +1,129 @@ +package io.quarkus.resteasy.reactive.server.test.security; + +import org.hamcrest.Matchers; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +import io.quarkus.security.StringPermission; +import io.quarkus.security.test.utils.TestIdentityController; +import io.restassured.RestAssured; + +public abstract class AbstractPermissionsAllowedTestCase { + + @BeforeAll + public static void setupUsers() { + TestIdentityController.resetRoles() + .add("admin", "admin", new StringPermission("read", "resource-admin"), new StringPermission("create"), + new StringPermission("update"), new CustomPermission("ignored")) + .add("user", "user", new StringPermission("read", "resource-admin"), new StringPermission("get-identity"), + new StringPermission("update")) + .add("viewer", "viewer", new StringPermission("read", "resource-viewer")); + } + + @Test + public void testStringPermission2RequiredPermissions() { + // invokes POST /permissions endpoint that requires 2 permissions: create AND update + + // admin must have both 'create' and 'update' in order to succeed + RestAssured.given().auth().basic("admin", "admin").post("/permissions").then().statusCode(200) + .body(Matchers.equalTo("done")); + + // user has only 'update', therefore should fail + RestAssured.given().auth().basic("user", "user").post("/permissions").then().statusCode(403); + } + + @Test + public void testStringPermission2RequiredPermissionsNonBlocking() { + // invokes POST /permissions-non-blocking endpoint that requires 2 permissions: create AND update + + // admin must have both 'create' and 'update' in order to succeed + RestAssured.given().auth().basic("admin", "admin").post("/permissions-non-blocking").then().statusCode(200) + .body(Matchers.equalTo("done")); + + // user has only 'update', therefore should fail + RestAssured.given().auth().basic("user", "user").post("/permissions-non-blocking").then().statusCode(403); + } + + @Test + public void testStringPermissionOneOfPermissionsAndActions() { + // invokes GET /permissions/admin endpoint that requires one of 2 permissions: read:resource-admin, read:resource-user + + // admin has 'read:resource-admin', therefore succeeds + RestAssured.given().auth().basic("admin", "admin").get("/permissions/admin").then().statusCode(200) + .body(Matchers.equalTo("admin")); + + // user has 'read:resource-user', therefore succeeds + RestAssured.given().auth().basic("user", "user").get("/permissions/admin").then().statusCode(200) + .body(Matchers.equalTo("admin")); + + // viewer has 'read:resource-viewer', therefore fails + RestAssured.given().auth().basic("viewer", "viewer").get("/permissions/admin").then().statusCode(403); + } + + @Test + public void testStringPermissionOneOfPermissionsAndActionsNonBlocking() { + // invokes GET /permissions-non-blocking/admin endpoint that requires one of 2 permissions: read:resource-admin, read:resource-user + + // admin has 'read:resource-admin', therefore succeeds + RestAssured.given().auth().basic("admin", "admin").get("/permissions-non-blocking/admin").then().statusCode(200) + .body(Matchers.equalTo("admin")); + + // user has 'read:resource-user', therefore succeeds + RestAssured.given().auth().basic("user", "user").get("/permissions-non-blocking/admin").then().statusCode(200) + .body(Matchers.equalTo("admin")); + + // viewer has 'read:resource-viewer', therefore fails + RestAssured.given().auth().basic("viewer", "viewer").get("/permissions-non-blocking/admin").then().statusCode(403); + } + + @Test + public void testBlockingAccessToIdentityOnIOThread() { + // invokes GET /permissions/security-identity endpoint that requires one permission: get-identity + + // - blocking path + + // user has 'get-identity, therefore succeeds + RestAssured.given().auth().basic("user", "user").get("/permissions/admin/security-identity").then().statusCode(200) + .body(Matchers.equalTo("user")); + + // admin lack 'get-identity', therefore fails + RestAssured.given().auth().basic("admin", "admin").get("/permissions/admin/security-identity").then().statusCode(403); + + // - non-blocking path + + // user has 'get-identity, therefore succeeds + RestAssured.given().auth().basic("user", "user").get("/permissions-non-blocking/admin/security-identity").then() + .statusCode(200) + .body(Matchers.equalTo("user")); + + // admin lack 'get-identity', therefore fails + RestAssured.given().auth().basic("admin", "admin").get("/permissions-non-blocking/admin/security-identity").then() + .statusCode(403); + } + + @Test + public void testCustomPermissionNonBlocking() { + // invokes GET /permissions/custom-permission endpoint that requires query param 'hello' + + // we send 'hello' => pass + RestAssured.given().auth().basic("admin", "admin").param("greeting", "hello") + .get("/permissions-non-blocking/custom-permission").then().statusCode(200).body(Matchers.equalTo("hello")); + + // we send 'hi' => fail + RestAssured.given().auth().basic("admin", "admin").param("greeting", "hi") + .get("/permissions-non-blocking/custom-permission").then().statusCode(403); + } + + @Test + public void testCustomPermission() { + // invokes GET /permissions/custom-permission endpoint that requires query param 'hello' + + // we send 'hello' => pass + RestAssured.given().auth().basic("admin", "admin").param("greeting", "hello") + .get("/permissions/custom-permission").then().statusCode(200).body(Matchers.equalTo("hello")); + + // we send 'hi' => pass + RestAssured.given().auth().basic("admin", "admin").param("greeting", "hi") + .get("/permissions/custom-permission").then().statusCode(403); + } +} diff --git a/extensions/resteasy-reactive/quarkus-resteasy-reactive/deployment/src/test/java/io/quarkus/resteasy/reactive/server/test/security/CustomPermission.java b/extensions/resteasy-reactive/quarkus-resteasy-reactive/deployment/src/test/java/io/quarkus/resteasy/reactive/server/test/security/CustomPermission.java new file mode 100644 index 00000000000000..d02eb893a0fd26 --- /dev/null +++ b/extensions/resteasy-reactive/quarkus-resteasy-reactive/deployment/src/test/java/io/quarkus/resteasy/reactive/server/test/security/CustomPermission.java @@ -0,0 +1,34 @@ +package io.quarkus.resteasy.reactive.server.test.security; + +import java.security.Permission; + +import io.quarkus.arc.Arc; +import io.vertx.ext.web.RoutingContext; + +public class CustomPermission extends Permission { + + public CustomPermission(String name) { + super(name); + } + + @Override + public boolean implies(Permission permission) { + var event = Arc.container().instance(RoutingContext.class).get(); + return "hello".equals(event.request().params().get("greeting")); + } + + @Override + public boolean equals(Object obj) { + return false; + } + + @Override + public int hashCode() { + return 0; + } + + @Override + public String getActions() { + return null; + } +} diff --git a/extensions/resteasy-reactive/quarkus-resteasy-reactive/deployment/src/test/java/io/quarkus/resteasy/reactive/server/test/security/LazyAuthPermissionsAllowedTestCase.java b/extensions/resteasy-reactive/quarkus-resteasy-reactive/deployment/src/test/java/io/quarkus/resteasy/reactive/server/test/security/LazyAuthPermissionsAllowedTestCase.java new file mode 100644 index 00000000000000..bdbc3e01c7bd16 --- /dev/null +++ b/extensions/resteasy-reactive/quarkus-resteasy-reactive/deployment/src/test/java/io/quarkus/resteasy/reactive/server/test/security/LazyAuthPermissionsAllowedTestCase.java @@ -0,0 +1,20 @@ +package io.quarkus.resteasy.reactive.server.test.security; + +import org.jboss.shrinkwrap.api.asset.StringAsset; +import org.junit.jupiter.api.extension.RegisterExtension; + +import io.quarkus.security.test.utils.TestIdentityController; +import io.quarkus.security.test.utils.TestIdentityProvider; +import io.quarkus.test.QuarkusUnitTest; + +public class LazyAuthPermissionsAllowedTestCase extends AbstractPermissionsAllowedTestCase { + + @RegisterExtension + static QuarkusUnitTest runner = new QuarkusUnitTest() + .withApplicationRoot((jar) -> jar + .addClasses(PermissionsAllowedResource.class, TestIdentityProvider.class, TestIdentityController.class, + NonBlockingPermissionsAllowedResource.class, CustomPermission.class) + .addAsResource(new StringAsset("quarkus.http.auth.proactive=false\n"), + "application.properties")); + +} diff --git a/extensions/resteasy-reactive/quarkus-resteasy-reactive/deployment/src/test/java/io/quarkus/resteasy/reactive/server/test/security/NonBlockingPermissionsAllowedResource.java b/extensions/resteasy-reactive/quarkus-resteasy-reactive/deployment/src/test/java/io/quarkus/resteasy/reactive/server/test/security/NonBlockingPermissionsAllowedResource.java new file mode 100644 index 00000000000000..220931072e9892 --- /dev/null +++ b/extensions/resteasy-reactive/quarkus-resteasy-reactive/deployment/src/test/java/io/quarkus/resteasy/reactive/server/test/security/NonBlockingPermissionsAllowedResource.java @@ -0,0 +1,48 @@ +package io.quarkus.resteasy.reactive.server.test.security; + +import jakarta.annotation.security.PermitAll; +import jakarta.inject.Inject; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.POST; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.QueryParam; + +import io.quarkus.security.PermissionsAllowed; +import io.quarkus.security.identity.CurrentIdentityAssociation; +import io.smallrye.mutiny.Uni; + +@Path("/permissions-non-blocking") +@PermitAll +public class NonBlockingPermissionsAllowedResource { + + @Inject + CurrentIdentityAssociation currentIdentityAssociation; + + @POST + @PermissionsAllowed("create") + @PermissionsAllowed("update") + public Uni createOrUpdate() { + return Uni.createFrom().item("done"); + } + + @Path("/admin") + @PermissionsAllowed({ "read:resource-admin", "read:resource-user" }) + @GET + public Uni admin() { + return Uni.createFrom().item("admin"); + } + + @Path("/admin/security-identity") + @PermissionsAllowed("get-identity") + @GET + public Uni getSecurityIdentity() { + return Uni.createFrom().item(currentIdentityAssociation.getIdentity().getPrincipal().getName()); + } + + @PermissionsAllowed(value = "perm1", permission = CustomPermission.class) + @Path("/custom-permission") + @GET + public Uni greetings(@QueryParam("greeting") String greeting) { + return Uni.createFrom().item(greeting); + } +} diff --git a/extensions/resteasy-reactive/quarkus-resteasy-reactive/deployment/src/test/java/io/quarkus/resteasy/reactive/server/test/security/PermissionsAllowedResource.java b/extensions/resteasy-reactive/quarkus-resteasy-reactive/deployment/src/test/java/io/quarkus/resteasy/reactive/server/test/security/PermissionsAllowedResource.java new file mode 100644 index 00000000000000..41c58363031894 --- /dev/null +++ b/extensions/resteasy-reactive/quarkus-resteasy-reactive/deployment/src/test/java/io/quarkus/resteasy/reactive/server/test/security/PermissionsAllowedResource.java @@ -0,0 +1,48 @@ +package io.quarkus.resteasy.reactive.server.test.security; + +import jakarta.inject.Inject; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.POST; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.QueryParam; + +import io.quarkus.security.PermissionsAllowed; +import io.quarkus.security.identity.CurrentIdentityAssociation; +import io.smallrye.common.annotation.NonBlocking; + +@Path("/permissions") +public class PermissionsAllowedResource { + + @Inject + CurrentIdentityAssociation currentIdentityAssociation; + + @POST + @PermissionsAllowed("create") + @PermissionsAllowed("update") + public String createOrUpdate() { + return "done"; + } + + @Path("/admin") + @PermissionsAllowed("read:resource-admin") + @GET + public String admin() { + return "admin"; + } + + @NonBlocking + @Path("/admin/security-identity") + @PermissionsAllowed("get-identity") + @GET + public String getSecurityIdentity() { + return currentIdentityAssociation.getIdentity().getPrincipal().getName(); + } + + @PermissionsAllowed(value = "perm1", permission = CustomPermission.class) + @Path("/custom-permission") + @GET + public String greetings(@QueryParam("greeting") String greeting) { + return greeting; + } + +} diff --git a/extensions/resteasy-reactive/quarkus-resteasy-reactive/deployment/src/test/java/io/quarkus/resteasy/reactive/server/test/security/ProactiveAuthPermissionsAllowedTestCase.java b/extensions/resteasy-reactive/quarkus-resteasy-reactive/deployment/src/test/java/io/quarkus/resteasy/reactive/server/test/security/ProactiveAuthPermissionsAllowedTestCase.java new file mode 100644 index 00000000000000..6fdb60c20d4814 --- /dev/null +++ b/extensions/resteasy-reactive/quarkus-resteasy-reactive/deployment/src/test/java/io/quarkus/resteasy/reactive/server/test/security/ProactiveAuthPermissionsAllowedTestCase.java @@ -0,0 +1,17 @@ +package io.quarkus.resteasy.reactive.server.test.security; + +import org.junit.jupiter.api.extension.RegisterExtension; + +import io.quarkus.security.test.utils.TestIdentityController; +import io.quarkus.security.test.utils.TestIdentityProvider; +import io.quarkus.test.QuarkusUnitTest; + +public class ProactiveAuthPermissionsAllowedTestCase extends AbstractPermissionsAllowedTestCase { + + @RegisterExtension + static QuarkusUnitTest runner = new QuarkusUnitTest() + .withApplicationRoot((jar) -> jar + .addClasses(PermissionsAllowedResource.class, TestIdentityProvider.class, TestIdentityController.class, + NonBlockingPermissionsAllowedResource.class, CustomPermission.class)); + +} diff --git a/extensions/resteasy-reactive/quarkus-resteasy-reactive/runtime/src/main/java/io/quarkus/resteasy/reactive/server/runtime/StandardSecurityCheckInterceptor.java b/extensions/resteasy-reactive/quarkus-resteasy-reactive/runtime/src/main/java/io/quarkus/resteasy/reactive/server/runtime/StandardSecurityCheckInterceptor.java index cd42e556e31662..9f86466ac25d7e 100644 --- a/extensions/resteasy-reactive/quarkus-resteasy-reactive/runtime/src/main/java/io/quarkus/resteasy/reactive/server/runtime/StandardSecurityCheckInterceptor.java +++ b/extensions/resteasy-reactive/quarkus-resteasy-reactive/runtime/src/main/java/io/quarkus/resteasy/reactive/server/runtime/StandardSecurityCheckInterceptor.java @@ -17,6 +17,7 @@ import org.jboss.resteasy.reactive.server.core.CurrentRequestManager; import io.quarkus.security.Authenticated; +import io.quarkus.security.PermissionsAllowed; import io.quarkus.security.spi.runtime.AuthorizationController; import io.quarkus.security.spi.runtime.MethodDescription; @@ -58,6 +59,16 @@ public static final class RolesAllowedInterceptor extends StandardSecurityCheckI } + /** + * Prevent the SecurityHandler from performing {@link io.quarkus.security.PermissionsAllowed} security checks + */ + @Interceptor + @PermissionsAllowed("") + @Priority(Interceptor.Priority.PLATFORM_BEFORE) + public static final class PermissionsAllowedInterceptor extends StandardSecurityCheckInterceptor { + + } + /** * Prevent the SecurityHandler from performing {@link jakarta.annotation.security.PermitAll} security checks */ diff --git a/extensions/resteasy-reactive/quarkus-resteasy-reactive/runtime/src/main/java/io/quarkus/resteasy/reactive/server/runtime/security/EagerSecurityHandler.java b/extensions/resteasy-reactive/quarkus-resteasy-reactive/runtime/src/main/java/io/quarkus/resteasy/reactive/server/runtime/security/EagerSecurityHandler.java index 2da1c299374659..39df69163b7ca4 100644 --- a/extensions/resteasy-reactive/quarkus-resteasy-reactive/runtime/src/main/java/io/quarkus/resteasy/reactive/server/runtime/security/EagerSecurityHandler.java +++ b/extensions/resteasy-reactive/quarkus-resteasy-reactive/runtime/src/main/java/io/quarkus/resteasy/reactive/server/runtime/security/EagerSecurityHandler.java @@ -94,13 +94,11 @@ public void handle(ResteasyReactiveRequestContext requestContext) throws Excepti }); } - deferredIdentity.map(new Function() { + deferredIdentity.flatMap(new Function>() { @Override - public Object apply(SecurityIdentity securityIdentity) { - theCheck.apply(securityIdentity, methodDescription, - requestContext.getParameters()); + public Uni apply(SecurityIdentity securityIdentity) { preventRepeatedSecurityChecks(requestContext, methodDescription); - return null; + return theCheck.nonBlockingApply(securityIdentity, methodDescription, requestContext.getParameters()); } }) .subscribe().withSubscriber(new UniSubscriber() { diff --git a/extensions/resteasy-reactive/quarkus-resteasy-reactive/runtime/src/main/java/io/quarkus/resteasy/reactive/server/runtime/security/SecurityContextOverrideHandler.java b/extensions/resteasy-reactive/quarkus-resteasy-reactive/runtime/src/main/java/io/quarkus/resteasy/reactive/server/runtime/security/SecurityContextOverrideHandler.java index 64e04f1eb5c920..8e5615856fe4f9 100644 --- a/extensions/resteasy-reactive/quarkus-resteasy-reactive/runtime/src/main/java/io/quarkus/resteasy/reactive/server/runtime/security/SecurityContextOverrideHandler.java +++ b/extensions/resteasy-reactive/quarkus-resteasy-reactive/runtime/src/main/java/io/quarkus/resteasy/reactive/server/runtime/security/SecurityContextOverrideHandler.java @@ -102,6 +102,12 @@ public Map getAttributes() { return oldAttributes; } + @Override + public List>> getPermissionCheckers() { + throw new UnsupportedOperationException( + "retrieving all permission checkers not supported when JAX-RS security context has been replaced"); + } + @Override public Uni checkPermission(Permission permission) { return Uni.createFrom().nullItem(); diff --git a/extensions/security/deployment/src/main/java/io/quarkus/security/deployment/DotNames.java b/extensions/security/deployment/src/main/java/io/quarkus/security/deployment/DotNames.java index 84936f9cd17462..620ab6a018e149 100644 --- a/extensions/security/deployment/src/main/java/io/quarkus/security/deployment/DotNames.java +++ b/extensions/security/deployment/src/main/java/io/quarkus/security/deployment/DotNames.java @@ -7,11 +7,13 @@ import org.jboss.jandex.DotName; import io.quarkus.security.Authenticated; +import io.quarkus.security.PermissionsAllowed; public final class DotNames { public static final DotName ROLES_ALLOWED = DotName.createSimple(RolesAllowed.class.getName()); public static final DotName AUTHENTICATED = DotName.createSimple(Authenticated.class.getName()); + public static final DotName PERMISSIONS_ALLOWED = DotName.createSimple(PermissionsAllowed.class.getName()); public static final DotName DENY_ALL = DotName.createSimple(DenyAll.class.getName()); public static final DotName PERMIT_ALL = DotName.createSimple(PermitAll.class.getName()); 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 new file mode 100644 index 00000000000000..34505be1b82e7e --- /dev/null +++ b/extensions/security/deployment/src/main/java/io/quarkus/security/deployment/PermissionSecurityChecks.java @@ -0,0 +1,697 @@ +package io.quarkus.security.deployment; + +import static io.quarkus.arc.processor.DotNames.STRING; +import static io.quarkus.security.PermissionsAllowed.AUTODETECTED; +import static io.quarkus.security.PermissionsAllowed.PERMISSION_TO_ACTION_SEPARATOR; +import static io.quarkus.security.deployment.SecurityProcessor.isPublicNonStaticNonConstructor; + +import java.security.Permission; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Comparator; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.function.Function; + +import org.jboss.jandex.AnnotationInstance; +import org.jboss.jandex.AnnotationTarget; +import org.jboss.jandex.ClassInfo; +import org.jboss.jandex.DotName; +import org.jboss.jandex.IndexView; +import org.jboss.jandex.MethodInfo; +import org.jboss.jandex.Type; + +import io.quarkus.runtime.RuntimeValue; +import io.quarkus.security.PermissionsAllowed; +import io.quarkus.security.StringPermission; +import io.quarkus.security.runtime.SecurityCheckRecorder; +import io.quarkus.security.runtime.interceptor.PermissionsAllowedInterceptor; +import io.quarkus.security.spi.runtime.SecurityCheck; + +interface PermissionSecurityChecks { + + Map get(); + + Set permissionClasses(); + + final class PermissionSecurityChecksBuilder { + + private static final DotName STRING_PERMISSION = DotName.createSimple(StringPermission.class); + private static final DotName PERMISSIONS_ALLOWED_INTERCEPTOR = DotName + .createSimple(PermissionsAllowedInterceptor.class); + private final Map>> methodToPermissionKeys = new HashMap<>(); + private final Map methodToPredicate = new HashMap<>(); + private final Map classSignatureToConstructor = new HashMap<>(); + private final SecurityCheckRecorder recorder; + + public PermissionSecurityChecksBuilder(SecurityCheckRecorder recorder) { + this.recorder = recorder; + } + + PermissionSecurityChecks build() { + return new PermissionSecurityChecks() { + @Override + public Map get() { + final Map cache = new HashMap<>(); + final Map methodToCheck = new HashMap<>(); + for (var methodToPredicate : methodToPredicate.entrySet()) { + SecurityCheck check = cache.computeIfAbsent(methodToPredicate.getValue(), + new Function() { + @Override + public SecurityCheck apply(LogicalAndPermissionPredicate predicate) { + return createSecurityCheck(predicate); + } + }); + methodToCheck.put(methodToPredicate.getKey(), check); + } + return methodToCheck; + } + + @Override + public Set permissionClasses() { + return classSignatureToConstructor.keySet(); + } + }; + } + + /** + * Creates predicate for each secured method. Predicates are cached if possible. + * What we call predicate here is combination of (possibly computed) {@link Permission}s joined with + * logical operators 'AND' or 'OR'. + * + * For example, combination of following 2 annotation instances: + * + *
+         * @PermissionsAllowed({"createResource", "createAll"})
+         * @PermissionsAllowed({"updateResource", "updateAll"})
+         * public void createOrUpdate() {
+         *      ...
+         * }
+         * 
+ * + * leads to (pseudocode): (createResource OR createAll) AND (updateResource OR updateAll) + * + * @return PermissionSecurityChecksBuilder + */ + PermissionSecurityChecksBuilder createPermissionPredicates() { + Map permissionCache = new HashMap<>(); + for (Map.Entry>> entry : methodToPermissionKeys.entrySet()) { + final MethodInfo securedMethod = entry.getKey(); + final LogicalAndPermissionPredicate predicate = new LogicalAndPermissionPredicate(); + + // 'AND' operands + for (List permissionKeys : entry.getValue()) { + var orPredicate = new LogicalOrPermissionPredicate(); + predicate.and(orPredicate); + + // 'OR' operands + for (PermissionKey permissionKey : permissionKeys) { + var permission = createPermission(permissionKey, securedMethod, permissionCache); + if (permission.isComputed()) { + predicate.markAsComputed(); + } + orPredicate.or(permission); + } + } + methodToPredicate.put(securedMethod, predicate); + } + return this; + } + + PermissionSecurityChecksBuilder validatePermissionClasses(IndexView index) { + for (List> keyLists : methodToPermissionKeys.values()) { + for (List keyList : keyLists) { + for (PermissionKey key : keyList) { + if (!classSignatureToConstructor.containsKey(key.classSignature())) { + + // validate permission class + final ClassInfo clazz = index.getClassByName(key.clazz.name()); + Objects.requireNonNull(clazz); + if (clazz.constructors().size() != 1) { + throw new RuntimeException( + String.format("Permission class '%s' has %d constructors, exactly one is allowed", + key.classSignature(), clazz.constructors().size())); + } + var constructor = clazz.constructors().get(0); + // first constructor parameter must be permission name + if (constructor.parametersCount() == 0 || !STRING.equals(constructor.parameterType(0).name())) { + throw new RuntimeException( + String.format("Permission constructor '%s' first argument must be '%s'", + clazz.name().toString(), String.class.getName())); + } + // rest of validation needs to be done for computed classes only and per each secured method + // therefore we do it later + + // cache validation result + classSignatureToConstructor.put(key.classSignature(), constructor); + } + } + } + } + return this; + } + + PermissionSecurityChecksBuilder gatherPermissionsAllowedAnnotations(List instances, + Map alreadyCheckedMethods, + Map alreadyCheckedClasses) { + + // make sure we process annotations on methods first + instances.sort(new Comparator() { + @Override + public int compare(AnnotationInstance o1, AnnotationInstance o2) { + if (o1.target().kind() != o2.target().kind()) { + return o1.target().kind() == AnnotationTarget.Kind.METHOD ? -1 : 1; + } + // variable 'instances' won't be modified + return 0; + } + }); + + List cache = new ArrayList<>(); + Map>> classMethodToPermissionKeys = new HashMap<>(); + for (AnnotationInstance instance : instances) { + + AnnotationTarget target = instance.target(); + if (target.kind() == AnnotationTarget.Kind.METHOD) { + // method annotation + final MethodInfo methodInfo = target.asMethod(); + + // we don't allow combining @PermissionsAllowed with other security annotations as @DenyAll, ... + if (alreadyCheckedMethods.containsKey(methodInfo)) { + throw new IllegalStateException( + String.format("Method %s of class %s is annotated with multiple security annotations", + methodInfo.name(), methodInfo.declaringClass())); + } + + gatherPermissionKeys(instance, methodInfo, cache, methodToPermissionKeys); + } else { + // class annotation + + // add permissions for the class annotation if respective method haven't already been annotated + if (target.kind() == AnnotationTarget.Kind.CLASS) { + final ClassInfo clazz = target.asClass(); + + // ignore PermissionsAllowedInterceptor in security module + // we also need to check string as long as duplicate "PermissionsAllowedInterceptor" exists + // in RESTEasy Reactive, however this workaround should be removed when the interceptor is dropped + if (PERMISSIONS_ALLOWED_INTERCEPTOR.equals(clazz.name()) + || clazz.name().toString().endsWith("PermissionsAllowedInterceptor")) { + continue; + } + + // check that class wasn't annotated with other security annotation + final AnnotationInstance existingClassInstance = alreadyCheckedClasses.get(clazz); + if (existingClassInstance == null) { + for (MethodInfo methodInfo : clazz.methods()) { + + if (!isPublicNonStaticNonConstructor(methodInfo)) { + continue; + } + + // ignore method annotated with other security annotation + boolean noMethodLevelSecurityAnnotation = !alreadyCheckedMethods.containsKey(methodInfo); + // ignore method annotated with method-level @PermissionsAllowed + boolean noMethodLevelPermissionsAllowed = !methodToPermissionKeys.containsKey(methodInfo); + if (noMethodLevelSecurityAnnotation && noMethodLevelPermissionsAllowed) { + + gatherPermissionKeys(instance, methodInfo, cache, classMethodToPermissionKeys); + } + } + } else { + + // we do not allow combining @PermissionsAllowed with other security annotations as @Authenticated + throw new IllegalStateException( + String.format("Class %s is annotated with multiple security annotations %s and %s", clazz, + instance.name(), existingClassInstance.name())); + } + } + } + } + methodToPermissionKeys.putAll(classMethodToPermissionKeys); + return this; + } + + private static void gatherPermissionKeys(AnnotationInstance instance, MethodInfo methodInfo, List cache, + Map>> methodToPermissionKeys) { + // @PermissionsAllowed value is in format permission:action, permission2:action, permission:action2, permission3 + // here we transform it to permission -> actions + final var permissionToActions = new HashMap>(); + for (String permissionToAction : instance.value().asStringArray()) { + if (permissionToAction.contains(PERMISSION_TO_ACTION_SEPARATOR)) { + + // expected format: permission:action + final String[] permissionToActionArr = permissionToAction.split(PERMISSION_TO_ACTION_SEPARATOR); + if (permissionToActionArr.length != 2) { + throw new RuntimeException(String.format( + "PermissionsAllowed value '%s' contains more than one separator '%2$s', expected format is 'permissionName%2$saction'", + permissionToAction, PERMISSION_TO_ACTION_SEPARATOR)); + } + final String permissionName = permissionToActionArr[0]; + final String action = permissionToActionArr[1]; + if (permissionToActions.containsKey(permissionName)) { + permissionToActions.get(permissionName).add(action); + } else { + final Set actions = new HashSet<>(); + actions.add(action); + permissionToActions.put(permissionName, actions); + } + } else { + + // expected format: permission + if (!permissionToActions.containsKey(permissionToAction)) { + permissionToActions.put(permissionToAction, new HashSet<>()); + } + } + } + + if (permissionToActions.isEmpty()) { + throw new RuntimeException(String.format( + "Method '%s' was annotated with '@PermissionsAllowed', but no valid permission was provided", + methodInfo.name())); + } + + // permissions specified via @PermissionsAllowed has 'one of' relation, therefore we put them in one list + final List orPermissions = new ArrayList<>(); + final String[] params = instance.value("params") == null ? new String[] { PermissionsAllowed.AUTODETECTED } + : instance.value("params").asStringArray(); + final Type classType = instance.value("permission") == null ? Type.create(STRING_PERMISSION, Type.Kind.CLASS) + : instance.value("permission").asClass(); + for (var permissionToAction : permissionToActions.entrySet()) { + final var key = new PermissionKey(permissionToAction.getKey(), permissionToAction.getValue(), params, + classType); + final int i = cache.indexOf(key); + if (i == -1) { + orPermissions.add(key); + cache.add(key); + } else { + orPermissions.add(cache.get(i)); + } + } + + // store annotation value as permission keys + methodToPermissionKeys + .computeIfAbsent(methodInfo, new Function>>() { + @Override + public List> apply(MethodInfo methodInfo) { + return new ArrayList<>(); + } + }) + .add(List.copyOf(orPermissions)); + } + + private SecurityCheck createSecurityCheck(LogicalAndPermissionPredicate andPredicate) { + final SecurityCheck securityCheck; + final boolean isSinglePermissionGroup = andPredicate.operands.size() == 1; + if (isSinglePermissionGroup) { + + final LogicalOrPermissionPredicate orPredicate = andPredicate.operands.iterator().next(); + final boolean isSinglePermission = orPredicate.operands.size() == 1; + if (isSinglePermission) { + + // single permission + final PermissionWrapper permissionWrapper = orPredicate.operands.iterator().next(); + securityCheck = recorder.permissionsAllowed(permissionWrapper.computedPermission, + permissionWrapper.permission); + } else { + + // multiple OR operands (permission OR permission OR ...) + if (andPredicate.atLeastOnePermissionIsComputed) { + securityCheck = recorder.permissionsAllowed(orPredicate.asComputedPermissions(recorder), null); + } else { + securityCheck = recorder.permissionsAllowed(null, orPredicate.asPermissions()); + } + } + } else { + + // permission group AND permission group AND permission group AND ... + // permission group = (permission OR permission OR permission OR ...) + if (andPredicate.atLeastOnePermissionIsComputed) { + final List>> computedPermissionGroups = new ArrayList<>(); + for (LogicalOrPermissionPredicate permissionGroup : andPredicate.operands) { + computedPermissionGroups.add(permissionGroup.asComputedPermissions(recorder)); + } + securityCheck = recorder.permissionsAllowedGroups(computedPermissionGroups, null); + } else { + final List>> permissionGroups = new ArrayList<>(); + for (LogicalOrPermissionPredicate permissionGroup : andPredicate.operands) { + permissionGroups.add(permissionGroup.asPermissions()); + } + securityCheck = recorder.permissionsAllowedGroups(null, permissionGroups); + } + } + + return securityCheck; + } + + private PermissionWrapper createPermission(PermissionKey permissionKey, MethodInfo securedMethod, + Map cache) { + var constructor = classSignatureToConstructor.get(permissionKey.classSignature()); + return cache.computeIfAbsent(new PermissionCacheKey(permissionKey, securedMethod, constructor), + new Function() { + @Override + public PermissionWrapper apply(PermissionCacheKey permissionCacheKey) { + if (permissionCacheKey.computed) { + return new PermissionWrapper(createComputedPermission(permissionCacheKey), null); + } else { + final RuntimeValue permission; + if (permissionCacheKey.isStringPermission()) { + permission = createStringPermission(permissionCacheKey.permissionKey); + } else { + permission = createCustomPermission(permissionCacheKey); + } + return new PermissionWrapper(null, permission); + } + } + }); + } + + private Function createComputedPermission(PermissionCacheKey permissionCacheKey) { + return recorder.createComputedPermission(permissionCacheKey.permissionKey.name, + permissionCacheKey.permissionKey.classSignature(), permissionCacheKey.permissionKey.actions(), + permissionCacheKey.passActionsToConstructor, permissionCacheKey.methodParamIndexes()); + } + + private RuntimeValue createCustomPermission(PermissionCacheKey permissionCacheKey) { + return recorder.createPermission(permissionCacheKey.permissionKey.name, + permissionCacheKey.permissionKey.classSignature(), permissionCacheKey.permissionKey.actions(), + permissionCacheKey.passActionsToConstructor); + } + + private RuntimeValue createStringPermission(PermissionKey permissionKey) { + if (permissionKey.notAutodetectParams()) { + // validate - no point to specify params as string permission only accept name and actions + throw new IllegalArgumentException(String.format("'%s' must have autodetected params", STRING_PERMISSION)); + } + return recorder.createStringPermission(permissionKey.name, permissionKey.actions()); + } + + private static final class LogicalOrPermissionPredicate { + private final Set operands = new HashSet<>(); + + private void or(PermissionWrapper permission) { + operands.add(permission); + } + + private List> asComputedPermissions(SecurityCheckRecorder recorder) { + final List> computedPermissions = new ArrayList<>(); + for (PermissionWrapper wrapper : operands) { + if (wrapper.isComputed()) { + computedPermissions.add(wrapper.computedPermission); + } else { + // make permission computed for we can't combine computed and plain permissions (to keep things simple) + computedPermissions.add(recorder.toComputedPermission(wrapper.permission)); + } + } + return List.copyOf(computedPermissions); + } + + private List> asPermissions() { + final List> permissions = new ArrayList<>(); + for (PermissionWrapper wrapper : operands) { + Objects.requireNonNull(wrapper.permission); + permissions.add(wrapper.permission); + } + return List.copyOf(permissions); + } + + @Override + public boolean equals(Object o) { + if (this == o) + return true; + if (o == null || getClass() != o.getClass()) + return false; + LogicalOrPermissionPredicate that = (LogicalOrPermissionPredicate) o; + return operands.equals(that.operands); + } + + @Override + public int hashCode() { + return Objects.hash(operands); + } + } + + private static final class LogicalAndPermissionPredicate { + private final Set operands = new HashSet<>(); + private boolean atLeastOnePermissionIsComputed = false; + + private void and(LogicalOrPermissionPredicate orPermissionPredicate) { + operands.add(orPermissionPredicate); + } + + private void markAsComputed() { + if (!atLeastOnePermissionIsComputed) { + atLeastOnePermissionIsComputed = true; + } + } + + @Override + public boolean equals(Object o) { + if (this == o) + return true; + if (o == null || getClass() != o.getClass()) + return false; + LogicalAndPermissionPredicate that = (LogicalAndPermissionPredicate) o; + return operands.equals(that.operands); + } + + @Override + public int hashCode() { + return Objects.hash(operands); + } + } + + private static final class PermissionWrapper { + + private final Function computedPermission; + private final RuntimeValue permission; + + private PermissionWrapper(Function computedPermission, RuntimeValue permission) { + this.computedPermission = computedPermission; + this.permission = permission; + } + + private boolean isComputed() { + return permission == null; + } + } + + private static final class PermissionKey { + + private final String name; + private final Set actions; + private final String[] params; + private final Type clazz; + + private PermissionKey(String name, Set actions, String[] params, Type clazz) { + this.name = name; + this.clazz = clazz; + if (!actions.isEmpty()) { + this.actions = actions; + } else { + this.actions = null; + } + this.params = params; + } + + private String classSignature() { + return clazz.name().toString(); + } + + private boolean notAutodetectParams() { + return !(params.length == 1 && AUTODETECTED.equals(params[0])); + } + + private String[] actions() { + return actions == null ? null : actions.toArray(new String[0]); + } + + @Override + public boolean equals(Object o) { + if (this == o) + return true; + if (o == null || getClass() != o.getClass()) + 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); + } + + @Override + public int hashCode() { + int result = Objects.hash(name, actions, clazz); + result = 31 * result + Arrays.hashCode(params); + return result; + } + } + + private static final class PermissionCacheKey { + private final int[] methodParamIndexes; + private final PermissionKey permissionKey; + private final boolean computed; + private final boolean passActionsToConstructor; + + private PermissionCacheKey(PermissionKey permissionKey, MethodInfo securedMethod, MethodInfo constructor) { + if (isComputed(permissionKey, constructor)) { + // computed permission + this.permissionKey = permissionKey; + 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); + } else { + // autodetect params path + + this.passActionsToConstructor = isSecondParamStringArr; + this.methodParamIndexes = autodetectConstructorParamIndexes(permissionKey, securedMethod, + constructor, isSecondParamStringArr); + } + } else { + // plain permission + this.methodParamIndexes = null; + this.permissionKey = permissionKey; + this.computed = false; + this.passActionsToConstructor = constructor.parametersCount() == 2; + } + } + + private static int[] userDefinedConstructorParamIndexes(MethodInfo securedMethod, MethodInfo constructor, + boolean passActionsToConstructor) { + // 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); + // 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) { + 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)); + } + 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; + } + } + 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), permissionKey.classSignature(), securedMethod.name())); + } + 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; + for (int i = 0; i < securedMethod.parametersCount(); i++) { + boolean paramNamesMatch = constructorParamName.equals(securedMethod.parameterName(i)); + if (paramNamesMatch) { + foundIx = i; + break; + } + } + return foundIx; + } + + @Override + public boolean equals(Object o) { + if (this == o) + return true; + if (o == null || getClass() != o.getClass()) + return false; + PermissionCacheKey that = (PermissionCacheKey) o; + return computed == that.computed && passActionsToConstructor == that.passActionsToConstructor + && Arrays.equals(methodParamIndexes, that.methodParamIndexes) + && permissionKey.equals(that.permissionKey); + } + + @Override + public int hashCode() { + int result = Objects.hash(permissionKey, computed, passActionsToConstructor); + result = 31 * result + Arrays.hashCode(methodParamIndexes); + return result; + } + + private int[] methodParamIndexes() { + return Objects.requireNonNull(methodParamIndexes); + } + + private boolean isStringPermission() { + return isStringPermission(permissionKey); + } + + private static boolean isComputed(PermissionKey permissionKey, MethodInfo constructor) { + // requirements for permission constructor: + // - first parameter is always permission name (String) + // - second parameter may be permission actions (String[]) + + // we want to pass secured method arguments if: + // - user specifically opted so (by setting PermissionsAllowed#params) + // - autodetect strategy is used and: + // - permission constructor has more than 2 args + // - permission constructor has 2 args and second param is not string array + return permissionKey.notAutodetectParams() || constructor.parametersCount() > 2 + || (constructor.parametersCount() == 2 && secondParamIsNotStringArr(constructor)); + } + + private static boolean secondParamIsNotStringArr(MethodInfo constructor) { + return constructor.parametersCount() < 2 || constructor.parameterType(1).kind() != Type.Kind.ARRAY + || !constructor.parameterType(1).asArrayType().component().name().equals(STRING); + } + + private static boolean isStringPermission(PermissionKey permissionKey) { + return STRING_PERMISSION.equals(permissionKey.clazz.name()); + } + + } + } +} diff --git a/extensions/security/deployment/src/main/java/io/quarkus/security/deployment/SecurityAnnotationsRegistrar.java b/extensions/security/deployment/src/main/java/io/quarkus/security/deployment/SecurityAnnotationsRegistrar.java index f95401701393c6..38936d13238d49 100644 --- a/extensions/security/deployment/src/main/java/io/quarkus/security/deployment/SecurityAnnotationsRegistrar.java +++ b/extensions/security/deployment/src/main/java/io/quarkus/security/deployment/SecurityAnnotationsRegistrar.java @@ -2,6 +2,7 @@ import java.util.Collections; import java.util.List; +import java.util.Set; import jakarta.annotation.security.DenyAll; import jakarta.annotation.security.PermitAll; @@ -9,6 +10,7 @@ import io.quarkus.arc.processor.InterceptorBindingRegistrar; import io.quarkus.security.Authenticated; +import io.quarkus.security.PermissionsAllowed; /** * @author Michal Szynkiewicz, michal.l.szynkiewicz@gmail.com @@ -18,6 +20,7 @@ public class SecurityAnnotationsRegistrar implements InterceptorBindingRegistrar static final List SECURITY_BINDINGS = List.of( // keep the contents the same as in io.quarkus.resteasy.deployment.SecurityTransformerUtils InterceptorBinding.of(RolesAllowed.class, Collections.singleton("value")), + InterceptorBinding.of(PermissionsAllowed.class, Set.of("value", "params", "permission")), InterceptorBinding.of(Authenticated.class), InterceptorBinding.of(DenyAll.class), InterceptorBinding.of(PermitAll.class)); 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 ec9bd2f7adec5c..9ff2dc7fc87712 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 @@ -2,6 +2,7 @@ import static io.quarkus.gizmo.MethodDescriptor.ofMethod; import static io.quarkus.security.deployment.DotNames.DENY_ALL; +import static io.quarkus.security.deployment.DotNames.PERMISSIONS_ALLOWED; import static io.quarkus.security.deployment.DotNames.ROLES_ALLOWED; import static io.quarkus.security.runtime.SecurityProviderUtils.findProviderIndex; @@ -71,6 +72,7 @@ import io.quarkus.gizmo.TryBlock; import io.quarkus.runtime.LaunchMode; import io.quarkus.runtime.RuntimeValue; +import io.quarkus.security.deployment.PermissionSecurityChecks.PermissionSecurityChecksBuilder; import io.quarkus.security.runtime.IdentityProviderManagerCreator; import io.quarkus.security.runtime.QuarkusSecurityRolesAllowedConfigBuilder; import io.quarkus.security.runtime.SecurityBuildTimeConfig; @@ -82,6 +84,7 @@ import io.quarkus.security.runtime.X509IdentityProvider; import io.quarkus.security.runtime.interceptor.AuthenticatedInterceptor; import io.quarkus.security.runtime.interceptor.DenyAllInterceptor; +import io.quarkus.security.runtime.interceptor.PermissionsAllowedInterceptor; import io.quarkus.security.runtime.interceptor.PermitAllInterceptor; import io.quarkus.security.runtime.interceptor.RolesAllowedInterceptor; import io.quarkus.security.runtime.interceptor.SecurityCheckStorageBuilder; @@ -430,7 +433,7 @@ void registerSecurityInterceptors(BuildProducer beans) { registrars.produce(new InterceptorBindingRegistrarBuildItem(new SecurityAnnotationsRegistrar())); Class[] interceptors = { AuthenticatedInterceptor.class, DenyAllInterceptor.class, PermitAllInterceptor.class, - RolesAllowedInterceptor.class }; + RolesAllowedInterceptor.class, PermissionsAllowedInterceptor.class }; beans.produce(new AdditionalBeanBuildItem(interceptors)); beans.produce(new AdditionalBeanBuildItem(SecurityHandler.class, SecurityConstrainer.class)); } @@ -495,6 +498,7 @@ void gatherSecurityChecks(BuildProducer syntheticBeans, BuildProducer configBuilderProducer, List additionalSecuredMethods, SecurityCheckRecorder recorder, + BuildProducer reflectiveClassBuildItemBuildProducer, List additionalSecurityChecks, SecurityBuildTimeConfig config) { classPredicate.produce(new ApplicationClassPredicateBuildItem(new SecurityCheckStorageAppPredicate())); @@ -508,7 +512,8 @@ void gatherSecurityChecks(BuildProducer syntheticBeans, IndexView index = beanArchiveBuildItem.getIndex(); Map securityChecks = gatherSecurityAnnotations(index, configExpSecurityCheckProducer, - additionalSecured.values(), config.denyUnannotated, recorder, configBuilderProducer); + additionalSecured.values(), config.denyUnannotated, recorder, configBuilderProducer, + reflectiveClassBuildItemBuildProducer); for (AdditionalSecurityCheckBuildItem additionalSecurityCheck : additionalSecurityChecks) { securityChecks.put(additionalSecurityCheck.getMethodInfo(), additionalSecurityCheck.getSecurityCheck()); @@ -554,7 +559,8 @@ public void resolveConfigExpressionRoles(Optional gatherSecurityAnnotations(IndexView index, BuildProducer configExpSecurityCheckProducer, Collection additionalSecuredMethods, boolean denyUnannotated, SecurityCheckRecorder recorder, - BuildProducer configBuilderProducer) { + BuildProducer configBuilderProducer, + BuildProducer reflectiveClassBuildItemBuildProducer) { Map methodToInstanceCollector = new HashMap<>(); Map classAnnotations = new HashMap<>(); @@ -647,6 +653,24 @@ public SecurityCheck apply(Set allowedRolesSet) { .produce(new RunTimeConfigBuilderBuildItem(QuarkusSecurityRolesAllowedConfigBuilder.class.getName())); } + List permissionInstances = new ArrayList<>( + index.getAnnotationsWithRepeatable(PERMISSIONS_ALLOWED, index)); + if (!permissionInstances.isEmpty()) { + var securityChecks = new PermissionSecurityChecksBuilder(recorder) + .gatherPermissionsAllowedAnnotations(permissionInstances, methodToInstanceCollector, classAnnotations) + .validatePermissionClasses(index) + .createPermissionPredicates() + .build(); + result.putAll(securityChecks.get()); + + // register used permission classes for reflection + for (String permissionClass : securityChecks.permissionClasses()) { + reflectiveClassBuildItemBuildProducer + .produce(ReflectiveClassBuildItem.builder(permissionClass).constructors().fields().methods().build()); + log.debugf("Register Permission class for reflection: %s", permissionClass); + } + } + /* * If we need to add the denyAll security check to all unannotated methods, we simply go through all secured methods, * collect the declaring classes, then go through all methods of the classes and add the necessary check @@ -702,7 +726,7 @@ private boolean alreadyHasAnnotation(AnnotationInstance alreadyExistingInstance, && alreadyExistingInstance.name().equals(annotationName); } - private boolean isPublicNonStaticNonConstructor(MethodInfo methodInfo) { + static boolean isPublicNonStaticNonConstructor(MethodInfo methodInfo) { return Modifier.isPublic(methodInfo.flags()) && !Modifier.isStatic(methodInfo.flags()) && !"".equals(methodInfo.name()); } @@ -800,4 +824,5 @@ public boolean test(String s) { return s.equals(SecurityCheckStorage.class.getName()); } } + } diff --git a/extensions/security/deployment/src/main/java/io/quarkus/security/deployment/SecurityTransformerUtils.java b/extensions/security/deployment/src/main/java/io/quarkus/security/deployment/SecurityTransformerUtils.java index 8092a41a31bca3..4bdf03a7b358d4 100644 --- a/extensions/security/deployment/src/main/java/io/quarkus/security/deployment/SecurityTransformerUtils.java +++ b/extensions/security/deployment/src/main/java/io/quarkus/security/deployment/SecurityTransformerUtils.java @@ -29,7 +29,7 @@ public static boolean hasStandardSecurityAnnotation(MethodInfo methodInfo) { } public static boolean hasStandardSecurityAnnotation(ClassInfo classInfo) { - return hasStandardSecurityAnnotation(classInfo.classAnnotations()); + return hasStandardSecurityAnnotation(classInfo.declaredAnnotations()); } private static boolean hasStandardSecurityAnnotation(Collection instances) { @@ -46,7 +46,7 @@ public static Optional findFirstStandardSecurityAnnotation(M } public static Optional findFirstStandardSecurityAnnotation(ClassInfo classInfo) { - return findFirstStandardSecurityAnnotation(classInfo.classAnnotations()); + return findFirstStandardSecurityAnnotation(classInfo.declaredAnnotations()); } private static Optional findFirstStandardSecurityAnnotation(Collection instances) { diff --git a/extensions/security/deployment/src/test/java/io/quarkus/security/test/permissionsallowed/AbstractMethodLevelPermissionsAllowedTest.java b/extensions/security/deployment/src/test/java/io/quarkus/security/test/permissionsallowed/AbstractMethodLevelPermissionsAllowedTest.java new file mode 100644 index 00000000000000..7401292505b42f --- /dev/null +++ b/extensions/security/deployment/src/test/java/io/quarkus/security/test/permissionsallowed/AbstractMethodLevelPermissionsAllowedTest.java @@ -0,0 +1,427 @@ +package io.quarkus.security.test.permissionsallowed; + +import static io.quarkus.security.test.utils.IdentityMock.ANONYMOUS; +import static io.quarkus.security.test.utils.SecurityTestUtils.assertFailureFor; +import static io.quarkus.security.test.utils.SecurityTestUtils.assertSuccess; + +import java.security.Permission; +import java.util.Collections; +import java.util.HashSet; +import java.util.Set; + +import org.junit.jupiter.api.Test; + +import io.quarkus.security.ForbiddenException; +import io.quarkus.security.UnauthorizedException; +import io.quarkus.security.test.utils.AuthData; +import io.smallrye.mutiny.Uni; + +public abstract class AbstractMethodLevelPermissionsAllowedTest { + protected static final String READ_PERMISSION = "read"; + protected static final String WRITE_PERMISSION = "write"; + protected static final String MULTIPLE_PERMISSION = "multiple"; + protected static final String READ_PERMISSION_BEAN = "read:bean"; + protected static final String WRITE_PERMISSION_BEAN = "write:bean"; + protected static final String MULTIPLE_BEAN = "multiple:bean"; + protected static final String PREDICATE = "predicate"; + protected final AuthData USER = new AuthData(Collections.singleton("user"), false, "user", + Set.of(createPermission("read", (String[]) null))); + protected final AuthData ADMIN = new AuthData(Collections.singleton("admin"), false, "admin", + Set.of(createPermission("write", (String[]) null))); + + @Test + public void shouldRestrictAccessToSpecificPermissionName() { + // identity has one permission, annotation has same permission + assertSuccess(() -> getPermissionsAllowedNameOnlyBean().write(), WRITE_PERMISSION, ADMIN); + assertSuccess(getPermissionsAllowedNameOnlyBean().writeNonBlocking(), WRITE_PERMISSION, ADMIN); + assertSuccess(() -> getPermissionsAllowedNameOnlyBean().read(), READ_PERMISSION, USER); + assertSuccess(getPermissionsAllowedNameOnlyBean().readNonBlocking(), READ_PERMISSION, USER); + + // identity has one permission, annotation has different permission + assertFailureFor(() -> getPermissionsAllowedNameOnlyBean().prohibited(), ForbiddenException.class, ADMIN); + assertFailureFor(getPermissionsAllowedNameOnlyBean().prohibitedNonBlocking(), ForbiddenException.class, ADMIN); + + // identity has no permission, annotation has one permission + assertFailureFor(() -> getPermissionsAllowedNameOnlyBean().prohibited(), UnauthorizedException.class, ANONYMOUS); + assertFailureFor(getPermissionsAllowedNameOnlyBean().prohibitedNonBlocking(), UnauthorizedException.class, ANONYMOUS); + + // identity has one permission, annotation has multiple permissions + assertSuccess(() -> getPermissionsAllowedNameOnlyBean().multiple(), MULTIPLE_PERMISSION, USER); + assertSuccess(getPermissionsAllowedNameOnlyBean().multipleNonBlocking(), MULTIPLE_PERMISSION, USER); + + // identity has one permission, annotation has different permissions + assertFailureFor(() -> getPermissionsAllowedNameOnlyBean().multiple(), ForbiddenException.class, ADMIN); + assertFailureFor(getPermissionsAllowedNameOnlyBean().multipleNonBlocking(), ForbiddenException.class, ADMIN); + + // identity has multiple permissions, annotation has multiple permissions + final var multiplePermissionsIdentity = new AuthData(Set.of(), false, MULTIPLE_PERMISSION, + permissions(READ_PERMISSION, "a", "b", "c", "d")); + assertSuccess(() -> getPermissionsAllowedNameOnlyBean().multiple(), MULTIPLE_PERMISSION, multiplePermissionsIdentity); + assertSuccess(getPermissionsAllowedNameOnlyBean().multipleNonBlocking(), MULTIPLE_PERMISSION, + multiplePermissionsIdentity); + + // identity has multiple permissions, annotation has one permission + assertSuccess(() -> getPermissionsAllowedNameOnlyBean().read(), READ_PERMISSION, multiplePermissionsIdentity); + assertSuccess(getPermissionsAllowedNameOnlyBean().readNonBlocking(), READ_PERMISSION, multiplePermissionsIdentity); + + // identity has multiple permissions, annotation has different permissions + final var diffPermissionsIdentity = new AuthData(Set.of(), false, MULTIPLE_PERMISSION, + permissions(WRITE_PERMISSION, "a", "b", "c", "d")); + assertFailureFor(() -> getPermissionsAllowedNameOnlyBean().multiple(), ForbiddenException.class, + diffPermissionsIdentity); + assertFailureFor(getPermissionsAllowedNameOnlyBean().multipleNonBlocking(), ForbiddenException.class, + diffPermissionsIdentity); + } + + @Test + public void shouldRestrictAccessToSpecificPermissionAndAction() { + final var admin = new AuthData(Collections.singleton("admin"), false, "admin", permission(WRITE_PERMISSION, "bean")); + final var user = new AuthData(Collections.singleton("user"), false, "user", permission(READ_PERMISSION, "bean")); + + // identity has one permission and action, annotation has same permission and action + assertSuccess(() -> getPermissionsAllowedNameAndActionsOnlyBean().write(), WRITE_PERMISSION_BEAN, admin); + assertSuccess(getPermissionsAllowedNameAndActionsOnlyBean().writeNonBlocking(), WRITE_PERMISSION_BEAN, admin); + assertSuccess(() -> getPermissionsAllowedNameAndActionsOnlyBean().read(), READ_PERMISSION_BEAN, user); + assertSuccess(getPermissionsAllowedNameAndActionsOnlyBean().readNonBlocking(), READ_PERMISSION_BEAN, user); + + // identity has one permission and action, annotation has same permission and multiple actions + assertSuccess(() -> getPermissionsAllowedNameAndActionsOnlyBean().multipleActions(), MULTIPLE_BEAN, user); + assertSuccess(getPermissionsAllowedNameAndActionsOnlyBean().multipleNonBlockingActions(), MULTIPLE_BEAN, user); + + // identity has one permission and action, annotation has same permission and different actions + final var user1 = new AuthData(Collections.singleton("user"), false, "user", + permission(READ_PERMISSION, "diff1", "diff2")); + assertFailureFor(() -> getPermissionsAllowedNameAndActionsOnlyBean().multipleActions(), ForbiddenException.class, + user1); + assertFailureFor(getPermissionsAllowedNameAndActionsOnlyBean().multipleNonBlockingActions(), ForbiddenException.class, + user1); + + // identity has one permission and action, annotation has different permission and action + assertFailureFor(() -> getPermissionsAllowedNameAndActionsOnlyBean().prohibited(), ForbiddenException.class, admin); + assertFailureFor(getPermissionsAllowedNameAndActionsOnlyBean().prohibitedNonBlocking(), ForbiddenException.class, + admin); + + // identity has no permission, annotation has one permission and action + assertFailureFor(() -> getPermissionsAllowedNameAndActionsOnlyBean().prohibited(), UnauthorizedException.class, + ANONYMOUS); + assertFailureFor(getPermissionsAllowedNameAndActionsOnlyBean().prohibitedNonBlocking(), UnauthorizedException.class, + ANONYMOUS); + + // identity has no permission, annotation has multiple permissions, each with one action + assertFailureFor(() -> getPermissionsAllowedNameAndActionsOnlyBean().multiple(), UnauthorizedException.class, + ANONYMOUS); + assertFailureFor(getPermissionsAllowedNameAndActionsOnlyBean().multipleNonBlocking(), UnauthorizedException.class, + ANONYMOUS); + + // identity has one permission and action, annotation has multiple permissions, each with one action + assertSuccess(() -> getPermissionsAllowedNameAndActionsOnlyBean().multiple(), MULTIPLE_BEAN, user); + assertSuccess(getPermissionsAllowedNameAndActionsOnlyBean().multipleNonBlocking(), MULTIPLE_BEAN, user); + + // identity has one permission and action, annotation has multiple permissions, some with actions, some not + // should succeed as identity has 'read:bean', while annotation requires 'read' + assertSuccess(() -> getPermissionsAllowedNameAndActionsOnlyBean().combination(), MULTIPLE_BEAN, user); + assertSuccess(getPermissionsAllowedNameAndActionsOnlyBean().combinationNonBlockingActions(), MULTIPLE_BEAN, user); + + // identity has one permission and action, annotation has multiple permissions, some with actions, some not + // should fail as identity has 'read:bean', while annotation requires 'read:meal' or 'read:bread' + assertFailureFor(() -> getPermissionsAllowedNameAndActionsOnlyBean().combination2(), ForbiddenException.class, user); + assertFailureFor(getPermissionsAllowedNameAndActionsOnlyBean().combination2NonBlockingActions(), + ForbiddenException.class, user); + + // identity has one permission and action, annotation has different permissions, each with one action + assertFailureFor(() -> getPermissionsAllowedNameAndActionsOnlyBean().multiple(), ForbiddenException.class, admin); + assertFailureFor(getPermissionsAllowedNameAndActionsOnlyBean().multipleNonBlocking(), ForbiddenException.class, admin); + + // identity has one permission and no action, annotation has different permissions, each with one action + assertFailureFor(() -> getPermissionsAllowedNameAndActionsOnlyBean().multiple(), ForbiddenException.class, USER); + assertFailureFor(getPermissionsAllowedNameAndActionsOnlyBean().multipleNonBlocking(), ForbiddenException.class, USER); + + // identity has one permission and action, annotation has different permissions with multiple actions + assertFailureFor(() -> getPermissionsAllowedNameAndActionsOnlyBean().multipleActions(), ForbiddenException.class, + admin); + assertFailureFor(getPermissionsAllowedNameAndActionsOnlyBean().multipleNonBlockingActions(), ForbiddenException.class, + admin); + + // identity has one permission and no action, annotation has different permissions with multiple actions + assertFailureFor(() -> getPermissionsAllowedNameAndActionsOnlyBean().multipleActions(), ForbiddenException.class, USER); + assertFailureFor(getPermissionsAllowedNameAndActionsOnlyBean().multipleNonBlockingActions(), ForbiddenException.class, + USER); + + // identity has one permission and one action, annotation has different permissions with multiple actions + // most notably, annotation has "read:bread", "read:meal" and identity has "read:bread" => success + final var readBread = new AuthData(Collections.singleton("admin"), false, "admin", permission("read", "bread")); + assertSuccess(() -> getPermissionsAllowedNameAndActionsOnlyBean().combination2(), "combination2", readBread); + assertSuccess(getPermissionsAllowedNameAndActionsOnlyBean().combination2NonBlockingActions(), "combination2", + readBread); + } + + @Test + public void shouldRequireMultiplePermissions() { + final var admin = new AuthData(Collections.singleton("admin"), false, "admin", permissions("create", "update")); + final var user = new AuthData(Collections.singleton("user"), false, "user", permissions("see", "view")); + + // identity has 2 permissions, annotation requires 2 same permissions + assertSuccess(() -> getMultiplePermissionsAllowedBean().createOrUpdate(), "create_or_update", admin); + assertSuccess(getMultiplePermissionsAllowedBean().createOrUpdateNonBlocking(), "create_or_update", admin); + assertSuccess(() -> getMultiplePermissionsAllowedBean().getOne(), "see_or_view_detail", user); + assertSuccess(getMultiplePermissionsAllowedBean().getOneNonBlocking(), "see_or_view_detail", user); + + // identity has 2 permissions, annotation requires 2 different permissions + assertFailureFor(() -> getMultiplePermissionsAllowedBean().createOrUpdate(), ForbiddenException.class, user); + assertFailureFor(getMultiplePermissionsAllowedBean().createOrUpdateNonBlocking(), ForbiddenException.class, user); + assertFailureFor(() -> getMultiplePermissionsAllowedBean().getOne(), ForbiddenException.class, admin); + assertFailureFor(getMultiplePermissionsAllowedBean().getOneNonBlocking(), ForbiddenException.class, admin); + + final var create = new AuthData(Collections.singleton("create"), false, "create", permissions("create")); + final var update = new AuthData(Collections.singleton("update"), false, "update", permissions("update")); + final var see = new AuthData(Collections.singleton("see"), false, "see", permissions("see")); + final var view = new AuthData(Collections.singleton("view"), false, "view", permissions("see")); + + // identity has 1 permissions, annotation requires 2 permissions, one of them is same as the identity possess + assertFailureFor(() -> getMultiplePermissionsAllowedBean().createOrUpdate(), ForbiddenException.class, update); + assertFailureFor(getMultiplePermissionsAllowedBean().createOrUpdateNonBlocking(), ForbiddenException.class, create); + assertFailureFor(() -> getMultiplePermissionsAllowedBean().getOne(), ForbiddenException.class, see); + assertFailureFor(getMultiplePermissionsAllowedBean().getOneNonBlocking(), ForbiddenException.class, view); + + // identity has 1 permissions, annotation requires 2 different permissions + assertFailureFor(() -> getMultiplePermissionsAllowedBean().createOrUpdate(), ForbiddenException.class, see); + assertFailureFor(getMultiplePermissionsAllowedBean().createOrUpdateNonBlocking(), ForbiddenException.class, view); + assertFailureFor(() -> getMultiplePermissionsAllowedBean().getOne(), ForbiddenException.class, create); + assertFailureFor(getMultiplePermissionsAllowedBean().getOneNonBlocking(), ForbiddenException.class, update); + + // (operand1 OR operand2 OR operand3) AND (operand4 OR operand5) AND (operand6 OR operand7) AND operand8 + + // - identity posses no matching + assertFailureFor(() -> getMultiplePermissionsAllowedBean().predicate(), ForbiddenException.class, admin); + assertFailureFor(getMultiplePermissionsAllowedBean().predicateNonBlocking(), ForbiddenException.class, admin); + + // - identity has operand1 AND operand4 AND operand6 AND operand8 => success + final var operand1_4_6_8 = new AuthData(Collections.singleton("operand1_4_6_8"), false, "operand1_4_6_8", + permissions("operand1", "operand4", "operand6", "operand8")); + assertSuccess(() -> getMultiplePermissionsAllowedBean().predicate(), PREDICATE, operand1_4_6_8); + assertSuccess(getMultiplePermissionsAllowedBean().predicateNonBlocking(), PREDICATE, operand1_4_6_8); + + // - identity has operand3 AND operand5 AND operand7 AND operand8 => success + final var operand3_5_7_8 = new AuthData(Collections.singleton("operand3_5_7_8"), false, "operand3_5_7_8", + permissions("operand3", "operand5", "operand7", "operand8")); + assertSuccess(() -> getMultiplePermissionsAllowedBean().predicate(), PREDICATE, operand3_5_7_8); + assertSuccess(getMultiplePermissionsAllowedBean().predicateNonBlocking(), PREDICATE, operand3_5_7_8); + + // - identity has operand1 AND operand4 AND operand6 => missing operand8 => failure + final var operand1_4_6 = new AuthData(Collections.singleton("operand1_4_6"), false, "operand1_4_6", + permissions("operand1", "operand4", "operand6")); + assertFailureFor(() -> getMultiplePermissionsAllowedBean().predicate(), ForbiddenException.class, operand1_4_6); + assertFailureFor(getMultiplePermissionsAllowedBean().predicateNonBlocking(), ForbiddenException.class, operand1_4_6); + + // - identity has operand1 AND operand4 AND operand8 => missing operand6 or operand7 => failure + final var operand1_4_8 = new AuthData(Collections.singleton("operand1_4_8"), false, "operand1_4_8", + permissions("operand1", "operand4", "operand8")); + assertFailureFor(() -> getMultiplePermissionsAllowedBean().predicate(), ForbiddenException.class, operand1_4_8); + assertFailureFor(getMultiplePermissionsAllowedBean().predicateNonBlocking(), ForbiddenException.class, operand1_4_8); + + // - identity has operand1 AND operand6 AND operand8 => missing operand4 or operand5 => failure + final var operand1_6_8 = new AuthData(Collections.singleton("operand1_6_8"), false, "operand1_6_8", + permissions("operand1", "operand6", "operand8")); + assertFailureFor(() -> getMultiplePermissionsAllowedBean().predicate(), ForbiddenException.class, operand1_6_8); + assertFailureFor(getMultiplePermissionsAllowedBean().predicateNonBlocking(), ForbiddenException.class, operand1_6_8); + + // - identity has operand4 AND operand6 AND operand8 => missing operand1 or operand2 or operand3 => failure + final var operand4_6_8 = new AuthData(Collections.singleton("operand4_6_8"), false, "operand4_6_8", + permissions("operand4", "operand6", "operand8")); + assertFailureFor(() -> getMultiplePermissionsAllowedBean().predicate(), ForbiddenException.class, operand4_6_8); + assertFailureFor(getMultiplePermissionsAllowedBean().predicateNonBlocking(), ForbiddenException.class, operand4_6_8); + + // - identity has all operands => success + final var full_operands = new AuthData(Collections.singleton("full_operands"), false, "full_operands", + permissions("operand1", "operand2", "operand3", "operand4", "operand5", "operand6", "operand7", "operand8")); + assertSuccess(() -> getMultiplePermissionsAllowedBean().predicate(), PREDICATE, full_operands); + assertSuccess(getMultiplePermissionsAllowedBean().predicateNonBlocking(), PREDICATE, full_operands); + } + + @Test + public void shouldRequireMultiplePermissionsWithActions() { + // (permission1:action1 OR permission2:action2) AND (permission1:action2 OR permission2:action1) + // - success + // - permission1:action1, permission2:action2, permission1:action2, permission2:action1 + assertSuccess(() -> getMultiplePermissionsAllowedBean().actionsPredicate(), PREDICATE, + withPermissionsAndActions(1, 1, 2, 2, 1, 2, 2, 1)); + assertSuccess(getMultiplePermissionsAllowedBean().actionsPredicateNonBlocking(), PREDICATE, + withPermissionsAndActions(1, 1, 2, 2, 1, 2, 2, 1)); + // - permission1:action1, permission1:action2, permission2:action1 + assertSuccess(() -> getMultiplePermissionsAllowedBean().actionsPredicate(), PREDICATE, + withPermissionsAndActions(1, 1, 1, 2, 2, 1)); + assertSuccess(getMultiplePermissionsAllowedBean().actionsPredicateNonBlocking(), PREDICATE, + withPermissionsAndActions(1, 1, 1, 2, 2, 1)); + // - permission2:action2, permission1:action2, permission2:action1 + assertSuccess(() -> getMultiplePermissionsAllowedBean().actionsPredicate(), PREDICATE, + withPermissionsAndActions(2, 2, 1, 2, 2, 1)); + assertSuccess(getMultiplePermissionsAllowedBean().actionsPredicateNonBlocking(), PREDICATE, + withPermissionsAndActions(2, 2, 1, 2, 2, 1)); + // - permission1:action1, permission2:action2, permission1:action2 + assertSuccess(() -> getMultiplePermissionsAllowedBean().actionsPredicate(), PREDICATE, + withPermissionsAndActions(1, 1, 2, 2, 1, 2)); + assertSuccess(getMultiplePermissionsAllowedBean().actionsPredicateNonBlocking(), PREDICATE, + withPermissionsAndActions(1, 1, 2, 2, 1, 2)); + // - permission1:action1, permission2:action2, permission2:action1 + assertSuccess(() -> getMultiplePermissionsAllowedBean().actionsPredicate(), PREDICATE, + withPermissionsAndActions(1, 1, 2, 2, 2, 1)); + assertSuccess(getMultiplePermissionsAllowedBean().actionsPredicateNonBlocking(), PREDICATE, + withPermissionsAndActions(1, 1, 2, 2, 2, 1)); + // - permission2:action2, permission2:action1 + assertSuccess(() -> getMultiplePermissionsAllowedBean().actionsPredicate(), PREDICATE, + withPermissionsAndActions(2, 2, 2, 1)); + assertSuccess(getMultiplePermissionsAllowedBean().actionsPredicateNonBlocking(), PREDICATE, + withPermissionsAndActions(2, 2, 2, 1)); + // - permission2:action2, permission1:action2 + assertSuccess(() -> getMultiplePermissionsAllowedBean().actionsPredicate(), PREDICATE, + withPermissionsAndActions(2, 2, 1, 2)); + assertSuccess(getMultiplePermissionsAllowedBean().actionsPredicateNonBlocking(), PREDICATE, + withPermissionsAndActions(2, 2, 1, 2)); + // - permission1:action1, permission2:action1 + assertSuccess(() -> getMultiplePermissionsAllowedBean().actionsPredicate(), PREDICATE, + withPermissionsAndActions(1, 1, 2, 1)); + assertSuccess(getMultiplePermissionsAllowedBean().actionsPredicateNonBlocking(), PREDICATE, + withPermissionsAndActions(1, 1, 2, 1)); + // - permission1:action1, permission1:action2 + assertSuccess(() -> getMultiplePermissionsAllowedBean().actionsPredicate(), PREDICATE, + withPermissionsAndActions(1, 1, 1, 2)); + assertSuccess(getMultiplePermissionsAllowedBean().actionsPredicateNonBlocking(), PREDICATE, + withPermissionsAndActions(1, 1, 1, 2)); + // - failure + // - permission1:action1, permission2:action2 + assertFailureFor(() -> getMultiplePermissionsAllowedBean().actionsPredicate(), ForbiddenException.class, + withPermissionsAndActions(1, 1, 2, 2)); + assertFailureFor(getMultiplePermissionsAllowedBean().actionsPredicateNonBlocking(), ForbiddenException.class, + withPermissionsAndActions(1, 1, 2, 2)); + // - permission1:action2, permission2:action1 + assertFailureFor(() -> getMultiplePermissionsAllowedBean().actionsPredicate(), ForbiddenException.class, + withPermissionsAndActions(1, 2, 2, 1)); + assertFailureFor(getMultiplePermissionsAllowedBean().actionsPredicateNonBlocking(), ForbiddenException.class, + withPermissionsAndActions(1, 2, 2, 1)); + // - permission1:action2 + assertFailureFor(() -> getMultiplePermissionsAllowedBean().actionsPredicate(), ForbiddenException.class, + withPermissionsAndActions(1, 2)); + assertFailureFor(getMultiplePermissionsAllowedBean().actionsPredicateNonBlocking(), ForbiddenException.class, + withPermissionsAndActions(1, 2)); + // - permission2:action1 + assertFailureFor(() -> getMultiplePermissionsAllowedBean().actionsPredicate(), ForbiddenException.class, + withPermissionsAndActions(2, 1)); + assertFailureFor(getMultiplePermissionsAllowedBean().actionsPredicateNonBlocking(), ForbiddenException.class, + withPermissionsAndActions(2, 1)); + // - permission2, permission1, permission2 + var permissionsOnlyIdentity = new AuthData(Collections.singleton("permissions_actions"), false, "permissions_actions", + permissions("permission2", "permission1", "permission2")); + assertFailureFor(() -> getMultiplePermissionsAllowedBean().actionsPredicate(), ForbiddenException.class, + permissionsOnlyIdentity); + assertFailureFor(getMultiplePermissionsAllowedBean().actionsPredicateNonBlocking(), ForbiddenException.class, + permissionsOnlyIdentity); + // - no matching permissions + var noMatchIdentity = new AuthData(Collections.singleton("permissions_actions"), false, "permissions_actions", + permissions("no_match")); + assertFailureFor(() -> getMultiplePermissionsAllowedBean().actionsPredicate(), ForbiddenException.class, + noMatchIdentity); + assertFailureFor(getMultiplePermissionsAllowedBean().actionsPredicateNonBlocking(), ForbiddenException.class, + noMatchIdentity); + } + + protected abstract MultiplePermissionsAllowedBeanI getMultiplePermissionsAllowedBean(); + + protected abstract PermissionsAllowedNameOnlyBeanI getPermissionsAllowedNameOnlyBean(); + + protected abstract PermissionsAllowedNameAndActionsOnlyBeanI getPermissionsAllowedNameAndActionsOnlyBean(); + + protected abstract Permission createPermission(String name, String... actions); + + AuthData withPermissionsAndActions(int... ixs) { + var permissions = new HashSet(); + String name = null; + for (int ix : ixs) { + if (name == null) { + name = "permission" + ix; + } else { + permissions.add(createPermission(name, "action" + ix)); + name = null; + } + } + return new AuthData(Collections.singleton("permissions_actions"), false, "permissions_actions", permissions); + } + + Set permissions(String... permissionNames) { + var permissions = new HashSet(); + for (String permissionName : permissionNames) { + permissions.add(createPermission(permissionName)); + } + return permissions; + } + + Set permission(String permissionName, String... actions) { + return Set.of(createPermission(permissionName, actions)); + } + + public interface PermissionsAllowedNameOnlyBeanI { + String write(); + + String read(); + + Uni writeNonBlocking(); + + Uni readNonBlocking(); + + void prohibited(); + + Uni prohibitedNonBlocking(); + + String multiple(); + + Uni multipleNonBlocking(); + } + + public interface PermissionsAllowedNameAndActionsOnlyBeanI { + String write(); + + String read(); + + Uni writeNonBlocking(); + + Uni readNonBlocking(); + + void prohibited(); + + Uni prohibitedNonBlocking(); + + String multiple(); + + Uni multipleNonBlocking(); + + String multipleActions(); + + Uni multipleNonBlockingActions(); + + String combination(); + + Uni combinationNonBlockingActions(); + + String combination2(); + + Uni combination2NonBlockingActions(); + } + + public interface MultiplePermissionsAllowedBeanI { + String createOrUpdate(); + + Uni createOrUpdateNonBlocking(); + + String getOne(); + + Uni getOneNonBlocking(); + + Uni predicateNonBlocking(); + + String predicate(); + + String actionsPredicate(); + + Uni actionsPredicateNonBlocking(); + } + +} 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 new file mode 100644 index 00000000000000..5b73fcafaafd5f --- /dev/null +++ b/extensions/security/deployment/src/test/java/io/quarkus/security/test/permissionsallowed/ClassLevelComputedPermissionsAllowedTest.java @@ -0,0 +1,202 @@ +package io.quarkus.security.test.permissionsallowed; + +import static io.quarkus.security.test.utils.SecurityTestUtils.assertFailureFor; +import static io.quarkus.security.test.utils.SecurityTestUtils.assertSuccess; + +import java.security.Permission; +import java.util.Collections; +import java.util.Objects; +import java.util.Set; + +import jakarta.inject.Inject; +import jakarta.inject.Singleton; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import io.quarkus.security.ForbiddenException; +import io.quarkus.security.PermissionsAllowed; +import io.quarkus.security.UnauthorizedException; +import io.quarkus.security.test.utils.AuthData; +import io.quarkus.security.test.utils.IdentityMock; +import io.quarkus.security.test.utils.SecurityTestUtils; +import io.quarkus.test.QuarkusUnitTest; +import io.smallrye.mutiny.Uni; + +public class ClassLevelComputedPermissionsAllowedTest { + + private static final String IGNORED = "ignored"; + private static final Set CHECKING_PERMISSION = Set.of(new Permission("permission_name") { + @Override + public boolean implies(Permission permission) { + return permission.implies(this); + } + + @Override + public boolean equals(Object obj) { + return false; + } + + @Override + public int hashCode() { + return 0; + } + + @Override + public String getActions() { + return null; + } + }); + private static final String SUCCESS = "success"; + + @RegisterExtension + static final QuarkusUnitTest config = new QuarkusUnitTest() + .withApplicationRoot((jar) -> jar + .addClasses(IdentityMock.class, AuthData.class, SecurityTestUtils.class)); + + @Inject + AutodetectParamsBean autodetectParamsBean; + + @Inject + ExplicitlyMatchedParamsBean explicitlyMatchedParamsBean; + + @Test + public void testAutodetectedParams() { + var anonymous = new AuthData(null, true, null, CHECKING_PERMISSION); + var user = new AuthData(Collections.singleton("user"), false, "user", CHECKING_PERMISSION); + + // secured class methods have exactly same parameters as Permission constructor (except of permission name and actions) + assertSuccess(() -> autodetectParamsBean.autodetect("hello", "world", "!"), SUCCESS, user); + assertFailureFor(() -> autodetectParamsBean.autodetect("what", "ever", "?"), ForbiddenException.class, user); + assertFailureFor(() -> autodetectParamsBean.autodetect("what", "ever", "?"), UnauthorizedException.class, anonymous); + assertSuccess(autodetectParamsBean.autodetectNonBlocking("hello", "world", "!"), SUCCESS, user); + assertFailureFor(autodetectParamsBean.autodetectNonBlocking("what", "ever", "?"), ForbiddenException.class, user); + assertFailureFor(autodetectParamsBean.autodetectNonBlocking("what", "ever", "?"), UnauthorizedException.class, + anonymous); + } + + @Test + public void testExplicitlyMatchedParams() { + var user = new AuthData(Collections.singleton("user"), false, "user", CHECKING_PERMISSION); + + // secured class methods have multiple params and Permission constructor selects one of them + assertSuccess(() -> explicitlyMatchedParamsBean.autodetect("hello", "world", "!"), SUCCESS, user); + assertFailureFor(() -> explicitlyMatchedParamsBean.autodetect("what", "ever", "?"), ForbiddenException.class, user); + assertSuccess(explicitlyMatchedParamsBean.autodetectNonBlocking("hello", "world", "!"), SUCCESS, user); + assertFailureFor(explicitlyMatchedParamsBean.autodetectNonBlocking("what", "ever", "?"), ForbiddenException.class, + user); + + // differs from above in params number, which means number of different methods can be secured via class-level annotation + assertSuccess(() -> explicitlyMatchedParamsBean.autodetect("world"), SUCCESS, user); + assertFailureFor(() -> explicitlyMatchedParamsBean.autodetect("ever"), ForbiddenException.class, user); + assertSuccess(explicitlyMatchedParamsBean.autodetectNonBlocking("world"), SUCCESS, user); + assertFailureFor(explicitlyMatchedParamsBean.autodetectNonBlocking("ever"), ForbiddenException.class, user); + } + + @PermissionsAllowed(value = IGNORED, permission = AllStrAutodetectedPermission.class) + @Singleton + public static class AutodetectParamsBean { + + public String autodetect(String hello, String world, String exclamationMark) { + return SUCCESS; + } + + public Uni autodetectNonBlocking(String hello, String world, String exclamationMark) { + return Uni.createFrom().item(SUCCESS); + } + + } + + @PermissionsAllowed(value = IGNORED, permission = AllStrExplicitlyMatchedPermission.class, params = "world") + @Singleton + public static class ExplicitlyMatchedParamsBean { + + public String autodetect(String hello, String world, String exclamationMark) { + return SUCCESS; + } + + public Uni autodetectNonBlocking(String hello, String world, String exclamationMark) { + return Uni.createFrom().item(SUCCESS); + } + + public String autodetect(String world) { + return SUCCESS; + } + + public Uni autodetectNonBlocking(String world) { + return Uni.createFrom().item(SUCCESS); + } + + } + + public static class AllStrAutodetectedPermission extends Permission { + private final boolean pass; + + public AllStrAutodetectedPermission(String name, String[] actions, String str1, String str2, String str3) { + super(name); + this.pass = "hello".equals(str1) && "world".equals(str2) && "!".equals(str3); + } + + @Override + public boolean implies(Permission permission) { + return pass; + } + + @Override + public boolean equals(Object o) { + if (this == o) + return true; + if (o == null || getClass() != o.getClass()) + return false; + AllStrAutodetectedPermission that = (AllStrAutodetectedPermission) o; + return pass == that.pass; + } + + @Override + public int hashCode() { + return Objects.hash(pass); + } + + @Override + public String getActions() { + return null; + } + + } + + public static class AllStrExplicitlyMatchedPermission extends Permission { + private final boolean pass; + + public AllStrExplicitlyMatchedPermission(String name, String world) { + super(name); + this.pass = "world".equals(world); + } + + @Override + public boolean implies(Permission permission) { + return pass; + } + + @Override + public boolean equals(Object o) { + if (this == o) + return true; + if (o == null || getClass() != o.getClass()) + return false; + AllStrExplicitlyMatchedPermission that = (AllStrExplicitlyMatchedPermission) o; + return pass == that.pass; + } + + @Override + public int hashCode() { + return Objects.hash(pass); + } + + @Override + public String getActions() { + return null; + } + + } + +} diff --git a/extensions/security/deployment/src/test/java/io/quarkus/security/test/permissionsallowed/ClassLevelCustomPermissionsAllowedTest.java b/extensions/security/deployment/src/test/java/io/quarkus/security/test/permissionsallowed/ClassLevelCustomPermissionsAllowedTest.java new file mode 100644 index 00000000000000..45792eccb8dbf6 --- /dev/null +++ b/extensions/security/deployment/src/test/java/io/quarkus/security/test/permissionsallowed/ClassLevelCustomPermissionsAllowedTest.java @@ -0,0 +1,226 @@ +package io.quarkus.security.test.permissionsallowed; + +import static io.quarkus.security.test.utils.SecurityTestUtils.assertFailureFor; +import static io.quarkus.security.test.utils.SecurityTestUtils.assertSuccess; + +import java.security.Permission; +import java.util.Collections; +import java.util.Objects; +import java.util.Set; + +import jakarta.inject.Inject; +import jakarta.inject.Singleton; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import io.quarkus.security.ForbiddenException; +import io.quarkus.security.PermissionsAllowed; +import io.quarkus.security.StringPermission; +import io.quarkus.security.test.utils.AuthData; +import io.quarkus.security.test.utils.IdentityMock; +import io.quarkus.security.test.utils.SecurityTestUtils; +import io.quarkus.test.QuarkusUnitTest; +import io.smallrye.mutiny.Uni; + +public class ClassLevelCustomPermissionsAllowedTest { + + static final String WRITE_PERMISSION = "write"; + static final String WRITE_PERMISSION_BEAN = "write:bean"; + static final String READ_PERMISSION = "read"; + static final String READ_PERMISSION_BEAN = "read:bean"; + + private final AuthData USER = new AuthData(Collections.singleton("user"), false, "user", + Set.of(createPermission("read", (String[]) null))); + private final AuthData ADMIN = new AuthData(Collections.singleton("admin"), false, "admin", + Set.of(createPermission("write", (String[]) null))); + + // mechanism for class level annotations does not differ from method level (where we do extensive testing), + // therefore what we really do want to test is annotation detection and smoke test + @RegisterExtension + static final QuarkusUnitTest config = new QuarkusUnitTest() + .withApplicationRoot((jar) -> jar + .addClasses(IdentityMock.class, AuthData.class, SecurityTestUtils.class)); + + @Inject + SingleAnnotationWriteBean writeBean; + + @Inject + SingleAnnotationWriteWithActionBean writeWithActionBean; + + @Inject + MultipleWriteReadBean writeReadBean; + + @Inject + MultipleWriteReadWithActionBean writeReadWithActionBean; + + protected Permission createPermission(String name, String... actions) { + return new CustomPermission(name, actions); + } + + @Test + public void testSinglePermission() { + // identity has one permission, annotation has same permission + assertSuccess(() -> writeBean.write(), WRITE_PERMISSION, ADMIN); + assertSuccess(writeBean.writeNonBlocking(), WRITE_PERMISSION, ADMIN); + + // identity has one permission, annotation has different permission + assertFailureFor(() -> writeBean.write(), ForbiddenException.class, USER); + assertFailureFor(writeBean.writeNonBlocking(), ForbiddenException.class, USER); + } + + @Test + public void testSinglePermissionWithAction() { + // identity has one permission and action, annotation has same permission and action + final var admin = new AuthData(Collections.singleton("admin"), false, "admin", permission(WRITE_PERMISSION, "bean")); + assertSuccess(() -> writeWithActionBean.write(), WRITE_PERMISSION, admin); + assertSuccess(writeWithActionBean.writeNonBlocking(), WRITE_PERMISSION, admin); + + // identity has one permission and action, annotation has same permission and different action + final var admin2 = new AuthData(Collections.singleton("admin"), false, "admin", permission(WRITE_PERMISSION, "bean2")); + assertFailureFor(() -> writeWithActionBean.write(), ForbiddenException.class, admin2); + assertFailureFor(writeWithActionBean.writeNonBlocking(), ForbiddenException.class, admin2); + } + + @Test + public void testMultiplePermissions() { + // identity has one permission, annotation has 2 permissions, one of them is matching + assertSuccess(() -> writeReadBean.write(), WRITE_PERMISSION, ADMIN); + assertSuccess(writeReadBean.writeNonBlocking(), WRITE_PERMISSION, ADMIN); + assertSuccess(() -> writeReadBean.read(), READ_PERMISSION, USER); + assertSuccess(writeReadBean.readNonBlocking(), READ_PERMISSION, USER); + + // identity has 2 permissions, annotation has different permission + final var user2 = new AuthData(Collections.singleton("user2"), false, "user2", + permission(READ_PERMISSION + 2, "bean2")); + assertFailureFor(() -> writeReadBean.write(), ForbiddenException.class, user2); + assertFailureFor(writeReadBean.writeNonBlocking(), ForbiddenException.class, user2); + } + + @Test + public void testMultiplePermissionsWithActions() { + final var admin = new AuthData(Collections.singleton("admin"), false, "admin", permission(WRITE_PERMISSION, "bean")); + final var user = new AuthData(Collections.singleton("user"), false, "user", permission(READ_PERMISSION, "bean")); + + // identity has one permission and action, annotation has 2 permissions and action, one of permission/action is matching + assertSuccess(() -> writeReadWithActionBean.write(), WRITE_PERMISSION_BEAN, admin); + assertSuccess(writeReadWithActionBean.writeNonBlocking(), WRITE_PERMISSION_BEAN, admin); + assertSuccess(() -> writeReadWithActionBean.read(), READ_PERMISSION_BEAN, user); + assertSuccess(writeReadWithActionBean.readNonBlocking(), READ_PERMISSION_BEAN, user); + + // identity has one permission and action, annotation has 2 permissions and action, one permission is matching, but action differs + final var admin2 = new AuthData(Collections.singleton("admin"), false, "admin", permission(WRITE_PERMISSION, "bean2")); + assertFailureFor(() -> writeReadWithActionBean.write(), ForbiddenException.class, admin2); + assertFailureFor(writeReadWithActionBean.writeNonBlocking(), ForbiddenException.class, admin2); + } + + Set permission(String permissionName, String... actions) { + return Set.of(createPermission(permissionName, actions)); + } + + public static class CustomPermission extends Permission { + + private final Permission delegate; + + public CustomPermission(String name, String... actions) { + super(name); + this.delegate = new StringPermission(name, actions); + } + + @Override + public boolean implies(Permission permission) { + if (permission instanceof CustomPermission) { + return delegate.implies(((CustomPermission) permission).delegate); + } + return false; + } + + @Override + public boolean equals(Object o) { + if (this == o) + return true; + if (o == null || getClass() != o.getClass()) + return false; + CustomPermission that = (CustomPermission) o; + return delegate.equals(that.delegate); + } + + @Override + public int hashCode() { + return Objects.hash(delegate); + } + + @Override + public String getActions() { + return delegate.getActions(); + } + } + + @PermissionsAllowed(value = WRITE_PERMISSION, permission = CustomPermission.class) + @Singleton + public static class SingleAnnotationWriteBean { + + public final String write() { + return WRITE_PERMISSION; + } + + public final Uni writeNonBlocking() { + return Uni.createFrom().item(WRITE_PERMISSION); + } + } + + @PermissionsAllowed(value = WRITE_PERMISSION_BEAN, permission = CustomPermission.class) + @Singleton + public static class SingleAnnotationWriteWithActionBean { + + public final String write() { + return WRITE_PERMISSION; + } + + public final Uni writeNonBlocking() { + return Uni.createFrom().item(WRITE_PERMISSION); + } + } + + @PermissionsAllowed(value = { WRITE_PERMISSION, READ_PERMISSION }, permission = CustomPermission.class) + @Singleton + public static class MultipleWriteReadBean { + + public final String write() { + return WRITE_PERMISSION; + } + + public final String read() { + return READ_PERMISSION; + } + + public final Uni writeNonBlocking() { + return Uni.createFrom().item(WRITE_PERMISSION); + } + + public final Uni readNonBlocking() { + return Uni.createFrom().item(READ_PERMISSION); + } + } + + @PermissionsAllowed(value = { WRITE_PERMISSION_BEAN, READ_PERMISSION_BEAN }, permission = CustomPermission.class) + @Singleton + public static class MultipleWriteReadWithActionBean { + + public final String write() { + return WRITE_PERMISSION_BEAN; + } + + public final String read() { + return READ_PERMISSION_BEAN; + } + + public final Uni writeNonBlocking() { + return Uni.createFrom().item(WRITE_PERMISSION_BEAN); + } + + public final Uni readNonBlocking() { + return Uni.createFrom().item(READ_PERMISSION_BEAN); + } + } +} diff --git a/extensions/security/deployment/src/test/java/io/quarkus/security/test/permissionsallowed/ClassLevelStringPermissionsAllowedTest.java b/extensions/security/deployment/src/test/java/io/quarkus/security/test/permissionsallowed/ClassLevelStringPermissionsAllowedTest.java new file mode 100644 index 00000000000000..d9a05effa74cb9 --- /dev/null +++ b/extensions/security/deployment/src/test/java/io/quarkus/security/test/permissionsallowed/ClassLevelStringPermissionsAllowedTest.java @@ -0,0 +1,187 @@ +package io.quarkus.security.test.permissionsallowed; + +import static io.quarkus.security.test.utils.SecurityTestUtils.assertFailureFor; +import static io.quarkus.security.test.utils.SecurityTestUtils.assertSuccess; + +import java.security.Permission; +import java.util.Collections; +import java.util.Set; + +import jakarta.inject.Inject; +import jakarta.inject.Singleton; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import io.quarkus.security.ForbiddenException; +import io.quarkus.security.PermissionsAllowed; +import io.quarkus.security.StringPermission; +import io.quarkus.security.test.utils.AuthData; +import io.quarkus.security.test.utils.IdentityMock; +import io.quarkus.security.test.utils.SecurityTestUtils; +import io.quarkus.test.QuarkusUnitTest; +import io.smallrye.mutiny.Uni; + +public class ClassLevelStringPermissionsAllowedTest { + + static final String WRITE_PERMISSION = "write"; + static final String WRITE_PERMISSION_BEAN = "write:bean"; + static final String READ_PERMISSION = "read"; + static final String READ_PERMISSION_BEAN = "read:bean"; + + private final AuthData USER = new AuthData(Collections.singleton("user"), false, "user", + Set.of(createPermission("read", (String[]) null))); + private final AuthData ADMIN = new AuthData(Collections.singleton("admin"), false, "admin", + Set.of(createPermission("write", (String[]) null))); + + // mechanism for class level annotations does not differ from method level (where we do extensive testing), + // therefore what we really do want to test is annotation detection and smoke test + @RegisterExtension + static final QuarkusUnitTest config = new QuarkusUnitTest() + .withApplicationRoot((jar) -> jar + .addClasses(IdentityMock.class, AuthData.class, SecurityTestUtils.class)); + + @Inject + SingleAnnotationWriteBean writeBean; + + @Inject + SingleAnnotationWriteWithActionBean writeWithActionBean; + + @Inject + MultipleWriteReadBean writeReadBean; + + @Inject + MultipleWriteReadWithActionBean writeReadWithActionBean; + + protected Permission createPermission(String name, String... actions) { + return new StringPermission(name, actions); + } + + @Test + public void testSinglePermission() { + // identity has one permission, annotation has same permission + assertSuccess(() -> writeBean.write(), WRITE_PERMISSION, ADMIN); + assertSuccess(writeBean.writeNonBlocking(), WRITE_PERMISSION, ADMIN); + + // identity has one permission, annotation has different permission + assertFailureFor(() -> writeBean.write(), ForbiddenException.class, USER); + assertFailureFor(writeBean.writeNonBlocking(), ForbiddenException.class, USER); + } + + @Test + public void testSinglePermissionWithAction() { + // identity has one permission and action, annotation has same permission and action + final var admin = new AuthData(Collections.singleton("admin"), false, "admin", permission(WRITE_PERMISSION, "bean")); + assertSuccess(() -> writeWithActionBean.write(), WRITE_PERMISSION, admin); + assertSuccess(writeWithActionBean.writeNonBlocking(), WRITE_PERMISSION, admin); + + // identity has one permission and action, annotation has same permission and different action + final var admin2 = new AuthData(Collections.singleton("admin"), false, "admin", permission(WRITE_PERMISSION, "bean2")); + assertFailureFor(() -> writeWithActionBean.write(), ForbiddenException.class, admin2); + assertFailureFor(writeWithActionBean.writeNonBlocking(), ForbiddenException.class, admin2); + } + + @Test + public void testMultiplePermissions() { + // identity has one permission, annotation has 2 permissions, one of them is matching + assertSuccess(() -> writeReadBean.write(), WRITE_PERMISSION, ADMIN); + assertSuccess(writeReadBean.writeNonBlocking(), WRITE_PERMISSION, ADMIN); + assertSuccess(() -> writeReadBean.read(), READ_PERMISSION, USER); + assertSuccess(writeReadBean.readNonBlocking(), READ_PERMISSION, USER); + + // identity has 2 permissions, annotation has different permission + final var user2 = new AuthData(Collections.singleton("user2"), false, "user2", + permission(READ_PERMISSION + 2, "bean2")); + assertFailureFor(() -> writeReadBean.write(), ForbiddenException.class, user2); + assertFailureFor(writeReadBean.writeNonBlocking(), ForbiddenException.class, user2); + } + + @Test + public void testMultiplePermissionsWithActions() { + final var admin = new AuthData(Collections.singleton("admin"), false, "admin", permission(WRITE_PERMISSION, "bean")); + final var user = new AuthData(Collections.singleton("user"), false, "user", permission(READ_PERMISSION, "bean")); + + // identity has one permission and action, annotation has 2 permissions and action, one of permission/action is matching + assertSuccess(() -> writeReadWithActionBean.write(), WRITE_PERMISSION_BEAN, admin); + assertSuccess(writeReadWithActionBean.writeNonBlocking(), WRITE_PERMISSION_BEAN, admin); + assertSuccess(() -> writeReadWithActionBean.read(), READ_PERMISSION_BEAN, user); + assertSuccess(writeReadWithActionBean.readNonBlocking(), READ_PERMISSION_BEAN, user); + + // identity has one permission and action, annotation has 2 permissions and action, one permission is matching, but action differs + final var admin2 = new AuthData(Collections.singleton("admin"), false, "admin", permission(WRITE_PERMISSION, "bean2")); + assertFailureFor(() -> writeReadWithActionBean.write(), ForbiddenException.class, admin2); + assertFailureFor(writeReadWithActionBean.writeNonBlocking(), ForbiddenException.class, admin2); + } + + static Set permission(String permissionName, String... actions) { + return Set.of(new StringPermission(permissionName, actions)); + } + + @PermissionsAllowed(WRITE_PERMISSION) + @Singleton + public static class SingleAnnotationWriteBean { + + public final String write() { + return WRITE_PERMISSION; + } + + public final Uni writeNonBlocking() { + return Uni.createFrom().item(WRITE_PERMISSION); + } + } + + @PermissionsAllowed(WRITE_PERMISSION_BEAN) + @Singleton + public static class SingleAnnotationWriteWithActionBean { + + public final String write() { + return WRITE_PERMISSION; + } + + public final Uni writeNonBlocking() { + return Uni.createFrom().item(WRITE_PERMISSION); + } + } + + @PermissionsAllowed({ WRITE_PERMISSION, READ_PERMISSION }) + @Singleton + public static class MultipleWriteReadBean { + + public final String write() { + return WRITE_PERMISSION; + } + + public final String read() { + return READ_PERMISSION; + } + + public final Uni writeNonBlocking() { + return Uni.createFrom().item(WRITE_PERMISSION); + } + + public final Uni readNonBlocking() { + return Uni.createFrom().item(READ_PERMISSION); + } + } + + @PermissionsAllowed({ WRITE_PERMISSION_BEAN, READ_PERMISSION_BEAN }) + @Singleton + public static class MultipleWriteReadWithActionBean { + + public final String write() { + return WRITE_PERMISSION_BEAN; + } + + public final String read() { + return READ_PERMISSION_BEAN; + } + + public final Uni writeNonBlocking() { + return Uni.createFrom().item(WRITE_PERMISSION_BEAN); + } + + public final Uni readNonBlocking() { + return Uni.createFrom().item(READ_PERMISSION_BEAN); + } + } +} 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 new file mode 100644 index 00000000000000..e64b9a2edba6c5 --- /dev/null +++ b/extensions/security/deployment/src/test/java/io/quarkus/security/test/permissionsallowed/InjectionPermissionsAllowedTest.java @@ -0,0 +1,137 @@ +package io.quarkus.security.test.permissionsallowed; + +import static io.quarkus.security.test.utils.SecurityTestUtils.assertFailureFor; +import static io.quarkus.security.test.utils.SecurityTestUtils.assertSuccess; + +import java.security.Permission; +import java.util.Collections; +import java.util.Objects; +import java.util.Set; + +import jakarta.inject.Inject; +import jakarta.inject.Singleton; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import io.quarkus.arc.Arc; +import io.quarkus.arc.Unremovable; +import io.quarkus.security.ForbiddenException; +import io.quarkus.security.PermissionsAllowed; +import io.quarkus.security.UnauthorizedException; +import io.quarkus.security.test.utils.AuthData; +import io.quarkus.security.test.utils.IdentityMock; +import io.quarkus.security.test.utils.SecurityTestUtils; +import io.quarkus.test.QuarkusUnitTest; +import io.smallrye.mutiny.Uni; + +public class InjectionPermissionsAllowedTest { + + private static final String IGNORED = "ignored"; + private static final Set CHECKING_PERMISSION = Set.of(new Permission("permission_name") { + @Override + public boolean implies(Permission permission) { + return permission.implies(this); + } + + @Override + public boolean equals(Object obj) { + return false; + } + + @Override + public int hashCode() { + return 0; + } + + @Override + public String getActions() { + return null; + } + }); + private static final String SUCCESS = "success"; + + @RegisterExtension + static final QuarkusUnitTest config = new QuarkusUnitTest() + .withApplicationRoot((jar) -> jar + .addClasses(IdentityMock.class, AuthData.class, SecurityTestUtils.class)); + + @Inject + SecuredBean securedBean; + + @Test + public void testInjection() { + var anonymous = new AuthData(null, true, null, CHECKING_PERMISSION); + var user = new AuthData(Collections.singleton("user"), false, "user", CHECKING_PERMISSION); + + // tests you can access bean via 'Arc.container()' + assertSuccess(() -> securedBean.injection("hello", "world", "!"), SUCCESS, user); + assertFailureFor(() -> securedBean.injection("what", "ever", "?"), ForbiddenException.class, user); + assertFailureFor(() -> securedBean.injection("what", "ever", "?"), UnauthorizedException.class, anonymous); + assertSuccess(securedBean.injectionNonBlocking("hello", "world", "!"), SUCCESS, user); + assertFailureFor(securedBean.injectionNonBlocking("what", "ever", "?"), ForbiddenException.class, user); + assertFailureFor(securedBean.injectionNonBlocking("what", "ever", "?"), UnauthorizedException.class, + anonymous); + } + + @PermissionsAllowed(value = IGNORED, permission = AllStrAutodetectedPermission.class) + @Singleton + public static class SecuredBean { + + public String injection(String hello, String world, String exclamationMark) { + return SUCCESS; + } + + public Uni injectionNonBlocking(String hello, String world, String exclamationMark) { + return Uni.createFrom().item(SUCCESS); + } + + } + + public static class AllStrAutodetectedPermission extends Permission { + private final boolean pass; + + public AllStrAutodetectedPermission(String name, String[] actions, String str1, String str2, String str3) { + super(name); + var sourceOfTruth = Arc.container().instance(SourceOfTruth.class).get(); + this.pass = "hello".equals(str1) && "world".equals(str2) && "!".equals(str3) && sourceOfTruth.shouldPass(); + } + + @Override + public boolean implies(Permission permission) { + return pass; + } + + @Override + public boolean equals(Object o) { + if (this == o) + return true; + if (o == null || getClass() != o.getClass()) + return false; + AllStrAutodetectedPermission that = (AllStrAutodetectedPermission) o; + return pass == that.pass; + } + + @Override + public int hashCode() { + return Objects.hash(pass); + } + + @Override + public String getActions() { + return null; + } + + } + + @Unremovable + @Singleton + public static class SourceOfTruth { + + public boolean shouldPass() { + return true; + } + + } + +} 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 new file mode 100644 index 00000000000000..7242dcd18748e8 --- /dev/null +++ b/extensions/security/deployment/src/test/java/io/quarkus/security/test/permissionsallowed/MethodLevelComputedPermissionsAllowedTest.java @@ -0,0 +1,440 @@ +package io.quarkus.security.test.permissionsallowed; + +import static io.quarkus.security.test.utils.SecurityTestUtils.assertFailureFor; +import static io.quarkus.security.test.utils.SecurityTestUtils.assertSuccess; + +import java.security.Permission; +import java.util.Collections; +import java.util.Objects; +import java.util.Set; + +import jakarta.inject.Inject; +import jakarta.inject.Singleton; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import io.quarkus.security.ForbiddenException; +import io.quarkus.security.PermissionsAllowed; +import io.quarkus.security.StringPermission; +import io.quarkus.security.UnauthorizedException; +import io.quarkus.security.test.utils.AuthData; +import io.quarkus.security.test.utils.IdentityMock; +import io.quarkus.security.test.utils.SecurityTestUtils; +import io.quarkus.test.QuarkusUnitTest; +import io.smallrye.mutiny.Uni; + +public class MethodLevelComputedPermissionsAllowedTest { + + private static final String IGNORED = "ignored"; + private static final Set CHECKING_PERMISSION = Set.of(new Permission("permission_name") { + @Override + public boolean implies(Permission permission) { + // the point here is to invoke permission check + // as tests in this class decides based on method inputs + // if incoming permission is StringPermission, we pass it identical permission + // as we need to test combination of computed and non-computed checks + if (permission instanceof StringPermission) { + return permission.implies(new StringPermission(permission.getName())); + } + return permission.implies(this); + } + + @Override + public boolean equals(Object obj) { + return false; + } + + @Override + public int hashCode() { + return 0; + } + + @Override + public String getActions() { + return null; + } + }); + private static final String SUCCESS = "success"; + + @RegisterExtension + static final QuarkusUnitTest config = new QuarkusUnitTest() + .withApplicationRoot((jar) -> jar + .addClasses(IdentityMock.class, AuthData.class, SecurityTestUtils.class)); + + @Inject + SecuredBean securedBean; + + @Test + public void testAutodetectedParams() { + var anonymous = new AuthData(null, true, null, CHECKING_PERMISSION); + var user = new AuthData(Collections.singleton("user"), false, "user", CHECKING_PERMISSION); + + // secured method has exactly same parameters as Permission constructor (except of permission name and actions) + assertSuccess(() -> securedBean.autodetect("hello", "world", "!"), SUCCESS, user); + assertFailureFor(() -> securedBean.autodetect("what", "ever", "?"), ForbiddenException.class, user); + assertFailureFor(() -> securedBean.autodetect("what", "ever", "?"), UnauthorizedException.class, anonymous); + assertSuccess(securedBean.autodetectNonBlocking("hello", "world", "!"), SUCCESS, user); + assertFailureFor(securedBean.autodetectNonBlocking("what", "ever", "?"), ForbiddenException.class, user); + assertFailureFor(securedBean.autodetectNonBlocking("what", "ever", "?"), UnauthorizedException.class, anonymous); + + // secured method has more parameters with all variety of data types, while Permission constructor accepts 3 'int' params + assertSuccess(() -> securedBean.autodetect(1, "something", 2, 3, new Object(), null), SUCCESS, user); + assertFailureFor(() -> securedBean.autodetect(1, "something", 5, 3, new Object(), null), ForbiddenException.class, + user); + assertSuccess(securedBean.autodetectNonBlocking(1, "something", 2, 3, new Object(), null), SUCCESS, user); + assertFailureFor(securedBean.autodetectNonBlocking(1, "something", 5, 3, new Object(), null), ForbiddenException.class, + user); + + // inheritance (constructor is called with actions and that is checked) + assertSuccess(() -> securedBean.autodetect(new Child(true)), SUCCESS, user); + assertFailureFor(() -> securedBean.autodetect(new Child(false)), ForbiddenException.class, user); + assertSuccess(securedBean.autodetectNonBlocking(new Child(true)), SUCCESS, user); + assertFailureFor(securedBean.autodetectNonBlocking(new Child(false)), ForbiddenException.class, user); + + // in addition to inheritance calls right above, here 2 permissions are created and tested for 1 annotation (both with actions) + assertSuccess(() -> securedBean.autodetectMultiplePermissions(new Child(true)), SUCCESS, user); + assertFailureFor(() -> securedBean.autodetectMultiplePermissions(new Child(false)), ForbiddenException.class, user); + assertSuccess(securedBean.autodetectMultiplePermissionsNonBlocking(new Child(true)), SUCCESS, user); + assertFailureFor(securedBean.autodetectMultiplePermissionsNonBlocking(new Child(false)), ForbiddenException.class, + user); + } + + @Test + public void testExplicitlyMarkedParams() { + var anonymous = new AuthData(null, true, null, CHECKING_PERMISSION); + var user = new AuthData(Collections.singleton("user"), false, "user", CHECKING_PERMISSION); + + // secured method 'sayHelloWorld' accepts multiple arguments, however only 'hello', 'world' and 'exclamationMark' + // are passed to Permission constructor as specified by 'params' attribute + assertSuccess(() -> securedBean.explicitlyDeclaredParams("something", "hello", "whatever", "world", "!", 1), SUCCESS, + user); + assertFailureFor(() -> securedBean.explicitlyDeclaredParams("something", "test", "whatever", "world", "!", 1), + ForbiddenException.class, user); + assertFailureFor(() -> securedBean.explicitlyDeclaredParams("something", "hello", "whatever", "rest", "!", 1), + UnauthorizedException.class, anonymous); + + // same as above, however method returns reactive data type, therefore the check is done asynchronously + assertSuccess(securedBean.explicitlyDeclaredParamsNonBlocking("something", "hello", "whatever", "world", "!", 1), + SUCCESS, user); + assertFailureFor(securedBean.explicitlyDeclaredParamsNonBlocking("something", "test", "whatever", "world", "!", 1), + ForbiddenException.class, user); + assertFailureFor(securedBean.explicitlyDeclaredParamsNonBlocking("something", "hello", "whatever", "rest", "!", 1), + UnauthorizedException.class, anonymous); + + // inheritance - Permission constructor accepts Parent while secured method accepts Child, should work + // as user explicitly marked param via 'params = "obj"' + // this test also differs from above ones in that Permission does not accept actions + assertSuccess( + securedBean.explicitlyDeclaredParamsInheritanceNonBlocking("something", "hello", "whatever", "world", "!", 1, + new Child(true)), + SUCCESS, user); + assertFailureFor( + securedBean.explicitlyDeclaredParamsInheritanceNonBlocking("something", "test", "whatever", "world", "!", 1, + new Child(false)), + ForbiddenException.class, user); + assertSuccess( + () -> securedBean.explicitlyDeclaredParamsInheritance("something", "hello", "whatever", "world", "!", 1, + new Child(true)), + SUCCESS, user); + assertFailureFor( + () -> securedBean.explicitlyDeclaredParamsInheritance("something", "test", "whatever", "world", "!", 1, + new Child(false)), + ForbiddenException.class, user); + } + + @Test + public void testCombinationOfComputedAndPlainPermissions() { + var user = new AuthData(Collections.singleton("user"), false, "user", CHECKING_PERMISSION); + + // one permission is computed (AKA its accepts method params) and the other one is created at runtime init + assertSuccess(() -> securedBean.combination(new Child(true)), SUCCESS, user); + assertFailureFor(() -> securedBean.combination(new Child(false)), ForbiddenException.class, user); + assertSuccess(securedBean.combinationNonBlocking(new Child(true)), SUCCESS, user); + assertFailureFor(securedBean.combinationNonBlocking(new Child(false)), ForbiddenException.class, user); + } + + @Singleton + public static class SecuredBean { + + @PermissionsAllowed(value = IGNORED, permission = AllStrAutodetectedPermission.class) + public String autodetect(String hello, String world, String exclamationMark) { + return SUCCESS; + } + + @PermissionsAllowed(value = IGNORED, permission = AllIntAutodetectedPermission.class) + public String autodetect(int one, String world, int two, int three, Object obj1, Object obj2) { + return SUCCESS; + } + + @PermissionsAllowed(value = "permissionName:action1234", permission = InheritanceWithActionsPermission.class) + public String autodetect(Parent parent) { + return SUCCESS; + } + + @PermissionsAllowed(value = { "permissionName:action1234", + "permission1:action1" }, permission = InheritanceWithActionsPermission.class) + public String autodetectMultiplePermissions(Parent parent) { + return SUCCESS; + } + + @PermissionsAllowed(value = "permissionName:action1234", permission = InheritanceWithActionsPermission.class) + public Uni autodetectNonBlocking(Parent parent) { + return Uni.createFrom().item(SUCCESS); + } + + @PermissionsAllowed(value = { "permissionName:action1234", + "permission1:action1" }, permission = InheritanceWithActionsPermission.class) + public Uni autodetectMultiplePermissionsNonBlocking(Parent parent) { + return Uni.createFrom().item(SUCCESS); + } + + @PermissionsAllowed(value = IGNORED, permission = AllStrAutodetectedPermission.class) + public Uni autodetectNonBlocking(String hello, String world, String exclamationMark) { + return Uni.createFrom().item(SUCCESS); + } + + @PermissionsAllowed(value = IGNORED, permission = AllIntAutodetectedPermission.class) + public Uni autodetectNonBlocking(int one, String world, int two, int three, Object obj1, Object obj2) { + return Uni.createFrom().item(SUCCESS); + } + + @PermissionsAllowed(value = IGNORED, permission = AllStrMatchingParamsPermission.class, params = { + "hello", "world", "exclamationMark" }) + public String explicitlyDeclaredParams(String something, String hello, String whatever, String world, + String exclamationMark, int i) { + return SUCCESS; + } + + @PermissionsAllowed(value = IGNORED, permission = AllStrMatchingParamsPermission.class, params = { + "hello", "world", "exclamationMark" }) + public Uni explicitlyDeclaredParamsNonBlocking(String something, String hello, String whatever, String world, + String exclamationMark, int i) { + return Uni.createFrom().item(SUCCESS); + } + + @PermissionsAllowed(value = IGNORED, permission = InheritancePermission.class, params = "obj") + public String explicitlyDeclaredParamsInheritance(String something, String hello, String whatever, String world, + String exclamationMark, int i, Child obj) { + return SUCCESS; + } + + @PermissionsAllowed(value = IGNORED, permission = InheritancePermission.class, params = "obj") + public Uni explicitlyDeclaredParamsInheritanceNonBlocking(String something, String hello, String whatever, + String world, String exclamationMark, int i, Child obj) { + return Uni.createFrom().item(SUCCESS); + } + + @PermissionsAllowed("read") + @PermissionsAllowed(value = "permissionName:action1234", permission = InheritanceWithActionsPermission.class) + public Uni combinationNonBlocking(Parent parent) { + return Uni.createFrom().item(SUCCESS); + } + + @PermissionsAllowed("read") + @PermissionsAllowed(value = "permissionName:action1234", permission = InheritanceWithActionsPermission.class) + public String combination(Parent parent) { + return SUCCESS; + } + } + + public interface Parent { + + boolean shouldPass(); + + } + + public static class Child implements Parent { + + private final boolean pass; + + public Child(boolean pass) { + this.pass = pass; + } + + @Override + public boolean shouldPass() { + return pass; + } + } + + public static class InheritanceWithActionsPermission extends Permission { + private final boolean pass; + + public InheritanceWithActionsPermission(String name, String[] actions, Parent obj) { + super(name); + this.pass = obj != null && obj.shouldPass() && actions != null && actions.length == 1 + && "action1234".equals(actions[0]); + } + + @Override + public boolean implies(Permission permission) { + return pass; + } + + @Override + public boolean equals(Object o) { + if (this == o) + return true; + if (o == null || getClass() != o.getClass()) + return false; + InheritanceWithActionsPermission that = (InheritanceWithActionsPermission) o; + return pass == that.pass; + } + + @Override + public int hashCode() { + return Objects.hash(pass); + } + + @Override + public String getActions() { + return null; + } + + } + + public static class InheritancePermission extends Permission { + private final boolean pass; + + public InheritancePermission(String name, Parent obj) { + super(name); + this.pass = obj != null && obj.shouldPass(); + } + + @Override + public boolean implies(Permission permission) { + return pass; + } + + @Override + public boolean equals(Object o) { + if (this == o) + return true; + if (o == null || getClass() != o.getClass()) + return false; + InheritancePermission that = (InheritancePermission) o; + return pass == that.pass; + } + + @Override + public int hashCode() { + return Objects.hash(pass); + } + + @Override + public String getActions() { + return null; + } + + } + + public static class AllStrAutodetectedPermission extends Permission { + private final boolean pass; + + public AllStrAutodetectedPermission(String name, String[] actions, String str1, String str2, String str3) { + super(name); + this.pass = "hello".equals(str1) && "world".equals(str2) && "!".equals(str3); + } + + @Override + public boolean implies(Permission permission) { + return pass; + } + + @Override + public boolean equals(Object o) { + if (this == o) + return true; + if (o == null || getClass() != o.getClass()) + return false; + AllStrAutodetectedPermission that = (AllStrAutodetectedPermission) o; + return pass == that.pass; + } + + @Override + public int hashCode() { + return Objects.hash(pass); + } + + @Override + public String getActions() { + return null; + } + + } + + public static class AllIntAutodetectedPermission extends Permission { + private final boolean pass; + + public AllIntAutodetectedPermission(String name, String[] actions, int i, int j, int k) { + 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; + } + + @Override + public boolean implies(Permission permission) { + return pass; + } + + @Override + public boolean equals(Object o) { + if (this == o) + return true; + if (o == null || getClass() != o.getClass()) + return false; + AllStrAutodetectedPermission that = (AllStrAutodetectedPermission) o; + return pass == that.pass; + } + + @Override + public int hashCode() { + return Objects.hash(pass); + } + + @Override + public String getActions() { + return null; + } + + } + + public static class AllStrMatchingParamsPermission extends Permission { + private final boolean pass; + + public AllStrMatchingParamsPermission(String name, String[] actions, String hello, String world, + String exclamationMark) { + super(name); + // constructor param names must exactly match secured method params as we explicitly marked them + this.pass = "hello".equals(hello) && "world".equals(world) && "!".equals(exclamationMark); + } + + @Override + public boolean implies(Permission permission) { + return pass; + } + + @Override + public boolean equals(Object o) { + if (this == o) + return true; + if (o == null || getClass() != o.getClass()) + return false; + AllStrMatchingParamsPermission that = (AllStrMatchingParamsPermission) o; + return pass == that.pass; + } + + @Override + public int hashCode() { + return Objects.hash(pass); + } + + @Override + public String getActions() { + return null; + } + + } +} diff --git a/extensions/security/deployment/src/test/java/io/quarkus/security/test/permissionsallowed/MethodLevelCustomPermissionsAllowedTest.java b/extensions/security/deployment/src/test/java/io/quarkus/security/test/permissionsallowed/MethodLevelCustomPermissionsAllowedTest.java new file mode 100644 index 00000000000000..589d8614b5a903 --- /dev/null +++ b/extensions/security/deployment/src/test/java/io/quarkus/security/test/permissionsallowed/MethodLevelCustomPermissionsAllowedTest.java @@ -0,0 +1,373 @@ +package io.quarkus.security.test.permissionsallowed; + +import static io.quarkus.security.test.utils.SecurityTestUtils.assertFailureFor; +import static io.quarkus.security.test.utils.SecurityTestUtils.assertSuccess; + +import java.security.Permission; +import java.util.Collections; +import java.util.Objects; +import java.util.Set; + +import jakarta.inject.Inject; +import jakarta.inject.Singleton; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import io.quarkus.security.ForbiddenException; +import io.quarkus.security.PermissionsAllowed; +import io.quarkus.security.StringPermission; +import io.quarkus.security.test.utils.AuthData; +import io.quarkus.security.test.utils.IdentityMock; +import io.quarkus.security.test.utils.SecurityTestUtils; +import io.quarkus.test.QuarkusUnitTest; +import io.smallrye.mutiny.Uni; + +public class MethodLevelCustomPermissionsAllowedTest extends AbstractMethodLevelPermissionsAllowedTest { + + @RegisterExtension + static final QuarkusUnitTest config = new QuarkusUnitTest() + .withApplicationRoot((jar) -> jar + .addClasses(IdentityMock.class, AuthData.class, SecurityTestUtils.class)); + + @Inject + PermissionsAllowedNameOnlyBean nameOnlyBean; + + @Inject + PermissionsAllowedNameAndActionsOnlyBean nameAndActionsBean; + + @Inject + MultiplePermissionsAllowedBean multiplePermissionsAllowedBean; + + @Inject + PermissionsAllowedWithoutActions withoutActionsBean; + + @Override + protected MultiplePermissionsAllowedBeanI getMultiplePermissionsAllowedBean() { + return multiplePermissionsAllowedBean; + } + + @Override + protected PermissionsAllowedNameOnlyBeanI getPermissionsAllowedNameOnlyBean() { + return nameOnlyBean; + } + + @Override + protected PermissionsAllowedNameAndActionsOnlyBeanI getPermissionsAllowedNameAndActionsOnlyBean() { + return nameAndActionsBean; + } + + @Override + protected Permission createPermission(String name, String... actions) { + return new CustomPermission(name, actions); + } + + @Test + public void testCustomAnnotationWithoutActions() { + AuthData admin = new AuthData(Collections.singleton("admin"), false, "admin", + Set.of(new CustomPermissionNameOnly("write"))); + + // custom annotation with constructor that does not accept actions + assertSuccess(() -> withoutActionsBean.write(), WRITE_PERMISSION, admin); + assertSuccess(withoutActionsBean.writeNonBlocking(), WRITE_PERMISSION, admin); + + // identity has one permission, annotation has different permission + assertFailureFor(() -> withoutActionsBean.prohibited(), ForbiddenException.class, admin); + assertFailureFor(withoutActionsBean.prohibitedNonBlocking(), ForbiddenException.class, admin); + } + + /** + * This permission does not accept actions, it is important in order to test class instantiation that differs + * for actions/without actions. + */ + public static class CustomPermissionNameOnly extends Permission { + + private final Permission delegate; + + public CustomPermissionNameOnly(String name) { + super(name); + this.delegate = new StringPermission(name); + } + + @Override + public boolean implies(Permission permission) { + if (permission instanceof CustomPermissionNameOnly) { + return delegate.implies(((CustomPermissionNameOnly) permission).delegate); + } + return false; + } + + @Override + public boolean equals(Object o) { + if (this == o) + return true; + if (o == null || getClass() != o.getClass()) + return false; + CustomPermissionNameOnly that = (CustomPermissionNameOnly) o; + return delegate.equals(that.delegate); + } + + @Override + public int hashCode() { + return Objects.hash(delegate); + } + + @Override + public String getActions() { + return delegate.getActions(); + } + } + + public static class CustomPermission extends Permission { + + private final Permission delegate; + + public CustomPermission(String name, String... actions) { + super(name); + this.delegate = new StringPermission(name, actions); + } + + @Override + public boolean implies(Permission permission) { + if (permission instanceof CustomPermission) { + return delegate.implies(((CustomPermission) permission).delegate); + } + return false; + } + + @Override + public boolean equals(Object o) { + if (this == o) + return true; + if (o == null || getClass() != o.getClass()) + return false; + CustomPermission that = (CustomPermission) o; + return delegate.equals(that.delegate); + } + + @Override + public int hashCode() { + return Objects.hash(delegate); + } + + @Override + public String getActions() { + return delegate.getActions(); + } + } + + @Singleton + public static class PermissionsAllowedWithoutActions { + + @PermissionsAllowed(value = WRITE_PERMISSION, permission = CustomPermissionNameOnly.class) + public final String write() { + return WRITE_PERMISSION; + } + + @PermissionsAllowed(value = WRITE_PERMISSION, permission = CustomPermissionNameOnly.class) + public final Uni writeNonBlocking() { + return Uni.createFrom().item(WRITE_PERMISSION); + } + + @PermissionsAllowed(value = "prohibited", permission = CustomPermissionNameOnly.class) + public final void prohibited() { + } + + @PermissionsAllowed(value = "prohibited", permission = CustomPermissionNameOnly.class) + public final Uni prohibitedNonBlocking() { + return Uni.createFrom().nullItem(); + } + } + + @Singleton + public static class PermissionsAllowedNameOnlyBean implements PermissionsAllowedNameOnlyBeanI { + + @PermissionsAllowed(value = WRITE_PERMISSION, permission = CustomPermission.class) + public final String write() { + return WRITE_PERMISSION; + } + + @PermissionsAllowed(value = READ_PERMISSION, permission = CustomPermission.class) + public final String read() { + return READ_PERMISSION; + } + + @PermissionsAllowed(value = WRITE_PERMISSION, permission = CustomPermission.class) + public final Uni writeNonBlocking() { + return Uni.createFrom().item(WRITE_PERMISSION); + } + + @PermissionsAllowed(value = READ_PERMISSION, permission = CustomPermission.class) + public final Uni readNonBlocking() { + return Uni.createFrom().item(READ_PERMISSION); + } + + @PermissionsAllowed(value = "prohibited", permission = CustomPermission.class) + public final void prohibited() { + } + + @PermissionsAllowed(value = "prohibited", permission = CustomPermission.class) + public final Uni prohibitedNonBlocking() { + return Uni.createFrom().nullItem(); + } + + @PermissionsAllowed(value = { "one", "two", "three", READ_PERMISSION }, permission = CustomPermission.class) + public final String multiple() { + return MULTIPLE_PERMISSION; + } + + @PermissionsAllowed(value = { "one", "two", "three", READ_PERMISSION }, permission = CustomPermission.class) + public final Uni multipleNonBlocking() { + return Uni.createFrom().item(MULTIPLE_PERMISSION); + } + + } + + @Singleton + public static class PermissionsAllowedNameAndActionsOnlyBean implements PermissionsAllowedNameAndActionsOnlyBeanI { + + @PermissionsAllowed(value = WRITE_PERMISSION_BEAN, permission = CustomPermission.class) + public final String write() { + return WRITE_PERMISSION_BEAN; + } + + @PermissionsAllowed(value = READ_PERMISSION_BEAN, permission = CustomPermission.class) + public final String read() { + return READ_PERMISSION_BEAN; + } + + @PermissionsAllowed(value = WRITE_PERMISSION_BEAN, permission = CustomPermission.class) + public final Uni writeNonBlocking() { + return Uni.createFrom().item(WRITE_PERMISSION_BEAN); + } + + @PermissionsAllowed(value = READ_PERMISSION_BEAN, permission = CustomPermission.class) + public final Uni readNonBlocking() { + return Uni.createFrom().item(READ_PERMISSION_BEAN); + } + + @PermissionsAllowed(value = "prohibited:bean", permission = CustomPermission.class) + public final void prohibited() { + } + + @PermissionsAllowed(value = "prohibited:bean", permission = CustomPermission.class) + public final Uni prohibitedNonBlocking() { + return Uni.createFrom().nullItem(); + } + + @PermissionsAllowed(value = { "one:a", "two:b", "three:c", + READ_PERMISSION_BEAN }, permission = CustomPermission.class) + public final String multiple() { + return MULTIPLE_BEAN; + } + + @PermissionsAllowed(value = { "one:a", "two:b", "three:c", + READ_PERMISSION_BEAN }, permission = CustomPermission.class) + public final Uni multipleNonBlocking() { + return Uni.createFrom().item(MULTIPLE_BEAN); + } + + @PermissionsAllowed(value = { "one:a", "two:b", "three:c", "one:b", "two:a", "three:a", READ_PERMISSION_BEAN, + "read:meal" }, permission = CustomPermission.class) + public final String multipleActions() { + return MULTIPLE_BEAN; + } + + @PermissionsAllowed(value = { "one:a", "two:b", "three:c", "one:b", "two:a", "three:a", READ_PERMISSION_BEAN, + "read:meal" }, permission = CustomPermission.class) + public final Uni multipleNonBlockingActions() { + return Uni.createFrom().item(MULTIPLE_BEAN); + } + + @PermissionsAllowed(value = { "one", "two", "three:c", "three", + READ_PERMISSION }, permission = CustomPermission.class) + public final String combination() { + return MULTIPLE_BEAN; + } + + @PermissionsAllowed(value = { "one", "two", "three:c", "three", + READ_PERMISSION }, permission = CustomPermission.class) + public final Uni combinationNonBlockingActions() { + return Uni.createFrom().item(MULTIPLE_BEAN); + } + + @PermissionsAllowed(value = { "one", "two", "three:c", "three", "read:bread", + "read:meal" }, permission = CustomPermission.class) + public final String combination2() { + return "combination2"; + } + + @PermissionsAllowed(value = { "one", "two", "three:c", "three", "read:bread", + "read:meal" }, permission = CustomPermission.class) + public final Uni combination2NonBlockingActions() { + return Uni.createFrom().item("combination2"); + } + + } + + @Singleton + public static class MultiplePermissionsAllowedBean implements MultiplePermissionsAllowedBeanI { + + @PermissionsAllowed(value = "update", permission = CustomPermission.class) + @PermissionsAllowed(value = "create", permission = CustomPermission.class) + public String createOrUpdate() { + return "create_or_update"; + } + + @PermissionsAllowed(value = "create", permission = CustomPermission.class) + @PermissionsAllowed(value = "update", permission = CustomPermission.class) + public Uni createOrUpdateNonBlocking() { + return Uni.createFrom().item("create_or_update"); + } + + @PermissionsAllowed(value = "see", permission = CustomPermission.class) + @PermissionsAllowed(value = "view", permission = CustomPermission.class) + public String getOne() { + return "see_or_view_detail"; + } + + @PermissionsAllowed(value = "view", permission = CustomPermission.class) + @PermissionsAllowed(value = "see", permission = CustomPermission.class) + public Uni getOneNonBlocking() { + return Uni.createFrom().item("see_or_view_detail"); + } + + @PermissionsAllowed(value = { "operand1", "operand2", "operand3" }, permission = CustomPermission.class) + @PermissionsAllowed(value = { "operand4", "operand5" }, permission = CustomPermission.class) + @PermissionsAllowed(value = { "operand6", "operand7" }, permission = CustomPermission.class) + @PermissionsAllowed(value = { "operand8" }, permission = CustomPermission.class) + public Uni predicateNonBlocking() { + // (operand1 OR operand2 OR operand3) AND (operand4 OR operand5) AND (operand6 OR operand7) AND operand8 + return Uni.createFrom().item(PREDICATE); + } + + @PermissionsAllowed(value = { "operand1", "operand2", "operand3" }, permission = CustomPermission.class) + @PermissionsAllowed(value = { "operand4", "operand5" }, permission = CustomPermission.class) + @PermissionsAllowed(value = { "operand6", "operand7" }, permission = CustomPermission.class) + @PermissionsAllowed(value = { "operand8" }, permission = CustomPermission.class) + public String predicate() { + // (permission1:action1 OR permission2:action2) AND (permission1:action2 OR permission2:action1) + return PREDICATE; + } + + @PermissionsAllowed(value = { "permission1:action1", + "permission2:action2" }, permission = CustomPermission.class) + @PermissionsAllowed(value = { "permission1:action2", + "permission2:action1" }, permission = CustomPermission.class) + public String actionsPredicate() { + // (permission1:action1 OR permission2:action2) AND (permission1:action2 OR permission2:action1) + return PREDICATE; + } + + @PermissionsAllowed(value = { "permission1:action1", + "permission2:action2" }, permission = CustomPermission.class) + @PermissionsAllowed(value = { "permission1:action2", + "permission2:action1" }, permission = CustomPermission.class) + public Uni actionsPredicateNonBlocking() { + // (permission1:action1 OR permission2:action2) AND (permission1:action2 OR permission2:action1) + return Uni.createFrom().item(PREDICATE); + } + + } + +} diff --git a/extensions/security/deployment/src/test/java/io/quarkus/security/test/permissionsallowed/MethodLevelStringPermissionsAllowedTest.java b/extensions/security/deployment/src/test/java/io/quarkus/security/test/permissionsallowed/MethodLevelStringPermissionsAllowedTest.java new file mode 100644 index 00000000000000..d9b64bdc6be32c --- /dev/null +++ b/extensions/security/deployment/src/test/java/io/quarkus/security/test/permissionsallowed/MethodLevelStringPermissionsAllowedTest.java @@ -0,0 +1,233 @@ +package io.quarkus.security.test.permissionsallowed; + +import java.security.Permission; + +import jakarta.inject.Inject; +import jakarta.inject.Singleton; + +import org.junit.jupiter.api.extension.RegisterExtension; + +import io.quarkus.security.PermissionsAllowed; +import io.quarkus.security.StringPermission; +import io.quarkus.security.test.utils.AuthData; +import io.quarkus.security.test.utils.IdentityMock; +import io.quarkus.security.test.utils.SecurityTestUtils; +import io.quarkus.test.QuarkusUnitTest; +import io.smallrye.mutiny.Uni; + +public class MethodLevelStringPermissionsAllowedTest extends AbstractMethodLevelPermissionsAllowedTest { + + @RegisterExtension + static final QuarkusUnitTest config = new QuarkusUnitTest() + .withApplicationRoot((jar) -> jar + .addClasses(IdentityMock.class, AuthData.class, SecurityTestUtils.class)); + + @Inject + PermissionsAllowedNameOnlyBean nameOnlyBean; + + @Inject + PermissionsAllowedNameAndActionsOnlyBean nameAndActionsBean; + + @Inject + MultiplePermissionsAllowedBean multiplePermissionsAllowedBean; + + @Override + protected MultiplePermissionsAllowedBeanI getMultiplePermissionsAllowedBean() { + return multiplePermissionsAllowedBean; + } + + @Override + protected PermissionsAllowedNameOnlyBeanI getPermissionsAllowedNameOnlyBean() { + return nameOnlyBean; + } + + @Override + protected PermissionsAllowedNameAndActionsOnlyBeanI getPermissionsAllowedNameAndActionsOnlyBean() { + return nameAndActionsBean; + } + + @Override + protected Permission createPermission(String name, String... actions) { + return new StringPermission(name, actions); + } + + @Singleton + public static class PermissionsAllowedNameOnlyBean implements PermissionsAllowedNameOnlyBeanI { + + @PermissionsAllowed(WRITE_PERMISSION) + public final String write() { + return WRITE_PERMISSION; + } + + @PermissionsAllowed(READ_PERMISSION) + public final String read() { + return READ_PERMISSION; + } + + @PermissionsAllowed(WRITE_PERMISSION) + public final Uni writeNonBlocking() { + return Uni.createFrom().item(WRITE_PERMISSION); + } + + @PermissionsAllowed(READ_PERMISSION) + public final Uni readNonBlocking() { + return Uni.createFrom().item(READ_PERMISSION); + } + + @PermissionsAllowed("prohibited") + public final void prohibited() { + } + + @PermissionsAllowed("prohibited") + public final Uni prohibitedNonBlocking() { + return Uni.createFrom().nullItem(); + } + + @PermissionsAllowed({ "one", "two", "three", READ_PERMISSION }) + public final String multiple() { + return MULTIPLE_PERMISSION; + } + + @PermissionsAllowed({ "one", "two", "three", READ_PERMISSION }) + public final Uni multipleNonBlocking() { + return Uni.createFrom().item(MULTIPLE_PERMISSION); + } + + } + + @Singleton + public static class PermissionsAllowedNameAndActionsOnlyBean implements PermissionsAllowedNameAndActionsOnlyBeanI { + + @PermissionsAllowed(WRITE_PERMISSION_BEAN) + public final String write() { + return WRITE_PERMISSION_BEAN; + } + + @PermissionsAllowed(READ_PERMISSION_BEAN) + public final String read() { + return READ_PERMISSION_BEAN; + } + + @PermissionsAllowed(WRITE_PERMISSION_BEAN) + public final Uni writeNonBlocking() { + return Uni.createFrom().item(WRITE_PERMISSION_BEAN); + } + + @PermissionsAllowed(READ_PERMISSION_BEAN) + public final Uni readNonBlocking() { + return Uni.createFrom().item(READ_PERMISSION_BEAN); + } + + @PermissionsAllowed("prohibited:bean") + public final void prohibited() { + } + + @PermissionsAllowed("prohibited:bean") + public final Uni prohibitedNonBlocking() { + return Uni.createFrom().nullItem(); + } + + @PermissionsAllowed({ "one:a", "two:b", "three:c", READ_PERMISSION_BEAN }) + public final String multiple() { + return MULTIPLE_BEAN; + } + + @PermissionsAllowed({ "one:a", "two:b", "three:c", READ_PERMISSION_BEAN }) + public final Uni multipleNonBlocking() { + return Uni.createFrom().item(MULTIPLE_BEAN); + } + + @PermissionsAllowed({ "one:a", "two:b", "three:c", "one:b", "two:a", "three:a", READ_PERMISSION_BEAN, "read:meal" }) + public final String multipleActions() { + return MULTIPLE_BEAN; + } + + @PermissionsAllowed({ "one:a", "two:b", "three:c", "one:b", "two:a", "three:a", READ_PERMISSION_BEAN, "read:meal" }) + public final Uni multipleNonBlockingActions() { + return Uni.createFrom().item(MULTIPLE_BEAN); + } + + @PermissionsAllowed({ "one", "two", "three:c", "three", READ_PERMISSION }) + public final String combination() { + return MULTIPLE_BEAN; + } + + @PermissionsAllowed({ "one", "two", "three:c", "three", READ_PERMISSION }) + public final Uni combinationNonBlockingActions() { + return Uni.createFrom().item(MULTIPLE_BEAN); + } + + @PermissionsAllowed({ "one", "two", "three:c", "three", "read:bread", "read:meal" }) + public final String combination2() { + return "combination2"; + } + + @PermissionsAllowed({ "one", "two", "three:c", "three", "read:bread", "read:meal" }) + public final Uni combination2NonBlockingActions() { + return Uni.createFrom().item("combination2"); + } + + } + + @Singleton + public static class MultiplePermissionsAllowedBean implements MultiplePermissionsAllowedBeanI { + + @PermissionsAllowed("update") + @PermissionsAllowed("create") + public String createOrUpdate() { + return "create_or_update"; + } + + @PermissionsAllowed("create") + @PermissionsAllowed("update") + public Uni createOrUpdateNonBlocking() { + return Uni.createFrom().item("create_or_update"); + } + + @PermissionsAllowed("see") + @PermissionsAllowed("view") + public String getOne() { + return "see_or_view_detail"; + } + + @PermissionsAllowed("view") + @PermissionsAllowed("see") + public Uni getOneNonBlocking() { + return Uni.createFrom().item("see_or_view_detail"); + } + + @PermissionsAllowed({ "operand1", "operand2", "operand3" }) + @PermissionsAllowed({ "operand4", "operand5" }) + @PermissionsAllowed({ "operand6", "operand7" }) + @PermissionsAllowed({ "operand8" }) + public Uni predicateNonBlocking() { + // (operand1 OR operand2 OR operand3) AND (operand4 OR operand5) AND (operand6 OR operand7) AND operand8 + return Uni.createFrom().item(PREDICATE); + } + + @PermissionsAllowed({ "operand1", "operand2", "operand3" }) + @PermissionsAllowed({ "operand4", "operand5" }) + @PermissionsAllowed({ "operand6", "operand7" }) + @PermissionsAllowed({ "operand8" }) + public String predicate() { + // (permission1:action1 OR permission2:action2) AND (permission1:action2 OR permission2:action1) + return PREDICATE; + } + + @PermissionsAllowed({ "permission1:action1", "permission2:action2" }) + @PermissionsAllowed({ "permission1:action2", "permission2:action1" }) + public String actionsPredicate() { + // (permission1:action1 OR permission2:action2) AND (permission1:action2 OR permission2:action1) + return PREDICATE; + } + + @PermissionsAllowed({ "permission1:action1", "permission2:action2" }) + @PermissionsAllowed({ "permission1:action2", "permission2:action1" }) + public Uni actionsPredicateNonBlocking() { + // (permission1:action1 OR permission2:action2) AND (permission1:action2 OR permission2:action1) + return Uni.createFrom().item(PREDICATE); + } + + } + +} diff --git a/extensions/security/deployment/src/test/java/io/quarkus/security/test/permissionsallowed/PermissionsIdentityAugmentor.java b/extensions/security/deployment/src/test/java/io/quarkus/security/test/permissionsallowed/PermissionsIdentityAugmentor.java new file mode 100644 index 00000000000000..6331c54851a200 --- /dev/null +++ b/extensions/security/deployment/src/test/java/io/quarkus/security/test/permissionsallowed/PermissionsIdentityAugmentor.java @@ -0,0 +1,45 @@ +package io.quarkus.security.test.permissionsallowed; + +import java.security.Permission; +import java.util.function.Function; + +import jakarta.enterprise.context.ApplicationScoped; + +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 PermissionsIdentityAugmentor implements SecurityIdentityAugmentor { + + @Override + public Uni augment(SecurityIdentity identity, AuthenticationRequestContext context) { + if (identity.isAnonymous()) { + return Uni.createFrom().item(identity); + } + return Uni.createFrom().item(build(identity)); + } + + SecurityIdentity build(SecurityIdentity identity) { + QuarkusSecurityIdentity.Builder builder = QuarkusSecurityIdentity.builder(identity); + if ("user".equals(identity.getPrincipal().getName())) { + builder.addPermissionChecker(createPermission("read")); + } + if ("admin".equals(identity.getPrincipal().getName())) { + builder.addPermissionChecker(createPermission("write")); + } + return builder.build(); + } + + private Function> createPermission(String permissionName) { + return new Function>() { + @Override + public Uni apply(Permission permission) { + return Uni.createFrom().item(permissionName.equals(permission.getName())); + } + }; + } + +} diff --git a/extensions/security/runtime-spi/src/main/java/io/quarkus/security/spi/runtime/SecurityCheck.java b/extensions/security/runtime-spi/src/main/java/io/quarkus/security/spi/runtime/SecurityCheck.java index ad82e4817c4f06..4318d06d93733d 100644 --- a/extensions/security/runtime-spi/src/main/java/io/quarkus/security/spi/runtime/SecurityCheck.java +++ b/extensions/security/runtime-spi/src/main/java/io/quarkus/security/spi/runtime/SecurityCheck.java @@ -3,13 +3,24 @@ import java.lang.reflect.Method; import io.quarkus.security.identity.SecurityIdentity; +import io.smallrye.mutiny.Uni; public interface SecurityCheck { void apply(SecurityIdentity identity, Method method, Object[] parameters); + default Uni nonBlockingApply(SecurityIdentity identity, Method method, Object[] parameters) { + apply(identity, method, parameters); + return Uni.createFrom().nullItem(); + } + void apply(SecurityIdentity identity, MethodDescription methodDescription, Object[] parameters); + default Uni nonBlockingApply(SecurityIdentity identity, MethodDescription methodDescription, Object[] parameters) { + apply(identity, methodDescription, parameters); + return Uni.createFrom().nullItem(); + } + default boolean isPermitAll() { return false; } diff --git a/extensions/security/runtime/src/main/java/io/quarkus/security/runtime/AnonymousIdentityProvider.java b/extensions/security/runtime/src/main/java/io/quarkus/security/runtime/AnonymousIdentityProvider.java index 4f77f844b88b63..a2094ccf273b88 100644 --- a/extensions/security/runtime/src/main/java/io/quarkus/security/runtime/AnonymousIdentityProvider.java +++ b/extensions/security/runtime/src/main/java/io/quarkus/security/runtime/AnonymousIdentityProvider.java @@ -3,8 +3,10 @@ import java.security.Permission; import java.security.Principal; import java.util.Collections; +import java.util.List; import java.util.Map; import java.util.Set; +import java.util.function.Function; import io.quarkus.security.credential.Credential; import io.quarkus.security.identity.AuthenticationRequestContext; @@ -63,6 +65,11 @@ public Map getAttributes() { return Collections.emptyMap(); } + @Override + public List>> getPermissionCheckers() { + return Collections.emptyList(); + } + @Override public Uni checkPermission(Permission permission) { return Uni.createFrom().item(false); diff --git a/extensions/security/runtime/src/main/java/io/quarkus/security/runtime/QuarkusSecurityIdentity.java b/extensions/security/runtime/src/main/java/io/quarkus/security/runtime/QuarkusSecurityIdentity.java index 192cd33a327aea..478de88c457e0e 100644 --- a/extensions/security/runtime/src/main/java/io/quarkus/security/runtime/QuarkusSecurityIdentity.java +++ b/extensions/security/runtime/src/main/java/io/quarkus/security/runtime/QuarkusSecurityIdentity.java @@ -78,6 +78,11 @@ public Map getAttributes() { return attributes; } + @Override + public List>> getPermissionCheckers() { + return permissionCheckers; + } + @Override public Uni checkPermission(Permission permission) { if (permissionCheckers.isEmpty()) { @@ -127,6 +132,7 @@ public static Builder builder(SecurityIdentity identity) { .addAttributes(identity.getAttributes()) .addCredentials(identity.getCredentials()) .addRoles(identity.getRoles()) + .addPermissionCheckers(identity.getPermissionCheckers()) .setPrincipal(identity.getPrincipal()) .setAnonymous(identity.isAnonymous()); return builder; @@ -216,6 +222,23 @@ public Builder addPermissionChecker(Function> function) return this; } + /** + * Adds a permission check functions. + * + * @param functions The permission check functions + * @return This builder + * @see #addPermissionChecker(Function) + */ + public Builder addPermissionCheckers(List>> functions) { + if (built) { + throw new IllegalStateException(); + } + if (functions != null) { + permissionCheckers.addAll(functions); + } + return this; + } + /** * Sets an anonymous identity status. * 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 0e26042dc6dbf9..9a90299b18e6dc 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,9 +2,14 @@ import static io.quarkus.security.runtime.QuarkusSecurityRolesAllowedConfigBuilder.transformToKey; +import java.lang.reflect.InvocationTargetException; +import java.security.Permission; import java.util.Arrays; +import java.util.List; +import java.util.Objects; import java.util.Set; import java.util.concurrent.ConcurrentHashMap; +import java.util.function.Function; import java.util.function.Supplier; import org.eclipse.microprofile.config.Config; @@ -12,9 +17,11 @@ import io.quarkus.runtime.RuntimeValue; import io.quarkus.runtime.annotations.Recorder; +import io.quarkus.security.StringPermission; import io.quarkus.security.runtime.interceptor.SecurityCheckStorageBuilder; import io.quarkus.security.runtime.interceptor.check.AuthenticatedCheck; import io.quarkus.security.runtime.interceptor.check.DenyAllCheck; +import io.quarkus.security.runtime.interceptor.check.PermissionSecurityCheck; import io.quarkus.security.runtime.interceptor.check.PermitAllCheck; import io.quarkus.security.runtime.interceptor.check.RolesAllowedCheck; import io.quarkus.security.runtime.interceptor.check.SupplierRolesAllowedCheck; @@ -84,6 +91,201 @@ public SecurityCheck authenticated() { return AuthenticatedCheck.INSTANCE; } + /** + * Creates {@link SecurityCheck} for a single permission. + * + * @return SecurityCheck + */ + public SecurityCheck permissionsAllowed(Function computedPermission, + RuntimeValue permissionRuntimeValue) { + final Permission permission; + if (computedPermission == null) { + Objects.requireNonNull(permissionRuntimeValue); + permission = permissionRuntimeValue.getValue(); + } else { + permission = null; + } + return PermissionSecurityCheck.of(permission, computedPermission); + } + + /** + * Creates {@link SecurityCheck} for a permission set. User must have at least one of security check permissions. + * + * @return SecurityCheck + */ + public SecurityCheck permissionsAllowed(List> computedPermissions, + List> permissionsRuntimeValue) { + final Permission[] permissions; + final Function computedPermissionsAggregator; + if (computedPermissions == null) { + + // plain permissions + Objects.requireNonNull(permissionsRuntimeValue); + computedPermissionsAggregator = null; + permissions = new Permission[permissionsRuntimeValue.size()]; + for (int i = 0; i < permissionsRuntimeValue.size(); i++) { + // assign permission + permissions[i] = Objects.requireNonNull(permissionsRuntimeValue.get(i).getValue()); + } + } else { + + // computed permissions + permissions = null; + computedPermissionsAggregator = new Function<>() { + @Override + public Permission[] apply(Object[] securedMethodParameters) { + + // compute permissions + Permission[] result = new Permission[computedPermissions.size()]; + for (int i = 0; i < computedPermissions.size(); i++) { + // instantiate Permission with actual method arguments + result[i] = computedPermissions.get(i).apply(securedMethodParameters); + } + return result; + } + }; + } + + return PermissionSecurityCheck.of(permissions, computedPermissionsAggregator); + } + + /** + * Creates {@link SecurityCheck} for a permission groups. + * User must have at least one of security check permissions from each permission group. + * + * @return SecurityCheck + */ + public SecurityCheck permissionsAllowedGroups(List>> computedPermissionGroups, + List>> permissionGroupsRuntimeValue) { + final Function computedPermissionGroupAggregator; + final Permission[][] permissionGroups; + if (computedPermissionGroups == null) { + + // plain permission groups + Objects.requireNonNull(permissionGroupsRuntimeValue); + computedPermissionGroupAggregator = null; + permissionGroups = new Permission[permissionGroupsRuntimeValue.size()][]; + + // collect runtime values + for (int i = 0; i < permissionGroupsRuntimeValue.size(); i++) { + var groupRuntimeValue = permissionGroupsRuntimeValue.get(i); + permissionGroups[i] = new Permission[groupRuntimeValue.size()]; + for (int j = 0; j < groupRuntimeValue.size(); j++) { + // assign permission + permissionGroups[i][j] = groupRuntimeValue.get(j).getValue(); + } + } + } else { + + // computed permission groups + permissionGroups = null; + computedPermissionGroupAggregator = new Function<>() { + @Override + public Permission[][] apply(Object[] securedMethodParams) { + + // compute permissions + Permission[][] permissionGroups = new Permission[computedPermissionGroups.size()][]; + for (int i = 0; i < computedPermissionGroups.size(); i++) { + var computedPermissionGroup = computedPermissionGroups.get(i); + permissionGroups[i] = new Permission[computedPermissionGroup.size()]; + for (int j = 0; j < computedPermissionGroup.size(); j++) { + // instantiate Permission with actual method arguments + permissionGroups[i][j] = computedPermissionGroup.get(j).apply(securedMethodParams); + } + } + + return permissionGroups; + } + }; + } + + return PermissionSecurityCheck.of(permissionGroups, computedPermissionGroupAggregator); + } + + public Function toComputedPermission(RuntimeValue permissionRuntimeVal) { + return new Function<>() { + @Override + public Permission apply(Object[] objects) { + return permissionRuntimeVal.getValue(); + } + }; + } + + public RuntimeValue createStringPermission(String name, String[] actions) { + return new RuntimeValue<>(new StringPermission(name, actions)); + } + + /** + * Creates permission. + * + * @param name permission name + * @param clazz permission class + * @param actions nullable actions + * @param passActionsToConstructor flag signals whether Permission constructor accepts (name) or (name, actions) + * @return {@link RuntimeValue} + */ + public RuntimeValue createPermission(String name, String clazz, String[] actions, + boolean passActionsToConstructor) { + final Permission permission; + try { + if (passActionsToConstructor) { + permission = (Permission) loadClass(clazz).getConstructors()[0].newInstance(name, actions); + } else { + permission = (Permission) loadClass(clazz).getConstructors()[0].newInstance(name); + } + } catch (InstantiationException | IllegalAccessException | InvocationTargetException e) { + throw new RuntimeException(String.format("Failed to create Permission - class '%s', name '%s', actions '%s'", clazz, + name, Arrays.toString(actions)), e); + } + return new RuntimeValue<>(permission); + } + + /** + * Creates function that transform arguments of a method annotated with {@link io.quarkus.security.PermissionsAllowed} + * to custom {@link Permission}. + * + * @param permissionName permission name + * @param clazz permission class + * @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 + * @return computed permission + */ + public Function createComputedPermission(String permissionName, String clazz, String[] actions, + boolean passActionsToConstructor, int[] formalParamIndexes) { + final int addActions = (passActionsToConstructor ? 1 : 0); + final int argsCount = 1 + addActions + formalParamIndexes.length; + final int methodArgsStart = 1 + addActions; + final var permissionClassConstructor = loadClass(clazz).getConstructors()[0]; + return new Function<>() { + @Override + public Permission apply(Object[] securedMethodArgs) { + try { + final Object[] initArgs = initArgs(securedMethodArgs); + return (Permission) permissionClassConstructor.newInstance(initArgs); + } catch (InstantiationException | IllegalAccessException | InvocationTargetException e) { + throw new RuntimeException( + String.format("Failed to create computed Permission - class '%s', name '%s', actions '%s', ", clazz, + permissionName, Arrays.toString(actions)), + e); + } + } + + private Object[] initArgs(Object[] methodArgs) { + // Permission constructor init args are: permission name, possibly actions, selected secured method args + final Object[] initArgs = new Object[argsCount]; + initArgs[0] = permissionName; + if (passActionsToConstructor) { + initArgs[1] = actions; + } + for (int i = 0; i < formalParamIndexes.length; i++) { + initArgs[methodArgsStart + i] = methodArgs[formalParamIndexes[i]]; + } + return initArgs; + } + }; + } + public RuntimeValue newBuilder() { return new RuntimeValue<>(new SecurityCheckStorageBuilder()); } @@ -107,4 +309,12 @@ public void resolveRolesAllowedConfigExpRoles() { configExpRolesAllowedChecks.clear(); } } + + private Class loadClass(String className) { + try { + return Thread.currentThread().getContextClassLoader().loadClass(className); + } catch (ClassNotFoundException e) { + throw new RuntimeException("Unable to load class '" + className + "' for creating permission", e); + } + } } diff --git a/extensions/security/runtime/src/main/java/io/quarkus/security/runtime/SecurityIdentityProxy.java b/extensions/security/runtime/src/main/java/io/quarkus/security/runtime/SecurityIdentityProxy.java index 26c4fe57b9ae67..f351247477927b 100644 --- a/extensions/security/runtime/src/main/java/io/quarkus/security/runtime/SecurityIdentityProxy.java +++ b/extensions/security/runtime/src/main/java/io/quarkus/security/runtime/SecurityIdentityProxy.java @@ -2,8 +2,10 @@ import java.security.Permission; import java.security.Principal; +import java.util.List; import java.util.Map; import java.util.Set; +import java.util.function.Function; import jakarta.enterprise.context.RequestScoped; import jakarta.inject.Inject; @@ -58,6 +60,11 @@ public Map getAttributes() { return association.getIdentity().getAttributes(); } + @Override + public List>> getPermissionCheckers() { + return association.getIdentity().getPermissionCheckers(); + } + @Override public Uni checkPermission(Permission permission) { return association.getIdentity().checkPermission(permission); diff --git a/extensions/security/runtime/src/main/java/io/quarkus/security/runtime/interceptor/PermissionsAllowedInterceptor.java b/extensions/security/runtime/src/main/java/io/quarkus/security/runtime/interceptor/PermissionsAllowedInterceptor.java new file mode 100644 index 00000000000000..c863df16b49c4f --- /dev/null +++ b/extensions/security/runtime/src/main/java/io/quarkus/security/runtime/interceptor/PermissionsAllowedInterceptor.java @@ -0,0 +1,31 @@ +package io.quarkus.security.runtime.interceptor; + +import jakarta.annotation.Priority; +import jakarta.inject.Inject; +import jakarta.interceptor.AroundInvoke; +import jakarta.interceptor.Interceptor; +import jakarta.interceptor.InvocationContext; + +import io.quarkus.security.PermissionsAllowed; +import io.quarkus.security.spi.runtime.AuthorizationController; + +@Interceptor +@PermissionsAllowed("") +@Priority(Interceptor.Priority.LIBRARY_BEFORE) +public class PermissionsAllowedInterceptor { + + @Inject + SecurityHandler handler; + + @Inject + AuthorizationController controller; + + @AroundInvoke + public Object intercept(InvocationContext ic) throws Exception { + if (controller.isAuthorizationEnabled()) { + return handler.handle(ic); + } else { + return ic.proceed(); + } + } +} diff --git a/extensions/security/runtime/src/main/java/io/quarkus/security/runtime/interceptor/SecurityConstrainer.java b/extensions/security/runtime/src/main/java/io/quarkus/security/runtime/interceptor/SecurityConstrainer.java index 72da6f9855c9fa..98e730c2eb0052 100644 --- a/extensions/security/runtime/src/main/java/io/quarkus/security/runtime/interceptor/SecurityConstrainer.java +++ b/extensions/security/runtime/src/main/java/io/quarkus/security/runtime/interceptor/SecurityConstrainer.java @@ -1,11 +1,13 @@ package io.quarkus.security.runtime.interceptor; import java.lang.reflect.Method; +import java.util.function.Function; import jakarta.inject.Inject; import jakarta.inject.Singleton; import io.quarkus.runtime.BlockingOperationNotAllowedException; +import io.quarkus.security.identity.SecurityIdentity; import io.quarkus.security.runtime.SecurityIdentityAssociation; import io.quarkus.security.spi.runtime.SecurityCheck; import io.quarkus.security.spi.runtime.SecurityCheckStorage; @@ -46,7 +48,12 @@ public Uni nonBlockingCheck(Method method, Object[] parameters) { if (securityCheck != null && !securityCheck.isPermitAll()) { return identity.getDeferredIdentity() .onItem() - .invoke(identity -> securityCheck.apply(identity, method, parameters)); + .transformToUni(new Function>() { + @Override + public Uni apply(SecurityIdentity securityIdentity) { + return securityCheck.nonBlockingApply(securityIdentity, method, parameters); + } + }); } return Uni.createFrom().item(CHECK_OK); } diff --git a/extensions/security/runtime/src/main/java/io/quarkus/security/runtime/interceptor/check/PermissionSecurityCheck.java b/extensions/security/runtime/src/main/java/io/quarkus/security/runtime/interceptor/check/PermissionSecurityCheck.java new file mode 100644 index 00000000000000..e57679f58b9486 --- /dev/null +++ b/extensions/security/runtime/src/main/java/io/quarkus/security/runtime/interceptor/check/PermissionSecurityCheck.java @@ -0,0 +1,236 @@ +package io.quarkus.security.runtime.interceptor.check; + +import static java.lang.Boolean.FALSE; +import static java.lang.Boolean.TRUE; + +import java.lang.reflect.Method; +import java.security.Permission; +import java.util.Objects; +import java.util.function.Function; + +import io.quarkus.security.ForbiddenException; +import io.quarkus.security.UnauthorizedException; +import io.quarkus.security.identity.SecurityIdentity; +import io.quarkus.security.spi.runtime.MethodDescription; +import io.quarkus.security.spi.runtime.SecurityCheck; +import io.smallrye.mutiny.Uni; + +public abstract class PermissionSecurityCheck implements SecurityCheck { + + private static final Uni SUCCESSFUL_CHECK = Uni.createFrom().nullItem(); + private final T permissions; + private final Function computedPermissions; + private final boolean useComputedPermissions; + + private PermissionSecurityCheck(T permissions, Function computedPermissions) { + if (permissions == null) { + Objects.requireNonNull(computedPermissions); + this.useComputedPermissions = true; + } else { + if (computedPermissions == null) { + this.useComputedPermissions = false; + } else { + throw new IllegalStateException("PermissionSecurityCheck must be created either for computed permissions" + + "or plain permissions, but received both"); + } + } + this.permissions = permissions; + this.computedPermissions = computedPermissions; + } + + private T getPermissions(Object[] parameters) { + if (useComputedPermissions) { + return computedPermissions.apply(parameters); + } + return permissions; + } + + @Override + public void apply(SecurityIdentity identity, Method method, Object[] parameters) { + checkPermissions(identity, getPermissions(parameters)); + } + + @Override + public void apply(SecurityIdentity identity, MethodDescription methodDescription, Object[] parameters) { + checkPermissions(identity, getPermissions(parameters)); + } + + @Override + public Uni nonBlockingApply(SecurityIdentity identity, Method method, Object[] parameters) { + return checkPermissions(identity, getPermissions(parameters), 0); + } + + @Override + public Uni nonBlockingApply(SecurityIdentity identity, MethodDescription methodDescription, Object[] parameters) { + return checkPermissions(identity, getPermissions(parameters), 0); + } + + private static void denyIdentityWithoutPermissions(SecurityIdentity identity) { + // if identity has no permissions, we can't check anything + // therefore we need to fail, otherwise anyone without permission will pass + if (identity.getPermissionCheckers() == null || identity.getPermissionCheckers().isEmpty()) { + throwException(identity); + } + } + + private static void throwException(SecurityIdentity identity) { + if (identity.isAnonymous()) { + throw new UnauthorizedException(); + } else { + throw new ForbiddenException(); + } + } + + protected abstract Uni checkPermissions(SecurityIdentity identity, T permissions, int i); + + protected abstract void checkPermissions(SecurityIdentity identity, T permissions); + + /** + * Creates permission check with a single permission. Either {@code permission} or {@code computedPermission} + * must not be null. + * + * @param permission Permission + * @param computedPermission the function that is invoked every single time permission is checked with request or + * method parameters + * @return created {@link SecurityCheck} + */ + public static SecurityCheck of(Permission permission, Function computedPermission) { + return new PermissionSecurityCheck<>(permission, computedPermission) { + @Override + protected Uni checkPermissions(SecurityIdentity identity, Permission permission, int i) { + PermissionSecurityCheck.denyIdentityWithoutPermissions(identity); + return identity + .checkPermission(permission) + .onItem() + .transformToUni(new Function<>() { + @Override + public Uni apply(Boolean hasPermission) { + if (FALSE.equals(hasPermission)) { + // check failed + throwException(identity); + } + + return SUCCESSFUL_CHECK; + } + }); + } + + @Override + protected void checkPermissions(SecurityIdentity identity, Permission permission) { + PermissionSecurityCheck.denyIdentityWithoutPermissions(identity); + if (!identity.checkPermissionBlocking(permission)) { + throwException(identity); + } + } + }; + } + + /** + * Creates permission check with permissions. Permission check will be successful if {@link SecurityIdentity} has + * at least one of permissions. Either {@code permission} or {@code computedPermission} must not be null. + * + * @param permissions Permission[] + * @param computedPermissions the function that is invoked every single time permissions are checked with request or + * method parameters + * @return created {@link SecurityCheck} + */ + public static SecurityCheck of(Permission[] permissions, Function computedPermissions) { + return new PermissionSecurityCheck<>(permissions, computedPermissions) { + @Override + protected Uni checkPermissions(SecurityIdentity identity, Permission[] permissions, int i) { + PermissionSecurityCheck.denyIdentityWithoutPermissions(identity); + // security identity must have at least one of required permissions + return PermissionSecurityCheck.checkPermissions(identity, permissions, i); + } + + @Override + protected void checkPermissions(SecurityIdentity identity, Permission[] permissions) { + PermissionSecurityCheck.denyIdentityWithoutPermissions(identity); + for (Permission permission : permissions) { + if (identity.checkPermissionBlocking(permission)) { + // success - security identity has at least one of required permissions + return; + } + } + throwException(identity); + } + }; + } + + /** + * Creates permission check with permission groups. Permission check will be successful if {@link SecurityIdentity} + * has at least one of permissions of each permission group. Either {@code permission} or {@code computedPermission} + * must not be null. + * + * @param permissions array of permission groups + * @param computedPermissions the function that is invoked every single time permissions are checked with request or + * method parameters + * @return created {@link SecurityCheck} + */ + public static SecurityCheck of(Permission[][] permissions, Function computedPermissions) { + return new PermissionSecurityCheck<>(permissions, computedPermissions) { + @Override + protected Uni checkPermissions(SecurityIdentity identity, Permission[][] permissionGroups, int i) { + PermissionSecurityCheck.denyIdentityWithoutPermissions(identity); + // check that identity has at least one permission from each permission group + return PermissionSecurityCheck.checkPermissions(identity, permissionGroups[i], 0) + .onItem() + .transformToUni(new Function>() { + @Override + public Uni apply(Object o) { + if (i + 1 < permissionGroups.length) { + // check next permission group + return checkPermissions(identity, permissionGroups, i + 1); + } + + return SUCCESSFUL_CHECK; + } + }); + } + + @Override + protected void checkPermissions(SecurityIdentity identity, Permission[][] permissionGroups) { + PermissionSecurityCheck.denyIdentityWithoutPermissions(identity); + // logical AND between permission groups (must have at least one permission from each group) + groupBlock: for (Permission[] permissionGroup : permissionGroups) { + + // logical OR between permissions + for (Permission permission : permissionGroup) { + if (identity.checkPermissionBlocking(permission)) { + // success - check next permission group + continue groupBlock; + } + } + + // must have at least one of 'OR' permissions + throwException(identity); + } + } + }; + } + + private static Uni checkPermissions(SecurityIdentity identity, Permission[] permissions, int i) { + // recursive check that the identity has at least one of required permissions + return identity + .checkPermission(permissions[i]) + .onItem() + .transformToUni(new Function<>() { + @Override + public Uni apply(Boolean hasPermission) { + if (TRUE.equals(hasPermission)) { + return SUCCESSFUL_CHECK; + } else { + final boolean hasAnotherPermission = i + 1 < permissions.length; + if (!hasAnotherPermission) { + // check failed + throwException(identity); + } + + // check next permission + return checkPermissions(identity, permissions, i + 1); + } + } + }); + } + +} diff --git a/extensions/security/spi/src/main/java/io/quarkus/security/spi/SecurityTransformerUtils.java b/extensions/security/spi/src/main/java/io/quarkus/security/spi/SecurityTransformerUtils.java index 3ef23c2c716440..8c73b6f1c54e60 100644 --- a/extensions/security/spi/src/main/java/io/quarkus/security/spi/SecurityTransformerUtils.java +++ b/extensions/security/spi/src/main/java/io/quarkus/security/spi/SecurityTransformerUtils.java @@ -15,6 +15,7 @@ import org.jboss.jandex.MethodInfo; import io.quarkus.security.Authenticated; +import io.quarkus.security.PermissionsAllowed; /** * @author Michal Szynkiewicz, michal.l.szynkiewicz@gmail.com @@ -28,6 +29,7 @@ public class SecurityTransformerUtils { // keep the contents the same as in io.quarkus.security.deployment.SecurityAnnotationsRegistrar SECURITY_ANNOTATIONS.addAll(asList( DotName.createSimple(RolesAllowed.class.getName()), + DotName.createSimple(PermissionsAllowed.class.getName()), DotName.createSimple(Authenticated.class.getName()), DotName.createSimple(DenyAll.class.getName()), DotName.createSimple(PermitAll.class.getName()))); @@ -44,7 +46,7 @@ public static boolean hasSecurityAnnotation(MethodInfo methodInfo) { } public static boolean hasSecurityAnnotation(ClassInfo classInfo) { - for (AnnotationInstance classAnnotation : classInfo.classAnnotations()) { + for (AnnotationInstance classAnnotation : classInfo.declaredAnnotations()) { if (SECURITY_ANNOTATIONS.contains(classAnnotation.name())) { return true; } diff --git a/extensions/security/test-utils/src/main/java/io/quarkus/security/test/utils/AuthData.java b/extensions/security/test-utils/src/main/java/io/quarkus/security/test/utils/AuthData.java index 010d011fc0a0f0..d0d9bf1306acd8 100644 --- a/extensions/security/test-utils/src/main/java/io/quarkus/security/test/utils/AuthData.java +++ b/extensions/security/test-utils/src/main/java/io/quarkus/security/test/utils/AuthData.java @@ -1,5 +1,6 @@ package io.quarkus.security.test.utils; +import java.security.Permission; import java.util.Set; /** @@ -9,10 +10,19 @@ public class AuthData { public final Set roles; public final boolean anonymous; public final String name; + public final Set permissions; public AuthData(Set roles, boolean anonymous, String name) { this.roles = roles; this.anonymous = anonymous; this.name = name; + this.permissions = null; + } + + public AuthData(Set roles, boolean anonymous, String name, Set permissions) { + this.roles = roles; + this.anonymous = anonymous; + this.name = name; + this.permissions = permissions; } } diff --git a/extensions/security/test-utils/src/main/java/io/quarkus/security/test/utils/IdentityMock.java b/extensions/security/test-utils/src/main/java/io/quarkus/security/test/utils/IdentityMock.java index edac1922ca3e94..2ede5b3f265e28 100644 --- a/extensions/security/test-utils/src/main/java/io/quarkus/security/test/utils/IdentityMock.java +++ b/extensions/security/test-utils/src/main/java/io/quarkus/security/test/utils/IdentityMock.java @@ -3,8 +3,12 @@ import java.security.Permission; import java.security.Principal; import java.util.Collections; +import java.util.HashSet; +import java.util.List; import java.util.Map; import java.util.Set; +import java.util.function.Function; +import java.util.stream.Collectors; import jakarta.annotation.Priority; import jakarta.enterprise.context.ApplicationScoped; @@ -25,17 +29,19 @@ public class IdentityMock implements SecurityIdentity { public static final AuthData ANONYMOUS = new AuthData(null, true, null); - public static final AuthData USER = new AuthData(Collections.singleton("user"), false, "user"); - public static final AuthData ADMIN = new AuthData(Collections.singleton("admin"), false, "admin"); + public static final AuthData USER = new AuthData(Collections.singleton("user"), false, "user", Set.of()); + public static final AuthData ADMIN = new AuthData(Collections.singleton("admin"), false, "admin", Set.of()); private static volatile boolean anonymous; private static volatile Set roles; + private static volatile Set permissions = new HashSet<>(); private static volatile String name; public static void setUpAuth(AuthData auth) { IdentityMock.anonymous = auth.anonymous; IdentityMock.roles = auth.roles; IdentityMock.name = auth.name; + IdentityMock.permissions = auth.permissions == null ? Set.of() : auth.permissions; } @Override @@ -86,9 +92,18 @@ public Map getAttributes() { return null; } + @Override + public List>> getPermissionCheckers() { + return permissions + .stream() + .>> map(p -> this::checkPermission) + .collect(Collectors.toList()); + } + @Override public Uni checkPermission(Permission permission) { - return null; + final boolean permitted = permission != null && permissions.stream().anyMatch(p -> p.implies(permission)); + return Uni.createFrom().item(permitted); } @Alternative diff --git a/extensions/security/test-utils/src/main/java/io/quarkus/security/test/utils/SecurityTestUtils.java b/extensions/security/test-utils/src/main/java/io/quarkus/security/test/utils/SecurityTestUtils.java index e4887b5f131e8e..a319764bf12be3 100644 --- a/extensions/security/test-utils/src/main/java/io/quarkus/security/test/utils/SecurityTestUtils.java +++ b/extensions/security/test-utils/src/main/java/io/quarkus/security/test/utils/SecurityTestUtils.java @@ -2,11 +2,14 @@ import static io.quarkus.security.test.utils.IdentityMock.setUpAuth; +import java.util.function.Consumer; import java.util.function.Supplier; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.function.Executable; +import io.smallrye.mutiny.Uni; + /** * @author Michal Szynkiewicz, michal.l.szynkiewicz@gmail.com */ @@ -19,6 +22,21 @@ public static void assertSuccess(Supplier action, T expectedResult, AuthD } + public static void assertSuccess(Uni action, T expectedResult, AuthData authData) { + setUpAuth(authData); + action.subscribe().with(new Consumer() { + @Override + public void accept(T actual) { + Assertions.assertEquals(expectedResult, actual); + } + }, new Consumer() { + @Override + public void accept(Throwable throwable) { + Assertions.fail("Assertion failed with: " + throwable.getMessage()); + } + }); + } + public static void assertFailureFor(Executable action, Class expectedException, AuthData... auth) { for (AuthData authData : auth) { @@ -27,6 +45,21 @@ public static void assertFailureFor(Executable action, Class void assertFailureFor(Uni action, Class expectedException, AuthData authData) { + setUpAuth(authData); + action.subscribe().with(new Consumer() { + @Override + public void accept(T actual) { + Assertions.fail(String.format("Expected exception %s was never thrown", expectedException)); + } + }, new Consumer() { + @Override + public void accept(Throwable actual) { + Assertions.assertEquals(expectedException, actual.getClass()); + } + }); + } + private SecurityTestUtils() { } } diff --git a/extensions/security/test-utils/src/main/java/io/quarkus/security/test/utils/TestIdentityController.java b/extensions/security/test-utils/src/main/java/io/quarkus/security/test/utils/TestIdentityController.java index 36d84b50ae9808..af064dc39e04a8 100644 --- a/extensions/security/test-utils/src/main/java/io/quarkus/security/test/utils/TestIdentityController.java +++ b/extensions/security/test-utils/src/main/java/io/quarkus/security/test/utils/TestIdentityController.java @@ -1,10 +1,15 @@ package io.quarkus.security.test.utils; +import java.security.Permission; import java.util.Arrays; import java.util.HashSet; +import java.util.List; import java.util.Map; import java.util.Set; import java.util.concurrent.ConcurrentHashMap; +import java.util.function.Function; + +import io.smallrye.mutiny.Uni; public class TestIdentityController { @@ -20,6 +25,11 @@ public Builder add(String username, String password, String... roles) { identities.put(username, new TestIdentity(username, password, roles)); return this; } + + public Builder add(String username, String password, Permission... permissions) { + identities.put(username, new TestIdentity(username, password, permissions)); + return this; + } } public static final class TestIdentity { @@ -27,11 +37,30 @@ public static final class TestIdentity { public final String username; public final String password; public final Set roles; + public final List>> permissionCheckers; private TestIdentity(String username, String password, String... roles) { this.username = username; this.password = password; this.roles = new HashSet<>(Arrays.asList(roles)); + this.permissionCheckers = List.of(); + } + + private TestIdentity(String username, String password, Permission... permissions) { + this.username = username; + this.password = password; + this.roles = Set.of(); + this.permissionCheckers = createPermissionCheckers(Arrays.asList(permissions)); + } + + private static List>> createPermissionCheckers(List permissions) { + return List.of(new Function>() { + @Override + public Uni apply(Permission requiredPermission) { + return Uni.createFrom().item(permissions.stream() + .anyMatch(possessedPermission -> possessedPermission.implies(requiredPermission))); + } + }); } } } diff --git a/extensions/security/test-utils/src/main/java/io/quarkus/security/test/utils/TestIdentityProvider.java b/extensions/security/test-utils/src/main/java/io/quarkus/security/test/utils/TestIdentityProvider.java index f1985097d4d929..08e8a4adc8be05 100644 --- a/extensions/security/test-utils/src/main/java/io/quarkus/security/test/utils/TestIdentityProvider.java +++ b/extensions/security/test-utils/src/main/java/io/quarkus/security/test/utils/TestIdentityProvider.java @@ -35,6 +35,7 @@ public Uni authenticate(UsernamePasswordAuthenticationRequest .setPrincipal(new QuarkusPrincipal(ident.username)) .addRoles(ident.roles) .addCredential(request.getPassword()) + .addPermissionCheckers(ident.permissionCheckers) .build(); return Uni.createFrom().item(identity); } diff --git a/integration-tests/elytron-security-jdbc/src/main/java/io/quarkus/elytron/security/jdbc/it/ElytronSecurityJdbcResource.java b/integration-tests/elytron-security-jdbc/src/main/java/io/quarkus/elytron/security/jdbc/it/ElytronSecurityJdbcResource.java index e32af89f9e29eb..aafabeb43cefe4 100644 --- a/integration-tests/elytron-security-jdbc/src/main/java/io/quarkus/elytron/security/jdbc/it/ElytronSecurityJdbcResource.java +++ b/integration-tests/elytron-security-jdbc/src/main/java/io/quarkus/elytron/security/jdbc/it/ElytronSecurityJdbcResource.java @@ -21,6 +21,8 @@ import jakarta.ws.rs.GET; import jakarta.ws.rs.Path; +import io.quarkus.security.PermissionsAllowed; + @Path("/api") @ApplicationScoped public class ElytronSecurityJdbcResource { @@ -38,6 +40,20 @@ public String authenticated() { return "authenticated"; } + @GET + @Path("/read-permission") + @PermissionsAllowed("read") + public String withReadPermission() { + return "withReadPermission"; + } + + @GET + @Path("/day-based-permission") + @PermissionsAllowed(value = "worker:adult", permission = WorkdayPermission.class) + public String withDayBasedPermission(String day) { + return day; + } + @GET @Path("/forbidden") @RolesAllowed("admin") diff --git a/integration-tests/elytron-security-jdbc/src/main/java/io/quarkus/elytron/security/jdbc/it/PermissionsIdentityAugmentor.java b/integration-tests/elytron-security-jdbc/src/main/java/io/quarkus/elytron/security/jdbc/it/PermissionsIdentityAugmentor.java new file mode 100644 index 00000000000000..3cce8dc35e096a --- /dev/null +++ b/integration-tests/elytron-security-jdbc/src/main/java/io/quarkus/elytron/security/jdbc/it/PermissionsIdentityAugmentor.java @@ -0,0 +1,55 @@ +package io.quarkus.elytron.security.jdbc.it; + +import java.security.Permission; +import java.util.function.Function; + +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 PermissionsIdentityAugmentor implements SecurityIdentityAugmentor { + + @Override + public Uni augment(SecurityIdentity identity, AuthenticationRequestContext context) { + if (identity.isAnonymous()) { + return Uni.createFrom().item(identity); + } + return Uni.createFrom().item(build(identity)); + } + + SecurityIdentity build(SecurityIdentity identity) { + QuarkusSecurityIdentity.Builder builder = QuarkusSecurityIdentity.builder(identity); + if ("admin".equals(identity.getPrincipal().getName())) { + builder.addPermissionChecker(createStringPermission("read")); + } + if ("worker".equals(identity.getPrincipal().getName())) { + builder.addPermissionChecker(createWorkdayPermission()); + } + return builder.build(); + } + + private Function> createStringPermission(String permissionName) { + return new Function>() { + @Override + public Uni apply(Permission permission) { + return Uni.createFrom().item(new StringPermission(permissionName).implies(permission)); + } + }; + } + + private Function> createWorkdayPermission() { + return new Function>() { + @Override + public Uni apply(Permission permission) { + return Uni.createFrom().item(new WorkdayPermission("ignored", null, null).implies(permission)); + } + }; + } + +} diff --git a/integration-tests/elytron-security-jdbc/src/main/java/io/quarkus/elytron/security/jdbc/it/WorkdayEvaluator.java b/integration-tests/elytron-security-jdbc/src/main/java/io/quarkus/elytron/security/jdbc/it/WorkdayEvaluator.java new file mode 100644 index 00000000000000..36d35701e8f8aa --- /dev/null +++ b/integration-tests/elytron-security-jdbc/src/main/java/io/quarkus/elytron/security/jdbc/it/WorkdayEvaluator.java @@ -0,0 +1,19 @@ +package io.quarkus.elytron.security.jdbc.it; + +import java.util.Set; + +import jakarta.enterprise.context.ApplicationScoped; + +import io.quarkus.arc.Unremovable; + +@Unremovable +@ApplicationScoped +public class WorkdayEvaluator { + + private static final Set WORKDAYS = Set.of("Monday", "Tuesday", "Wednesday", "Thrusday", "Friday"); + + public boolean isWorkday(String day) { + return WORKDAYS.contains(day); + } + +} diff --git a/integration-tests/elytron-security-jdbc/src/main/java/io/quarkus/elytron/security/jdbc/it/WorkdayPermission.java b/integration-tests/elytron-security-jdbc/src/main/java/io/quarkus/elytron/security/jdbc/it/WorkdayPermission.java new file mode 100644 index 00000000000000..60c7688f6b7c0e --- /dev/null +++ b/integration-tests/elytron-security-jdbc/src/main/java/io/quarkus/elytron/security/jdbc/it/WorkdayPermission.java @@ -0,0 +1,72 @@ +package io.quarkus.elytron.security.jdbc.it; + +import java.security.Permission; +import java.util.Arrays; +import java.util.Objects; + +import io.quarkus.arc.Arc; +import io.quarkus.security.PermissionsAllowed; + +/** + * Permit access if secured method (one annotated with {@link PermissionsAllowed} using this permission has parameter + * 'day' with actual value 'Monday', 'Tuesday', 'Wednesday', 'Thursday' or 'Friday'. Secondary check is based on user + * actions. + */ +public class WorkdayPermission extends Permission { + + private final String[] actions; + private final String day; + + /** + * Constructs a permission with the specified name, actions and String parameter 'day'. + * Every method secured with {@link io.quarkus.security.PermissionsAllowed} whose {@link PermissionsAllowed#permission()} + * matches this class must have a formal parameter {@link String} named 'day'. + * + * @param name name of the Permission object being created. + * @param actions Permission actions + * @param day workday + */ + public WorkdayPermission(String name, String[] actions, String day) { + super(name); + this.actions = actions; + this.day = day; + } + + @Override + public boolean implies(Permission permission) { + if (permission instanceof WorkdayPermission) { + + WorkdayPermission that = (WorkdayPermission) permission; + // verify Permission name and actions has been passed to the constructor + if (that.getName().equals("worker") && that.getActions().contains("adult")) { + + // verify we can obtain bean instance + final WorkdayEvaluator workdayEvaluator = Arc.container().instance(WorkdayEvaluator.class).get(); + return workdayEvaluator.isWorkday(that.day); + } + } + return false; + } + + @Override + public boolean equals(Object o) { + if (this == o) + return true; + if (o == null || getClass() != o.getClass()) + return false; + WorkdayPermission that = (WorkdayPermission) o; + return Arrays.equals(actions, that.actions) && Objects.equals(day, that.day); + } + + @Override + public int hashCode() { + int result = Objects.hash(day); + result = 31 * result + Arrays.hashCode(actions); + return result; + } + + @Override + public String getActions() { + return String.join(",", actions); + } +} diff --git a/integration-tests/elytron-security-jdbc/src/main/resources/import.sql b/integration-tests/elytron-security-jdbc/src/main/resources/import.sql index 25e2fec3fad186..0cfee8e39f3826 100644 --- a/integration-tests/elytron-security-jdbc/src/main/resources/import.sql +++ b/integration-tests/elytron-security-jdbc/src/main/resources/import.sql @@ -6,3 +6,5 @@ CREATE TABLE test_user ( ); INSERT INTO test_user (id, username, password, role) VALUES (1, 'user','user', 'user'); +INSERT INTO test_user (id, username, password, role) VALUES (1, 'worker','worker', 'worker'); +INSERT INTO test_user (id, username, password, role) VALUES (1, 'admin','admin', 'admin'); diff --git a/integration-tests/elytron-security-jdbc/src/test/java/io/quarkus/elytron/security/jdbc/it/ElytronSecurityJdbcTest.java b/integration-tests/elytron-security-jdbc/src/test/java/io/quarkus/elytron/security/jdbc/it/ElytronSecurityJdbcTest.java index 7892c5c7e34fde..126bf391c1b1dd 100644 --- a/integration-tests/elytron-security-jdbc/src/test/java/io/quarkus/elytron/security/jdbc/it/ElytronSecurityJdbcTest.java +++ b/integration-tests/elytron-security-jdbc/src/test/java/io/quarkus/elytron/security/jdbc/it/ElytronSecurityJdbcTest.java @@ -2,6 +2,7 @@ import static org.hamcrest.Matchers.containsString; +import org.hamcrest.Matchers; import org.junit.jupiter.api.Test; import io.quarkus.test.junit.QuarkusTest; @@ -54,6 +55,96 @@ void authenticated() { .body(containsString("authenticated")); } + @Test + void permitted() { + CookieFilter cookies = new CookieFilter(); + RestAssured + .given() + .filter(cookies) + .redirects().follow(false) + .when() + .formParam("j_username", "admin") + .formParam("j_password", "admin") + .post("/j_security_check") + .then() + .assertThat() + .statusCode(302); + + // permitted because admin has assigned 'read' permission in 'PermissionIdentityAugmentor' + RestAssured.given() + .redirects().follow(false) + .filter(cookies) + .when() + .get("/api/read-permission") + .then() + .statusCode(200) + .body(containsString("withReadPermission")); + } + + @Test + void notPermitted() { + CookieFilter cookies = new CookieFilter(); + RestAssured + .given() + .filter(cookies) + .redirects().follow(false) + .when() + .formParam("j_username", "user") + .formParam("j_password", "user") + .post("/j_security_check") + .then() + .assertThat() + .statusCode(302); + + RestAssured.given() + .redirects().follow(false) + .filter(cookies) + .when() + .get("/api/read-permission") + .then() + .statusCode(403); + } + + @Test + void permissionBasedOnSecuredMethodArguments() { + CookieFilter cookies = new CookieFilter(); + // user 'worker' is assigned 'Workday' permission checker in 'PermissionIdentityAugmentor' + RestAssured + .given() + .filter(cookies) + .redirects().follow(false) + .when() + .formParam("j_username", "worker") + .formParam("j_password", "worker") + .post("/j_security_check") + .then() + .assertThat() + .statusCode(302); + + // not permitted because 'Saturday' is not a workday + String day = "Saturday"; + RestAssured.given() + .redirects().follow(false) + .filter(cookies) + .when() + .body(day) + .get("/api/day-based-permission") + .then() + .statusCode(403); + + // permitted because 'Monday' is a workday + day = "Monday"; + RestAssured.given() + .redirects().follow(false) + .filter(cookies) + .when() + .body(day) + .get("/api/day-based-permission") + .then() + .statusCode(200) + .body(Matchers.equalTo(day)); + } + @Test void authenticated_not_authenticated() { RestAssured.given()