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:
- Policy-defined roles with attribute-based conditions. Defining explicit role policies for each tenant where hierarchical logic is hardcoded inside the policy.
- 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
dataRecordresource 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 ifh1is a descendant ofh2.immediateChildOf(h1, h2). Checks ifh1is an immediate child ofh2.- 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.
Tagged in




