From 539a7e11039a470bb0ae9bfaea2696e7e39be95b Mon Sep 17 00:00:00 2001 From: DavudSafarli Date: Wed, 6 May 2026 16:28:31 +0200 Subject: [PATCH 1/3] rewrite multitenant rbac example with OPL --- docs/keto/guides/rbac.mdx | 362 +++++++----- src/theme/ketoRelationsPermissionsPrism.js | 14 +- ...ketoRelationsPermissionsPrism.test.ts.snap | 540 +++++++++++------- .../ketoRelationsPermissionsPrism.test.ts | 4 + 4 files changed, 540 insertions(+), 380 deletions(-) diff --git a/docs/keto/guides/rbac.mdx b/docs/keto/guides/rbac.mdx index c20183245..71e14e218 100644 --- a/docs/keto/guides/rbac.mdx +++ b/docs/keto/guides/rbac.mdx @@ -2,204 +2,262 @@ title: Role Based Access Control (RBAC) --- -This guide will explain how to implement RBAC using Ory Permissions. +Role-based access control (RBAC) is useful when users should receive permissions through roles instead of assigning permissions to +each user directly. -[Role Based Access Control (RBAC)](https://en.wikipedia.org/wiki/Role-based_access_control) maps subjects to roles and roles to -permissions. The goal of (H)RBAC is to make permission management convenient by grouping subjects in roles and assigning -permissions roles. This type of access control is common in web applications where one often encounters roles such as -"administrator", "moderator", and so on. +For example, assume we are building a reporting application. Each organization has reports, and users need different access +levels: -In **Hierarchical Role Based Access Control (HRBAC)** roles can inherit permissions from other roles. The "administrator" role, -for example, could inherit all permissions from the "moderator" role. This reduces duplication and management complexity around -defining privileges. +- Admins can manage reports and invite members +- Viewers can only view reports +- Later, each organization can create custom roles with its own permission set -Let's assume that we are building a reporting application and need to have three groups of users with different access levels. We -have the following group of reports in our application. +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. -- Financial performance reports -- Marketing performance reports -- Community performance reports +## OPL schema -This time we model the access rights using (H)RBAC and the roles `community`, `marketing`, `finance` and `admin`: +```ts +import { Namespace, Context } from "@ory/keto-namespace-types" -The role `admin` inherits all privileges from `finance`, `marketing` and `community` +class User implements Namespace {} -(H)RBAC is everywhere. If you ever installed a forum software such as -[phpBB](https://www.phpbb.com/support/docs/en/3.1/ug/adminguide/permissions_roles/) or -[Wordpress](https://codex.wordpress.org/Roles_and_Capabilities), you have definitely encountered ACL, (H)RBAC, or both. +class Role implements Namespace { + related: { + members: User[] + } -(H)RBAC reduces management complexity and overhead with large user bases. Sometimes however, even (H)RBAC is not enough. An -example is when you need to express ownership (e.g. `Dilan` can only modify his own reports), have attributes (e.g. `Dilan` needs -to have access only during work hours), or in multi-tenant environments. + permits = { + isMember: (ctx: Context): boolean => this.related.members.includes(ctx.subject), + } +} -**Benefits:** +class Organization implements Namespace { + related: { + "members.invite": Role[] + "roles.manage": Role[] -- Reduces management complexity when many identities share similar permissions. -- Role hierarchies can reduce redundancy even further. -- Is well established and easily understood by many developers as it is a de-facto standard for web applications. + "reports.view": Role[] + "reports.create": Role[] + "reports.edit": Role[] + "reports.delete": Role[] + } -**Shortcomings:** + permits = { + inviteMembers: (ctx: Context): boolean => this.related["members.invite"].traverse((role) => role.permits.isMember(ctx)), -- There is no concept of ownership: _Dan is the author of article "Hello World" and is thus allowed to update it_. -- There is no concept of environment: _Dan is allowed to access accounting services when the request comes from IP 10.0.0.3_. -- There is no concept of tenants: _Dan is allowed to access resources on the "dan's test" tenant_. + manageRoles: (ctx: Context): boolean => this.related["roles.manage"].traverse((role) => role.permits.isMember(ctx)), -## RBAC with Ory Keto + viewReports: (ctx: Context): boolean => this.related["reports.view"].traverse((role) => role.permits.isMember(ctx)), -We need to have three groups, `finance`, `marketing`, `community`. Also, we need to have two namespaces: `reports` to manage -access control and `groups` to add users to this group + createReports: (ctx: Context): boolean => this.related["reports.create"].traverse((role) => role.permits.isMember(ctx)), -Let's add namespaces to Keto config. See the full reference API config [here](../../keto/reference/configuration). + editReports: (ctx: Context): boolean => this.related["reports.edit"].traverse((role) => role.permits.isMember(ctx)), -```yaml -# ... -namespaces: - - id: 0 - name: groups - - id: 1 - name: reports -#... + deleteReports: (ctx: Context): boolean => this.related["reports.delete"].traverse((role) => role.permits.isMember(ctx)), + } +} ``` -We can have two types of permission to access reports for granularity. Let's assume that we need `edit` and `view` access to the -reports. +## How the model works -```keto-tuples -// View only access for finance department -reports:finance#view@(groups:finance#member) -// View only access for community department -reports:community#view@(groups:community#member) -// View only access for marketing department -reports:marketing#view@(groups:marketing#member) -// Edit access for admin group -reports:finance#edit@(groups:admin#member) -reports:community#edit@(groups:admin#member) -reports:marketing#edit@(groups:admin#member) -reports:finance#view@(groups:admin#member) -reports:community#view@(groups:admin#member) -reports:marketing#view@(groups:admin#member) -``` - -Let's assume that we have four people in our organization. Lila is CFO and needs access to financial reports, Hadley works in -marketing, and Dilan works as a community manager. Neel is an admin of a system and needs to have edit permissions for reports. +A user belongs to a role: -```keto-tuples -groups:finance#member@Lila -groups:community#member@Dilan -groups:marketing#member@Hadley -groups:admin#member@Neel +```keto-natural +User:alice is in members of Role:admin ``` -## Creating relationships +A role is granted a permission on an organization: + +```keto-natural +Role:admin is allowed to perform reports.view on Organization:org_123 +``` + +So this check is allowed: + +```keto-natural +is User:alice allowed to viewReports on Organization:org_123 ? // allowed +``` -Let's copy all permissions we created to a `policies.rts` file with the following content. +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: ```keto-tuples -reports:finance#view@(groups:finance#member) -reports:community#view@(groups:community#member) -reports:marketing#view@(groups:marketing#member) -reports:finance#edit@(groups:admin#member) -reports:community#edit@(groups:admin#member) -reports:marketing#edit@(groups:admin#member) -reports:finance#view@(groups:admin#member) -reports:community#view@(groups:admin#member) -reports:marketing#view@(groups:admin#member) -groups:finance#member@Lila -groups:community#member@Dilan -groups:marketing#member@Hadley -groups:admin#member@Neel -``` - -Then we can run +// 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 +``` + +```shell +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 +``` ```bash -keto relation-tuple parse policies.rts --format json | \ - keto relation-tuple create - >/dev/null \ - && echo "Successfully created tuple" \ - || echo "Encountered error" +keto check User:alice manageRoles Organization:org_123 --insecure-disable-transport-security +Allowed ``` -Since Dilan works as a community manager, the following check examples show that he has access only to community reports +### 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: ```bash -keto check Dilan view reports finance -Denied -keto check Dilan view reports community +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: + +```bash +keto relation-tuple create User:bob members Role:viewer --insecure-disable-transport-security +``` + +```bash +keto check User:bob viewReports Organization:org_123 --insecure-disable-transport-security Allowed -keto check Dilan edit reports community +keto check User:bob createReports Organization:org_123 --insecure-disable-transport-security Denied ``` -Now Dilan decided to also work with marketing. Therefore we need to update his permissions and add him to the marketing group. +### 3. Creating a custom role -```keto-tuples -groups:marketing#member@Dilan +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: + +```bash +keto check User:alice manageRoles Organization:org_123 --insecure-disable-transport-security +Allowed ``` -Now he also has access to marketing reports: +If allowed, the application creates the role in its own database and writes its permissions and membership to Keto: + +```keto-tuples +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 +``` +```bash +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 Dilan view reports marketing + +```bash +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 ``` -## Display all objects a user has access to +### 4. Updating a role -The example below shows you how to get a list of objects Dilan has access to +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: ```bash -# Get all groups for Dilan -keto relation-tuple get --subject-id=Dilan --relation=member --format json --read-remote localhost:4466 | jq -{ - "relation_tuples": [ - { - "namespace": "groups", - "object": "community", - "relation": "member", - "subject_id": "Dilan" - }, - { - "namespace": "groups", - "object": "marketing", - "relation": "member", - "subject_id": "Dilan" - } - ], - "next_page_token": "" -} +keto relation-tuple create Role:report_editor reports.delete Organization:org_123 --insecure-disable-transport-security +``` -# Get permissions to objects for marketing group -keto relation-tuple get --subject-set="groups:marketing#member" --format json --read-remote localhost:4466 | jq -{ - "relation_tuples": [ - { - "namespace": "reports", - "object": "marketing", - "relation": "view", - "subject_set": { - "namespace": "groups", - "object": "marketing", - "relation": "member" - } - } - ], - "next_page_token": "" -} -# Get permissions to objects for community group -keto relation-tuple get --subject-set="groups:community#member" --format json --read-remote localhost:4466 | jq -{ - "relation_tuples": [ - { - "namespace": "reports", - "object": "community", - "relation": "view", - "subject_set": { - "namespace": "groups", - "object": "community", - "relation": "member" - } - } - ], - "next_page_token": "" +To remove a permission, the application deletes the corresponding tuple. For example, to remove report editing from the role: + +```bash +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: + +```ts +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: + +```keto-tuples +// 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: + +```keto-tuples +check User:eve viewReports Organization:org_123 +check User:eve createReports Organization:org_123 +``` + +`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. + +```text +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. diff --git a/src/theme/ketoRelationsPermissionsPrism.js b/src/theme/ketoRelationsPermissionsPrism.js index 5a4870b69..cf40583b7 100644 --- a/src/theme/ketoRelationsPermissionsPrism.js +++ b/src/theme/ketoRelationsPermissionsPrism.js @@ -24,7 +24,7 @@ export default (prism) => { // Placeholder-based declarative sentences (match first, more specific) "natural-placeholder": { pattern: - /(?=.*<(?:Subject|relation|Object)>)(?:(?:[a-z][a-zA-Z0-9_-]* (?:of|in) )?[A-Za-z][a-zA-Z0-9_-]*:[^@#:\s]+|) (?:is|are)(?: in)? (?:[a-z][a-zA-Z0-9_-]*|) (?:of|on) (?:[A-Za-z][a-zA-Z0-9_-]*:[^@#:\s]+|)/, + /(?=.*<(?:Subject|relation|Object)>)(?:(?:[a-z][a-zA-Z0-9_-]* (?:of|in) )?[A-Za-z][a-zA-Z0-9_-]*:[^@#:\s]+|) (?:is|are)(?: in)? (?:[a-z][a-zA-Z0-9_.-]*|) (?:of|on) (?:[A-Za-z][a-zA-Z0-9_-]*:[^@#:\s]+|)/, alias: "natural", inside: { // Placeholder @@ -71,7 +71,7 @@ export default (prism) => { // Declarative relationship sentences natural: { pattern: - /(?:[a-z][a-zA-Z0-9_-]* (?:of|in) )?[A-Za-z][a-zA-Z0-9_-]*:[^@#:\s]+ (?:(?:is|are)(?: in)? [a-z][a-zA-Z0-9_-]* (?:of|on)|(?:is|are) allowed to [a-z][a-zA-Z0-9_-]*(?: to)?) [A-Za-z][a-zA-Z0-9_-]*:[^@#:\s]+/, + /(?:[a-z][a-zA-Z0-9_-]* (?:of|in) )?[A-Za-z][a-zA-Z0-9_-]*:[^@#:\s]+ (?:(?:is|are)(?: in)? [a-z][a-zA-Z0-9_.-]* (?:of|on)|(?:is|are) allowed to (?:perform )?[a-z][a-zA-Z0-9_.-]*(?: (?:to|on|of))?) [A-Za-z][a-zA-Z0-9_-]*:[^@#:\s]+/, inside: { // Subject: can be "relation of Namespace:Id" or "relation in Namespace:Id" or just "Namespace:Id" (at start) subject: { @@ -95,15 +95,15 @@ export default (prism) => { }, }, // Keywords - match before permit so permit only matches the remaining word - keyword: /\b(?:is|are|allowed to|to|in|of|on)\b/, + keyword: /\b(?:is|are|allowed to|perform|to|in|of|on)\b/, // Permit (the action/role) - matches any remaining lowercase word - permit: /[a-z][a-zA-Z0-9_-]*/, + permit: /[a-z][a-zA-Z0-9_.-]*/, }, }, // Permission question sentences "natural-check": { pattern: - /(?:is|are) (?:[a-z][a-zA-Z0-9_-]* (?:of|in) )?[A-Za-z][a-zA-Z0-9_-]*:[^@#:\s]+ (?:allowed to|in) [a-z][a-zA-Z0-9_-]* (?:of|on) [A-Za-z][a-zA-Z0-9_-]*:[^@#:\s]+/, + /(?:is|are) (?:[a-z][a-zA-Z0-9_-]* (?:of|in) )?[A-Za-z][a-zA-Z0-9_-]*:[^@#:\s]+ (?:allowed to|in) (?:perform )?[a-z][a-zA-Z0-9_.-]* (?:of|on) [A-Za-z][a-zA-Z0-9_-]*:[^@#:\s]+/, inside: { // Subject (comes after is/are) subject: { @@ -127,9 +127,9 @@ export default (prism) => { }, }, // Permit - match before keywords - permit: /[a-z][a-zA-Z0-9_-]*(?= (?:of|on))/, + permit: /[a-z][a-zA-Z0-9_.-]*(?= (?:of|on))/, // Keywords (including the starting is/are) - match last - keyword: /\b(?:is|are|allowed to|in|of|on)\b/, + keyword: /\b(?:is|are|allowed to|perform|in|of|on)\b/, }, }, } diff --git a/tests/jest/prism/__snapshots__/ketoRelationsPermissionsPrism.test.ts.snap b/tests/jest/prism/__snapshots__/ketoRelationsPermissionsPrism.test.ts.snap index 8d4b38eaf..f68f66dd6 100644 --- a/tests/jest/prism/__snapshots__/ketoRelationsPermissionsPrism.test.ts.snap +++ b/tests/jest/prism/__snapshots__/ketoRelationsPermissionsPrism.test.ts.snap @@ -1,6 +1,6 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`ketoRelationsPermissionsPrism declarative: lowercase simple: user:bob is owner of document:x should tokenize: "user:bob is owner of document:x" 1`] = ` +exports[`ketoRelationsPermissionsPrism declarative: allowed to simple: User:Bob is allowed to read Document:X should tokenize: "User:Bob is allowed to read Document:X" 1`] = ` [ Token { "alias": undefined, @@ -10,7 +10,7 @@ exports[`ketoRelationsPermissionsPrism declarative: lowercase simple: user:bob i "content": [ Token { "alias": undefined, - "content": "user", + "content": "User", "length": 4, "type": "namespace", }, @@ -22,7 +22,7 @@ exports[`ketoRelationsPermissionsPrism declarative: lowercase simple: user:bob i }, Token { "alias": undefined, - "content": "bob", + "content": "Bob", "length": 3, "type": "id", }, @@ -40,16 +40,16 @@ exports[`ketoRelationsPermissionsPrism declarative: lowercase simple: user:bob i " ", Token { "alias": undefined, - "content": "owner", - "length": 5, - "type": "permit", + "content": "allowed to", + "length": 10, + "type": "keyword", }, " ", Token { "alias": undefined, - "content": "of", - "length": 2, - "type": "keyword", + "content": "read", + "length": 4, + "type": "permit", }, " ", Token { @@ -57,7 +57,7 @@ exports[`ketoRelationsPermissionsPrism declarative: lowercase simple: user:bob i "content": [ Token { "alias": undefined, - "content": "document", + "content": "Document", "length": 8, "type": "namespace", }, @@ -69,7 +69,7 @@ exports[`ketoRelationsPermissionsPrism declarative: lowercase simple: user:bob i }, Token { "alias": undefined, - "content": "x", + "content": "X", "length": 1, "type": "id", }, @@ -78,13 +78,13 @@ exports[`ketoRelationsPermissionsPrism declarative: lowercase simple: user:bob i "type": "object", }, ], - "length": 31, + "length": 38, "type": "natural", }, ] `; -exports[`ketoRelationsPermissionsPrism declarative: relation as subject with 'are in': viewers of Group:Eng are in readers of Document:Xyz should tokenize: "viewers of Group:Eng are in readers of Document:Xyz" 1`] = ` +exports[`ketoRelationsPermissionsPrism declarative: allowed to with 'to': User:Bob is allowed to access to Document:X should tokenize: "User:Bob is allowed to access to Document:X" 1`] = ` [ Token { "alias": undefined, @@ -94,22 +94,8 @@ exports[`ketoRelationsPermissionsPrism declarative: relation as subject with 'ar "content": [ Token { "alias": undefined, - "content": "viewers", - "length": 7, - "type": "subjectRelation", - }, - " ", - Token { - "alias": undefined, - "content": "of", - "length": 2, - "type": "keyword", - }, - " ", - Token { - "alias": undefined, - "content": "Group", - "length": 5, + "content": "User", + "length": 4, "type": "namespace", }, Token { @@ -120,39 +106,39 @@ exports[`ketoRelationsPermissionsPrism declarative: relation as subject with 'ar }, Token { "alias": undefined, - "content": "Eng", + "content": "Bob", "length": 3, "type": "id", }, ], - "length": 20, + "length": 8, "type": "subject", }, " ", Token { "alias": undefined, - "content": "are", - "length": 3, + "content": "is", + "length": 2, "type": "keyword", }, " ", Token { "alias": undefined, - "content": "in", - "length": 2, + "content": "allowed to", + "length": 10, "type": "keyword", }, " ", Token { "alias": undefined, - "content": "readers", - "length": 7, + "content": "access", + "length": 6, "type": "permit", }, " ", Token { "alias": undefined, - "content": "of", + "content": "to", "length": 2, "type": "keyword", }, @@ -174,22 +160,22 @@ exports[`ketoRelationsPermissionsPrism declarative: relation as subject with 'ar }, Token { "alias": undefined, - "content": "Xyz", - "length": 3, + "content": "X", + "length": 1, "type": "id", }, ], - "length": 12, + "length": 10, "type": "object", }, ], - "length": 51, + "length": 43, "type": "natural", }, ] `; -exports[`ketoRelationsPermissionsPrism declarative: relation as subject with 'in': members in Group:Eng are viewers of Document:Xyz should tokenize: "members in Group:Eng are viewers of Document:Xyz" 1`] = ` +exports[`ketoRelationsPermissionsPrism declarative: allowed to with relation containing dot: User:Bob is allowed to perform users.list on Document:X should tokenize: "User:Bob is allowed to perform users.list on Document:X" 1`] = ` [ Token { "alias": undefined, @@ -199,22 +185,8 @@ exports[`ketoRelationsPermissionsPrism declarative: relation as subject with 'in "content": [ Token { "alias": undefined, - "content": "members", - "length": 7, - "type": "subjectRelation", - }, - " ", - Token { - "alias": undefined, - "content": "in", - "length": 2, - "type": "keyword", - }, - " ", - Token { - "alias": undefined, - "content": "Group", - "length": 5, + "content": "User", + "length": 4, "type": "namespace", }, Token { @@ -225,32 +197,46 @@ exports[`ketoRelationsPermissionsPrism declarative: relation as subject with 'in }, Token { "alias": undefined, - "content": "Eng", + "content": "Bob", "length": 3, "type": "id", }, ], - "length": 20, + "length": 8, "type": "subject", }, " ", Token { "alias": undefined, - "content": "are", - "length": 3, + "content": "is", + "length": 2, "type": "keyword", }, " ", Token { "alias": undefined, - "content": "viewers", + "content": "allowed to", + "length": 10, + "type": "keyword", + }, + " ", + Token { + "alias": undefined, + "content": "perform", "length": 7, + "type": "keyword", + }, + " ", + Token { + "alias": undefined, + "content": "users.list", + "length": 10, "type": "permit", }, " ", Token { "alias": undefined, - "content": "of", + "content": "on", "length": 2, "type": "keyword", }, @@ -272,22 +258,22 @@ exports[`ketoRelationsPermissionsPrism declarative: relation as subject with 'in }, Token { "alias": undefined, - "content": "Xyz", - "length": 3, + "content": "X", + "length": 1, "type": "id", }, ], - "length": 12, + "length": 10, "type": "object", }, ], - "length": 48, + "length": 55, "type": "natural", }, ] `; -exports[`ketoRelationsPermissionsPrism declarative: relation as subject: members of Group:Eng are viewers of Document:Xyz should tokenize: "members of Group:Eng are viewers of Document:Xyz" 1`] = ` +exports[`ketoRelationsPermissionsPrism declarative: allowed to with subject relation: members of Group:Eng is allowed to read Document:X should tokenize: "members of Group:Eng is allowed to read Document:X" 1`] = ` [ Token { "alias": undefined, @@ -334,23 +320,23 @@ exports[`ketoRelationsPermissionsPrism declarative: relation as subject: members " ", Token { "alias": undefined, - "content": "are", - "length": 3, + "content": "is", + "length": 2, "type": "keyword", }, " ", Token { "alias": undefined, - "content": "viewers", - "length": 7, - "type": "permit", + "content": "allowed to", + "length": 10, + "type": "keyword", }, " ", Token { "alias": undefined, - "content": "of", - "length": 2, - "type": "keyword", + "content": "read", + "length": 4, + "type": "permit", }, " ", Token { @@ -370,22 +356,22 @@ exports[`ketoRelationsPermissionsPrism declarative: relation as subject: members }, Token { "alias": undefined, - "content": "Xyz", - "length": 3, + "content": "X", + "length": 1, "type": "id", }, ], - "length": 12, + "length": 10, "type": "object", }, ], - "length": 48, + "length": 50, "type": "natural", }, ] `; -exports[`ketoRelationsPermissionsPrism declarative: simple with 'on': User:Bob is owner on Document:X should tokenize: "User:Bob is owner on Document:X" 1`] = ` +exports[`ketoRelationsPermissionsPrism declarative: lowercase simple: user:bob is owner of document:x should tokenize: "user:bob is owner of document:x" 1`] = ` [ Token { "alias": undefined, @@ -395,7 +381,7 @@ exports[`ketoRelationsPermissionsPrism declarative: simple with 'on': User:Bob i "content": [ Token { "alias": undefined, - "content": "User", + "content": "user", "length": 4, "type": "namespace", }, @@ -407,7 +393,7 @@ exports[`ketoRelationsPermissionsPrism declarative: simple with 'on': User:Bob i }, Token { "alias": undefined, - "content": "Bob", + "content": "bob", "length": 3, "type": "id", }, @@ -432,7 +418,7 @@ exports[`ketoRelationsPermissionsPrism declarative: simple with 'on': User:Bob i " ", Token { "alias": undefined, - "content": "on", + "content": "of", "length": 2, "type": "keyword", }, @@ -442,7 +428,7 @@ exports[`ketoRelationsPermissionsPrism declarative: simple with 'on': User:Bob i "content": [ Token { "alias": undefined, - "content": "Document", + "content": "document", "length": 8, "type": "namespace", }, @@ -454,7 +440,7 @@ exports[`ketoRelationsPermissionsPrism declarative: simple with 'on': User:Bob i }, Token { "alias": undefined, - "content": "X", + "content": "x", "length": 1, "type": "id", }, @@ -469,7 +455,7 @@ exports[`ketoRelationsPermissionsPrism declarative: simple with 'on': User:Bob i ] `; -exports[`ketoRelationsPermissionsPrism declarative: simple: User:Bob is owner of Document:X should tokenize: "User:Bob is owner of Document:X" 1`] = ` +exports[`ketoRelationsPermissionsPrism declarative: relation as subject with 'are in': viewers of Group:Eng are in readers of Document:Xyz should tokenize: "viewers of Group:Eng are in readers of Document:Xyz" 1`] = ` [ Token { "alias": undefined, @@ -479,8 +465,22 @@ exports[`ketoRelationsPermissionsPrism declarative: simple: User:Bob is owner of "content": [ Token { "alias": undefined, - "content": "User", - "length": 4, + "content": "viewers", + "length": 7, + "type": "subjectRelation", + }, + " ", + Token { + "alias": undefined, + "content": "of", + "length": 2, + "type": "keyword", + }, + " ", + Token { + "alias": undefined, + "content": "Group", + "length": 5, "type": "namespace", }, Token { @@ -491,26 +491,33 @@ exports[`ketoRelationsPermissionsPrism declarative: simple: User:Bob is owner of }, Token { "alias": undefined, - "content": "Bob", + "content": "Eng", "length": 3, "type": "id", }, ], - "length": 8, + "length": 20, "type": "subject", }, " ", Token { "alias": undefined, - "content": "is", + "content": "are", + "length": 3, + "type": "keyword", + }, + " ", + Token { + "alias": undefined, + "content": "in", "length": 2, "type": "keyword", }, " ", Token { "alias": undefined, - "content": "owner", - "length": 5, + "content": "readers", + "length": 7, "type": "permit", }, " ", @@ -538,22 +545,22 @@ exports[`ketoRelationsPermissionsPrism declarative: simple: User:Bob is owner of }, Token { "alias": undefined, - "content": "X", - "length": 1, + "content": "Xyz", + "length": 3, "type": "id", }, ], - "length": 10, + "length": 12, "type": "object", }, ], - "length": 31, + "length": 51, "type": "natural", }, ] `; -exports[`ketoRelationsPermissionsPrism declarative: with 'is in': Group:group2 is in members of Group:group1 should tokenize: "Group:group2 is in members of Group:group1" 1`] = ` +exports[`ketoRelationsPermissionsPrism declarative: relation as subject with 'in': members in Group:Eng are viewers of Document:Xyz should tokenize: "members in Group:Eng are viewers of Document:Xyz" 1`] = ` [ Token { "alias": undefined, @@ -561,6 +568,20 @@ exports[`ketoRelationsPermissionsPrism declarative: with 'is in': Group:group2 i Token { "alias": undefined, "content": [ + Token { + "alias": undefined, + "content": "members", + "length": 7, + "type": "subjectRelation", + }, + " ", + Token { + "alias": undefined, + "content": "in", + "length": 2, + "type": "keyword", + }, + " ", Token { "alias": undefined, "content": "Group", @@ -575,32 +596,25 @@ exports[`ketoRelationsPermissionsPrism declarative: with 'is in': Group:group2 i }, Token { "alias": undefined, - "content": "group2", - "length": 6, + "content": "Eng", + "length": 3, "type": "id", }, ], - "length": 12, + "length": 20, "type": "subject", }, " ", Token { "alias": undefined, - "content": "is", - "length": 2, - "type": "keyword", - }, - " ", - Token { - "alias": undefined, - "content": "in", - "length": 2, + "content": "are", + "length": 3, "type": "keyword", }, " ", Token { "alias": undefined, - "content": "members", + "content": "viewers", "length": 7, "type": "permit", }, @@ -617,8 +631,8 @@ exports[`ketoRelationsPermissionsPrism declarative: with 'is in': Group:group2 i "content": [ Token { "alias": undefined, - "content": "Group", - "length": 5, + "content": "Document", + "length": 8, "type": "namespace", }, Token { @@ -629,8 +643,8 @@ exports[`ketoRelationsPermissionsPrism declarative: with 'is in': Group:group2 i }, Token { "alias": undefined, - "content": "group1", - "length": 6, + "content": "Xyz", + "length": 3, "type": "id", }, ], @@ -638,31 +652,38 @@ exports[`ketoRelationsPermissionsPrism declarative: with 'is in': Group:group2 i "type": "object", }, ], - "length": 42, + "length": 48, "type": "natural", }, ] `; -exports[`ketoRelationsPermissionsPrism question: question with 'in': is User:Alice in viewers of Document:X should tokenize: "is User:Alice in viewers of Document:X" 1`] = ` +exports[`ketoRelationsPermissionsPrism declarative: relation as subject: members of Group:Eng are viewers of Document:Xyz should tokenize: "members of Group:Eng are viewers of Document:Xyz" 1`] = ` [ Token { "alias": undefined, "content": [ - Token { - "alias": undefined, - "content": "is", - "length": 2, - "type": "keyword", - }, - " ", Token { "alias": undefined, "content": [ Token { "alias": undefined, - "content": "User", - "length": 4, + "content": "members", + "length": 7, + "type": "subjectRelation", + }, + " ", + Token { + "alias": undefined, + "content": "of", + "length": 2, + "type": "keyword", + }, + " ", + Token { + "alias": undefined, + "content": "Group", + "length": 5, "type": "namespace", }, Token { @@ -673,19 +694,19 @@ exports[`ketoRelationsPermissionsPrism question: question with 'in': is User:Ali }, Token { "alias": undefined, - "content": "Alice", - "length": 5, + "content": "Eng", + "length": 3, "type": "id", }, ], - "length": 10, + "length": 20, "type": "subject", }, " ", Token { "alias": undefined, - "content": "in", - "length": 2, + "content": "are", + "length": 3, "type": "keyword", }, " ", @@ -720,54 +741,33 @@ exports[`ketoRelationsPermissionsPrism question: question with 'in': is User:Ali }, Token { "alias": undefined, - "content": "X", - "length": 1, + "content": "Xyz", + "length": 3, "type": "id", }, ], - "length": 10, + "length": 12, "type": "object", }, ], - "length": 38, - "type": "natural-check", + "length": 48, + "type": "natural", }, ] `; -exports[`ketoRelationsPermissionsPrism question: question with relation subject: are members of Group:XYZ allowed to view on Document:X should tokenize: "are members of Group:XYZ allowed to view on Document:X" 1`] = ` +exports[`ketoRelationsPermissionsPrism declarative: simple with 'on': User:Bob is owner on Document:X should tokenize: "User:Bob is owner on Document:X" 1`] = ` [ Token { "alias": undefined, "content": [ - Token { - "alias": undefined, - "content": "are", - "length": 3, - "type": "keyword", - }, - " ", Token { "alias": undefined, "content": [ Token { "alias": undefined, - "content": "members", - "length": 7, - "type": "subjectRelation", - }, - " ", - Token { - "alias": undefined, - "content": "of", - "length": 2, - "type": "keyword", - }, - " ", - Token { - "alias": undefined, - "content": "Group", - "length": 5, + "content": "User", + "length": 4, "type": "namespace", }, Token { @@ -778,26 +778,26 @@ exports[`ketoRelationsPermissionsPrism question: question with relation subject: }, Token { "alias": undefined, - "content": "XYZ", + "content": "Bob", "length": 3, "type": "id", }, ], - "length": 20, + "length": 8, "type": "subject", }, " ", Token { "alias": undefined, - "content": "allowed to", - "length": 10, + "content": "is", + "length": 2, "type": "keyword", }, " ", Token { "alias": undefined, - "content": "view", - "length": 4, + "content": "owner", + "length": 5, "type": "permit", }, " ", @@ -834,24 +834,17 @@ exports[`ketoRelationsPermissionsPrism question: question with relation subject: "type": "object", }, ], - "length": 54, - "type": "natural-check", + "length": 31, + "type": "natural", }, ] `; -exports[`ketoRelationsPermissionsPrism question: simple question: is User:Bob allowed to view on Document:X should tokenize: "is User:Bob allowed to view on Document:X" 1`] = ` +exports[`ketoRelationsPermissionsPrism declarative: simple: User:Bob is owner of Document:X should tokenize: "User:Bob is owner of Document:X" 1`] = ` [ Token { "alias": undefined, "content": [ - Token { - "alias": undefined, - "content": "is", - "length": 2, - "type": "keyword", - }, - " ", Token { "alias": undefined, "content": [ @@ -880,21 +873,21 @@ exports[`ketoRelationsPermissionsPrism question: simple question: is User:Bob al " ", Token { "alias": undefined, - "content": "allowed to", - "length": 10, + "content": "is", + "length": 2, "type": "keyword", }, " ", Token { "alias": undefined, - "content": "view", - "length": 4, + "content": "owner", + "length": 5, "type": "permit", }, " ", Token { "alias": undefined, - "content": "on", + "content": "of", "length": 2, "type": "keyword", }, @@ -925,13 +918,13 @@ exports[`ketoRelationsPermissionsPrism question: simple question: is User:Bob al "type": "object", }, ], - "length": 41, - "type": "natural-check", + "length": 31, + "type": "natural", }, ] `; -exports[`ketoRelationsPermissionsPrism declarative: allowed to simple: User:Bob is allowed to read Document:X should tokenize: "User:Bob is allowed to read Document:X" 1`] = ` +exports[`ketoRelationsPermissionsPrism declarative: with 'is in': Group:group2 is in members of Group:group1 should tokenize: "Group:group2 is in members of Group:group1" 1`] = ` [ Token { "alias": undefined, @@ -941,8 +934,8 @@ exports[`ketoRelationsPermissionsPrism declarative: allowed to simple: User:Bob "content": [ Token { "alias": undefined, - "content": "User", - "length": 4, + "content": "Group", + "length": 5, "type": "namespace", }, Token { @@ -953,12 +946,12 @@ exports[`ketoRelationsPermissionsPrism declarative: allowed to simple: User:Bob }, Token { "alias": undefined, - "content": "Bob", - "length": 3, + "content": "group2", + "length": 6, "type": "id", }, ], - "length": 8, + "length": 12, "type": "subject", }, " ", @@ -971,18 +964,116 @@ exports[`ketoRelationsPermissionsPrism declarative: allowed to simple: User:Bob " ", Token { "alias": undefined, - "content": "allowed to", + "content": "in", + "length": 2, + "type": "keyword", + }, + " ", + Token { + "alias": undefined, + "content": "members", + "length": 7, + "type": "permit", + }, + " ", + Token { + "alias": undefined, + "content": "of", + "length": 2, + "type": "keyword", + }, + " ", + Token { + "alias": undefined, + "content": [ + Token { + "alias": undefined, + "content": "Group", + "length": 5, + "type": "namespace", + }, + Token { + "alias": undefined, + "content": ":", + "length": 1, + "type": "delimiter", + }, + Token { + "alias": undefined, + "content": "group1", + "length": 6, + "type": "id", + }, + ], + "length": 12, + "type": "object", + }, + ], + "length": 42, + "type": "natural", + }, +] +`; + +exports[`ketoRelationsPermissionsPrism question: question with 'in': is User:Alice in viewers of Document:X should tokenize: "is User:Alice in viewers of Document:X" 1`] = ` +[ + Token { + "alias": undefined, + "content": [ + Token { + "alias": undefined, + "content": "is", + "length": 2, + "type": "keyword", + }, + " ", + Token { + "alias": undefined, + "content": [ + Token { + "alias": undefined, + "content": "User", + "length": 4, + "type": "namespace", + }, + Token { + "alias": undefined, + "content": ":", + "length": 1, + "type": "delimiter", + }, + Token { + "alias": undefined, + "content": "Alice", + "length": 5, + "type": "id", + }, + ], "length": 10, + "type": "subject", + }, + " ", + Token { + "alias": undefined, + "content": "in", + "length": 2, "type": "keyword", }, " ", Token { "alias": undefined, - "content": "read", - "length": 4, + "content": "viewers", + "length": 7, "type": "permit", }, " ", + Token { + "alias": undefined, + "content": "of", + "length": 2, + "type": "keyword", + }, + " ", Token { "alias": undefined, "content": [ @@ -1010,16 +1101,23 @@ exports[`ketoRelationsPermissionsPrism declarative: allowed to simple: User:Bob }, ], "length": 38, - "type": "natural", + "type": "natural-check", }, ] `; -exports[`ketoRelationsPermissionsPrism declarative: allowed to with subject relation: members of Group:Eng is allowed to read Document:X should tokenize: "members of Group:Eng is allowed to read Document:X" 1`] = ` +exports[`ketoRelationsPermissionsPrism question: question with relation subject: are members of Group:XYZ allowed to view on Document:X should tokenize: "are members of Group:XYZ allowed to view on Document:X" 1`] = ` [ Token { "alias": undefined, "content": [ + Token { + "alias": undefined, + "content": "are", + "length": 3, + "type": "keyword", + }, + " ", Token { "alias": undefined, "content": [ @@ -1051,7 +1149,7 @@ exports[`ketoRelationsPermissionsPrism declarative: allowed to with subject rela }, Token { "alias": undefined, - "content": "Eng", + "content": "XYZ", "length": 3, "type": "id", }, @@ -1060,13 +1158,6 @@ exports[`ketoRelationsPermissionsPrism declarative: allowed to with subject rela "type": "subject", }, " ", - Token { - "alias": undefined, - "content": "is", - "length": 2, - "type": "keyword", - }, - " ", Token { "alias": undefined, "content": "allowed to", @@ -1076,11 +1167,18 @@ exports[`ketoRelationsPermissionsPrism declarative: allowed to with subject rela " ", Token { "alias": undefined, - "content": "read", + "content": "view", "length": 4, "type": "permit", }, " ", + Token { + "alias": undefined, + "content": "on", + "length": 2, + "type": "keyword", + }, + " ", Token { "alias": undefined, "content": [ @@ -1107,17 +1205,24 @@ exports[`ketoRelationsPermissionsPrism declarative: allowed to with subject rela "type": "object", }, ], - "length": 50, - "type": "natural", + "length": 54, + "type": "natural-check", }, ] `; -exports[`ketoRelationsPermissionsPrism declarative: allowed to with 'to': User:Bob is allowed to access to Document:X should tokenize: "User:Bob is allowed to access to Document:X" 1`] = ` +exports[`ketoRelationsPermissionsPrism question: simple question: is User:Bob allowed to view on Document:X should tokenize: "is User:Bob allowed to view on Document:X" 1`] = ` [ Token { "alias": undefined, "content": [ + Token { + "alias": undefined, + "content": "is", + "length": 2, + "type": "keyword", + }, + " ", Token { "alias": undefined, "content": [ @@ -1144,13 +1249,6 @@ exports[`ketoRelationsPermissionsPrism declarative: allowed to with 'to': User:B "type": "subject", }, " ", - Token { - "alias": undefined, - "content": "is", - "length": 2, - "type": "keyword", - }, - " ", Token { "alias": undefined, "content": "allowed to", @@ -1160,14 +1258,14 @@ exports[`ketoRelationsPermissionsPrism declarative: allowed to with 'to': User:B " ", Token { "alias": undefined, - "content": "access", - "length": 6, + "content": "view", + "length": 4, "type": "permit", }, " ", Token { "alias": undefined, - "content": "to", + "content": "on", "length": 2, "type": "keyword", }, @@ -1198,8 +1296,8 @@ exports[`ketoRelationsPermissionsPrism declarative: allowed to with 'to': User:B "type": "object", }, ], - "length": 43, - "type": "natural", + "length": 41, + "type": "natural-check", }, ] `; diff --git a/tests/jest/prism/ketoRelationsPermissionsPrism.test.ts b/tests/jest/prism/ketoRelationsPermissionsPrism.test.ts index c1110d029..f3c52262d 100644 --- a/tests/jest/prism/ketoRelationsPermissionsPrism.test.ts +++ b/tests/jest/prism/ketoRelationsPermissionsPrism.test.ts @@ -50,6 +50,10 @@ describe("ketoRelationsPermissionsPrism", () => { name: "allowed to with 'to': User:Bob is allowed to access to Document:X", input: "User:Bob is allowed to access to Document:X", }, + { + name: "allowed to with relation containing dot: User:Bob is allowed to perform users.list on Document:X", + input: "User:Bob is allowed to perform users.list on Document:X", + }, ] const questionTestCases = [ From 1ef3c76609d2963ee7de222dd75f3271a4db0689 Mon Sep 17 00:00:00 2001 From: DavudSafarli Date: Wed, 6 May 2026 16:55:22 +0200 Subject: [PATCH 2/3] code block lang inconcistency --- docs/keto/guides/rbac.mdx | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/docs/keto/guides/rbac.mdx b/docs/keto/guides/rbac.mdx index 71e14e218..1bfec2769 100644 --- a/docs/keto/guides/rbac.mdx +++ b/docs/keto/guides/rbac.mdx @@ -107,7 +107,7 @@ Organization:org_123#reports.view@Role:viewer Role:admin#members@User:alice ``` -```shell +```bash keto relation-tuple parse -f policies.rts --format json | \ keto relation-tuple create -f - --insecure-disable-transport-security @@ -227,21 +227,21 @@ inherit from it: ```keto-tuples // viewer can view reports; -Organization:org_123 reports.view Role:viewer +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 +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 +User:eve#members@Role:report_editor ``` Both checks are allowed for Eve: -```keto-tuples -check User:eve viewReports Organization:org_123 -check User:eve createReports Organization:org_123 +```bash +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 From 6e11c1dc54ca6417270f2bf462b21f400f1efa91 Mon Sep 17 00:00:00 2001 From: DavudSafarli Date: Fri, 8 May 2026 18:02:36 +0200 Subject: [PATCH 3/3] review comments; correct hrbac example --- docs/keto/guides/rbac.mdx | 288 ++++++++++++++++++++++++++------------ 1 file changed, 202 insertions(+), 86 deletions(-) diff --git a/docs/keto/guides/rbac.mdx b/docs/keto/guides/rbac.mdx index 1bfec2769..8f7bdc823 100644 --- a/docs/keto/guides/rbac.mdx +++ b/docs/keto/guides/rbac.mdx @@ -5,15 +5,18 @@ title: 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: +A reporting application might define a fixed set of permissions: -- Admins can manage reports and invite members -- Viewers can only view reports -- Later, each organization can create custom roles with its own permission set +- `reports.view` +- `reports.create` +- `reports.edit` +- `reports.delete` +- `members.invite` +- `roles.manage` -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. +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 @@ -59,75 +62,109 @@ class Organization implements Namespace { } ``` +## Authorization scope + +This example uses `Organization` as the scope for every RBAC decision. Every permission check includes both the subject and the +organization: + +```keto-natural +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 -A user belongs to a role: +Permissions are modeled as relations on `Organization`. Each relation holds the set of roles that have been granted that +permission: ```keto-natural -User:alice is in members of Role:admin +Role:org_123/admin is allowed to perform reports.view on Organization:org_123 ``` -A role is granted a permission on an organization: +A user belongs to a role: ```keto-natural -Role:admin is allowed to perform reports.view on Organization:org_123 +User:alice is in members of Role:org_123/admin ``` So this check is allowed: ```keto-natural -is User:alice allowed to viewReports on Organization:org_123 ? // allowed +is User:alice allowed to viewReports on Organization:org_123 ``` -Keto traverses the roles granted `reports.view` on the organization and checks whether alice is a member of any of them. +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 -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. +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. -### 1. Creating a new organization +### Create 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: +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: ```keto-tuples // 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 +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:viewer +Organization:org_123#reports.view@Role:org_123/viewer // Assign Alice to the admin role -Role:admin#members@User:alice +Role:org_123/admin#members@User:alice ``` ```bash 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 +# 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: + ```bash keto check User:alice manageRoles Organization:org_123 --insecure-disable-transport-security Allowed ``` -### 2. Inviting a user +### Invite a user Suppose Alice invites Bob to the organization as a viewer. The application first checks whether Alice is allowed to invite members: @@ -137,12 +174,17 @@ keto check User:alice inviteMembers Organization:org_123 --insecure-disable-tran Allowed ``` -If allowed, the application processes the invitation and writes the role membership tuple: +If allowed, the application processes the invitation and assigns Bob to the viewer role: ```bash -keto relation-tuple create User:bob members Role:viewer --insecure-disable-transport-security +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: + ```bash keto check User:bob viewReports Organization:org_123 --insecure-disable-transport-security Allowed @@ -150,114 +192,188 @@ keto check User:bob createReports Organization:org_123 --insecure-disable-transp Denied ``` -### 3. Creating a custom role +### 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" — a role that can view, create, and edit reports. The application first -checks whether Alice can manage roles: +Suppose Alice creates a custom role called "Report Editor". The application first checks whether Alice can manage roles: ```bash 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: +If allowed, the application writes the role's permissions and assigns Eve to it. Save the following to a `report_editor.rts` file: ```keto-tuples -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 +// 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 ``` ```bash 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 + +# 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 ``` ```bash +# granted to report_editor 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 + +# not granted to report_editor +keto check User:eve deleteReports Organization:org_123 --insecure-disable-transport-security Denied ``` -### 4. Updating a role +### Update 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: +Suppose Alice adds permission to delete reports to the "Report Editor" role. After checking `manageRoles`, the application creates +the new tuple: ```bash -keto relation-tuple create Role:report_editor reports.delete Organization:org_123 --insecure-disable-transport-security +keto relation-tuple create Role:org_123/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: +To remove a permission, delete the corresponding tuple: ```bash -keto relation-tuple delete Role:report_editor reports.edit Organization:org_123 --insecure-disable-transport-security +keto relation-tuple delete Role:org_123/report_editor reports.delete Organization:org_123 --insecure-disable-transport-security ``` -## Extending the model with role inheritance +## Extending to hierarchical roles (HRBAC) -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. +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 the `Role` namespace with an `inherits_from` relation: +Extend `Role` with an `inheritors` relation: ```ts class Role implements Namespace { related: { members: User[] - inherits_from: Role[] + inheritors: Role[] } permits = { isMember: (ctx: Context): boolean => - this.related.members.includes(ctx.subject) || this.related.inherits_from.traverse((role) => role.permits.isMember(ctx)), + this.related.members.includes(ctx.subject) || this.related.inheritors.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: +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: + +```keto-natural +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: ```keto-tuples -// viewer can view reports; -Organization:org_123#reports.view@Role:viewer +// report_manager-specific permission +Organization:org_123#reports.delete@Role:org_123/report_manager -// 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 +// report_manager inherits report_editor, so view/create/edit come for free +Role:org_123/report_editor#inheritors@Role:org_123/report_manager -User:eve#members@Role:report_editor +// Assign Charlie to the report_manager role +Role:org_123/report_manager#members@User:charlie ``` -Both checks are allowed for Eve: +```bash +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 +``` ```bash -check User:eve viewReports Organization:org_123 // Allowed -check User:eve createReports Organization:org_123 // Allowed +# 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 ``` -`viewReports` passes because `report_editor` inherits from `viewer`, so eve passes viewer's `isMember` check without -`reports.view` being explicitly granted to `report_editor`. +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. -## Tenant isolation and role IDs +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. -In a multi-tenant application, role IDs must be scoped by organization. Without scoping, `Role:admin` is shared across all -tenants. +## Application responsibilities -For objects, use the format, where the `organization_id` and `role_id` refers to the unique id in your system that identifies -those resources. +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 `manageRoles` before 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: ```text -Role:{organization_id}/{role_id} +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: + +```bash +keto check User:alice deleteReports Organization:org_123 --insecure-disable-transport-security ``` -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. +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.