Manage access with RBAC
This guide walks through defining permissions, creating roles, binding them to users and workspaces, and applying common authorization patterns in Kessel. It covers the practical steps for setting up and managing RBAC — from writing your permission schema to granting access at the right scope.
Define your permission schema
Section titled “Define your permission schema”Permissions in Kessel are defined using the KSL schema language (.ksl files). KSL schemas declare the permissions your application needs and how they integrate with the RBAC model — roles, role bindings, and the workspace hierarchy.
The core RBAC schema
Section titled “The core RBAC schema”The foundation is rbac.ksl, which defines the five core types that every application’s permissions build on:
version 0.1namespace rbac
public type principal {}
public type group { relation member: [Any principal or group.member]}
public type role {}
public type role_binding { relation subject: [Any principal or group.member] relation granted: [AtLeastOne role]}
public type workspace { relation parent: [AtMostOne workspace] relation user_grant: [Any role_binding]}
public extension add_permission(name) { type role { private relation `${name}`: [bool] }
type role_binding { internal relation `${name}`: subject and granted.`${name}` }
type workspace { internal relation `${name}`: user_grant.`${name}` or parent.`${name}` }}These types work together:
principal— An individual identity (user or service account)group— A collection of principals; role bindings typically target groupsrole— A named collection of permissionsrole_binding— Binds a role to a subject (principal or group) and to the resource the grant applies to. The role and subject are relations on the binding itself; the resource points back at the binding — inrbac.ksl, through the workspace’suser_grantrelation. Permissions are granted only through role bindings, never by assigning a permission directly to a resourceworkspace— A container for resources; supports parent-child hierarchy for permission inheritance
The add_permission extension is the mechanism for registering permissions. When you call @rbac.add_permission(name:'view_document'), it adds a view_document relation to role, role_binding, and workspace — threading the permission through the entire RBAC chain so it can be granted via roles and inherited through the workspace hierarchy.
@rbac.add_permission — register a permission
Section titled “@rbac.add_permission — register a permission”Use @rbac.add_permission to register permissions for your application. This is the base extension that all other permission extensions build on.
Here’s a complete example — a document management application that defines three permissions:
version 0.1namespace drive
import rbac
// Extend RBAC model with relevant document permissions@rbac.add_permission(name:'view_document')@rbac.add_permission(name:'edit_document')@rbac.add_permission(name:'delete_document')
public type document { relation workspace: [ExactlyOne rbac.workspace]
// Define document permissions, // which are inherited from the workspace-level permissions // that we defined above. relation view: workspace.view_document relation edit: workspace.edit_document relation delete: workspace.delete_document}This schema:
- Imports the
rbacnamespace to access the core RBAC types - Registers three permissions (
view_document,edit_document,delete_document) using@rbac.add_permission - Defines a
documenttype that belongs to exactly one workspace and derives its permissions from workspace-level grants
The key line relation view: workspace.view_document means: “a user can view this document if they have the view_document permission on the document’s workspace.” This is how permissions flow from workspace-level role bindings down to individual resources.
@rbac.add_unified_permission — recommended for new services
Section titled “@rbac.add_unified_permission — recommended for new services”For new applications that do not have existing RBAC v1 permissions, use @rbac.add_unified_permission. This is the recommended extension for registering permissions — it generates a structured permission name from the application, resource, and verb, along with the wildcard permutations that make broad grants work. It is “unified” because the v1 and v2 names are the same, so there is only one name to maintain.
version 0.1namespace myservice
import rbac
@rbac.add_unified_permission(app:'myservice', resource:'integration', verb:'view');@rbac.add_unified_permission(app:'myservice', resource:'integration', verb:'edit');This creates permission names like myservice_integration_view and myservice_integration_edit that can be used in Check calls. These permissions are automatically threaded through the RBAC model — they can be granted via roles, bound through role bindings, and inherited through the workspace hierarchy.
See Protect an Endpoint for a complete example of using @rbac.add_unified_permission in context.
@rbac.add_v1_based_permission — when the v1 and v2 names differ
Section titled “@rbac.add_v1_based_permission — when the v1 and v2 names differ”Use @rbac.add_v1_based_permission when an application’s existing RBAC v1 permission name is not the name you want to expose in v2 — for example, keeping config_manager:profile:read working while your application checks config_manager_profile_view.
Both extensions generate the same wildcard permutations; wildcards are not what distinguishes them. The difference is that add_v1_based_permission takes the v1 app / resource / verb triple and a separate v2_perm name, so the two can diverge.
version 0.1namespace config_manager
import rbac
@rbac.add_v1_based_permission(app:'config_manager', resource:'profile', verb:'read', v2_perm:'config_manager_profile_view');@rbac.add_v1_based_permission(app:'config_manager', resource:'profile', verb:'write', v2_perm:'config_manager_profile_edit');Parameters:
| Parameter | Description | Example |
|---|---|---|
app |
Application name | 'config_manager' |
resource |
Resource type name (singular) | 'profile' |
verb |
The v1 verb | 'read' |
v2_perm |
Name of the compound permission in Kessel | 'config_manager_profile_view' |
What it generates:
For @rbac.add_v1_based_permission(app:'config_manager', resource:'profile', verb:'read', v2_perm:'config_manager_profile_view'), Kessel creates:
config_manager_profile_read— direct equivalent of the v1 permissionconfig_manager_profile_all— matchesconfig_manager:profile:*config_manager_all_read— matchesconfig_manager:*:readconfig_manager_all_all— matchesconfig_manager:*:*all_all_all— matches*:*:*config_manager_profile_view— compound permission that combines all of the above
The compound permission (v2_perm) is what your application should use in Check calls. It evaluates all wildcard permutations server-side, so your application no longer needs client-side wildcard matching logic.
Naming convention: The v2_perm name should use view instead of read and edit instead of write to distinguish v2 compound permissions from v1 originals.
@rbac.add_contingent_permission — compound permissions for host-centric assets
Section titled “@rbac.add_contingent_permission — compound permissions for host-centric assets”Some permissions need to be checked in combination with another permission. For example, viewing vulnerability data for a host requires both the vulnerability-specific permission and the inventory host view permission. The @rbac.add_contingent_permission extension creates this compound check.
version 0.1namespace patch
import rbacimport hbi
@rbac.add_v1_based_permission(app:'patch', resource:'system', verb:'read', v2_perm:'patch_system_view_assigned');@rbac.add_contingent_permission(first: 'inventory_host_view', second: 'patch_system_view_assigned', contingent: 'patch_system_view');Parameters:
| Parameter | Description | Example |
|---|---|---|
first |
The prerequisite permission (typically inventory_host_view) |
'inventory_host_view' |
second |
The application-specific permission | 'patch_system_view_assigned' |
contingent |
The resulting compound permission name | 'patch_system_view' |
This is equivalent to:
patch_system_view = inventory_host_view AND patch_system_view_assignedA user must have both permissions on the same workspace for the contingent permission to be granted. Use the contingent permission name (patch_system_view) in your Check calls — not the _assigned intermediate.
Full example with host exposure:
version 0.1namespace patch
import rbacimport hbi
@rbac.add_v1_based_permission(app:'patch', resource:'system', verb:'read', v2_perm:'patch_system_view_assigned');@rbac.add_contingent_permission(first: 'inventory_host_view', second: 'patch_system_view_assigned', contingent: 'patch_system_view');@hbi.expose_host_permission(v2_perm: 'patch_system_view', host_perm: 'patch_system_view');
@rbac.add_v1_based_permission(app:'patch', resource:'system', verb:'write', v2_perm:'patch_system_edit_assigned');@rbac.add_contingent_permission(first: 'inventory_host_view', second: 'patch_system_edit_assigned', contingent: 'patch_system_edit');@hbi.expose_host_permission(v2_perm: 'patch_system_edit', host_perm: 'patch_system_edit');Test with the SpiceDB Playground
Section titled “Test with the SpiceDB Playground”Before submitting your schema, transpile your .ksl files to a .zed schema and test it in the SpiceDB Playground. The playground lets you define test relationships and verify that permission checks produce the expected results.
- Transpile your KSL files to generate a
.zedschema. - Paste the
.zedschema into the SpiceDB Playground. - Add test relationships (roles, role bindings, workspace assignments) in the “Test Relationships” tab.
- Write assertions to verify permission checks produce the correct results.
Here’s what the compiled schema looks like for the drive example — this is what you’d paste into the playground. It is transpiled from three sources: rbac.ksl (the core RBAC types shown above), kessel.ksl (internal kessel/lock types that Kessel manages itself — your schema never references them), and your application’s drive.ksl:
definition drive/document { permission edit = t_workspace->edit_document permission delete = t_workspace->delete_document permission view = t_workspace->view_document permission workspace = t_workspace relation t_workspace: rbac/workspace}
definition kessel/lock { permission version = t_version relation t_version: kessel/lockversion}
definition kessel/lockversion {}
definition rbac/group { permission member = t_member relation t_member: rbac/principal | rbac/group#member}
definition rbac/principal {}
definition rbac/role { permission delete_document = t_delete_document relation t_delete_document: rbac/principal:* permission edit_document = t_edit_document relation t_edit_document: rbac/principal:* permission view_document = t_view_document relation t_view_document: rbac/principal:*}
definition rbac/role_binding { permission delete_document = (subject & t_granted->delete_document) permission edit_document = (subject & t_granted->edit_document) permission granted = t_granted relation t_granted: rbac/role permission subject = t_subject relation t_subject: rbac/principal | rbac/group#member permission view_document = (subject & t_granted->view_document)}
definition rbac/workspace { permission delete_document = t_user_grant->delete_document + t_parent->delete_document permission edit_document = t_user_grant->edit_document + t_parent->edit_document permission parent = t_parent relation t_parent: rbac/workspace permission user_grant = t_user_grant relation t_user_grant: rbac/role_binding permission view_document = t_user_grant->view_document + t_parent->view_document}Submit your schema
Section titled “Submit your schema”Once tested, add your .ksl files to the rbac-config repository by opening a PR. Separate configs exist for each environment (stage, prod), so you can test in stage before releasing to production.
You do not need to include the compiled .zed schema in your PR — an automated job transpiles the .ksl files after merge.
Create custom roles
Section titled “Create custom roles”Custom roles are collections of permissions created by organization administrators.
Create a role using the RBAC v2 API:
POST /api/rbac/v2/roles/{ "name": "Document Editor", "description": "Can view and edit documents", "permissions": [ { "application": "drive", "resource_type": "document", "operation": "view" }, { "application": "drive", "resource_type": "document", "operation": "edit" } ]}The role stores each permission as a relationship in the authorization graph. When a user receives a role binding to this role, they inherit all its permissions.
Each application / resource_type / operation triple corresponds to a permission your schema has already registered, named application_resourcetype_operation — the form generated by @rbac.add_unified_permission and @rbac.add_v1_based_permission. A role can only reference permissions that exist in the schema, so define the permission in your .ksl file before creating roles that use it.
Create role bindings
Section titled “Create role bindings”A role binding grants a role’s permissions to a subject (group or principal) on a specific resource (workspace or tenant). As with roles, the API below is most useful for seeding a test environment. Create role bindings using the RBAC v2 API:
POST /api/rbac/v2/role-bindings:batchCreate/{ "requests": [ { "resource": { "id": "<engineering-workspace-uuid>", "type": "workspace" }, "subject": { "id": "<engineering-group-uuid>", "type": "group" }, "role": { "id": "<document-editor-role-uuid>" } } ]}This grants the “Document Editor” role to the Engineering group on the Engineering workspace. All members of the Engineering group can now view and edit documents in that workspace — and in any child workspaces.
What happens under the hood
Section titled “What happens under the hood”A role binding creates three relationships in the authorization graph:
- role_binding → granted → role — Links the binding to the role it grants
- role_binding → subject → principal/group — Links the binding to who receives it
- workspace → user_grant → role_binding — Links the workspace to the binding
These three relationships are what Kessel traverses when evaluating a Check call. The binding ID follows a deterministic pattern: {role}--{user}--{resource}.
Workspace hierarchy and permission inheritance
Section titled “Workspace hierarchy and permission inheritance”Workspaces form a tree rooted at the ROOT workspace. Permissions granted on a parent workspace automatically apply to all descendant workspaces.
Root Workspace ← org-wide access (Pattern 4)└── Default Workspace ← general access (Pattern 3) ├── Engineering ← team-scoped access │ ├── Frontend Team │ └── Backend Team ├── Sales └── Ungrouped HostsHow permissions inherit
Section titled “How permissions inherit”When Kessel evaluates a Check call for a resource in the “Frontend Team” workspace, it walks up the tree:
- Check “Frontend Team” — any role bindings granting the permission here?
- Check “Engineering” (parent) — any role bindings here?
- Check “Default Workspace” (grandparent) — any role bindings here?
- Check “Root Workspace” (root) — any role bindings here?
Access is granted if any workspace in the path has a matching role binding. This means:
- A role binding at Root grants access across the entire organization
- A role binding at Engineering grants access to Engineering, Frontend Team, and Backend Team
- A role binding at Frontend Team grants access only to Frontend Team
Look up built-in workspace IDs
Section titled “Look up built-in workspace IDs”Most patterns require looking up the Root or Default workspace ID. Use the RBAC v2 workspace API:
GET /api/rbac/v2/workspaces/?type=root&with_ancestry=trueGET /api/rbac/v2/workspaces/?type=default&with_ancestry=trueRoot and Default workspace IDs are immutable per tenant. Cache them aggressively to avoid the latency and reliability cost of an RBAC lookup on every request.
Common patterns
Section titled “Common patterns”The right authorization pattern depends on the scope of the resource and whether it’s already organized into workspaces. These patterns align with the Migration Pattern Reference — see that page for the full decision tree and detailed request translations.
| Pattern | Check against | Use when |
|---|---|---|
| Native resource-level | The resource itself | The resource is reported to Kessel Inventory and assigned to a workspace |
| Workspace-scoped roles | A specific workspace | Resources are organized into workspaces but not individually represented in Kessel |
| Org-wide settings | The Root workspace | The resource is an org-wide setting that could later be workspace-scoped |
| Asset-level access | The Default workspace | Resources are not workspace-aware yet but could be |
| Host-centric assets | Workspaces, via a contingent permission | Access also requires inventory:hosts:read |
Native resource-level access
Section titled “Native resource-level access”When: Your resource is represented natively in Kessel — it has its own type in the schema, and each instance is reported to Kessel Inventory with a workspace_id. This is Pattern 1, the most flexible option, and it applies when queries return fewer than ~10,000 results or most results are accessible to the requesting user.
This is what the document type in drive.ksl models. The type declares its workspace and derives each permission from the corresponding workspace permission:
public type document { relation workspace: [ExactlyOne rbac.workspace]
relation view: workspace.view_document relation edit: workspace.edit_document relation delete: workspace.delete_document}As noted above, a real schema would name these permissions drive_document_view, drive_document_edit, and drive_document_delete; the short names here are carried over from the drive.ksl example for consistency.
Role bindings are still created on workspaces — today there is no way to bind a role directly to a document. What native representation buys you is the ability to check against the resource itself and let Kessel resolve the workspace assignment and hierarchy walk:
result, err := client.Check(ctx, &v1beta2.CheckRequest{ Object: &v1beta2.ResourceReference{ ResourceType: "document", ResourceId: documentID, Reporter: &v1beta2.ReporterReference{Type: "drive"}, }, Relation: "view", Subject: &v1beta2.SubjectReference{ Resource: &v1beta2.ResourceReference{ ResourceType: "principal", ResourceId: fmt.Sprintf("redhat/%s", userID), Reporter: &v1beta2.ReporterReference{Type: "rbac"}, }, },})Note the two differences from the workspace-level patterns below: the object is the document rather than its workspace, and the relation is the relation declared on the document type (view), not the workspace permission it derives from (view_document).
For list endpoints, StreamedListObjects can return the accessible document IDs directly instead of workspace IDs — see Protect a list endpoint.
Workspace-scoped roles (per-team access)
Section titled “Workspace-scoped roles (per-team access)”When: Your resources are organized into workspaces, and you want different teams to have different levels of access.
Grant role bindings at specific workspaces to scope access to teams or projects:
{ "requests": [ { "resource": { "id": "<engineering-workspace-uuid>", "type": "workspace" }, "subject": { "id": "<frontend-team-group-uuid>", "type": "group" }, "role": { "id": "<document-editor-role-uuid>" } } ]}The Frontend Team group can edit documents in the Engineering workspace and its children, but not in the Sales workspace.
Authorization check:
Use Check for read operations and CheckForUpdate for write operations or sensitive reads (e.g., credential access). CheckForUpdate provides stronger consistency guarantees.
The relation is the permission name registered in your schema. For the drive.ksl example that is view_document, because the permission was registered with @rbac.add_permission(name:'view_document'). Permissions registered with @rbac.add_unified_permission or @rbac.add_v1_based_permission instead follow the generated app_resource_verb form (for example, myservice_integration_view).
result, err := client.Check(ctx, &v1beta2.CheckRequest{ Object: &v1beta2.ResourceReference{ ResourceType: "workspace", ResourceId: resourceWorkspaceID, Reporter: &v1beta2.ReporterReference{Type: "rbac"}, }, Relation: "view_document", Subject: &v1beta2.SubjectReference{ Resource: &v1beta2.ResourceReference{ ResourceType: "principal", ResourceId: fmt.Sprintf("redhat/%s", userID), Reporter: &v1beta2.ReporterReference{Type: "rbac"}, }, },})For listing resources across workspaces, use StreamedListObjects to get the workspace IDs the user can access, then filter your database query. See Protect a list endpoint for examples.
Org-wide settings (Root Workspace)
Section titled “Org-wide settings (Root Workspace)”When: The resource represents an organization-wide setting that could eventually be scoped to workspaces (e.g., recommendation acknowledgements, RHC configuration).
Bind roles at the Root Workspace. Because permissions inherit downward, a binding at Root grants access across all workspaces:
{ "requests": [ { "resource": { "id": "<root-workspace-uuid>", "type": "workspace" }, "subject": { "id": "<admin-group-uuid>", "type": "group" }, "role": { "id": "<org-settings-admin-role-uuid>" } } ]}Authorization check:
Check against the Root Workspace:
result, err := client.Check(ctx, &v1beta2.CheckRequest{ Object: &v1beta2.ResourceReference{ ResourceType: "workspace", ResourceId: rootWorkspaceID, Reporter: &v1beta2.ReporterReference{Type: "rbac"}, }, Relation: "advisor_recommendation_edit", Subject: &v1beta2.SubjectReference{ Resource: &v1beta2.ResourceReference{ ResourceType: "principal", ResourceId: fmt.Sprintf("redhat/%s", userID), Reporter: &v1beta2.ReporterReference{Type: "rbac"}, }, },})For settings that will never be workspace-scoped (e.g., authentication policy, user invitation), use the tenant resource instead — see Pattern 5: Organization-level.
Asset-level access (Default Workspace)
Section titled “Asset-level access (Default Workspace)”When: Your resources are not yet organized into workspaces but can be conceptualized as assets that a customer manages (e.g., repositories, SCAP policies).
Bind roles at the Default Workspace as a stepping stone. This preserves org-wide behavior while making the resource compatible with workspace-scoped authorization for a future migration:
{ "requests": [ { "resource": { "id": "<default-workspace-uuid>", "type": "workspace" }, "subject": { "id": "<engineering-group-uuid>", "type": "group" }, "role": { "id": "<repo-viewer-role-uuid>" } } ]}Authorization check:
Check against the Default Workspace:
result, err := client.Check(ctx, &v1beta2.CheckRequest{ Object: &v1beta2.ResourceReference{ ResourceType: "workspace", ResourceId: defaultWorkspaceID, Reporter: &v1beta2.ReporterReference{Type: "rbac"}, }, Relation: "myapp_repository_view", Subject: &v1beta2.SubjectReference{ Resource: &v1beta2.ResourceReference{ ResourceType: "principal", ResourceId: fmt.Sprintf("redhat/%s", userID), Reporter: &v1beta2.ReporterReference{Type: "rbac"}, }, },})Host-centric assets (contingent permissions)
Section titled “Host-centric assets (contingent permissions)”When: Your resources are related to hosts and access requires both the app-specific permission and the inventory:hosts:read permission (e.g., host vulnerabilities, advisories, patch data).
Define contingent permissions in your KSL schema (see @rbac.add_contingent_permission above), then use StreamedListObjects with the contingent permission name to get workspace IDs where the user has both permissions:
stream, err := client.StreamedListObjects(ctx, &v1beta2.StreamedListObjectsRequest{ ObjectType: &v1beta2.RepresentationType{ ResourceType: "workspace", ReporterType: "rbac", }, Relation: "patch_system_view", Subject: &v1beta2.SubjectReference{ Resource: &v1beta2.ResourceReference{ ResourceType: "principal", ResourceId: fmt.Sprintf("redhat/%s", userID), Reporter: &v1beta2.ReporterReference{Type: "rbac"}, }, },})Use the returned workspace IDs to filter your database query (e.g., WHERE workspace_id IN (...)). This is Pattern 2 from the Migration Pattern Reference.