Skip to content

Commit

Permalink
Introduce HTTP security policy mapping between roles and permissions
Browse files Browse the repository at this point in the history
closes: #12219
  • Loading branch information
michalvavrik committed Mar 25, 2023
1 parent f017f9f commit 53d5da6
Show file tree
Hide file tree
Showing 22 changed files with 1,002 additions and 16 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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;
Expand All @@ -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;
Expand All @@ -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";
}
Expand All @@ -432,13 +439,14 @@ public class CRUDResource {
}
@PermissionsAllowed(value = "list", permission = CustomPermission.class) <4>
@Path("/list")
@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 static class CustomPermission extends BasicPermission {
public CustomPermission(String name) {
super(name);
Expand All @@ -451,8 +459,6 @@ public class CRUDResource {
var hasPermission = getName().equals(permission.getName());
return hasPermission && publicContent;
}
...
}
}
----
Expand All @@ -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.
<<authorization-using-configuration,HTTP role-based policies>> 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.grants-permissions.user=see:all <2>
quarkus.http.auth.policy.role-policy1.grants-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.grants-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 <<matching-multiple-paths, Matching multiple paths>> 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.
Expand Down Expand Up @@ -597,7 +631,8 @@ 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].
The only one way to add custom `java.security.Permission` that accepts additional arguments like `LibraryPermission` is
xref:security-customization.adoc#security-identity-customization[Security Identity Customization].

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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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));
}

Expand Down
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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;
Expand All @@ -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<HttpSecurityPolicyBuildItem> producer,
HttpBuildTimeConfig buildTimeConfig,
BuildProducer<ReflectiveClassBuildItem> reflectiveClassProducer,
CombinedIndexBuildItem combinedIndexBuildItem,
HttpBuildTimeConfig buildTimeConfig, HttpSecurityRecorder recorder,
BuildProducer<AdditionalBeanBuildItem> beanProducer) {
producer.produce(new HttpSecurityPolicyBuildItem("deny", new SupplierImpl<>(new DenySecurityPolicy())));
producer.produce(new HttpSecurityPolicyBuildItem("permit", new SupplierImpl<>(new PermitSecurityPolicy())));
Expand All @@ -48,11 +66,89 @@ public void builtins(BuildProducer<HttpSecurityPolicyBuildItem> producer,
if (!buildTimeConfig.auth.permissions.isEmpty()) {
beanProducer.produce(AdditionalBeanBuildItem.unremovableOf(PathMatchingHttpSecurityPolicy.class));
}
Map<String, BiFunction<String, String[], Permission>> permClassToCreator = new HashMap<>();
for (Map.Entry<String, PolicyConfig> e : buildTimeConfig.auth.rolePolicy.entrySet()) {
producer.produce(new HttpSecurityPolicyBuildItem(e.getKey(),
new SupplierImpl<>(new RolesAllowedHttpSecurityPolicy(e.getValue().rolesAllowed))));
PolicyConfig policyConfig = e.getValue();
if (policyConfig.grantsPermissions.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<String, BiFunction<String, String[], Permission>>() {
@Override
public BiFunction<String, String[], Permission> 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.grantsPermissions,
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
Expand Down
Original file line number Diff line number Diff line change
@@ -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);
}
}
Original file line number Diff line number Diff line change
@@ -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();
}
}
Loading

0 comments on commit 53d5da6

Please sign in to comment.