Skip to content

Commit

Permalink
Allow permission checks via @PermissionsAllowed security annotation
Browse files Browse the repository at this point in the history
  • Loading branch information
michalvavrik committed Mar 8, 2023
1 parent 781736a commit d8287e8
Show file tree
Hide file tree
Showing 48 changed files with 4,784 additions and 22 deletions.
2 changes: 1 addition & 1 deletion bom/application/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -185,7 +185,7 @@
<mockito.version>5.1.1</mockito.version>
<jna.version>5.8.0</jna.version><!-- should satisfy both testcontainers and mongodb -->
<antlr.version>4.10.1</antlr.version><!-- needs to align with same property in build-parent/pom.xml -->
<quarkus-security.version>2.0.1.Final</quarkus-security.version>
<quarkus-security.version>2.0.2.Final</quarkus-security.version>
<keycloak.version>20.0.3</keycloak.version>
<logstash-gelf.version>1.15.0</logstash-gelf.version>
<checker-qual.version>3.32.0</checker-qual.version>
Expand Down
251 changes: 251 additions & 0 deletions docs/src/main/asciidoc/security-authorize-web-endpoints-reference.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -386,6 +386,257 @@ It is possible to use multiple expressions in the role definition.
<3> This `/subject/user` endpoint requires an authenticated user that has been granted the role "User" through the use of the `@RolesAllowed("${customer:User}")` annotation, as we did not set the configuration property `customer`.
<4> This `/subject/secured` endpoint requires an authenticated user that has been granted the role `User` in production but allows any authenticated user in development mode.

=== Permission annotation

Quarkus also provides the `io.quarkus.security.PermissionsAllowed` annotation that will permit any authenticated user with given permission to access the resource.
The annotation is extension of the common security annotations and has no relation to configuration permissions defined with the configuration property `quarkus.http.auth.permission`.

.Example of endpoints secured with the `@PermissionsAllowed` annotation

[source,java]
----
import io.quarkus.arc.Arc;
import io.vertx.ext.web.RoutingContext;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.QueryParam;
import io.quarkus.security.PermissionsAllowed;
import java.security.Permission;
import java.util.Collection;
import java.util.Collections;
@Path("/crud")
public class CRUDResource {
@PermissionsAllowed("create") <1>
@PermissionsAllowed("update")
@POST
public String createOrUpdate() {
return "modified";
}
@PermissionsAllowed(value = {"create", "update"}, inclusive=true) <2>
@POST
public String createOrUpdate(Long id) {
return id + " modified";
}
@PermissionsAllowed({"see:detail", "see:all", "read"}) <3>
@GET
@Path("/id/{id}")
public String getItem(String id) {
return "item-detail-" + id;
}
@PermissionsAllowed(value = "list", permission = CustomPermission.class) <4>
@GET
public Collection<String> list(@QueryParam("query-options") String queryOptions) {
// your business logic comes here
return Collections.emptySet();
}
public static class CustomPermission extends Permission {
public CustomPermission(String name) {
super(name);
}
@Override
public boolean implies(Permission permission) {
var event = Arc.container().instance(RoutingContext.class).get(); <5>
var publicContent = "public-content".equals(event.request().params().get("query-options"));
var hasPermission = getName().equals(permission.getName());
return hasPermission && publicContent;
}
...
}
}
----
<1> Resource method `createOrUpdate` is only accessible by user with both `create` and `update` permissions.
<2> By default, at least one of the permissions specified through one annotation instance is required.
You can require all of them by setting `inclusive=true`. Both resource methods `createOrUpdate` have equal authorization requirements.
<3> Access is granted to `getItem` if `SecurityIdentity` has either `read` permission or `see` permission and one of actions (`all`, `detail`).
<4> You can use any `java.security.Permission` implementation of your choice.
By default, string-based permission is performed by the `io.quarkus.security.StringPermission`.
<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.

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.

.Example of a custom `java.security.Permission` that accepts additional arguments

[source,java]
----
import java.security.Permission;
import java.util.Arrays;
import java.util.Set;
public class LibraryPermission extends Permission {
private final Set<String> actions;
private final Library library;
public LibraryPermission(String libraryName, String[] actions, Library library) { <1>
super(libraryName);
this.actions = Set.copyOf(Arrays.asList(actions));
this.library = library;
}
@Override
public boolean implies(Permission requiredPermission) {
if (requiredPermission instanceof LibraryPermission) {
LibraryPermission that = (LibraryPermission) requiredPermission;
boolean librariesMatch = getName().equals(that.getName());
boolean requiredLibraryIsSublibrary = library.isParentLibraryOf(that.library);
boolean hasOneOfRequiredActions = that.actions.stream().anyMatch(actions::contains);
return (librariesMatch || requiredLibraryIsSublibrary) && hasOneOfRequiredActions;
}
return false;
}
...
public static abstract class Library {
protected String description;
abstract boolean isParentLibraryOf(Library library);
}
public static class MediaLibrary extends Library {
@Override
boolean isParentLibraryOf(Library library) {
return library instanceof MediaLibrary;
}
}
public static class TvLibrary extends MediaLibrary {
...
}
}
----
<1> There must be exactly one constructor of a custom `Permission` class, also first parameter is always considered a permission name (must be `String`).
Optionally, Quarkus may pass Permission actions to the constructor. Just declare the second parameter as `String[]`.

The `LibraryPermission` permit access to a library if `SecurityIdentity` is allowed to perform one of required actions
(like `read`, `write`, `list`) on the very same library, or the parent one. Let's see how it is used:

[source,java]
----
import io.quarkus.security.PermissionsAllowed;
import jakarta.enterprise.context.ApplicationScoped;
@ApplicationScoped
public class LibraryService {
@PermissionsAllowed(value = "tv:write", permission = LibraryPermission.class) <1>
public Library updateLibrary(String newDesc, Library update) {
update.description = newDesc;
return update;
}
@PermissionsAllowed(value = "tv:write", permission = LibraryPermission.class, params = "library") <2>
@PermissionsAllowed(value = {"tv:read", "tv:list"}, permission = LibraryPermission.class)
public Library migrateLibrary(Library migrate, Library library) {
// migrate libraries
return library;
}
}
----
<1> Formal parameter `update` is identified as the first `Library` parameter and passed to the `LibraryPermission`.
However this option comes with a price, as the `LibraryPermission` must be instantiated every single time `updateLibrary` method is invoked.
<2> Here, the first `Library` parameter is `migrate`, therefore we marked `library` parameter explicitly via `PermissionsAllowed#params`.
Please note that both Permission constructor and annotated method must have parameter `library`, otherwise validation will fail.

CAUTION: Passing method parameters to a custom `Permission` constructor is not supported on RESTEasy Reactive endpoints,
because there, security checks are done before serialization.

.Example of endpoint limitations when it comes to passing annotated method arguments to the Permission constructor

[source,java]
----
@Path("/library")
public class LibraryResource {
@PermissionsAllowed(value = "tv:write", permission = LibraryPermission.class)
@PUT
@Path("/id/{id}")
public Library updateLibrary(@PathParam("id") Integer id, Library library) { <1>
...
}
}
----
<1> In the RESTEasy Reactive, the endpoint argument `library` won't ever be passed to the `LibraryPermission`, because it is not available.
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.

Currently, there is only one way to add permissions, and that is xref:security-customization.adoc#security-identity-customization[Security Identity Customization].

.Example of Adding the `LibraryPermission` to the `SecurityIdentity`

[source,java]
----
import java.security.Permission;
import java.util.function.Function;
import jakarta.enterprise.context.ApplicationScoped;
import io.quarkus.security.identity.AuthenticationRequestContext;
import io.quarkus.security.identity.SecurityIdentity;
import io.quarkus.security.identity.SecurityIdentityAugmentor;
import io.quarkus.security.runtime.QuarkusSecurityIdentity;
import io.smallrye.mutiny.Uni;
@ApplicationScoped
public class PermissionsIdentityAugmentor implements SecurityIdentityAugmentor {
@Override
public Uni<SecurityIdentity> augment(SecurityIdentity identity, AuthenticationRequestContext context) {
if (isNotAdmin(identity)) {
return Uni.createFrom().item(identity);
}
return Uni.createFrom().item(build(identity));
}
private boolean isNotAdmin(SecurityIdentity identity) {
return identity.isAnonymous() || !"admin".equals(identity.getPrincipal().getName());
}
SecurityIdentity build(SecurityIdentity identity) {
Permission possessedPermission = new LibraryPermission("media-library",
new String[] { "read", "write", "list"}, new MediaLibrary()); <1>
return QuarkusSecurityIdentity.builder(identity)
.addPermissionChecker(new Function<Permission, Uni<Boolean>>() { <2>
@Override
public Uni<Boolean> apply(Permission requiredPermission) {
boolean accessGranted = possessedPermission.implies(requiredPermission);
return Uni.createFrom().item(accessGranted);
}
})
.build();
}
}
----
<1> Created permission `media-library` 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.
<2> You can add a permission checker via `io.quarkus.security.runtime.QuarkusSecurityIdentity.Builder#addPermissionChecker`.

CAUTION: Annotation permissions do not work with the custom xref:security-customization.adoc#jaxrs-security-context[JAX-RS SecurityContext], for there are no permissions in `jakarta.ws.rs.core.SecurityContext`.

== References

* xref:security-overview-concept.adoc[Quarkus Security overview]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,7 @@
import io.quarkus.runtime.TemplateHtmlBuilder;
import io.quarkus.runtime.util.HashUtil;
import io.quarkus.security.Authenticated;
import io.quarkus.security.PermissionsAllowed;
import io.quarkus.vertx.http.deployment.FilterBuildItem;
import io.quarkus.vertx.http.deployment.HttpRootPathBuildItem;
import io.quarkus.vertx.http.deployment.RequireBodyHandlerBuildItem;
Expand Down Expand Up @@ -141,6 +142,7 @@ class ReactiveRoutesProcessor {
private static final DotName ROLES_ALLOWED = DotName.createSimple(RolesAllowed.class.getName());
private static final DotName AUTHENTICATED = DotName.createSimple(Authenticated.class.getName());
private static final DotName DENY_ALL = DotName.createSimple(DenyAll.class.getName());
private static final DotName PERMISSIONS_ALLOWED = DotName.createSimple(PermissionsAllowed.class.getName());

private static final List<ParameterInjector> PARAM_INJECTORS = initParamInjectors();

Expand Down Expand Up @@ -232,6 +234,7 @@ void validateBeanDeployment(
&& !returnTypeName.equals(DotNames.MULTI) && !returnTypeName.equals(DotNames.COMPLETION_STAGE);
final boolean hasRbacAnnotationThatRequiresAuth = annotationStore.hasAnnotation(method, ROLES_ALLOWED)
|| annotationStore.hasAnnotation(method, AUTHENTICATED)
|| annotationStore.hasAnnotation(method, PERMISSIONS_ALLOWED)
|| annotationStore.hasAnnotation(method, DENY_ALL);
alwaysAuthenticateRoute = possiblySynchronousResponse && hasRbacAnnotationThatRequiresAuth;
} else {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1509,7 +1509,8 @@ void registerSecurityInterceptors(Capabilities capabilities,
// Register interceptors for standard security annotations to prevent repeated security checks
beans.produce(new AdditionalBeanBuildItem(StandardSecurityCheckInterceptor.RolesAllowedInterceptor.class,
StandardSecurityCheckInterceptor.AuthenticatedInterceptor.class,
StandardSecurityCheckInterceptor.PermitAllInterceptor.class));
StandardSecurityCheckInterceptor.PermitAllInterceptor.class,
StandardSecurityCheckInterceptor.PermissionsAllowedInterceptor.class));
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,13 +15,15 @@
import org.jboss.jandex.MethodInfo;

import io.quarkus.security.Authenticated;
import io.quarkus.security.PermissionsAllowed;

public class SecurityTransformerUtils {
public static final Set<DotName> 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()));
Expand All @@ -32,7 +34,7 @@ public static boolean hasStandardSecurityAnnotation(MethodInfo methodInfo) {
}

public static boolean hasStandardSecurityAnnotation(ClassInfo classInfo) {
return hasStandardSecurityAnnotation(classInfo.classAnnotations());
return hasStandardSecurityAnnotation(classInfo.declaredAnnotations());
}

private static boolean hasStandardSecurityAnnotation(Collection<AnnotationInstance> instances) {
Expand All @@ -49,7 +51,7 @@ public static Optional<AnnotationInstance> findFirstStandardSecurityAnnotation(M
}

public static Optional<AnnotationInstance> findFirstStandardSecurityAnnotation(ClassInfo classInfo) {
return findFirstStandardSecurityAnnotation(classInfo.classAnnotations());
return findFirstStandardSecurityAnnotation(classInfo.declaredAnnotations());
}

private static Optional<AnnotationInstance> findFirstStandardSecurityAnnotation(Collection<AnnotationInstance> instances) {
Expand Down
Loading

0 comments on commit d8287e8

Please sign in to comment.