From 624195b2af2822461bfd6a7a2531ba0e997696d7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michal=20Vav=C5=99=C3=ADk?= Date: Tue, 27 Aug 2024 17:36:27 +0200 Subject: [PATCH] Add new AuthorizationPolicy annotation --- ...ity-authorize-web-endpoints-reference.adoc | 30 +++ .../grpc/auth/BlockingHttpSecurityPolicy.java | 2 +- .../KeycloakPolicyEnforcerAuthorizer.java | 4 +- .../deployment/ResteasyBuiltinsProcessor.java | 2 + .../AbstractAuthorizationPolicyTest.java | 165 ++++++++++++ ...PolicyAndPathMatchingPoliciesResource.java | 34 +++ .../BasicAuthenticationResource.java | 27 ++ ...AuthZPolicyMethodRolesAllowedResource.java | 27 ++ ...RolesAllowedMethodAuthZPolicyResource.java | 27 ++ ...DenyAllUnannotatedWithAuthzPolicyTest.java | 50 ++++ ...ForbidAllButViewerAuthorizationPolicy.java | 28 ++ .../ForbidViewerClassLevelPolicyResource.java | 19 ++ ...ForbidViewerMethodLevelPolicyResource.java | 30 +++ .../LazyAuthAuthorizationPolicyTest.java | 37 +++ .../NoAuthorizationPolicyResource.java | 43 +++ .../PermitUserAuthorizationPolicy.java | 28 ++ .../ProactiveAuthAuthorizationPolicyTest.java | 16 ++ .../authzpolicy/ViewerAugmentingPolicy.java | 30 +++ .../resteasy/runtime/EagerSecurityFilter.java | 8 +- .../runtime/JaxRsPermissionChecker.java | 38 +-- .../deployment/ResteasyReactiveProcessor.java | 43 ++- .../deployment/SecurityTransformerUtils.java | 66 ----- .../AbstractAuthorizationPolicyTest.java | 165 ++++++++++++ ...PolicyAndPathMatchingPoliciesResource.java | 34 +++ .../BasicAuthenticationResource.java | 27 ++ ...AuthZPolicyMethodRolesAllowedResource.java | 27 ++ ...RolesAllowedMethodAuthZPolicyResource.java | 27 ++ ...DenyAllUnannotatedWithAuthzPolicyTest.java | 50 ++++ ...ForbidAllButViewerAuthorizationPolicy.java | 28 ++ .../ForbidViewerClassLevelPolicyResource.java | 19 ++ ...ForbidViewerMethodLevelPolicyResource.java | 30 +++ .../LazyAuthAuthorizationPolicyTest.java | 37 +++ .../NoAuthorizationPolicyResource.java | 43 +++ .../PermitUserAuthorizationPolicy.java | 28 ++ .../ProactiveAuthAuthorizationPolicyTest.java | 16 ++ .../authzpolicy/ViewerAugmentingPolicy.java | 30 +++ .../security/EagerSecurityContext.java | 23 +- .../security/EagerSecurityHandler.java | 58 +++- ...ditionalDenyingUnannotatedTransformer.java | 2 +- .../AdditionalRolesAllowedTransformer.java | 5 +- .../DenyingUnannotatedTransformer.java | 7 +- .../deployment/PermissionSecurityChecks.java | 48 +++- .../deployment/SecurityProcessor.java | 255 +++++++++++------- .../deployment/SecurityTransformerUtils.java | 61 ----- ...AdditionalSecurityAnnotationBuildItem.java | 27 ++ .../spi/SecurityTransformerUtils.java | 44 ++- .../deployment/SpringSecurityProcessor.java | 14 +- .../runtime/ServletHttpSecurityPolicy.java | 6 +- ...AuthorizationPolicyInstancesBuildItem.java | 29 ++ .../deployment/HttpSecurityProcessor.java | 189 ++++++++++++- .../http/deployment/HttpSecurityUtils.java | 41 +++ .../PathMatchingHttpSecurityPolicyTest.java | 2 +- .../security/AbstractHttpAuthorizer.java | 2 +- ...bstractPathMatchingHttpSecurityPolicy.java | 38 ++- .../security/AuthorizationPolicyStorage.java | 48 ++++ .../runtime/security/DenySecurityPolicy.java | 2 +- .../runtime/security/HttpSecurityPolicy.java | 26 +- .../JaxRsPathMatchingHttpSecurityPolicy.java | 111 ++++++++ .../security/PermitSecurityPolicy.java | 2 +- .../http/security/AuthorizationPolicy.java | 82 ++++++ 60 files changed, 2114 insertions(+), 323 deletions(-) create mode 100644 extensions/resteasy-classic/resteasy/deployment/src/test/java/io/quarkus/resteasy/test/security/authzpolicy/AbstractAuthorizationPolicyTest.java create mode 100644 extensions/resteasy-classic/resteasy/deployment/src/test/java/io/quarkus/resteasy/test/security/authzpolicy/AuthorizationPolicyAndPathMatchingPoliciesResource.java create mode 100644 extensions/resteasy-classic/resteasy/deployment/src/test/java/io/quarkus/resteasy/test/security/authzpolicy/BasicAuthenticationResource.java create mode 100644 extensions/resteasy-classic/resteasy/deployment/src/test/java/io/quarkus/resteasy/test/security/authzpolicy/ClassAuthZPolicyMethodRolesAllowedResource.java create mode 100644 extensions/resteasy-classic/resteasy/deployment/src/test/java/io/quarkus/resteasy/test/security/authzpolicy/ClassRolesAllowedMethodAuthZPolicyResource.java create mode 100644 extensions/resteasy-classic/resteasy/deployment/src/test/java/io/quarkus/resteasy/test/security/authzpolicy/DenyAllUnannotatedWithAuthzPolicyTest.java create mode 100644 extensions/resteasy-classic/resteasy/deployment/src/test/java/io/quarkus/resteasy/test/security/authzpolicy/ForbidAllButViewerAuthorizationPolicy.java create mode 100644 extensions/resteasy-classic/resteasy/deployment/src/test/java/io/quarkus/resteasy/test/security/authzpolicy/ForbidViewerClassLevelPolicyResource.java create mode 100644 extensions/resteasy-classic/resteasy/deployment/src/test/java/io/quarkus/resteasy/test/security/authzpolicy/ForbidViewerMethodLevelPolicyResource.java create mode 100644 extensions/resteasy-classic/resteasy/deployment/src/test/java/io/quarkus/resteasy/test/security/authzpolicy/LazyAuthAuthorizationPolicyTest.java create mode 100644 extensions/resteasy-classic/resteasy/deployment/src/test/java/io/quarkus/resteasy/test/security/authzpolicy/NoAuthorizationPolicyResource.java create mode 100644 extensions/resteasy-classic/resteasy/deployment/src/test/java/io/quarkus/resteasy/test/security/authzpolicy/PermitUserAuthorizationPolicy.java create mode 100644 extensions/resteasy-classic/resteasy/deployment/src/test/java/io/quarkus/resteasy/test/security/authzpolicy/ProactiveAuthAuthorizationPolicyTest.java create mode 100644 extensions/resteasy-classic/resteasy/deployment/src/test/java/io/quarkus/resteasy/test/security/authzpolicy/ViewerAugmentingPolicy.java delete mode 100644 extensions/resteasy-reactive/rest/deployment/src/main/java/io/quarkus/resteasy/reactive/server/deployment/SecurityTransformerUtils.java create mode 100644 extensions/resteasy-reactive/rest/deployment/src/test/java/io/quarkus/resteasy/reactive/server/test/security/authzpolicy/AbstractAuthorizationPolicyTest.java create mode 100644 extensions/resteasy-reactive/rest/deployment/src/test/java/io/quarkus/resteasy/reactive/server/test/security/authzpolicy/AuthorizationPolicyAndPathMatchingPoliciesResource.java create mode 100644 extensions/resteasy-reactive/rest/deployment/src/test/java/io/quarkus/resteasy/reactive/server/test/security/authzpolicy/BasicAuthenticationResource.java create mode 100644 extensions/resteasy-reactive/rest/deployment/src/test/java/io/quarkus/resteasy/reactive/server/test/security/authzpolicy/ClassAuthZPolicyMethodRolesAllowedResource.java create mode 100644 extensions/resteasy-reactive/rest/deployment/src/test/java/io/quarkus/resteasy/reactive/server/test/security/authzpolicy/ClassRolesAllowedMethodAuthZPolicyResource.java create mode 100644 extensions/resteasy-reactive/rest/deployment/src/test/java/io/quarkus/resteasy/reactive/server/test/security/authzpolicy/DenyAllUnannotatedWithAuthzPolicyTest.java create mode 100644 extensions/resteasy-reactive/rest/deployment/src/test/java/io/quarkus/resteasy/reactive/server/test/security/authzpolicy/ForbidAllButViewerAuthorizationPolicy.java create mode 100644 extensions/resteasy-reactive/rest/deployment/src/test/java/io/quarkus/resteasy/reactive/server/test/security/authzpolicy/ForbidViewerClassLevelPolicyResource.java create mode 100644 extensions/resteasy-reactive/rest/deployment/src/test/java/io/quarkus/resteasy/reactive/server/test/security/authzpolicy/ForbidViewerMethodLevelPolicyResource.java create mode 100644 extensions/resteasy-reactive/rest/deployment/src/test/java/io/quarkus/resteasy/reactive/server/test/security/authzpolicy/LazyAuthAuthorizationPolicyTest.java create mode 100644 extensions/resteasy-reactive/rest/deployment/src/test/java/io/quarkus/resteasy/reactive/server/test/security/authzpolicy/NoAuthorizationPolicyResource.java create mode 100644 extensions/resteasy-reactive/rest/deployment/src/test/java/io/quarkus/resteasy/reactive/server/test/security/authzpolicy/PermitUserAuthorizationPolicy.java create mode 100644 extensions/resteasy-reactive/rest/deployment/src/test/java/io/quarkus/resteasy/reactive/server/test/security/authzpolicy/ProactiveAuthAuthorizationPolicyTest.java create mode 100644 extensions/resteasy-reactive/rest/deployment/src/test/java/io/quarkus/resteasy/reactive/server/test/security/authzpolicy/ViewerAugmentingPolicy.java delete mode 100644 extensions/security/deployment/src/main/java/io/quarkus/security/deployment/SecurityTransformerUtils.java create mode 100644 extensions/security/spi/src/main/java/io/quarkus/security/spi/AdditionalSecurityAnnotationBuildItem.java create mode 100644 extensions/vertx-http/deployment/src/main/java/io/quarkus/vertx/http/deployment/AuthorizationPolicyInstancesBuildItem.java create mode 100644 extensions/vertx-http/deployment/src/main/java/io/quarkus/vertx/http/deployment/HttpSecurityUtils.java create mode 100644 extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/security/AuthorizationPolicyStorage.java create mode 100644 extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/security/JaxRsPathMatchingHttpSecurityPolicy.java create mode 100644 extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/security/AuthorizationPolicy.java diff --git a/docs/src/main/asciidoc/security-authorize-web-endpoints-reference.adoc b/docs/src/main/asciidoc/security-authorize-web-endpoints-reference.adoc index fd1bc7ced2d30..24452af2d5d71 100644 --- a/docs/src/main/asciidoc/security-authorize-web-endpoints-reference.adoc +++ b/docs/src/main/asciidoc/security-authorize-web-endpoints-reference.adoc @@ -149,6 +149,33 @@ quarkus.http.auth.permission.custom1.policy=custom ---- <1> Custom policy name must match the value returned by the `io.quarkus.vertx.http.runtime.security.HttpSecurityPolicy.name` method. +Alternatively, you can bind custom named HttpSecurityPolicy to Jakarta REST endpoints with the `@AuthorizationPolicy` security annotation. + +[[authorization-policy-example]] +.Example of custom named HttpSecurityPolicy bound to a Jakarta REST endpoint +[source,java] +---- +import io.quarkus.vertx.http.security.AuthorizationPolicy; +import jakarta.annotation.security.DenyAll; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.Path; + +@DenyAll <1> +@Path("hello") +public class HelloResource { + + @AuthorizationPolicy(name = "custom") <2> + @GET + public String hello() { + return "hello"; + } + +} +---- +<1> The `@AuthorizationPolicy` annotation can be used together with other standard security annotations. +As usual, the method-level annotation has priority over class-level annotation. +<2> Apply custom named HttpSecurityPolicy to the Jakarta REST `hello` endpoint. + [TIP] ==== You can also create global `HttpSecurityPolicy` invoked on every request. @@ -466,6 +493,9 @@ s| `@PermitAll` | Specifies that all security roles are allowed to invoke the sp `@PermitAll` lets everybody in, even without authentication. s| `@RolesAllowed` | Specifies the list of security roles allowed to access methods in an application. s| `@Authenticated` | {project-name} provides the `io.quarkus.security.Authenticated` annotation that permits any authenticated user to access the resource. It's equivalent to `@RolesAllowed("**")`. +s| `@PermissionsAllowed` | Specifies the list of permissions that are allowed to invoke the specified methods. +s| `@AuthorizationPolicy` | Specifies named `io.quarkus.vertx.http.runtime.security.HttpSecurityPolicy` that should authorize access to the specified endpoints.HttpSecurityPolicy. +Named HttpSecurityPolicy can be used for general authorization checks as demonstrated by <>. |=== The following <> demonstrates an endpoint that uses both Jakarta REST and Common Security annotations to describe and secure its endpoints. diff --git a/extensions/grpc/deployment/src/test/java/io/quarkus/grpc/auth/BlockingHttpSecurityPolicy.java b/extensions/grpc/deployment/src/test/java/io/quarkus/grpc/auth/BlockingHttpSecurityPolicy.java index 9c2b6585af7d1..9f823250450af 100644 --- a/extensions/grpc/deployment/src/test/java/io/quarkus/grpc/auth/BlockingHttpSecurityPolicy.java +++ b/extensions/grpc/deployment/src/test/java/io/quarkus/grpc/auth/BlockingHttpSecurityPolicy.java @@ -26,7 +26,7 @@ public CheckResult apply(RoutingContext routingContext, SecurityIdentity securit } }); } else { - return Uni.createFrom().item(CheckResult.PERMIT); + return CheckResult.permit(); } } } diff --git a/extensions/keycloak-authorization/runtime/src/main/java/io/quarkus/keycloak/pep/runtime/KeycloakPolicyEnforcerAuthorizer.java b/extensions/keycloak-authorization/runtime/src/main/java/io/quarkus/keycloak/pep/runtime/KeycloakPolicyEnforcerAuthorizer.java index 00ee292e133ef..76a46624920d9 100644 --- a/extensions/keycloak-authorization/runtime/src/main/java/io/quarkus/keycloak/pep/runtime/KeycloakPolicyEnforcerAuthorizer.java +++ b/extensions/keycloak-authorization/runtime/src/main/java/io/quarkus/keycloak/pep/runtime/KeycloakPolicyEnforcerAuthorizer.java @@ -68,7 +68,7 @@ public PathConfig get() { public Uni apply(PathConfig pathConfig) { if (pathConfig != null && pathConfig.getEnforcementMode() == EnforcementMode.ENFORCING) { - return Uni.createFrom().item(CheckResult.DENY); + return CheckResult.deny(); } return checkPermissionInternal(routingContext, identity); } @@ -121,7 +121,7 @@ private Uni checkPermissionInternal(RoutingContext routingContext, if (credential == null) { // SecurityIdentity has been created by the authentication mechanism other than quarkus-oidc - return Uni.createFrom().item(CheckResult.PERMIT); + return CheckResult.permit(); } VertxHttpFacade httpFacade = new VertxHttpFacade(routingContext, credential.getToken(), resolver.getReadTimeout()); diff --git a/extensions/resteasy-classic/resteasy/deployment/src/main/java/io/quarkus/resteasy/deployment/ResteasyBuiltinsProcessor.java b/extensions/resteasy-classic/resteasy/deployment/src/main/java/io/quarkus/resteasy/deployment/ResteasyBuiltinsProcessor.java index d9941cd29c6cf..36d693e5a5cb9 100644 --- a/extensions/resteasy-classic/resteasy/deployment/src/main/java/io/quarkus/resteasy/deployment/ResteasyBuiltinsProcessor.java +++ b/extensions/resteasy-classic/resteasy/deployment/src/main/java/io/quarkus/resteasy/deployment/ResteasyBuiltinsProcessor.java @@ -32,6 +32,7 @@ import io.quarkus.resteasy.runtime.vertx.JsonObjectReader; import io.quarkus.resteasy.runtime.vertx.JsonObjectWriter; import io.quarkus.security.spi.DefaultSecurityCheckBuildItem; +import io.quarkus.vertx.http.runtime.security.JaxRsPathMatchingHttpSecurityPolicy; public class ResteasyBuiltinsProcessor { @@ -67,6 +68,7 @@ void setUpSecurity(BuildProducer providers, providers.produce(new ResteasyJaxrsProviderBuildItem(EagerSecurityFilter.class.getName())); additionalBeanBuildItem.produce(AdditionalBeanBuildItem.unremovableOf(EagerSecurityFilter.class)); transformEagerSecurityNativeMethod(bytecodeTransformerProducer); + additionalBeanBuildItem.produce(AdditionalBeanBuildItem.unremovableOf(JaxRsPathMatchingHttpSecurityPolicy.class)); additionalBeanBuildItem.produce(AdditionalBeanBuildItem.unremovableOf(JaxRsPermissionChecker.class)); additionalBeanBuildItem.produce( AdditionalBeanBuildItem.unremovableOf(StandardSecurityCheckInterceptor.RolesAllowedInterceptor.class)); diff --git a/extensions/resteasy-classic/resteasy/deployment/src/test/java/io/quarkus/resteasy/test/security/authzpolicy/AbstractAuthorizationPolicyTest.java b/extensions/resteasy-classic/resteasy/deployment/src/test/java/io/quarkus/resteasy/test/security/authzpolicy/AbstractAuthorizationPolicyTest.java new file mode 100644 index 0000000000000..9f875101e6f8e --- /dev/null +++ b/extensions/resteasy-classic/resteasy/deployment/src/test/java/io/quarkus/resteasy/test/security/authzpolicy/AbstractAuthorizationPolicyTest.java @@ -0,0 +1,165 @@ +package io.quarkus.resteasy.test.security.authzpolicy; + +import org.hamcrest.Matchers; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +import io.quarkus.security.test.utils.TestIdentityController; +import io.quarkus.security.test.utils.TestIdentityProvider; +import io.restassured.RestAssured; + +public abstract class AbstractAuthorizationPolicyTest { + + protected static final Class[] TEST_CLASSES = { TestIdentityProvider.class, TestIdentityController.class, + ForbidAllButViewerAuthorizationPolicy.class, ForbidViewerClassLevelPolicyResource.class, + ForbidViewerMethodLevelPolicyResource.class, NoAuthorizationPolicyResource.class, + PermitUserAuthorizationPolicy.class, ClassRolesAllowedMethodAuthZPolicyResource.class, + ClassAuthZPolicyMethodRolesAllowedResource.class, ViewerAugmentingPolicy.class, + AuthorizationPolicyAndPathMatchingPoliciesResource.class }; + + protected static final String APPLICATION_PROPERTIES = """ + quarkus.http.auth.policy.admin-role.roles-allowed=admin + quarkus.http.auth.policy.viewer-role.roles-allowed=viewer + quarkus.http.auth.permission.jax-rs1.paths=/no-authorization-policy/jax-rs-path-matching-http-perm + quarkus.http.auth.permission.jax-rs1.policy=admin-role + quarkus.http.auth.permission.jax-rs1.applies-to=JAXRS + quarkus.http.auth.permission.standard1.paths=/no-authorization-policy/path-matching-http-perm + quarkus.http.auth.permission.standard1.policy=admin-role + quarkus.http.auth.permission.jax-rs2.paths=/authz-policy-and-path-matching-policies/jax-rs-path-matching-http-perm + quarkus.http.auth.permission.jax-rs2.policy=viewer-role + quarkus.http.auth.permission.jax-rs2.applies-to=JAXRS + quarkus.http.auth.permission.standard2.paths=/authz-policy-and-path-matching-policies/path-matching-http-perm + quarkus.http.auth.permission.standard2.policy=viewer-role + """; + + @BeforeAll + public static void setupUsers() { + TestIdentityController.resetRoles() + .add("admin", "admin", "admin", "viewer") + .add("user", "user") + .add("viewer", "viewer", "viewer"); + } + + @Test + public void testNoAuthorizationPolicy() { + // unsecured endpoint + RestAssured.given().auth().preemptive().basic("viewer", "viewer").get("/no-authorization-policy/unsecured") + .then().statusCode(200).body(Matchers.equalTo("viewer")); + + // secured with JAX-RS path-matching roles allowed HTTP permission requiring 'admin' role + RestAssured.given().auth().preemptive().basic("user", "user") + .get("/no-authorization-policy/jax-rs-path-matching-http-perm") + .then().statusCode(403); + RestAssured.given().auth().preemptive().basic("admin", "admin") + .get("/no-authorization-policy/jax-rs-path-matching-http-perm") + .then().statusCode(200).body(Matchers.equalTo("admin")); + + // secured with path-matching roles allowed HTTP permission requiring 'admin' role + RestAssured.given().auth().preemptive().basic("user", "user").get("/no-authorization-policy/path-matching-http-perm") + .then().statusCode(403); + RestAssured.given().auth().preemptive().basic("admin", "admin").get("/no-authorization-policy/path-matching-http-perm") + .then().statusCode(200).body(Matchers.equalTo("admin")); + + // secured with @RolesAllowed("admin") + RestAssured.given().auth().preemptive().basic("user", "user").get("/no-authorization-policy/roles-allowed-annotation") + .then().statusCode(403); + RestAssured.given().auth().preemptive().basic("admin", "admin").get("/no-authorization-policy/roles-allowed-annotation") + .then().statusCode(200).body(Matchers.equalTo("admin")); + } + + @Test + public void testMethodLevelAuthorizationPolicy() { + // policy placed on the endpoint directly, requires 'viewer' principal and must not pass anyone else + RestAssured.given().auth().preemptive().basic("admin", "admin").get("/forbid-viewer-method-level-policy") + .then().statusCode(403); + RestAssured.given().auth().preemptive().basic("viewer", "viewer").get("/forbid-viewer-method-level-policy") + .then().statusCode(200).body(Matchers.equalTo("viewer")); + + // which means the other endpoint inside same resource class must not be affected by the policy + RestAssured.given().auth().preemptive().basic("admin", "admin").get("/forbid-viewer-method-level-policy/unsecured") + .then().statusCode(200).body(Matchers.equalTo("admin")); + } + + @Test + public void testClassLevelAuthorizationPolicy() { + // policy placed on the resource, requires 'viewer' principal and must not pass anyone else + RestAssured.given().auth().preemptive().basic("admin", "admin").get("/forbid-viewer-class-level-policy") + .then().statusCode(403); + RestAssured.given().auth().preemptive().basic("viewer", "viewer").get("/forbid-viewer-class-level-policy") + .then().statusCode(200).body(Matchers.equalTo("viewer")); + } + + @Test + public void testAuthorizationPolicyOnMethodAndRolesAllowedOnClass() { + // class with @RolesAllowed("admin") + // method with @AuthorizationPolicy(policy = "permit-user") + RestAssured.given().auth().preemptive().basic("admin", "admin").get("/roles-allowed-class-authorization-policy-method") + .then().statusCode(403); + RestAssured.given().auth().preemptive().basic("user", "user").get("/roles-allowed-class-authorization-policy-method") + .then().statusCode(200).body(Matchers.equalTo("user")); + + // no @AuthorizationPolicy on method, therefore require admin + RestAssured.given().auth().preemptive().basic("user", "user") + .get("/roles-allowed-class-authorization-policy-method/no-authz-policy") + .then().statusCode(403); + RestAssured.given().auth().preemptive().basic("admin", "admin") + .get("/roles-allowed-class-authorization-policy-method/no-authz-policy") + .then().statusCode(200).body(Matchers.equalTo("admin")); + } + + @Test + public void testAuthorizationPolicyOnClassRolesAllowedOnMethod() { + // class with @AuthorizationPolicy(policy = "permit-user") + // method with @RolesAllowed("admin") + RestAssured.given().auth().preemptive().basic("user", "user").get("/authorization-policy-class-roles-allowed-method") + .then().statusCode(403); + RestAssured.given().auth().preemptive().basic("admin", "admin").get("/authorization-policy-class-roles-allowed-method") + .then().statusCode(200).body(Matchers.equalTo("admin")); + + // class with @AuthorizationPolicy(policy = "permit-user") + // method has no annotation, therefore expect to permit only the user + RestAssured.given().auth().preemptive().basic("admin", "admin") + .get("/authorization-policy-class-roles-allowed-method/no-roles-allowed") + .then().statusCode(403); + RestAssured.given().auth().preemptive().basic("user", "user") + .get("/authorization-policy-class-roles-allowed-method/no-roles-allowed") + .then().statusCode(200).body(Matchers.equalTo("user")); + } + + @Test + public void testCombinationOfAuthzPolicyAndPathConfigPolicies() { + // ViewerAugmentingPolicy adds 'admin' role to the viewer + + // here we test that both @AuthorizationPolicy and path-matching policies work together + // viewer role is required by (JAX-RS) path-matching HTTP policies, + RestAssured.given().auth().preemptive().basic("admin", "admin") + .get("/authz-policy-and-path-matching-policies/jax-rs-path-matching-http-perm") + .then().statusCode(200).body(Matchers.equalTo("true")); + RestAssured.given().auth().preemptive().basic("viewer", "viewer") + .get("/authz-policy-and-path-matching-policies/jax-rs-path-matching-http-perm") + .then().statusCode(200).body(Matchers.equalTo("true")); + RestAssured.given().auth().preemptive().basic("user", "user") + .get("/authz-policy-and-path-matching-policies/jax-rs-path-matching-http-perm") + .then().statusCode(403); + RestAssured.given().auth().preemptive().basic("admin", "admin") + .get("/authz-policy-and-path-matching-policies/path-matching-http-perm") + .then().statusCode(200).body(Matchers.equalTo("true")); + RestAssured.given().auth().preemptive().basic("viewer", "viewer") + .get("/authz-policy-and-path-matching-policies/path-matching-http-perm") + .then().statusCode(200).body(Matchers.equalTo("true")); + RestAssured.given().auth().preemptive().basic("user", "user") + .get("/authz-policy-and-path-matching-policies/path-matching-http-perm") + .then().statusCode(403); + + // endpoint is annotated with @RolesAllowed("admin"), therefore class-level @AuthorizationPolicy is not applied + RestAssured.given().auth().preemptive().basic("admin", "admin") + .get("/authz-policy-and-path-matching-policies/roles-allowed-annotation") + .then().statusCode(200).body(Matchers.equalTo("admin")); + RestAssured.given().auth().preemptive().basic("viewer", "viewer") + .get("/authz-policy-and-path-matching-policies/roles-allowed-annotation") + .then().statusCode(403); + RestAssured.given().auth().preemptive().basic("user", "user") + .get("/authz-policy-and-path-matching-policies/roles-allowed-annotation") + .then().statusCode(403); + } +} diff --git a/extensions/resteasy-classic/resteasy/deployment/src/test/java/io/quarkus/resteasy/test/security/authzpolicy/AuthorizationPolicyAndPathMatchingPoliciesResource.java b/extensions/resteasy-classic/resteasy/deployment/src/test/java/io/quarkus/resteasy/test/security/authzpolicy/AuthorizationPolicyAndPathMatchingPoliciesResource.java new file mode 100644 index 0000000000000..e4dc9b0ced461 --- /dev/null +++ b/extensions/resteasy-classic/resteasy/deployment/src/test/java/io/quarkus/resteasy/test/security/authzpolicy/AuthorizationPolicyAndPathMatchingPoliciesResource.java @@ -0,0 +1,34 @@ +package io.quarkus.resteasy.test.security.authzpolicy; + +import jakarta.annotation.security.RolesAllowed; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.core.Context; +import jakarta.ws.rs.core.SecurityContext; + +import io.quarkus.vertx.http.security.AuthorizationPolicy; + +@AuthorizationPolicy(name = "viewer-augmenting-policy") +@Path("authz-policy-and-path-matching-policies") +public class AuthorizationPolicyAndPathMatchingPoliciesResource { + + @GET + @Path("jax-rs-path-matching-http-perm") + public boolean jaxRsPathMatchingHttpPerm(@Context SecurityContext securityContext) { + return securityContext.isUserInRole("admin"); + } + + @GET + @Path("path-matching-http-perm") + public boolean pathMatchingHttpPerm(@Context SecurityContext securityContext) { + return securityContext.isUserInRole("admin"); + } + + @RolesAllowed("admin") + @GET + @Path("roles-allowed-annotation") + public String rolesAllowed(@Context SecurityContext securityContext) { + return securityContext.getUserPrincipal().getName(); + } + +} diff --git a/extensions/resteasy-classic/resteasy/deployment/src/test/java/io/quarkus/resteasy/test/security/authzpolicy/BasicAuthenticationResource.java b/extensions/resteasy-classic/resteasy/deployment/src/test/java/io/quarkus/resteasy/test/security/authzpolicy/BasicAuthenticationResource.java new file mode 100644 index 0000000000000..e04ee70414c3a --- /dev/null +++ b/extensions/resteasy-classic/resteasy/deployment/src/test/java/io/quarkus/resteasy/test/security/authzpolicy/BasicAuthenticationResource.java @@ -0,0 +1,27 @@ +package io.quarkus.resteasy.test.security.authzpolicy; + +import jakarta.ws.rs.GET; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.core.Context; +import jakarta.ws.rs.core.SecurityContext; + +import io.quarkus.vertx.http.runtime.security.annotation.BasicAuthentication; +import io.quarkus.vertx.http.security.AuthorizationPolicy; + +@Path("basic-auth-ann") +@BasicAuthentication +public class BasicAuthenticationResource { + + @GET + public String noAuthorizationPolicy(@Context SecurityContext securityContext) { + return securityContext.getUserPrincipal().getName(); + } + + @Path("authorization-policy") + @AuthorizationPolicy(name = "forbid-all-but-viewer") + @GET + public String authorizationPolicy(@Context SecurityContext securityContext) { + return securityContext.getUserPrincipal().getName(); + } + +} diff --git a/extensions/resteasy-classic/resteasy/deployment/src/test/java/io/quarkus/resteasy/test/security/authzpolicy/ClassAuthZPolicyMethodRolesAllowedResource.java b/extensions/resteasy-classic/resteasy/deployment/src/test/java/io/quarkus/resteasy/test/security/authzpolicy/ClassAuthZPolicyMethodRolesAllowedResource.java new file mode 100644 index 0000000000000..30a2c447bab25 --- /dev/null +++ b/extensions/resteasy-classic/resteasy/deployment/src/test/java/io/quarkus/resteasy/test/security/authzpolicy/ClassAuthZPolicyMethodRolesAllowedResource.java @@ -0,0 +1,27 @@ +package io.quarkus.resteasy.test.security.authzpolicy; + +import jakarta.annotation.security.RolesAllowed; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.core.Context; +import jakarta.ws.rs.core.SecurityContext; + +import io.quarkus.vertx.http.security.AuthorizationPolicy; + +@AuthorizationPolicy(name = "permit-user") +@Path("authorization-policy-class-roles-allowed-method") +public class ClassAuthZPolicyMethodRolesAllowedResource { + + @RolesAllowed("admin") + @GET + public String principal(@Context SecurityContext securityContext) { + return securityContext.getUserPrincipal().getName(); + } + + @Path("no-roles-allowed") + @GET + public String noAuthorizationPolicy(@Context SecurityContext securityContext) { + return securityContext.getUserPrincipal().getName(); + } + +} diff --git a/extensions/resteasy-classic/resteasy/deployment/src/test/java/io/quarkus/resteasy/test/security/authzpolicy/ClassRolesAllowedMethodAuthZPolicyResource.java b/extensions/resteasy-classic/resteasy/deployment/src/test/java/io/quarkus/resteasy/test/security/authzpolicy/ClassRolesAllowedMethodAuthZPolicyResource.java new file mode 100644 index 0000000000000..62d71ccc0cd8c --- /dev/null +++ b/extensions/resteasy-classic/resteasy/deployment/src/test/java/io/quarkus/resteasy/test/security/authzpolicy/ClassRolesAllowedMethodAuthZPolicyResource.java @@ -0,0 +1,27 @@ +package io.quarkus.resteasy.test.security.authzpolicy; + +import jakarta.annotation.security.RolesAllowed; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.core.Context; +import jakarta.ws.rs.core.SecurityContext; + +import io.quarkus.vertx.http.security.AuthorizationPolicy; + +@RolesAllowed("admin") +@Path("roles-allowed-class-authorization-policy-method") +public class ClassRolesAllowedMethodAuthZPolicyResource { + + @AuthorizationPolicy(name = "permit-user") + @GET + public String principal(@Context SecurityContext securityContext) { + return securityContext.getUserPrincipal().getName(); + } + + @Path("no-authz-policy") + @GET + public String noAuthorizationPolicy(@Context SecurityContext securityContext) { + return securityContext.getUserPrincipal().getName(); + } + +} diff --git a/extensions/resteasy-classic/resteasy/deployment/src/test/java/io/quarkus/resteasy/test/security/authzpolicy/DenyAllUnannotatedWithAuthzPolicyTest.java b/extensions/resteasy-classic/resteasy/deployment/src/test/java/io/quarkus/resteasy/test/security/authzpolicy/DenyAllUnannotatedWithAuthzPolicyTest.java new file mode 100644 index 0000000000000..dd48cc419ae0d --- /dev/null +++ b/extensions/resteasy-classic/resteasy/deployment/src/test/java/io/quarkus/resteasy/test/security/authzpolicy/DenyAllUnannotatedWithAuthzPolicyTest.java @@ -0,0 +1,50 @@ +package io.quarkus.resteasy.test.security.authzpolicy; + +import org.hamcrest.Matchers; +import org.jboss.shrinkwrap.api.asset.StringAsset; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +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; +import io.restassured.RestAssured; + +public class DenyAllUnannotatedWithAuthzPolicyTest { + + @RegisterExtension + static QuarkusUnitTest runner = new QuarkusUnitTest() + .withApplicationRoot((jar) -> jar + .addClasses(ForbidViewerClassLevelPolicyResource.class, ForbidViewerMethodLevelPolicyResource.class, + ForbidAllButViewerAuthorizationPolicy.class, TestIdentityProvider.class, + TestIdentityController.class) + .addAsResource(new StringAsset("quarkus.security.jaxrs.deny-unannotated-endpoints=true\n"), + "application.properties")); + + @BeforeAll + public static void setupUsers() { + TestIdentityController.resetRoles() + .add("admin", "admin", "admin", "viewer") + .add("user", "user") + .add("viewer", "viewer", "viewer"); + } + + @Test + public void testEndpointWithoutAuthorizationPolicyIsDenied() { + RestAssured.given().auth().preemptive().basic("admin", "admin").get("/forbid-viewer-method-level-policy/unsecured") + .then().statusCode(403); + RestAssured.given().auth().preemptive().basic("viewer", "viewer").get("/forbid-viewer-method-level-policy/unsecured") + .then().statusCode(403); + } + + @Test + public void testEndpointWithAuthorizationPolicyIsNotDenied() { + // test not denied for authorized + RestAssured.given().auth().preemptive().basic("admin", "admin").get("/forbid-viewer-method-level-policy") + .then().statusCode(403); + RestAssured.given().auth().preemptive().basic("viewer", "viewer").get("/forbid-viewer-method-level-policy") + .then().statusCode(200).body(Matchers.equalTo("viewer")); + } + +} diff --git a/extensions/resteasy-classic/resteasy/deployment/src/test/java/io/quarkus/resteasy/test/security/authzpolicy/ForbidAllButViewerAuthorizationPolicy.java b/extensions/resteasy-classic/resteasy/deployment/src/test/java/io/quarkus/resteasy/test/security/authzpolicy/ForbidAllButViewerAuthorizationPolicy.java new file mode 100644 index 0000000000000..83f0344143a18 --- /dev/null +++ b/extensions/resteasy-classic/resteasy/deployment/src/test/java/io/quarkus/resteasy/test/security/authzpolicy/ForbidAllButViewerAuthorizationPolicy.java @@ -0,0 +1,28 @@ +package io.quarkus.resteasy.test.security.authzpolicy; + +import jakarta.enterprise.context.ApplicationScoped; + +import io.quarkus.security.identity.SecurityIdentity; +import io.quarkus.vertx.http.runtime.security.HttpSecurityPolicy; +import io.smallrye.mutiny.Uni; +import io.vertx.ext.web.RoutingContext; + +@ApplicationScoped +public class ForbidAllButViewerAuthorizationPolicy implements HttpSecurityPolicy { + + @Override + public Uni checkPermission(RoutingContext request, Uni identity, + AuthorizationRequestContext requestContext) { + return identity.map(i -> { + if (!i.isAnonymous() && "viewer".equals(i.getPrincipal().getName())) { + return CheckResult.PERMIT; + } + return CheckResult.DENY; + }); + } + + @Override + public String name() { + return "forbid-all-but-viewer"; + } +} diff --git a/extensions/resteasy-classic/resteasy/deployment/src/test/java/io/quarkus/resteasy/test/security/authzpolicy/ForbidViewerClassLevelPolicyResource.java b/extensions/resteasy-classic/resteasy/deployment/src/test/java/io/quarkus/resteasy/test/security/authzpolicy/ForbidViewerClassLevelPolicyResource.java new file mode 100644 index 0000000000000..1963221e80ef4 --- /dev/null +++ b/extensions/resteasy-classic/resteasy/deployment/src/test/java/io/quarkus/resteasy/test/security/authzpolicy/ForbidViewerClassLevelPolicyResource.java @@ -0,0 +1,19 @@ +package io.quarkus.resteasy.test.security.authzpolicy; + +import jakarta.ws.rs.GET; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.core.Context; +import jakarta.ws.rs.core.SecurityContext; + +import io.quarkus.vertx.http.security.AuthorizationPolicy; + +@AuthorizationPolicy(name = "forbid-all-but-viewer") +@Path("forbid-viewer-class-level-policy") +public class ForbidViewerClassLevelPolicyResource { + + @GET + public String principal(@Context SecurityContext securityContext) { + return securityContext.getUserPrincipal().getName(); + } + +} diff --git a/extensions/resteasy-classic/resteasy/deployment/src/test/java/io/quarkus/resteasy/test/security/authzpolicy/ForbidViewerMethodLevelPolicyResource.java b/extensions/resteasy-classic/resteasy/deployment/src/test/java/io/quarkus/resteasy/test/security/authzpolicy/ForbidViewerMethodLevelPolicyResource.java new file mode 100644 index 0000000000000..99845527fa37a --- /dev/null +++ b/extensions/resteasy-classic/resteasy/deployment/src/test/java/io/quarkus/resteasy/test/security/authzpolicy/ForbidViewerMethodLevelPolicyResource.java @@ -0,0 +1,30 @@ +package io.quarkus.resteasy.test.security.authzpolicy; + +import jakarta.inject.Inject; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.core.Context; +import jakarta.ws.rs.core.SecurityContext; + +import io.quarkus.security.identity.SecurityIdentity; +import io.quarkus.vertx.http.security.AuthorizationPolicy; + +@Path("forbid-viewer-method-level-policy") +public class ForbidViewerMethodLevelPolicyResource { + + @Inject + SecurityIdentity securityIdentity; + + @AuthorizationPolicy(name = "forbid-all-but-viewer") + @GET + public String principal(@Context SecurityContext securityContext) { + return securityContext.getUserPrincipal().getName(); + } + + @Path("unsecured") + @GET + public String unsecured() { + return securityIdentity.getPrincipal().getName(); + } + +} diff --git a/extensions/resteasy-classic/resteasy/deployment/src/test/java/io/quarkus/resteasy/test/security/authzpolicy/LazyAuthAuthorizationPolicyTest.java b/extensions/resteasy-classic/resteasy/deployment/src/test/java/io/quarkus/resteasy/test/security/authzpolicy/LazyAuthAuthorizationPolicyTest.java new file mode 100644 index 0000000000000..5a081bdb641cf --- /dev/null +++ b/extensions/resteasy-classic/resteasy/deployment/src/test/java/io/quarkus/resteasy/test/security/authzpolicy/LazyAuthAuthorizationPolicyTest.java @@ -0,0 +1,37 @@ +package io.quarkus.resteasy.test.security.authzpolicy; + +import org.hamcrest.Matchers; +import org.jboss.shrinkwrap.api.asset.StringAsset; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import io.quarkus.test.QuarkusUnitTest; +import io.restassured.RestAssured; + +public class LazyAuthAuthorizationPolicyTest extends AbstractAuthorizationPolicyTest { + + @RegisterExtension + static QuarkusUnitTest runner = new QuarkusUnitTest() + .withApplicationRoot((jar) -> jar + .addClasses(TEST_CLASSES) + .addClass(BasicAuthenticationResource.class) + .addAsResource(new StringAsset("quarkus.http.auth.proactive=false\n" + APPLICATION_PROPERTIES), + "application.properties")); + + @Test + public void testBasicAuthSelectedWithAnnotation() { + // no @AuthorizationPolicy == authentication required + RestAssured.given().auth().preemptive().basic("admin", "admin").get("/basic-auth-ann") + .then().statusCode(200).body(Matchers.equalTo("admin")); + RestAssured.given().auth().preemptive().basic("viewer", "viewer").get("/basic-auth-ann") + .then().statusCode(200).body(Matchers.equalTo("viewer")); + RestAssured.given().get("/basic-auth-ann").then().statusCode(401); + + // @AuthorizationPolicy requires viewer and overrides class level @BasicAuthentication + RestAssured.given().auth().preemptive().basic("admin", "admin").get("/basic-auth-ann/authorization-policy") + .then().statusCode(403); + RestAssured.given().auth().preemptive().basic("viewer", "viewer").get("/basic-auth-ann/authorization-policy") + .then().statusCode(200).body(Matchers.equalTo("viewer")); + RestAssured.given().get("/basic-auth-ann/authorization-policy").then().statusCode(401); + } +} diff --git a/extensions/resteasy-classic/resteasy/deployment/src/test/java/io/quarkus/resteasy/test/security/authzpolicy/NoAuthorizationPolicyResource.java b/extensions/resteasy-classic/resteasy/deployment/src/test/java/io/quarkus/resteasy/test/security/authzpolicy/NoAuthorizationPolicyResource.java new file mode 100644 index 0000000000000..e7b39b2b1258f --- /dev/null +++ b/extensions/resteasy-classic/resteasy/deployment/src/test/java/io/quarkus/resteasy/test/security/authzpolicy/NoAuthorizationPolicyResource.java @@ -0,0 +1,43 @@ +package io.quarkus.resteasy.test.security.authzpolicy; + +import jakarta.annotation.security.RolesAllowed; +import jakarta.inject.Inject; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.core.Context; +import jakarta.ws.rs.core.SecurityContext; + +import io.quarkus.security.identity.SecurityIdentity; + +@Path("no-authorization-policy") +public class NoAuthorizationPolicyResource { + + @Inject + SecurityIdentity securityIdentity; + + @GET + @Path("unsecured") + public String unsecured() { + return securityIdentity.getPrincipal().getName(); + } + + @GET + @Path("jax-rs-path-matching-http-perm") + public String jaxRsPathMatchingHttpPerm(@Context SecurityContext securityContext) { + return securityContext.getUserPrincipal().getName(); + } + + @GET + @Path("path-matching-http-perm") + public String pathMatchingHttpPerm(@Context SecurityContext securityContext) { + return securityContext.getUserPrincipal().getName(); + } + + @RolesAllowed("admin") + @GET + @Path("roles-allowed-annotation") + public String rolesAllowed(@Context SecurityContext securityContext) { + return securityContext.getUserPrincipal().getName(); + } + +} diff --git a/extensions/resteasy-classic/resteasy/deployment/src/test/java/io/quarkus/resteasy/test/security/authzpolicy/PermitUserAuthorizationPolicy.java b/extensions/resteasy-classic/resteasy/deployment/src/test/java/io/quarkus/resteasy/test/security/authzpolicy/PermitUserAuthorizationPolicy.java new file mode 100644 index 0000000000000..68a0e89cfa665 --- /dev/null +++ b/extensions/resteasy-classic/resteasy/deployment/src/test/java/io/quarkus/resteasy/test/security/authzpolicy/PermitUserAuthorizationPolicy.java @@ -0,0 +1,28 @@ +package io.quarkus.resteasy.test.security.authzpolicy; + +import jakarta.enterprise.context.ApplicationScoped; + +import io.quarkus.security.identity.SecurityIdentity; +import io.quarkus.vertx.http.runtime.security.HttpSecurityPolicy; +import io.smallrye.mutiny.Uni; +import io.vertx.ext.web.RoutingContext; + +@ApplicationScoped +public class PermitUserAuthorizationPolicy implements HttpSecurityPolicy { + + @Override + public Uni checkPermission(RoutingContext request, Uni identity, + AuthorizationRequestContext requestContext) { + return identity.map(i -> { + if (!i.isAnonymous() && "user".equals(i.getPrincipal().getName())) { + return CheckResult.PERMIT; + } + return CheckResult.DENY; + }); + } + + @Override + public String name() { + return "permit-user"; + } +} diff --git a/extensions/resteasy-classic/resteasy/deployment/src/test/java/io/quarkus/resteasy/test/security/authzpolicy/ProactiveAuthAuthorizationPolicyTest.java b/extensions/resteasy-classic/resteasy/deployment/src/test/java/io/quarkus/resteasy/test/security/authzpolicy/ProactiveAuthAuthorizationPolicyTest.java new file mode 100644 index 0000000000000..79af2fa634ce9 --- /dev/null +++ b/extensions/resteasy-classic/resteasy/deployment/src/test/java/io/quarkus/resteasy/test/security/authzpolicy/ProactiveAuthAuthorizationPolicyTest.java @@ -0,0 +1,16 @@ +package io.quarkus.resteasy.test.security.authzpolicy; + +import org.jboss.shrinkwrap.api.asset.StringAsset; +import org.junit.jupiter.api.extension.RegisterExtension; + +import io.quarkus.test.QuarkusUnitTest; + +public class ProactiveAuthAuthorizationPolicyTest extends AbstractAuthorizationPolicyTest { + + @RegisterExtension + static QuarkusUnitTest runner = new QuarkusUnitTest() + .withApplicationRoot((jar) -> jar + .addClasses(TEST_CLASSES) + .addAsResource(new StringAsset(APPLICATION_PROPERTIES), "application.properties")); + +} diff --git a/extensions/resteasy-classic/resteasy/deployment/src/test/java/io/quarkus/resteasy/test/security/authzpolicy/ViewerAugmentingPolicy.java b/extensions/resteasy-classic/resteasy/deployment/src/test/java/io/quarkus/resteasy/test/security/authzpolicy/ViewerAugmentingPolicy.java new file mode 100644 index 0000000000000..890fc65e2fdb1 --- /dev/null +++ b/extensions/resteasy-classic/resteasy/deployment/src/test/java/io/quarkus/resteasy/test/security/authzpolicy/ViewerAugmentingPolicy.java @@ -0,0 +1,30 @@ +package io.quarkus.resteasy.test.security.authzpolicy; + +import jakarta.enterprise.context.ApplicationScoped; + +import io.quarkus.security.identity.SecurityIdentity; +import io.quarkus.security.runtime.QuarkusSecurityIdentity; +import io.quarkus.vertx.http.runtime.security.HttpSecurityPolicy; +import io.smallrye.mutiny.Uni; +import io.vertx.ext.web.RoutingContext; + +@ApplicationScoped +public class ViewerAugmentingPolicy implements HttpSecurityPolicy { + + @Override + public Uni checkPermission(RoutingContext request, Uni identity, + AuthorizationRequestContext requestContext) { + return identity.flatMap(i -> { + if (!i.isAnonymous() && i.getPrincipal().getName().equals("viewer")) { + var newIdentity = QuarkusSecurityIdentity.builder(i).addRole("admin").build(); + return Uni.createFrom().item(new CheckResult(true, newIdentity)); + } + return CheckResult.permit(); + }); + } + + @Override + public String name() { + return "viewer-augmenting-policy"; + } +} diff --git a/extensions/resteasy-classic/resteasy/runtime/src/main/java/io/quarkus/resteasy/runtime/EagerSecurityFilter.java b/extensions/resteasy-classic/resteasy/runtime/src/main/java/io/quarkus/resteasy/runtime/EagerSecurityFilter.java index b5e932a6a1538..cf6d7348b95f5 100644 --- a/extensions/resteasy-classic/resteasy/runtime/src/main/java/io/quarkus/resteasy/runtime/EagerSecurityFilter.java +++ b/extensions/resteasy-classic/resteasy/runtime/src/main/java/io/quarkus/resteasy/runtime/EagerSecurityFilter.java @@ -68,11 +68,15 @@ public void filter(ContainerRequestContext requestContext) throws IOException { if (interceptorStorage != null) { applyEagerSecurityInterceptors(description); } + var authZPolicyMethod = jaxRsPermissionChecker.getMethodSecuredWithAuthZPolicy(description.invokedMethodDesc(), + description.fallbackMethodDesc()); if (jaxRsPermissionChecker.shouldRunPermissionChecks()) { - jaxRsPermissionChecker.applyPermissionChecks(); + jaxRsPermissionChecker.applyPermissionChecks(authZPolicyMethod); } - applySecurityChecks(description); + if (authZPolicyMethod == null) { // if we didn't run check for @AuthorizationPolicy + applySecurityChecks(description); + } } } diff --git a/extensions/resteasy-classic/resteasy/runtime/src/main/java/io/quarkus/resteasy/runtime/JaxRsPermissionChecker.java b/extensions/resteasy-classic/resteasy/runtime/src/main/java/io/quarkus/resteasy/runtime/JaxRsPermissionChecker.java index 97232cb23b0bf..7897e484052eb 100644 --- a/extensions/resteasy-classic/resteasy/runtime/src/main/java/io/quarkus/resteasy/runtime/JaxRsPermissionChecker.java +++ b/extensions/resteasy-classic/resteasy/runtime/src/main/java/io/quarkus/resteasy/runtime/JaxRsPermissionChecker.java @@ -2,13 +2,11 @@ import static io.quarkus.security.spi.runtime.SecurityEventHelper.AUTHORIZATION_FAILURE; import static io.quarkus.security.spi.runtime.SecurityEventHelper.AUTHORIZATION_SUCCESS; -import static io.quarkus.vertx.http.runtime.PolicyMappingConfig.AppliesTo.JAXRS; import java.util.Map; import jakarta.enterprise.context.ApplicationScoped; import jakarta.enterprise.event.Event; -import jakarta.enterprise.inject.Instance; import jakarta.enterprise.inject.spi.BeanManager; import jakarta.inject.Inject; @@ -20,13 +18,11 @@ import io.quarkus.security.identity.SecurityIdentity; import io.quarkus.security.spi.runtime.AuthorizationFailureEvent; import io.quarkus.security.spi.runtime.AuthorizationSuccessEvent; -import io.quarkus.security.spi.runtime.BlockingSecurityExecutor; +import io.quarkus.security.spi.runtime.MethodDescription; import io.quarkus.security.spi.runtime.SecurityEventHelper; -import io.quarkus.vertx.http.runtime.HttpBuildTimeConfig; -import io.quarkus.vertx.http.runtime.HttpConfiguration; import io.quarkus.vertx.http.runtime.security.AbstractPathMatchingHttpSecurityPolicy; import io.quarkus.vertx.http.runtime.security.HttpSecurityPolicy; -import io.quarkus.vertx.http.runtime.security.HttpSecurityPolicy.DefaultAuthorizationRequestContext; +import io.quarkus.vertx.http.runtime.security.JaxRsPathMatchingHttpSecurityPolicy; import io.quarkus.vertx.http.runtime.security.QuarkusHttpUser; import io.vertx.ext.web.RoutingContext; @@ -37,8 +33,7 @@ */ @ApplicationScoped public class JaxRsPermissionChecker { - private final AbstractPathMatchingHttpSecurityPolicy jaxRsPathMatchingPolicy; - private final HttpSecurityPolicy.AuthorizationRequestContext authorizationRequestContext; + private final JaxRsPathMatchingHttpSecurityPolicy jaxRsPathMatchingPolicy; private final SecurityEventHelper eventHelper; @Inject @@ -47,18 +42,14 @@ public class JaxRsPermissionChecker { @Inject CurrentIdentityAssociation identityAssociation; - JaxRsPermissionChecker(HttpConfiguration httpConfig, Instance installedPolicies, - HttpBuildTimeConfig httpBuildTimeConfig, BlockingSecurityExecutor blockingSecurityExecutor, BeanManager beanManager, + JaxRsPermissionChecker(BeanManager beanManager, Event authZFailureEvent, Event authZSuccessEvent, - @ConfigProperty(name = "quarkus.security.events.enabled") boolean securityEventsEnabled) { - var jaxRsPathMatchingPolicy = new AbstractPathMatchingHttpSecurityPolicy(httpConfig.auth.permissions, - httpConfig.auth.rolePolicy, httpBuildTimeConfig.rootPath, installedPolicies, JAXRS); + @ConfigProperty(name = "quarkus.security.events.enabled") boolean securityEventsEnabled, + JaxRsPathMatchingHttpSecurityPolicy jaxRsPathMatchingPolicy) { if (jaxRsPathMatchingPolicy.hasNoPermissions()) { this.jaxRsPathMatchingPolicy = null; - this.authorizationRequestContext = null; } else { this.jaxRsPathMatchingPolicy = jaxRsPathMatchingPolicy; - this.authorizationRequestContext = new DefaultAuthorizationRequestContext(blockingSecurityExecutor); } this.eventHelper = new SecurityEventHelper<>(authZSuccessEvent, authZFailureEvent, AUTHORIZATION_SUCCESS, AUTHORIZATION_FAILURE, beanManager, securityEventsEnabled); @@ -68,9 +59,9 @@ boolean shouldRunPermissionChecks() { return jaxRsPathMatchingPolicy != null; } - void applyPermissionChecks() { + void applyPermissionChecks(MethodDescription methodDescription) { HttpSecurityPolicy.CheckResult checkResult = jaxRsPathMatchingPolicy - .checkPermission(routingContext, identityAssociation.getDeferredIdentity(), authorizationRequestContext) + .checkPermission(routingContext, identityAssociation.getDeferredIdentity(), methodDescription) .await().indefinitely(); final SecurityIdentity newIdentity; if (checkResult.getAugmentedIdentity() == null) { @@ -112,6 +103,19 @@ void applyPermissionChecks() { throw exception; } + MethodDescription getMethodSecuredWithAuthZPolicy(MethodDescription invokedMethodDesc, + MethodDescription fallbackMethodDesc) { + if (shouldRunPermissionChecks()) { + if (jaxRsPathMatchingPolicy.requiresAuthorizationPolicy(invokedMethodDesc)) { + return invokedMethodDesc; + } + if (jaxRsPathMatchingPolicy.requiresAuthorizationPolicy(fallbackMethodDesc)) { + return fallbackMethodDesc; + } + } + return null; + } + SecurityEventHelper getEventHelper() { return eventHelper; } diff --git a/extensions/resteasy-reactive/rest/deployment/src/main/java/io/quarkus/resteasy/reactive/server/deployment/ResteasyReactiveProcessor.java b/extensions/resteasy-reactive/rest/deployment/src/main/java/io/quarkus/resteasy/reactive/server/deployment/ResteasyReactiveProcessor.java index 49c2bf93a4507..45299fec7a673 100644 --- a/extensions/resteasy-reactive/rest/deployment/src/main/java/io/quarkus/resteasy/reactive/server/deployment/ResteasyReactiveProcessor.java +++ b/extensions/resteasy-reactive/rest/deployment/src/main/java/io/quarkus/resteasy/reactive/server/deployment/ResteasyReactiveProcessor.java @@ -208,11 +208,15 @@ import io.quarkus.security.AuthenticationFailedException; import io.quarkus.security.AuthenticationRedirectException; import io.quarkus.security.ForbiddenException; +import io.quarkus.security.spi.SecurityTransformerUtils; +import io.quarkus.vertx.http.deployment.AuthorizationPolicyInstancesBuildItem; import io.quarkus.vertx.http.deployment.EagerSecurityInterceptorMethodsBuildItem; import io.quarkus.vertx.http.deployment.FilterBuildItem; +import io.quarkus.vertx.http.deployment.HttpSecurityUtils; import io.quarkus.vertx.http.deployment.RouteBuildItem; import io.quarkus.vertx.http.runtime.HttpBuildTimeConfig; import io.quarkus.vertx.http.runtime.RouteConstants; +import io.quarkus.vertx.http.runtime.security.JaxRsPathMatchingHttpSecurityPolicy; import io.vertx.core.Handler; import io.vertx.core.http.HttpServerRequest; import io.vertx.core.http.HttpServerResponse; @@ -1569,10 +1573,12 @@ public void securityExceptionMappers(BuildProducer exc @BuildStep MethodScannerBuildItem integrateEagerSecurity(Capabilities capabilities, CombinedIndexBuildItem indexBuildItem, + Optional authorizationPolicyInstancesItemOpt, List eagerSecurityInterceptors, JaxRsSecurityConfig securityConfig) { if (!capabilities.isPresent(Capability.SECURITY)) { return null; } + var authZPolicyInstancesItem = authorizationPolicyInstancesItemOpt.get(); final boolean applySecurityInterceptors = !eagerSecurityInterceptors.isEmpty(); final var interceptedMethods = applySecurityInterceptors ? collectInterceptedMethods(eagerSecurityInterceptors) : null; @@ -1584,40 +1590,50 @@ MethodScannerBuildItem integrateEagerSecurity(Capabilities capabilities, Combine public List scan(MethodInfo method, ClassInfo actualEndpointClass, Map methodContext) { var endpointImpl = ServerEndpointIndexer.findEndpointImplementation(method, actualEndpointClass, index); + boolean applyAuthorizationPolicy = shouldApplyAuthZPolicy(method, endpointImpl, authZPolicyInstancesItem); if (applySecurityInterceptors) { boolean isMethodIntercepted = interceptedMethods.containsKey(endpointImpl); if (isMethodIntercepted) { return createEagerSecCustomizerWithInterceptor(interceptedMethods, endpointImpl, method, endpointImpl, - withDefaultSecurityCheck); + withDefaultSecurityCheck, applyAuthorizationPolicy); } else { isMethodIntercepted = interceptedMethods.containsKey(method); if (isMethodIntercepted && !endpointImpl.equals(method)) { return createEagerSecCustomizerWithInterceptor(interceptedMethods, method, method, endpointImpl, - withDefaultSecurityCheck); + withDefaultSecurityCheck, applyAuthorizationPolicy); } } } - return List.of(newEagerSecurityHandlerCustomizerInstance(method, endpointImpl, withDefaultSecurityCheck)); + return List.of(newEagerSecurityHandlerCustomizerInstance(method, endpointImpl, withDefaultSecurityCheck, + applyAuthorizationPolicy)); } }); } + private static boolean shouldApplyAuthZPolicy(MethodInfo method, MethodInfo endpointImpl, + AuthorizationPolicyInstancesBuildItem item) { + return item.applyAuthorizationPolicy(method) || item.applyAuthorizationPolicy(endpointImpl); + } + private static List createEagerSecCustomizerWithInterceptor( Map interceptedMethods, MethodInfo method, MethodInfo originalMethod, MethodInfo endpointImpl, - boolean withDefaultSecurityCheck) { + boolean withDefaultSecurityCheck, boolean applyAuthorizationPolicy) { var requiresSecurityCheck = interceptedMethods.get(method); final HandlerChainCustomizer eagerSecCustomizer; - if (requiresSecurityCheck) { + if (requiresSecurityCheck && !applyAuthorizationPolicy) { eagerSecCustomizer = EagerSecurityHandler.Customizer.newInstance(false); } else { eagerSecCustomizer = newEagerSecurityHandlerCustomizerInstance(originalMethod, endpointImpl, - withDefaultSecurityCheck); + withDefaultSecurityCheck, applyAuthorizationPolicy); } return List.of(EagerSecurityInterceptorHandler.Customizer.newInstance(), eagerSecCustomizer); } private static HandlerChainCustomizer newEagerSecurityHandlerCustomizerInstance(MethodInfo method, MethodInfo endpointImpl, - boolean withDefaultSecurityCheck) { + boolean withDefaultSecurityCheck, boolean applyAuthorizationPolicy) { + if (applyAuthorizationPolicy) { + return EagerSecurityHandler.Customizer.newInstanceWithAuthorizationPolicy(); + } if (withDefaultSecurityCheck || consumesStandardSecurityAnnotations(method, endpointImpl)) { return EagerSecurityHandler.Customizer.newInstance(false); } @@ -1674,7 +1690,7 @@ public void visit(int version, int access, String name, String signature, } @BuildStep - void registerSecurityInterceptors(Capabilities capabilities, + void registerSecurityBeans(Capabilities capabilities, BuildProducer beans) { if (capabilities.isPresent(Capability.SECURITY)) { // Register interceptors for standard security annotations to prevent repeated security checks @@ -1682,7 +1698,9 @@ void registerSecurityInterceptors(Capabilities capabilities, StandardSecurityCheckInterceptor.AuthenticatedInterceptor.class, StandardSecurityCheckInterceptor.PermitAllInterceptor.class, StandardSecurityCheckInterceptor.PermissionsAllowedInterceptor.class)); + beans.produce(AdditionalBeanBuildItem.unremovableOf(EagerSecurityContext.class)); + beans.produce(AdditionalBeanBuildItem.unremovableOf(JaxRsPathMatchingHttpSecurityPolicy.class)); } } @@ -1697,8 +1715,13 @@ private static boolean consumesStandardSecurityAnnotations(MethodInfo methodInfo } private static boolean consumesStandardSecurityAnnotations(MethodInfo methodInfo) { - return SecurityTransformerUtils.hasStandardSecurityAnnotation(methodInfo) - || SecurityTransformerUtils.hasStandardSecurityAnnotation(methodInfo.declaringClass()); + return SecurityTransformerUtils.hasSecurityAnnotation(methodInfo) + || (SecurityTransformerUtils.hasSecurityAnnotation(methodInfo.declaringClass()) + // security annotations cannot be combined + // and the most specific wins, so if we have both class-level security check + // and the method-level @AuthorizationPolicy, the policy wins as it is more specific + // as would any other security annotation + && !HttpSecurityUtils.hasAuthorizationPolicyAnnotation(methodInfo)); } private Optional getAppPath(Optional newPropertyValue) { diff --git a/extensions/resteasy-reactive/rest/deployment/src/main/java/io/quarkus/resteasy/reactive/server/deployment/SecurityTransformerUtils.java b/extensions/resteasy-reactive/rest/deployment/src/main/java/io/quarkus/resteasy/reactive/server/deployment/SecurityTransformerUtils.java deleted file mode 100644 index 4f1de827ac5a2..0000000000000 --- a/extensions/resteasy-reactive/rest/deployment/src/main/java/io/quarkus/resteasy/reactive/server/deployment/SecurityTransformerUtils.java +++ /dev/null @@ -1,66 +0,0 @@ -package io.quarkus.resteasy.reactive.server.deployment; - -import java.util.Collection; -import java.util.HashSet; -import java.util.Optional; -import java.util.Set; - -import jakarta.annotation.security.DenyAll; -import jakarta.annotation.security.PermitAll; -import jakarta.annotation.security.RolesAllowed; - -import org.jboss.jandex.AnnotationInstance; -import org.jboss.jandex.ClassInfo; -import org.jboss.jandex.DotName; -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<>(); - - 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())); - } - - public static boolean hasStandardSecurityAnnotation(MethodInfo methodInfo) { - return hasStandardSecurityAnnotation(methodInfo.annotations()); - } - - public static boolean hasStandardSecurityAnnotation(ClassInfo classInfo) { - return hasStandardSecurityAnnotation(classInfo.declaredAnnotations()); - } - - private static boolean hasStandardSecurityAnnotation(Collection instances) { - for (AnnotationInstance instance : instances) { - if (SECURITY_BINDINGS.contains(instance.name())) { - return true; - } - } - return false; - } - - public static Optional findFirstStandardSecurityAnnotation(MethodInfo methodInfo) { - return findFirstStandardSecurityAnnotation(methodInfo.annotations()); - } - - public static Optional findFirstStandardSecurityAnnotation(ClassInfo classInfo) { - return findFirstStandardSecurityAnnotation(classInfo.declaredAnnotations()); - } - - private static Optional findFirstStandardSecurityAnnotation(Collection instances) { - for (AnnotationInstance instance : instances) { - if (SECURITY_BINDINGS.contains(instance.name())) { - return Optional.of(instance); - } - } - return Optional.empty(); - } - -} diff --git a/extensions/resteasy-reactive/rest/deployment/src/test/java/io/quarkus/resteasy/reactive/server/test/security/authzpolicy/AbstractAuthorizationPolicyTest.java b/extensions/resteasy-reactive/rest/deployment/src/test/java/io/quarkus/resteasy/reactive/server/test/security/authzpolicy/AbstractAuthorizationPolicyTest.java new file mode 100644 index 0000000000000..e9717af9ade6b --- /dev/null +++ b/extensions/resteasy-reactive/rest/deployment/src/test/java/io/quarkus/resteasy/reactive/server/test/security/authzpolicy/AbstractAuthorizationPolicyTest.java @@ -0,0 +1,165 @@ +package io.quarkus.resteasy.reactive.server.test.security.authzpolicy; + +import org.hamcrest.Matchers; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +import io.quarkus.security.test.utils.TestIdentityController; +import io.quarkus.security.test.utils.TestIdentityProvider; +import io.restassured.RestAssured; + +public abstract class AbstractAuthorizationPolicyTest { + + protected static final Class[] TEST_CLASSES = { TestIdentityProvider.class, TestIdentityController.class, + ForbidAllButViewerAuthorizationPolicy.class, ForbidViewerClassLevelPolicyResource.class, + ForbidViewerMethodLevelPolicyResource.class, NoAuthorizationPolicyResource.class, + PermitUserAuthorizationPolicy.class, ClassRolesAllowedMethodAuthZPolicyResource.class, + ClassAuthZPolicyMethodRolesAllowedResource.class, ViewerAugmentingPolicy.class, + AuthorizationPolicyAndPathMatchingPoliciesResource.class }; + + protected static final String APPLICATION_PROPERTIES = """ + quarkus.http.auth.policy.admin-role.roles-allowed=admin + quarkus.http.auth.policy.viewer-role.roles-allowed=viewer + quarkus.http.auth.permission.jax-rs1.paths=/no-authorization-policy/jax-rs-path-matching-http-perm + quarkus.http.auth.permission.jax-rs1.policy=admin-role + quarkus.http.auth.permission.jax-rs1.applies-to=JAXRS + quarkus.http.auth.permission.standard1.paths=/no-authorization-policy/path-matching-http-perm + quarkus.http.auth.permission.standard1.policy=admin-role + quarkus.http.auth.permission.jax-rs2.paths=/authz-policy-and-path-matching-policies/jax-rs-path-matching-http-perm + quarkus.http.auth.permission.jax-rs2.policy=viewer-role + quarkus.http.auth.permission.jax-rs2.applies-to=JAXRS + quarkus.http.auth.permission.standard2.paths=/authz-policy-and-path-matching-policies/path-matching-http-perm + quarkus.http.auth.permission.standard2.policy=viewer-role + """; + + @BeforeAll + public static void setupUsers() { + TestIdentityController.resetRoles() + .add("admin", "admin", "admin", "viewer") + .add("user", "user") + .add("viewer", "viewer", "viewer"); + } + + @Test + public void testNoAuthorizationPolicy() { + // unsecured endpoint + RestAssured.given().auth().preemptive().basic("viewer", "viewer").get("/no-authorization-policy/unsecured") + .then().statusCode(200).body(Matchers.equalTo("viewer")); + + // secured with JAX-RS path-matching roles allowed HTTP permission requiring 'admin' role + RestAssured.given().auth().preemptive().basic("user", "user") + .get("/no-authorization-policy/jax-rs-path-matching-http-perm") + .then().statusCode(403); + RestAssured.given().auth().preemptive().basic("admin", "admin") + .get("/no-authorization-policy/jax-rs-path-matching-http-perm") + .then().statusCode(200).body(Matchers.equalTo("admin")); + + // secured with path-matching roles allowed HTTP permission requiring 'admin' role + RestAssured.given().auth().preemptive().basic("user", "user").get("/no-authorization-policy/path-matching-http-perm") + .then().statusCode(403); + RestAssured.given().auth().preemptive().basic("admin", "admin").get("/no-authorization-policy/path-matching-http-perm") + .then().statusCode(200).body(Matchers.equalTo("admin")); + + // secured with @RolesAllowed("admin") + RestAssured.given().auth().preemptive().basic("user", "user").get("/no-authorization-policy/roles-allowed-annotation") + .then().statusCode(403); + RestAssured.given().auth().preemptive().basic("admin", "admin").get("/no-authorization-policy/roles-allowed-annotation") + .then().statusCode(200).body(Matchers.equalTo("admin")); + } + + @Test + public void testMethodLevelAuthorizationPolicy() { + // policy placed on the endpoint directly, requires 'viewer' principal and must not pass anyone else + RestAssured.given().auth().preemptive().basic("admin", "admin").get("/forbid-viewer-method-level-policy") + .then().statusCode(403); + RestAssured.given().auth().preemptive().basic("viewer", "viewer").get("/forbid-viewer-method-level-policy") + .then().statusCode(200).body(Matchers.equalTo("viewer")); + + // which means the other endpoint inside same resource class must not be affected by the policy + RestAssured.given().auth().preemptive().basic("admin", "admin").get("/forbid-viewer-method-level-policy/unsecured") + .then().statusCode(200).body(Matchers.equalTo("admin")); + } + + @Test + public void testClassLevelAuthorizationPolicy() { + // policy placed on the resource, requires 'viewer' principal and must not pass anyone else + RestAssured.given().auth().preemptive().basic("admin", "admin").get("/forbid-viewer-class-level-policy") + .then().statusCode(403); + RestAssured.given().auth().preemptive().basic("viewer", "viewer").get("/forbid-viewer-class-level-policy") + .then().statusCode(200).body(Matchers.equalTo("viewer")); + } + + @Test + public void testAuthorizationPolicyOnMethodAndRolesAllowedOnClass() { + // class with @RolesAllowed("admin") + // method with @AuthorizationPolicy(policy = "permit-user") + RestAssured.given().auth().preemptive().basic("admin", "admin").get("/roles-allowed-class-authorization-policy-method") + .then().statusCode(403); + RestAssured.given().auth().preemptive().basic("user", "user").get("/roles-allowed-class-authorization-policy-method") + .then().statusCode(200).body(Matchers.equalTo("user")); + + // no @AuthorizationPolicy on method, therefore require admin + RestAssured.given().auth().preemptive().basic("user", "user") + .get("/roles-allowed-class-authorization-policy-method/no-authz-policy") + .then().statusCode(403); + RestAssured.given().auth().preemptive().basic("admin", "admin") + .get("/roles-allowed-class-authorization-policy-method/no-authz-policy") + .then().statusCode(200).body(Matchers.equalTo("admin")); + } + + @Test + public void testAuthorizationPolicyOnClassRolesAllowedOnMethod() { + // class with @AuthorizationPolicy(policy = "permit-user") + // method with @RolesAllowed("admin") + RestAssured.given().auth().preemptive().basic("user", "user").get("/authorization-policy-class-roles-allowed-method") + .then().statusCode(403); + RestAssured.given().auth().preemptive().basic("admin", "admin").get("/authorization-policy-class-roles-allowed-method") + .then().statusCode(200).body(Matchers.equalTo("admin")); + + // class with @AuthorizationPolicy(policy = "permit-user") + // method has no annotation, therefore expect to permit only the user + RestAssured.given().auth().preemptive().basic("admin", "admin") + .get("/authorization-policy-class-roles-allowed-method/no-roles-allowed") + .then().statusCode(403); + RestAssured.given().auth().preemptive().basic("user", "user") + .get("/authorization-policy-class-roles-allowed-method/no-roles-allowed") + .then().statusCode(200).body(Matchers.equalTo("user")); + } + + @Test + public void testCombinationOfAuthzPolicyAndPathConfigPolicies() { + // ViewerAugmentingPolicy adds 'admin' role to the viewer + + // here we test that both @AuthorizationPolicy and path-matching policies work together + // viewer role is required by (JAX-RS) path-matching HTTP policies, + RestAssured.given().auth().preemptive().basic("admin", "admin") + .get("/authz-policy-and-path-matching-policies/jax-rs-path-matching-http-perm") + .then().statusCode(200).body(Matchers.equalTo("true")); + RestAssured.given().auth().preemptive().basic("viewer", "viewer") + .get("/authz-policy-and-path-matching-policies/jax-rs-path-matching-http-perm") + .then().statusCode(200).body(Matchers.equalTo("true")); + RestAssured.given().auth().preemptive().basic("user", "user") + .get("/authz-policy-and-path-matching-policies/jax-rs-path-matching-http-perm") + .then().statusCode(403); + RestAssured.given().auth().preemptive().basic("admin", "admin") + .get("/authz-policy-and-path-matching-policies/path-matching-http-perm") + .then().statusCode(200).body(Matchers.equalTo("true")); + RestAssured.given().auth().preemptive().basic("viewer", "viewer") + .get("/authz-policy-and-path-matching-policies/path-matching-http-perm") + .then().statusCode(200).body(Matchers.equalTo("true")); + RestAssured.given().auth().preemptive().basic("user", "user") + .get("/authz-policy-and-path-matching-policies/path-matching-http-perm") + .then().statusCode(403); + + // endpoint is annotated with @RolesAllowed("admin"), therefore class-level @AuthorizationPolicy is not applied + RestAssured.given().auth().preemptive().basic("admin", "admin") + .get("/authz-policy-and-path-matching-policies/roles-allowed-annotation") + .then().statusCode(200).body(Matchers.equalTo("admin")); + RestAssured.given().auth().preemptive().basic("viewer", "viewer") + .get("/authz-policy-and-path-matching-policies/roles-allowed-annotation") + .then().statusCode(403); + RestAssured.given().auth().preemptive().basic("user", "user") + .get("/authz-policy-and-path-matching-policies/roles-allowed-annotation") + .then().statusCode(403); + } +} diff --git a/extensions/resteasy-reactive/rest/deployment/src/test/java/io/quarkus/resteasy/reactive/server/test/security/authzpolicy/AuthorizationPolicyAndPathMatchingPoliciesResource.java b/extensions/resteasy-reactive/rest/deployment/src/test/java/io/quarkus/resteasy/reactive/server/test/security/authzpolicy/AuthorizationPolicyAndPathMatchingPoliciesResource.java new file mode 100644 index 0000000000000..efa3bbf3f285d --- /dev/null +++ b/extensions/resteasy-reactive/rest/deployment/src/test/java/io/quarkus/resteasy/reactive/server/test/security/authzpolicy/AuthorizationPolicyAndPathMatchingPoliciesResource.java @@ -0,0 +1,34 @@ +package io.quarkus.resteasy.reactive.server.test.security.authzpolicy; + +import jakarta.annotation.security.RolesAllowed; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.core.Context; +import jakarta.ws.rs.core.SecurityContext; + +import io.quarkus.vertx.http.security.AuthorizationPolicy; + +@AuthorizationPolicy(name = "viewer-augmenting-policy") +@Path("authz-policy-and-path-matching-policies") +public class AuthorizationPolicyAndPathMatchingPoliciesResource { + + @GET + @Path("jax-rs-path-matching-http-perm") + public boolean jaxRsPathMatchingHttpPerm(@Context SecurityContext securityContext) { + return securityContext.isUserInRole("admin"); + } + + @GET + @Path("path-matching-http-perm") + public boolean pathMatchingHttpPerm(@Context SecurityContext securityContext) { + return securityContext.isUserInRole("admin"); + } + + @RolesAllowed("admin") + @GET + @Path("roles-allowed-annotation") + public String rolesAllowed(@Context SecurityContext securityContext) { + return securityContext.getUserPrincipal().getName(); + } + +} diff --git a/extensions/resteasy-reactive/rest/deployment/src/test/java/io/quarkus/resteasy/reactive/server/test/security/authzpolicy/BasicAuthenticationResource.java b/extensions/resteasy-reactive/rest/deployment/src/test/java/io/quarkus/resteasy/reactive/server/test/security/authzpolicy/BasicAuthenticationResource.java new file mode 100644 index 0000000000000..83f19981ba18c --- /dev/null +++ b/extensions/resteasy-reactive/rest/deployment/src/test/java/io/quarkus/resteasy/reactive/server/test/security/authzpolicy/BasicAuthenticationResource.java @@ -0,0 +1,27 @@ +package io.quarkus.resteasy.reactive.server.test.security.authzpolicy; + +import jakarta.ws.rs.GET; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.core.Context; +import jakarta.ws.rs.core.SecurityContext; + +import io.quarkus.vertx.http.runtime.security.annotation.BasicAuthentication; +import io.quarkus.vertx.http.security.AuthorizationPolicy; + +@Path("basic-auth-ann") +@BasicAuthentication +public class BasicAuthenticationResource { + + @GET + public String noAuthorizationPolicy(@Context SecurityContext securityContext) { + return securityContext.getUserPrincipal().getName(); + } + + @Path("authorization-policy") + @AuthorizationPolicy(name = "forbid-all-but-viewer") + @GET + public String authorizationPolicy(@Context SecurityContext securityContext) { + return securityContext.getUserPrincipal().getName(); + } + +} diff --git a/extensions/resteasy-reactive/rest/deployment/src/test/java/io/quarkus/resteasy/reactive/server/test/security/authzpolicy/ClassAuthZPolicyMethodRolesAllowedResource.java b/extensions/resteasy-reactive/rest/deployment/src/test/java/io/quarkus/resteasy/reactive/server/test/security/authzpolicy/ClassAuthZPolicyMethodRolesAllowedResource.java new file mode 100644 index 0000000000000..116fbf832843b --- /dev/null +++ b/extensions/resteasy-reactive/rest/deployment/src/test/java/io/quarkus/resteasy/reactive/server/test/security/authzpolicy/ClassAuthZPolicyMethodRolesAllowedResource.java @@ -0,0 +1,27 @@ +package io.quarkus.resteasy.reactive.server.test.security.authzpolicy; + +import jakarta.annotation.security.RolesAllowed; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.core.Context; +import jakarta.ws.rs.core.SecurityContext; + +import io.quarkus.vertx.http.security.AuthorizationPolicy; + +@AuthorizationPolicy(name = "permit-user") +@Path("authorization-policy-class-roles-allowed-method") +public class ClassAuthZPolicyMethodRolesAllowedResource { + + @RolesAllowed("admin") + @GET + public String principal(@Context SecurityContext securityContext) { + return securityContext.getUserPrincipal().getName(); + } + + @Path("no-roles-allowed") + @GET + public String noAuthorizationPolicy(@Context SecurityContext securityContext) { + return securityContext.getUserPrincipal().getName(); + } + +} diff --git a/extensions/resteasy-reactive/rest/deployment/src/test/java/io/quarkus/resteasy/reactive/server/test/security/authzpolicy/ClassRolesAllowedMethodAuthZPolicyResource.java b/extensions/resteasy-reactive/rest/deployment/src/test/java/io/quarkus/resteasy/reactive/server/test/security/authzpolicy/ClassRolesAllowedMethodAuthZPolicyResource.java new file mode 100644 index 0000000000000..55f23fac7bca0 --- /dev/null +++ b/extensions/resteasy-reactive/rest/deployment/src/test/java/io/quarkus/resteasy/reactive/server/test/security/authzpolicy/ClassRolesAllowedMethodAuthZPolicyResource.java @@ -0,0 +1,27 @@ +package io.quarkus.resteasy.reactive.server.test.security.authzpolicy; + +import jakarta.annotation.security.RolesAllowed; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.core.Context; +import jakarta.ws.rs.core.SecurityContext; + +import io.quarkus.vertx.http.security.AuthorizationPolicy; + +@RolesAllowed("admin") +@Path("roles-allowed-class-authorization-policy-method") +public class ClassRolesAllowedMethodAuthZPolicyResource { + + @AuthorizationPolicy(name = "permit-user") + @GET + public String principal(@Context SecurityContext securityContext) { + return securityContext.getUserPrincipal().getName(); + } + + @Path("no-authz-policy") + @GET + public String noAuthorizationPolicy(@Context SecurityContext securityContext) { + return securityContext.getUserPrincipal().getName(); + } + +} diff --git a/extensions/resteasy-reactive/rest/deployment/src/test/java/io/quarkus/resteasy/reactive/server/test/security/authzpolicy/DenyAllUnannotatedWithAuthzPolicyTest.java b/extensions/resteasy-reactive/rest/deployment/src/test/java/io/quarkus/resteasy/reactive/server/test/security/authzpolicy/DenyAllUnannotatedWithAuthzPolicyTest.java new file mode 100644 index 0000000000000..261c4a2e7fa0a --- /dev/null +++ b/extensions/resteasy-reactive/rest/deployment/src/test/java/io/quarkus/resteasy/reactive/server/test/security/authzpolicy/DenyAllUnannotatedWithAuthzPolicyTest.java @@ -0,0 +1,50 @@ +package io.quarkus.resteasy.reactive.server.test.security.authzpolicy; + +import org.hamcrest.Matchers; +import org.jboss.shrinkwrap.api.asset.StringAsset; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +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; +import io.restassured.RestAssured; + +public class DenyAllUnannotatedWithAuthzPolicyTest { + + @RegisterExtension + static QuarkusUnitTest runner = new QuarkusUnitTest() + .withApplicationRoot((jar) -> jar + .addClasses(ForbidViewerClassLevelPolicyResource.class, ForbidViewerMethodLevelPolicyResource.class, + ForbidAllButViewerAuthorizationPolicy.class, TestIdentityProvider.class, + TestIdentityController.class) + .addAsResource(new StringAsset("quarkus.security.jaxrs.deny-unannotated-endpoints=true\n"), + "application.properties")); + + @BeforeAll + public static void setupUsers() { + TestIdentityController.resetRoles() + .add("admin", "admin", "admin", "viewer") + .add("user", "user") + .add("viewer", "viewer", "viewer"); + } + + @Test + public void testEndpointWithoutAuthorizationPolicyIsDenied() { + RestAssured.given().auth().preemptive().basic("admin", "admin").get("/forbid-viewer-method-level-policy/unsecured") + .then().statusCode(403); + RestAssured.given().auth().preemptive().basic("viewer", "viewer").get("/forbid-viewer-method-level-policy/unsecured") + .then().statusCode(403); + } + + @Test + public void testEndpointWithAuthorizationPolicyIsNotDenied() { + // test not denied for authorized + RestAssured.given().auth().preemptive().basic("admin", "admin").get("/forbid-viewer-method-level-policy") + .then().statusCode(403); + RestAssured.given().auth().preemptive().basic("viewer", "viewer").get("/forbid-viewer-method-level-policy") + .then().statusCode(200).body(Matchers.equalTo("viewer")); + } + +} diff --git a/extensions/resteasy-reactive/rest/deployment/src/test/java/io/quarkus/resteasy/reactive/server/test/security/authzpolicy/ForbidAllButViewerAuthorizationPolicy.java b/extensions/resteasy-reactive/rest/deployment/src/test/java/io/quarkus/resteasy/reactive/server/test/security/authzpolicy/ForbidAllButViewerAuthorizationPolicy.java new file mode 100644 index 0000000000000..3b880e3ee4ea1 --- /dev/null +++ b/extensions/resteasy-reactive/rest/deployment/src/test/java/io/quarkus/resteasy/reactive/server/test/security/authzpolicy/ForbidAllButViewerAuthorizationPolicy.java @@ -0,0 +1,28 @@ +package io.quarkus.resteasy.reactive.server.test.security.authzpolicy; + +import jakarta.enterprise.context.ApplicationScoped; + +import io.quarkus.security.identity.SecurityIdentity; +import io.quarkus.vertx.http.runtime.security.HttpSecurityPolicy; +import io.smallrye.mutiny.Uni; +import io.vertx.ext.web.RoutingContext; + +@ApplicationScoped +public class ForbidAllButViewerAuthorizationPolicy implements HttpSecurityPolicy { + + @Override + public Uni checkPermission(RoutingContext request, Uni identity, + AuthorizationRequestContext requestContext) { + return identity.map(i -> { + if (!i.isAnonymous() && "viewer".equals(i.getPrincipal().getName())) { + return CheckResult.PERMIT; + } + return CheckResult.DENY; + }); + } + + @Override + public String name() { + return "forbid-all-but-viewer"; + } +} diff --git a/extensions/resteasy-reactive/rest/deployment/src/test/java/io/quarkus/resteasy/reactive/server/test/security/authzpolicy/ForbidViewerClassLevelPolicyResource.java b/extensions/resteasy-reactive/rest/deployment/src/test/java/io/quarkus/resteasy/reactive/server/test/security/authzpolicy/ForbidViewerClassLevelPolicyResource.java new file mode 100644 index 0000000000000..6f5dc843a5a7a --- /dev/null +++ b/extensions/resteasy-reactive/rest/deployment/src/test/java/io/quarkus/resteasy/reactive/server/test/security/authzpolicy/ForbidViewerClassLevelPolicyResource.java @@ -0,0 +1,19 @@ +package io.quarkus.resteasy.reactive.server.test.security.authzpolicy; + +import jakarta.ws.rs.GET; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.core.Context; +import jakarta.ws.rs.core.SecurityContext; + +import io.quarkus.vertx.http.security.AuthorizationPolicy; + +@AuthorizationPolicy(name = "forbid-all-but-viewer") +@Path("forbid-viewer-class-level-policy") +public class ForbidViewerClassLevelPolicyResource { + + @GET + public String principal(@Context SecurityContext securityContext) { + return securityContext.getUserPrincipal().getName(); + } + +} diff --git a/extensions/resteasy-reactive/rest/deployment/src/test/java/io/quarkus/resteasy/reactive/server/test/security/authzpolicy/ForbidViewerMethodLevelPolicyResource.java b/extensions/resteasy-reactive/rest/deployment/src/test/java/io/quarkus/resteasy/reactive/server/test/security/authzpolicy/ForbidViewerMethodLevelPolicyResource.java new file mode 100644 index 0000000000000..1079b20bf155f --- /dev/null +++ b/extensions/resteasy-reactive/rest/deployment/src/test/java/io/quarkus/resteasy/reactive/server/test/security/authzpolicy/ForbidViewerMethodLevelPolicyResource.java @@ -0,0 +1,30 @@ +package io.quarkus.resteasy.reactive.server.test.security.authzpolicy; + +import jakarta.inject.Inject; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.core.Context; +import jakarta.ws.rs.core.SecurityContext; + +import io.quarkus.security.identity.SecurityIdentity; +import io.quarkus.vertx.http.security.AuthorizationPolicy; + +@Path("forbid-viewer-method-level-policy") +public class ForbidViewerMethodLevelPolicyResource { + + @Inject + SecurityIdentity securityIdentity; + + @AuthorizationPolicy(name = "forbid-all-but-viewer") + @GET + public String principal(@Context SecurityContext securityContext) { + return securityContext.getUserPrincipal().getName(); + } + + @Path("unsecured") + @GET + public String unsecured() { + return securityIdentity.getPrincipal().getName(); + } + +} diff --git a/extensions/resteasy-reactive/rest/deployment/src/test/java/io/quarkus/resteasy/reactive/server/test/security/authzpolicy/LazyAuthAuthorizationPolicyTest.java b/extensions/resteasy-reactive/rest/deployment/src/test/java/io/quarkus/resteasy/reactive/server/test/security/authzpolicy/LazyAuthAuthorizationPolicyTest.java new file mode 100644 index 0000000000000..3395fad5988cd --- /dev/null +++ b/extensions/resteasy-reactive/rest/deployment/src/test/java/io/quarkus/resteasy/reactive/server/test/security/authzpolicy/LazyAuthAuthorizationPolicyTest.java @@ -0,0 +1,37 @@ +package io.quarkus.resteasy.reactive.server.test.security.authzpolicy; + +import org.hamcrest.Matchers; +import org.jboss.shrinkwrap.api.asset.StringAsset; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import io.quarkus.test.QuarkusUnitTest; +import io.restassured.RestAssured; + +public class LazyAuthAuthorizationPolicyTest extends AbstractAuthorizationPolicyTest { + + @RegisterExtension + static QuarkusUnitTest runner = new QuarkusUnitTest() + .withApplicationRoot((jar) -> jar + .addClasses(TEST_CLASSES) + .addClass(BasicAuthenticationResource.class) + .addAsResource(new StringAsset("quarkus.http.auth.proactive=false\n" + APPLICATION_PROPERTIES), + "application.properties")); + + @Test + public void testBasicAuthSelectedWithAnnotation() { + // no @AuthorizationPolicy == authentication required + RestAssured.given().auth().preemptive().basic("admin", "admin").get("/basic-auth-ann") + .then().statusCode(200).body(Matchers.equalTo("admin")); + RestAssured.given().auth().preemptive().basic("viewer", "viewer").get("/basic-auth-ann") + .then().statusCode(200).body(Matchers.equalTo("viewer")); + RestAssured.given().get("/basic-auth-ann").then().statusCode(401); + + // @AuthorizationPolicy requires viewer and overrides class level @BasicAuthentication + RestAssured.given().auth().preemptive().basic("admin", "admin").get("/basic-auth-ann/authorization-policy") + .then().statusCode(403); + RestAssured.given().auth().preemptive().basic("viewer", "viewer").get("/basic-auth-ann/authorization-policy") + .then().statusCode(200).body(Matchers.equalTo("viewer")); + RestAssured.given().get("/basic-auth-ann/authorization-policy").then().statusCode(401); + } +} diff --git a/extensions/resteasy-reactive/rest/deployment/src/test/java/io/quarkus/resteasy/reactive/server/test/security/authzpolicy/NoAuthorizationPolicyResource.java b/extensions/resteasy-reactive/rest/deployment/src/test/java/io/quarkus/resteasy/reactive/server/test/security/authzpolicy/NoAuthorizationPolicyResource.java new file mode 100644 index 0000000000000..e9ffce3eb917c --- /dev/null +++ b/extensions/resteasy-reactive/rest/deployment/src/test/java/io/quarkus/resteasy/reactive/server/test/security/authzpolicy/NoAuthorizationPolicyResource.java @@ -0,0 +1,43 @@ +package io.quarkus.resteasy.reactive.server.test.security.authzpolicy; + +import jakarta.annotation.security.RolesAllowed; +import jakarta.inject.Inject; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.core.Context; +import jakarta.ws.rs.core.SecurityContext; + +import io.quarkus.security.identity.SecurityIdentity; + +@Path("no-authorization-policy") +public class NoAuthorizationPolicyResource { + + @Inject + SecurityIdentity securityIdentity; + + @GET + @Path("unsecured") + public String unsecured() { + return securityIdentity.getPrincipal().getName(); + } + + @GET + @Path("jax-rs-path-matching-http-perm") + public String jaxRsPathMatchingHttpPerm(@Context SecurityContext securityContext) { + return securityContext.getUserPrincipal().getName(); + } + + @GET + @Path("path-matching-http-perm") + public String pathMatchingHttpPerm(@Context SecurityContext securityContext) { + return securityContext.getUserPrincipal().getName(); + } + + @RolesAllowed("admin") + @GET + @Path("roles-allowed-annotation") + public String rolesAllowed(@Context SecurityContext securityContext) { + return securityContext.getUserPrincipal().getName(); + } + +} diff --git a/extensions/resteasy-reactive/rest/deployment/src/test/java/io/quarkus/resteasy/reactive/server/test/security/authzpolicy/PermitUserAuthorizationPolicy.java b/extensions/resteasy-reactive/rest/deployment/src/test/java/io/quarkus/resteasy/reactive/server/test/security/authzpolicy/PermitUserAuthorizationPolicy.java new file mode 100644 index 0000000000000..bd6c95f3848b4 --- /dev/null +++ b/extensions/resteasy-reactive/rest/deployment/src/test/java/io/quarkus/resteasy/reactive/server/test/security/authzpolicy/PermitUserAuthorizationPolicy.java @@ -0,0 +1,28 @@ +package io.quarkus.resteasy.reactive.server.test.security.authzpolicy; + +import jakarta.enterprise.context.ApplicationScoped; + +import io.quarkus.security.identity.SecurityIdentity; +import io.quarkus.vertx.http.runtime.security.HttpSecurityPolicy; +import io.smallrye.mutiny.Uni; +import io.vertx.ext.web.RoutingContext; + +@ApplicationScoped +public class PermitUserAuthorizationPolicy implements HttpSecurityPolicy { + + @Override + public Uni checkPermission(RoutingContext request, Uni identity, + AuthorizationRequestContext requestContext) { + return identity.map(i -> { + if (!i.isAnonymous() && "user".equals(i.getPrincipal().getName())) { + return CheckResult.PERMIT; + } + return CheckResult.DENY; + }); + } + + @Override + public String name() { + return "permit-user"; + } +} diff --git a/extensions/resteasy-reactive/rest/deployment/src/test/java/io/quarkus/resteasy/reactive/server/test/security/authzpolicy/ProactiveAuthAuthorizationPolicyTest.java b/extensions/resteasy-reactive/rest/deployment/src/test/java/io/quarkus/resteasy/reactive/server/test/security/authzpolicy/ProactiveAuthAuthorizationPolicyTest.java new file mode 100644 index 0000000000000..78021e492de99 --- /dev/null +++ b/extensions/resteasy-reactive/rest/deployment/src/test/java/io/quarkus/resteasy/reactive/server/test/security/authzpolicy/ProactiveAuthAuthorizationPolicyTest.java @@ -0,0 +1,16 @@ +package io.quarkus.resteasy.reactive.server.test.security.authzpolicy; + +import org.jboss.shrinkwrap.api.asset.StringAsset; +import org.junit.jupiter.api.extension.RegisterExtension; + +import io.quarkus.test.QuarkusUnitTest; + +public class ProactiveAuthAuthorizationPolicyTest extends AbstractAuthorizationPolicyTest { + + @RegisterExtension + static QuarkusUnitTest runner = new QuarkusUnitTest() + .withApplicationRoot((jar) -> jar + .addClasses(TEST_CLASSES) + .addAsResource(new StringAsset(APPLICATION_PROPERTIES), "application.properties")); + +} diff --git a/extensions/resteasy-reactive/rest/deployment/src/test/java/io/quarkus/resteasy/reactive/server/test/security/authzpolicy/ViewerAugmentingPolicy.java b/extensions/resteasy-reactive/rest/deployment/src/test/java/io/quarkus/resteasy/reactive/server/test/security/authzpolicy/ViewerAugmentingPolicy.java new file mode 100644 index 0000000000000..d94ef2647ed4a --- /dev/null +++ b/extensions/resteasy-reactive/rest/deployment/src/test/java/io/quarkus/resteasy/reactive/server/test/security/authzpolicy/ViewerAugmentingPolicy.java @@ -0,0 +1,30 @@ +package io.quarkus.resteasy.reactive.server.test.security.authzpolicy; + +import jakarta.enterprise.context.ApplicationScoped; + +import io.quarkus.security.identity.SecurityIdentity; +import io.quarkus.security.runtime.QuarkusSecurityIdentity; +import io.quarkus.vertx.http.runtime.security.HttpSecurityPolicy; +import io.smallrye.mutiny.Uni; +import io.vertx.ext.web.RoutingContext; + +@ApplicationScoped +public class ViewerAugmentingPolicy implements HttpSecurityPolicy { + + @Override + public Uni checkPermission(RoutingContext request, Uni identity, + AuthorizationRequestContext requestContext) { + return identity.flatMap(i -> { + if (!i.isAnonymous() && i.getPrincipal().getName().equals("viewer")) { + var newIdentity = QuarkusSecurityIdentity.builder(i).addRole("admin").build(); + return Uni.createFrom().item(new CheckResult(true, newIdentity)); + } + return CheckResult.permit(); + }); + } + + @Override + public String name() { + return "viewer-augmenting-policy"; + } +} diff --git a/extensions/resteasy-reactive/rest/runtime/src/main/java/io/quarkus/resteasy/reactive/server/runtime/security/EagerSecurityContext.java b/extensions/resteasy-reactive/rest/runtime/src/main/java/io/quarkus/resteasy/reactive/server/runtime/security/EagerSecurityContext.java index b75ac82fd32c9..f151242574152 100644 --- a/extensions/resteasy-reactive/rest/runtime/src/main/java/io/quarkus/resteasy/reactive/server/runtime/security/EagerSecurityContext.java +++ b/extensions/resteasy-reactive/rest/runtime/src/main/java/io/quarkus/resteasy/reactive/server/runtime/security/EagerSecurityContext.java @@ -2,7 +2,6 @@ import static io.quarkus.security.spi.runtime.SecurityEventHelper.AUTHORIZATION_FAILURE; import static io.quarkus.security.spi.runtime.SecurityEventHelper.AUTHORIZATION_SUCCESS; -import static io.quarkus.vertx.http.runtime.PolicyMappingConfig.AppliesTo.JAXRS; import java.util.Map; import java.util.function.Function; @@ -10,7 +9,6 @@ import jakarta.enterprise.event.Event; import jakarta.enterprise.event.Observes; -import jakarta.enterprise.inject.Instance; import jakarta.enterprise.inject.spi.BeanManager; import jakarta.inject.Singleton; @@ -27,13 +25,12 @@ import io.quarkus.security.spi.runtime.AuthorizationController; import io.quarkus.security.spi.runtime.AuthorizationFailureEvent; import io.quarkus.security.spi.runtime.AuthorizationSuccessEvent; -import io.quarkus.security.spi.runtime.BlockingSecurityExecutor; +import io.quarkus.security.spi.runtime.MethodDescription; import io.quarkus.security.spi.runtime.SecurityEventHelper; import io.quarkus.vertx.http.runtime.HttpBuildTimeConfig; -import io.quarkus.vertx.http.runtime.HttpConfiguration; import io.quarkus.vertx.http.runtime.security.AbstractPathMatchingHttpSecurityPolicy; import io.quarkus.vertx.http.runtime.security.HttpSecurityPolicy; -import io.quarkus.vertx.http.runtime.security.HttpSecurityPolicy.DefaultAuthorizationRequestContext; +import io.quarkus.vertx.http.runtime.security.JaxRsPathMatchingHttpSecurityPolicy; import io.quarkus.vertx.http.runtime.security.QuarkusHttpUser; import io.smallrye.mutiny.Uni; import io.vertx.ext.web.RoutingContext; @@ -42,8 +39,7 @@ public class EagerSecurityContext { static EagerSecurityContext instance = null; - private final HttpSecurityPolicy.AuthorizationRequestContext authorizationRequestContext; - final AbstractPathMatchingHttpSecurityPolicy jaxRsPathMatchingPolicy; + private final JaxRsPathMatchingHttpSecurityPolicy jaxRsPathMatchingPolicy; final SecurityEventHelper eventHelper; final InjectableInstance identityAssociation; final AuthorizationController authorizationController; @@ -54,22 +50,18 @@ public class EagerSecurityContext { @ConfigProperty(name = "quarkus.security.events.enabled") boolean securityEventsEnabled, Event authorizationSuccessEvent, BeanManager beanManager, InjectableInstance identityAssociation, AuthorizationController authorizationController, - HttpConfiguration httpConfig, BlockingSecurityExecutor blockingExecutor, - HttpBuildTimeConfig buildTimeConfig, Instance installedPolicies) { + HttpBuildTimeConfig buildTimeConfig, + JaxRsPathMatchingHttpSecurityPolicy jaxRsPathMatchingPolicy) { this.isProactiveAuthDisabled = !buildTimeConfig.auth.proactive; this.identityAssociation = identityAssociation; this.authorizationController = authorizationController; this.eventHelper = new SecurityEventHelper<>(authorizationSuccessEvent, authorizationFailureEvent, AUTHORIZATION_SUCCESS, AUTHORIZATION_FAILURE, beanManager, securityEventsEnabled); - var jaxRsPathMatchingPolicy = new AbstractPathMatchingHttpSecurityPolicy(httpConfig.auth.permissions, - httpConfig.auth.rolePolicy, buildTimeConfig.rootPath, installedPolicies, JAXRS); if (jaxRsPathMatchingPolicy.hasNoPermissions()) { this.jaxRsPathMatchingPolicy = null; - this.authorizationRequestContext = null; this.doNotRunPermissionSecurityCheck = true; } else { this.jaxRsPathMatchingPolicy = jaxRsPathMatchingPolicy; - this.authorizationRequestContext = new DefaultAuthorizationRequestContext(blockingExecutor); this.doNotRunPermissionSecurityCheck = false; } } @@ -95,7 +87,8 @@ public Uni get() { }); } - Uni getPermissionCheck(ResteasyReactiveRequestContext requestContext, SecurityIdentity identity) { + Uni getPermissionCheck(ResteasyReactiveRequestContext requestContext, SecurityIdentity identity, + MethodDescription invokedMethodDesc) { final RoutingContext routingContext = requestContext.unwrap(RoutingContext.class); if (routingContext == null) { throw new IllegalStateException( @@ -105,7 +98,7 @@ record SecurityCheckWithIdentity(SecurityIdentity identity, HttpSecurityPolicy.C } return jaxRsPathMatchingPolicy .checkPermission(routingContext, identity == null ? getDeferredIdentity() : Uni.createFrom().item(identity), - authorizationRequestContext) + invokedMethodDesc) .flatMap(new Function>() { @Override public Uni apply(HttpSecurityPolicy.CheckResult checkResult) { diff --git a/extensions/resteasy-reactive/rest/runtime/src/main/java/io/quarkus/resteasy/reactive/server/runtime/security/EagerSecurityHandler.java b/extensions/resteasy-reactive/rest/runtime/src/main/java/io/quarkus/resteasy/reactive/server/runtime/security/EagerSecurityHandler.java index 625c149fd8c5a..8b269ab3a59ba 100644 --- a/extensions/resteasy-reactive/rest/runtime/src/main/java/io/quarkus/resteasy/reactive/server/runtime/security/EagerSecurityHandler.java +++ b/extensions/resteasy-reactive/rest/runtime/src/main/java/io/quarkus/resteasy/reactive/server/runtime/security/EagerSecurityHandler.java @@ -23,6 +23,7 @@ import io.quarkus.security.spi.runtime.MethodDescription; import io.quarkus.security.spi.runtime.SecurityCheck; import io.quarkus.security.spi.runtime.SecurityCheckStorage; +import io.quarkus.vertx.http.runtime.security.AuthorizationPolicyStorage; import io.quarkus.vertx.http.runtime.security.QuarkusHttpUser; import io.smallrye.mutiny.Uni; import io.smallrye.mutiny.subscription.UniSubscriber; @@ -77,7 +78,7 @@ public void handle(ResteasyReactiveRequestContext requestContext) throws Excepti check = Uni.createFrom().deferred(new Supplier>() { @Override public Uni get() { - return EagerSecurityContext.instance.getPermissionCheck(requestContext, null); + return EagerSecurityContext.instance.getPermissionCheck(requestContext, null, invokedMethodDesc); } }); } @@ -91,7 +92,8 @@ public Uni get() { .flatMap(new Function>() { @Override public Uni apply(SecurityIdentity securityIdentity) { - return EagerSecurityContext.instance.getPermissionCheck(requestContext, securityIdentity); + return EagerSecurityContext.instance.getPermissionCheck(requestContext, securityIdentity, + invokedMethodDesc); } }) .chain(checkRequiringIdentity); @@ -228,6 +230,10 @@ private static boolean isRequestAlreadyChecked(ResteasyReactiveRequestContext re public static abstract class Customizer implements HandlerChainCustomizer { + public static HandlerChainCustomizer newInstanceWithAuthorizationPolicy() { + return new AuthZPolicyCustomizer(); + } + public static HandlerChainCustomizer newInstance(boolean onlyCheckForHttpPermissions) { return onlyCheckForHttpPermissions ? new HttpPermissionsOnlyCustomizer() : new HttpPermissionsAndSecurityChecksCustomizer(); @@ -238,6 +244,9 @@ public List handlers(Phase phase, ResourceClass resourceClass ServerResourceMethod serverResourceMethod) { if (phase == Phase.AFTER_MATCH) { if (onlyCheckForHttpPermissions()) { + if (applyAuthorizationPolicy()) { + return createHandlerForAuthZPolicy(serverResourceMethod); + } return Collections.singletonList(HTTP_PERMS_ONLY); } @@ -270,14 +279,54 @@ public List handlers(Phase phase, ResourceClass resourceClass return Collections.emptyList(); } + private static List createHandlerForAuthZPolicy(ServerResourceMethod serverResourceMethod) { + var desc = ResourceMethodDescription.of(serverResourceMethod); + var authorizationPolicyStorage = Arc.container().select(AuthorizationPolicyStorage.class).get(); + final MethodDescription securedMethod; + if (authorizationPolicyStorage.requiresAuthorizationPolicy(desc.invokedMethodDesc())) { + securedMethod = desc.invokedMethodDesc(); + } else if (authorizationPolicyStorage.requiresAuthorizationPolicy(desc.fallbackMethodDesc())) { + securedMethod = desc.fallbackMethodDesc(); + } else { + throw new IllegalStateException( + """ + @AuthorizationPolicy annotation placed on resource method '%s#%s' wasn't detected by Quarkus during the build time. + Please consult https://quarkus.io/guides/cdi-reference#bean_discovery on how to make the module containing the code discoverable by Quarkus. + """ + .formatted(desc.invokedMethodDesc().getClassName(), + desc.invokedMethodDesc().getMethodName())); + } + return Collections.singletonList(new EagerSecurityHandler(null, false, securedMethod)); + } + protected abstract boolean onlyCheckForHttpPermissions(); + protected abstract boolean applyAuthorizationPolicy(); + public static final class HttpPermissionsOnlyCustomizer extends Customizer { @Override protected boolean onlyCheckForHttpPermissions() { return true; } + + @Override + protected boolean applyAuthorizationPolicy() { + return false; + } + } + + public static final class AuthZPolicyCustomizer extends Customizer { + + @Override + protected boolean onlyCheckForHttpPermissions() { + return true; + } + + @Override + protected boolean applyAuthorizationPolicy() { + return true; + } } public static final class HttpPermissionsAndSecurityChecksCustomizer extends Customizer { @@ -286,6 +335,11 @@ public static final class HttpPermissionsAndSecurityChecksCustomizer extends Cus protected boolean onlyCheckForHttpPermissions() { return false; } + + @Override + protected boolean applyAuthorizationPolicy() { + return false; + } } } diff --git a/extensions/security/deployment/src/main/java/io/quarkus/security/deployment/AdditionalDenyingUnannotatedTransformer.java b/extensions/security/deployment/src/main/java/io/quarkus/security/deployment/AdditionalDenyingUnannotatedTransformer.java index 48b92f02ef981..c1053ee9185b9 100644 --- a/extensions/security/deployment/src/main/java/io/quarkus/security/deployment/AdditionalDenyingUnannotatedTransformer.java +++ b/extensions/security/deployment/src/main/java/io/quarkus/security/deployment/AdditionalDenyingUnannotatedTransformer.java @@ -1,7 +1,7 @@ package io.quarkus.security.deployment; import static io.quarkus.security.deployment.SecurityProcessor.createMethodDescription; -import static io.quarkus.security.deployment.SecurityTransformerUtils.DENY_ALL; +import static io.quarkus.security.spi.SecurityTransformerUtils.DENY_ALL; import java.util.Collection; import java.util.HashSet; diff --git a/extensions/security/deployment/src/main/java/io/quarkus/security/deployment/AdditionalRolesAllowedTransformer.java b/extensions/security/deployment/src/main/java/io/quarkus/security/deployment/AdditionalRolesAllowedTransformer.java index 5333526e78dcc..dd7c5d4ef7f00 100644 --- a/extensions/security/deployment/src/main/java/io/quarkus/security/deployment/AdditionalRolesAllowedTransformer.java +++ b/extensions/security/deployment/src/main/java/io/quarkus/security/deployment/AdditionalRolesAllowedTransformer.java @@ -1,21 +1,24 @@ package io.quarkus.security.deployment; import static io.quarkus.security.deployment.SecurityProcessor.createMethodDescription; -import static io.quarkus.security.deployment.SecurityTransformerUtils.ROLES_ALLOWED; import java.util.Collection; import java.util.HashSet; import java.util.List; import java.util.Set; +import jakarta.annotation.security.RolesAllowed; + import org.jboss.jandex.AnnotationTarget; import org.jboss.jandex.AnnotationValue; +import org.jboss.jandex.DotName; import io.quarkus.arc.processor.AnnotationsTransformer; import io.quarkus.security.spi.runtime.MethodDescription; public class AdditionalRolesAllowedTransformer implements AnnotationsTransformer { + private static final DotName ROLES_ALLOWED = DotName.createSimple(RolesAllowed.class.getName()); private final Set methods; private final AnnotationValue[] rolesAllowed; diff --git a/extensions/security/deployment/src/main/java/io/quarkus/security/deployment/DenyingUnannotatedTransformer.java b/extensions/security/deployment/src/main/java/io/quarkus/security/deployment/DenyingUnannotatedTransformer.java index b35fbb585def0..68621c1104179 100644 --- a/extensions/security/deployment/src/main/java/io/quarkus/security/deployment/DenyingUnannotatedTransformer.java +++ b/extensions/security/deployment/src/main/java/io/quarkus/security/deployment/DenyingUnannotatedTransformer.java @@ -1,6 +1,6 @@ package io.quarkus.security.deployment; -import static io.quarkus.security.deployment.SecurityTransformerUtils.DENY_ALL; +import static io.quarkus.security.spi.SecurityTransformerUtils.DENY_ALL; import java.util.List; @@ -9,6 +9,7 @@ import org.jboss.jandex.MethodInfo; import io.quarkus.arc.processor.AnnotationsTransformer; +import io.quarkus.security.spi.SecurityTransformerUtils; /** * @author Michal Szynkiewicz, michal.l.szynkiewicz@gmail.com @@ -24,8 +25,8 @@ public boolean appliesTo(AnnotationTarget.Kind kind) { public void transform(TransformationContext transformationContext) { ClassInfo classInfo = transformationContext.getTarget().asClass(); List methods = classInfo.methods(); - if (!SecurityTransformerUtils.hasStandardSecurityAnnotation(classInfo) - && methods.stream().anyMatch(SecurityTransformerUtils::hasStandardSecurityAnnotation)) { + if (!SecurityTransformerUtils.hasSecurityAnnotation(classInfo) + && methods.stream().anyMatch(SecurityTransformerUtils::hasSecurityAnnotation)) { transformationContext.transform().add(DENY_ALL).done(); } } diff --git a/extensions/security/deployment/src/main/java/io/quarkus/security/deployment/PermissionSecurityChecks.java b/extensions/security/deployment/src/main/java/io/quarkus/security/deployment/PermissionSecurityChecks.java index a53a93a5bb508..5f09bb774210c 100644 --- a/extensions/security/deployment/src/main/java/io/quarkus/security/deployment/PermissionSecurityChecks.java +++ b/extensions/security/deployment/src/main/java/io/quarkus/security/deployment/PermissionSecurityChecks.java @@ -1,6 +1,5 @@ package io.quarkus.security.deployment; -import static io.quarkus.arc.processor.DotNames.CLASS; 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; @@ -17,6 +16,7 @@ import java.util.Objects; import java.util.Set; import java.util.function.Function; +import java.util.function.Predicate; import org.jboss.jandex.AnnotationInstance; import org.jboss.jandex.AnnotationTarget; @@ -206,7 +206,8 @@ PermissionSecurityChecksBuilder validatePermissionClasses(IndexView index) { PermissionSecurityChecksBuilder gatherPermissionsAllowedAnnotations(List instances, Map alreadyCheckedMethods, Map alreadyCheckedClasses, - List additionalClassInstances) { + List additionalClassInstances, + Predicate hasAdditionalSecurityAnnotations) { // make sure we process annotations on methods first instances.sort(new Comparator() { @@ -230,7 +231,7 @@ public int compare(AnnotationInstance o1, AnnotationInstance o2) { final MethodInfo methodInfo = target.asMethod(); // we don't allow combining @PermissionsAllowed with other security annotations as @DenyAll, ... - if (alreadyCheckedMethods.containsKey(methodInfo)) { + if (alreadyCheckedMethods.containsKey(methodInfo) || hasAdditionalSecurityAnnotations.test(methodInfo)) { throw new IllegalStateException( String.format("Method %s of class %s is annotated with multiple security annotations", methodInfo.name(), methodInfo.declaringClass())); @@ -261,6 +262,10 @@ public int compare(AnnotationInstance o1, AnnotationInstance o2) { continue; } + if (hasAdditionalSecurityAnnotations.test(methodInfo)) { + continue; + } + // ignore method annotated with other security annotation boolean noMethodLevelSecurityAnnotation = !alreadyCheckedMethods.containsKey(methodInfo); // ignore method annotated with method-level @PermissionsAllowed @@ -284,9 +289,46 @@ public int compare(AnnotationInstance o1, AnnotationInstance o2) { for (var instance : additionalClassInstances) { gatherPermissionKeys(instance, instance.target(), cache, targetToPermissionKeys); } + + // for validation purposes, so that we detect correctly combinations with other security annotations + var targetInstances = new ArrayList<>(instances); + targetInstances.addAll(additionalClassInstances); + targetToPermissionKeys.keySet().forEach(at -> { + if (at.kind() == AnnotationTarget.Kind.CLASS) { + var classInfo = at.asClass(); + alreadyCheckedClasses.put(classInfo, getAnnotationInstance(classInfo, targetInstances)); + } else { + var methodInfo = at.asMethod(); + var methodLevelAnn = getAnnotationInstance(methodInfo, targetInstances); + if (methodLevelAnn != null) { + alreadyCheckedMethods.put(methodInfo, methodLevelAnn); + } else { + var classInfo = methodInfo.declaringClass(); + alreadyCheckedClasses.put(classInfo, getAnnotationInstance(classInfo, targetInstances)); + } + } + }); + return this; } + private static AnnotationInstance getAnnotationInstance(ClassInfo classInfo, + List annotationInstances) { + return annotationInstances.stream() + .filter(ai -> ai.target().kind() == AnnotationTarget.Kind.CLASS) + .filter(ai -> ai.target().asClass().name().equals(classInfo.name())) + .findFirst().orElseThrow(); + } + + private static AnnotationInstance getAnnotationInstance(MethodInfo methodInfo, + List annotationInstances) { + return annotationInstances.stream() + .filter(ai -> ai.target().kind() == AnnotationTarget.Kind.METHOD) + .filter(ai -> ai.target().asMethod().name().equals(methodInfo.name())) + .findFirst() + .orElse(null); + } + private static void gatherPermissionKeys(AnnotationInstance instance, T annotationTarget, List cache, Map>> targetToPermissionKeys) { 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 59211ca8ae8f6..6dd1c15118422 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 @@ -7,9 +7,9 @@ import static io.quarkus.security.deployment.DotNames.PERMISSIONS_ALLOWED; import static io.quarkus.security.deployment.DotNames.PERMIT_ALL; import static io.quarkus.security.deployment.DotNames.ROLES_ALLOWED; -import static io.quarkus.security.deployment.SecurityTransformerUtils.findFirstStandardSecurityAnnotation; -import static io.quarkus.security.deployment.SecurityTransformerUtils.hasStandardSecurityAnnotation; import static io.quarkus.security.runtime.SecurityProviderUtils.findProviderIndex; +import static io.quarkus.security.spi.SecurityTransformerUtils.findFirstStandardSecurityAnnotation; +import static io.quarkus.security.spi.SecurityTransformerUtils.hasSecurityAnnotation; import java.io.IOException; import java.lang.reflect.Modifier; @@ -33,6 +33,7 @@ import java.util.function.BiConsumer; import java.util.function.Function; import java.util.function.Predicate; +import java.util.stream.Collectors; import jakarta.enterprise.context.ApplicationScoped; import jakarta.inject.Singleton; @@ -110,12 +111,14 @@ import io.quarkus.security.runtime.interceptor.SecurityHandler; import io.quarkus.security.spi.AdditionalSecuredClassesBuildItem; import io.quarkus.security.spi.AdditionalSecuredMethodsBuildItem; +import io.quarkus.security.spi.AdditionalSecurityAnnotationBuildItem; import io.quarkus.security.spi.AdditionalSecurityConstrainerEventPropsBuildItem; import io.quarkus.security.spi.ClassSecurityCheckAnnotationBuildItem; import io.quarkus.security.spi.ClassSecurityCheckStorageBuildItem; import io.quarkus.security.spi.ClassSecurityCheckStorageBuildItem.ClassStorageBuilder; import io.quarkus.security.spi.DefaultSecurityCheckBuildItem; import io.quarkus.security.spi.RolesAllowedConfigExpResolverBuildItem; +import io.quarkus.security.spi.SecurityTransformerUtils; import io.quarkus.security.spi.runtime.AuthorizationController; import io.quarkus.security.spi.runtime.DevModeDisabledAuthorizationController; import io.quarkus.security.spi.runtime.MethodDescription; @@ -561,11 +564,13 @@ MethodSecurityChecks gatherSecurityChecks( BuildProducer classPredicate, BuildProducer configBuilderProducer, List additionalSecuredMethods, - SecurityCheckRecorder recorder, + SecurityCheckRecorder recorder, List additionalSecurityAnnotationItems, BuildProducer classSecurityCheckStorageProducer, List registerClassSecurityCheckBuildItems, BuildProducer reflectiveClassBuildItemBuildProducer, List additionalSecurityChecks, SecurityBuildTimeConfig config) { + var hasAdditionalSecAnn = hasAdditionalSecurityAnnotation(additionalSecurityAnnotationItems.stream() + .map(AdditionalSecurityAnnotationBuildItem::getSecurityAnnotationName).collect(Collectors.toSet())); classPredicate.produce(new ApplicationClassPredicateBuildItem(new SecurityCheckStorageAppPredicate())); final Map additionalSecured = new HashMap<>(); @@ -580,7 +585,8 @@ MethodSecurityChecks gatherSecurityChecks( Map securityChecks = gatherSecurityAnnotations(index, configExpSecurityCheckProducer, additionalSecured.values(), config.denyUnannotated(), recorder, configBuilderProducer, reflectiveClassBuildItemBuildProducer, rolesAllowedConfigExpResolverBuildItems, - registerClassSecurityCheckBuildItems, classSecurityCheckStorageProducer); + registerClassSecurityCheckBuildItems, classSecurityCheckStorageProducer, hasAdditionalSecAnn, + additionalSecurityAnnotationItems); for (AdditionalSecurityCheckBuildItem additionalSecurityCheck : additionalSecurityChecks) { securityChecks.put(additionalSecurityCheck.getMethodInfo(), additionalSecurityCheck.getSecurityCheck()); @@ -657,23 +663,85 @@ private static Map gatherSecurityAnnotations(IndexVie BuildProducer reflectiveClassBuildItemBuildProducer, List rolesAllowedConfigExpResolverBuildItems, List registerClassSecurityCheckBuildItems, - BuildProducer classSecurityCheckStorageProducer) { - + BuildProducer classSecurityCheckStorageProducer, + Predicate hasAdditionalSecurityAnnotations, + List additionalSecurityAnnotationItems) { Map methodToInstanceCollector = new HashMap<>(); Map classAnnotations = new HashMap<>(); Map result = new HashMap<>(); - gatherSecurityAnnotations(index, PERMIT_ALL, methodToInstanceCollector, classAnnotations, - ((m, i) -> result.put(m, recorder.permitAll()))); - gatherSecurityAnnotations(index, DotNames.AUTHENTICATED, methodToInstanceCollector, classAnnotations, - ((m, i) -> result.put(m, recorder.authenticated()))); - gatherSecurityAnnotations(index, DENY_ALL, methodToInstanceCollector, classAnnotations, - ((m, i) -> result.put(m, recorder.denyAll()))); - + var permitAllGatherer = new SecurityAnnotationGatherer(index.getAnnotations(PERMIT_ALL), methodToInstanceCollector, + ((m, i) -> result.put(m, recorder.permitAll())), classAnnotations, hasAdditionalSecurityAnnotations); + var authenticatedGatherer = new SecurityAnnotationGatherer(index.getAnnotations(DotNames.AUTHENTICATED), + methodToInstanceCollector, ((m, i) -> result.put(m, recorder.authenticated())), classAnnotations, + hasAdditionalSecurityAnnotations); + var denyAllGatherer = new SecurityAnnotationGatherer(index.getAnnotations(DENY_ALL), methodToInstanceCollector, + ((m, i) -> result.put(m, recorder.denyAll())), classAnnotations, hasAdditionalSecurityAnnotations); // here we just collect all methods annotated with @RolesAllowed Map methodToRoles = new HashMap<>(); - gatherSecurityAnnotations( - index, ROLES_ALLOWED, methodToInstanceCollector, classAnnotations, - ((methodInfo, instance) -> methodToRoles.put(methodInfo, instance.value().asStringArray()))); + var rolesAllowedGatherer = new SecurityAnnotationGatherer(index.getAnnotations(ROLES_ALLOWED), + methodToInstanceCollector, + ((methodInfo, instance) -> methodToRoles.put(methodInfo, instance.value().asStringArray())), classAnnotations, + hasAdditionalSecurityAnnotations); + + // gather method-level instances for @Authenticated, @RolesAllowed, @PermitAll, @DenyAll + permitAllGatherer.gatherMethodSecurityAnnotations(); + authenticatedGatherer.gatherMethodSecurityAnnotations(); + denyAllGatherer.gatherMethodSecurityAnnotations(); + rolesAllowedGatherer.gatherMethodSecurityAnnotations(); + + // gather @PermissionsAllowed security checks + final Map classNameToPermCheck; + List permissionInstances = new ArrayList<>( + index.getAnnotationsWithRepeatable(PERMISSIONS_ALLOWED, index)); + if (!permissionInstances.isEmpty()) { + var additionalClassInstances = registerClassSecurityCheckBuildItems + .stream() + .filter(i -> PERMISSIONS_ALLOWED.equals(i.securityAnnotationInstance.name())) + .map(i -> i.securityAnnotationInstance) + .toList(); + var securityChecks = new PermissionSecurityChecksBuilder(recorder) + .gatherPermissionsAllowedAnnotations(permissionInstances, methodToInstanceCollector, classAnnotations, + additionalClassInstances, hasAdditionalSecurityAnnotations) + .validatePermissionClasses(index) + .createPermissionPredicates() + .build(); + result.putAll(securityChecks.getMethodSecurityChecks()); + classNameToPermCheck = securityChecks.getClassNameSecurityChecks(); + + // 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); + } + } else { + classNameToPermCheck = Map.of(); + } + + // gather class-level instances for @Authenticated, @RolesAllowed, @PermitAll, @DenyAll + permitAllGatherer.gatherClassSecurityAnnotations(); + authenticatedGatherer.gatherClassSecurityAnnotations(); + denyAllGatherer.gatherClassSecurityAnnotations(); + rolesAllowedGatherer.gatherClassSecurityAnnotations(); + + // validate additional annotations on class level are not accompanied by standard security annotations + additionalSecurityAnnotationItems + .stream() + .map(AdditionalSecurityAnnotationBuildItem::getSecurityAnnotationName) + .forEach(additionalSecAnnName -> index + .getAnnotations(additionalSecAnnName) + .stream() + .filter(ai -> ai.target().kind() == AnnotationTarget.Kind.CLASS) + .map(ai -> ai.target().asClass()) + .filter(SecurityTransformerUtils::hasSecurityAnnotation) + .findFirst() + .ifPresent(ci -> { + var securityAnnotation = findFirstStandardSecurityAnnotation(ci).get().name(); + throw new RuntimeException(""" + Class '%s' is annotated with '%s' and '%s' security annotations, + however security annotations cannot be combined. + """.formatted(ci.name(), additionalSecAnnName, securityAnnotation)); + })); /* * Handle additional secured methods by adding the denyAll/rolesAllowed check to all public non-static methods @@ -683,6 +751,9 @@ private static Map gatherSecurityAnnotations(IndexVie if (!isPublicNonStaticNonConstructor(additionalSecuredMethod.methodInfo)) { continue; } + if (hasAdditionalSecurityAnnotations.test(additionalSecuredMethod.methodInfo)) { + continue; + } AnnotationInstance alreadyExistingInstance = methodToInstanceCollector.get(additionalSecuredMethod.methodInfo); if (additionalSecuredMethod.rolesAllowed.isPresent()) { if (alreadyExistingInstance == null) { @@ -717,34 +788,6 @@ private static Map gatherSecurityAnnotations(IndexVie computeRolesAllowedCheck(cache, hasRolesAllowedCheckWithConfigExp, keyIndex, recorder, entry.getValue())); } - final Map classNameToPermCheck; - List permissionInstances = new ArrayList<>( - index.getAnnotationsWithRepeatable(PERMISSIONS_ALLOWED, index)); - if (!permissionInstances.isEmpty()) { - var additionalClassInstances = registerClassSecurityCheckBuildItems - .stream() - .filter(i -> PERMISSIONS_ALLOWED.equals(i.securityAnnotationInstance.name())) - .map(i -> i.securityAnnotationInstance) - .toList(); - var securityChecks = new PermissionSecurityChecksBuilder(recorder) - .gatherPermissionsAllowedAnnotations(permissionInstances, methodToInstanceCollector, classAnnotations, - additionalClassInstances) - .validatePermissionClasses(index) - .createPermissionPredicates() - .build(); - result.putAll(securityChecks.getMethodSecurityChecks()); - classNameToPermCheck = securityChecks.getClassNameSecurityChecks(); - - // 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); - } - } else { - classNameToPermCheck = Map.of(); - } - if (!registerClassSecurityCheckBuildItems.isEmpty()) { var classStorageBuilder = new ClassStorageBuilder(); registerClassSecurityCheckBuildItems.forEach(item -> { @@ -812,6 +855,9 @@ private static Map gatherSecurityAnnotations(IndexVie if (methodToInstanceCollector.containsKey(methodInfo)) { // the method already has a security check continue; } + if (hasAdditionalSecurityAnnotations.test(methodInfo)) { + continue; + } result.put(methodInfo, recorder.denyAll()); } } @@ -883,50 +929,6 @@ static boolean isPublicNonStaticNonConstructor(MethodInfo methodInfo) { && !"".equals(methodInfo.name()); } - private static void gatherSecurityAnnotations( - IndexView index, DotName dotName, - Map alreadyCheckedMethods, - Map classLevelAnnotations, - BiConsumer putResult) { - - Collection instances = index.getAnnotations(dotName); - // make sure we process annotations on methods first - for (AnnotationInstance instance : instances) { - AnnotationTarget target = instance.target(); - if (target.kind() == AnnotationTarget.Kind.METHOD) { - MethodInfo methodInfo = target.asMethod(); - if (alreadyCheckedMethods.containsKey(methodInfo)) { - throw new IllegalStateException("Method " + methodInfo.name() + " of class " + methodInfo.declaringClass() - + " is annotated with multiple security annotations"); - } - alreadyCheckedMethods.put(methodInfo, instance); - putResult.accept(methodInfo, instance); - } - } - // now add the class annotations to methods if they haven't already been annotated - for (AnnotationInstance instance : instances) { - AnnotationTarget target = instance.target(); - if (target.kind() == AnnotationTarget.Kind.CLASS) { - List methods = target.asClass().methods(); - AnnotationInstance existingClassInstance = classLevelAnnotations.get(target.asClass()); - if (existingClassInstance == null) { - classLevelAnnotations.put(target.asClass(), instance); - for (MethodInfo methodInfo : methods) { - AnnotationInstance alreadyExistingInstance = alreadyCheckedMethods.get(methodInfo); - if ((alreadyExistingInstance == null)) { - putResult.accept(methodInfo, instance); - } - } - } else { - throw new IllegalStateException( - "Class " + target.asClass() + " is annotated with multiple security annotations " + instance.name() - + " and " + existingClassInstance.name()); - } - } - - } - } - @BuildStep FeatureBuildItem feature() { return new FeatureBuildItem(Feature.SECURITY); @@ -963,7 +965,7 @@ void validateStartUpObserversNotSecured(SynthesisFinishedBuildItem synthesisFini .map(ObserverInfo::getObserverMethod) .filter(Objects::nonNull) // synthetic observer method created for @Startup is null and not secured .forEach(mi -> { - if (hasStandardSecurityAnnotation(annotationStore.getAnnotations(mi)) + if (hasSecurityAnnotation(annotationStore.getAnnotations(mi)) || hasClassLevelStandardSecurityAnnotation(mi, annotationStore)) { var declaringClass = mi.declaringClass(); findFirstStandardSecurityAnnotation(annotationStore.getAnnotations(mi)) @@ -995,7 +997,7 @@ void gatherClassSecurityChecks(BuildProducer ai.target().kind() == AnnotationTarget.Kind.CLASS) .map(ai -> ai.target().asClass()) - .filter(SecurityTransformerUtils::hasStandardSecurityAnnotation) + .filter(SecurityTransformerUtils::hasSecurityAnnotation) .map(c -> new RegisterClassSecurityCheckBuildItem(c.name(), findFirstStandardSecurityAnnotation(c).get())) .forEach(producer::produce); } @@ -1003,7 +1005,7 @@ void gatherClassSecurityChecks(BuildProducer hasAdditionalSecurityAnnotation(Set additionalSecAnnotations) { + return new Predicate() { + @Override + public boolean test(MethodInfo methodInfo) { + return additionalSecAnnotations.stream().anyMatch(methodInfo::hasDeclaredAnnotation); + } + }; + } + + private static final class SecurityAnnotationGatherer { + private final Collection annotationInstances; + private final Map alreadyCheckedMethods; + private final BiConsumer putResult; + private final Map classLevelAnnotations; + private final Predicate hasAdditionalSecurityAnnotation; + + private SecurityAnnotationGatherer(Collection annotationInstances, + Map alreadyCheckedMethods, BiConsumer putResult, + Map classLevelAnnotations, + Predicate hasAdditionalSecurityAnnotation) { + this.annotationInstances = annotationInstances; + this.alreadyCheckedMethods = alreadyCheckedMethods; + this.putResult = putResult; + this.classLevelAnnotations = classLevelAnnotations; + this.hasAdditionalSecurityAnnotation = hasAdditionalSecurityAnnotation; + } + + private void gatherClassSecurityAnnotations() { + // now add the class annotations to methods if they haven't already been annotated + for (AnnotationInstance instance : annotationInstances) { + AnnotationTarget target = instance.target(); + if (target.kind() == AnnotationTarget.Kind.CLASS) { + List methods = target.asClass().methods(); + AnnotationInstance existingClassInstance = classLevelAnnotations.get(target.asClass()); + if (existingClassInstance == null) { + classLevelAnnotations.put(target.asClass(), instance); + for (MethodInfo methodInfo : methods) { + AnnotationInstance alreadyExistingInstance = alreadyCheckedMethods.get(methodInfo); + if ((alreadyExistingInstance == null) && !hasAdditionalSecurityAnnotation.test(methodInfo)) { + putResult.accept(methodInfo, instance); + } + } + } else { + throw new IllegalStateException( + "Class " + target.asClass() + " is annotated with multiple security annotations " + + instance.name() + + " and " + existingClassInstance.name()); + } + } + + } + } + + private void gatherMethodSecurityAnnotations() { + // make sure we process annotations on methods first + for (AnnotationInstance instance : annotationInstances) { + AnnotationTarget target = instance.target(); + if (target.kind() == AnnotationTarget.Kind.METHOD) { + MethodInfo methodInfo = target.asMethod(); + if (alreadyCheckedMethods.containsKey(methodInfo) || hasAdditionalSecurityAnnotation.test(methodInfo)) { + throw new IllegalStateException( + "Method " + methodInfo.name() + " of class " + methodInfo.declaringClass() + + " is annotated with multiple security annotations"); + } + alreadyCheckedMethods.put(methodInfo, instance); + putResult.accept(methodInfo, instance); + } + } + } + } } 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 deleted file mode 100644 index 8306a8a664483..0000000000000 --- a/extensions/security/deployment/src/main/java/io/quarkus/security/deployment/SecurityTransformerUtils.java +++ /dev/null @@ -1,61 +0,0 @@ -package io.quarkus.security.deployment; - -import java.util.Collection; -import java.util.Optional; -import java.util.Set; -import java.util.stream.Collectors; - -import jakarta.annotation.security.DenyAll; -import jakarta.annotation.security.RolesAllowed; - -import org.jboss.jandex.AnnotationInstance; -import org.jboss.jandex.ClassInfo; -import org.jboss.jandex.DotName; -import org.jboss.jandex.MethodInfo; - -import io.quarkus.arc.processor.InterceptorBindingRegistrar; - -/** - * @author Michal Szynkiewicz, michal.l.szynkiewicz@gmail.com - */ -public class SecurityTransformerUtils { - public static final DotName DENY_ALL = DotName.createSimple(DenyAll.class.getName()); - public static final DotName ROLES_ALLOWED = DotName.createSimple(RolesAllowed.class.getName()); - private static final Set SECURITY_ANNOTATIONS = SecurityAnnotationsRegistrar.SECURITY_BINDINGS.stream() - .map(InterceptorBindingRegistrar.InterceptorBinding::getName).collect(Collectors.toSet()); - - public static boolean hasStandardSecurityAnnotation(MethodInfo methodInfo) { - return hasStandardSecurityAnnotation(methodInfo.annotations()); - } - - public static boolean hasStandardSecurityAnnotation(ClassInfo classInfo) { - return hasStandardSecurityAnnotation(classInfo.declaredAnnotations()); - } - - static boolean hasStandardSecurityAnnotation(Collection instances) { - for (AnnotationInstance instance : instances) { - if (SECURITY_ANNOTATIONS.contains(instance.name())) { - return true; - } - } - return false; - } - - public static Optional findFirstStandardSecurityAnnotation(MethodInfo methodInfo) { - return findFirstStandardSecurityAnnotation(methodInfo.annotations()); - } - - public static Optional findFirstStandardSecurityAnnotation(ClassInfo classInfo) { - return findFirstStandardSecurityAnnotation(classInfo.declaredAnnotations()); - } - - static Optional findFirstStandardSecurityAnnotation(Collection instances) { - for (AnnotationInstance instance : instances) { - if (SECURITY_ANNOTATIONS.contains(instance.name())) { - return Optional.of(instance); - } - } - return Optional.empty(); - } - -} diff --git a/extensions/security/spi/src/main/java/io/quarkus/security/spi/AdditionalSecurityAnnotationBuildItem.java b/extensions/security/spi/src/main/java/io/quarkus/security/spi/AdditionalSecurityAnnotationBuildItem.java new file mode 100644 index 0000000000000..661bcad0d7ae2 --- /dev/null +++ b/extensions/security/spi/src/main/java/io/quarkus/security/spi/AdditionalSecurityAnnotationBuildItem.java @@ -0,0 +1,27 @@ +package io.quarkus.security.spi; + +import org.jboss.jandex.DotName; + +import io.quarkus.builder.item.MultiBuildItem; + +/** + * Allows integrating extensions to signal they provide their own security annotation. + * Standard security annotations cannot be combined and when two different annotations are applied, + * one on the class level and second on the method level, the method level must win. + * Without this build item, the Quarkus Security extension won't know about your security annotation integration. + * Please beware that integrating extension-specific security annotation is responsibility of that extension. + * This build item is intended for very specialized Quarkus core use cases, like integration of the authorization + * policy in the Vert.x HTTP extension. + */ +public final class AdditionalSecurityAnnotationBuildItem extends MultiBuildItem { + + private final DotName securityAnnotationName; + + public AdditionalSecurityAnnotationBuildItem(DotName securityAnnotationName) { + this.securityAnnotationName = securityAnnotationName; + } + + public DotName getSecurityAnnotationName() { + return securityAnnotationName; + } +} 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 54562cd6dcaa6..c85c497a3db9b 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 @@ -1,5 +1,7 @@ package io.quarkus.security.spi; +import java.util.Collection; +import java.util.Optional; import java.util.Set; import jakarta.annotation.security.DenyAll; @@ -17,7 +19,7 @@ /** * @author Michal Szynkiewicz, michal.l.szynkiewicz@gmail.com */ -public class SecurityTransformerUtils { +public final class SecurityTransformerUtils { public static final DotName DENY_ALL = DotName.createSimple(DenyAll.class.getName()); private static final Set SECURITY_ANNOTATIONS = Set.of(DotName.createSimple(RolesAllowed.class.getName()), DotName.createSimple(PermissionsAllowed.class.getName()), @@ -25,27 +27,41 @@ public class SecurityTransformerUtils { DotName.createSimple(DenyAll.class.getName()), DotName.createSimple(PermitAll.class.getName())); - public static boolean hasSecurityAnnotation(MethodInfo methodInfo) { - for (AnnotationInstance annotation : methodInfo.annotations()) { - if (SECURITY_ANNOTATIONS.contains(annotation.name())) { - return true; - } - } + private SecurityTransformerUtils() { + // utils + } - return false; + public static boolean hasSecurityAnnotation(MethodInfo methodInfo) { + return findFirstStandardSecurityAnnotation(methodInfo).isPresent(); } public static boolean hasSecurityAnnotation(ClassInfo classInfo) { - for (AnnotationInstance classAnnotation : classInfo.declaredAnnotations()) { - if (SECURITY_ANNOTATIONS.contains(classAnnotation.name())) { - return true; - } - } + return findFirstStandardSecurityAnnotation(classInfo).isPresent(); + } - return false; + public static boolean hasSecurityAnnotation(Collection instances) { + return findFirstStandardSecurityAnnotation(instances).isPresent(); } public static boolean isStandardSecurityAnnotation(AnnotationInstance annotationInstance) { return SECURITY_ANNOTATIONS.contains(annotationInstance.name()); } + + public static Optional findFirstStandardSecurityAnnotation(MethodInfo methodInfo) { + return findFirstStandardSecurityAnnotation(methodInfo.annotations()); + } + + public static Optional findFirstStandardSecurityAnnotation(ClassInfo classInfo) { + return findFirstStandardSecurityAnnotation(classInfo.declaredAnnotations()); + } + + public static Optional findFirstStandardSecurityAnnotation(Collection instances) { + for (AnnotationInstance instance : instances) { + if (SECURITY_ANNOTATIONS.contains(instance.name())) { + return Optional.of(instance); + } + } + return Optional.empty(); + } + } diff --git a/extensions/spring-security/deployment/src/main/java/io/quarkus/spring/security/deployment/SpringSecurityProcessor.java b/extensions/spring-security/deployment/src/main/java/io/quarkus/spring/security/deployment/SpringSecurityProcessor.java index d5d70eb2fbace..afb633fa252ae 100644 --- a/extensions/spring-security/deployment/src/main/java/io/quarkus/spring/security/deployment/SpringSecurityProcessor.java +++ b/extensions/spring-security/deployment/src/main/java/io/quarkus/spring/security/deployment/SpringSecurityProcessor.java @@ -1,7 +1,7 @@ package io.quarkus.spring.security.deployment; -import static io.quarkus.security.deployment.SecurityTransformerUtils.findFirstStandardSecurityAnnotation; -import static io.quarkus.security.deployment.SecurityTransformerUtils.hasStandardSecurityAnnotation; +import static io.quarkus.security.spi.SecurityTransformerUtils.findFirstStandardSecurityAnnotation; +import static io.quarkus.security.spi.SecurityTransformerUtils.hasSecurityAnnotation; import java.lang.reflect.Modifier; import java.util.ArrayList; @@ -41,7 +41,6 @@ import io.quarkus.deployment.builditem.FeatureBuildItem; import io.quarkus.deployment.builditem.GeneratedClassBuildItem; import io.quarkus.security.deployment.AdditionalSecurityCheckBuildItem; -import io.quarkus.security.deployment.SecurityTransformerUtils; import io.quarkus.security.runtime.SecurityCheckRecorder; import io.quarkus.security.spi.runtime.SecurityCheck; import io.quarkus.spring.di.deployment.SpringBeanNameToDotNameBuildItem; @@ -139,7 +138,7 @@ private boolean hasSpringSecurityAnnotationOtherThan(MethodInfo methodInfo, DotN //Validates that there is no @Secured with the standard security annotations at class level private void checksStandardSecurity(AnnotationInstance instance, ClassInfo classInfo) { - if (hasStandardSecurityAnnotation(classInfo)) { + if (hasSecurityAnnotation(classInfo)) { Optional firstStandardSecurityAnnotation = findFirstStandardSecurityAnnotation(classInfo); if (firstStandardSecurityAnnotation.isPresent()) { String securityAnnotationName = findFirstStandardSecurityAnnotation(classInfo).get().name() @@ -153,11 +152,10 @@ private void checksStandardSecurity(AnnotationInstance instance, ClassInfo class //Validates that there is no @Secured with the standard security annotations at method level private void checksStandardSecurity(AnnotationInstance instance, MethodInfo methodInfo) { - if (SecurityTransformerUtils.hasStandardSecurityAnnotation(methodInfo)) { - Optional firstStandardSecurityAnnotation = SecurityTransformerUtils - .findFirstStandardSecurityAnnotation(methodInfo); + if (hasSecurityAnnotation(methodInfo)) { + Optional firstStandardSecurityAnnotation = findFirstStandardSecurityAnnotation(methodInfo); if (firstStandardSecurityAnnotation.isPresent()) { - String securityAnnotationName = SecurityTransformerUtils.findFirstStandardSecurityAnnotation(methodInfo).get() + String securityAnnotationName = findFirstStandardSecurityAnnotation(methodInfo).get() .name() .withoutPackagePrefix(); throw new IllegalArgumentException("An invalid security annotation combination was detected: Found " diff --git a/extensions/undertow/runtime/src/main/java/io/quarkus/undertow/runtime/ServletHttpSecurityPolicy.java b/extensions/undertow/runtime/src/main/java/io/quarkus/undertow/runtime/ServletHttpSecurityPolicy.java index 747314b32c23e..7dd1411e338b4 100644 --- a/extensions/undertow/runtime/src/main/java/io/quarkus/undertow/runtime/ServletHttpSecurityPolicy.java +++ b/extensions/undertow/runtime/src/main/java/io/quarkus/undertow/runtime/ServletHttpSecurityPolicy.java @@ -28,7 +28,7 @@ public Uni checkPermission(RoutingContext request, Uni checkPermission(RoutingContext request, Uni() { @Override diff --git a/extensions/vertx-http/deployment/src/main/java/io/quarkus/vertx/http/deployment/AuthorizationPolicyInstancesBuildItem.java b/extensions/vertx-http/deployment/src/main/java/io/quarkus/vertx/http/deployment/AuthorizationPolicyInstancesBuildItem.java new file mode 100644 index 0000000000000..5eaef65534175 --- /dev/null +++ b/extensions/vertx-http/deployment/src/main/java/io/quarkus/vertx/http/deployment/AuthorizationPolicyInstancesBuildItem.java @@ -0,0 +1,29 @@ +package io.quarkus.vertx.http.deployment; + +import java.util.Map; + +import org.jboss.jandex.MethodInfo; + +import io.quarkus.builder.item.SimpleBuildItem; + +/** + * Carries all gathered {@link io.quarkus.vertx.http.security.AuthorizationPolicy} instances that should be applied. + */ +public final class AuthorizationPolicyInstancesBuildItem extends SimpleBuildItem { + + /** + * Contains: + * - Methods annotated with {@link io.quarkus.vertx.http.security.AuthorizationPolicy} + * - Methods of classes annotated with {@link io.quarkus.vertx.http.security.AuthorizationPolicy} that + * doesn't have another standard security annotation. + */ + final Map methodToPolicyName; + + AuthorizationPolicyInstancesBuildItem(Map methodToPolicyName) { + this.methodToPolicyName = Map.copyOf(methodToPolicyName); + } + + public boolean applyAuthorizationPolicy(MethodInfo methodInfo) { + return methodToPolicyName.containsKey(methodInfo); + } +} diff --git a/extensions/vertx-http/deployment/src/main/java/io/quarkus/vertx/http/deployment/HttpSecurityProcessor.java b/extensions/vertx-http/deployment/src/main/java/io/quarkus/vertx/http/deployment/HttpSecurityProcessor.java index 9d27aac812e25..2b1ed130e4d1f 100644 --- a/extensions/vertx-http/deployment/src/main/java/io/quarkus/vertx/http/deployment/HttpSecurityProcessor.java +++ b/extensions/vertx-http/deployment/src/main/java/io/quarkus/vertx/http/deployment/HttpSecurityProcessor.java @@ -3,6 +3,7 @@ import static io.quarkus.arc.processor.DotNames.APPLICATION_SCOPED; import static io.quarkus.arc.processor.DotNames.DEFAULT_BEAN; import static io.quarkus.arc.processor.DotNames.SINGLETON; +import static io.quarkus.vertx.http.deployment.HttpSecurityUtils.AUTHORIZATION_POLICY; import static io.quarkus.vertx.http.runtime.security.HttpAuthenticator.BASIC_AUTH_ANNOTATION_DETECTED; import static io.quarkus.vertx.http.runtime.security.HttpAuthenticator.TEST_IF_BASIC_AUTH_IMPLICITLY_REQUIRED; import static java.util.stream.Collectors.toMap; @@ -31,11 +32,17 @@ import org.jboss.jandex.DotName; import org.jboss.jandex.IndexView; import org.jboss.jandex.MethodInfo; +import org.jboss.jandex.ParameterizedType; +import org.jboss.jandex.Type; +import org.jboss.jandex.TypeVariable; +import org.objectweb.asm.Opcodes; import io.quarkus.arc.deployment.AdditionalBeanBuildItem; import io.quarkus.arc.deployment.AnnotationsTransformerBuildItem; import io.quarkus.arc.deployment.BeanContainerBuildItem; import io.quarkus.arc.deployment.BeanRegistrationPhaseBuildItem; +import io.quarkus.arc.deployment.GeneratedBeanBuildItem; +import io.quarkus.arc.deployment.GeneratedBeanGizmoAdaptor; import io.quarkus.arc.deployment.SyntheticBeanBuildItem; import io.quarkus.arc.processor.AnnotationsTransformer; import io.quarkus.arc.processor.BeanInfo; @@ -50,15 +57,20 @@ import io.quarkus.deployment.builditem.ApplicationIndexBuildItem; import io.quarkus.deployment.builditem.CombinedIndexBuildItem; import io.quarkus.deployment.builditem.SystemPropertyBuildItem; +import io.quarkus.gizmo.ClassCreator; +import io.quarkus.gizmo.DescriptorUtils; +import io.quarkus.gizmo.MethodDescriptor; +import io.quarkus.gizmo.ResultHandle; import io.quarkus.runtime.RuntimeValue; import io.quarkus.runtime.configuration.ConfigurationException; import io.quarkus.security.spi.AdditionalSecuredMethodsBuildItem; +import io.quarkus.security.spi.AdditionalSecurityAnnotationBuildItem; import io.quarkus.security.spi.AdditionalSecurityConstrainerEventPropsBuildItem; -import io.quarkus.security.spi.SecurityTransformerUtils; import io.quarkus.security.spi.runtime.MethodDescription; import io.quarkus.vertx.http.runtime.HttpBuildTimeConfig; import io.quarkus.vertx.http.runtime.HttpConfiguration; import io.quarkus.vertx.http.runtime.management.ManagementInterfaceBuildTimeConfig; +import io.quarkus.vertx.http.runtime.security.AuthorizationPolicyStorage; import io.quarkus.vertx.http.runtime.security.BasicAuthenticationMechanism; import io.quarkus.vertx.http.runtime.security.EagerSecurityInterceptorStorage; import io.quarkus.vertx.http.runtime.security.FormAuthenticationMechanism; @@ -74,13 +86,13 @@ import io.quarkus.vertx.http.runtime.security.annotation.FormAuthentication; import io.quarkus.vertx.http.runtime.security.annotation.HttpAuthenticationMechanism; import io.quarkus.vertx.http.runtime.security.annotation.MTLSAuthentication; +import io.quarkus.vertx.http.security.AuthorizationPolicy; import io.vertx.core.http.ClientAuth; import io.vertx.ext.web.RoutingContext; public class HttpSecurityProcessor { private static final DotName AUTH_MECHANISM_NAME = DotName.createSimple(HttpAuthenticationMechanism.class); - private static final DotName BASIC_AUTH_MECH_NAME = DotName.createSimple(BasicAuthenticationMechanism.class); private static final DotName BASIC_AUTH_ANNOTATION_NAME = DotName.createSimple(BasicAuthentication.class); @@ -408,6 +420,173 @@ void addRoutingCtxToSecurityEventsForCdiBeans(HttpSecurityRecorder recorder, Cap } } + @BuildStep + AuthorizationPolicyInstancesBuildItem gatherAuthorizationPolicyInstances(CombinedIndexBuildItem combinedIndex, + Capabilities capabilities) { + if (!capabilities.isPresent(Capability.SECURITY)) { + return null; + } + var methodToPolicy = combinedIndex.getIndex() + // @AuthorizationPolicy(name = "policy-name") + .getAnnotations(AUTHORIZATION_POLICY) + .stream() + .flatMap(ai -> { + var policyName = ai.value("name").asString(); + if (policyName.isBlank()) { + var targetName = ai.target().kind() == AnnotationTarget.Kind.CLASS + ? ai.target().asClass().name().toString() + : ai.target().asMethod().name(); + throw new RuntimeException(""" + The @AuthorizationPolicy annotation placed on '%s' must not have blank policy name. + """.formatted(targetName)); + } + return getPolicyTargetEndpointCandidates(ai.target()) + .map(mi -> Map.entry(mi, policyName)); + }) + .collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue)); + return new AuthorizationPolicyInstancesBuildItem(methodToPolicy); + } + + /** + * Implements {@link io.quarkus.vertx.http.runtime.security.AuthorizationPolicyStorage} as a bean. + * If no {@link AuthorizationPolicy} are detected, generated bean will look like this: + * + *
+     * {@code
+     * public class AuthorizationPolicyStorage_Imp extends AuthorizationPolicyStorage {
+     *     AuthorizationPolicyStorage_Imp() {
+     *         super();
+     *     }
+     *
+     *     @Override
+     *     protected Map getMethodToPolicyName() {
+     *         return Map.of();
+     *     }
+     * }
+     * }
+     * 
+ * + * On the other hand, if {@link AuthorizationPolicy} is detected, getMethodToPolicyName returns + * method descriptions of detected annotation instances. + */ + @BuildStep + void generateAuthorizationPolicyStorage(BuildProducer generatedBeanProducer, + Capabilities capabilities, + AuthorizationPolicyInstancesBuildItem authZPolicyInstancesItem, + BuildProducer additionalSecurityAnnotationProducer) { + if (!capabilities.isPresent(Capability.SECURITY)) { + return; + } + + // provide support for JAX-RS HTTP Security Policies to extensions that supports them + if (capabilities.isPresent(Capability.REST) || capabilities.isPresent(Capability.RESTEASY)) { + // generates: + // public class AuthorizationPolicyStorage_Impl extends AuthorizationPolicyStorage + GeneratedBeanGizmoAdaptor beanAdaptor = new GeneratedBeanGizmoAdaptor(generatedBeanProducer); + var generatedClassName = AuthorizationPolicyStorage.class.getName() + "_Impl"; + try (ClassCreator cc = ClassCreator.builder().className(generatedClassName) + .superClass(AuthorizationPolicyStorage.class).classOutput(beanAdaptor).build()) { + cc.addAnnotation(Singleton.class); + + // generate matching constructor that calls the super + var constructor = cc.getConstructorCreator(new String[] {}); + constructor.invokeSpecialMethod(MethodDescriptor.ofConstructor(AuthorizationPolicyStorage.class), + constructor.getThis()); + + var mapDescriptorType = DescriptorUtils.typeToString( + ParameterizedType.create(Map.class, Type.create(MethodDescription.class), Type.create(String.class))); + if (authZPolicyInstancesItem.methodToPolicyName.isEmpty()) { + // generate: + // protected Map getMethodToPolicyName() { Map.of(); } + try (var mc = cc.getMethodCreator(MethodDescriptor.ofMethod(AuthorizationPolicyStorage.class, + "getMethodToPolicyName", mapDescriptorType))) { + var map = mc.invokeStaticInterfaceMethod(MethodDescriptor.ofMethod(Map.class, "of", Map.class)); + mc.returnValue(map); + } + } else { + // detected @AuthorizationPolicy annotation instances + additionalSecurityAnnotationProducer + .produce(new AdditionalSecurityAnnotationBuildItem(AUTHORIZATION_POLICY)); + + // generates: + // private final Map methodToPolicyName; + var methodToPolicyNameField = cc.getFieldCreator("methodToPolicyName", mapDescriptorType) + .setModifiers(Opcodes.ACC_PRIVATE | Opcodes.ACC_FINAL); + + // generates: + // protected Map getMethodToPolicyName() { this.methodToPolicyName; } + try (var mc = cc.getMethodCreator(MethodDescriptor.ofMethod(AuthorizationPolicyStorage.class, + "getMethodToPolicyName", mapDescriptorType))) { + var map = mc.readInstanceField(methodToPolicyNameField.getFieldDescriptor(), mc.getThis()); + mc.returnValue(map); + } + + // === constructor + // initializes 'methodToPolicyName' private field in the constructor + // this.methodToPolicyName = new MethodsToPolicyBuilder() + // .addMethodToPolicyName(policyName, className, methodName, parameterTypes) + // .build(); + + // create builder + var builder = constructor.newInstance( + MethodDescriptor.ofConstructor(AuthorizationPolicyStorage.MethodsToPolicyBuilder.class)); + + var addMethodToPolicyNameType = MethodDescriptor.ofMethod( + AuthorizationPolicyStorage.MethodsToPolicyBuilder.class, "addMethodToPolicyName", + AuthorizationPolicyStorage.MethodsToPolicyBuilder.class, String.class, String.class, String.class, + String[].class); + for (var e : authZPolicyInstancesItem.methodToPolicyName.entrySet()) { + MethodInfo securedMethod = e.getKey(); + String policyNameStr = e.getValue(); + + // String policyName + var policyName = constructor.load(policyNameStr); + // String methodName + var methodName = constructor.load(securedMethod.name()); + // String declaringClassName + var declaringClassName = constructor.load(securedMethod.declaringClass().name().toString()); + // String[] paramTypes + var paramTypes = constructor.marshalAsArray(String[].class, securedMethod.parameterTypes().stream() + .map(pt -> pt.name().toString()).map(constructor::load).toArray(ResultHandle[]::new)); + + // builder.addMethodToPolicyName(policyName, className, methodName, paramTypes) + builder = constructor.invokeVirtualMethod(addMethodToPolicyNameType, builder, policyName, + declaringClassName, methodName, paramTypes); + } + + // builder.build() + var resultMapType = DescriptorUtils + .typeToString(ParameterizedType.create(Map.class, TypeVariable.create(MethodDescription.class), + TypeVariable.create(String.class))); + var buildMethodType = MethodDescriptor.ofMethod(AuthorizationPolicyStorage.MethodsToPolicyBuilder.class, + "build", resultMapType); + var resultMap = constructor.invokeVirtualMethod(buildMethodType, builder); + // assign builder to the private field + constructor.writeInstanceField(methodToPolicyNameField.getFieldDescriptor(), constructor.getThis(), + resultMap); + } + + // late return from constructor in case we need to write value to the field + constructor.returnVoid(); + } + } + } + + private static Stream getPolicyTargetEndpointCandidates(AnnotationTarget target) { + if (target.kind() == AnnotationTarget.Kind.METHOD) { + var method = target.asMethod(); + if (!hasProperEndpointModifiers(method)) { + throw new RuntimeException(""" + Found method annotated with the @AuthorizationPolicy annotation that is not an endpoint: %s#%s + """.formatted(method.asClass().name().toString(), method.name())); + } + return Stream.of(method); + } + return target.asClass().methods().stream() + .filter(HttpSecurityProcessor::hasProperEndpointModifiers) + .filter(mi -> !HttpSecurityUtils.hasSecurityAnnotation(mi)); + } + private static void validateAuthMechanismAnnotationUsage(Capabilities capabilities, HttpBuildTimeConfig buildTimeConfig, DotName[] annotationNames) { if (buildTimeConfig.auth.proactive @@ -425,18 +604,18 @@ private static boolean isMtlsClientAuthenticationEnabled(HttpBuildTimeConfig bui private static Set collectClassMethodsWithoutRbacAnnotation(Collection classes) { return classes .stream() - .filter(c -> !SecurityTransformerUtils.hasSecurityAnnotation(c)) + .filter(c -> !HttpSecurityUtils.hasSecurityAnnotation(c)) .map(ClassInfo::methods) .flatMap(Collection::stream) .filter(HttpSecurityProcessor::hasProperEndpointModifiers) - .filter(m -> !SecurityTransformerUtils.hasSecurityAnnotation(m)) + .filter(m -> !HttpSecurityUtils.hasSecurityAnnotation(m)) .collect(Collectors.toSet()); } private static Set collectMethodsWithoutRbacAnnotation(Collection methods) { return methods .stream() - .filter(m -> !SecurityTransformerUtils.hasSecurityAnnotation(m)) + .filter(m -> !HttpSecurityUtils.hasSecurityAnnotation(m)) .collect(Collectors.toSet()); } diff --git a/extensions/vertx-http/deployment/src/main/java/io/quarkus/vertx/http/deployment/HttpSecurityUtils.java b/extensions/vertx-http/deployment/src/main/java/io/quarkus/vertx/http/deployment/HttpSecurityUtils.java new file mode 100644 index 0000000000000..3589ef76d6615 --- /dev/null +++ b/extensions/vertx-http/deployment/src/main/java/io/quarkus/vertx/http/deployment/HttpSecurityUtils.java @@ -0,0 +1,41 @@ +package io.quarkus.vertx.http.deployment; + +import java.util.Collection; +import java.util.Optional; + +import org.jboss.jandex.AnnotationInstance; +import org.jboss.jandex.ClassInfo; +import org.jboss.jandex.DotName; +import org.jboss.jandex.MethodInfo; + +import io.quarkus.security.spi.SecurityTransformerUtils; +import io.quarkus.vertx.http.security.AuthorizationPolicy; + +public final class HttpSecurityUtils { + + static final DotName AUTHORIZATION_POLICY = DotName.createSimple(AuthorizationPolicy.class); + + private HttpSecurityUtils() { + // deployment module security utility class + } + + public static boolean hasAuthorizationPolicyAnnotation(MethodInfo methodInfo) { + return findAuthorizationPolicyAnnotation(methodInfo.annotations()).isPresent(); + } + + public static boolean hasAuthorizationPolicyAnnotation(ClassInfo classInfo) { + return findAuthorizationPolicyAnnotation(classInfo.declaredAnnotations()).isPresent(); + } + + public static boolean hasSecurityAnnotation(MethodInfo methodInfo) { + return SecurityTransformerUtils.hasSecurityAnnotation(methodInfo) || hasAuthorizationPolicyAnnotation(methodInfo); + } + + public static boolean hasSecurityAnnotation(ClassInfo classInfo) { + return SecurityTransformerUtils.hasSecurityAnnotation(classInfo) || hasAuthorizationPolicyAnnotation(classInfo); + } + + static Optional findAuthorizationPolicyAnnotation(Collection instances) { + return instances.stream().filter(ai -> ai.name().equals(AUTHORIZATION_POLICY)).findFirst(); + } +} diff --git a/extensions/vertx-http/deployment/src/test/java/io/quarkus/vertx/http/security/PathMatchingHttpSecurityPolicyTest.java b/extensions/vertx-http/deployment/src/test/java/io/quarkus/vertx/http/security/PathMatchingHttpSecurityPolicyTest.java index e5ee6f22bf9b5..a599be732321f 100644 --- a/extensions/vertx-http/deployment/src/test/java/io/quarkus/vertx/http/security/PathMatchingHttpSecurityPolicyTest.java +++ b/extensions/vertx-http/deployment/src/test/java/io/quarkus/vertx/http/security/PathMatchingHttpSecurityPolicyTest.java @@ -314,7 +314,7 @@ public Uni checkPermission(RoutingContext event, Uni>> sharedPermissionsPathMatchers; private final boolean hasNoPermissions; - public AbstractPathMatchingHttpSecurityPolicy(Map permissions, + AbstractPathMatchingHttpSecurityPolicy(Map permissions, Map rolePolicy, String rootPath, Instance installedPolicies, PolicyMappingConfig.AppliesTo appliesTo) { boolean hasNoPermissions = true; @@ -87,6 +87,24 @@ public boolean hasNoPermissions() { public Uni checkPermission(RoutingContext routingContext, Uni identity, AuthorizationRequestContext requestContext) { + return checkPermissions(routingContext, identity, requestContext); + } + + Uni checkPermissions(RoutingContext routingContext, Uni identity, + AuthorizationRequestContext requestContext, HttpSecurityPolicy... additionalPolicies) { + final List permissionCheckers = hasNoPermissions ? new ArrayList<>() + : getHttpSecurityPolicies(routingContext); + if (additionalPolicies.length > 0) { + if (additionalPolicies.length == 1) { + permissionCheckers.add(additionalPolicies[0]); + } else { + permissionCheckers.addAll(Arrays.asList(additionalPolicies)); + } + } + return doPermissionCheck(routingContext, identity, 0, null, permissionCheckers, requestContext); + } + + private List getHttpSecurityPolicies(RoutingContext routingContext) { final List permissionCheckers; if (sharedPermissionsPathMatchers == null) { permissionCheckers = findPermissionCheckers(routingContext, pathMatcher); @@ -97,7 +115,7 @@ public Uni checkPermission(RoutingContext routingContext, Uni doPermissionCheck(RoutingContext routingContext, @@ -117,7 +135,7 @@ private Uni doPermissionCheck(RoutingContext routingContext, public Uni apply(CheckResult checkResult) { if (!checkResult.isPermitted()) { if (checkResult.getAugmentedIdentity() == null) { - return Uni.createFrom().item(CheckResult.DENY); + return CheckResult.deny(); } else { return Uni.createFrom().item(new CheckResult(false, checkResult.getAugmentedIdentity())); } @@ -126,7 +144,7 @@ public Uni apply(CheckResult checkResult) { //attempt to run the next checker return doPermissionCheck(routingContext, - Uni.createFrom().item(checkResult.getAugmentedIdentity()), index + 1, + checkResult.getAugmentedIdentityAsUni(), index + 1, checkResult.getAugmentedIdentity(), permissionCheckers, requestContext); @@ -176,9 +194,11 @@ private static void addPermissionToPathMatcher(Map p private static List findPermissionCheckers(RoutingContext context, ImmutablePathMatcher> pathMatcher) { + var result = new ArrayList(); + PathMatch> toCheck = pathMatcher.match(context.normalizedPath()); if (toCheck.getValue() == null || toCheck.getValue().isEmpty()) { - return Collections.emptyList(); + return result; } List methodMatch = new ArrayList<>(); List noMethod = new ArrayList<>(); @@ -190,14 +210,14 @@ private static List findPermissionCheckers(RoutingContext co } } if (!methodMatch.isEmpty()) { - return methodMatch; + result.addAll(methodMatch); } else if (!noMethod.isEmpty()) { - return noMethod; + result.addAll(noMethod); } else { //we deny if we did not match due to method filtering - return Collections.singletonList(DenySecurityPolicy.INSTANCE); + result.add(DenySecurityPolicy.INSTANCE); } - + return result; } static boolean policyApplied(RoutingContext routingContext) { diff --git a/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/security/AuthorizationPolicyStorage.java b/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/security/AuthorizationPolicyStorage.java new file mode 100644 index 0000000000000..30a42e757ed9a --- /dev/null +++ b/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/security/AuthorizationPolicyStorage.java @@ -0,0 +1,48 @@ +package io.quarkus.vertx.http.runtime.security; + +import java.util.HashMap; +import java.util.Map; + +import io.quarkus.security.spi.runtime.MethodDescription; + +/** + * Quarkus generates this storage of endpoints secured with {@link io.quarkus.vertx.http.security.AuthorizationPolicy}. + * The storage can be retrieved from CDI container when the Quarkus Security extension is present. + */ +public abstract class AuthorizationPolicyStorage { + + protected AuthorizationPolicyStorage() { + } + + protected abstract Map getMethodToPolicyName(); + + /** + * @param securedMethodDesc method description + * @return true if method is secured with {@link io.quarkus.vertx.http.security.AuthorizationPolicy} + */ + public boolean requiresAuthorizationPolicy(MethodDescription securedMethodDesc) { + if (securedMethodDesc == null) { + return false; + } + return getMethodToPolicyName().containsKey(securedMethodDesc); + } + + // used by generated subclass + public final static class MethodsToPolicyBuilder { + + private final Map methodToPolicyName = new HashMap<>(); + + public MethodsToPolicyBuilder() { + } + + public MethodsToPolicyBuilder addMethodToPolicyName(String policyName, String className, String methodName, + String[] parameterTypes) { + methodToPolicyName.put(new MethodDescription(className, methodName, parameterTypes), policyName); + return this; + } + + public Map build() { + return Map.copyOf(methodToPolicyName); + } + } +} diff --git a/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/security/DenySecurityPolicy.java b/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/security/DenySecurityPolicy.java index 34e2deb0f121d..172b970bab5dc 100644 --- a/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/security/DenySecurityPolicy.java +++ b/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/security/DenySecurityPolicy.java @@ -11,6 +11,6 @@ public class DenySecurityPolicy implements HttpSecurityPolicy { @Override public Uni checkPermission(RoutingContext request, Uni identity, AuthorizationRequestContext requestContext) { - return Uni.createFrom().item(CheckResult.DENY); + return CheckResult.deny(); } } diff --git a/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/security/HttpSecurityPolicy.java b/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/security/HttpSecurityPolicy.java index a522595a4cdad..45a89bfef6d4c 100644 --- a/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/security/HttpSecurityPolicy.java +++ b/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/security/HttpSecurityPolicy.java @@ -6,6 +6,7 @@ import io.quarkus.security.identity.SecurityIdentity; import io.quarkus.security.spi.runtime.BlockingSecurityExecutor; +import io.quarkus.vertx.http.security.AuthorizationPolicy; import io.smallrye.mutiny.Uni; import io.vertx.ext.web.RoutingContext; @@ -13,7 +14,7 @@ * An HTTP Security policy, that controls which requests are allowed to proceed. * CDI beans implementing this interface are invoked on every request unless they define {@link #name()}. * The policy with {@link #name()} can then be referenced in the application.properties path matching rules, - * which allows this policy to be applied only to specific requests. + * or from the {@link AuthorizationPolicy#name()} annotation attribute. */ public interface HttpSecurityPolicy { @@ -21,9 +22,12 @@ Uni checkPermission(RoutingContext request, Uni i AuthorizationRequestContext requestContext); /** - * HTTP Security policy name referenced in the application.properties path matching rules, which allows this - * policy to be applied to specific requests. The name must not be blank. When the name is {@code null}, policy - * will be applied to every request. + * If HTTP Security policy name is not null, then this policy is only called in two cases: + * - winning path-matching policy references this name in the application.properties + * - invoked Jakarta REST endpoint references this name in the {@link AuthorizationPolicy#name()} annotation attribute + *

+ * When the name is null, this policy is considered global and is applied on every single request. + * More details and examples can be found in Quarkus documentation. * * @return policy name */ @@ -68,6 +72,18 @@ public boolean isPermitted() { public SecurityIdentity getAugmentedIdentity() { return augmentedIdentity; } + + public Uni getAugmentedIdentityAsUni() { + return Uni.createFrom().item(augmentedIdentity); + } + + public static Uni permit() { + return Uni.createFrom().item(PERMIT); + } + + public static Uni deny() { + return Uni.createFrom().item(DENY); + } } /** @@ -86,7 +102,7 @@ Uni runBlocking(RoutingContext context, Uni ident class DefaultAuthorizationRequestContext implements AuthorizationRequestContext { private final BlockingSecurityExecutor blockingExecutor; - public DefaultAuthorizationRequestContext(BlockingSecurityExecutor blockingExecutor) { + DefaultAuthorizationRequestContext(BlockingSecurityExecutor blockingExecutor) { this.blockingExecutor = blockingExecutor; } diff --git a/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/security/JaxRsPathMatchingHttpSecurityPolicy.java b/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/security/JaxRsPathMatchingHttpSecurityPolicy.java new file mode 100644 index 0000000000000..7e4022e226027 --- /dev/null +++ b/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/security/JaxRsPathMatchingHttpSecurityPolicy.java @@ -0,0 +1,111 @@ +package io.quarkus.vertx.http.runtime.security; + +import static io.quarkus.vertx.http.runtime.PolicyMappingConfig.AppliesTo.JAXRS; + +import java.util.HashMap; +import java.util.Map; + +import jakarta.enterprise.inject.Instance; + +import io.quarkus.security.identity.SecurityIdentity; +import io.quarkus.security.spi.runtime.BlockingSecurityExecutor; +import io.quarkus.security.spi.runtime.MethodDescription; +import io.quarkus.vertx.http.runtime.HttpBuildTimeConfig; +import io.quarkus.vertx.http.runtime.HttpConfiguration; +import io.quarkus.vertx.http.runtime.security.HttpSecurityPolicy.AuthorizationRequestContext; +import io.quarkus.vertx.http.runtime.security.HttpSecurityPolicy.CheckResult; +import io.quarkus.vertx.http.runtime.security.HttpSecurityPolicy.DefaultAuthorizationRequestContext; +import io.smallrye.mutiny.Uni; +import io.vertx.ext.web.RoutingContext; + +/** + * Decorates {@link AbstractPathMatchingHttpSecurityPolicy} path matching capabilities + * with support for policies selected with {@link io.quarkus.vertx.http.security.AuthorizationPolicy}. + * Decorator may only run after HTTP requests have been matched with the endpoint class method. + * Extensions can make this class bean if they need it. + */ +public class JaxRsPathMatchingHttpSecurityPolicy { + + private final AbstractPathMatchingHttpSecurityPolicy delegate; + private final boolean foundNoAnnotatedMethods; + private final AuthorizationRequestContext requestContext; + private final Map policyNameToPolicy; + private final AuthorizationPolicyStorage storage; + + JaxRsPathMatchingHttpSecurityPolicy(AuthorizationPolicyStorage storage, + Instance installedPolicies, HttpConfiguration httpConfig, + HttpBuildTimeConfig buildTimeConfig, BlockingSecurityExecutor blockingSecurityExecutor) { + this.storage = storage; + this.delegate = new AbstractPathMatchingHttpSecurityPolicy(httpConfig.auth.permissions, + httpConfig.auth.rolePolicy, buildTimeConfig.rootPath, installedPolicies, JAXRS); + this.foundNoAnnotatedMethods = storage.getMethodToPolicyName().isEmpty(); + this.requestContext = new DefaultAuthorizationRequestContext(blockingSecurityExecutor); + if (storage.getMethodToPolicyName().isEmpty()) { + this.policyNameToPolicy = Map.of(); + } else { + var allPolicies = new HashMap(); + for (HttpSecurityPolicy installedPolicy : installedPolicies) { + if (installedPolicy.name() != null) { + allPolicies.put(installedPolicy.name(), installedPolicy); + } + } + var annotationPoliciesOnly = new HashMap(); + for (Map.Entry e : storage.getMethodToPolicyName().entrySet()) { + var policyName = e.getValue(); + if (annotationPoliciesOnly.containsKey(policyName)) { + continue; + } + if (allPolicies.containsKey(policyName)) { + annotationPoliciesOnly.put(policyName, allPolicies.get(policyName)); + continue; + } + var classAndMethodName = e.getKey().getClassName() + "#" + e.getKey().getMethodName(); + throw new RuntimeException(""" + Endpoint '%s' requires named HttpSecurityPolicy '%s' specified with '@AuthorizationPolicy', + but no such policies has bean found. Please provide required policy as CDI bean. + """.formatted(classAndMethodName, policyName)); + } + policyNameToPolicy = Map.copyOf(annotationPoliciesOnly); + } + } + + /** + * @param securedMethodDesc method description + * @return true if method is secured with {@link io.quarkus.vertx.http.security.AuthorizationPolicy} + */ + public boolean requiresAuthorizationPolicy(MethodDescription securedMethodDesc) { + return storage.requiresAuthorizationPolicy(securedMethodDesc); + } + + /** + * @return true if there is no point running {@link #checkPermission(RoutingContext, Uni, MethodDescription)} + */ + public boolean hasNoPermissions() { + return delegate.hasNoPermissions() && foundNoAnnotatedMethods; + } + + /** + * Applies {@link HttpSecurityPolicy} matched by path-matching rules + * or by {@link io.quarkus.vertx.http.security.AuthorizationPolicy}. + */ + public Uni checkPermission(RoutingContext routingContext, Uni identity, + MethodDescription description) { + var authorizationPolicy = findAuthorizationPolicy(description); + if (authorizationPolicy == null) { + return delegate.checkPermissions(routingContext, identity, requestContext); + } else { + return delegate.checkPermissions(routingContext, identity, requestContext, authorizationPolicy); + } + } + + private HttpSecurityPolicy findAuthorizationPolicy(MethodDescription description) { + if (description != null) { + var policyName = storage.getMethodToPolicyName().get(description); + if (policyName != null) { + return policyNameToPolicy.get(policyName); + } + } + return null; + } + +} diff --git a/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/security/PermitSecurityPolicy.java b/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/security/PermitSecurityPolicy.java index f4f1b39e77770..521f781404377 100644 --- a/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/security/PermitSecurityPolicy.java +++ b/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/security/PermitSecurityPolicy.java @@ -9,6 +9,6 @@ public class PermitSecurityPolicy implements HttpSecurityPolicy { @Override public Uni checkPermission(RoutingContext request, Uni identity, AuthorizationRequestContext requestContext) { - return Uni.createFrom().item(CheckResult.PERMIT); + return CheckResult.permit(); } } diff --git a/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/security/AuthorizationPolicy.java b/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/security/AuthorizationPolicy.java new file mode 100644 index 0000000000000..73f2ae524fb57 --- /dev/null +++ b/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/security/AuthorizationPolicy.java @@ -0,0 +1,82 @@ +package io.quarkus.vertx.http.security; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Inherited; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +import io.quarkus.vertx.http.runtime.security.HttpSecurityPolicy; + +/** + * Secures endpoint classes and methods with {@link HttpSecurityPolicy}. + * Policies selected by this annotation will run right after all path-matching policies. + * Consider following example of the {@link HttpSecurityPolicy}: + * + *

+ * {@code
+ * import io.quarkus.security.identity.SecurityIdentity;
+ * import io.quarkus.vertx.http.runtime.security.HttpSecurityPolicy;
+ * import io.smallrye.mutiny.Uni;
+ * import io.vertx.ext.web.RoutingContext;
+ *
+ * public class ExampleAuthorizationPolicy implements HttpSecurityPolicy {
+ *
+ *     @Override
+ *     public Uni checkPermission(RoutingContext request, Uni identity,
+ *             AuthorizationRequestContext requestContext) {
+ *         return isRequestValid(request) ? CheckResult.permit() : CheckResult.deny();
+ *     }
+ *
+ *     private static boolean isRequestValid(RoutingContext event) {
+ *         // perform your authorization check
+ *         // for example, you can validate headers
+ *         var authorizationHeader = event.request().getHeader("Authorization");
+ *         // or query params
+ *         var crudAction = event.queryParam("action").getFirst();
+ *         // replace with your business logic
+ *         return authorizationHeader != null && "retrieve".equals(crudAction);
+ *     }
+ *
+ *     @Override
+ *     public String name() {
+ *         return "example-policy";
+ *     }
+ * }
+ * }
+ * 
+ * + * This policy can be bound to Jakarta REST resource in following fashion: + * + *
+ * {@code
+ * import io.quarkus.vertx.http.security.AuthorizationPolicy;
+ * import jakarta.ws.rs.GET;
+ * import jakarta.ws.rs.Path;
+ *
+ * @AuthorizationPolicy(name = "example-policy")
+ * @Path("example")
+ * public class ExampleResource {
+ *
+ *     @GET
+ *     public String sayHello() {
+ *         return "hello";
+ *     }
+ *
+ * }
+ * }
+ * 
+ */ +@Retention(RetentionPolicy.RUNTIME) +@Target({ ElementType.TYPE, ElementType.METHOD }) +@Inherited +public @interface AuthorizationPolicy { + + /** + * Specifies name of the {@link HttpSecurityPolicy} that should be applied on the annotation target. + * + * @return {@link HttpSecurityPolicy#name()} + */ + String name(); + +}