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 three approaches to implementing hierarchy-based permissions in Cerbos, inspired by a real-world use case for a data analytics platform. All three 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.
- Attributes fetched at request time with Synapse. Keeping the generic policy from the second approach, but querying the hierarchy directly from the database that already holds it.
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:
- actions: ["*"]
effect: EFFECT_DENY
roles: ["*"]
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, to be narrowed by the role policies below
- actions: ["view", "analyze"]
effect: EFFECT_ALLOW
roles: ["employee"]
name: "base-employee-access"
Note that actions and roles are required on every resource policy rule, including deny rules. Use ["*"] to apply the rule to everything.
This sets up deny-by-default for cross-tenant access based on the resource's tenant attribute.
One thing to be clear about before we go further: role policies narrow permissions, they don't grant them. The base-employee-access rule above is what actually grants view and analyze. The role policies in the next two sections act as filters on top of that grant β a role policy on its own, with no matching resource policy rule for its parent role, allows nothing at all.
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
version: "default"
# Tenant-specific scope
scope: "alpha_org"
# Inherits from the 'employee' role
parentRoles: ["employee"]
rules:
- resource: dataRecord
# allowActions is the exhaustive list of permitted actions
allowActions: ["view", "analyze"]
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"]) ||
hierarchy(R.attr.geography).descendentOf(hierarchy(["Global", "Europe", "Germany"]))
# Match the business unit (Sales or its descendants within AlphaOrg)
- expr: >
hierarchy(R.attr.businessUnit) == hierarchy(["AlphaOrg", "Sales"]) ||
hierarchy(R.attr.businessUnit).descendentOf(hierarchy(["AlphaOrg", "Sales"]))
Here, hierarchy() and descendentOf() are powerful functions that implement ReBAC logic directly within the policy by checking resource attributes.
A few details about role policy syntax that are easy to get wrong:
- The field is
allowActions, notactions. It is the exhaustive list of actions the role permits on that resource β anything not listed is denied. - Role policy rules have no
effectfield. There is no ALLOW/DENY in a role policy;allowActionsis the permit list. - The hierarchy functions are method-style only and the spelling is
descendentOf, with an e. Writehierarchy(a).descendentOf(hierarchy(b)). There is no two-argumentdescendentOf(a, b)form.
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
version: "default"
scope: "alpha_org"
parentRoles: ["employee"]
rules:
- resource: dataRecord
allowActions: ["view", "analyze"]
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: >
hierarchy(R.attr.geography).immediateChildOf(hierarchy(["Global", "Europe"]))
Scope has to be on the request
Both role policies above declare scope: "alpha_org", and a scoped role policy only applies when the request carries a matching scope. It is the resource scope that selects it, so your check request needs to look like this:
{
"principal": {
"id": "user123",
"roles": ["germany_sales_manager"],
"attr": { "tenantId": "alpha_org" }
},
"resources": [
{
"actions": ["view", "analyze"],
"resource": {
"kind": "dataRecord",
"id": "record-1",
"scope": "alpha_org",
"attr": {
"tenant": "alpha_org",
"geography": ["Global", "Europe", "Germany", "Berlin"],
"businessUnit": ["AlphaOrg", "Sales", "EMEA_Sales"]
}
}
}
]
}
Leave scope off the resource and the scoped role policy never applies, so germany_sales_manager never resolves to its employee parent role and every action is denied.
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, SELF_DESCENDANTS, CHILDREN, SELF_CHILDREN
"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.
Note that this approach uses uppercase action names (VIEW, ANALYZE) where Approach 1 used lowercase (view, analyze). Cerbos action matching is case-sensitive, so pick one convention and stay on it β the two approaches as written here are not interchangeable.
There is one important constraint to design around: the action being evaluated is not available inside a condition. A CEL expression can see request.principal, request.resource and request.auxData, but there is no request.action. Rules are selected by action first and the condition runs afterwards, so the action is already out of scope by then.
That means allowed_actions has to be enforced structurally, with one rule per action, rather than with a single rule that reads the action dynamically:
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)
# The condition is required; without it this denies everything.
- actions: ["*"]
effect: EFFECT_DENY
roles: ["*"]
name: "tenant-isolation"
condition:
match:
expr: "P.attr.tenantId != R.attr.tenant"
# One rule per action, each checking the principal's allowed_actions
- effect: EFFECT_ALLOW
name: "view"
roles: ["employee"]
actions: ["VIEW"]
condition:
match:
all:
of:
- expr: '"VIEW" in P.attr.allowed_actions'
- expr: V.principalConditionMatch
- effect: EFFECT_ALLOW
name: "analyze"
roles: ["employee"]
actions: ["ANALYZE"]
condition:
match:
all:
of:
- expr: '"ANALYZE" in P.attr.allowed_actions'
- expr: V.principalConditionMatch
# ...EDIT and SHARE follow the same shape
The repetition is the price of enforcing a dynamic action list. If the list of actions is long, generate this policy rather than hand-writing it.
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.access_rules.all(rule,
(rule.depth_type == "SELF"
&& hierarchy(R.attr[rule.resource_attribute]) == hierarchy(rule.hierarchy_path)
)
||
(rule.depth_type == "DESCENDANTS"
&& hierarchy(R.attr[rule.resource_attribute]).descendentOf(hierarchy(rule.hierarchy_path))
)
||
(rule.depth_type == "SELF_DESCENDANTS"
&& (hierarchy(R.attr[rule.resource_attribute]) == hierarchy(rule.hierarchy_path)
|| hierarchy(R.attr[rule.resource_attribute]).descendentOf(hierarchy(rule.hierarchy_path)))
)
||
(rule.depth_type == "CHILDREN"
&& hierarchy(R.attr[rule.resource_attribute]).immediateChildOf(hierarchy(rule.hierarchy_path))
)
||
(rule.depth_type == "SELF_CHILDREN"
&& (hierarchy(R.attr[rule.resource_attribute]) == hierarchy(rule.hierarchy_path)
|| hierarchy(R.attr[rule.resource_attribute]).immediateChildOf(hierarchy(rule.hierarchy_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.
The field names here have to match the principal attributes exactly β access_rules, resource_attribute, depth_type and hierarchy_path. This is the easiest thing in the whole approach to get wrong, and the failure mode is nasty: CEL resolves a missing map key to an empty value rather than erroring, so a mismatch compiles cleanly and then denies every request with no diagnostic to work from. If this policy denies everything, check the spelling of these four names first.
You may also have noticed there is no LEAVES depth type. A hierarchy value is a standalone path with no knowledge of the wider tree, so it cannot tell whether it is a leaf β that check has to happen in your data layer, by passing a boolean resource attribute like isLeaf and testing it alongside DESCENDANTS.
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.
Approach 3: Let Synapse fetch the hierarchy for you
That last con is the real cost of Approach 2. The policy is generic and stable, but something has to build access_rules correctly on every single request β which usually means the calling application learns the shape of your hierarchy, queries it, and marshals the result into the principal. That logic tends to get duplicated across every service that performs a check, and it drifts.
Cerbos Synapse moves that work out of your application. It sits in front of the PDP and enriches authorization requests with identity, resource and relationship data pulled from your existing systems at request time β identity providers like Okta, Entra ID and Keycloak, and databases including PostgreSQL, MySQL, Oracle and Neo4j, plus custom WebAssembly connectors if you need something else.
Hierarchies are a particularly good fit here, because a graph database is already the natural home for one. Ancestor and descendant traversal is exactly what a graph query is good at, and it is exactly the question hierarchical authorization keeps asking.
This works through two extension points. A data source connects to the system holding your data β for a graph database, a small connector that takes a query and returns the rows. A proxy extension intercepts each incoming authorization request, calls that data source, and writes the results onto the principal before the PDP sees the request.
extensions:
dataSources:
hierarchyGraph:
extension:
extensionURL: /extensions/neo4j-data-source.crbs
configuration:
uri: ${NEO4J_URI}
username: ${NEO4J_USERNAME}
password: ${NEO4J_PASSWORD}
proxyExtensions:
principalEnricher:
extensionURL: /extensions/principal-enricher.star
If you model each hierarchy node as a vertex and each grant as an edge carrying its depth, one query returns everything the policy needs:
MATCH (u:User {id: $userId})-[g:HAS_ACCESS_TO]->(n:Node)
RETURN collect(DISTINCT {
resource_attribute: n.dimension,
depth_type: g.depth,
hierarchy_path: n.path
}) AS accessRules
The enricher then maps that straight onto the attribute names the policy already reads:
def enrich(req):
resp = cerbos.data_source_lookup(
datasource="hierarchyGraph",
query={"cypher": ACCESS_RULES_QUERY, "params": {"userId": req.principal.id}},
)
if resp == None or resp.result == None:
return req
attr = dict(req.principal.attr)
attr["access_rules"] = resp.result[0].get("accessRules", [])
req.principal.attr = attr
return req
def augment_check_request(req):
return enrich(req)
def augment_plan_request(req):
return enrich(req)
The important part: the policy does not change. The dataRecord.resource.yaml and hierarchy_checks.variables.yaml from Approach 2 work unmodified, because the enricher produces exactly the access_rules shape they already expect. Your services go back to sending a user ID and a resource, and the hierarchy lives in one place, queried fresh on every decision.
Note that the enricher hooks both augment_check_request and augment_plan_request. That second one matters for hierarchies: query planning is how you turn a policy into a database filter for list endpoints, and it needs the same enriched attributes to produce a correct plan.
This also composes with the layering the other approaches use. The graph answers the relational question β which nodes does this user touch, and how deep β while the policy keeps doing the attribute-based refinement on top: tenant isolation, action lists, and any conditions on the resource itself. ReBAC and ABAC in the same decision, each expressed where it belongs.
Pros of the Synapse approach
- Callers no longer need to know anything about the hierarchy. No attribute-marshalling code to duplicate or keep in sync.
- The hierarchy is read at decision time from the system of record, so moving a node takes effect immediately rather than whenever tokens or caches refresh.
- A graph database models and traverses the hierarchy far more naturally than flattened path arrays passed by hand.
- Works for query planning as well as point checks, so list endpoints stay consistent with single-resource decisions.
Cons of the Synapse approach
- Another component to deploy and operate alongside the PDP.
- Decisions now depend on your graph database being available and fast, so it belongs on the latency and availability budget for authorization.
- The enrichment logic is code, and it needs testing like any other code β it has simply moved out of your services and into one place.
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 (
descendentOf,immediateChildOf) are a direct implementation of ReBAC concepts. hierarchy(). Creates a comparable hierarchy object from a list or a dotted string.h1.descendentOf(h2). Checks ifh1is a descendant ofh2. Note the spelling βdescendent, with an e.h1.immediateChildOf(h2). Checks ifh1is an immediate child ofh2.- All hierarchy functions are method-style: the first hierarchy is the receiver, not a first argument. There is no
descendentOf(h1, h2)form. - Variables (
V.variableName). For reusable expressions and cleaner policies. - Principal & resource attributes (
P.attr,R.attr). Accessing attributes of the principal and resource. The action being checked is not available in a condition. - Scope. Used in role policies to associate them with a specific tenant. The scope must be set on the resource in the check request for a scoped role policy to apply.
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.
-
Synapse-fetched attributes are the right call when:
- The hierarchy already lives in a database β especially a graph database β and you would rather query it than replicate it.
- Several services perform checks and you don't want each one reimplementing attribute marshalling.
- Hierarchy changes need to take effect immediately, without waiting for a token or cache to refresh.
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, to letting Synapse fetch the hierarchy from the database that already holds it. Functions like hierarchy(), descendentOf(), 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.
Hierarchical policies are where a policy set starts to need real version control, testing and staged rollout, because a single change to a parent node moves access for everything beneath it.
Try Cerbos Hub to test these policies against real principals before they reach production, or book a session to model your own hierarchy with our engineers.
Go deeper: A guide to multitenant authorization (eBook) for the tenant isolation patterns underneath this example.
FAQ
Tagged in




