Skip to main content

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

Application responsibilities

The client application is responsible for product and lifecycle rules around authorization data.

It should:

  • Create default roles when a tenant is created
  • Assign the initial creator to an admin role
  • Grant default permissions to built-in roles
  • Check manageRoles before creating, updating, or deleting roles
  • Check inviteMembers before inviting users
  • Prevent privilege escalation — users should not be able to grant themselves permissions they do not already hold
  • Decide whether built-in roles are editable or protected
  • Store role display names and metadata outside Keto
  • Use stable role IDs in Keto

Keto answers authorization checks. The application still owns business invariants.

Extending the model with resource-scoped roles

The main model grants permissions at the organization level. If the application needs different roles per resource — for example, Bob can edit reports in one group but only view reports in another — introduce a resource namespace.

class ReportGroup implements Namespace {
related: {
"reports.view": Role[]
"reports.edit": Role[]
}

permits = {
viewReports: (ctx: Context): boolean =>
this.related["reports.view"].traverse(role =>
role.permits.isMember(ctx)
),

editReports: (ctx: Context): boolean =>
this.related["reports.edit"].traverse(role =>
role.permits.isMember(ctx)
),
}
}

Assign roles to specific resources:

Role:report_editor reports.edit ReportGroup:finance
Role:viewer reports.view ReportGroup:finance

Example check:

check User:eve editReports ReportGroup:finance

Allowed if Eve is a member of Role:report_editor.

Extending the model with role inheritance

The main model grants permissions directly to each role. If the application needs hierarchical roles — for example, admin members should automatically pass any permission check that viewer has been granted — extend the Role namespace with an inherits_from relation and an effectiveMember permit.

class Role implements Namespace {
related: {
members: User[]
inherits_from: Role[]
}

permits = {
effectiveMember: (ctx: Context): boolean =>
this.related.members.includes(ctx.subject) ||
this.related.inherits_from.traverse(role =>
role.permits.effectiveMember(ctx)
),
}
}

Then organization permissions use effectiveMember instead of isMember:

class Organization implements Namespace {
related: {
"reports.view": Role[]
}

permits = {
viewReports: (ctx: Context): boolean =>
this.related["reports.view"].traverse(role =>
role.permits.effectiveMember(ctx)
),
}
}

The tuple Role:admin inherits_from Role:viewer means: when Keto evaluates viewer's effectiveMember, it also traverses roles that declared themselves as inheriting from viewer — in this case admin. Admin members therefore pass any check that viewer members pass.

Example:

Role:viewer reports.view Organization:org_123
Role:admin inherits_from Role:viewer
User:alice members Role:admin

This check is allowed:

check User:alice viewReports Organization:org_123

Even though reports.view is only granted to viewer, alice passes viewer's effectiveMember check because admin declared itself as inheriting from viewer.

When using role inheritance, the application should prevent inheritance cycles. For example:

Role:admin inherits_from Role:viewer
Role:viewer inherits_from Role:admin

The application should also decide whether tenants are allowed to configure inheritance for custom roles, or whether inheritance is only used for built-in roles.

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.

Use the format:

Role:{organization_id}/{role_id}

Use readable reserved IDs for built-in roles:

Role:org_123/admin
Role:org_123/viewer

Use stable opaque IDs for custom roles, such as UUIDs or ULIDs:

Role:org_123/role_01HZY3K7J8K2D9WQ7Y1A4F8X9B

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:

Application database:
role_id = role_01HZY3K7J8K2D9WQ7Y1A4F8X9B
display_name = Report Editor

Keto object:
Role:org_123/role_01HZY3K7J8K2D9WQ7Y1A4F8X9B

A user can have different roles in different organizations:

User:alice members Role:org_123/admin
User:alice members Role:org_456/viewer