Skip to content

Commit

Permalink
Merge pull request #43846 from michalvavrik/feature/permissions-allow…
Browse files Browse the repository at this point in the history
…ed-user-exp-improvements

Add  annotation to allow using custom CDI bean methods as permission checkers
  • Loading branch information
sberyozkin authored Nov 4, 2024
2 parents 7ab2138 + ee1c852 commit 4968e01
Show file tree
Hide file tree
Showing 54 changed files with 3,077 additions and 268 deletions.
126 changes: 93 additions & 33 deletions docs/src/main/asciidoc/security-authorize-web-endpoints-reference.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -1055,8 +1055,85 @@ Because `MediaLibrary` is the `TvLibrary` class parent, a user with the `admin`

CAUTION: Annotation-based permissions do not work with custom xref:security-customization.adoc#jaxrs-security-context[Jakarta REST SecurityContexts] because there are no permissions in `jakarta.ws.rs.core.SecurityContext`.

[[permission-checker]]
==== Create permission checkers

By default, `SecurityIdentity` must be configured with permissions which can be used to check if this identity passes `@PermissionAllowed` authorization restrictions.
Alternatively, you can use a `@PermissionChecker` annotation to mark any CDI bean method as a permission checker.
The `@PermissionChecker` annotation value should match required permission declared by the `@PermissionsAllowed` annotation value.
For example, a permission checker can be created like this:

[source,java]
----
package org.acme.security.rest.resource;
import io.quarkus.security.PermissionChecker;
import io.quarkus.security.PermissionsAllowed;
import io.quarkus.security.identity.SecurityIdentity;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.Path;
import org.jboss.resteasy.reactive.RestForm;
import org.jboss.resteasy.reactive.RestPath;
@Path("/project")
public class ProjectResource {
@PermissionsAllowed("rename-project") <1>
@POST
public void renameProject(@RestPath String projectName, @RestForm String newName) {
Project project = Project.findByName(projectName);
project.name = newName;
}
@PermissionChecker("rename-project") <2>
boolean canRenameProject(SecurityIdentity identity, String projectName) { <3> <4>
var principalName = identity.getPrincipal().getName();
var user = User.getUserByName(principalName);
return userOwnsProject(projectName, user);
}
}
----
<1> The permission required to access the `ProjectResource#renameProject` is the `rename-project` permission.
<2> The `ProjectResource#canRenameProject` method authorizes access to the `ProjectResource#renameProject` endpoint.
<3> The `SecurityIdentity` instance can be injected into any permission checker method.
<4> In this example, the `rename-project` permission checker is declared on the same resource.
However, there is no restriction on where this permission checker can be declared as demonstrated in the next example.

NOTE: Permission checker methods can be declared on a normal scoped CDI bean or on a `@Singleton` bean.
The `@Dependent` CDI bean scope is currently not supported.

The permission checker above requires the `SecurityIdentity` instance to authorize the `renameProject` endpoint.
Instead of declaring the `rename-project` permission checker directly on the resource, you can declare it on any CDI bean like in this example:

[source,java]
----
package org.acme.security.rest.resource;
import io.quarkus.security.PermissionChecker;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
@ApplicationScoped <1>
public class ProjectPermissionChecker {
@PermissionChecker("rename-project")
boolean canRenameProject(String projectName, SecurityIdentity identity) { <2>
var principalName = identity.getPrincipal().getName();
var user = User.getUserByName(principalName);
return userOwnsProject(projectName, user);
}
}
----
<1> A CDI bean with the permission checker must be either a normal scoped bean or a `@Singleton` bean.
<2> The permission checker method must return either `boolean` or `Uni<Boolean>`. Private checker methods are not supported.

TIP: Permission checks run by default on event loops whenever possible.
Annotate a permission checker method with the `io.smallrye.common.annotation.Blocking` annotation if you want to run the check on a worker thread.

[[permission-meta-annotation]]
=== Create permission meta-annotations
==== Create permission meta-annotations

`@PermissionsAllowed` can also be used in meta-annotations.
For example, a new `@CanWrite` security annotation can be created like this:
Expand All @@ -1080,7 +1157,7 @@ public @interface CanWrite {
<1> Any method or class annotated with the `@CanWrite` annotation is secured with this `@PermissionsAllowed` annotation instance.

[[permission-bean-params]]
=== Pass `@BeanParam` parameters into a custom permission
==== Pass `@BeanParam` parameters into a custom permission

Quarkus can map fields of a secured method parameters to a custom permission constructor parameters.
You can use this feature to pass `jakarta.ws.rs.BeanParam` parameters into your custom permission.
Expand All @@ -1096,20 +1173,19 @@ import jakarta.ws.rs.GET;
import jakarta.ws.rs.Path;
@Path("/hello")
public class SimpleResource {
public class HelloResource {
@PermissionsAllowed(value = "say:hello", permission = BeanParamPermission.class,
params = "beanParam.securityContext.userPrincipal.name") <1>
@PermissionsAllowed(value = "say-hello", params = "beanParam.securityContext.userPrincipal.name") <1>
@GET
public String sayHello(@BeanParam SimpleBeanParam beanParam) {
return "Hello from " + beanParam.uriInfo.getPath();
}
}
----
<1> The `params` annotation attribute specifies that user principal name should be passed to the `BeanParamPermission` constructor.
Other `BeanParamPermission` constructor parameters like `customAuthorizationHeader` and `query` are matched automatically.
Quarkus identifies the `BeanParamPermission` constructor parameters among `beanParam` fields and their public accessors.
<1> The `params` annotation attribute specifies that user principal name should be passed to the `BeanParamPermissionChecker#canSayHello` method.
Other `BeanParamPermissionChecker#canSayHello` method parameters like `customAuthorizationHeader` and `query` are matched automatically.
Quarkus identifies the `BeanParamPermissionChecker#canSayHello` method parameters among `beanParam` fields and their public accessors.
To avoid ambiguous resolution, automatic detection only works for the `beanParam` fields.
For that reason, we had to specify path to the user principal name explicitly.

Expand Down Expand Up @@ -1155,47 +1231,31 @@ public class SimpleBeanParam {
<3> The `customAuthorizationHeader` field is not public, therefore Quarkus access this field with the `customAuthorizationHeader` accessor.
That is particularly useful with Java records, where generated accessors are not prefixed with `get`.

Here is an example of the `BeanParamPermission` permission that checks user principal, custom header and query parameter:
Here is an example of a `@PermissionChecker` method that checks the `say-hello` permission based on a user principal, custom header and query parameter:

[source,java]
----
package org.acme.security.permission;
import java.security.Permission;
public class BeanParamPermission extends Permission {
private final String actions;
public BeanParamPermission(String permissionName, String customAuthorizationHeader, String name, String query) {
super(permissionName);
this.actions = computeActions(customAuthorizationHeader, name, query);
}
import jakarta.enterprise.context.ApplicationScoped;
import io.quarkus.security.PermissionChecker;
@Override
public boolean implies(Permission p) {
boolean nameMatches = getName().equals(p.getName());
boolean actionMatches = actions.equals(p.getActions());
return nameMatches && actionMatches;
}
@ApplicationScoped
public class BeanParamPermissionChecker {
private static String computeActions(String customAuthorizationHeader, String name, String query) {
@PermissionChecker("say-hello")
boolean canSayHello(String customAuthorizationHeader, String name, String query) {
boolean queryParamAllowedForPermissionName = checkQueryParams(query);
boolean usernameWhitelisted = isUserNameWhitelisted(name);
boolean customAuthorizationMatches = checkCustomAuthorization(customAuthorizationHeader);
var isAuthorized = queryParamAllowedForPermissionName && usernameWhitelisted && customAuthorizationMatches;
if (isAuthorized) {
return "hello";
} else {
return "goodbye";
}
return queryParamAllowedForPermissionName && usernameWhitelisted && customAuthorizationMatches;
}
...
}
----

NOTE: You can pass `@BeanParam` directly into a custom permission constructor and access its fields programmatically in the constructor instead.
NOTE: You can pass `@BeanParam` directly into a `@PermissionChecker` method and access its fields programmatically.
Ability to reference `@BeanParam` fields with the `@PermissionsAllowed#params` attribute is useful when you have multiple differently structured `@BeanParam` classes.

== References
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ public abstract class AbstractPermissionsAllowedTestCase {
@BeforeAll
public static void setupUsers() {
TestIdentityController.resetRoles()
.add("admin", "admin")
.add("admin", "admin", "admin")
.add("user", "user")
.add("viewer", "viewer");
}
Expand Down Expand Up @@ -190,6 +190,40 @@ public void testCustomPermissionWithAdditionalArgs_MetaAnnotation() {
.statusCode(403);
}

@Test
public void testPermissionCheckerDeclaredInsideResource() {
reqPermChecker("checker-inside-resource", null, false).statusCode(401);
reqPermChecker("checker-inside-resource", "user", false).statusCode(403);
reqPermChecker("checker-inside-resource", "admin", false).statusCode(200).body(Matchers.is("admin"));
}

@Test
public void testPermissionCheckRunOnCorrectThread() {
testPermissionCheckRunOnCorrectThread("worker-thread");
testPermissionCheckRunOnCorrectThread("io-thread");
testPermissionCheckRunOnCorrectThread("io-thread-uni");
testPermissionCheckRunOnCorrectThread("worker-thread-method-args");
testPermissionCheckRunOnCorrectThread("io-thread-method-args");
}

private static void testPermissionCheckRunOnCorrectThread(String subPath) {
reqPermChecker(subPath, "user", false).statusCode(403);
reqPermChecker(subPath, "admin", false).statusCode(200).body(Matchers.is("admin"));
reqPermChecker(subPath, "admin", true).statusCode(403);
}

private static ValidatableResponse reqPermChecker(String path, String user, boolean addFailHeader) {
var req = RestAssured.given();
if (user != null) {
req.auth().basic(user, user);
}
if (addFailHeader) {
// this "fail" header is about checking that we have RoutingContext available
req.header("fail", "true");
}
return req.get("/permission-checkers/" + path).then();
}

private static ValidatableResponse reqAutodetectedExtraArgs(String user, String place) {
return RestAssured.given()
.auth().basic(user, user)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
package io.quarkus.resteasy.reactive.server.test.security;

import jakarta.enterprise.context.RequestScoped;

import io.quarkus.security.PermissionChecker;

@RequestScoped
public class BeanParamPermissionChecker {

@PermissionChecker("say-hello")
boolean canSayHello(String customAuthorizationHeader, String name, String query) {
boolean queryParamAllowedForPermissionName = checkQueryParams(query);
boolean usernameWhitelisted = isUserNameWhitelisted(name);
boolean customAuthorizationMatches = checkCustomAuthorization(customAuthorizationHeader);
return queryParamAllowedForPermissionName && usernameWhitelisted && customAuthorizationMatches;
}

static boolean checkCustomAuthorization(String customAuthorization) {
return "customAuthorization".equals(customAuthorization);
}

static boolean isUserNameWhitelisted(String userName) {
return "admin".equals(userName);
}

static boolean checkQueryParams(String queryParam) {
return "myQueryParam".equals(queryParam);
}
}

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,8 @@ public class LazyAuthPermissionsAllowedTestCase extends AbstractPermissionsAllow
.addClasses(PermissionsAllowedResource.class, TestIdentityProvider.class, TestIdentityController.class,
NonBlockingPermissionsAllowedResource.class, CustomPermission.class,
PermissionsIdentityAugmentor.class, CustomPermissionWithExtraArgs.class,
StringPermissionsAllowedMetaAnnotation.class, CreateOrUpdate.class)
StringPermissionsAllowedMetaAnnotation.class, CreateOrUpdate.class, PermissionCheckers.class,
PermissionCheckersResource.class)
.addAsResource(new StringAsset("quarkus.http.auth.proactive=false\n"),
"application.properties"));

Expand Down

This file was deleted.

Loading

0 comments on commit 4968e01

Please sign in to comment.