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:
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:
Cerbos addresses these challenges by enabling granular, hierarchical, and dynamically evaluated access control policies.
Before diving into the solution, let's understand the core Cerbos components we'll be using:
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.
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. |
acme_corp_admin
role policyThis 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.
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:
SCOPE_PERMISSIONS_OVERRIDE_PARENT
(Default):
SCOPE_PERMISSIONS_REQUIRE_PARENTAL_CONSENT_FOR_ALLOWS
:
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.
Let's consider a multi-tenant SaaS HR platform where:
acme_corp
, beta_inc
) needs to manage its employees, leave requests, and salary records.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
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.leave_request
compared to the template.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?
edit
action for tenant_admin
:
beta_inc
policy allows edit
if R.attr.direct_manager_id == P.id
.edit
for platform_tenant_admin
if R.attr.tenantId == P.attr.tenantId
.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
.view
action:
beta_inc
and root policies (assuming tenantId
matches).export_all_company_data
action:
beta_inc
policy attempts to EFFECT_ALLOW
this.salary_record
policy does not define or allow the export_all_company_data
action.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.
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
):
acme_corp
).acme_manager
role policy (scoped to acme_corp
) is found.view
on salary_record
if R.attr.department == P.attr.department
.view
on salary_record
is permitted by its parentRoles
(platform_tenant_manager
). If this check passes, the role policy grants an initial EFFECT_ALLOW
.salary_record
policy.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.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.
SCOPE_PERMISSIONS_REQUIRE_PARENTAL_CONSENT_FOR_ALLOWS
combined with tenant ID checks in root policies provides robust data boundaries.
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
Join thousands of developers | Features and updates | 1x per month | No spam, just goodies.