Secure Your Micronaut API: Mastering Role-Based Access Control for Bulletproof Endpoints

Role-based access control in Micronaut secures API endpoints. Implement JWT authentication, create custom roles, and use @Secured annotations. Configure application.yml, test endpoints, and consider custom annotations and method-level security for enhanced protection.

Secure Your Micronaut API: Mastering Role-Based Access Control for Bulletproof Endpoints

Implementing role-based access control (RBAC) in Micronaut applications is a crucial step in securing your API endpoints. As a developer who’s been working with Micronaut for years, I’ve found it to be a powerful and flexible framework for building microservices. Let’s dive into how we can add RBAC to our Micronaut apps and keep those endpoints locked down tight.

First things first, we need to set up our dependencies. In your build.gradle file, make sure you’ve got the following:

dependencies {
    implementation("io.micronaut.security:micronaut-security-jwt")
    implementation("io.micronaut:micronaut-validation")
}

This will give us the JWT and validation support we need for our RBAC implementation.

Now, let’s create a simple User model to represent our users:

import io.micronaut.core.annotation.Introspected;

@Introspected
public class User {
    private String username;
    private String password;
    private List<String> roles;

    // Getters and setters omitted for brevity
}

Next, we’ll need a way to authenticate our users. Let’s create a simple AuthenticationProvider:

import io.micronaut.http.HttpRequest;
import io.micronaut.security.authentication.*;
import io.reactivex.BackpressureStrategy;
import io.reactivex.Flowable;
import org.reactivestreams.Publisher;

import javax.inject.Singleton;
import java.util.ArrayList;
import java.util.List;

@Singleton
public class CustomAuthenticationProvider implements AuthenticationProvider {

    @Override
    public Publisher<AuthenticationResponse> authenticate(HttpRequest<?> httpRequest, AuthenticationRequest<?, ?> authenticationRequest) {
        return Flowable.create(emitter -> {
            if (authenticationRequest.getIdentity().equals("admin") &&
                authenticationRequest.getSecret().equals("password")) {
                List<String> roles = new ArrayList<>();
                roles.add("ROLE_ADMIN");
                emitter.onNext(new UserDetails((String) authenticationRequest.getIdentity(), roles));
                emitter.onComplete();
            } else {
                emitter.onError(new AuthenticationException(new AuthenticationFailed()));
            }
        }, BackpressureStrategy.ERROR);
    }
}

This is a very basic example, and in a real-world scenario, you’d want to check against a database or external authentication service. But for our purposes, this will do.

Now, let’s create a controller with some endpoints that we want to secure:

import io.micronaut.http.annotation.Controller;
import io.micronaut.http.annotation.Get;
import io.micronaut.security.annotation.Secured;
import io.micronaut.security.rules.SecurityRule;

@Controller("/api")
public class SecureController {

    @Get("/public")
    @Secured(SecurityRule.IS_ANONYMOUS)
    public String publicEndpoint() {
        return "This is a public endpoint";
    }

    @Get("/user")
    @Secured({"ROLE_USER", "ROLE_ADMIN"})
    public String userEndpoint() {
        return "This endpoint is accessible to users and admins";
    }

    @Get("/admin")
    @Secured("ROLE_ADMIN")
    public String adminEndpoint() {
        return "This endpoint is only accessible to admins";
    }
}

In this controller, we’ve defined three endpoints with different access levels. The @Secured annotation is key here - it’s how we specify which roles are allowed to access each endpoint.

To make all of this work, we need to configure our application. Create an application.yml file in your resources directory:

micronaut:
  application:
    name: secureApp
  security:
    authentication: bearer
    token:
      jwt:
        signatures:
          secret:
            generator:
              secret: '"${JWT_GENERATOR_SIGNATURE_SECRET:pleaseChangeThisSecretForANewOne}"'

This sets up JWT for our authentication. Remember to change the secret in a production environment!

Now, let’s test our RBAC implementation. We’ll need to get a JWT token first. Create a simple login endpoint:

import io.micronaut.http.annotation.Controller;
import io.micronaut.http.annotation.Post;
import io.micronaut.security.annotation.Secured;
import io.micronaut.security.rules.SecurityRule;
import io.micronaut.security.token.jwt.render.BearerAccessRefreshToken;

@Controller("/login")
public class AuthController {

    @Post
    @Secured(SecurityRule.IS_ANONYMOUS)
    public BearerAccessRefreshToken login(AuthenticationRequest authenticationRequest) {
        return new BearerAccessRefreshToken();
    }
}

With this setup, you can now test your endpoints. Here’s how you might do it using curl:

  1. Get a token:
curl -X POST -H "Content-Type: application/json" -d '{"username":"admin","password":"password"}' http://localhost:8080/login
  1. Use the token to access a secured endpoint:
curl -H "Authorization: Bearer YOUR_TOKEN_HERE" http://localhost:8080/api/admin

If everything is set up correctly, you should be able to access the admin endpoint with the admin token, but not with a user token or without a token.

Now, this is just scratching the surface of what’s possible with RBAC in Micronaut. In a real-world application, you’d want to implement more robust user management, perhaps integrate with an identity provider like OAuth2 or LDAP, and definitely improve your error handling and logging.

One thing I’ve found particularly useful is to create custom annotations for common role combinations. For example:

import io.micronaut.security.annotation.Secured;
import io.micronaut.core.annotation.Introspected;

import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;

@Secured({"ROLE_ADMIN", "ROLE_SUPER_USER"})
@Retention(RetentionPolicy.RUNTIME)
@Introspected
public @interface AdminOrSuperUser {
}

You can then use this annotation on your endpoints:

@Get("/important")
@AdminOrSuperUser
public String importantEndpoint() {
    return "This endpoint is for admins and super users";
}

This can make your code more readable and easier to maintain, especially if you have complex role structures.

Another tip: don’t forget about method-level security. You can apply the @Secured annotation to individual methods within a service, not just on controllers. This can be really useful for securing business logic that might be called from multiple endpoints.

@Singleton
public class UserService {
    @Secured("ROLE_ADMIN")
    public void deleteUser(String userId) {
        // Delete user logic here
    }
}

In my experience, it’s also a good idea to implement a custom AccessDeniedHandler to provide more informative responses when a user tries to access a resource they’re not authorized for. Here’s a simple example:

import io.micronaut.http.HttpRequest;
import io.micronaut.http.HttpResponse;
import io.micronaut.http.HttpStatus;
import io.micronaut.http.MutableHttpResponse;
import io.micronaut.security.authentication.AuthorizationException;
import io.micronaut.security.handlers.AccessDeniedHandler;

import javax.inject.Singleton;

@Singleton
public class CustomAccessDeniedHandler implements AccessDeniedHandler {

    @Override
    public MutableHttpResponse<?> handle(HttpRequest request, AuthorizationException exception) {
        return HttpResponse.status(HttpStatus.FORBIDDEN)
                .body("Access denied: You don't have the required role to access this resource");
    }
}

Remember, security is not just about implementing RBAC. It’s also crucial to follow other best practices like using HTTPS, properly handling and storing sensitive data, and regularly updating your dependencies to patch known vulnerabilities.

As you work with RBAC in Micronaut, you’ll likely encounter scenarios where you need more fine-grained control. For instance, you might want to allow users to only access their own data. In these cases, you can implement custom security rules.

Here’s an example of a custom security rule that checks if the authenticated user is accessing their own profile:

import io.micronaut.http.HttpRequest;
import io.micronaut.security.rules.SecurityRule;
import io.micronaut.security.rules.SecurityRuleResult;
import io.micronaut.web.router.RouteMatch;
import org.reactivestreams.Publisher;
import reactor.core.publisher.Mono;

import javax.inject.Singleton;
import java.util.Map;

@Singleton
public class OwnProfileSecurityRule implements SecurityRule {

    @Override
    public Publisher<SecurityRuleResult> check(HttpRequest<?> request, RouteMatch<?> routeMatch, Map<String, Object> claims) {
        if (routeMatch.getVariables().containsKey("userId") && claims.containsKey("sub")) {
            String requestedUserId = routeMatch.getVariables().get("userId", String.class).orElse(null);
            String authenticatedUserId = (String) claims.get("sub");
            
            if (requestedUserId != null && requestedUserId.equals(authenticatedUserId)) {
                return Mono.just(SecurityRuleResult.ALLOWED);
            }
        }
        return Mono.just(SecurityRuleResult.UNKNOWN);
    }
}

You can then use this custom rule in your controller:

@Get("/users/{userId}")
@Secured({"ROLE_USER", "OwnProfileSecurityRule"})
public String getUserProfile(String userId) {
    // Fetch and return user profile
}

This ensures that users can only access their own profiles, even if they have the ROLE_USER role.

As your application grows, you might find yourself needing to manage a large number of roles and permissions. In such cases, it can be helpful to implement a permission-based system on top of your role-based system. This allows for more granular control and makes it easier to manage complex access scenarios.

Here’s a simple example of how you might implement this:

import io.micronaut.core.annotation.Introspected;

@Introspected
public class Permission {
    private String name;
    private String description;

    // Getters and setters omitted for brevity
}

@Introspected
public class Role {
    private String name;
    private List<Permission> permissions;

    // Getters and setters omitted for brevity
}

@Singleton
public class PermissionService {
    private Map<String, Role> roles = new HashMap<>();

    public PermissionService() {
        // Initialize roles and permissions
        Role adminRole = new Role();
        adminRole.setName("ROLE_ADMIN");
        adminRole.setPermissions(Arrays.asList(
            new Permission("CREATE_USER", "Can create new users"),
            new Permission("DELETE_USER", "Can delete users"),
            new Permission("VIEW_ALL_USERS", "Can view all users")
        ));
        roles.put("ROLE_ADMIN", adminRole);

        Role userRole = new Role();
        userRole.setName("ROLE_USER");
        userRole.setPermissions(Arrays.asList(
            new Permission("VIEW_OWN_PROFILE", "Can view own profile")
        ));
        roles.put("ROLE_USER", userRole);
    }

    public boolean hasPermission(List<String> userRoles, String requiredPermission) {
        return userRoles.stream()
            .map(roles::get)
            .filter(Objects::nonNull)
            .flatMap(role -> role.getPermissions().stream())
            .anyMatch(permission -> permission.getName().equals(requiredPermission));
    }
}

You can then use this service in your controllers or services to check for specific permissions:

@Singleton
public class UserService {
    @Inject
    private PermissionService permissionService;

    @