Wick Technology Blog

Custom Micronaut Security Rules

June 20, 2020

Micronaut comes with a few useful security rules such as ip filtering, url pattern matching and an annotation inspecting rule. However, extending these by creating custom rules is also possible and is useful to lock down your application and reduce the amount of code needed for checking authorisation.

First, let’s look at customising the default rules in Micronaut security:

Customising @Secured

The usual way to secure endpoints is by using @Secured with the name of a role or roles e.g. @Secured("ADMIN"). This will look for a string of “ADMIN” in the roles key in the claims. Either

{
    "sub":"1",
    "roles":["ADMIN"]
}

or

{
    "sub":"1",
    "roles": "ADMIN"
}

would be allowed.

To customise this, you can either change the key the default roles finder looks in by setting the property micronaut.security.token.rolesName or implement io.micronaut.security.token.RolesFinder in a new Bean.

Custom Security Rules

To implement a new custom security rule you need to implement io.micronaut.security.rules.SecurityRule which only has one method:
SecurityRuleResult check(HttpRequest request, @Nullable RouteMatch routeMatch, @Nullable Map<String, Object> claims);
SecurityRule also implements Ordered so you can override the default getOrder method to define when your rule gets run.

When implementing the check method you need to return SecurityRuleResult. This is an enum with 3 values: ALLOWED, DENIED and UNKNOWN. You should return ALLOWED if your rule explicitly allows the action, DENIED if your rule specifically does not allow the action and UNKNOWN if you don’t have enough information to make a decision. Returning UNKNOWN allows the other security rules to also try make a decision, rather than immediately denying the request if there’s not enough information.

When you implement the rule you have 3 parameters passed to you: the request, the route match and claims. The request is simply the request made by the client which is being authorised. The route match is what gives you access to the method in your controller so you can get annotations or arguments. Claims is a map of the JSON from the JWT sent in the request. This can be null if there wasn’t a JWT, or it couldn’t be parsed or validated.

So when would you need a custom security rule?

Permissions on Specific Resources

If you want to implement permissions on specific resources then you need to implement a custom rule. This is useful if, for example, your system is multi-tenant and users are only allowed to manage their tenant. This is different from a list of roles because the user is only allowed to do something on a specific resource. Usually, JWTs with resource specific permissions would look like:

    {
      "sub": "",
      "https://your-domain.com/claims": {
        "123": "ADMIN",
        "234": "READ_ONLY"
      }
    } 

The permissions are in a namespaced map (namespacing by domain is the conventional way to do it - Auth0 explains a bit more about this) where the keys are resource IDs. Following my example, 123 and 234 would be the IDs of two tenants. The user who asked for this JWT would be able to perform ADMIN actions on tenant with ID 123 and only view tenant with ID 234.

So how can we authorise this user in Mirconaut?

You’ll need two classes, one annotation and one SecurityRule implementation. The annotation will need to define the resourceIdName and the permission it’s looking for:

public @interface RequiredPermission {

    /**
     * The name of the parameter in the controller method which contains the 
     * resource ID this permisssion is required for
     * @return resourceIdName
     */
    String resourceIdName();

    /**
     * The permission required, e.g. READ_ONLY, ADMIN, WRITE
     * @return permission
     */
    String permission();

}

Then we define a security rule that looks for this permission and uses the values in the annotation to pull out the resource ID in the url and compare it with the claims in the token:

import io.micronaut.core.annotation.AnnotationValue;
import io.micronaut.http.HttpRequest;
import io.micronaut.security.rules.SecurityRule;
import io.micronaut.security.rules.SecurityRuleResult;
import io.micronaut.web.router.MethodBasedRouteMatch;
import io.micronaut.web.router.RouteMatch;

import javax.annotation.Nullable;
import javax.inject.Singleton;
import java.util.Map;
import java.util.Optional;

@Singleton
public class PermissionSecurityRule implements SecurityRule {
    @Override
    public SecurityRuleResult check(HttpRequest request, @Nullable RouteMatch routeMatch, @Nullable Map<String, Object> claims) {
        if (routeMatch instanceof MethodBasedRouteMatch) {
            MethodBasedRouteMatch methodBasedRouteMatch = (MethodBasedRouteMatch) routeMatch;
            if (methodBasedRouteMatch.hasAnnotation(RequiredPermission.class)) {
                AnnotationValue<RequiredPermission> requiredPermissionAnnotation = methodBasedRouteMatch.getAnnotation(RequiredPermission.class);
                // Get parameters from annotation on method
                Optional<String> resourceIdName = requiredPermissionAnnotation.stringValue("resourceIdName");
                Optional<String> permission = requiredPermissionAnnotation.stringValue("permission");
                if (permission.isPresent() && resourceIdName.isPresent() && claims != null) {
                    // Use name of parameter to get the value passed in as an argument to the method
                    String resourceId = methodBasedRouteMatch.getVariableValues().get(resourceIdName.get()).toString();
                    // Get claim from jwt using the resource ID
                    Object permissionForResource = ((Map) claims.get("https://your-domain.com/claims")).get(resourceId);
                    if (permissionForResource != null && permissionForResource.equals(permission.get())) {
                        // if the permission exists and it's equal, allow access
                        return SecurityRuleResult.ALLOWED;
                    }
                }
            }
        }
        return SecurityRuleResult.UNKNOWN;
    }
}

Annotating this rule with @Singleton means it will be picked up by Micronaut and executed as part of a list of rules. Notice how one only path in the code leads to the method returning ALLOWED and all others return UNKNOWN, meaning other rules can also be checked - if everything returns UNKNOWN the request will be rejected with a 403.

To put this rule before other rules, implement the getOrder method and return a number lower than the numbers returned from the other rules. Currently, this is the order of the rules, and the numbers returned from getOrder():

  1. IpPatternsRule - Order number: -300
  2. SecuredAnnotationRule - Order number: -200
  3. ConfigurationInterceptUrlMapRule - Order number: -100
  4. SensitiveEndpointRule - Order number: 0

You can have much more complicated JWT claims and still validate they are correct. For example, I would probably have a list of permissions in the JWT per resource ID like {"123":["READ_ONLY","WRITE","ADMIN"]} and so I would verify that the required permission is in the list. Also, unless your IDs are globally unique, I would put these in a nested object to show what resource they relate to, e.g. {"https://your-domain.com/claims": {"tenants": {"123": ["READ_ONLY"]}}}.

Conclusion

Micronaut makes it really easy to hook into the security and provides all you need to validate claims and authorise requests. All code for this, plus tests, is on Github at https://github.com/PhilHardwick/micronaut-custom-security-rule.


Phil Hardwick

Written by Phil Hardwick