Advanced multi-tenant SaaS authorization with Cerbos: Role policies and scoped resource policies

Published by Alex Olivier on May 28, 2025
Advanced multi-tenant SaaS authorization with Cerbos: Role policies and scoped resource policies

Multi-tenancy in SaaS applications presents a critical challenge: ensuring robust access control that isolates tenant data and operations while maintaining flexibility and scalability. Cerbos, particularly with features like role policies and the fine-grained control offered by scoped resource policies (governed by scope permissions mode), provides a powerful toolkit to define and enforce multi-tenant security effectively.

In this article, we will:

  1. Explain key Cerbos concepts: Scopes, role policies, and scoped resource policies with scope permission modes.
  2. Demonstrate how these features combine to address the multi-tenant access control problem.
  3. Provide practical policy examples for a hypothetical SaaS HR platform.

 

The need for scalable and isolated access control in multi-tenant applications

As SaaS applications grow, managing access for numerous tenants, users, and roles becomes complex. Traditional, static approaches fall short. A solution like Cerbos is essential because:

  • Each tenant's data and operations must be strictly isolated, preventing any unauthorized cross-tenant access.
  • Tenants often require the ability to define or customize roles specific to their organizational structure, beyond generic platform roles.
  • The authorization system must seamlessly accommodate new tenants and evolving permission needs without manual, widespread policy overhauls.
  • Access controls must be auditable and capable of enforcing regulatory requirements (e.g., GDPR, SOC 2, HIPAA).

Cerbos addresses these challenges by enabling granular, hierarchical, and dynamically evaluated access control policies.

 

Key Cerbos concepts for multi-tenancy

Before diving into the solution, let's understand the core Cerbos components we'll be using:

1. Scopes

Scopes in Cerbos allow you to hierarchically structure your policies, typically along organizational or environmental boundaries. For multi-tenancy, a scope usually represents a tenant (e.g., acme_corp, beta_inc). Policies can be defined at a global (root) level or within specific scopes. This hierarchical structure is fundamental to isolating tenant configurations.

2. Role policies

Introduced in Cerbos PDP v0.41 and enhanced since, role policies allow you to define permissions from the perspective of a role, rather than directly on a resource or principal.

Key features of role policies Description
Role-centric definition Permissions are grouped by role, specifying what actions that role can perform on various resources, and removing the need for new roles to be added to the resource policies.
Inheritance and narrowing Role policies inherit from parentRoles. These parents can be roles defined in an Identity Provider (IdP) or other role policies within Cerbos. A role policy can only narrow the permissions granted by its parentRoles. It cannot grant permissions that are not already allowed by the parents.
Scope-aware Role policies are inherently scope-aware. A role policy defined with a scope attribute will only apply to principals acting within that specific tenant context.
Implicit deny Actions not explicitly listed in allowActions within a matching rule are implicitly denied for that rule. The policy as a whole represents an exhaustive view of what is allowed on that resource.

Example: Tenant-specific acme_corp_admin role policy

This defines an admin for the "acme_corp" tenant, inheriting from the global admin IdP role but narrowing its permissions.

# Filename: policies/acme_corp/role_tenant_admin.yaml
apiVersion: api.cerbos.dev/v1
rolePolicy:
  role: "acme_corp_admin" # Role name specific to this tenant or used in JWT
  scope: "acme_corp"
  parentRoles:
    - "admin" # Inherits from and narrows the global IdP 'admin' role
  rules:
    - resource: "leave_request"
      allowActions: ["create", "view:*", "approve", "reject"] # Cannot delete leave requests
      # Implicitly, this acme_corp_admin cannot delete salary_records or manage tenant_settings
      # if not specified here, because Role Policies are exhaustive for the defined role.
      # To allow those, they must be re-stated from the parent.
    - resource: "salary_record"
      allowActions: ["create", "view:*", "edit"] # Can't delete salary records
    # Note: If acme_corp_admin should manage tenant_settings, that rule must be copied from parent.
    # Role policies are an *exhaustive* view for that role; they don't merge rules from parents,
    # they only check if the allowed actions are a SUBSET of what parent roles allow.

More accurately, if acme_corp_admin is meant to be a constrained version of the global admin, the rules must be explicitly stated, and Cerbos ensures these stated rules don't exceed the parent's capabilities.

A more common pattern for tenant admins:

# Filename: policies/acme_corp/role_tenant_admin.yaml
apiVersion: api.cerbos.dev/v1
rolePolicy:
  role: "tenant_admin" # Generic name, scoped by context
  scope: "acme_corp"   # This policy applies when scope is acme_corp
  parentRoles:
    - "platform_tenant_admin" # A global role defining max tenant admin capabilities
  rules:
    - resource: "leave_request" # Rules specific to acme_corp's tenant_admin
      allowActions: ["create", "view:*", "approve"]
      condition:
        match:
          expr: R.attr.department == P.attr.department # Only within their department
    - resource: "salary_record"
      allowActions: ["view:*"]
      condition:
        match:
          expr: R.attr.confidentiality_level <= P.attr.clearance_level

In this pattern, the platform_tenant_admin would define the maximum allowable permissions for any tenant admin, and each scoped tenant_admin role policy would then pick a subset of those, possibly with further conditions.

3. Scoped resource policies & scope permission modes

While role policies define what roles can do, resource policies define access controls directly on resources. When resource policies are defined within a scope (e.g., a tenant-specific policy for salary_record), their interaction with policies in parent scopes (e.g., a global policy for salary_record) is governed by scope permission modes.

These modes are set on the scoped resource policy using the scopePermissions field:

  1. SCOPE_PERMISSIONS_OVERRIDE_PARENT (Default):

    • If a policy in a more specific scope (e.g., tenant scope) makes a decision (ALLOW or DENY) for a given action, that decision is final.
    • Parent scope policies are effectively ignored for that specific check once a matching rule is found in the child.
    • This mode gives tenants more autonomy but requires careful design to prevent overly permissive tenant policies if tenants can author them.
  2. SCOPE_PERMISSIONS_REQUIRE_PARENTAL_CONSENT_FOR_ALLOWS:

    • This is crucial for robust multi-tenant isolation where the platform needs to enforce overarching rules.
    • A scoped policy cannot grant permissions that are not also allowed by its parent scope policies.
    • Every EFFECT_ALLOW rule in the scoped policy is only effective if the same request would also be allowed by the parent scope's policies.
    • EFFECT_DENY rules in the scoped policy are always respected and can deny actions even if the parent would allow them.

 

Solving multi-tenancy for a SaaS HR platform

Let's consider a multi-tenant SaaS HR platform where:

  • The platform provider defines global rules and maximum permissions.
  • Each tenant (e.g., acme_corp, beta_inc) needs to manage its employees, leave requests, and salary records.
  • Tenant data must be strictly isolated.
  • Tenants might have slightly different rules for their managers or admins.

Step 1: Platform-wide guardrails (Root scope policies)

First, we define global resource policies that act as the ultimate guardrails. These ensure fundamental isolation and define maximum capabilities.

Global salary_record resource policy: This policy ensures that users can only interact with salary records belonging to their own tenant.

# Filename: policies/resource_salary_record.yaml
apiVersion: api.cerbos.dev/v1
resourcePolicy:
  version: "default"
  resource: "salary_record"
  # No scope here, so it's a root/global policy
  rules:
    - actions: ["view", "edit"] # Platform-defined actions
      effect: EFFECT_ALLOW
      roles: ["platform_tenant_admin", "platform_tenant_manager"] # Platform-defined roles
      condition:
        match:
          # Essential for multi-tenancy: ensure principal's tenant matches resource's tenant
          expr: R.attr.tenantId == P.attr.tenantId
    - actions: ["delete"]
      effect: EFFECT_ALLOW
      roles: ["platform_tenant_admin"] # Only tenant admins can delete
      condition:
        match:
          expr: R.attr.tenantId == P.attr.tenantId

Step 2: Tenant-specific role definitions (Scoped role policies)

Tenants can have their own variations of roles. For instance, acme_corp might have a department_manager role.

Scoped role policy for acme_manager (Tenant: Acme Corp): Acme Corp defines its acme_manager role, constrained by platform_tenant_manager.

# Filename: policies/acme_corp/role_acme_manager.yaml
apiVersion: api.cerbos.dev/v1
rolePolicy:
  role: "acme_manager" # This role is passed in the Principal's roles list for Acme Corp users
  scope: "acme_corp"
  parentRoles:
    - "platform_tenant_manager" # Ensures acme_manager doesn't exceed platform limits
  rules:
    - resource: "salary_record"
      allowActions: ["view"] # Acme managers can only view, not edit, salary records
      # Condition from root resource policy (R.attr.tenantId == P.attr.tenantId) still applies implicitly
      # because the resource policy is evaluated after the role policy grants initial access.
      # We can add further conditions here:
      condition:
        match:
          expr: R.attr.department == P.attr.department # Only view salaries in their own department
    - resource: "leave_request"
      allowActions: ["view:*", "approve"] # Cannot reject
  • acme_manager users in acme_corp scope can only view salary records within their department. They inherit the ability to view from platform_tenant_manager, but the edit action is not included, effectively narrowing permissions.
  • They also have slightly reduced permissions for leave_request compared to the template.

Step 3: Tenant-specific resource controls (Scoped resource policies with parental consent)

Sometimes, tenants need to define specific rules for resources beyond what roles define, or the platform wants to allow tenants to author their resource policies, but under strict supervision. This is where SCOPE_PERMISSIONS_REQUIRE_PARENTAL_CONSENT_FOR_ALLOWS is invaluable.

Tenant-scoped salary_record policy for beta_inc (Tenant Authored, Platform Governed): Let's say beta_inc wants very specific rules for who can edit salary records, and they author their own policy.

# Filename: policies/beta_inc/resource_salary_record.yaml
apiVersion: api.cerbos.dev/v1
resourcePolicy:
  version: "default"
  resource: "salary_record"
  scope: "beta_inc" # Policy specific to beta_inc
  scopePermissions: SCOPE_PERMISSIONS_REQUIRE_PARENTAL_CONSENT_FOR_ALLOWS # CRITICAL
  rules:
    - actions: ["edit"]
      effect: EFFECT_ALLOW
      roles: ["platform_tenant_admin"] # Beta Inc's tenant_admin
      # Beta Inc wants their tenant_admin to edit only if they are the record's direct_manager
      condition:
        match:
          expr: R.attr.direct_manager_id == P.id
    - actions: ["view"]
      effect: EFFECT_ALLOW
      roles: ["tenant_admin", "tenant_manager"] # Standard view access
    # Attempt to add a forbidden action:
    - actions: ["export_all_company_data"] # This action is NOT defined in the root policy
      effect: EFFECT_ALLOW
      roles: ["tenant_admin"]

How does this beta_inc role policy interact with the root salary_record resource policy?

  1. edit action for tenant_admin:
    • The beta_inc policy allows edit if R.attr.direct_manager_id == P.id.
    • The root policy also allows edit for platform_tenant_admin if R.attr.tenantId == P.attr.tenantId.
    • Due to REQUIRE_PARENTAL_CONSENT_FOR_ALLOWS, both conditions must effectively be met. The principal must be in beta_inc (from the check request's scope or principal attributes matching the root policy's condition on tenantId), be the direct_manager_id, and the resource must belong to beta_inc.
  2. view action:
    • Allowed by both beta_inc and root policies (assuming tenantId matches).
  3. export_all_company_data action:
    • The beta_inc policy attempts to EFFECT_ALLOW this.
    • However, the root salary_record policy does not define or allow the export_all_company_data action.
    • Because of SCOPE_PERMISSIONS_REQUIRE_PARENTAL_CONSENT_FOR_ALLOWS, this rule in the beta_inc policy will NEVER result in an ALLOW, as it lacks parental consent from the root policy. This prevents tenants from granting themselves arbitrary permissions.

 

How these policies work together in an access request

When Cerbos evaluates a request (e.g., principal P with roles: ["acme_manager"], scope: "acme_corp" wants to view resource R of type salary_record):

  1. Scope matching: Cerbos identifies relevant policies based on the request's scope (acme_corp).
  2. Principal policy evaluation (if any): Policies directly targeting the principal are checked.
  3. Role policy evaluation:
    • The acme_manager role policy (scoped to acme_corp) is found.
    • It allows view on salary_record if R.attr.department == P.attr.department.
    • Cerbos verifies that view on salary_record is permitted by its parentRoles (platform_tenant_manager). If this check passes, the role policy grants an initial EFFECT_ALLOW.
  4. Resource policy evaluation:
    • Cerbos finds the global salary_record policy.
    • It checks if its rules allow the action for an acme_manager (or a role which uses it as a parent, like platform_tenant_manager). The condition R.attr.tenantId == P.attr.tenantId is crucial.
    • If there were a salary_record resource policy scoped to acme_corp (like the beta_inc example), its rules and scopePermissions mode would also be evaluated in conjunction with the global policy.
  5. Final decision: If all relevant policy stages result in an overall ALLOW, access is granted. A DENY at any stage (e.g., by a deny rule, or lack of parental consent for an allow) can override.

 

Benefits for multi-tenant SaaS

  • Strong tenant isolation. SCOPE_PERMISSIONS_REQUIRE_PARENTAL_CONSENT_FOR_ALLOWS combined with tenant ID checks in root policies provides robust data boundaries.
  • Platform control with tenant flexibility. The platform sets the maximum boundaries, while tenants can customize roles (via role policies) and resource access rules (via scoped resource policies) within those boundaries.
  • Self-service potential. Tenants could potentially manage their own scoped policies (if your system allows deploying them), knowing they cannot overstep platform-defined limits.
  • Scalability. Defining policies hierarchically and per-role/resource avoids massive, monolithic policy files. Adding new tenants can be as simple as deploying a new set of scoped policies.
  • Auditability. Cerbos logs every decision, providing a clear audit trail for compliance and debugging.

 

Conclusion

Cerbos's role policies and scoped resource policies (with SCOPE_PERMISSIONS_REQUIRE_PARENTAL_CONSENT_FOR_ALLOWS) offer a sophisticated, flexible, and secure model for multi-tenant SaaS authorization. By layering platform-wide guardrails with tenant-specific customizations, developers can build systems that are both powerful for tenants and safely controlled by the platform provider. This approach ensures data isolation, supports custom role requirements, and scales effectively as the SaaS application grows.

If you want to dive deeper, check out Cerbos PDP, as well as our in-depth documentation.

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