Why Cerbos doesn’t support role inheritance

Published by Sam Lock on July 19, 2022
image

Decoupling Auth*

  1. *entication: Who is this person?

  2. *orization: What can this person do?

There are a plethora of hosted authentication services out there (Auth0, AWS Cognito, FusionAuth, to name a few). These services are very capable of answering question 1. Depending on which one you use and how you use it, it’s likely that it can also achieve a great deal of other things.

Not only do these “identity providers” (IdPs) deal with identity - they can also deal with how these identities fit into your organisation. This is often achieved, in the simplest scenarios, by adding identities to groups (e.g. Auth0, AWS Cognito). In more complex scenarios, it might be achieved via integrations with LDAP, offering a hierarchy of identities (think a tree structure with parents inheriting the permissions of its children nodes).

Flexibility > Complexity

Cerbos doesn’t know what design decisions users may have made when building their organisational structures. Perhaps each defined role in the IdP is independent? Perhaps they intersect (logical or)? Perhaps they union (logical and)? Perhaps there are implied hierarchies? Perhaps there are explicit ones?

In the spirit of simplicity and clean programmatic boundaries, Cerbos made the decision to offload this responsibility to the IdPs entirely, and maintain a completely flat and flexible role structure.

To the end user, the cost of this decision could be the need to replicate policy rules between roles which may have a predefined relationship within the IdP. However, the benefit is that they have no knowledge requirement of any of those relationships that are defined within the IdP. When developing an application, the IdP and all of its complexity can remain entirely obscured, meaning a much simpler development experience.

Modelling relationships in your application

That said, perhaps you have no predefined relationships in your IdP and you would like to implement role relationships into your application. Let’s take a look at a few potential approaches:

Standalone policies: the simple, repetitive approach

This is the simplest implementation. Each role type has all of its access rules attached directly to it. The cost is duplicating rule logic between roles (more dev time). The benefit is that you only ever need to pass the specific roles when executing a query against Cerbos.

apiVersion: api.cerbos.dev/v1
resourcePolicy:
  version: default
  resource: batmobile
  rules:
    - actions: ["drive", "wash"] # we duplicate `wash` here
      effect: EFFECT_ALLOW
      roles:
        - batman

    - actions: ["wash"]
      effect: EFFECT_ALLOW
      roles:
        - batcave_assistant
from cerbos.sdk.model import Principal, Resource
from cerbos.sdk.client import CerbosClient

p = Principal(
    user_id,
    roles=["batman"],
)

r = Resource(
    resource_id,
    kind="batmobile",
)

with CerbosClient("https://localhost:3592") as c:
    is_allowed = c.is_allowed("wash", p, r)

It’s worth acknowledging that the policy rules from each role the principal is a member of are union'd when resolving against a resource (e.g. all rules from role A and all from role B). If you look at the example above, the batman role wouldn’t strictly need to have the wash action if everybody who had the batman role also, by default, had the batcave_assistant role. However, by keeping the repetition and applying all access rules to all roles, we’re ensuring a clean boundary between the application and the IdP.

Derived roles: attribute magic

Cerbos provides the concept of derived roles. These enable you to build much more complex relationships via a combination of Cerbos’ powerful attribute mechanism and the IdP provided identities. Theoretically speaking; given that you can group the IdP roles, you can effectively model a single level of inheritance whereby the derived role becomes the “parent” of all listed IdP roles.

Food for thought: Cerbos’ attribute system is completely freeform, and very powerful - any arbitrary attribute can be used. Dependent on your needs, perhaps your problem can be solved without any complex application-defined relationships and just through the use of derived roles.

The following example shows how you might model a multi-tenant system where principals can belong to multiple "teams", with different roles for each:

The batcave has multiple batmobiles on rotation, with a team responsible for each. To ensure a high quality of maintenance, Batman insists that each team can only be responsible for carrying out work on their own batmobile, and that it is up to other teams to inspect and sign off on them (thus preventing any shoddy work).

To model this, we maintain admin and user as regular IdP roles, and then derive the following additional roles from the user (admin maintains it's super-privileges):

apiVersion: api.cerbos.dev/v1
derivedRoles:
  name: team_roles
  definitions:
    - name: mechanic
      parentRoles:
        - user
      condition:
        match:
          expr: R.attr.teamId in P.attr.teams.filter(x, P.attr.teams[x].role == "mechanic")

    - name: inspector
      parentRoles:
        - user
      condition:
        match:
          expr: R.attr.teamId in P.attr.teams.filter(x, P.attr.teams[x].role == "inspector")

We then import these, and use them in specific resource policies, like so:

apiVersion: api.cerbos.dev/v1
resourcePolicy:
  version: default
  resource: batmobile
  importDerivedRoles:
    - team_roles
  rules:
    - actions:
        - "*"
      effect: EFFECT_ALLOW
      roles:
        - admin

    - actions:
        - "drive:slowly"
        - "oil_change"
      effect: EFFECT_ALLOW
      derivedRoles:
        - mechanic

    - actions: ["inspect"]
      effect: EFFECT_ALLOW
      derivedRoles:
        - inspector

Now we can determine access based on what role the principal has in the given team. In this case, we're seeing what actions Alfred can carry out on batmobiles belonging to team1 and team2:

roles = {"user"}  # retrieved from the IdP
p = Principal(
    "albert",
    roles=roles,
    attr={
        "teams": {
            "team1": {
                "role": "mechanic",
            },
            "team2": {
                "role": "inspector",
            },
        },
    },
)

actions = ["drive:*", "inspect", "drive:slowly", "oil_change"]
resource_list = ResourceList(
    resources=[
        ResourceAction(
            Resource(
                "bat1",
                kind="batmobile",
                attr={
                    "teamId": "team1",
                },
            ),
            actions=actions,
        ),
        ResourceAction(
            Resource(
                "bat2",
                kind="batmobile",
                attr={
                    "teamId": "team2",
                },
            ),
            actions=actions,
        ),
    ]
)

with CerbosClient(host="http://localhost:3592") as c:
    resp = c.check_resources(principal=p, resources=resource_list)

resp is as follows:

{
  "results": [
    {
      "resource": {
        "id": "bat1",
        "kind": "batmobile",
        "attr": {},
        "policy_version": "default",
        "scope": ""
      },
      "actions": {
        "drive:*": "EFFECT_DENY",
        "drive:slowly": "EFFECT_ALLOW",
        "inspect": "EFFECT_DENY",
        "oil_change": "EFFECT_ALLOW"
      },
      "validation_errors": null
    },
    {
      "resource": {
        "id": "bat2",
        "kind": "batmobile",
        "attr": {},
        "policy_version": "default",
        "scope": ""
      },
      "actions": {
        "drive:*": "EFFECT_DENY",
        "drive:slowly": "EFFECT_DENY",
        "inspect": "EFFECT_ALLOW",
        "oil_change": "EFFECT_DENY"
      },
      "validation_errors": null
    }
  ],
  "status_code": 200,
  "status_msg": null
}

You can see how, given his different roles in each team, Alfred's permissions also change.

Hierarchies: tree structures

Perhaps you don’t want principals to carry multiple roles. Perhaps you want them to be associated with a single role, e.g. their job title, or their position within an organisation.

In this approach, we model an organisation as a tree structure. The root of the tree represents full organisation ownership. Each child node represents a department, sub-department, team, etc; in descending order. By default, each node will automatically receive all of the permissions of its children. When you want to obtain rules for a given role, you simply carry out a sub-tree traversal, taking a union of all of the policies of each of the nodes’ children.

apiVersion: api.cerbos.dev/v1
resourcePolicy:
  version: default
  resource: batmobile
  rules:
    - actions: ["drive:*"]
      effect: EFFECT_ALLOW
      roles:
        - batman

    - actions: ["inspect"]
      effect: EFFECT_ALLOW
      roles:
        - butler

    - actions: ["drive:slowly"]
      effect: EFFECT_ALLOW
      roles:
        - batcave_mechanic

    - actions: ["oil_change"]
      effect: EFFECT_ALLOW
      roles:
        - batcave_jr_mechanic

    - actions: ["wash"]
      effect: EFFECT_ALLOW
      roles:
        - batcave_valet

A tree structure can be modelled in many ways. For simplicity, in the below example, I’m modelling it as a map based structure, with string keys, and set[string] values. The key represents a node, and its corresponding set represents its children.

# batman (root)
# └── butler
#     ├── batcave_valet
#     └── batcave_mechanic
#         └── batcave_jr_mechanic

tree: dict[str, set] = {
    "batman": {"butler"},
    "butler": {"batcave_mechanic", "batcave_valet"},
    "batcave_mechanic": {"batcave_jr_mechanic"},
    "batcave_jr_mechanic": {},
    "batcave_valet": {},
}

def get_all_children_roles(role: str) -> set:
    roles = set()
    rem = [role]
    while len(rem):
        cur, *rem = rem
        roles.add(cur)
        if (children := tree.get(cur)) is not None:
            rem.extend(children)
    return roles

role = "butler"  # retrieved from the IdP
p = Principal(
    "alfred",
    roles=get_all_children_roles(role),
)

resource_list = ResourceList(
    resources=[
        ResourceAction(
            Resource(
                "bat1",
                kind="batmobile",
            ),
            actions=["drive:*", "inspect", "drive:slowly", "oil_change", "wash"],
        ),
    ]
)

with CerbosClient(host="http://localhost:3592") as c:
    resp = c.check_resources(principal=p, resources=resource_list)

The butler role can do everything, except for driving like a lunatic - the above returns the following (truncated for clarity):

{
  "results": [
    {
      "actions": {
        "drive:*": "EFFECT_DENY",
        "drive:slowly": "EFFECT_ALLOW",
        "inspect": "EFFECT_ALLOW",
        "oil_change": "EFFECT_ALLOW",
        "wash": "EFFECT_ALLOW"
      },
    }
  ],
}

A batcave_mechanic can only drive:slowly or carry out an oil_change:

{
  "results": [
    {
      "actions": {
        "drive:*": "EFFECT_DENY",
        "drive:slowly": "EFFECT_ALLOW",
        "inspect": "EFFECT_DENY",
        "oil_change": "EFFECT_ALLOW",
        "wash": "EFFECT_DENY"
      },
    }
  ],
}

You can see how a call to the get_all_children_roles method with the single role returns a set of all children roles, and therefore all of the corresponding access rules.

Needless to say, batman can do anything.


Cerbos has been built to be clean and unopinionated, while still offering the capability to model any system you can throw at it. If you're just playing around and don't know where to start, then check out the examples on the playground to get a taste of what it can do.

As always, if you have any questions or feedback on this topic or anything else Cerbos, join our Slack community and we'll be keen to chat! Thanks for reading!

Book a free Policy Workshop to discuss your requirements and get your first policy written by the Cerbos team