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:

ResourceActions
UserCreate, Read, Update, Delete
CompanyCreate, Read, Update, Delete
ContactCreate, 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:

  1. The Principal - who is making the request
  2. The Resources - a map of entities of a resource kind that are they requesting access too
  3. 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.