A big challenge with API based microservices architecture is handling authentication (authN) and authorization (authZ) . If you are like most companies today, you are probably using some sort of OAuth identity provider like OpenID, Google, GitHub, etc. This takes care of both identity and authentication, but authorization (AuthZ) is not addressed by this.
In our previous blog posts, we discussed two REST API best practices for making one Database call per API route and assembling complex objects that need to be displayed in the UI. In response, one of our readers asked a great question: If the design pattern is to always make one DB call per API route and then handle joins in the UI to create complex objects, how do we manage authorization/permissions? With a finished API, you can abstract it across the lower level APIs.
This blog describes pros and cons of two options we considered for handling authZ and why we chose the approach we did. Our two possible approaches were:
- Create a user on the DB for every single user who interacted with our service and manage all permissions at the DB level
- Create a superuser DB account that has “data modification access” and no “data definition access,” and use that account to access data
We were initially hesitant to go with option 2 since it meant accessing all data with superuser credentials, which felt like we weren't enforcing permissions at the lowest level we could.
Let's look at both options in greater detail.
Option 1—Create Users on the DB
First, we considered creating a user in the database for each user who interacted with our service and managing all permissions at the DB level. This approach seemed superior as it provides data level security at the lowest layer- storage.
It does however create a few challenges:
- Our DB server would be flooded with too many accounts, leading to scale issues.
- For each user, we would need to maintain unique passwords. This means maintaining an access control list (ACL) somewhere other than the main DB server.
- We would also need superuser access to this access control list. The DB connection string would change dynamically depending on which user is making a call to the API.
- To maintain some sort of mapping between tokens and users in this ACL, we would need to reverse engineer a user from an OAuth token to get the DB connection string, which again needs superuser access.
As you can see, option 1 is painful to implement. We needed an ACL DB and would need to create dynamic connection strings for every single call,. This can lead to performance issues as the number of calls to the DB increase.
But even more problematic was the need for super user access to the ACL DB. Our initial rationale for going with this approach so that no single user could escalate their privileges and access data they are not supposed to access. However, since we would need an ACL DB and all users would need to connect to it to find the connection string to the DB, we would need to impersonate a superuser account that has access to the ACL DB.
This meant that option 1 wasn't really more secure than option 2, and yet needed a lot of new work.So we moved on to Option 2.
Option 2—Create a Superuser DB Account
Here is how we implemented this approach:
- First and foremost, we committed to making one database call per API route. This is important to implement a clean, successful REST API.
- Every object in our DB was owned by some other object; i.e., accounts had subscriptions, subscriptions had projects, and projects had tasks. We believe that the most important foundational aspect of microservices is data modeling. Unless your model is designed this way, it’s almost impossible to recover from the problems you will run into without a rewrite of your platform.
- We then implemented permissions at the account, subscription, and project levels. Every object in the DB is owned by the most granular level object as the parent. For example, a task is owned by a project, but since projects are owned by subscriptions, tasks will also be owned by subscriptions by transference. In the data model, we added all keys up to the highest level possible; i.e., a task object has a projectId and subscriptionId, but not accountId, since a single subscription can be owned by multiple accounts.
- Next, we created a simple Role definition. In our case, it was Owner, Collaborator and Member. These roles can be as granular as you need them to be. The idea here is that you can add different HTTP verb authorizations to these roles at a DB object level. This will make sense as you read further.
- We created a route object that had the following properties:
- ResourceURL, for example, /projects/id
- Http verbs PUT, POST, GET
- PermittedRoles (which roles could access this object)
With just this, we could check if the caller token was a member and, when making a GET call to /project/id, whether or not they were permitted to make the call. This is the building block of our permissions system.
- At each object level at which we enforced authorizations, we created an object. In this case, objects were created for AccountPermissions, SubscriptionPermissions and ProjectPermissions. Each of the objects followed the same structure (ObjectId account, Sub or projId, AllowedAccountId-the accounts that are allowed to make calls and the roles they are allowed to play in; i.e., for a particular subscription-Acct1 and Acct2 are Owners, Acct3 is a collaborator, and Acct4 is a member). We manage this list through UI, and let users add and remove other accounts with roles. A large part of the process is now complete.
- The above created authorization models can now be used in the workflow. We call this the validateCaller module. This module is injected into every API call made in this system. What validateCaller does is very simple:
- It first retrieves the Account object of the token that was presented in the authorization header.
- With this Account, it checks what route was called. Let’s say /projects/id.
- Then, it gets permissions for this Account on the projectId object.
- Next, it queries the route objects based on the resourceURL being accessed, filters by HTTP method, and finds all the roles that have permissions to make this call.
- Now, these can be intersected with roles from the route query set. If the intersection is 0, then the user is not allowed to make the call. If the intersection is > 0, control is passed to the route handler, which performs the logic of the API.
- Make sure the resource URL id is never replaced in the route handler, as that will cause a security breach. If you follow this process, your authorization is performed with impersonation and there is really no way to break out.
- Lastly, we cached routes and permissions objects and avoided making DB calls when possible to improve performance.
With this ReST best practice, you’ll find authorizations and permissions managed at the level you desire. With options to choose from, you gain a valuable understanding and select the best methods possible for managing your networked applications.