Skip to content

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.

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 foundation is rbac.ksl, which defines the five core types that every application’s permissions build on:

rbac.ksl
version 0.1
namespace 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 groups
  • role — A named collection of permissions
  • role_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 — in rbac.ksl, through the workspace’s user_grant relation. Permissions are granted only through role bindings, never by assigning a permission directly to a resource
  • workspace — 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:

drive.ksl
version 0.1
namespace 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:

  1. Imports the rbac namespace to access the core RBAC types
  2. Registers three permissions (view_document, edit_document, delete_document) using @rbac.add_permission
  3. Defines a document type 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.

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.1
namespace 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.1
namespace 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 permission
  • config_manager_profile_all — matches config_manager:profile:*
  • config_manager_all_read — matches config_manager:*:read
  • config_manager_all_all — matches config_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.1
namespace patch
import rbac
import 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_assigned

A 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.1
namespace patch
import rbac
import 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');

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.

  1. Transpile your KSL files to generate a .zed schema.
  2. Paste the .zed schema into the SpiceDB Playground.
  3. Add test relationships (roles, role bindings, workspace assignments) in the “Test Relationships” tab.
  4. 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:

schema.zed (compiled from rbac.ksl + kessel.ksl + 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
}

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.

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.

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.

A role binding creates three relationships in the authorization graph:

  1. role_binding → granted → role — Links the binding to the role it grants
  2. role_binding → subject → principal/group — Links the binding to who receives it
  3. 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 Hosts

When Kessel evaluates a Check call for a resource in the “Frontend Team” workspace, it walks up the tree:

  1. Check “Frontend Team” — any role bindings granting the permission here?
  2. Check “Engineering” (parent) — any role bindings here?
  3. Check “Default Workspace” (grandparent) — any role bindings here?
  4. 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

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=true
GET /api/rbac/v2/workspaces/?type=default&with_ancestry=true

Root 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.

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

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.

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.

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.

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.