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

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, 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. This file declares a static statement object that enumerates allowed actions for each resource:

// 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:

// 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:

// 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:

// 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 populates this table on workspace creation:

// 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, these organization statements spread into the workspace role definitions (lines 20‑23):

// 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:

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:

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 Permission statements, AC instance, built‑in roles, default payloads
apps/api/src/database/schema.ts workspace_role table with JSONB role column
apps/api/src/utils/seed-default-workspace-roles.ts Workspace creation role seeding
apps/api/src/workspace/index.ts API endpoints with permission guards
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.
  • 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. 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 ensures payloads remain compatible with the AC system's expectations, enabling runtime permission evolution.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →