# How to Configure Custom Workspace Roles in Kaneo: A Complete RBAC Guide

> Master Kaneo's RBAC system to configure custom workspace roles. Learn how to define granular access controls using its three-layer permission system for enhanced security.

- Repository: [kaneo.app/kaneo](https://github.com/usekaneo/kaneo)
- Tags: how-to-guide
- Published: 2026-08-30

---

**Kaneo implements custom workspace roles through a three-layer RBAC system that combines static permission definitions, JSON-stored database overrides, and runtime resolution via Better-Auth to let you define granular access controls per workspace.**

Kaneo’s role-based access control (RBAC) allows workspace administrators to move beyond the four built-in roles and define precise permissions for projects, tasks, labels, and workspace settings. By leveraging the `workspace_role` table and Better-Auth’s dynamic access control as implemented in the `usekaneo/kaneo` repository, you can create, assign, and modify custom roles without deploying code changes.

## Understanding Kaneo's Three-Layer RBAC Architecture

Kaneo’s permission system operates through three distinct layers that work together at runtime:

- **Permission Definitions**: The [`packages/permissions/src/index.ts`](https://github.com/usekaneo/kaneo/blob/main/packages/permissions/src/index.ts) file declares a static set of **statements** describing what actions each resource supports. This module exports the base `ac` instance, the `statement` object, and the four built-in roles: `viewer`, `member`, `admin`, and `owner`.

- **Database Persistence**: The `workspace_role` table defined in [`apps/api/src/database/schema.ts`](https://github.com/usekaneo/kaneo/blob/main/apps/api/src/database/schema.ts) (lines 85-99) stores editable JSON payloads for the three customizable roles (`viewer`, `member`, `admin`) per workspace. The `permission` column contains a stringified JSON object that mirrors the static statements, allowing per-workspace overrides.

- **Runtime Resolution**: In [`apps/api/src/auth.ts`](https://github.com/usekaneo/kaneo/blob/main/apps/api/src/auth.ts), Better-Auth’s `createAccessControl` loads the built-in role definitions, then merges any matching `workspace_role` row to produce the effective permission set for the current user.

Additionally, [`apps/api/src/utils/seed-default-workspace-roles.ts`](https://github.com/usekaneo/kaneo/blob/main/apps/api/src/utils/seed-default-workspace-roles.ts) ensures every workspace receives default role rows on API startup, backfilling existing workspaces that lack them.

## Creating a Custom Workspace Role

To define a custom role, you must construct a permission payload and persist it through the API.

### Step 1: Define the Permission Payload

Create a plain object where keys represent resource names (`project`, `task`, `label`, `workspace`) and values are arrays of allowed actions:

```typescript
const customPayload = {
  project: ["create", "read", "update"],
  task: ["create", "read", "update", "assign"],
  label: ["read"],
  workspace: ["read", "update"],
};

```

### Step 2: Persist the Role via API

Insert a row into the `workspace_role` table using the typed client from `@kaneo/libs`. The endpoint follows the standard CRUD pattern:

```typescript
import { client } from "@kaneo/libs";

const workspaceId = "w_01ABC...";
const roleName = "project-manager";

const payload = {
  project: ["create", "read", "update", "delete", "share"],
  task: ["create", "read", "update", "assign"],
  label: ["create", "read", "update", "delete"],
  workspace: ["read", "update"],
};

await client.post(`/workspaces/${workspaceId}/roles`, {
  role: roleName,
  permission: JSON.stringify(payload),
});

```

The `role` column stores your custom identifier (e.g., `project-manager`), while the `permission` column stores the JSON stringified payload.

## Assigning Custom Roles to Workspace Members

Once created, apply the custom role to users by updating their `workspace_member` record. The `role` field references any name existing in the `workspace_role` table:

```typescript
await client.patch(
  `/workspaces/${workspaceId}/members/${userId}`,
  { role: roleName }
);

```

Better-Auth’s middleware validates permissions against this assigned role on every subsequent request.

## Modifying Custom Workspace Role Permissions

Update existing roles by patching the `workspace_role` row with a new JSON payload. Changes take effect immediately without requiring users to re-authenticate:

```typescript
const newPayload = {
  ...payload,
  task: [...payload.task, "delete"], // add delete permission on tasks
};

await client.patch(
  `/workspaces/${workspaceId}/roles/${roleName}`,
  { permission: JSON.stringify(newPayload) }
);

```

## Runtime Enforcement and Permission Validation

When a request hits a protected route, the `requireWorkspacePermission` middleware resolves the effective permission set:

1. Loads built-in role definitions from [`packages/permissions/src/index.ts`](https://github.com/usekaneo/kaneo/blob/main/packages/permissions/src/index.ts)
2. Queries the `workspace_role` table for the user's assigned role
3. Merges the JSON payload with base permissions
4. Validates the requested action against the final statement set

If the permission is missing, the middleware returns a **403 Forbidden** response. The integration tests in [`tests/api-integration/workspace-rbac.test.ts`](https://github.com/usekaneo/kaneo/blob/main/tests/api-integration/workspace-rbac.test.ts) verify this behavior, including malformed payload handling:

```typescript
// Example test verification from workspace-rbac.test.ts
it("returns 403 when the workspace_role permission JSON is malformed", async () => { 
  // Test implementation verifies error handling
});

```

## Summary

- Kaneo stores custom workspace roles in the `workspace_role` table with JSON payloads that override built-in permissions
- The three editable built-in roles (`viewer`, `member`, `admin`) can be customized per workspace, while `owner` remains static
- Permission payloads map resources (`project`, `task`, `label`, `workspace`) to arrays of allowed actions
- Runtime resolution merges static definitions from [`packages/permissions/src/index.ts`](https://github.com/usekaneo/kaneo/blob/main/packages/permissions/src/index.ts) with database overrides in [`apps/api/src/auth.ts`](https://github.com/usekaneo/kaneo/blob/main/apps/api/src/auth.ts)
- Changes to role permissions apply immediately to all assigned members via standard PATCH requests

## Frequently Asked Questions

### What built-in roles come with Kaneo by default?

Kaneo ships with four built-in roles defined in [`packages/permissions/src/index.ts`](https://github.com/usekaneo/kaneo/blob/main/packages/permissions/src/index.ts): **viewer** (read-only), **member** (standard participation), **admin** (management capabilities), and **owner** (full control). The system seeds editable database rows for `viewer`, `member`, and `admin`, allowing per-workspace customization, while `owner` remains a static super-admin role.

### Can I create entirely new role names beyond the built-in ones?

Yes. The `workspace_role` table accepts any string identifier for the `role` column. When you POST to `/workspaces/{workspaceId}/roles` with a unique name like `project-manager` or `external-contractor`, Kaneo creates a new role definition that can be assigned to members via the membership API.

### How does Kaneo handle missing or malformed permission JSON?

If the `permission` column contains invalid JSON or lacks required fields, Better-Auth’s runtime resolution falls back to built-in defaults for that role level. The integration tests in [`tests/api-integration/workspace-rbac.test.ts`](https://github.com/usekaneo/kaneo/blob/main/tests/api-integration/workspace-rbac.test.ts) specifically verify that malformed payloads trigger 403 responses rather than server errors, maintaining security boundaries.

### Do I need to restart the API after modifying role permissions?

No. Because Kaneo resolves permissions at request-time by querying the `workspace_role` table, changes to custom workspace roles take effect immediately. The `seedDefaultWorkspaceRoles()` function in [`apps/api/src/utils/seed-default-workspace-roles.ts`](https://github.com/usekaneo/kaneo/blob/main/apps/api/src/utils/seed-default-workspace-roles.ts) only runs at startup to ensure default rows exist, but does not cache permission content.