Scaling Authorization with Cedar and OPAL
- Share:
Revamping Application-Level Authorization with Cedar
AWS recently rolled out Cedar, a new policy language that indicates the necessity for a revamp in application-level authorization. As opposed to embedding policy definitions in an application's code, decentralized and declarative approaches are becoming more prevalent. By separating policy configuration from application code, this fresh approach allows for a scalable and easier-to-maintain authorization system.
Cedar offers a unique and refreshing 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.
In this article, we'll discuss the benefits of separating policy from application code, which include easier scaling, fewer bugs, quicker response times, and enhanced access control security. We'll also delve into the utilization of open-source projects like Cedar-Agent and OPAL to construct a comprehensive and scalable authorization system.
By the end of this article, you'll have the knowledge required to construct a complete open-source authorization system that is specifically designed to handle scalability.
The OPAL-Cedar Example Repository
To begin our exploration of modern authorization implementation, let's first clone the project we created that utilizes the entire system in an Infrastructure as Code (IaC) manner.
Clone the project using the following command:
git clone git@github.com:permitio/opal-cedar.git
In the upcoming sections, we will examine each component declared in the code you just cloned. We'll start with the store, which manages the policies, and then move on to the data required for policy evaluation. Next, we'll deploy an administration layer responsible for auto-scaling the policy decision-makers. Finally, we'll focus on the end applications where we will enforce our policies.
This repository example uses Cedar-Agent to run Cedar policies, but this can work similarly with Amazon Verified Permissions, or other solutions running the Cedar engine.
In order to follow along with the practical portions of this article, you need a local installation of Docker on your machine
Setup The Policy and Its Retrieval Point
As we are going to configure our permissions as code declaration, we would like to have one centralized store for it. To set up our policy store, we will use a Git repository. Of course, you can use any type of file system to write and maintain the policy files, but Git will help us to scale later with its built-in features like branching, version control, revisioning, and immutability.
Taking a look at the repo we just cloned, we can see two parts of our retrieval point.
First, is a folder named Policy that represents our policy storage.
Second, is the docker-compose file on our root folder that contains the configuration that creates a local git server that will serve our policy files.
We used a local git server to avoid a redundant mess with SSH keys for real repositories. In the real world, you will probably use a remote git server like GitHub or GitLab.
Let's take a look at the folder named policy in our repository. As you can see, there are 3 simple policy files, each consisting of a policy permit statement for different roles of users.
admin.cedar
// Permit any action on any resource assuming the principal (user) role is admin
permit(
principal in Role::"admin",
action,
resource
);
writer.cedar
// Permit any principal (user) with the role writer to perform post/put on articles
permit(
principal in Role::"writer",
action in [Action::"post", Action::"put"],
resource in ResourceType::"article"
);
user.cedar
// Permit any principal (user) to perform the get action on any resource
permit(
principal,
action in Action::"get",
resource
);
As we have done for the policy configuration, we can also configure the data sources for our application.
Loading Data into Cedar
Getting policy decisions in real-time always depends on the data of our application at a particular moment. For example, if we want to allow a user to post an article, we may want to know if the user is a writer, if the article is auto-published, etc.
To handle data in Cedar, we require it to be in a special format, called Cedar entities: A hierarchical data structure that helps us to restructure the data in a way that will be easy to use in our policy declaration.
The data.json file in our repository contains a mock data source that represents the data of our application. Let's take a look at the different data types we have in our data source, bottom down.
At the top level, we will start by defining roles we want to assign our users to.
...
{
"attrs": {},
"parents": [],
"uid": {
"id": "admin",
"type": "Role"
}
},
...
Same as for roles, we would like also to define resource types and actions.
...
{
"attrs": {},
"parents": [],
"uid": {
"id": "article",
"type": "ResourceType"
}
},
{
"attrs": {},
"parents": [],
"uid": {
"id": "put",
"type": "Action"
}
},
...
As we are done with the root level of our data mocking, we can define our users and assign them as children to the roles we defined earlier.
...
{
"attrs": {},
"parents": [
{
"id": "admin",
"type": "Role"
}
],
"uid": {
"id": "admin@blog.app",
"type": "User"
}
},
...
Of course, in your application, you can always continue to nest this data by configuring resources and actions in the same hierarchical way. The main benefit of keeping those data in a hierarchical structure is that we can easily query them in our policy declaration. By using this data, we can easily see how easy it is to make decisions in the policy, like allowing if a user is a writer or denying if a user is a reader.
As we configure our both control plane (policies) and data plane (data source), we can now connect them together using the Administration point.
Loading Policy to Cedar with OPAL
Our administration point, OPAL, consists of two components, the OPAL server, and the OPAL client. The server is the part that tracks the changes in the policy and the data sources configuration and makes sure the client is up to date. The client, together with the configuration managed by the server, is responsible to make sure all data is updated and the decision point is ready to answer permissions decisions. The client is also running the policy agent itself, in our case, the Cedar agent - Our decision maker.
OPAL is configured as code and you can use any kind of IaC (such as Helm, TF, or docker) to set it up. In our case, we will use docker-compose to set up our OPAL server and client.
Let's take a look at the first services declared in the docker-compose file, where we compose the retrieval and information point. This is not a part of OPAL yet, just a way to set up everything needed to run OPAL in our demo:
version: "3.8"
services:
# A simple nginx server to serve our mock data sources.
cedar_data_nginx:
image: nginx:latest
volumes:
- "./data:/usr/share/nginx/html:ro"
# Local git repo to host our policy.
cedar_retrieval_repository:
image: rockstorm/gitweb
ports:
- "80:80"
volumes:
- "../:/srv/git:ro"
The second part is OPAL itself, let’s take a look at the important parts of the configuration.
Initialize first the OPAL server container, responsible for syncing our policy config and scaling the clients.
Opal_server: ... image: permitio/opal-server:latest ... environment: ... - OPAL_POLICY_REPO_URL=http://cedar_retrieval_repository/opal-cedar/.git - OPAL_POLICY_REPO_MAIN_BRANCH=main
2. Configure the connection to our mock data source
- OPAL_DATA_CONFIG_SOURCES={"config":{"entries":[{"url":"http://cedar_data_nginx/data.json","topics":["policy_data"],"dst_path":""},{"url":"http://cedar_data_nginx/data.json","topics":["policy_data"],"dst_path":""}]}}
- OPAL_LOG_FORMAT_INCLUDE_PID=true
3. Verify we collect all the .cedar files from the policy store
# By default, the OPAL server looks for OPA rego files. Configure it to look for cedar files.
- OPAL_FILTER_FILE_EXTENSIONS=.cedar
- OPAL_POLICY_REPO_POLICY_EXTENSIONS=.cedar
4. Finish by spinning the clients that will act as the decision points. We could auto-scale them independently to the server, and ensure the server synced them with the current data and configuration.
opal_client:
# by default we run opal-client from latest official image
image: permitio/opal-client-cedar:latest
environment:
- OPAL_SERVER_URL=http://opal_server:7002
- OPAL_LOG_FORMAT_INCLUDE_PID=true
ports:
Let's run this configuration to spin up our authorization system.
docker-compose up
Let's wait until OPAL finishes to set up everything and then we can start to use it.
Query The Decision Point
One of the benefits of using administration points is the ability to auto-scale our decision points and manage them by OPAL client. If we look at the logs of the compose we ran, we can see that our cedar-agent is running on port 8180. We now have the option to call the decision APIs via REST and enforce the permissions in our application.
We can verify that our cedar-agent is up and running, returning the right decisions, by calling the is_authorized endpoint with the following requests.
The following request requires a decision for a writer user to perform a delete action on an article, and it gets a Deny response:
curl -X POST "http://localhost:8180/v1/is_authorized" \
-H "Accept: application/json" \
-H "Content-Type: application/json" \
-d '{"principal":"User::\"writer@blog.app\"","action":"Action::\"delete\"","resource":"Resource::\"article\""}'
The following request, require a decision for a writer user to perform a post action on an article, and it gets an Allowed response.
curl -X POST "http://localhost:8180/v1/is_authorized" \
-H "Accept: application/json" \
-H "Content-Type: application/json" \
-d '{"principal":"User::\"writer@blog.app\"","action":"Action::\"post\"","resource":"Resource::\"article\""}'
Enforce Permissions
Since we configured a blog permissions mode, we also created a mock blog server, written in Node.js. Let's take a look at the file named server.js, and the authorization middleware we created there.
...
// Generic authorization middleware - Enforcement Point
const authorization = async (req, res, next) => {
const { user } = req.headers;
const { method, originalUrl, body } = req;
// Call the authorization service (Decision Point)
const response = await fetch('http://host.docker.internal:8180/v1/is_authorized', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
},
// The body of the request is the authorization request
body: JSON.stringify({
"principal": `User::\"${user}\"`,
"action": `Action::\"${method.toLowerCase()}\"`,
"resource": `ResourceType::\"${originalUrl.split('/')[1]}\"`,
"context": body
})
});
const { decision } = await response.json();
// If the decision is not 'Allow', return a 403
if (decision !== 'Allow') {
res.status(403).send('Access Denied');
return;
}
next();
};
// Mock routes
app.get('/article', authorization, async (req, res) => {
const articles = ['article1', 'article2', 'article3'];
res.send(articles);
});
...
To run this server, in another terminal window, run the following command to spin up our server.
docker-compose -f applications/node/docker-compose.yml up
We can now use CURL or Postman to verify our permissions model. Let's run the following two calls to see our authorization magic in action.
In case we POST an article with a writer identity, it succeeds
curl -X POST http://localhost:3000/article/2 \
-H 'Content-Type: application/json' \
-H "user: writer@blog.app" \
In case we POST an article with a standard user identity, the access denied
curl -X POST http://localhost:3000/article/2 \
-H 'Content-Type: application/json' \
-H "user: user@blog.app" \
At this point, we have all our components up and running. As you saw, we separate all the components and verify that each of them is doing only one thing. The enforcement point is enforcing the permissions, the decision point is making permission decisions, and the administration point connects the policy and data sources to the decision point. In the control plane, we just have policy and data source declarations.
Enforce Permissions at Scale!
Now that we are done with spinning up our authorization system, let's test the different scale aspects of our authorization system.
Scale Enforcement
First, since we separated our policy configuration from our application, we can seamlessly add enforcement points in any other application we want. For example, here is a simple Python application that enforces the same permissions model we created earlier:
...
# Authorization decorator middleware
def authorization(f):
@wraps(f)
def decorated(*args, **kwargs):
user = request.headers.get('user')
method = request.method
original_url = request.path
# Call authorization service
# In the request body, we pass the relevant request information
response = requests.post('http://host.docker.internal:8180/v1/is_authorized', json={
"principal": f"User::\"{user}\"",
"action": f"Action::\"{method.lower()}\"",
"resource": f"ResourceType::\"{original_url.split('/')[1]}\"",
"context": request.json
}, headers={
'Content-Type': 'application/json',
'Accept': 'application/json'
})
decision = response.json().get('decision')
# If the decision is not Allow, we return a 403
if decision != 'Allow':
return 'Access Denied', 403
return f(*args, **kwargs)
return decorated
# Mock endpoints
@app.route('/article')
@authorization
...
As you can see, no more imperative code is required to enforce the permissions and streamline them on all our applications.
Let's run the application to see it in action.
docker-compose -f applications/python/docker-compose.yml up
Now, let's do the same CURL test we did earlier, but from the new application.
In case we POST an article with a writer identity, it succeeds
curl -X POST http://localhost:3001/article/2 \
-H 'Content-Type: application/json' \
-H "user: writer@blog.app" \
In case we POST an article with a writer identity, it succeeds
curl -X POST http://localhost:3001/article/2 \
-H 'Content-Type: application/json' \
-H "user: user@blog.app" \
Scale Permissions Model
We have a new feature request: We now want to allow writers to auto-publish their posts only if their account exceeds 1000 karma points. In the imperative style permissions model, we would need to change the code in all our applications to add this new permission. In our case, we just need to edit the policy file in our policy repository.
In our writer.cedar file, let's add the following permission. These permissions will allow writers to post published: true articles only if their account has more than 1000 karma points.
permit(
principal in Role::"writer",
action in [Action::"post", Action::"put"],
resource in ResourceType::"article"
) when { principal.karma > 1000 || (context has published && context.published == false) }
As you can read in the policy, we added new permission that allow users to auto-publish their posts only if their account exists for more than 30 days. Not only we can now make changes to our policy code without any change to the application, but the declaration is also much more readable and easy to understand.
Scale Data Sources
Adding a policy is one thing, but how would we know what is our writer karma? For that, we may want to use external data sources that will tell us more about the user. Do it in imperative style, we would need to change the code in all our applications to add this new data source. In our case, we just need to add a new data source in our policy administration layer.
To save some time and reinitialization, we just add the karma data to our main data source. In a real-world application, we could use a different data source for each data type.
Let's add the karma data to our data.json
file.
...
{
"attrs": {
"karma": 2000
},
"parents": [
{
"id": "writer",
"type": "Role"
}
],
"uid": {
"id": "senior_writer@blog.app",
"type": "User"
}
},
{
"attrs": {
"karma": 500
},
"parents": [
{
"id": "writer",
"type": "Role"
}
],
"uid": {
"id": "writer@blog.app",
"type": "User"
}
},
...
Let's run now the following CURL to see our new permission in action.
Allowed request
curl -X POST http://localhost:3001/article/2 \
-H 'Content-Type: application/json' \
-H "user: senior_writer@blog.app" \
-d '{"published": true}'
Denied request
curl -X POST http://localhost:3001/article/2 \
-H 'Content-Type: application/json' \
-H "user: writer@blog.app" \
-d '{"published": true}'
Allowed request with unpublished article
curl -X POST http://localhost:3001/article/2 \
-H 'Content-Type: application/json' \
-H "user: writer@blog.app" \
-d '{"published": false}'
Scaling-Out Decision Points
In this tutorial, we just use a local demonstration of OPAL running on the local machine with a docker-compose file, and run all the components on the same machine. In production, you can use OPAL to scale your decision points as you need. As we saw earlier, OPAL separates the concern of the stateful servers and stateless clients that run as sidecars in individual applications. You can find helm charts and other instructions in the OPAL repository that will help you deploy OPAL in production and scale it as needed.
Conclusion
We just created a basic auto-scaled authorization system that can be used to enforce permissions in any application we want. We separated the control plane from the data plane and the enforcement plane. We also separated the policy configuration from the application code. We can now scale our system in any aspect we want, without changing the application code.
As the next steps of this learning path, you can take a look at OPAL and Cedar docs, and understand more how you can customize your needs with it for authorization in scale. We would also want to invite you to our Authorization Slack community, hear your feedback and chat further on advanced use cases for application-level authorization.
Written by
Gabriel L. Manor
Full-Stack Software Technical Leader | Security, JavaScript, DevRel, OPA | Writer and Public Speaker