Written by Federico Karabogosian & Emmanuel Thioux
Since its founding in 2015, CyberCube has been using a very simple licensing scheme. This has worked for us because we had two easy-to-handle products. Essentially, we had a number of licenses per product and for different roles (namely to support our Portfolio Manager and Account Manager applications.
Over the past 18 months, we have added new products such as Broking Manager and CyberConnect (API) which showed the limitations of our licensing system. We decided to build a service dedicated to handling licenses.
This blog outlines some of the steps we took to build a new licensing service. We aimed to create a flexible, unique source of truth to manage the licensing model and the tenants’ configurations. We needed to identify all the concepts and properties that build up our Licensing Model and the Tenant Model to achieve that.
Then we decided to utilise a datasource that enables us to represent the relation between tenants, products, roles, bundles and users. AWS Neptune was the choice for us since we already use it for the User Collaboration Service. Instead of relying on Cognito’s shallow group structure, we implemented a graph that allows for more granularity and flexibility when it comes to authorization.
Exhibit 1 shows a simple architecture in which a set of services or applications are behind an API Gateway. The user will interact with those services via the Web UI and after authentication, the Authz Lambda (Authorization Lambda) will allow or deny requests, based on the role(s) the logged-on user has.
Exhibit 1
The authorization Lambda normally just checks the Cognito groups the user belongs to. Then, it checks whether the user can access the path contained in the URI.
We used regular expressions such as this*:
"^(.*?)(@verb\/admin\/),^(.*?)(@verb\/oss\/)"
*The @verb is replaced with the HTTP method used for the call.
If the path requested is included in the RegEx, the call is authorized. Nothing else was involved in the authorization until we switched to a more flexible approach.
Since we now have to handle different types of licenses, such as for Broking Manager, which limit the number of analyses run by users, we decided to handle it with the authorization Lambda.
We have kept the license information in AWS Neptune, in the same way we store users and objects for the User Collaboration Service. A graph database allows us to have greater flexibility with establishing relationships between users and the licenses their company purchased.
Exhibit 2 shows how the relationships are created within Neptune:
Exhibit 2
We’ve discussed how the authorizer would look at the request ARN, which appears like this:
arn:aws:execute-api:us-east-1:000000000000:xf6ghlkfrz/stage/POST/telemetry/)
Match it against the regular expressions that apply to different roles, and if a match is found, we would return the authorization context with “action” : “Allow”
With the use of Neptune, we no longer store the regular expressions that apply to the roles (which are Cognito groups). Instead, we look up the user in Neptune and retrieve its role(s). Each role has an applies_to edge that points to a product or addon. The product or addon has a path property set that contains all the accessible paths.
For instance, if we look at Portfolio Manager, we can see that the property was created like this (Exhibit 3):
Exhibit 3
g.addV("product").property("type", "PM").property("name", "Portfolio Manager").
property("sku", "PM").
property("path", "^(.*?)(@verb\\/cat\\/)").
property("path", "^(.*?)(@verb\\/portfolio\\/)").
property("path", "^(.*?)(@verb\\/platform\\/)").
property("cognitoGroup", "PORTFOLIOMANAGER").next()
We will try to match the request ARN with regular expressions.
There is an extra set of paths that are stored in a special vertex so that those locations may be accessed by any authenticated user.
Our tests indicate that the authorizer will run for approximately 20 milliseconds. This is fast because the connection to Neptune is kept alive between calls (the container that hosts the Lambda function does not go down).
Additionally, looking up users via Neptune is a very quick operation, as it is a simple query that looks like this (see Exhibit 4):
Exhibit 4
g.V().has('tenant', 'CognitoPoolID', 'us-east-2_ABCdEfGHi')
.in('user')
.has('email', 'Wile-E.Coyote@acme.com')
.valueMap();
By making these changes, we have made the process of authorization more flexible and more scalable. As mentioned earlier, originally we would store the paths and groups in the Lambda’s environment variables, but there’s a limit to how many variables you can have, and making a change would force us to re-deploy the authorization Lambda on all environments.
By using Neptune, the authorizer can easily look up the user requesting access to a resource, and based on the roles, allow or deny the request.
A big security issue is that the JWT generated by Cognito does not change during a session, and if while a user is logged in, her access to some resource gets revoked, she would still be able to access it as long as the token is valid.
With Neptune, the roles are independent of the Cognito Groups and even if the token remains the same, the authorizer can make the right decision based on the latest data in the database.