Attribute-Based Access Control (ABAC) is a modern method of securing resources in applications. Unlike traditional Role-Based Access Control (RBAC), ABAC evaluates access rights based on user attributes, making it a more dynamic and flexible approach to security.
In this blog, we will explore a simple implementation of ABAC using TypeScript, demonstrating how to easily integrate it into your application for better security management.
Scenario
Imagine a fairly large company with multiple departments that are divided into teams (e.g. based on products or functions). Each team has members with different positions.
- Regularly a member of a division is allowed to access the resources of the division they are in
- Technical documentation and best practices
- Code libraries, development tools, and testing equipment
- Some resources are shared between divisions of the same department
- Physical resources such as meeting rooms, equipment, and tools
- Information resources such as databases, software, and technology
- Customer data and research information
- Other resources are shared with all departments of the company
- Invitations to "Townhall" meetings
- Guidelines on how to use the printers
- Policies for the usage company cars or parking spots
- There are subjects (people, for example), who require access to all or specific resources of two independent departments
- HR staff may need access to selected financial reports to plan for hiring
- Resources are independent of a person's position in the hierarchy, but are assigned based on the person's job function
- Staff engineers of different departments should have access to performance reviews of all software engineers
As you can see, covering all of the above use cases requires many nested groups that must be maintained and expanded over time.
Role-Based Access Control
Role-Based Access Control (RBAC) is a widely used method of access control in which access rights are assigned based on an individual's role in an organization.
Lack of Flexibility
RBAC is based on predefined roles, which can lead to inflexible and rigid access management. As business requirements change, the roles and access rights need to be updated accordingly, leading to a complex and time-consuming maintenance process.
Every time a new employee is on- or offboarded, they need to be added or removed from a set of groups they were added to. It is necessary to keep track of the groups every member of a team should be part of.
Security Concerns
Persons are added to additional groups, when they move to a new job function or get promoted. But rarely are they removed from existing groups that are no longer relevant to that person. This results in individuals with longer tenure gaining more and more access rights. It violates security best practices such as the principle of least privilege.
Complexity
Additionally, RBAC does not take into account the attributes of individual users, such as location, time of day, or clearance level, making it less effective in dynamic and complex environments.
In order to deal with the complexity more roles are introduced which leads to the problem of exploding roles. It occurs when the number of roles and permissions becomes too large and difficult to manage. This can happen when organizations try to assign specific permissions to each individual or when they assign many different roles to a single individual. The result is an excessive number of roles, which makes it difficult to manage access rights effectively and increases the risk of security breaches.
In the given scenario, all kinds of nested roles would be required, describing the different enterprise levels, job functions, additional specialized groups, groups spanning multiple departments, etc. An implementation would have to consider the combination of groups (e.g. department and function) or the hierarchy of groups (is a subject not part of a group but part of the parent group) which only adds to the complexity.
It gets even more complicated when permissions are mixed with group memberships, e.g. "Read only", "Commentators" or "Write access".
Fine-grained Access to Resources
It can be challenging to limit the resources each individual in a group has access to. Since individuals are assigned access rights based on their role, it can be difficult to ensure that they do not have access to sensitive resources they should not have access to. This leads to a lack of fine-grained control over user access and can pose a security risk to the organization.
Attribute-based Access Control (ABAC)
RBAC makes a lot of sense, when the authorization concept is fairly straight forward. This is true, for example, for applications that distinguish between only a handful of easily distinguishable groups without relationships, such as "admin" and "user".
In Attribute-based Access Control (ABAC), a policy defines the conditions under which access to a resource is granted or denied. It specifies the attributes (such as role, location, time, etc.) of the user and the resource, and the relationship between them that must be satisfied for access to be granted.
Permissions define the specific operations that are allowed when access is granted. When a user attempts to perform an operation on a resource, the ABAC system evaluates the relevant policies to determine whether the user's attributes satisfy the conditions for access and, if so, what specific permissions the user has been granted.
Implementation
Subjects and Resources
Let's use persons as subjects and documents as resources that are accessed by persons:
class Person {
constructor(id, position, team) {
this.id = id
this.position = position
this.team = team
}
}
class Document {
constructor(id, type, owner) {
this.id = id
this.type = type
this.owner = owner
}
}
We define two requirements for accessing the documents of the finance department:
- Every member of the finance department has
READ
,WRITE
andCOMMENT
permissions. Everything that is created by one member of the finance department, is fully accessible by their colleagues. - Every person with the position
MANAGER
hasREAD
andCOMMENT
permissions. Every manager, independent of their department, can read and comment on a document, that is created by one of the employees working in the finance department.
Policies
class TeamPolicy {
static getPermissions(subject, resource) {
if (subject.team === resource.owner.team) {
return ['READ', 'WRITE', 'COMMENT']
}
return []
}
}
The TeamPolicy
takes the attributes of the requesting person (the subject) and the document (the resource) into account. Only if the person's team matches the team of the person who created the document, it gets full access to the document (including WRITE
permissions).
What's important to note is, that the policy is very generic: Although it could specify the finance department, it rather matches the resource owner's team. The documents of a person of the finance department are only fully accessible by other colleagues of the same department. The same would work for documents published by members of the marketing department etc.
However, this policy could cause problems if the resource owner's team changes.
class ManagerPolicy {
static getPermissions(subject) {
if (subject.position === 'MANAGER') {
return ['READ', 'COMMENT']
}
return []
}
}
The ManagerPolicy
completely ignores the attributes of the resource and only considers the position of the subject.
If the position of the requesting person changes, and its team is different from the one of the resource owner, it will lose access to the document.
This solves the maintainability and security issues mentioned earlier: Since a person can hold only one position, which changes when they are promoted or move to another team, there is no need to add or remove groups from a person.
Persons
Peter is the person who creates the document we want to access. He is an analyst in the finance department:
const PeterFromFinance = new Person('peter', 'ANALYST', 'FINANCE')
Caroline is one of his direct colleagues. She's an account in the same department:
const CarolineFromFinance = new Person('caroline', 'ACCOUNTANT', 'FINANCE')
Mary, on the other hand, comes from marketing. She is a manager there:
const MaryFromMarketing = new Person('mary', 'MANAGER', 'MARKETING')
Marcus is an engineer working on the "chat" app and shouldn't have access to our document:
const MarcusFromChatTeam = new Person('macrus', 'ENGINEER', 'CHAT')
They are all registered as subjects:
const subjects = {
peter: PeterFromFinance,
caroline: CarolineFromFinance,
mary: MaryFromMarketing,
marcus: MarcusFromChatTeam
}
Documents
We have only one document, which is a report prepared by Peter:
const report = new Document(
'marketing_finance_report_2022',
'report',
PeterFromFinance
)
It is the only resource in the map of resources:
const resources = {
marketing_finance_report_2022: report
}
Middleware
Assuming that a user sends an HTTP request to our server, there is a middleware that parses the request. It takes the userId
to identify the person requesting a resource under the given id
.
The subject and the resource are loaded to make their attributes available. They are then passed to the policies that should apply in that case before the request is passed to the controller that generates the response.
The idea of a context and middleware borrows heavily from the foundations of the koa.js framework. In short, the context is an object filled with information relevant to the request and response processed by a stack of middlewares. That's why the resource and permissions are attached to the context (ctx
) object. It is then forwarded to the controller to which the middleware is applied.
const permissionsMiddleware = (ctx, controller) => {
const subject = subjects[ctx.request.query.userId]
const resource = resources[ctx.request.query.id]
const permissions = new Set([
...TeamPolicy.getPermissions(subject, resource),
...ManagerPolicy.getPermissions(subject, resource)
])
ctx.resource = resource
ctx.permissions = permissions
return controller(ctx)
}
Controller
Once the controller is called, it has all the information it needs to generate a response. The set of permissions are used to determine whether the requesting user is allowed to read, write, or comment on a document. And the resource is also available for further processing. In this example it will be returned if the user has the READ
permission, or an empty object will be returned if this is not the case.
const getReportsController = ctx => {
if (ctx.permissions.includes('READ')) {
return {
permissions: ctx.permissions,
data: ctx.resource
}
}
return {}
}
Requests and Responses
For Mary, the request might look like this
const response = permissionsMiddleware(
{
request: {
query: {
id: 'marketing_finance_report_2022',
userId: 'mary'
}
}
},
getReportsController
)
She can read the document, because the ManagerPolicy
applies her in her case.
For Caroline, the request would also be successful because she is part of the same team as Peter and is therefore authorized to read, write and comment on the document.
The response would look like this:
{
"permissions": ["READ", "COMMENT"],
"data": {
"id": "marketing_finance_report_2022",
"type": "report",
"owner": {
"id": "peter",
"position": "ANALYST",
"team": "FINANCE"
}
}
}
Only for Marcus, the response is an empty object. None of the policies apply to him. That's why he is not allowed to view the document.
Conclusion
Due to the policies there was no need to establish a hierarchy of groups the users must be assigned to. They are very versatile, can be applied generically in many contexts, and implement complex boolean conditions. Permissions can be granted or denied to a user based on the context, such as the action (e.g. a GET
, POST
or PUT
request) and the requested resource.