Implementing Role-Based Access Control (RBAC) with AWS’ Cedar
- Share:
Building authorization for your application can be a complicated endeavor. There are various policy models to choose from (Like RBAC, ABAC, and ReBAC), and several policy-as-code engines and languages to implement with.
In this tutorial, we will focus on Role-Based Access Control (RBAC) and how (and why) you should implement it using the AWS Cedar policy engine - which allows you to create a separate microservice for authorization, decoupling our policy from our code.
This tutorial was created with the help of Mike Hicks - Senior Principal Scientist at AWS and Co-lead of the Cedar policy language open-source project.
What is RBAC?
Role-based access control (RBAC) is an authorization model used to determine access control based on predefined roles. Permissions are assigned to roles (Like “Admin or “User”), and roles are assigned to users by the administrator. The RBAC model allows you to easily understand who has access to what and is one of the most popular policy models for building authorization.
Other models like Attribute Based Access Control (ABAC), allow for more granular access control. Creating policies with Cedar allows you to write policies that use RBAC, ABAC, or elements of both.
You can read more about different policy models and when they should be applied here.
Why Policy-As-Code?
After making the decision that you need an RBAC model in your application, it is important to note the challenges you’ll have to face along the way:
The set of policies for each individual service has to be manually set up inside the service itself. This can be quite a pain to do - as the amount of policies, users, and services grows, updating them in each relevant service becomes super tedious and time-consuming. Considering the fact that policies change all the time - they have to be at least somewhat fluid.
Having the code of the authorization layer mixed in with the code of the application itself is also a major pitfall you should avoid. Hard-coding authorization into your application creates a situation where every upgrade, added capability and monitoring of the code as it is replicated between different microservices become a huge hassle. Each change would require refactoring large areas of code that only drift further from one another as these microservices develop.
How can we avoid this?
Creating a separate microservice for authorization, thus decoupling our policy from our code. Controlling access management centrally through a separate authorization service allows you to offer it as a service to every system in your application that needs to check whether a resource can be accessed. One way to achieve this separation is by using AWS Cedar.
What is AWS Cedar?
Cedar is a language for writing authorization policies, together with an engine for evaluating those policies to make authorization decisions.
You can run Cedar in the cloud with Amazon Verified Permissions, and deploy Cedar as a standalone agent (e.g. locally, in other cloud environments, or as a sidecar in K8s) with Cedar-Agent.
Cedar offers a unique approach to the Policy-as-Code trend. While other policy languages (like Rego) tend to offer a multi-propose language that could fit into application-level authorization, Cedar is built with application-level authorization in mind.
You can learn about how Cedar compares other policy languages (Namely OPA’s Rego) here.
Let’s dig into how the Cedar policy language works -
The Cedar Policy Syntax
Roughly speaking, the policy scope describes a role-based access control (RBAC)-style policy, while the conditional clauses refine it to express an attribute-based access control (ABAC) policy. The effect and policy scope are mandatory, but the conditional clauses are optional.
Like every authorization policy, Cedar aims to answer the question of: Who can perform which Actions on which Resource
In Cedar, this type of question is asked in the following terms: Which Principal can perform what Actions on a given Resource.
The structure of a Cedar policy looks like this:
Effect - Either Permit or Forbid All requests are set to be denied by default. Choosing an effect allows us to define two types of policies - based on either permitting or forbidding requests. A permit policy grants access, while a forbid policy restricts access by overriding a permit policy.
Policy Scope- Describes the Principal, Action, and Resource the policy applies to, partly based on role or group membership.
This structure describes an RBAC policy. To extend upon basic RBAC, Cedar offers a Condition clause.
Condition - Allows adding more granular permissions by adding generic conditions to them This is relevant for attribute-based access control (ABAC) policies where a Context and Attributes can be added.
Sample policy - ‘User’ can perform ‘Action’ on ‘Resource’
Principals, Actions, and Resources are objects called Entities. An Entity is composed of a Type and an ID in the following format:
principal == Type::”ID”
Combining the syntax we learned so far, we can create the following policy:
permit (
principal == User::”Frank”,
action == Action::”get_document”,
resource == Document::”cedar_tutorial.pdf”
);
This policy allows the user ‘Frank’
to ‘get_document
’ ‘cedar_tutorial.pdf
’.
Principals and resources are identified by a unique combination of a Type and an ID.
For example, a principal of type ‘User
’ called ‘Frank
’. Each time a policy references a principal or a resource, it must call out both the type and the ID.
So far, our policy allows a specific user (‘Frank
’) access to a specific resource. In order to achieve RBAC, we need to be able to define Roles. This can be done by using the concept of Parents and the ‘In’ keyword. Let’s dive into those.
Creating Roles - Parents, the ‘In’ keyword
In addition to the Type and ID discussed previously, an Entity also possesses Parents. Parents help establish connections between multiple entities and enable us to create enforcements within our policy.
In real-world applications, it's common to have multiple users sharing the same permissions.
To address this, we often group them using Roles. That’s what the Parents feature is here for. Let's consider an example with three types of users: Admin, Editor, and Viewer.
Admins can "get
," "update
," "create
," and "delete
" documents.
Editors can "get
" and "update
" documents.
Viewers can "get
" documents.
Now, let's modify the previous policy example to align with our new policy:
permit (
principal in Role::"Admin",
action in [Action::"get_document", Action::"update_document", Action::"create_document", Action::"delete_document"],
resource == Document::"cedar_tutorial.pdf"
);
permit (
principal in Role::"Editor",
action in [Action::"get_document", Action::"update_document"],
resource == Document::"cedar_tutorial.pdf"
);
permit (
principal in Role::"Viewer",
action in [Action::"get_document"],
resource == Document::"cedar_tutorial.pdf"
);
Consider a scenario where you have 1000 users. Without grouping, you would need to create a separate policy for each user. However, with the concept of grouping, you can significantly reduce the number of required policies.
You may have noticed a new keyword called "in”. By utilizing this keyword, you can recursively check if an entity is under a parent or if an entity exists in an array.
In this example, we defined an Entity of Type Role. We'll group our users by assigning these entities as Parents (Or, an ancestor - a parent, grandparent, great-grandparent, etc.) in the following manner:
Every admin user will have the
Role::"Admin"
as an ancestor.Every editor user will have the
Role::"Editor"
as an ancestor.Every viewer user will have the
Role::"Viewer"
as an ancestor.
Ancestors create a hierarchy of roles, as can be seen in this diagram:
In this particular example, Admin has both Editor and Viewer as its parents, and Editor has Viewer as its parent. Thus, all Editors are Viewers, and all Admins are both Viewers and Editors. There's no need to put the “view” operations in both the Admin and Editor policies — users will inherit them from the Viewer policy.
This Parent relationship allows for efficient management and fewer policies.
So how do we define these entities?
The JSON format of an Entity is:
{
"uid": {
"id": "Frank",
"type": "User"
},
"parents": [
{
"id": "Admin",
"type": "Role"
}
],
"attrs": {}
}
The uid
key represents the Type and ID, and the parents key represents the Parents. This Entity defines Frank as a User, with his parents being the Admin role.
‘attrs
’ represent entity attributes, which allow adding specific attributes to entities, thus creating more granular policies (ABAC).
Policy examples
To check the examples yourself, use the following entities at: https://www.cedarpolicy.com/en/playground.
[
{
"uid": {
"id": "Frank",
"type": "User"
},
"parents": [
{
"id": "Admin",
"type": "Role"
}
],
"attrs": {}
},
{
"uid": {
"id": "John",
"type": "User"
},
"parents":[
{
"type":"Role",
"id":"Viewer"
}
],
"attrs": {}
},
{
"uid": {
"id": "Admin",
"type": "Role"
},
"parents": [
{
"id": "Editor",
"type": "Role"
}
],
"attrs": {}
},
{
"uid": {
"id": "Editor",
"type": "Role"
},
"parents": [
{
"id": "Viewer",
"type": "Role"
}
],
"attrs": {}
},
{
"uid": {
"id": "cedar_tutorial.pdf",
"type": "Document"
},
"attrs":{},
"parents":[]
},
{
"uid": {
"id": "non_viewer.pdf",
"type": "Document"
},
"parents":[],
"attrs": {}
}
]
Every principal is allowed to perform the ‘
get_document
’ Action on the Document ‘cedar_tutorial.pdf
’
permit(
principal,
action == Action::”get_document”,
resource == Document::”cedar_tutorial.pdf”
);
The following authorization request will be allowed because all three parameters in the policy scope are matched:
Principal:
User::"unknown-user"
Action:
Action::"get_document"
Resource:
Document::"cedar_tutorial.pdf"
The following authorization request will be denied because the resource doesn’t match our allowing policy:
Principal:
User::"unknown-user"
Action:
Action::"get_document"
Resource:
Document::"internal_doc.pdf"
2. A principal that is a part of ‘Role::Admin
’ is allowed to perform any Action on the Document ‘cedar_tutorial.pdf
’.
permit(
principal in Role::”Admin”,
action,
resource == Document::”cedar_tutorial.pdf”
);
The following authorization request will be allowed because all three parameters in the policy scope are matched ( User:::”Frank” has the Role::”Admin” ancestor):
Principal:
User::"Frank"
Action:
Action::"update_document"
Resource:
Document::"cedar_tutorial.pdf"
The following authorization request will be denied because User::”John” doesn’t have the Role::”Admin” ancestor:
Principal:
User::"John"
Action:
Action::"update_document"
Resource:
Document::"cedar_tutorial.pdf"
3. A principal that is a part of ‘Role::Viewer
’ is allowed to perform ‘get
’ Action on any resource.
permit(
principal in Role::”Viewer”,
action == Action::”get”,
resource
);
The following authorization request will be allowed because all three parameters in the policy scope are matched ( User:::”John” has the Role::”Viewer” ancestor ):
Principal:
User::"John"
Action:
Action::"get"
Resource:
Document::"some_document.pdf"
The following authorization request will be denied because the action doesn’t match our allowing policy:
Principal:
User::"John"
Action:
Action::"delete"
Resource:
Document::"some_document.pdf"
4. A principal that is a part of ‘Role::Viewer
’ is allowed to perform the 'get
' Action on any resource except Document ‘non_viewer.pdf
’, to do this, we’ll use both permit and forbid to easily manage this rule.
permit(
principal in Role::”Viewer”,
action == Action::”get”,
resource
);
forbid(
principal in Role::”Viewer”,
action,
resource == Document::”non_viewer.pdf”
);
The following authorization request will be allowed because all three parameters in the policy scope are matched ( User:::”John” has the Role::”Viewer” ancestor ):
Principal:
User::"John"
Action:
Action::"get"
Resource:
Document::"some_document.pdf"
The following authorization request will be denied because the query matches our forbid policy:
Principal:
User::"John"
Action:
Action::"get"
Resource:
Document::"non_viewer.pdf"
Now that we understand how Cedar policies are built, it's important to mention two open-source tools that could significantly aid with our Cedar implementation:
The ‘Cedar Agent’
Cedar Agent is an open-source project that allows for easy deployment of Cedar policies within your application. Acting as an HTTP server, Cedar Agent efficiently manages a policy store and a data store, allowing you to easily control and monitor access to your application's resources. “Cedar Agent” includes:
Policy Store Management: Cedar-Agent includes a centralized store for creating, retrieving, updating, and deleting policies. This allows fine-grained control over user access, defining who should have access to specific application resources.
Data Store Management: Cedar-Agent offers an efficient in-memory data store for managing application data. By integrating it with Cedar-Agent, authorized checks can be performed on stored data based on incoming HTTP requests, ensuring secure and controlled access.
Authorization Checks: Cedar-Agent excels at performing authorization checks on stored policies and data. Evaluating Cedar policies, it restricts user access to permitted resources. These checks are seamlessly performed based on incoming HTTP requests, providing a robust and secure mechanism for access control within your application.
While Cedar provides us with a centralized authorization solution, the enforcement itself is still distributed - a Cedar agent is deployed next to every microservice, providing decisions and enforcement with near-zero network latency. The Cedar agents are distributed and can grow along with the services as they grow in scale.
Real-time dynamic authorization - Cedar + OPAL
Scaling the deployment of Cedar for policy evaluation in production can be challenging.
While Cedar provides the necessary building blocks for defining policies and evaluating decisions, deploying it with your application at scale requires a solution. This is where OPAL comes in. OPAL is an open-source project which serves as an administration layer that simplifies the deployment of Cedar engines and ensures seamless scalability.
With OPAL, you can effortlessly keep your policy configuration up to date across all deployed policy engines, ensuring consistency and accuracy. It also guarantees that the data used for evaluation remains current, facilitating precise policy enforcement. OPAL's configuration-as-code approach simplifies the deployment process by providing a centralized and easily manageable configuration. You can learn more about how Cedar works with OPAL here.
Learn more!
Implementing Role-Based Access Control (RBAC) using AWS' Cedar Policy Language offers a powerful solution for managing access to resources within your application. We explored the challenges associated with setting up RBAC policies and how AWS Cedar provides a robust framework to address them.
Want to know more about building and implementing authorization? Join our Slack community, where hundreds of developers are discussing, building, and implementing access control into their applications.
Cedar is a new and evolving policy language. Join the Cedar Slack community for questions about Cedar-specific use cases, new ideas, and feedback.
Written by
Daniel Bass
Application authorization enthusiast with years of experience as a customer engineer, technical writing, and open-source community advocacy. Comunity Manager, Dev. Convention Extrovert and Meme Enthusiast.