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 6e67e932abd449..78f9091ae88d10 100644 --- a/docs/src/main/asciidoc/security-authorize-web-endpoints-reference.adoc +++ b/docs/src/main/asciidoc/security-authorize-web-endpoints-reference.adoc @@ -20,6 +20,7 @@ Authorization is based on user roles that the security provider provides. To customize these roles, a `SecurityIdentityAugmentor` can be created, see xref:security-customization.adoc#security-identity-customization[Security Identity Customization]. +[[authorization-using-configuration]] == Authorization using configuration Permissions are defined in the Quarkus configuration using permission sets, with each permission set specifying a policy for access control. @@ -85,6 +86,7 @@ The request is rejected if a request matches one or more permission sets based o TIP: Given the above permission set, `GET /public/foo` would match both the path and method and thus be allowed, whereas `POST /public/foo` would match the path but not the method and would therefore be rejected. +[[matching-multiple-paths]] === Matching multiple paths: longest path wins Matching is always done on the "longest path wins" basis. @@ -389,12 +391,14 @@ It is possible to use multiple expressions in the role definition. === Permission annotation Quarkus also provides the `io.quarkus.security.PermissionsAllowed` annotation that will permit any authenticated user with given permission to access the resource. -The annotation is extension of the common security annotations and has no relation to configuration permissions defined with the configuration property `quarkus.http.auth.permission`. +The annotation is extension of the common security annotations and check permissions granted to `SecurityIdentity`. .Example of endpoints secured with the `@PermissionsAllowed` annotation [source,java] ---- +package org.acme.crud; + import io.quarkus.arc.Arc; import io.vertx.ext.web.RoutingContext; import jakarta.ws.rs.GET; @@ -404,6 +408,7 @@ import jakarta.ws.rs.QueryParam; import io.quarkus.security.PermissionsAllowed; +import java.security.BasicPermission; import java.security.Permission; import java.util.Collection; import java.util.Collections; @@ -414,12 +419,14 @@ public class CRUDResource { @PermissionsAllowed("create") <1> @PermissionsAllowed("update") @POST + @Path("/modify/repeated") public String createOrUpdate() { return "modified"; } @PermissionsAllowed(value = {"create", "update"}, inclusive=true) <2> @POST + @Path("/modify/inclusive") public String createOrUpdate(Long id) { return id + " modified"; } @@ -432,13 +439,14 @@ public class CRUDResource { } @PermissionsAllowed(value = "list", permission = CustomPermission.class) <4> + @Path("/list") @GET public Collection list(@QueryParam("query-options") String queryOptions) { // your business logic comes here return Collections.emptySet(); } - public static class CustomPermission extends Permission { + public static class CustomPermission extends BasicPermission { public CustomPermission(String name) { super(name); @@ -451,8 +459,6 @@ public class CRUDResource { var hasPermission = getName().equals(permission.getName()); return hasPermission && publicContent; } - - ... } } ---- @@ -465,9 +471,37 @@ By default, string-based permission is performed by the `io.quarkus.security.Str <5> Permissions are not beans, therefore only way to obtain bean instances is programmatically via the `Arc.container()`. CAUTION: If you plan to use the `@PermissionsAllowed` on the IO thread, review the information in xref:security-proactive-authentication-concept.adoc[Proactive Authentication]. + NOTE: The `@PermissionsAllowed` is not repeatable on class-level due to limitations of Quarkus interceptors. Please find well-argued explanation in the xref:cdi-reference.adoc#repeatable-interceptor-bindings[Repeatable interceptor bindings] section of the CDI reference. +Provided you are already using role-based authorization, the easiest way of adding permissions to `SecurityIdentity` is permission to role mapping. +<> can be used to grant `SecurityIdentity` permissions required by `CRUDResource` endpoints to authenticated requests like this: + +[source,properties] +---- +quarkus.http.auth.policy.role-policy1.roles-allowed=** <1> +quarkus.http.auth.policy.role-policy1.permissions.user=see:all <2> +quarkus.http.auth.policy.role-policy1.permissions.admin=create,update,read <3> +quarkus.http.auth.permission.roles1.paths=/crud/modify/*,/crud/id/* <4> +quarkus.http.auth.permission.roles1.policy=role-policy1 + +quarkus.http.auth.policy.role-policy2.roles-allowed=user +quarkus.http.auth.policy.role-policy2.permissions.user=list +quarkus.http.auth.policy.role-policy2.permission-class=org.acme.crud.CRUDResource.CustomPermission <5> +quarkus.http.auth.permission.roles2.paths=/crud/list +quarkus.http.auth.permission.roles2.policy=role-policy2 +---- +<1> The `**` role is a special role that means any authenticated user. +<2> Add permission `see` with action `all` to `SecurityIdentity` that holds role `user`. +Similarly as for `@PermissionsAllowed` annotation, `io.quarkus.security.StringPermission` is used by default. +<3> Permissions `create`, `update` and `read` are mapped to the role `admin`. +<4> The role policy `role-policy1` only allows authenticated requests to access `/crud/modify` and `/crud/id` sub-paths. +Please see <> section of this guide for more information on path matching algorithm. +<5> You can also specify your own implementation of the `java.security.Permission` class. +The class you provide must define exactly one constructor that accepts permission name and optionally actions (as `String` array). +Here, permission `list` will be added to `SecurityIdentity` as `new CustomPermission("list")`. + You can also create a custom `java.security.Permission` with additional constructor parameters. These additional parameters will be matched with arguments of the method annotated with the `@PermissionsAllowed` annotation. Later, Quarkus will instantiate your custom Permission with actual arguments, with which the method annotated with the `@PermissionsAllowed` has been invoked. @@ -597,9 +631,43 @@ Instead, Quarkus will pass `null` for the argument `library`. That gives you option to reuse your custom Permission when the method argument (like `library`) is optional. <2> Argument `library` will be passed to the `LibraryPermission` constructor as the `LibraryService#updateLibrary` method is not an endpoint. -Currently, there is only one way to add permissions, and that is xref:security-customization.adoc#security-identity-customization[Security Identity Customization]. +Similarly to the `CRUDResource` example, we can use permission to role mapping and grant user with role `admin` right to +update `MediaLibrary`: + +[source,java] +---- +package org.acme.library; + +import java.security.Permission; +import java.util.Arrays; +import java.util.Set; + +public class MediaLibraryPermission extends LibraryPermission { + + public MediaLibraryPermission(String libraryName, String[] actions) { + super(libraryName, actions, new MediaLibrary()); <1> + } + +} +---- +<1> We want to pass `MediaLibrary` instance to the `LibraryPermission` constructor. + +[source,properties] +---- +quarkus.http.auth.policy.role-policy3.roles-allowed=** +quarkus.http.auth.policy.role-policy3.permissions.admin=media-library:list,media-library:read,media-library:write <1> +quarkus.http.auth.policy.role-policy3.permission-class=org.acme.library.MediaLibraryPermission +quarkus.http.auth.permission.roles3.paths=/library/* +quarkus.http.auth.permission.roles3.policy=role-policy3 +---- +<1> Grants permission `media-library` that is allowed to perform actions `read`, `write` and `list`. +Considering `MediaLibrary` is the `TvLibrary` class parent, we know that administrator is also going to be allowed to modify television library. + +All the examples above leveraged role to permission mapping, but you can also add permissions to the `SecurityIdentity` programmatically. +In the example below, we use xref:security-customization.adoc#security-identity-customization[Security Identity Customization] to add +the same permission as we previously granted with the HTTP role-based policy. -.Example of Adding the `LibraryPermission` to the `SecurityIdentity` +.Example of Adding the `LibraryPermission` programmatically to the `SecurityIdentity` [source,java] ---- @@ -630,8 +698,8 @@ public class PermissionsIdentityAugmentor implements SecurityIdentityAugmentor { } SecurityIdentity build(SecurityIdentity identity) { - Permission possessedPermission = new LibraryPermission("media-library", - new String[] { "read", "write", "list"}, new MediaLibrary()); <1> + Permission possessedPermission = new MediaLibraryPermission("media-library", + new String[] { "read", "write", "list"}); <1> return QuarkusSecurityIdentity.builder(identity) .addPermissionChecker(new Function>() { <2> @Override 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 c550924ca3a37a..652e7435ff60c2 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 @@ -653,7 +653,8 @@ private static int[] autodetectConstructorParamIndexes(PermissionKey permissionK if (foundIndex == -1) { throw new RuntimeException(String.format( "Failed to identify matching data type for '%s' param of '%s' constructor for method '%s' annotated with @PermissionsAllowed", - constructor.parameterName(i), permissionKey.classSignature(), securedMethod.name())); + constructor.parameterName(i + nonMethodParams), permissionKey.classSignature(), + securedMethod.name())); } methodParamIndexes[i] = foundIndex; } diff --git a/extensions/security/test-utils/src/main/java/io/quarkus/security/test/utils/TestIdentityController.java b/extensions/security/test-utils/src/main/java/io/quarkus/security/test/utils/TestIdentityController.java index 55b2aef9b4b515..85e1ff69c09d67 100644 --- a/extensions/security/test-utils/src/main/java/io/quarkus/security/test/utils/TestIdentityController.java +++ b/extensions/security/test-utils/src/main/java/io/quarkus/security/test/utils/TestIdentityController.java @@ -54,7 +54,7 @@ private TestIdentity(String username, String password, String... roles) { private TestIdentity(String username, String password, Permission... permissions) { this.username = username; this.password = password; - this.roles = Set.of(); + this.roles = Set.of(username); this.permissionCheckers = createPermissionCheckers(Arrays.asList(permissions)); } 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 1e76b1e9c4e4ea..ad5dd730eb7ca3 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 @@ -1,12 +1,21 @@ package io.quarkus.vertx.http.deployment; +import java.security.Permission; import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.function.BiFunction; +import java.util.function.Function; import java.util.function.Supplier; import jakarta.inject.Singleton; +import org.jboss.jandex.ClassInfo; +import org.jboss.jandex.DotName; +import org.jboss.jandex.IndexView; +import org.jboss.jandex.MethodInfo; +import org.jboss.jandex.Type; + import io.quarkus.arc.deployment.AdditionalBeanBuildItem; import io.quarkus.arc.deployment.BeanContainerListenerBuildItem; import io.quarkus.arc.deployment.SyntheticBeanBuildItem; @@ -16,6 +25,10 @@ import io.quarkus.deployment.annotations.BuildStep; import io.quarkus.deployment.annotations.ExecutionTime; import io.quarkus.deployment.annotations.Record; +import io.quarkus.deployment.builditem.CombinedIndexBuildItem; +import io.quarkus.deployment.builditem.nativeimage.ReflectiveClassBuildItem; +import io.quarkus.runtime.configuration.ConfigurationException; +import io.quarkus.security.StringPermission; import io.quarkus.vertx.http.runtime.HttpBuildTimeConfig; import io.quarkus.vertx.http.runtime.PolicyConfig; import io.quarkus.vertx.http.runtime.management.ManagementInterfaceBuildTimeConfig; @@ -37,9 +50,14 @@ public class HttpSecurityProcessor { + private static final DotName PERMISSION = DotName.createSimple(Permission.class.getName()); + @BuildStep + @Record(ExecutionTime.STATIC_INIT) public void builtins(BuildProducer producer, - HttpBuildTimeConfig buildTimeConfig, + BuildProducer reflectiveClassProducer, + CombinedIndexBuildItem combinedIndexBuildItem, + HttpBuildTimeConfig buildTimeConfig, HttpSecurityRecorder recorder, BuildProducer beanProducer) { producer.produce(new HttpSecurityPolicyBuildItem("deny", new SupplierImpl<>(new DenySecurityPolicy()))); producer.produce(new HttpSecurityPolicyBuildItem("permit", new SupplierImpl<>(new PermitSecurityPolicy()))); @@ -48,11 +66,89 @@ public void builtins(BuildProducer producer, if (!buildTimeConfig.auth.permissions.isEmpty()) { beanProducer.produce(AdditionalBeanBuildItem.unremovableOf(PathMatchingHttpSecurityPolicy.class)); } + Map> permClassToCreator = new HashMap<>(); for (Map.Entry e : buildTimeConfig.auth.rolePolicy.entrySet()) { - producer.produce(new HttpSecurityPolicyBuildItem(e.getKey(), - new SupplierImpl<>(new RolesAllowedHttpSecurityPolicy(e.getValue().rolesAllowed)))); + PolicyConfig policyConfig = e.getValue(); + if (policyConfig.permissions.isEmpty()) { + producer.produce(new HttpSecurityPolicyBuildItem(e.getKey(), + new SupplierImpl<>(new RolesAllowedHttpSecurityPolicy(e.getValue().rolesAllowed)))); + } else { + // create HTTP Security policy that checks allowed roles and grants SecurityIdentity permissions to + // requests that this policy allows to proceed + var permissionCreator = permClassToCreator.computeIfAbsent(policyConfig.permissionClass, + new Function>() { + @Override + public BiFunction apply(String s) { + if (StringPermission.class.getName().equals(s)) { + return recorder.stringPermissionCreator(); + } + boolean constructorAcceptsActions = validateConstructor(combinedIndexBuildItem.getIndex(), + policyConfig.permissionClass); + return recorder.customPermissionCreator(s, constructorAcceptsActions); + } + }); + var policy = recorder.createRolesAllowedPolicy(policyConfig.rolesAllowed, policyConfig.permissions, + permissionCreator); + producer.produce(new HttpSecurityPolicyBuildItem(e.getKey(), policy)); + } + } + + if (!permClassToCreator.isEmpty()) { + // we need to register Permission classes for reflection as strictly speaking + // they might not exactly match classes defined via `PermissionsAllowed#permission` + var permissionClassesArr = permClassToCreator.keySet().toArray(new String[0]); + reflectiveClassProducer + .produce(ReflectiveClassBuildItem.builder(permissionClassesArr).constructors().fields().methods().build()); + } + } + + private static boolean validateConstructor(IndexView index, String permissionClass) { + ClassInfo classInfo = index.getClassByName(permissionClass); + + if (classInfo == null) { + throw new ConfigurationException(String.format("Permission class '%s' is missing", permissionClass)); + } + + // must have exactly one constructor + if (classInfo.constructors().size() != 1) { + throw new ConfigurationException( + String.format("Permission class '%s' must have exactly one constructor", permissionClass)); } + MethodInfo constructor = classInfo.constructors().get(0); + + // first parameter must be permission name (String) + if (constructor.parametersCount() == 0 || !isString(constructor.parameterType(0))) { + throw new ConfigurationException( + String.format("Permission class '%s' constructor first parameter must be '%s' (permission name)", + permissionClass, String.class.getName())); + } + + // second parameter (actions) is optional + if (constructor.parametersCount() == 1) { + // permission constructor accepts just name, no actions + return false; + } + + if (constructor.parametersCount() == 2) { + if (!isStringArray(constructor.parameterType(1))) { + throw new ConfigurationException( + String.format("Permission class '%s' constructor second parameter must be '%s' array", permissionClass, + String.class.getName())); + } + return true; + } + + throw new ConfigurationException(String.format( + "Permission class '%s' constructor must accept either one parameter (String permissionName), or two parameters (String permissionName, String[] actions)", + permissionClass)); + } + + private static boolean isStringArray(Type type) { + return type.kind() == Type.Kind.ARRAY && isString(type.asArrayType().component()); + } + private static boolean isString(Type type) { + return type.kind() == Type.Kind.CLASS && type.name().toString().equals(String.class.getName()); } @BuildStep diff --git a/extensions/vertx-http/deployment/src/test/java/io/quarkus/vertx/http/security/CustomPermission.java b/extensions/vertx-http/deployment/src/test/java/io/quarkus/vertx/http/security/CustomPermission.java new file mode 100644 index 00000000000000..e939561e23e1e5 --- /dev/null +++ b/extensions/vertx-http/deployment/src/test/java/io/quarkus/vertx/http/security/CustomPermission.java @@ -0,0 +1,9 @@ +package io.quarkus.vertx.http.security; + +import java.security.BasicPermission; + +public class CustomPermission extends BasicPermission { + public CustomPermission(String name) { + super(name); + } +} diff --git a/extensions/vertx-http/deployment/src/test/java/io/quarkus/vertx/http/security/CustomPermissionWithActions.java b/extensions/vertx-http/deployment/src/test/java/io/quarkus/vertx/http/security/CustomPermissionWithActions.java new file mode 100644 index 00000000000000..84299581bac46e --- /dev/null +++ b/extensions/vertx-http/deployment/src/test/java/io/quarkus/vertx/http/security/CustomPermissionWithActions.java @@ -0,0 +1,38 @@ +package io.quarkus.vertx.http.security; + +import java.security.Permission; + +import io.quarkus.security.StringPermission; + +public class CustomPermissionWithActions extends Permission { + + private final Permission delegate; + + public CustomPermissionWithActions(String name, String[] actions) { + super(name); + this.delegate = new StringPermission(name, actions); + } + + @Override + public boolean implies(Permission permission) { + if (permission instanceof CustomPermissionWithActions) { + return delegate.implies(((CustomPermissionWithActions) permission).delegate); + } + return false; + } + + @Override + public boolean equals(Object obj) { + return delegate.equals(obj); + } + + @Override + public int hashCode() { + return delegate.hashCode(); + } + + @Override + public String getActions() { + return delegate.getActions(); + } +} diff --git a/extensions/vertx-http/deployment/src/test/java/io/quarkus/vertx/http/security/permission/AbstractHttpSecurityPolicyGrantingPermissionsTest.java b/extensions/vertx-http/deployment/src/test/java/io/quarkus/vertx/http/security/permission/AbstractHttpSecurityPolicyGrantingPermissionsTest.java new file mode 100644 index 00000000000000..d7719a9996b396 --- /dev/null +++ b/extensions/vertx-http/deployment/src/test/java/io/quarkus/vertx/http/security/permission/AbstractHttpSecurityPolicyGrantingPermissionsTest.java @@ -0,0 +1,361 @@ +package io.quarkus.vertx.http.security.permission; + +import static io.quarkus.vertx.http.security.permission.AbstractHttpSecurityPolicyGrantingPermissionsTest.AuthenticatedUserImpl.ADMIN; +import static io.quarkus.vertx.http.security.permission.AbstractHttpSecurityPolicyGrantingPermissionsTest.AuthenticatedUserImpl.TEST; +import static io.quarkus.vertx.http.security.permission.AbstractHttpSecurityPolicyGrantingPermissionsTest.AuthenticatedUserImpl.TEST2; +import static io.quarkus.vertx.http.security.permission.AbstractHttpSecurityPolicyGrantingPermissionsTest.AuthenticatedUserImpl.USER; + +import java.util.function.Supplier; + +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.enterprise.event.Observes; +import jakarta.inject.Inject; + +import org.hamcrest.Matchers; +import org.junit.jupiter.api.Test; + +import io.quarkus.arc.Arc; +import io.quarkus.security.ForbiddenException; +import io.quarkus.security.PermissionsAllowed; +import io.quarkus.security.StringPermission; +import io.quarkus.security.UnauthorizedException; +import io.quarkus.security.runtime.SecurityIdentityAssociation; +import io.quarkus.security.test.utils.TestIdentityController; +import io.quarkus.vertx.http.runtime.security.QuarkusHttpUser; +import io.quarkus.vertx.http.security.CustomPermission; +import io.quarkus.vertx.http.security.CustomPermissionWithActions; +import io.restassured.RestAssured; +import io.smallrye.mutiny.Uni; +import io.vertx.core.Handler; +import io.vertx.ext.web.Router; +import io.vertx.ext.web.RoutingContext; + +public abstract class AbstractHttpSecurityPolicyGrantingPermissionsTest { + + @Test + public void grantSingleStringPermissionWithoutAction() { + // only user with permission 'create-test' is allowed to proceed + // we mapped role 'test' to 'create-test' on path '/test/create' + // path '/test/create' is only accessible to requests with role 'test' + assertSuccess(TEST, "/test/create"); + + // 'test2' has permission granted by the same policy as 'test' role + assertSuccess(TEST2, "/test/create"); + + // admin role has access to '/test/create' path, but no 'create-test' permission + assertForbidden(ADMIN, "/test/create"); + + // user role has no access to '/test/create' path + assertForbidden(USER, "/test/create"); + + // we don't grant permission via HTTP Security Policy, but directly to the identity + assertSuccess(withOtherPermissions("create-test"), "/test/create"); + + // we grant wrong permission directly to the identity + assertForbidden(withOtherPermissions("wrong-permission"), "/test/create"); + + // unauthenticated request must be denied + assertUnauthorized("/test/create"); + } + + @Test + public void grantMultipleStringPermissionsSomeWithActions() { + // assert permissions granted by other policy don't affect other policies + // that is we require 'create-test' permission granted to role 'test' on path '/test/create' + // but not on path '/test/create2'; path "/test/update2" must be forbidden as permission is missing action + assertForbidden(TEST, "/test/create2", "/test/update2"); + // role 'test' must have access to all the policy paths + assertSuccess(TEST, "/test/list", "/test/update", "/test/delete"); + + // role 'test2' must have 2 paths (list, update, update2), but not other paths + assertSuccess(TEST2, "/test/list", "/test/update", "/test/update2", "/test/create"); + assertForbidden(TEST2, "/test/delete", "/test/create2"); + + // role 'admin' must be denied access to '/test/delete' as the admin is missing required action + assertForbidden(ADMIN, "/test/delete", "/test/list", "/test/update", "/test/create2"); + + // we don't grant permission via HTTP Security Policy, but directly to the identity + assertSuccess(withOtherPermissions("list-test"), "/test/list"); + assertForbidden(withOtherPermissions("list-test"), "/test/update"); + assertSuccess(withOtherPermissions("update-test"), "/test/update"); + // path '/test/update2' must be denied as the role is missing required action + assertForbidden(withOtherPermissions("update-test"), "/test/list", "/test/update2"); + } + + @Test + public void grantSingleCustomPermission() { + assertSuccess(TEST, "/test/custom"); + + // fail as has no permission (but has granted path access) + assertForbidden(TEST2, "/test/custom"); + + // fail as has different permission + assertForbidden(ADMIN, "/test/custom"); + } + + @Test + public void grantMultiCustomPermissionsSomeWithActions() { + // role 'test' has correct permissions without any actions + assertForbidden(TEST, "/test/custom-action"); + // role 'test2' has correct permissions with wrong actions + assertForbidden(TEST2, "/test/custom-action"); + // role 'user' has correct permissions and actions required by 1st annotation, but miss actions required by 2nd one + assertForbidden(USER, "/test/custom-action"); + // role 'admin' has at least one correct permission and action required by both annotations + assertSuccess(ADMIN, "/test/custom-action"); + } + + @Test + public void grantStringPermissionToAnyAuthenticatedReq() { + // any authenticated user is allowed is granted HTTP permission to access path '/test/authenticated' + assertSuccess(ADMIN, "/test/authenticated"); + assertSuccess(USER, "/test/authenticated"); + assertSuccess(TEST, "/test/authenticated"); + assertSuccess(TEST2, "/test/authenticated"); + assertUnauthorized("/test/authenticated"); + + // any authenticated user is granted HTTP permission to access path '/test/authenticated-admin', + // but only admin is granted 'auth-admin-perm' permission + assertSuccess(ADMIN, "/test/authenticated-admin"); + assertForbidden(USER, "/test/authenticated-admin"); + assertForbidden(TEST, "/test/authenticated-admin"); + assertUnauthorized("/test/authenticated-admin"); + + // any authenticated user is granted HTTP permission to access path '/test/authenticated-user', + // but only user is granted 'auth-user-perm' permission + assertSuccess(USER, "/test/authenticated-user"); + assertForbidden(ADMIN, "/test/authenticated-user"); + + // any authenticated user is granted HTTP permission to access path '/test/authenticated-test-role', + // but only test role is granted both 'auth-test-perm1' and 'auth-test-perm2' permissions + assertSuccess(TEST, "/test/authenticated-test-role"); + // role 'test2' has only one of two required permissions + assertForbidden(TEST2, "/test/authenticated-test-role"); + // admin has no permissions + assertForbidden(ADMIN, "/test/authenticated-test-role"); + } + + private void assertSuccess(AuthenticatedUser user, String... paths) { + user.authenticate(); + for (var path : paths) { + RestAssured + .given() + .auth() + .basic(user.role(), user.role()) + .get(path) + .then() + .statusCode(200) + .body(Matchers.is(user.role() + ":" + path)); + } + } + + private void assertForbidden(AuthenticatedUser user, String... paths) { + user.authenticate(); + for (var path : paths) { + RestAssured + .given() + .auth() + .basic(user.role(), user.role()) + .get(path) + .then() + .statusCode(403); + } + } + + private void assertUnauthorized(String path) { + RestAssured + .given() + .get(path) + .then() + .statusCode(401); + } + + @ApplicationScoped + public static class PermissionsPathHandler { + + @Inject + CDIBean cdiBean; + + public void setup(@Observes Router router) { + router.route("/test/create").blockingHandler(new RouteHandler(() -> { + cdiBean.createTestBlocking(); + return Uni.createFrom().nullItem(); + })); + router.route("/test/create2").handler(new RouteHandler(cdiBean::createTest)); + router.route("/test/delete").handler(new RouteHandler(cdiBean::deleteTest)); + router.route("/test/update").handler(new RouteHandler(cdiBean::updateTest)); + router.route("/test/update2").handler(new RouteHandler(cdiBean::update2Test)); + router.route("/test/list").handler(new RouteHandler(cdiBean::listTest)); + router.route("/test/custom").handler(new RouteHandler(cdiBean::customTest)); + router.route("/test/custom-action").handler(new RouteHandler(cdiBean::customActionsTest)); + router.route("/test/authenticated").handler(new RouteHandler(cdiBean::authenticatedTest)); + router.route("/test/authenticated-admin").handler(new RouteHandler(cdiBean::authenticatedAdminTest)); + router.route("/test/authenticated-user").handler(new RouteHandler(cdiBean::authenticatedUserTest)); + router.route("/test/authenticated-test-role").handler(new RouteHandler(cdiBean::authenticatedTestRoleTest)); + } + } + + private static final class RouteHandler implements Handler { + + private final Supplier> callService; + + private RouteHandler(Supplier> callService) { + this.callService = callService; + } + + @Override + public void handle(RoutingContext event) { + // activate context so that we can use CDI beans + Arc.container().requestContext().activate(); + // set identity used by security checks performed by standard security interceptors + QuarkusHttpUser user = (QuarkusHttpUser) event.user(); + Arc.container().instance(SecurityIdentityAssociation.class).get().setIdentity(user.getSecurityIdentity()); + + callService.get().subscribe().with(unused -> { + String ret = user.getSecurityIdentity().getPrincipal().getName() + + ":" + event.normalizedPath(); + event.response().end(ret); + }, throwable -> { + if (throwable instanceof UnauthorizedException) { + event.response().setStatusCode(401); + } else if (throwable instanceof ForbiddenException) { + event.response().setStatusCode(403); + } else { + event.response().setStatusCode(500); + } + event.end(); + }); + } + } + + @ApplicationScoped + public static class CDIBean { + + @PermissionsAllowed("create-test") + public void createTestBlocking() { + // NOTHING TO DO + } + + @PermissionsAllowed("create-test") + public Uni createTest() { + return Uni.createFrom().nullItem(); + } + + @PermissionsAllowed("update-test") + public Uni updateTest() { + return Uni.createFrom().nullItem(); + } + + @PermissionsAllowed("update-test:action2") + public Uni update2Test() { + return Uni.createFrom().nullItem(); + } + + @PermissionsAllowed("list-test") + public Uni listTest() { + return Uni.createFrom().nullItem(); + } + + @PermissionsAllowed("delete-test:action1") + public Uni deleteTest() { + return Uni.createFrom().nullItem(); + } + + @PermissionsAllowed(value = "custom-test", permission = CustomPermission.class) + public Uni customTest() { + return Uni.createFrom().nullItem(); + } + + @PermissionsAllowed(value = { "custom-actions-test", "custom-actions-test1:action9", + "custom-actions-test2:action9" }, permission = CustomPermissionWithActions.class) + @PermissionsAllowed(value = { "custom-actions-test1:action7", + "custom-actions-test1:action8" }, permission = CustomPermissionWithActions.class) + public Uni customActionsTest() { + return Uni.createFrom().nullItem(); + } + + public Uni authenticatedTest() { + return Uni.createFrom().nullItem(); + } + + @PermissionsAllowed("auth-admin-perm") + public Uni authenticatedAdminTest() { + return Uni.createFrom().nullItem(); + } + + @PermissionsAllowed("auth-user-perm") + public Uni authenticatedUserTest() { + return Uni.createFrom().nullItem(); + } + + @PermissionsAllowed("auth-test-perm2") + @PermissionsAllowed("auth-test-perm1") + public Uni authenticatedTestRoleTest() { + return Uni.createFrom().nullItem(); + } + } + + interface AuthenticatedUser { + + String role(); + + void authenticate(); + + } + + enum AuthenticatedUserImpl implements AuthenticatedUser { + ADMIN(AuthenticatedUserImpl::useAdminRole), + USER(AuthenticatedUserImpl::useUserRole), + TEST(AuthenticatedUserImpl::useTestRole), + TEST2(AuthenticatedUserImpl::useTest2Role); + + private final Runnable authenticate; + + AuthenticatedUserImpl(Runnable authenticate) { + this.authenticate = authenticate; + } + + public void authenticate() { + authenticate.run(); + } + + public String role() { + return this.toString().toLowerCase(); + } + + private static void useTestRole() { + TestIdentityController.resetRoles().add("test", "test", "test"); + } + + private static void useTest2Role() { + TestIdentityController.resetRoles().add("test2", "test2", "test2"); + } + + private static void useAdminRole() { + TestIdentityController.resetRoles().add("admin", "admin", "admin"); + } + + private static void useUserRole() { + TestIdentityController.resetRoles().add("user", "user", "user"); + } + + } + + private static AuthenticatedUser withOtherPermissions(String permissionName) { + return new AuthenticatedUser() { + + private static final String OTHER = "other"; + + @Override + public String role() { + return OTHER; + } + + @Override + public void authenticate() { + // we grant additional permissions to the user directly + TestIdentityController.resetRoles().add(OTHER, OTHER, new StringPermission(permissionName)); + } + }; + } +} diff --git a/extensions/vertx-http/deployment/src/test/java/io/quarkus/vertx/http/security/permission/HttpPermConstructorValidationFailureTest.java b/extensions/vertx-http/deployment/src/test/java/io/quarkus/vertx/http/security/permission/HttpPermConstructorValidationFailureTest.java new file mode 100644 index 00000000000000..e9997792aecaf8 --- /dev/null +++ b/extensions/vertx-http/deployment/src/test/java/io/quarkus/vertx/http/security/permission/HttpPermConstructorValidationFailureTest.java @@ -0,0 +1,30 @@ +package io.quarkus.vertx.http.security.permission; + +import org.jboss.shrinkwrap.api.ShrinkWrap; +import org.jboss.shrinkwrap.api.asset.StringAsset; +import org.jboss.shrinkwrap.api.spec.JavaArchive; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import io.quarkus.runtime.configuration.ConfigurationException; +import io.quarkus.test.QuarkusUnitTest; + +public class HttpPermConstructorValidationFailureTest { + + @RegisterExtension + static QuarkusUnitTest test = new QuarkusUnitTest().setArchiveProducer(() -> ShrinkWrap.create(JavaArchive.class) + .addClasses(PermissionImpl.class) + .addAsResource(new StringAsset("quarkus.http.auth.policy.t1.roles-allowed=admin\n" + + "quarkus.http.auth.policy.t1.permissions.test=perm1\n" + + "quarkus.http.auth.policy.t1.permission-class=io.quarkus.vertx.http.security.permission.PermissionImpl\n" + + + "quarkus.http.auth.permission.t1.paths=/*\n" + + "quarkus.http.auth.permission.t1.policy=t1"), "application.properties")) + .setExpectedException(ConfigurationException.class); + + @Test + public void test() { + Assertions.fail(); + } +} diff --git a/extensions/vertx-http/deployment/src/test/java/io/quarkus/vertx/http/security/permission/HttpSecPolicyGrantingPermissionsLazyAuthTest.java b/extensions/vertx-http/deployment/src/test/java/io/quarkus/vertx/http/security/permission/HttpSecPolicyGrantingPermissionsLazyAuthTest.java new file mode 100644 index 00000000000000..749208d87c22ed --- /dev/null +++ b/extensions/vertx-http/deployment/src/test/java/io/quarkus/vertx/http/security/permission/HttpSecPolicyGrantingPermissionsLazyAuthTest.java @@ -0,0 +1,23 @@ +package io.quarkus.vertx.http.security.permission; + +import org.jboss.shrinkwrap.api.ShrinkWrap; +import org.jboss.shrinkwrap.api.asset.StringAsset; +import org.jboss.shrinkwrap.api.spec.JavaArchive; +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.quarkus.vertx.http.security.CustomPermission; +import io.quarkus.vertx.http.security.CustomPermissionWithActions; + +public class HttpSecPolicyGrantingPermissionsLazyAuthTest extends AbstractHttpSecurityPolicyGrantingPermissionsTest { + + @RegisterExtension + static QuarkusUnitTest test = new QuarkusUnitTest().setArchiveProducer(() -> ShrinkWrap.create(JavaArchive.class) + .addClasses(TestIdentityController.class, TestIdentityProvider.class, PermissionsPathHandler.class, + CDIBean.class, CustomPermission.class, CustomPermissionWithActions.class) + .addAsResource("conf/http-permission-grant-config.properties", "application.properties") + .addAsResource(new StringAsset("quarkus.http.auth.proactive=false\n"), "META-INF/microprofile-config.properties")); + +} diff --git a/extensions/vertx-http/deployment/src/test/java/io/quarkus/vertx/http/security/permission/HttpSecPolicyGrantingPermissionsTest.java b/extensions/vertx-http/deployment/src/test/java/io/quarkus/vertx/http/security/permission/HttpSecPolicyGrantingPermissionsTest.java new file mode 100644 index 00000000000000..eb0d868f3e9df9 --- /dev/null +++ b/extensions/vertx-http/deployment/src/test/java/io/quarkus/vertx/http/security/permission/HttpSecPolicyGrantingPermissionsTest.java @@ -0,0 +1,21 @@ +package io.quarkus.vertx.http.security.permission; + +import org.jboss.shrinkwrap.api.ShrinkWrap; +import org.jboss.shrinkwrap.api.spec.JavaArchive; +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.quarkus.vertx.http.security.CustomPermission; +import io.quarkus.vertx.http.security.CustomPermissionWithActions; + +public class HttpSecPolicyGrantingPermissionsTest extends AbstractHttpSecurityPolicyGrantingPermissionsTest { + + @RegisterExtension + static QuarkusUnitTest test = new QuarkusUnitTest().setArchiveProducer(() -> ShrinkWrap.create(JavaArchive.class) + .addClasses(TestIdentityController.class, TestIdentityProvider.class, PermissionsPathHandler.class, + CDIBean.class, CustomPermission.class, CustomPermissionWithActions.class) + .addAsResource("conf/http-permission-grant-config.properties", "application.properties")); + +} diff --git a/extensions/vertx-http/deployment/src/test/java/io/quarkus/vertx/http/security/permission/PermissionImpl.java b/extensions/vertx-http/deployment/src/test/java/io/quarkus/vertx/http/security/permission/PermissionImpl.java new file mode 100644 index 00000000000000..b001c15354b14b --- /dev/null +++ b/extensions/vertx-http/deployment/src/test/java/io/quarkus/vertx/http/security/permission/PermissionImpl.java @@ -0,0 +1,10 @@ +package io.quarkus.vertx.http.security.permission; + +import java.security.BasicPermission; + +public final class PermissionImpl extends BasicPermission { + + public PermissionImpl(String name, Object arg1) { + super(name); + } +} \ No newline at end of file diff --git a/extensions/vertx-http/deployment/src/test/resources/conf/http-permission-grant-config.properties b/extensions/vertx-http/deployment/src/test/resources/conf/http-permission-grant-config.properties new file mode 100644 index 00000000000000..5101e48591c271 --- /dev/null +++ b/extensions/vertx-http/deployment/src/test/resources/conf/http-permission-grant-config.properties @@ -0,0 +1,42 @@ +quarkus.http.auth.basic=true +quarkus.http.auth.policy.t1.roles-allowed=test,test2,admin,other +quarkus.http.auth.policy.t1.permissions.test=create-test +quarkus.http.auth.policy.t1.permissions.test2=create-test +quarkus.http.auth.permission.t1.paths=/test/create +quarkus.http.auth.permission.t1.policy=t1 +quarkus.http.auth.policy.t2.roles-allowed=test,test2,admin,other +quarkus.http.auth.policy.t2.permissions.test=list-test,update-test,delete-test:action1 +quarkus.http.auth.policy.t2.permissions.test2=list-test,update-test:action2 +quarkus.http.auth.policy.t2.permissions.admin=delete-test +quarkus.http.auth.permission.t2.paths=/test/update,/test/list,/test/delete,/test/create2,/test/update2 +quarkus.http.auth.permission.t2.policy=t2 +quarkus.http.auth.policy.t3.roles-allowed=test,test2,admin +quarkus.http.auth.policy.t3.permissions.test=custom-test +quarkus.http.auth.policy.t3.permissions.admin=different-permission +quarkus.http.auth.policy.t3.permission-class=io.quarkus.vertx.http.security.CustomPermission +quarkus.http.auth.permission.t3.paths=/test/custom +quarkus.http.auth.permission.t3.policy=t3 +quarkus.http.auth.policy.t4.roles-allowed=user,admin,test,test2 +quarkus.http.auth.policy.t4.permissions.test=custom-actions-test,custom-actions-test1,custom-actions-test2 +quarkus.http.auth.policy.t4.permissions.test2=custom-actions-test:wrong-action,custom-actions-test1:wrong-action2,custom-actions-test2:wrong-action3 +quarkus.http.auth.policy.t4.permissions.user=custom-actions-test,custom-actions-test1:action9,custom-actions-test2:action9 +quarkus.http.auth.policy.t4.permissions.admin=custom-actions-test1:action8,custom-actions-test2:action9 +quarkus.http.auth.policy.t4.permission-class=io.quarkus.vertx.http.security.CustomPermissionWithActions +quarkus.http.auth.permission.t4.paths=/test/custom-action +quarkus.http.auth.permission.t4.policy=t4 +quarkus.http.auth.policy.t5.roles-allowed=** +quarkus.http.auth.permission.t5.paths=/test/authenticated +quarkus.http.auth.permission.t5.policy=t5 +quarkus.http.auth.policy.t6.roles-allowed=** +quarkus.http.auth.policy.t6.permissions.admin=auth-admin-perm +quarkus.http.auth.permission.t6.paths=/test/authenticated-admin +quarkus.http.auth.permission.t6.policy=t6 +quarkus.http.auth.policy.t7.roles-allowed=** +quarkus.http.auth.policy.t7.permissions.user=auth-user-perm +quarkus.http.auth.permission.t7.paths=/test/authenticated-user +quarkus.http.auth.permission.t7.policy=t7 +quarkus.http.auth.policy.t8.roles-allowed=** +quarkus.http.auth.policy.t8.permissions.test=auth-test-perm1,auth-test-perm2 +quarkus.http.auth.policy.t8.permissions.test2=auth-test-perm1 +quarkus.http.auth.permission.t8.paths=/test/authenticated-test-role +quarkus.http.auth.permission.t8.policy=t8 diff --git a/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/PolicyConfig.java b/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/PolicyConfig.java index b5feb89fb2eefb..2ceab0071d3223 100644 --- a/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/PolicyConfig.java +++ b/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/PolicyConfig.java @@ -1,19 +1,42 @@ package io.quarkus.vertx.http.runtime; import java.util.List; +import java.util.Map; +import io.quarkus.runtime.annotations.ConfigDocMapKey; import io.quarkus.runtime.annotations.ConfigGroup; import io.quarkus.runtime.annotations.ConfigItem; import io.quarkus.runtime.annotations.ConvertWith; import io.quarkus.runtime.configuration.TrimmedStringConverter; +import io.quarkus.security.StringPermission; @ConfigGroup public class PolicyConfig { /** - * The roles that are allowed to access resources protected by this policy + * The roles that are allowed to access resources protected by this policy. */ @ConfigItem @ConvertWith(TrimmedStringConverter.class) public List rolesAllowed; + + /** + * Permissions granted to the `SecurityIdentity` if this policy is applied successfully + * (the policy allows request to proceed) and the authenticated request has required role. + * For example, you can map permission `perm1` with actions `action1` and `action2` to role `admin` by setting + * `quarkus.http.auth.policy.role-policy1.permissions.admin=perm1:action1,perm1:action2` configuration property. + * Granted permissions are used for authorization with the `@PermissionsAllowed` annotation. + */ + @ConfigDocMapKey("role1") + @ConfigItem + public Map> permissions; + + /** + * Permissions granted by this policy will be created with a `java.security.Permission` implementation + * specified by this configuration property. The permission class must declare exactly one constructor + * that accepts permission name (`String`) or permission name and actions (`String`, `String[]`). + */ + @ConfigItem(defaultValue = "io.quarkus.security.StringPermission") + public String permissionClass = StringPermission.class.getName(); + } diff --git a/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/security/HttpSecurityRecorder.java b/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/security/HttpSecurityRecorder.java index ba618a23b4a5ef..bd5a54932010bf 100644 --- a/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/security/HttpSecurityRecorder.java +++ b/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/security/HttpSecurityRecorder.java @@ -1,10 +1,20 @@ package io.quarkus.vertx.http.runtime.security; +import static io.quarkus.security.PermissionsAllowed.PERMISSION_TO_ACTION_SEPARATOR; + +import java.lang.reflect.InvocationTargetException; +import java.security.Permission; import java.security.SecureRandom; +import java.util.Arrays; import java.util.Base64; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; import java.util.Map; +import java.util.Set; import java.util.concurrent.CompletionException; import java.util.function.BiConsumer; +import java.util.function.BiFunction; import java.util.function.Consumer; import java.util.function.Function; import java.util.function.Supplier; @@ -18,9 +28,11 @@ import io.quarkus.arc.runtime.BeanContainerListener; import io.quarkus.runtime.RuntimeValue; import io.quarkus.runtime.annotations.Recorder; +import io.quarkus.runtime.configuration.ConfigurationException; import io.quarkus.security.AuthenticationCompletionException; import io.quarkus.security.AuthenticationFailedException; import io.quarkus.security.AuthenticationRedirectException; +import io.quarkus.security.StringPermission; import io.quarkus.security.identity.SecurityIdentity; import io.quarkus.security.identity.request.AnonymousAuthenticationRequest; import io.quarkus.vertx.http.runtime.FormAuthConfig; @@ -181,6 +193,95 @@ public void onFailure(Throwable throwable) { }; } + public BiFunction stringPermissionCreator() { + return StringPermission::new; + } + + public BiFunction customPermissionCreator(String clazz, boolean acceptsActions) { + return new BiFunction() { + @Override + public Permission apply(String name, String[] actions) { + try { + if (acceptsActions) { + return (Permission) loadClass(clazz).getConstructors()[0].newInstance(name, actions); + } else { + return (Permission) loadClass(clazz).getConstructors()[0].newInstance(name); + } + } catch (InstantiationException | IllegalAccessException | InvocationTargetException e) { + throw new RuntimeException( + String.format("Failed to create Permission - class '%s', name '%s', actions '%s'", clazz, + name, Arrays.toString(actions)), + e); + } + } + }; + } + + public Supplier createRolesAllowedPolicy(List rolesAllowed, + Map> roleToPermissionsStr, BiFunction permissionCreator) { + final Map> roleToPermissions = createPermissions(roleToPermissionsStr, permissionCreator); + return new SupplierImpl<>(new RolesAllowedHttpSecurityPolicy(rolesAllowed, roleToPermissions)); + } + + private static Map> createPermissions(Map> roleToPermissions, + BiFunction permissionCreator) { + // role -> created permissions + Map> result = new HashMap<>(); + for (Map.Entry> e : roleToPermissions.entrySet()) { + + // collect permission actions + // perm1:action1,perm2:action2,perm1:action3 -> perm1:action1,action3 and perm2:action2 + Map cache = new HashMap<>(); + final String role = e.getKey(); + for (String permissionToAction : e.getValue()) { + // parse permission to actions and add it to cache + addPermissionToAction(cache, role, permissionToAction); + } + + // create permissions + var permissions = new HashSet(); + for (PermissionToActions permission : cache.values()) { + permissions.add(permission.create(permissionCreator)); + } + + result.put(role, Set.copyOf(permissions)); + } + return Map.copyOf(result); + } + + private static void addPermissionToAction(Map cache, String role, String permissionToAction) { + final String permissionName; + final String action; + // incoming value is either in format perm1:action1 or perm1 (with or withot action) + if (permissionToAction.contains(PERMISSION_TO_ACTION_SEPARATOR)) { + // perm1:action1 + var permToActions = permissionToAction.split(PERMISSION_TO_ACTION_SEPARATOR); + if (permToActions.length != 2) { + throw new ConfigurationException( + String.format("Invalid permission format '%s', please use exactly one permission to action separator", + permissionToAction)); + } + permissionName = permToActions[0].trim(); + action = permToActions[1].trim(); + } else { + // perm1 + permissionName = permissionToAction.trim(); + action = null; + } + + if (permissionName.isEmpty()) { + throw new ConfigurationException( + String.format("Invalid permission name '%s' for role '%s'", permissionToAction, role)); + } + + cache.computeIfAbsent(permissionName, new Function() { + @Override + public PermissionToActions apply(String s) { + return new PermissionToActions(s); + } + }).addAction(action); + } + public static abstract class DefaultAuthFailureHandler implements BiConsumer { protected DefaultAuthFailureHandler() { @@ -407,4 +508,32 @@ public void accept(SecurityIdentity identity, Throwable throwable, Boolean aBool protected abstract void setPathMatchingPolicy(RoutingContext event); } + + private static final class PermissionToActions { + private final String permissionName; + private final Set actions; + + private PermissionToActions(String permissionName) { + this.permissionName = permissionName; + this.actions = new HashSet<>(); + } + + private void addAction(String action) { + if (action != null) { + this.actions.add(action); + } + } + + private Permission create(BiFunction permissionCreator) { + return permissionCreator.apply(permissionName, actions.toArray(new String[0])); + } + } + + private static Class loadClass(String className) { + try { + return Thread.currentThread().getContextClassLoader().loadClass(className); + } catch (ClassNotFoundException e) { + throw new RuntimeException("Unable to load class '" + className + "' for creating permission", e); + } + } } diff --git a/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/security/RolesAllowedHttpSecurityPolicy.java b/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/security/RolesAllowedHttpSecurityPolicy.java index 9fd846f4111471..4b96c48c8786fa 100644 --- a/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/security/RolesAllowedHttpSecurityPolicy.java +++ b/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/security/RolesAllowedHttpSecurityPolicy.java @@ -1,8 +1,14 @@ package io.quarkus.vertx.http.runtime.security; +import java.security.Permission; +import java.security.Principal; +import java.util.HashSet; import java.util.List; +import java.util.Map; +import java.util.Set; import java.util.function.Function; +import io.quarkus.security.credential.Credential; import io.quarkus.security.identity.SecurityIdentity; import io.smallrye.mutiny.Uni; import io.vertx.ext.web.RoutingContext; @@ -12,12 +18,24 @@ */ public class RolesAllowedHttpSecurityPolicy implements HttpSecurityPolicy { private List rolesAllowed; + private final boolean grantPermissions; + private final Map> roleToPermissions; public RolesAllowedHttpSecurityPolicy(List rolesAllowed) { this.rolesAllowed = rolesAllowed; + this.grantPermissions = false; + this.roleToPermissions = null; } public RolesAllowedHttpSecurityPolicy() { + this.grantPermissions = false; + this.roleToPermissions = null; + } + + public RolesAllowedHttpSecurityPolicy(List rolesAllowed, Map> roleToPermissions) { + this.rolesAllowed = rolesAllowed; + this.grantPermissions = true; + this.roleToPermissions = roleToPermissions; } public List getRolesAllowed() { @@ -36,7 +54,11 @@ public Uni checkPermission(RoutingContext request, Uni roles = securityIdentity.getRoles(); + if (roles != null && !roles.isEmpty()) { + Set permissions = new HashSet<>(); + for (String role : roles) { + if (roleToPermissions.containsKey(role)) { + permissions.addAll(roleToPermissions.get(role)); + } + } + if (!permissions.isEmpty()) { + return new CheckResult(true, augmentIdentity(securityIdentity, permissions)); + } + } + return CheckResult.PERMIT; + } + + private static SecurityIdentity augmentIdentity(SecurityIdentity securityIdentity, Set permissions) { + return new SecurityIdentity() { + @Override + public Principal getPrincipal() { + return securityIdentity.getPrincipal(); + } + + @Override + public boolean isAnonymous() { + return securityIdentity.isAnonymous(); + } + + @Override + public Set getRoles() { + return securityIdentity.getRoles(); + } + + @Override + public boolean hasRole(String s) { + return securityIdentity.hasRole(s); + } + + @Override + public T getCredential(Class aClass) { + return securityIdentity.getCredential(aClass); + } + + @Override + public Set getCredentials() { + return securityIdentity.getCredentials(); + } + + @Override + public T getAttribute(String s) { + return securityIdentity.getAttribute(s); + } + + @Override + public Map getAttributes() { + return securityIdentity.getAttributes(); + } + + @Override + public Uni checkPermission(Permission requiredPermission) { + for (Permission possessedPermission : permissions) { + if (possessedPermission.implies(requiredPermission)) { + return Uni.createFrom().item(true); + } + } + + return securityIdentity.checkPermission(requiredPermission); + } + + @Override + public boolean checkPermissionBlocking(Permission requiredPermission) { + for (Permission possessedPermission : permissions) { + if (possessedPermission.implies(requiredPermission)) { + return true; + } + } + + return securityIdentity.checkPermissionBlocking(requiredPermission); + } + }; + } } diff --git a/integration-tests/elytron-resteasy-reactive/src/main/java/io/quarkus/it/resteasy/reactive/elytron/ManagerPermission.java b/integration-tests/elytron-resteasy-reactive/src/main/java/io/quarkus/it/resteasy/reactive/elytron/ManagerPermission.java new file mode 100644 index 00000000000000..7329f0e688e3ea --- /dev/null +++ b/integration-tests/elytron-resteasy-reactive/src/main/java/io/quarkus/it/resteasy/reactive/elytron/ManagerPermission.java @@ -0,0 +1,10 @@ +package io.quarkus.it.resteasy.reactive.elytron; + +import java.security.BasicPermission; + +public class ManagerPermission extends BasicPermission { + public ManagerPermission(String name) { + super(name); + } + +} diff --git a/integration-tests/elytron-resteasy-reactive/src/main/java/io/quarkus/it/resteasy/reactive/elytron/RootResource.java b/integration-tests/elytron-resteasy-reactive/src/main/java/io/quarkus/it/resteasy/reactive/elytron/RootResource.java index 23bd635a836355..8b167bb416a5e1 100644 --- a/integration-tests/elytron-resteasy-reactive/src/main/java/io/quarkus/it/resteasy/reactive/elytron/RootResource.java +++ b/integration-tests/elytron-resteasy-reactive/src/main/java/io/quarkus/it/resteasy/reactive/elytron/RootResource.java @@ -15,6 +15,7 @@ import jakarta.ws.rs.core.SecurityContext; import io.quarkus.security.Authenticated; +import io.quarkus.security.PermissionsAllowed; import io.quarkus.security.identity.SecurityIdentity; @Path("/") @@ -57,6 +58,13 @@ public String user(@Context SecurityContext sec) { return sec.getUserPrincipal().getName(); } + @GET + @Path("/manager-permission") + @PermissionsAllowed(value = "manager-permission", permission = ManagerPermission.class) + public String managerPermission(@Context SecurityContext sec) { + return sec.getUserPrincipal().getName(); + } + @GET @Path("/employee") @RolesAllowed("${employees-config-property}") diff --git a/integration-tests/elytron-resteasy-reactive/src/main/resources/application.properties b/integration-tests/elytron-resteasy-reactive/src/main/resources/application.properties index 578e5f42da9020..930f7c07159bc6 100644 --- a/integration-tests/elytron-resteasy-reactive/src/main/resources/application.properties +++ b/integration-tests/elytron-resteasy-reactive/src/main/resources/application.properties @@ -7,4 +7,10 @@ quarkus.security.users.embedded.users.poul=poul quarkus.security.users.embedded.roles.poul=interns quarkus.security.users.embedded.plain-text=true quarkus.http.auth.basic=true -employees-config-property=employees \ No newline at end of file +employees-config-property=employees + +quarkus.http.auth.policy.roles1.roles-allowed=** +quarkus.http.auth.policy.roles1.permissions.managers=manager-permission +quarkus.http.auth.policy.roles1.permission-class=io.quarkus.it.resteasy.reactive.elytron.ManagerPermission +quarkus.http.auth.permission.roles1.paths=/manager-permission +quarkus.http.auth.permission.roles1.policy=roles1 \ No newline at end of file diff --git a/integration-tests/elytron-resteasy-reactive/src/test/java/io/quarkus/it/resteasy/reactive/elytron/ExceptionMapperTest.java b/integration-tests/elytron-resteasy-reactive/src/test/java/io/quarkus/it/resteasy/reactive/elytron/ExceptionMapperTest.java index cbe0673aa62834..ad40abdca6963e 100644 --- a/integration-tests/elytron-resteasy-reactive/src/test/java/io/quarkus/it/resteasy/reactive/elytron/ExceptionMapperTest.java +++ b/integration-tests/elytron-resteasy-reactive/src/test/java/io/quarkus/it/resteasy/reactive/elytron/ExceptionMapperTest.java @@ -1,6 +1,7 @@ package io.quarkus.it.resteasy.reactive.elytron; import static io.restassured.RestAssured.given; +import static org.hamcrest.Matchers.is; import java.util.Map; @@ -26,6 +27,24 @@ public void testCustomExceptionMapper() { .body(Matchers.equalTo("customized")); } + @Test + void testCustomPermission() { + // test HTTP policy granting permissions with disabled proactive auth + given() + .auth().preemptive().basic("mary", Users.password("mary")) + .when() + .get("/manager-permission") + .then() + .statusCode(200) + .body(is("mary")); + given() + .auth().preemptive().basic("john", Users.password("john")) + .when() + .get("/manager-permission") + .then() + .statusCode(403); + } + public static class ExceptionMapperTestProfile implements QuarkusTestProfile { @Override diff --git a/integration-tests/elytron-resteasy-reactive/src/test/java/io/quarkus/it/resteasy/reactive/elytron/RootResourceTest.java b/integration-tests/elytron-resteasy-reactive/src/test/java/io/quarkus/it/resteasy/reactive/elytron/RootResourceTest.java index 207cc0b9e255e6..8a4f77f49fb6e9 100644 --- a/integration-tests/elytron-resteasy-reactive/src/test/java/io/quarkus/it/resteasy/reactive/elytron/RootResourceTest.java +++ b/integration-tests/elytron-resteasy-reactive/src/test/java/io/quarkus/it/resteasy/reactive/elytron/RootResourceTest.java @@ -38,6 +38,24 @@ void testGet() { .body(is("get success")); } + @Test + void testCustomPermission() { + // test HTTP policy granting permissions with enabled proactive auth + given() + .auth().preemptive().basic("mary", Users.password("mary")) + .when() + .get("/manager-permission") + .then() + .statusCode(200) + .body(is("mary")); + given() + .auth().preemptive().basic("john", Users.password("john")) + .when() + .get("/manager-permission") + .then() + .statusCode(403); + } + @Test void testRolesAllowedConfigExpression() { given() diff --git a/integration-tests/elytron-security-jdbc/src/main/java/io/quarkus/elytron/security/jdbc/it/PermissionsIdentityAugmentor.java b/integration-tests/elytron-security-jdbc/src/main/java/io/quarkus/elytron/security/jdbc/it/PermissionsIdentityAugmentor.java index 3cce8dc35e096a..0d4b798eb54047 100644 --- a/integration-tests/elytron-security-jdbc/src/main/java/io/quarkus/elytron/security/jdbc/it/PermissionsIdentityAugmentor.java +++ b/integration-tests/elytron-security-jdbc/src/main/java/io/quarkus/elytron/security/jdbc/it/PermissionsIdentityAugmentor.java @@ -25,9 +25,6 @@ public Uni augment(SecurityIdentity identity, AuthenticationRe SecurityIdentity build(SecurityIdentity identity) { QuarkusSecurityIdentity.Builder builder = QuarkusSecurityIdentity.builder(identity); - if ("admin".equals(identity.getPrincipal().getName())) { - builder.addPermissionChecker(createStringPermission("read")); - } if ("worker".equals(identity.getPrincipal().getName())) { builder.addPermissionChecker(createWorkdayPermission()); } diff --git a/integration-tests/elytron-security-jdbc/src/main/resources/application.properties b/integration-tests/elytron-security-jdbc/src/main/resources/application.properties index dc21e611d992a0..4841e8c0e2054e 100644 --- a/integration-tests/elytron-security-jdbc/src/main/resources/application.properties +++ b/integration-tests/elytron-security-jdbc/src/main/resources/application.properties @@ -10,3 +10,8 @@ quarkus.security.jdbc.principal-query.clear-password-mapper.password-index=1 quarkus.security.jdbc.principal-query.attribute-mappings.0.index=2 quarkus.security.jdbc.principal-query.attribute-mappings.0.to=groups quarkus.http.auth.form.enabled=true + +quarkus.http.auth.policy.roles1.roles-allowed=** +quarkus.http.auth.policy.roles1.permissions.admin=read +quarkus.http.auth.permission.roles1.paths=/api/read-permission +quarkus.http.auth.permission.roles1.policy=roles1 \ No newline at end of file