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:
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:
In Cerbos, we define:
dataRecord
resource representing a piece of analyzable data.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
}
}
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
Cons of policy-defined roles
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
Cons of dynamic approach
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
.V.variableName
). For reusable expressions and cleaner policies.P.attr
, R.attr
). Accessing attributes of the principal and resource.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.
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
Join thousands of developers | Features and updates | 1x per month | No spam, just goodies.