# How Kaneo's Permission System Handles Workspaces and Teams: A Complete Technical Guide

> Explore Kaneo's robust permission system. Learn how workspaces and teams are managed with a hierarchical RBAC model, workspace-scoped roles, and four built-in permission levels. Get the technical details.

- Repository: [kaneo.app/kaneo](https://github.com/usekaneo/kaneo)
- Tags: deep-dive
- Published: 2026-08-06

---

**Kaneo implements a hierarchical RBAC model using Better‑Auth as its foundation, with workspace‑scoped roles stored in PostgreSQL JSONB columns and four built‑in permission levels (viewer, member, admin, owner).**

In the open‑source project management platform [Kaneo](https://github.com/usekaneo/kaneo), the permission system bridges organization‑level team membership with fine‑grained workspace access control. This article explains how the codebase in `usekaneo/kaneo` implements this architecture, from static permission statements to dynamic workspace role resolution.

## Permission Statements and the Access Control Core

All permission logic lives in **[`packages/permissions/src/index.ts`](https://github.com/usekaneo/kaneo/blob/main/packages/permissions/src/index.ts)**. This file declares a static **statement** object that enumerates allowed actions for each resource:

```ts
// packages/permissions/src/index.ts
export const statement = {
  ...defaultStatements,
  project: ["create", "read", "update", "delete", "share"],
  task: ["create", "read", "update", "delete", "assign"],
  label: ["create", "read", "update", "delete"],
  workspace: ["read", "update", "delete", "manage_settings"],
} as const;

```

These statements feed into Better‑Auth's `createAccessControl` factory to produce an **AC instance** (`ac`). This instance generates role objects and performs runtime permission checks throughout the API.

The statement design separates **resource types** (project, task, label, workspace) from **action verbs**, enabling granular policy expressions while keeping the permission matrix comprehensible.

## Built‑in Roles and Their Permission Sets

Kaneo defines four built‑in roles using the AC instance. Each role combines Better‑Auth's default statements with workspace‑specific permissions:

| Role | Base Permissions | Workspace‑Specific Grants |
|------|------------------|---------------------------|
| **viewer** | `memberAc.statements` | `workspace: read` |
| **member** | `memberAc.statements` | `project:create \| read`, `task:create \| read \| update`, `label:*`, `workspace: read` |
| **admin** | `adminAc.statements` | `project:*`, `task:* \| assign`, `label:*`, `workspace: read \| update \| manage_settings` |
| **owner** | `ownerAc.statements` | Same as admin, plus `workspace: delete` |

The role definitions appear immediately after the statement declaration:

```ts
// packages/permissions/src/index.ts
export const viewer = ac.newRole({ ... });
export const member = ac.newRole({ ... });
export const admin  = ac.newRole({ ... });
export const owner  = ac.newRole({ ... });

```

Notably, **owner** is the only role that can delete a workspace. This distinction ensures destructive operations require explicit top‑level authorization.

## Workspace‑Scoped Role Storage and Resolution

When a user creates a workspace, Kaneo persists role definitions in PostgreSQL rather than relying solely on code. This enables per‑workspace customization.

### Default Role Payloads

The same file exports **mutable JSON payloads** for the three seedable roles:

```ts
// packages/permissions/src/index.ts
export const defaultRolePayloads = {
  viewer: toMutablePayload(viewer.statements),
  member: toMutablePayload(member.statements),
  admin:  toMutablePayload(admin.statements),
};

```

These payloads exclude **owner**, which remains organization‑level only.

### Database Schema

The `workspace_role` table stores these payloads in a **JSONB column**, defined in **[`apps/api/src/database/schema.ts`](https://github.com/usekaneo/kaneo/blob/main/apps/api/src/database/schema.ts)**:

```ts
// apps/api/src/database/schema.ts
export const workspaceRoleTable = pgTable("workspace_role", {
  id: text("id").primaryKey(),
  workspaceId: text("workspace_id").notNull(),
  name: text("name").notNull(),        // "viewer", "member", or "admin"
  role: jsonb("role").notNull(),       // The permission payload
  createdAt: timestamp("created_at").defaultNow(),
});

```

Using JSONB permits flexible permission evolution without schema migrations, while PostgreSQL's JSON indexing supports efficient lookups.

### Seeding Workspace Roles

The utility 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)** populates this table on workspace creation:

```ts
// Typical usage pattern
import { seedDefaultWorkspaceRoles } from "@/utils/seed-default-workspace-roles";

async function createWorkspace(workspaceData: CreateWorkspaceInput) {
  const workspace = await db.insert(workspaceTable).values(workspaceData).returning();
  await seedDefaultWorkspaceRoles(workspace[0].id);  // Creates 3 role rows
  return workspace[0];
}

```

### Runtime Role Resolution

During request handling, Kaneo resolves effective permissions through a merge process:

1. **Determine organization‑level role** via Better‑Auth (owner/member/viewer from team membership).
2. **Fetch workspace‑specific role row** from `workspace_role` where `workspaceId` matches.
3. **Merge static statements with stored payload**, with workspace overrides taking precedence.
4. **Fallback to built‑in definitions** if no workspace role exists.

## Teams and Organization‑Level Permissions

Kaneo delegates team management to **Better‑Auth's organization plugin** (`better-auth/plugins/organization`). This plugin provides:

- Default statements for **organization member, admin, and owner** roles.
- Team invitation and membership APIs.
- Base permission sets that flow into workspace roles.

In [`packages/permissions/src/index.ts`](https://github.com/usekaneo/kaneo/blob/main/packages/permissions/src/index.ts), these organization statements spread into the workspace role definitions (lines 20‑23):

```ts
// The ...defaultStatements spread includes org‑level permissions
export const statement = {
  ...defaultStatements,  // Contains org: create, read, update, delete, etc.
  project: ["create", "read", "update", "delete", "share"],
  // ... workspace resources
};

```

This architecture means **a user's team membership establishes their permission baseline**, while workspace roles provide contextual refinement. An organization owner automatically carries owner‑level permissions into any workspace they access, unless explicitly restricted.

## Performing Permission Checks

The API enforces authorization using the AC instance directly:

```ts
import { ac } from "@/permissions";
import type { BuiltInRoleName } from "@/permissions";

function can(userRole: BuiltInRoleName, resource: keyof typeof statement, action: string): boolean {
  return ac.can(userRole, resource, action);
}

// Route guard example
app.post("/tasks/:id/assign", async (c) => {
  const userRole = await getEffectiveRole(c.var.user.id, c.var.workspace.id);
  if (!can(userRole, "task", "assign")) {
    return c.json({ error: "Forbidden" }, 403);
  }
  // Proceed with assignment...
});

```

For dynamic workspace roles loaded from the database:

```ts
import { getWorkspaceRole } from "@/database";

async function hasPermission(
  userId: string,
  workspaceId: string,
  resource: string,
  action: string
): Promise<boolean> {
  const wsRoleRow = await getWorkspaceRole(userId, workspaceId);
  const dynamicRole = ac.newRole(wsRoleRow.role);  // Rebuild role from JSONB
  return ac.can(dynamicRole, resource, action);
}

```

## Key Implementation Files

| File Path | Responsibility |
|-----------|--------------|
| [`packages/permissions/src/index.ts`](https://github.com/usekaneo/kaneo/blob/main/packages/permissions/src/index.ts) | Permission statements, AC instance, built‑in roles, default payloads |
| [`apps/api/src/database/schema.ts`](https://github.com/usekaneo/kaneo/blob/main/apps/api/src/database/schema.ts) | `workspace_role` table with JSONB `role` column |
| [`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) | Workspace creation role seeding |
| [`apps/api/src/workspace/index.ts`](https://github.com/usekaneo/kaneo/blob/main/apps/api/src/workspace/index.ts) | API endpoints with permission guards |
| [`apps/api/src/auth.ts`](https://github.com/usekaneo/kaneo/blob/main/apps/api/src/auth.ts) | Better‑Auth integration, organization plugin setup |

## Summary

- **Statement‑driven architecture**: All permissions derive from a single `statement` object in [`packages/permissions/src/index.ts`](https://github.com/usekaneo/kaneo/blob/main/packages/permissions/src/index.ts).
- **Four built‑in roles**: viewer, member, admin, and owner—with owner exclusively holding workspace deletion rights.
- **JSONB persistence**: Workspace roles store in PostgreSQL JSONB columns via `workspace_role`, enabling per‑workspace customization without code changes.
- **Hierarchical resolution**: Organization team membership (Better‑Auth) provides base permissions; workspace roles layer on context‑specific grants.
- **Runtime AC checks**: The `ac.can()` method enforces authorization throughout the API, supporting both static and dynamically reconstructed roles.

## Frequently Asked Questions

### How does Kaneo store workspace permissions in the database?

Kaneo persists workspace permissions in 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). Each row contains a `role` column of type **JSONB** that stores the complete permission payload for a specific workspace and role name (viewer, member, or admin). This design allows administrators to modify permissions per workspace without deploying code changes.

### What is the difference between organization teams and workspace roles?

**Organization teams** are managed by Better‑Auth's organization plugin and establish a user's base permission level (viewer, member, admin, or owner) across the entire organization. **Workspace roles** are stored in the `workspace_role` table and provide contextual permission overrides for specific workspaces. Kaneo merges these two layers at runtime, with workspace roles taking precedence for workspace‑scoped resources.

### Why doesn't Kaneo store the owner role in workspace_role?

The **owner** role remains exclusively at the organization level because workspace destruction is a high‑impact operation. Limiting `workspace: delete` to organization‑level owners ensures consistent governance: only users with top‑level organizational authority can eliminate workspaces entirely, regardless of workspace‑specific role customizations.

### Can workspace permissions be customized without modifying code?

Yes. Because permissions store as **JSONB payloads** in `workspace_role.role`, administrators can directly update database rows or build UI workflows that modify these payloads. The `toMutablePayload()` utility in [`packages/permissions/src/index.ts`](https://github.com/usekaneo/kaneo/blob/main/packages/permissions/src/index.ts) ensures payloads remain compatible with the AC system's expectations, enabling runtime permission evolution.