Role Based Access Control (RBAC)
Role-based access control (RBAC) is useful when users should receive permissions through roles instead of assigning permissions to each user directly.
For example, assume we are building a reporting application. Each organization has reports, and users need different access levels:
- Admins can manage reports and invite members
- Viewers can only view reports
- Later, each organization can create custom roles with its own permission set
This guide shows a starting point for modeling tenant-scoped RBAC in Ory Keto using OPL. Built-in roles and custom roles
intentionally use the same Role namespace — no roles are hardcoded into the schema.
OPL schema
import { Namespace, Context } from "@ory/keto-namespace-types"
class User implements Namespace {}
class Role implements Namespace {
related: {
members: User[]
}
permits = {
isMember: (ctx: Context): boolean => this.related.members.includes(ctx.subject),
}
}
class Organization implements Namespace {
related: {
"members.invite": Role[]
"roles.manage": Role[]
"reports.view": Role[]
"reports.create": Role[]
"reports.edit": Role[]
"reports.delete": Role[]
}
permits = {
inviteMembers: (ctx: Context): boolean => this.related["members.invite"].traverse((role) => role.permits.isMember(ctx)),
manageRoles: (ctx: Context): boolean => this.related["roles.manage"].traverse((role) => role.permits.isMember(ctx)),
viewReports: (ctx: Context): boolean => this.related["reports.view"].traverse((role) => role.permits.isMember(ctx)),
createReports: (ctx: Context): boolean => this.related["reports.create"].traverse((role) => role.permits.isMember(ctx)),
editReports: (ctx: Context): boolean => this.related["reports.edit"].traverse((role) => role.permits.isMember(ctx)),
deleteReports: (ctx: Context): boolean => this.related["reports.delete"].traverse((role) => role.permits.isMember(ctx)),
}
}
How the model works
A user belongs to a role:
User:alice is in members of Role:admin
A role is granted a permission on an organization:
Role:admin is allowed to perform reports.view on Organization:org_123
So this check is allowed:
is User:alice allowed to viewReports on Organization:org_123 ? // allowed
Keto traverses the roles granted reports.view on the organization and checks whether alice is a member of any of them.
Client application flow
Keto does not create roles, users, tenants, or reports by itself. The client application owns those lifecycle events and writes the corresponding tuples to Keto.
1. Creating a new organization
When Alice creates a new organization, the application seeds default roles and grants permissions to them. Save the following to a
policies.rts file:
// Admin role permissions
Organization:org_123#members.invite@Role:admin
Organization:org_123#roles.manage@Role:admin
Organization:org_123#reports.view@Role:admin
Organization:org_123#reports.create@Role:admin
Organization:org_123#reports.edit@Role:admin
Organization:org_123#reports.delete@Role:admin
// Viewer role permissions
Organization:org_123#reports.view@Role:viewer
// Assign Alice to the admin role
Role:admin#members@User:alice
keto relation-tuple parse -f policies.rts --format json | \
keto relation-tuple create -f - --insecure-disable-transport-security
# NAMESPACE OBJECT RELATION NAME SUBJECT
# Organization org_123 members.invite Role:admin
# Organization org_123 roles.manage Role:admin
# Organization org_123 reports.view Role:admin
# Organization org_123 reports.create Role:admin
# Organization org_123 reports.edit Role:admin
# Organization org_123 reports.delete Role:admin
# Organization org_123 reports.view Role:viewer
# Role admin members User:alice
keto check User:alice manageRoles Organization:org_123 --insecure-disable-transport-security
Allowed
2. Inviting a user
Suppose Alice invites Bob to the organization as a viewer. The application first checks whether Alice is allowed to invite members:
keto check User:alice inviteMembers Organization:org_123 --insecure-disable-transport-security
Allowed
If allowed, the application processes the invitation and writes the role membership tuple:
keto relation-tuple create User:bob members Role:viewer --insecure-disable-transport-security
keto check User:bob viewReports Organization:org_123 --insecure-disable-transport-security
Allowed
keto check User:bob createReports Organization:org_123 --insecure-disable-transport-security
Denied
3. Creating a custom role
Suppose Alice creates a custom role called "Report Editor" — a role that can view, create, and edit reports. The application first checks whether Alice can manage roles:
keto check User:alice manageRoles Organization:org_123 --insecure-disable-transport-security
Allowed
If allowed, the application creates the role in its own database and writes its permissions and membership to Keto:
Organization:org_123#reports.view@Role:report_editor
Organization:org_123#reports.create@Role:report_editor
Organization:org_123#reports.edit@Role:report_editor
Role:report_editor#members@User:eve
keto relation-tuple parse -f report_editor.rts --format json | \
keto relation-tuple create -f - --insecure-disable-transport-security
NAMESPACE OBJECT RELATION NAME SUBJECT
Organization org_123 reports.view Role:report_editor
Organization org_123 reports.create Role:report_editor
Organization org_123 reports.edit Role:report_editor
Role report_editor members User:eve
keto check User:eve createReports Organization:org_123 --insecure-disable-transport-security
Allowed
keto check User:eve manageRoles Organization:org_123 --insecure-disable-transport-security
Denied
4. Updating a role
Suppose Alice adds permission to delete reports to the "Report Editor" role. After checking manageRoles as in step 3, the
application creates the new tuple:
keto relation-tuple create Role:report_editor reports.delete Organization:org_123 --insecure-disable-transport-security
To remove a permission, the application deletes the corresponding tuple. For example, to remove report editing from the role:
keto relation-tuple delete Role:report_editor reports.edit Organization:org_123 --insecure-disable-transport-security
Extending the model with role inheritance
The main model grants permissions directly to each role. With inheritance, a role can extend another role and automatically pass its permission checks — without duplicating grants.
Extend the Role namespace with an inherits_from relation:
class Role implements Namespace {
related: {
members: User[]
inherits_from: Role[]
}
permits = {
isMember: (ctx: Context): boolean =>
this.related.members.includes(ctx.subject) || this.related.inherits_from.traverse((role) => role.permits.isMember(ctx)),
}
}
Instead of granting reports.view to every role that should be able to view, grant it once to viewer and have report_editor
inherit from it:
// viewer can view reports;
Organization:org_123#reports.view@Role:viewer
// report_editor can create and edit and inherits from report_viewer
Organization:org_123#reports.create@Role:report_editor
Organization:org_123#reports.edit@Role:report_editor
Role:report_editor#inherits_from@Role:viewer
User:eve#members@Role:report_editor
Both checks are allowed for Eve:
check User:eve viewReports Organization:org_123 // Allowed
check User:eve createReports Organization:org_123 // Allowed
viewReports passes because report_editor inherits from viewer, so eve passes viewer's isMember check without
reports.view being explicitly granted to report_editor.
Tenant isolation and role IDs
In a multi-tenant application, role IDs must be scoped by organization. Without scoping, Role:admin is shared across all
tenants.
For objects, use the format, where the organization_id and role_id refers to the unique id in your system that identifies
those resources.
Role:{organization_id}/{role_id}
Do not use mutable display names as Keto object IDs. Store the display name in your application database and use a stable ID in Keto, to avoid collisions between different tenants.
