Cerbforce
'Cerbforce' is the new killer CRM system that is taking the world by storm. It began as a small SaaS app that has grown into an enterprise-scale, multi-tenant, global powerhouse.
It is now at the point where the existing basic permission model created at the start of development is no longer fit for purpose and Cerbos has been selected as the system to implement.
This tutorial walks through the decision-making process for implementing Cerbos. It covers setting up, defining the various resources and policies for the different objects and users in the system, and evolve them to make use of all of Cerbos' features.
Cerbos
Authorization-as-a-Service
One of the key tenants that have allowed Cerbforce to scale is it's the adoption of a microservices architecture where each component can be scaled to meet the exact demands of the system.
Cerbos has been built as a standalone service which gives it several characteristics that are desirable for such an architecture
- Authorization checks can be made from any system or part of the app stack. No more complicated logic replicating rules - now it is a single call out to Cerbos which returns a simple ALLOW or DENY response for the request.
- All policy decisions are centralized in the Cerbos instances so there is a single location where audit logs can be gathered from.
- The Cerbos instances can be scaled alongside the rest of your services for example as a Kubernetes sidecar
Policy as Code
As Cerbforce has grown the complexity of authorization rules has required complicated logic to be translated into each language used and hardcoded into each service in the app stack. Any updates require engineering time to go and change the logic, run tests, and then cut a release of every part of the system which is affected.
Cerbos' approach is to define all policy as human-readable policy definitions held centrally and that is read by all the Cerbos instances. This way any updates or changes to authorization rules can be made once and then all services that call Cerbos for permissions checks get the updated result. No code changes or releases are needed.
Bring your own identity
Cerbforce has standardized on using Auth0 for authentication across their suite of applications. This contains the user profile information such as what role the user has, which department they belong to and what office they are based in.
Cerbos can consume an identity from any authentication provider be it homegrown or a managed service and can even natively support JWTs including verification.
This profile information from Auth0 is used to construct the user information (called the principal in Cerbos speak due to supporting not user identities also) which is passed in with an authorization call to make policy decisions with.
Performance
Given the scale of Cerbforce there is concern about how performant Cerbos is be given authorization checks are being made in the blocking path of every request. Several key features of Cerbos have quelled these concerns:
- The Cerbos API is exposed over a highly performant gRPC interface to keep overheads low (with an HTTP gateway on top).
- A recommended approach is a sidecar deployment so that each service instance has its Cerbos instance to keep latency as low as possible - calls can even be made over UNIX sockets.
- Cerbos is advocating a modern cloud-native approach to dealing with common infrastructure services such as authorization. This is a proven method - Microsoft Dapr is a good example at scale.
Running locally
As the developers of Cerbforce began their investigation of the system the first step was getting a Cerbos instance up and running locally.
Config file
The first step is to create a server configuration file. The most simple configuration to get up and running using a local folder for storage of policies requires only the port and location to be set.
---
server:
httpListenAddr: ":3592"
storage:
driver: "disk"
disk:
directory: /policies
Save this file in a directory for example /tutorial/config/conf.yaml
and also create an empty policy folder for example /tutorial/policies
You can find the full configuration schema in the Cerbos docs.
With the configuration defined there are two options to choose from for running Cerbos locally.
Container
If you have Docker you can simply use the published images. You need to mount the folder created in the preceding step into the container for it to be able to read the policies:
docker run --rm --name cerbos -t \
-v /tutorial:/tutorial \
-p 3592:3592 \
ghcr.io/cerbos/cerbos:latest server --config=/tutorial/config/conf.yaml
Binary
Alternatively, if you don't have Docker running you can grab the relevant release binary from here, extract it, and then run:
./cerbos server --config=/tutorial/config/conf.yaml
Once started you can open http://localhost:3592
to see the API documentation.
Policy authoring
The policies for this section can be found on Github.
Authentication roles
To begin with Cerbos needs to know about the basic roles which are provided by your authentication provider. In the case of Cerbforce, Auth0 provides a role of either ADMIN
or USER
for all profiles. This is important when starting to define access to resources below - for now just make a note of them.
Resources
The best place to start with defining policies is listing out all the resources and their actions that exist in the system. A resource is an entity type that users are authorized to access.
In the case of Cerbforce some of the resources and actions are as follows:
Resource | Actions |
---|---|
User | Create, Read, Update, Delete |
Company | Create, Read, Update, Delete |
Contact | Create, Read, Update, Delete |
With this as a start, you can begin creating your first Cerbos policy - a resource policy.
Resource policies
Taking the user resource as an example, the most basic resource policy can be defined like below:
---
apiVersion: api.cerbos.dev/v1
resourcePolicy:
version: "default"
resource: "user"
rules:
- actions:
- create
- read
effect: EFFECT_ALLOW
roles:
- user
- actions:
- create
- read
- update
- delete
effect: EFFECT_ALLOW
roles:
- admin
The structure of a resource policy requires a name to be set on the resource
key and then a list of rules is defined. A rule defines a list of actions on the resource, the effect of the rule (EFFECT_ALLOW
or EFFECT_DENY
) and then fields to state who this applies to - in this simple case a list of roles
which is checked for in the roles of the user making the request.
In this case, a request made for a principal with a role of user
is granted only create
and read
actions whilst an admin
role can also perform update
, delete
actions.
The full documentation for resource policies can be found here.
Wildcard action
To simplify things further, admins to be able to do every action so a special *
wildcard action can be used to keep things clean:
---
apiVersion: api.cerbos.dev/v1
resourcePolicy:
version: "default"
resource: "user"
rules:
- actions:
- create
- read
- update
effect: EFFECT_ALLOW
roles:
- user
- actions:
- "*"
effect: EFFECT_ALLOW
roles:
- admin
The contact
and company
resources have a similar structure at this stage and can be modeled as so:
---
apiVersion: api.cerbos.dev/v1
resourcePolicy:
version: "default"
resource: "contact"
rules:
- actions:
- create
- read
- update
- delete
effect: EFFECT_ALLOW
roles:
- user
- actions:
- "*"
effect: EFFECT_ALLOW
roles:
- admin
---
apiVersion: api.cerbos.dev/v1
resourcePolicy:
version: "default"
resource: "company"
rules:
- actions:
- create
- read
- update
- delete
effect: EFFECT_ALLOW
roles:
- user
- actions:
- "*"
effect: EFFECT_ALLOW
roles:
- admin
Validating policies
Now with the initial policies in place, you can run Cerbos in compile mode which validates the content of the policy files to ensure they are correct.
If you are running Cerbos in a container then mount the folder containing your policies and run the compile
command pointing to the folder of your policies.
# Using Container
docker run --rm --name cerbos -t \
-v /tutorial:/tutorial \
ghcr.io/cerbos/cerbos:latest compile /tutorial/policies
# Using Binary
./cerbos compile /tutorial/policies
If the policies are valid then the process exits with no errors. If there is an issue, the error message points you to where you need to look and the specific problem to fix.
Conclusion
At this stage, a simple Roles-based Access Control (RBAC) model has been designed and the policies have been validated - next up is making an authorization call to Cerbos.
Calling Cerbos
The policies for this section can be found on Github.
Now that you know the policies are valid, it is time to make your first call to Cerbos to make an authorization check.
Starting Cerbos
To start you need to launch the server:
# Using Container
docker run --rm --name cerbos -t \
-v /tutorial:/tutorial \
-p 3592:3592 ghcr.io/cerbos/cerbos:latest server --config=/tutorial/config/conf.yaml
# Using Binary
./cerbos server --config=/tutorial/config/conf.yaml
Once Cerbos has started up you should see an output confirming that there are 3 policies loaded and ready to start processing authorization checks:
2022-01-25T09:46:03.370Z INFO cerbos.server maxprocs: Leaving GOMAXPROCS=4: CPU quota undefined
2022-01-25T09:46:03.382Z INFO cerbos.index Found 3 executable policies
2022-01-25T09:46:03.383Z INFO cerbos.grpc Starting gRPC server at :3593
2022-01-25T09:46:03.383Z INFO cerbos.http Starting HTTP server at :3592
At this point how you make a request to the Cerbos instance is down to your preference - a simple cURL command or using a GUI such as Postman also works.
Cerbos check call
A call to Cerbos contains 3 key bits of information:
- The Principal - who is making the request
- The Resources - a map of entities of a resource kind that are they requesting access too
- The Actions - what actions are they trying to person on the entities
The request payload to the /api/check
endpoint takes these 3 bits of information as JSON:
{
"principal": {
"id": "user_1", // the user ID
"roles": ["user"], // list of roles from user's profile
"attr": {} // a map of attributes about the user - not used yet
},
"resource": {
"kind": "contact", // the type of the resoureces
"instances": { // a map of the resource instance(s) being checked
"contact_1": { // key is the ID of the resource instance
"attr": {} // a map of attributes about the resource - not used yet
}
}
},
"actions": ["read"] // the list of actions to be done on the resource
}
To make the actual call as a cURL with the default server config:
curl --location --request POST 'http://localhost:3592/api/check' \
--header 'Content-Type: application/json' \
--data-raw '{
"principal": {
"id": "user_1",
"roles": ["user"],
"attr": {}
},
"resource": {
"kind": "contact",
"instances": {
"contact_1": {
"attr": {}
}
}
},
"actions": ["read"]
}'
The response object looks as follows where for each instance of the resource the authorization decision for each action is either EFFECT_ALLOW
or EFFECT_DENY
depending on the policies:
{
"resourceInstances": {
"contact_1": {
"actions": {
"read": "EFFECT_ALLOW"
}
}
}
}
You can find the Swagger definition of the Cerbos API via going to the root of the Cerbos instance - for example http://localhost:3592 if running on the default port.
Conclusion
Now that you have made the first call to Cerbos you can move on to a way of checking policy logic without having to make individual calls each time by writing unit tests.
Testing policies
The policies for this section can be found on Github.
Cerbos allows you to write tests for policies and run them as part of the compilation stage to make sure that the policies do exactly what you expect. This saves the manual effort of running example requests over and over to ensure the policy logic is as you expect.
A test suite defines a number of resources and principals and the expected result of actions for any combination of them.
To define a test suite, create a tests
folder alongside your policy folder. In this folder, any number of tests can be fined as YAML but the file must end with _test
.
As an example, the contact
policy states that a user
can create, read and update a contact, but only an admin
can delete them - therefore you can create a test suite for this like the below:
---
name: ContactTestSuite
description: Tests for verifying the contact resource policy
principals:
admin:
id: admin
roles:
- admin
user:
id: user
roles:
- user
resources:
contact:
kind: contact
tests:
- name: Contact CRUD Actions
input:
principals:
- admin
- user
resources:
- contact
actions:
- create
- read
- update
- delete
expected:
- principal: admin
resource: contact
actions:
create: EFFECT_ALLOW
read: EFFECT_ALLOW
update: EFFECT_ALLOW
delete: EFFECT_ALLOW
- principal: user
resource: contact
actions:
create: EFFECT_ALLOW
read: EFFECT_ALLOW
update: EFFECT_ALLOW
delete: EFFECT_DENY
With this defined, you can now extend the compile command to also run the tests for example:
# Using Container
docker run --rm --name cerbos -t \
-v /tutorial:/tutorial \
-p 3592:3592 \
ghcr.io/cerbos/cerbos:latest compile --tests=/tutorial/tests /tutorial/policies
# Using Binary
./cerbos compile --tests=/tutorial/tests /tutorial/policies
If everything is as expected the output of the tests should be green:
Test results
= ContactTestSuite (contact_test.yaml)
== 'Contact CRUD Actions' for resource 'contact_test' by principal 'user' [OK]
== 'Contact CRUD Actions' for resource 'contact_test' by principal 'admin' [OK]
Full testing documentation can be found here.
Adding conditions
The policies for this section can be found on Github.
In the previous section, an RBAC policy was created that allowed anyone with a user
role to update a user resource - this isn't what is intended as it would allow users to update other users' profiles.
---
apiVersion: api.cerbos.dev/v1
resourcePolicy:
version: "default"
resource: "user"
rules:
- actions:
- create
- read
- update
effect: EFFECT_ALLOW
roles:
- user
# ....other conditions
This blanket approach is where using pure role-based access controls falls down as there is more nuanced required to meet the requirements.
Conditions
Cerbos is a powerful Attribute-based Access Control system that can make contextual decisions at request time whether an action can be taken.
In this scenario, Cerbforce's business logic states that a user can only update their own user profile. To implement this a check needs to be made to ensure the ID of the user making the request matches the ID of the user resource being updated.
Conditions in Cerbos are written in Common Expression Language (CEL) which is a simple way of defining boolean logic of conditions. In this environment, there are two main bits of data provided that are of interest request.principal
which is the information about the user making the request and request.resource
which is the information about the resource being accessed.
The data model for each of these is as follows:
// request.principal
{
"id": "somePrinicpalId", // the prinicpal ID
"roles": ["user"], // the list of roles from the auth provider
"attr": {
// a map of attributes about the prinicpal
}
}
// request.resource
{
"id": "someResourceId", // the resource ID
"attr": {
// a map of attributes about the resourece
}
}
Using this information a check to see if the principal ID is the same as the ID of the user resource being accessed can be defined as
request.resource.id == request.principal.id
Adding this to the policy request a new rule to be created that is just for the update
and delete
actions which are for the user
role and has a single condition.
---
apiVersion: api.cerbos.dev/v1
resourcePolicy:
version: "default"
resource: "user"
rules:
- actions:
- create
- read
effect: EFFECT_ALLOW
roles:
- user
- actions:
- update
- delete
effect: EFFECT_ALLOW
roles:
- user
condition:
match:
expr: request.resource.id == request.principal.id
# ....other conditions
Complex logic can be defined in conditions (or sets of conditions) which you can read more about in the docs.
Extending tests
Now that you have a conditional policy, you can add these as test cases in the user tests. You can now define multiple user
resources and principals and create test cases for ensuring the update
action is allowed when the ID of the principal matches the ID of the resource, as well as checking that it isn't allowed if the condition is not met.
---
name: UserTestSuite
description: Tests for verifying the user resource policy
principals:
admin:
id: admin
roles:
- admin
user1:
id: user1
roles:
- user
user2:
id: user2
roles:
- user
resources:
admin:
kind: user
id: admin
user1:
kind: user
id: user1
user2:
kind: user
id: user2
tests:
- name: User CRUD Actions
input:
principals:
- admin
- user1
- user2
resources:
- admin
- user1
- user2
actions:
- create
- read
- update
- delete
expected:
- principal: admin
resource: admin
actions:
create: EFFECT_ALLOW
read: EFFECT_ALLOW
update: EFFECT_ALLOW
delete: EFFECT_ALLOW
- principal: admin
resource: user1
actions:
create: EFFECT_ALLOW
read: EFFECT_ALLOW
update: EFFECT_ALLOW
delete: EFFECT_ALLOW
- principal: admin
resource: user2
actions:
create: EFFECT_ALLOW
read: EFFECT_ALLOW
update: EFFECT_ALLOW
delete: EFFECT_ALLOW
- principal: user1
resource: admin
actions:
create: EFFECT_ALLOW
read: EFFECT_ALLOW
update: EFFECT_DENY
delete: EFFECT_DENY
- principal: user1
resource: user1
actions:
create: EFFECT_ALLOW
read: EFFECT_ALLOW
update: EFFECT_ALLOW
delete: EFFECT_ALLOW
- principal: user1
resource: user2
actions:
create: EFFECT_ALLOW
read: EFFECT_ALLOW
update: EFFECT_DENY
delete: EFFECT_DENY
- principal: user2
resource: admin
actions:
create: EFFECT_ALLOW
read: EFFECT_ALLOW
update: EFFECT_DENY
delete: EFFECT_DENY
- principal: user2
resource: user1
actions:
create: EFFECT_ALLOW
read: EFFECT_ALLOW
update: EFFECT_DENY
delete: EFFECT_DENY
- principal: user2
resource: user2
actions:
create: EFFECT_ALLOW
read: EFFECT_ALLOW
update: EFFECT_ALLOW
delete: EFFECT_ALLOW
Derived roles
The policies for this section can be found on Github.
The business requirements for Cerbforce state that only an owner of Contacts and Companies are allowed to delete them from the system. With Cerbos, the aim is to keep policies as simple as possible and not repeat logic across different resources, so in this situation, a Derived Role can enable help.
Derived roles are a way of augmenting the broad roles with are attached to the user in the directory of the authentication system with contextual data to provide more fine-grained control at runtime. On every request, all the relevant derived role policies are evaluated and those matching roles are 'attached' to the user as Cerbos computes access.
Onwer derived role
In the Cerbforce data model, the contact
and company
both have an attribute called ownerId
which is the ID of the user that created the record. Rather than adding a condition to both of these resource policies, you are going to create a derived role that gives the principal an additional owner
role within the context of the request. The policy for this is as follows:
---
apiVersion: "api.cerbos.dev/v1"
description: |-
Common dynamic roles used within the Cerbforce app
derivedRoles:
name: cerbforce_derived_roles
definitions:
- name: owner
parentRoles: ["user"]
condition:
match:
expr: request.resource.attr.ownerId == request.principal.id
The structure is similar to a resource policy but rather than defining actions with conditions, it defines roles that are an extension of the listed parentRoles
and can have any number of conditions as with resources.
With this derived role policy setup a resource can import them and then make use of them in rules eg:
---
apiVersion: api.cerbos.dev/v1
resourcePolicy:
version: "default"
resource: "contact"
importDerivedRoles:
- cerbforce_derived_roles
rules:
- actions:
- create
- read
effect: EFFECT_ALLOW
roles:
- user
- actions:
- update
- delete
effect: EFFECT_ALLOW
derivedRoles:
- owner
- actions:
- "*"
effect: EFFECT_ALLOW
roles:
- admin
Full documentation can be found here.
Principal policies
The policies for this section can be found on Github.
The final type of policy that Cerbos supports is a principal policy which is a special type that allows user-specific overrides to be defined.
In the case of Cerbforce there is a Data Protection Officer (DPO) that handles any data deletion requests. By default, they would not have any delete access to contacts unless they were the owner of the record or have the admin
role. To overcome this a principal policy has been created which targets their userId and overrides this for the delete action on a contact resource:
---
apiVersion: "api.cerbos.dev/v1"
principalPolicy:
version: "default"
principal: "dpo1"
rules:
- resource: contact
actions:
- name: contact_delete
action: "delete"
effect: EFFECT_ALLOW
With this policy in place, when an authorization check is made with the principal ID of dpo1
the delete action on a contact
resource is overridden to be allowed.
Full documentation can be found here.
Attribute schema
The policies for this section can be found on Github.
An additional check bit of business logic has been introduced for the contact
resource which requires the active
attribute of a contact to be set to True
to be able to update
or delete
it. This is so that old contacts are kept for reporting purposes and can't be accidentally deleted or updated.
This now means there are two attributes of a contact
resource that are now required for the policies to be computed - ownerId
and active
. If either of these is not included in the request to check permissions the result would not be as expected (defaulting to EFFECT_DENY
).
To prevent this mistake, it is possible to define a schema for the attributes of a principal and resources which Cerbos validates against at request time to ensure all fields are provided as expected.
Defining schema
Atrribute schema are defined in JSON Schema (draft 2020-12) and stored in a special _schemas
sub-directory along side the policies
For the contact resource the schema looks like the following:
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"properties": {
"ownerId": { "type": "string" },
"active": { "type": "boolean" }
},
"required": ["ownerId", "active"]
}
Once defined, it is then linked to the resource via adding a reference in the policy:
---
apiVersion: api.cerbos.dev/v1
resourcePolicy:
version: "default"
resource: "contact"
importDerivedRoles:
- cerbforce_derived_roles
rules:
- actions:
- create
- read
effect: EFFECT_ALLOW
roles:
- user
- actions:
- update
- delete
effect: EFFECT_ALLOW
derivedRoles:
- owner
condition:
match:
expr: request.resource.attr.active == true
- actions:
- "*"
effect: EFFECT_ALLOW
roles:
- admin
schemas:
resourceSchema:
ref: cerbos:///contact.json
The same can be done with attributes of a principal - you can find out more in the documentation.
Enforcing schema
Validating the request against the schema is done at request time by the server - to enable this a new schema configuration block needs adding to the config.yaml
.
---
server:
httpListenAddr: ":3592"
storage:
driver: "disk"
disk:
directory: /tutorial/policies
schema:
enforcement: reject
With this now in place, any request that is made to check authorization of a contact
resource is rejected if the attributes are not provided or of the wrong type:
Request
{
"principal": {
"id": "user_1",
"roles": ["user"],
"attr": {}
},
"resource": {
"kind": "contact",
"instances": {
"contact_1": {
"attr": {
"ownerId": "user1"
}
}
}
},
"actions": ["read"]
}
Response
{
"resourceInstances": {
"contact_1": {
"actions": {
"read": "EFFECT_DENY"
},
"validationErrors": [
{
"message": "missing properties: 'active'",
"source": "SOURCE_RESOURCE"
}
]
}
}
}
Integrating Cerbos
With the policies now defined the authorization logic inside the app can be replaced with a call out to a running Cerbos instance.
Cerbos has SDKs available for Go, Node, Java with more coming soon. Documentation for these and other examples can be found here.