Implementing RBAC in Quarkus
Explore a detailed article on implementing Role-Based Access Control (RBAC) in Quarkus. It reviews both built-in and custom methods to ensure secure REST API access.
Join the DZone community and get the full member experience.
Join For FreeREST APIs are the heart of any modern software application. Securing access to REST APIs is critical for preventing unauthorized actions and protecting sensitive data. Additionally, companies must comply with regulations and standards to operate successfully.
This article describes how we can protect REST APIs using Role-based access control (RBAC) in the Quarkus Java framework. Quarkus is an open-source, full-stack Java framework designed for building cloud-native, containerized applications. The Quarkus Java framework comes with native support for RBAC, which will be the initial focus of this article. Additionally, the article will cover building a custom solution to secure REST endpoints.
Concepts
- Authentication: Authentication is the process of validating a user's identity and typically involves utilizing a username and password. (However, other approaches, such as biometric and two-factor authentication, can also be employed). Authentication is a critical element of security and is vital for protecting systems and resources against unauthorized access.
- Authorization: Authorization is the process of verifying if a user has the necessary privileges to access a particular resource or execute an action. Usually, authorization follows authentication. Several methods, such as role-based access control and attribute-based access control, can be employed to implement authorization.
- Role-Based Access Control: Role-based access control (RBAC) is a security model that grants users access to resources based on the roles assigned to them. In RBAC, users are assigned to specific roles, and each role is given permissions that are necessary to perform their job functions.
- Gateway: In a conventional software setup, the gateway is responsible for authenticating the client and validating whether the client has the necessary permissions to access the resource. Gateway authentication plays a critical role in securing microservices-based architectures, as it allows organizations to implement centralized authentication.
- Token-based authentication: This is a technique where the gateway provides an access token to the client following successful authentication. The client then presents the access token to the gateway with each subsequent request.
- JWT: JSON Web Token (JWT) is a widely accepted standard for securely transmitting information between parties in the form of a JSON object. On successful login, the gateway generates a JWT and sends it back to the client. The client then includes the JWT in the header of each subsequent request to the server. The JWT can include required permissions that can be used to allow or deny access to APIs based on the user's authorization level.
Example Application
Consider a simple application that includes REST APIs for creating and retrieving tasks.
The application has two user roles:
- Admin: Allowed to read and write.
- Member: Allowed to read only.
Admin and Member can access the GET API; however, only Admins are authorized to use the POST API.
@Path("/task")
public class TaskResource {
@GET
@Produces(MediaType.TEXT_PLAIN)
public String getTask() {
return "Task Data";
}
@POST
@Produces(MediaType.TEXT_PLAIN)
public String createTask() {
return "Valid Task received";
}
}
Configure Quarkus Security Modules
In order to process and verify incoming JWTs in Quarkus, the following JWT security modules need to be included.
For a maven-based project, add the following to pom.xml
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-smallrye-jwt</artifactId>
</dependency>
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-smallrye-jwt-build</artifactId>
</dependency>
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-test-security-jwt</artifactId>
<scope>test</scope>
</dependency>
For a gradle-based project, add the following:
implementation("io.quarkus:quarkus-smallrye-jwt")
implementation("io.quarkus:quarkus-smallrye-jwt-build")
testImplementation("io.quarkus:quarkus-test-security-jwt")
Implementing RBAC
Quarkus provides built-in RBAC support to protect REST APIs based on user roles. This can be done in a few steps.
Step 1
The first step in utilizing Quarkus' built-in RBAC support is to annotate the APIs with the roles that are allowed to access them. The annotation to be added is @RolesAllowed
, which is a JSR 250 security annotation that indicates that the given endpoint is accessible only if the user belongs to the specified role.
@GET
@RolesAllowed({"Admin", "Member"})
@Produces(MediaType.TEXT_PLAIN)
public String getTask() {
return "Task Data";
}
@POST
@RolesAllowed({"Admin"})
@Produces(MediaType.TEXT_PLAIN)
public String createTask() {
return "Valid Task received";
}
Step 2
The next step is to configure the issuer URL and the public key. This enables Quarkus to verify the JWT and ensure it has not been tampered with. This can be done by adding the following properties to the application.properties
file located in the /resources
folder.
mp.jwt.verify.publickey.location=publicKey.pem
mp.jwt.verify.issuer=https://myapp.com/issuer
quarkus.native.resources.includes=publicKey.pem
mp.jwt.verify.publickey.location
- This configuration specifies the location of the public key to Quarkus, which must be located in the classpath. The default location Quarkus looks for is the/resources
folder.mp.jwt.verify.issuer
- This property represents the issuer of the token, who created it and signed it with their private key.quarkus.native.resources.includes
- this property informs quarks to include the public key as a resource in the native executable.
Step 3
The last step is to add your public key to the application. Create a file named publicKey.pem
, save the public key in it. Copy the file to the /resources
folder located in the /src
directory.
Testing
Quarkus offers robust support for unit testing to ensure code quality, particularly when it comes to RBAC. Using the @TestSecurity
annotation, user roles can be defined, and a JWT can be generated to call REST APIs from within unit tests.
@Test
@TestSecurity(user = "testUser", roles = "Admin")
public void testTaskPostEndpoint() {
given().log().all()
.body("{id: task1}")
.when().post("/task")
.then()
.statusCode(200)
.body(is("Valid Task received"));
}
Custom RBAC Implementation
As the application grows and incorporates additional features, the built-in RBAC support may become insufficient. A well-written application allows users to create custom roles with specific permissions associated with them. It is important to decouple roles and permissions and avoid hardcoding them in the code. A role can be considered as a collection of permissions, and each API can be labeled with the required permissions to access it.
To decouple roles and permissions and provide flexibility to users, let’s expand our example application to include two permissions for tasks.
task:read
: Permission would allow users to read taskstask:write
: Permission would allow users to create or modify tasks.
We can then associate these permissions with the two roles: "Admin" and "Member"
- Admin: Assigned both read and write.
["task:read", "task:write"]
- Member: Would only have read.
["task:read"]
Step 1
To associate each API with permission, we need a custom annotation that simplifies its usage and application. Let's create a new annotation called @Permissions
, which accepts a string of permissions that the user must have in order to call the API.
@Target({ ElementType.METHOD })
@Retention(RetentionPolicy.RUNTIME)
public @interface Permissions {
String[] value();
}
Step 2
The @Permissions
annotation can be added to the task APIs to specify the required permissions for accessing them. The GET task API can be accessed if the user has either task:read
or task:write
permissions, while the POST task API can only be accessed if the user has task:write
permission.
@GET
@Permissions({"task:read", "task:write"})
@Produces(MediaType.TEXT_PLAIN)
public String getTask() {
return "Task Data";
}
@POST
@Permissions("task:write")
@Produces(MediaType.TEXT_PLAIN)
public String createTask() {
return "Valid Task received";
}
Step 3
The last step involves adding a filter that intercepts API requests and verifies if the included JWT has the necessary permissions to call the REST API. The JWT must include the userID as part of the claims, which is the case in a typical application since some form of user identification is included in the JWT token
The Reflection API is used to determine the method and its associated annotation that is invoked. In the provided code, user -> role
mapping and role -> permissions
mapping are stored in HashMaps. In a real-world scenario, this information would be retrieved from a database and cached to allow for faster access.
@Provider
public class PermissionFilter implements ContainerRequestFilter {
@Context
ResourceInfo resourceInfo;
@Inject
JsonWebToken jwt;
@Override
public void filter(ContainerRequestContext requestContext) throws IOException {
Method method = resourceInfo.getResourceMethod();
Permissions methodPermAnnotation = method.getAnnotation(Permissions.class);
if(methodPermAnnotation != null && checkAccess(methodPermAnnotation)) {
System.out.println("Verified permissions");
} else {
requestContext.abortWith(Response.status(Response.Status.FORBIDDEN).build());
}
}
/**
* Verify if JWT permissions match the API permissions
*/
private boolean checkAccess(Permissions perm) {
boolean verified = false;
if(perm == null) {
//If no permission annotation verification failed
verified = false;
} else if(jwt.getClaim("userId") == null) {
// Don’t support Anonymous users
verified = false;
}
else {
String userId = jwt.getClaim("userId");
String role = getRolesForUser(userId);
String[] userPermissions = getPermissionForRole(role);
if(Arrays.asList(userPermissions).stream()
.anyMatch(userPerm -> Arrays.asList(perm.value()).contains(userPerm))) {
verified = true;
}
}
return verified;
}
// role -> permission mapping
private String[] getPermissionForRole(String role) {
Map<String, String[]> rolePermissionMap = new HashMap<>();
rolePermissionMap.put("Admin", new String[] {"task:write", "task:read"});
rolePermissionMap.put("Member", new String[] {"task:read"});
return rolePermissionMap.get(role);
}
// userId -> role mapping
private String getRolesForUser(String userId) {
Map<String, String> userMap = new HashMap<>();
userMap.put("1234", "Admin");
userMap.put("6789", "Member");
return userMap.get(userId);
}
}
Testing
In a similar way to testing the built-in RBAC, the @TestSecurity
annotation can be utilized to create a JWT for testing purposes. Additionally, the Quarkus library offers the @JwtSecurity
annotation, which enables the addition of extra claims to the JWT, including the userId claim.
@Test
@TestSecurity(user = "testUser", roles = "Admin")
@JwtSecurity(claims = {
@Claim(key = "userId", value = "1234")
})
public void testTaskPosttEndpoint() {
given().log().all()
.body("{id: task1}")
.when().post("/task")
.then()
.statusCode(200)
.body(is("Task edited"));
}
@Test
@TestSecurity(user = "testUser", roles = "Admin")
@JwtSecurity(claims = {
@Claim(key = "userId", value = "6789")
})
public void testTaskPostMember() {
given().log().all()
.body("{id: task1}")
.when().post("/task")
.then()
.statusCode(403);
}
Conclusion
As cyber-attacks continue to rise, protecting REST APIs is becoming increasingly crucial. A potential security breach can result in massive financial losses and reputational damage for a company. While Quarkus is a versatile Java framework that provides built-in RBAC support for securing REST APIs, its native support may be inadequate in certain scenarios, particularly for fine-grained access control. The above article covers both the implementation of the built-in RBAC support in Quarkus as well as the development and testing of a custom role-based access control solution in Quarkus.
Opinions expressed by DZone contributors are their own.
Comments