Fine Grained Authorization
When we are working on API security design, multiple layers need to be carefully considered. Just like an onion, you can peel multiple layers until you reach the core.
First, the JWT verifier handler will ensure that the authorization header has a valid, unexpired token with the correct signature. It will allow the consumer to invoke the API at the API level.
Second, the JWT verifier also validates the scope in the JWT token against the scopes defined in the OpenAPI specification to ensure the JWT token has access to the current endpoint. This is the endpoint level security.
The above two layers of security happen at the technical level, and it is common to any API and organization. A standard implementation has been done in the security module in the light-4j open-source repository.
After the JWT verifier handler verifies the request, the security check goes into the business domain, and this will be different from API to API and organization to organization.
The following design is based on the generic requirements for financial organizations like banks. You can follow the same pattern or customize it for other organizations if the security requirement is different.
The fine-grained authorization for APIs is very complicated, and traditional role-based and attribute-based authorization cannot fulfill the requirement. You need to run a list of business rules to calculate if a user has permission for an API request. Let’s take a look at the rule-based authorization requirement, design and implementation.
Let’s list several common requirements about rule-based authorization at the API endpoint level.
Allow Client Credentails Token access even without user_id claim. However, a sid (serviceId) claim is not null and it equals to one of the values in the request (a path parameter or a property in the body). This is for API to API invocation. For example, during the API startup, it register to the controller and access config server to get its configuration.
Allow certain roles to access. For example, only manager role can create a new account.
Allow certain roles to access their own data. For example, a user role can update only his/her user profile.
- A manager role can see extra data elements than a user role. For example, a manager returns 27 columns and a teller can only return 16 columns for customer account query.
- A manager role can see more rows than a user role. For example, a manager can see all accounts for a customer and a teller can only see customers personal accounts.
The most important thing we have to resolve is how the service get the rule-based authroization definition at runtime. Like the scopes definition for endpoints, the best location would be the OpenAPI specification as light-4j is using it at the runtime. However, the current specification and tool chains don’t support it at the moment and we have to use the extension of OpenAPI. We are lucky to have our own implementation of OpenAPI parser to parse the extension at runtime and use the cached information to perform role-based authorization.
paths: /accounts: get: summary: "List all accounts" operationId: "listAccounts" x-request-access: rule: "account-cc-group-role-auth" roles: "manager teller customer" x-response-filter: rule: "account-row-filter" teller: status: open customer: status: open owner: @user_id rule: "account-col-filter" teller: ["num","owner","type","firstName","lastName","status"] customer: ["num","owner","type","firstName","lastName"] security: - account_auth: - "account.r"
We have a rule engine called yaml-rule and the rule we are using is defined as the following for the x-request-access
account-cc-group-role-auth: ruleId: account-cc-group-role-auth host: lightapi.net description: Role-based authorization rule for account service and allow cc token and transform group to role. conditions: - conditionId: allow-cc variableName: auditInfo propertyPath: subject_claims.ClaimsMap.user_id operatorCode: NIL joinCode: OR index: 1 - conditionId: manager variableName: auditInfo propertyPath: subject_claims.ClaimsMap.groups operatorCode: CS joinCode: OR index: 2 conditionValues: - conditionValueId: manager conditionValue: admin - conditionId: teller variableName: auditInfo propertyPath: subject_claims.ClaimsMap.groups operatorCode: CS joinCode: OR index: 3 conditionValues: - conditionValueId: teller conditionValue: frontOffice - conditionId: allow-role-jwt variableName: auditInfo propertyPath: subject_claims.ClaimsMap.roles operatorCode: NNIL joinCode: OR index: 4 actions: - actionId: match-role actionClassName: com.networknt.rule.FineGrainedAuthAction actionValues: - actionValueId: roles value: $roles
All rules are managed by the light-portal and shared by all the services.
In light-4j, we have a startup hook in the rule-loader module to load the rules for the service during the startup.
In the light-rest-4j we have a module called access-control that is responsible for the fine-grained access control.