10 min read

Designing an Authorization System - Introduction

Designing an effective authorization system is crucial for controlling access to resources and ensuring the security of an application or platform. In this article I go more in-depth on how you can design such a system.
Designing an Authorization System - Introduction

Backend systems often require an Authorization system. As a developer, you know the importance of having a robust authorization system in place to protect your organization's sensitive resources. Whether you're in charge of a small business or a large enterprise, having a system in place to control who has access to what is essential for maintaining the integrity of your organization's data and assets.

Two common types of authorization systems are role-based access control (RBAC) and attribute-based access control (ABAC). In this blog post, we'll take a closer (deep dive) on how we can implement the latter. But first' let's look at the different between the two and why we picked ABAC.

RBAC vs ABAC

RBAC is a type of authorization system that uses an individual's role within an organization to determine their access to resources. For example, a user who has been assigned the role of "manager" might have access to certain resources that are not available to other users. This type of system is useful because it allows organizations to easily control access to resources by simply assigning users to the appropriate roles.

However, RBAC has its limitations. Because access is determined solely based on an individual's role, it can be inflexible and may not provide the granular level of control that some organizations require. For example, if you have a user who is a manager but also has additional responsibilities, they might not have access to the resources they need to do their job effectively.

ABAC, on the other hand, offers a more flexible approach to authorization. With ABAC, access to resources is determined based not only on an individual's role, but also on other attributes such as their location, the time of day, and the specific resource they are trying to access. This allows organizations to create fine-grained access controls that can be customized to fit the unique needs of the organization.

While ABAC offers greater flexibility than RBAC, it can be more complex to implement and manage. It also requires organizations to have a thorough understanding of the attributes that will be used to determine access, and to keep those attributes up to date as the organization changes over time.

In conclusion, both RBAC and ABAC are useful authorization systems that can help organizations control access to their sensitive resources. But seeing the complexity ABAC can handle, it's more interesting to pick this one for implementation as current systems and enterprises in the ecosystem often require these complex cases.

Lexicon

Below an overview is given of the different lexicon items and their meanings. It's recommended to first go over these to understand the used terminology more in-depth further on in this article.

General Terms

  • Principal: The principal is the individual or entity who is performing the action. In the context of an authorization system, the principal is often the user who is trying to access a particular resource.
  • Action: The action refers to the specific operation that the principal is attempting to perform. For example, a user might try to read a particular article, or update their account information (e.g., article, user: update).
  • Subject: The subject is the resource that the action is being performed on. For example, if the action is to read an article, the subject would be the article itself.
  • Resource: The resource is the thing that the principal is trying to access or modify. This could be a physical object, such as a file on a computer or a folder on a network, or it could be a digital object, such as a record in a database or a web page on the internet.
  • Attr: The term "attr" is short for "attribute," which refers to a characteristic or quality of something. In the context of an authorization system, an attribute might be a piece of information that is used to determine whether a user is allowed to perform a particular action on a resource. For example, an attribute might be the user's role within an organization, or the time of day that they are trying to access the resource.
  • Conditions: Conditions are criteria that must be met for the action to be performed on the subject. These conditions can be used to restrict the actions that a user is allowed to perform. For example, a user might only be allowed to manage their own articles, rather than articles belonging to other users. In this case, the condition would be that the user is the owner of the article.

Request Types

There are several different types of requests that a user can make in an authorization system. Some common types of requests include:

  • Access request or authorization request: This is a request made by a user to determine whether they are allowed to perform a particular action on a specific resource. The authorization system evaluates the request to determine whether the user has the necessary permissions, and either grants or denies the request based on the result of that evaluation.
  • Discovery request: This is a request made by a user to determine whether they are allowed to create a new resource based on a particular set of criteria. The authorization system evaluates the request to determine whether the user has the necessary permissions to create the resource, and either grants or denies the request based on the result of that evaluation.
  • Resource discovery query: This is a request made by a user to see a list of all the resources that they are allowed to access. The authorization system evaluates the request to determine which resources the user has permission to access, and then returns a list of those resources to the user.
  • Permission query: This is a request made by a user to determine their specific permissions for a particular resource. The authorization system evaluates the request to determine the specific actions that the user is allowed to perform on the resource, and then returns that information to the user.

Designing our Authorization system ("Gatekeeper")

To get started, I refer to the authorization system as the "Gatekeeper" service that takes care of evaluating if a user can access the requested resource.

A Gatekeeper "controls the gate" and allows or denies access to a certain user for a certain request

Definition of an Authorization System

Now looking at our design, what should an Authorization System do and how does it work? Well, an authorization system works by evaluating a user's request to access a resource based on the conditions that have been set for the resource and the attributes of the user. If the request satisfies the conditions and the user has the necessary attributes, they are granted access to the resource. If not, the request is denied.

Components

As defined above, there are two main components to an authorization system:

  • Define: We should define the policies that tell us if we are allowed access to a resource. E.g., as a user we have read access to the user object if the current principal id matches the user id it is trying to access.
  • Check: We should implement a check that checks if the policy defined before is validated with the current principal, resource, and variables.

Pitfalls

As described earlier, an ABAC system is a complex undertaking. When we look at a simple query as "Am I, as a user, able to access my user with id: USER_ID" we can simply check if the ids match.

However, when we look at a more complex example where we have the dependency chain User -> Company -> Article, then we can see that if we have a similar question "Can I as a user create an article?" that we must check:

  • Does the user have access to the company?
  • Does the user have "create" access on the article?

Let's make this all more practical by applying all the learned theory above on a CRUD (Created, Read, Update, Delete) example, which are the most used requests.

Example - Create, Read, Update, Delete (CRUD)

Defining the requirements by an example we look at what is required for an Authorization system to work together with the CRUD operations.

Let's create a table that provides an overview of CRUD endpoints that are often utilized. In here we utilize the dependency User -> Company -> Article, where a user can have multiple companies and a company can have multiple articles.

We can see that for the REST operations defined with the respective functions, the questions are quite different from each other.

Verb URL Function Question
GET /company/:companyId/article getAll() Can the current user READ all articles for company :companyId?
GET /company/:companyId/article/:id get() Can the current user READ article :id for company :companyId?
POST /company/:companyId/article create() Can the current user CREATE an article for company :companyId?
PUT /company/:companyId/article/:articleId update() Can the current user UPDATE an article for company :companyId?
DELETE /company/:companyId/article/:articleId delete() Can the current user DELETE an article :id for company :companyId?

Access filters and required parameters

As seen above, we thus require several parameters to answer all the questions:

Question Required Parameters
Can the current user READ all articles for company :companyId? user, companyId
Can the current user READ article :id for company :companyId? user, companyId, articleId
Can the current user CREATE an article for company :companyId? user, companyId
Can the current user UPDATE an article for company :companyId? user, companyId, articleId
Can the current user DELETE an article :id for company :companyId? user, companyId, articleId

Often, based on these questions, we create translations that define how we should look up if we got access. For the questions above, we can define that we need the following filters from the following subjects:

Function Subject Filter
getAll() Article { company: { id: "COMPANY_ID", userId: "USER_ID" } }
get() Article { id: "ARTICLE_ID", company: { id: "COMPANY_ID", userId: "USER_ID" } }
create() Company { id: "COMPANY_ID", userId: "USER_ID" }
update() Article {id: "ARTICLE_ID", company: { id: "COMPANY_ID", userId: "USER_ID" } }
delete() Article {id: "ARTICLE_ID", company: { id: "COMPANY_ID", userId: "USER_ID" } }

Now, let's take a look on our components and how we would use those to use the above.

Component - Define

In a definition, we define the policy that describes how a user is allowed access to a given resource.

As we learned before, conditions are criteria that must be met for the action to be performed on the subject. Implementing such conditions is often done through a query on database, which is commonly implemented through:

  • Abstract Syntax Tree (AST): an AST, or Abstract Syntax Tree, is a tree-like data structure that represents the syntactic structure of a query. It is often used in programming languages and databases as a way of representing the structure of a query in a way that can be easily manipulated by a computer.
  • MongoQuery: MongoQuery, on the other hand, is a specific type of query language that is used with the MongoDB database. MongoDB is a popular database system that is known for its flexibility and scalability. MongoQuery is the query language that is used to search and manipulate data within a MongoDB database.

The difference between AST and MongoQuery is that AST is a more general way of representing a query, while MongoQuery is specific to the MongoDB database. An authorization system that uses AST might be able to work with any type of database, while one that uses MongoQuery would only be able to work with a MongoDB database.

As an example, to receive access to an Article, we can define that a principal has access to the Article resource if the condition "R.company.id == V.companyId && R.company.userId == P.id" is met.

This condition defines that the article resource can be accessed for the actions READ, CREATE, DELETE and UPDATE if the company id equals the requested company id and if the company owner is that of the current principal.

{
  resource: GatekeeperResourceKindEnum.Article,
  actions: [
    AuthPermissionActionEnum.READ,
    AuthPermissionActionEnum.CREATE,
    AuthPermissionActionEnum.DELETE,
    AuthPermissionActionEnum.UPDATE,
  ],
  effect: AuthPermissionEffectEnum.ALLOW,
  condition: {
    condition: "R.company.id == V.companyId && R.company.userId == P.id"
  }
}

Important to note is that the condition in the example above is especially tricky in certain situations such as the CREATE situation, as we are not accessing the Article kind itself, but should rather check its dependency. Another interesting situation is the one of READ_ALL where we don't want to specify the id, but want to have a filter that returns the resources we can access.

Diving deeper into this, let's categorize these scenarios in their respective actions and map these to the lexicon items of different requests we can make in an authorization system:

  • READ, UPDATE, DELETE with context: Here we know the specific resource we want to access, often defined through an id attribute. This is also commonly referred to as an "Access Request"
  • READ without context: Here we don't know the specific resource we want to access. We want to know all the resources we can access for the provided condition. This is also referred to as a "Resource Discovery" query.
  • CREATE: In a CREATE operation we should know the criteria the resource has to be able to create it. In common cases, this is the the principal is owner of the dependent resource. This is often called a Discovery Request.

Summarizing this, we can say that the following requests require the following types of contexts:

Request Type Requirements
Access Request Kind of resource, Unique identifier of the resource
Resource Discovery Request Kind the resource
Discovery Request Kind of resource of the dependency

Finally, let's look at some examples

Request Examples

For the condition `R.company.id == V.companyId && R.company.userId == P.id` we should be able to get the requests below.

This would mean that we must find:

  1. Dependencies (e.g., company) which can be nested (e.g., in user -> project -> device -> event this could be device.project)
  2. Attributes (e.g., userId) on the respective resources
Access Request (can I access this resource?)
const resource = await this.prismaService.article.firstFirst({
  where: {
    id: "MY_ID",
    company: {
      id: "COMPANY_ID",
      userId: "USER_ID"
    }
  },
  include: {
    company: true
  }
});
Resource Discovery Request (which resources can we access?)
const resource = await this.prismaService.article.findMany({
  where: {
    company: {
      id: "COMPANY_ID",
      userId: "USER_ID"
    }
  },
  include: {
    company: true
  }
});
Discovery Request (can we create this resource?)

We could write a discovery request in a naive approach where we instantly try to create the resource and wrap it in a try / catch when it fails due to a dependency:

const resource = await this.prismaService.article.create({
  data: {
    ...dto,
    company: {
      connect: {
        id: "MY_COMPANY_ID"
      }
    }
  },
});

Much better however is to pre-check if we can access a resource. To be able to do so, we should 1. correctly identify the subject and 2. correctly translate the rest of the condition into a find query.

This would translate the condition device.ProjectDevice.$some.project.userId == "USER_ID" && device.ProjectDevice.$some.project.id == "PROJECT_ID" into:

const resource = await this.prismaService.device.findFirst({
  where: {
    ProjectDevice: {
      every: {
        project: {
          userId: "USER_ID",
          id: "PROJECT_ID"
        }
      }
    }
  }
})

Or for a simpler condition, it would translate company.userId == "USER_ID" && company.id == "COMPANY_ID" into:

const resource = await this.prismaService.company.findFirst({
  where: {
    id: "COMPANY_ID",
    userId: "USER_ID"
  }
})

Note that the subject (as described before in the Action Filters) changed to device and company

Component - Check

Checking if we have access to a resource happens through an implementation. Note that this implementation differs from the used language and framework, but one way to implement these would be by using guards. Programmatically, we would then define on a provided route what the requirement is to access this route.

// TODO: This partly works for get and getAll but not for create yet
@GatekeeperCheck(
  [GatekeeperResourceKindEnum.Article, [GatekeeperAction.CREATE]],
])
@Create()
async function create(body: CreateDTO) {}

As the scope of this article is to explain how we would design this and not how we would implement this, I won't be going into details on how we are implementing this. The example above shows us an interface of how we would check such access for such a route without having to implement it in the actual code of the controller.

Summary

Hopefully through this article you have gained insights in how to develop an authorization yourself from scratch, looking at the requirements and translating these requirements into a theoretical system.

In a next article, I want to go more in-depth in how to implement such a system using Nest.js and Prisma.

Finally, I have as well submitted the system described here to the Dapr repository, as any backend system (or even more complex microservices) require this kind of authorization. So, feel free to go check it out and leave your remarks!