Mastering hierarchy-based permissions with Cerbos - Policy-defined roles vs. dynamic attributes

Published by Alex Olivier on June 18, 2025
Mastering hierarchy-based permissions with Cerbos - Policy-defined roles vs. dynamic attributes

Effective Identity and Access Management is critical for modern applications, and a core component of IAM is authorization. Handling permissions becomes especially complex in applications with hierarchical data, like organizational structures, geographical regions, or product categories. This is a classic challenge in achieving fine-grained authorization.

Users often need access not just to a specific node in the hierarchy but also to its descendants or immediate children, a pattern often addressed by Relationship-Based Access Control (ReBAC). Cerbos, an open source, stateless authorization layer, provides powerful and flexible ways to manage such scenarios through Policy-Based Access Control (PBAC).

In this post, we'll explore two approaches to implementing hierarchy-based permissions in Cerbos, inspired by a real-world use case for a data analytics platform. Both methods leverage Attribute-Based Access Control (ABAC), but differ in their implementation strategy:

  1. Policy-defined roles with attribute-based conditions. Defining explicit role policies for each tenant where hierarchical logic is hardcoded inside the policy.
  2. Dynamic, attribute-driven generic policies. Shifting the hierarchical conditions entirely to the principal's attributes and using a single, generic policy for interpretation.

The use case. Multi-tenant data analytics platform

Imagine a platform that provides data analytics services. This platform is multi-tenant, meaning different client companies (tenants) use it. Each tenant has its own users, roles, and data, which is categorized by attributes like geography (e.g., Global > Europe > Germany > Berlin) and businessUnit (e.g., AlphaOrg > Sales > EMEA_Sales).

Key fine-grained authorization requirements include:

  • Restricting data visibility based on a user's role within their tenant.
  • Allowing users to see data for their specific hierarchical node and potentially its descendants or children - a core ReBAC problem.
  • Ensuring strict data isolation between tenants.

Setting the stage. Principals, resources, and hierarchies

In Cerbos, we define:

  • Principals. The actors in the system (users, services). They have an ID, roles, and attributes.
  • Resources. The objects principals interact with. In our case, a generic dataRecord resource representing a piece of analyzable data.
  • Policies. The rules that determine what actions a principal can perform on a resource. These policies are the foundation of a PBAC system.

Our dataRecord resource will have attributes like:

  • geography. An array representing its geographical hierarchy (e.g., ["Global", "Europe", "Germany", "Berlin"]).
  • businessUnit. An array representing its organizational hierarchy (e.g., ["AlphaOrg", "Sales", "EMEA_Sales"]).
  • tenant. The tenant ID this data belongs to (e.g., "alpha_org", "delta_inc").

A principal might look like this (attributes vary based on the approach):

{
  "id": "user123",
  // Roles depend on the approach
  "roles": ["employee", "germany_sales_manager"], 
  "attr": {
    // User's tenant
    "tenantId": "alpha_org" 
    // Other attributes for dynamic approach later
  }
}

Approach 1: Policy-defined roles with attribute-based conditions

In this approach, we create distinct role policies that act as named containers for a set of attribute-based rules. While it uses roles, it is a form of ABAC because the decision logic relies on evaluating attributes of the principal and resource.

1. Base employee and deny rules

First, we usually have a base employee role and some global deny rules. A dataRecord.resource.yaml might start with:

apiVersion: api.cerbos.dev/v1
resourcePolicy:
  resource: dataRecord
  version: "default"
  # Global deny rules for tenant isolation
  rules:
    - effect: EFFECT_DENY
      name: "tenant-isolation"
      condition:
        match:
          # Deny if user's tenant not data's tenant
          expr: "P.attr.tenantId != R.attr.tenant"

    # Base employee rule:
    # Allow view/analyze, but will be constrained by derived roles
    - actions: ["view", "analyze"]
      effect: EFFECT_ALLOW
      roles: ["employee"]
      name: "base-employee-access"
      # Further conditions will be added by derived roles

This sets up deny-by-default for cross-tenant access based on the resource's tenant attribute. The employee role itself might grant broad permissions that are then refined by more specific derived roles.

2. Sales manager for Germany (Tenant: AlphaOrg)

A sales manager for Germany at AlphaOrg should see all sales data for Germany and its sub-regions (e.g., Berlin).

alpha_org-germany_sales_manager.role.yaml:

apiVersion: api.cerbos.dev/v1
rolePolicy:
  # Specific role name
  role: germany_sales_manager 
  # Tenant-specific scope
  scope: "alpha_org"
  # Inherits from the 'employee' role
  parentRoles: ["employee"]
  rules:
    - resource: dataRecord
      actions: ["view", "analyze"]
      effect: EFFECT_ALLOW
      condition:
        match:
          all: # Both conditions must be true
            of:
              # Match the region (Germany or its descendants)
              - expr: >
                  (hierarchy(R.attr.geography) == hierarchy(["Global", "Europe", "Germany"])) ||
                  (descendantOf(hierarchy(R.attr.geography), hierarchy(["Global", "Europe", "Germany"])))
              # Match the business unit (Sales or its descendants within AlphaOrg)
              - expr: >
                  (hierarchy(R.attr.businessUnit) == hierarchy(["AlphaOrg", "Sales"])) ||
                  (descendantOf(hierarchy(R.attr.businessUnit), hierarchy(["AlphaOrg", "Sales"])))

Here, hierarchy() and descendantOf() are powerful functions that implement ReBAC logic directly within the policy by checking resource attributes.

3. Operations director for Europe (Tenant: AlphaOrg)

An operations director for Europe might only see data for the Europe level and its immediate children (e.g., Germany, France), but not grandchildren (e.g., Berlin).

alpha_org-europe_ops_director.role.yaml:

apiVersion: api.cerbos.dev/v1
rolePolicy:
  role: europe_ops_director
  scope: "alpha_org"
  parentRoles: ["employee"]
  rules:
    - resource: dataRecord
      actions: ["view", "analyze"]
      effect: EFFECT_ALLOW
      condition:
        match:
          # Either condition can be true
          any: 
            of:
              # Exact match Europe
              - expr: >
                  hierarchy(R.attr.geography) == hierarchy(["Global", "Europe"])
              # Immediate children of Europe
              - expr: >
                  immediateChildOf(hierarchy(R.attr.geography), hierarchy(["Global", "Europe"]))

Pros of policy-defined roles

  • The logic for each role is self-contained and easy to read.
  • Ideal for roles with unique, unchanging hierarchical needs.

Cons of policy-defined roles

  • Can lead to many policy files if you have numerous tenants and fine-grained roles within each (policy proliferation).
  • Changes to hierarchical access logic require editing and deploying the policy file itself.

Approach 2: Dynamic, attribute-driven authorization

This approach centralizes the permission logic into a more generic resource policy and drives the specifics entirely through attributes passed with the principal during an access check. This is a more pure implementation of ABAC and PBAC.

1. Modified principal attributes

The principal object now carries all the specific conditions defining their access.

{
  "id": "user456",
  // Base role
  "roles": ["employee"], 
  "attr": {
    "tenantId": "alpha_org",
    // Actions this principal can perform if conditions match
    "allowed_actions": ["VIEW", "ANALYZE"], 
    // List of hierarchical conditions to satisfy
    "access_rules": [ 
      {
        // Which resource attribute to check (e.g., R.attr.geography)
        "resource_attribute": "geography", 
        // SELF, DESCENDANTS, CHILDREN, SELF_CHILDREN, LEAVES    
        "depth_type": "SELF_DESCENDANTS",  
         // The hierarchy path to match against
        "hierarchy_path": ["Global", "Europe", "Germany"]
      },
      {
        "resource_attribute": "businessUnit",
        "depth_type": "SELF_DESCENDANTS",
        "hierarchy_path": ["AlphaOrg", "Sales"]
      }
    ]
  }
}

2. Generic resource policy (dataRecord.resource.yaml)

This single policy evaluates the conditions from the principal's attributes.

apiVersion: api.cerbos.dev/v1
resourcePolicy:
  resource: dataRecord
  version: "default"
  variables:
    import:
      - hierarchy_checks # Imports shared logic for matching conditions
  rules:
    # Global deny rules (tenant isolation - same as before)
    - effect: EFFECT_DENY
      name: "tenant-isolation"
      # ... (conditions as in Approach 1)

    # Allow actions based on principal's permissions and conditions
    - effect: EFFECT_ALLOW
      roles: ["employee"] # Applies to all authenticated users
      actions: ["VIEW", "ANALYZE", "EDIT", "SHARE"] # Broad actions
      condition:
        match:
          all:
            of:
              # Action must be in principal's allowed actions
              - expr: request.action in P.attr.allowed_actions
              # All principal conditions must match resource
              - expr: V.principalConditionMatch

The magic happens in hierarchy_checks.variables.yaml, where the PBAC engine interprets the principal's attributes:

apiVersion: api.cerbos.dev/v1
exportVariables:
  name: hierarchy_checks
  definitions:
    principalConditionMatch: >
      P.attr.conditions.all(condition, 
        (condition.depth == "SELF" 
          && hierarchy(R.attr[condition.attribute]) == (hierarchy(condition.path))
        )
        ||
        (condition.depth == "DESCENDANTS" 
          && hierarchy(R.attr[condition.attribute]).descendentOf(hierarchy(condition.path))
        )
        ||
        (condition.depth == "SELF_DESCENDANTS" 
          && (hierarchy(R.attr[condition.attribute]) == hierarchy(condition.path) 
          || hierarchy(R.attr[condition.attribute]).descendentOf(hierarchy(condition.path)))
        )
        ||
        (condition.depth == "CHILDREN"
          && hierarchy(R.attr[condition.attribute]).immediateChildOf(hierarchy(condition.path))
        )
        ||
        (condition.depth == "SELF_CHILDREN"
          && (hierarchy(R.attr[condition.attribute]) == hierarchy(condition.path)
          || hierarchy(R.attr[condition.attribute]).immediateChildOf(hierarchy(condition.path)))
        )
        ||
        (condition.depth == "LEAVES"
          && hierarchy(R.attr[condition.attribute]).descendentOf(hierarchy(condition.path))
        )
      )

This principalConditionMatch variable iterates through all rules in P.attr.access_rules. For each rule, it applies the correct hierarchical check against the specified resource attribute and hierarchy path. If all these rules evaluate to true, and the requested action is in P.attr.allowed_actions, access is granted.

Pros of dynamic approach

  • A single set of policies can handle numerous tenants and permissions. New roles or access patterns are managed by changing principal attributes, not Cerbos policies.
  • Easier to adapt to evolving access requirements by updating attributes.
  • The core hierarchical logic is in one place.

Cons of dynamic approach

  • The policy logic itself can be more complex to write initially.
  • Requires a robust system to manage and correctly populate principal attributes at runtime.

Key Cerbos concepts illustrated

  • ABAC. A model where AuthZ decisions are made by evaluating attributes. Both approaches in this article are forms of ABAC, differing in whether rules are defined statically in policies or dynamically in principal attributes.
  • PBAC. An approach where policies are executable logic that evaluates request context. Cerbos is a PBAC engine that can implement various authorization models.
  • RBAC. A model where permissions are assigned to roles. Our first approach uses roles as a familiar organizational concept, but enhances it with attribute checks.
  • ReBAC. An access model based on relationships between entities. The hierarchy functions (descendantOf, immediateChildOf) are a direct implementation of ReBAC concepts.
  • hierarchy(). Creates a comparable hierarchy object.
  • descendantOf(h1, h2). Checks if h1 is a descendant of h2.
  • immediateChildOf(h1, h2). Checks if h1 is an immediate child of h2.
  • Variables (V.variableName). For reusable expressions and cleaner policies.
  • Principal & resource attributes (P.attr, R.attr). Accessing attributes of the principal and resource.
  • Scope. Used in role policies to associate them with a specific tenant.

Choosing your approach

  • Policy-defined roles are suitable if:
    • You have a small, relatively fixed number of tenants and roles.
    • Hierarchical rules are simple and don't change often.
    • Explicitness and auditability per role are highly valued.
  • Dynamic attribute-driven policies are powerful when:
    • You have many tenants or expect rapid growth.
    • Roles and their hierarchical access needs vary significantly and change frequently.
    • You prefer to manage access specifics via attributes in your identity provider or user database rather than in policy code.

Often, a hybrid approach can also work, where some common, stable roles are defined in static policies, and more variable or numerous ones are handled dynamically.

Conclusion

Cerbos offers tools for managing complex hierarchy-based permissions. By leveraging powerful ABAC capabilities, you can implement the authorization strategy that best fits your needs - from explicit, policy-defined roles to fully dynamic, attribute-driven models. Functions like hierarchy(), descendantOf(), and dynamic principal attributes enable you to build scalable and maintainable authorization systems that accurately reflect your business rules, whether you opt for static definitions or a more dynamic strategy.

If you want to dive deeper, check out Cerbos PDP, join one of our engineering demos or check out our in-depth documentation.

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