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.
A reporting application might define a fixed set of permissions:
reports.viewreports.createreports.editreports.deletemembers.inviteroles.manage
Users do not receive these permissions directly. Instead, users are assigned to roles, and roles are granted permissions. Permissions are application-defined. Roles are application data represented in Ory Keto through relationship tuples, so roles and role assignments can be created, updated, and deleted without changing the OPL 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)),
}
}
Authorization scope
This example uses Organization as the scope for every RBAC decision. Every permission check includes both the subject and the
organization:
is User:alice allowed to viewReports on Organization:org_123
Without a scope, checks are global — "can Alice edit reports?" — with no way to express boundaries. With Organization, the same
user can hold different roles in different organizations.
In a non-multi-tenant app, use a single fixed object such as Organization:main. In a multi-tenant app, each tenant gets its own
Organization object.
How the model works
Permissions are modeled as relations on Organization. Each relation holds the set of roles that have been granted that
permission:
Role:org_123/admin is allowed to perform reports.view on Organization:org_123
A user belongs to a role:
User:alice is in members of Role:org_123/admin
So this check is allowed:
is User:alice allowed to viewReports on Organization:org_123
Ory Keto traverses the roles granted reports.view on the organization and checks whether Alice is a member of any of them. The
relation Organization:org_123#reports.view contains roles. Each role decides whether the checked subject is a member — which is
why Role.isMember(ctx) exists as a permit.
Why permissions are modeled as relations
An Ory Keto check always has three parts: a subject, a relation (the permission), and an object. By modeling permissions as
relations on Organization, every check is automatically scoped:
subject = User:alice
relation = viewReports
object = Organization:org_123
This means the same user can be allowed to view reports in one organization and denied in another — with no extra logic in the application. The scope is part of the check itself.
Client application flow
Ory Keto does not create roles, users, or organizations by itself. The client application owns those lifecycle events and writes the corresponding tuples to Ory Keto.
Create a new organization
When Alice creates a new organization, the application seeds two built-in roles: admin, which has full access, and viewer,
which can only view reports. It then assigns Alice to the admin role. Save the following to a policies.rts file:
// Admin role permissions
Organization:org_123#members.invite@Role:org_123/admin
Organization:org_123#roles.manage@Role:org_123/admin
Organization:org_123#reports.view@Role:org_123/admin
Organization:org_123#reports.create@Role:org_123/admin
Organization:org_123#reports.edit@Role:org_123/admin
Organization:org_123#reports.delete@Role:org_123/admin
// Viewer role permissions
Organization:org_123#reports.view@Role:org_123/viewer
// Assign Alice to the admin role
Role:org_123/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:org_123/admin
# Organization org_123 roles.manage Role:org_123/admin
# Organization org_123 reports.view Role:org_123/admin
# Organization org_123 reports.create Role:org_123/admin
# Organization org_123 reports.edit Role:org_123/admin
# Organization org_123 reports.delete Role:org_123/admin
# Organization org_123 reports.view Role:org_123/viewer
# Role org_123/admin members User:alice
Verify that Alice, as an admin, can manage roles:
keto check User:alice manageRoles Organization:org_123 --insecure-disable-transport-security
Allowed
Invite 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 assigns Bob to the viewer role:
keto relation-tuple create User:bob members Role:org_123/viewer --insecure-disable-transport-security
# NAMESPACE OBJECT RELATION NAME SUBJECT
# Role org_123/viewer members User:bob
Verify that Bob, as a viewer, can view reports but cannot create them:
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
Create a custom role
Creating a custom role does not require an OPL change. The application creates a new role ID in its own database and writes tuples that grant permissions to that role.
Suppose Alice creates a custom role called "Report Editor". 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 writes the role's permissions and assigns Eve to it. Save the following to a report_editor.rts file:
// Grant permissions to the new role
Organization:org_123#reports.view@Role:org_123/report_editor
Organization:org_123#reports.create@Role:org_123/report_editor
Organization:org_123#reports.edit@Role:org_123/report_editor
// Assign Eve to the role
Role:org_123/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:org_123/report_editor
# Organization org_123 reports.create Role:org_123/report_editor
# Organization org_123 reports.edit Role:org_123/report_editor
# Role org_123/report_editor members User:eve
# granted to report_editor
keto check User:eve createReports Organization:org_123 --insecure-disable-transport-security
Allowed
# not granted to report_editor
keto check User:eve deleteReports Organization:org_123 --insecure-disable-transport-security
Denied
Update a role
Suppose Alice adds permission to delete reports to the "Report Editor" role. After checking manageRoles, the application creates
the new tuple:
keto relation-tuple create Role:org_123/report_editor reports.delete Organization:org_123 --insecure-disable-transport-security
To remove a permission, delete the corresponding tuple:
keto relation-tuple delete Role:org_123/report_editor reports.delete Organization:org_123 --insecure-disable-transport-security
Extending to hierarchical roles (HRBAC)
The model above grants permissions directly to each role. With role hierarchy, a role can extend another role and automatically pass its permission checks — without duplicating grants.
Extend Role with an inheritors relation:
class Role implements Namespace {
related: {
members: User[]
inheritors: Role[]
}
permits = {
isMember: (ctx: Context): boolean =>
this.related.members.includes(ctx.subject) || this.related.inheritors.traverse((role) => role.permits.isMember(ctx)),
}
}
The inheritors relation is declared on the parent role and lists every role that inherits it. When Ory Keto evaluates
viewer.isMember, it checks viewer's direct members first, then walks each role in inheritors and checks those too. Members of
inheriting roles therefore pass any permission check that goes through viewer.
This change allows us creating relationship that can make report_editor an inheritor of viewer:
Role:org_123/report_editor is in inheritors of Role:org_123/viewer
Which means report_editor inherits viewer — members of report_editor are treated as members of viewer for all permission
checks. If reports.view is granted to viewer, then report_editor members can also view reports without an explicit grant.
Example
Suppose the application introduces a report_manager role — everything report_editor can do, plus the ability to delete
reports. Instead of duplicating all grants, report_manager inherits report_editor and only adds reports.delete on top. Save
the following to a report_manager.rts file:
// report_manager-specific permission
Organization:org_123#reports.delete@Role:org_123/report_manager
// report_manager inherits report_editor, so view/create/edit come for free
Role:org_123/report_editor#inheritors@Role:org_123/report_manager
// Assign Charlie to the report_manager role
Role:org_123/report_manager#members@User:charlie
keto relation-tuple parse -f report_manager.rts --format json | \
keto relation-tuple create -f - --insecure-disable-transport-security
# NAMESPACE OBJECT RELATION NAME SUBJECT
# Organization org_123 reports.delete Role:org_123/report_manager
# Role org_123/report_editor inheritors Role:org_123/report_manager
# Role org_123/report_manager members User:charlie
# inherited from report_editor
keto check User:charlie viewReports Organization:org_123 --insecure-disable-transport-security
Allowed
# explicitly granted to report_manager
keto check User:charlie deleteReports Organization:org_123 --insecure-disable-transport-security
Allowed
# not in the inheritance chain
keto check User:charlie manageRoles Organization:org_123 --insecure-disable-transport-security
Denied
Charlie gets view, create, and edit through the inheritance chain (report_manager → report_editor), and delete through the
explicit grant. Only reports.delete needed to be written — nothing else was duplicated.
This guide models additive role inheritance. If a role should not receive a permission, do not inherit from a role that grants it — instead, split common permissions into smaller base roles.
Application responsibilities
Ory Keto stores and evaluates authorization relationships. The application owns role lifecycle and product-specific safety rules.
The application is responsible for:
- Creating default roles when an organization is created
- Defining built-in roles if the product requires them
- Checking
manageRolesbefore changing role grants or memberships - Preventing inheritance cycles
- Preventing inheritance across organizations
- Preventing removal of the last administrator, if the product requires one
Role ID guidance
Role IDs must be globally unique. The simplest way to guarantee this is to use the stable role ID from your application database:
Role:01HZY3K7J8K2D9WQ7Y1A4F8X9B
What to avoid is using human-readable labels like admin or viewer as role IDs directly. These are not unique across tenants.
In a multi-tenant app, Role:admin would refer to the same role object for every organization, causing role assignments and
permission grants to be shared across tenants.
Large permission sets
This model keeps permissions in OPL because permissions are application-defined actions. A permission usually corresponds to a product action, API endpoint, page, button, or workflow step. Tenants can decide which roles receive those permissions, but the application defines what each permission means.
The benefit is that every check keeps the full authorization context:
keto check User:alice deleteReports Organization:org_123 --insecure-disable-transport-security
The subject is the user, the relation is the action, and the object is the organization or resource scope. Roles remain dynamic data: creating a new role or changing which permissions a role has only requires tuple changes. Only adding a new application permission requires updating the OPL, because a new permission means the application has introduced a new action that authorization can check.
If the application has hundreds of fixed permissions, the OPL schema will be large but remains correct and predictable. This tradeoff keeps permission checks scoped and explicit while still allowing roles to be managed dynamically.
