Using custom annotations to secure your API in Kotlin
In the following post, I’ll discuss my challenges as a backend engineer at HiBob around Kotlin microservice API permission handling, including, how we handled it at first, and the problems we had with our initial implementation that led us to our current infrastructure pattern. I’ll describe this new pattern and its advantages, and provide code examples to elaborate on use cases. My examples use JAX-RS but the concept is general and can apply to any HTTP server technology.
The permissions issue
Shortly after we started implementing one of Bob’s features in a new Kotlin microservice, we faced some challenging requirements for our API permissions and access control. We had many different permission cases for different APIs, for example:
- Public access by anyone (anonymous)
- Access allowed for all logged-in users
- Restricted access for users with specific permissions (system-wide)
- Restricted access for logged-in users with a specific role that is internal to the microservice
- Restricted access for superusers (a user with a specific flag)
The initial solution
We started simple. We used Request Filters (Java ContainerRequestFilter) to resolve the user from the request (JWT) and check the permissions of all APIs before the actual API function is invoked. Request Filters give us this ability, they contain a method which is called before a request has been dispatched to the actual code that handles it. Our API calls were filtered by checking their paths. For example, for every path that starts with “/settings” we verified that the user had settings permission. In this solution, the filtering was in a different file than the actual API function.
The problems with our initial solution
We soon realized that this solution is not good enough for our needs. It was confusing and not scalable, and we encountered bugs. For instance, every time we added a new API, we had to remember to add instructions to filter that API in a separate file too (the filter), which we sometimes forgot, resulting in unauthorized people accessing APIs they shouldn’t have — for example, people accessing privileged APIs without proper permissions.
In another case, we created a new API that fell into one of the existing filter conditions. An API that should have been accessible by all logged-in users was incorrectly caught by the admin path condition, so non-admin logged-in users could not access it.
Our team decided to find a better solution for the permissions issue, to prevent these problems.
The new infrastructure using annotations
I considered mark permissions conditions on the API function somehow, and removing the path conditions logic from the Filters, when the idea of AOP (Aspect-oriented programming) annotations came to me. I remembered that I implemented some infrastructure using AOP at the first startup I worked for, and it was one of the tasks I enjoyed most, but there it mainly solved some logging issues. I searched for existing AOP solutions but none of my findings were flexible enough for our needs.
We decided to use a combination of AOP and Request Filters. Using AOP, new behavior can be added to existing code without modifying it. Annotations allow us to declare the permission rules on top of an API function or on top of a Resource (aka controller) class, and then handle the logic in the Filters.
We created a new annotation called Secured which holds several attributes:
- authentication — declares the authentication type, so we’ll know how to resolve the user from the request. For example in Bob we have superuser (bob developer), user (user of the system) and unauthenticated user (anonymous).
- permissions — defines what permissions should be defined for the user. For example, in Bob a user can be defined with permissions to view or edit a specific feature setting
- roles — for checking specific feature roles
The attribute types are enums or a list of enums containing all available options. For example, if we want to restrict API access to logged-in users who have the manage settings permission, and have a specific feature role, we will add that to the Resource class or to the relevant API function:
How it works
We fetch the annotation data from the routing context using this function, where the routing context is part of the container request context:
Following the separation of concerns principle, we wanted to separate the permissions code into small separate units of logic, so we split the logic into several different filters - a filter for resolving user data from the request and storing it in the request scope (using a ThreadLocal), a filter for determining permissions (which relies on the previously fetched user data), and a filter for verifying service-specific roles. The filter logic is skipped if the annotation doesn’t require the relevant permission. On an API call, all defined filters run according to their priority order (defined as @Priority on the Filter class) so that the filter that populates the request scope with user data runs first.
The filter that does the actual permission validation looks something like this:
Testing the new infrastructure
What if someone created a new resource and forgot to add the secured annotation? We wouldn’t check for the relevant permissions for the APIs in that resource. To make sure no one forgets to define permissions for new APIs, I added a unit test that uses the org.reflections library to scan all relevant classes (annotated with @Path) and makes sure that all resources defined Secured annotations. If an annotation is missing, the test will fail.
Conclusion
The permissions solution using annotations solved our previously mentioned problems, and the non-service-specific part of it is now used in our Kotlin project template, which serves as the baseline for all new Kotlin microservices. Each service can extend the annotation according to its needs. Although my examples use Kotlin, the solution is not Kotlin-specific and can also be implemented in Java or other JVM languages supporting annotations.