How Permissions Are Defined and Managed in Kaneo: A Complete Technical Guide

Kaneo implements a declarative access-control system built on a centralized permission map that extends better-auth statements, storing role definitions as JSON in the workspace_role table and enforcing them via the requireWorkspacePermission middleware.

Kaneo is an open-source project management platform that uses a granular, resource-based permission system to control access to projects, tasks, labels, and workspace settings. This article examines how permissions are defined and managed in Kaneo, covering the declarative permission map, role storage mechanisms, and enforcement middleware implemented across the TypeScript codebase.

The Declarative Permission Map

The foundation of Kaneo's access control lives in packages/permissions/src/index.ts. This file exports a statement constant that extends the default statements from better-auth with Kaneo-specific resources and actions.

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;

This map defines every possible action on each core resource. A Role is created from this map via createAccessControl(statement). The system provides four built-in roles—viewer, member, admin, and owner—each receiving a subset of these actions (defined in lines 19‑49 of the file).

The role definitions are exported as builtInRoles, while the standard role names are listed in DEFAULT_ROLE_NAMES.

Database Storage and Default Payloads

Permissions for a workspace are persisted in the workspace_role table as a JSON string that mirrors the role's .statements. The payloads are generated by toMutablePayload (lines 63‑70) and exposed as defaultRolePayloads (lines 73‑84).

When a workspace is created, apps/api/src/utils/seed-default-workspace-roles.ts inserts rows for the default roles using defaultRolePayloads. This ensures the database row matches the static role definition while still allowing UI-based edits later.

Enforcing Permissions with Middleware

The API layer enforces permissions through the requireWorkspacePermission middleware located in apps/api/src/utils/require-workspace-permission.ts:

export function requireWorkspacePermission(permissions: PermissionMap) {
  return async (c, next) => {
    const has = await hasWorkspacePermission(c, permissions);
    if (!has) throw new HTTPException(403, { message: "Insufficient permissions" });
    await next();
  };
}

The helper hasWorkspacePermission reads the workspace_role.permission column, parses it with parsePermissionStatements, and uses the satisfies routine from better-auth to verify that the stored actions include the required ones.

Applying Permissions in API Routes

Controllers invoke the middleware to protect endpoints. For example, to restrict project creation to users with the appropriate permission:

import { requireWorkspacePermission } from "../utils/require-workspace-permission";

router.post(
  "/project",
  requireWorkspacePermission({ project: ["create"] }),
  createProjectController,
);

This guarantees that only users whose role grants project:create can access the endpoint. Similarly, task deletion can be protected:

import { requireWorkspacePermission } from "@/utils/require-workspace-permission";

router.delete(
  "/task/:taskId",
  requireWorkspacePermission({ task: ["delete"] }),
  async (c) => { /* delete logic */ }
);

Extending the System with Custom Roles

Custom roles can be added without modifying the core codebase by inserting a new row in workspace_role with a JSON payload following the same shape ({ resource: ["action", …] }). The existing requireWorkspacePermission logic automatically respects these new roles.

To create a custom role from the client side:

import { api } from "@kaneo/client";

await api.mutate({
  endpoint: "/workspace/:id/role",
  method: "POST",
  body: {
    name: "qa_engineer",
    permission: {
      project: ["read"],
      task: ["read", "update"],
      label: ["read"],
      workspace: ["read"],
    },
  },
});

For debugging, you can inspect a role's statements using the exported constants:

import { builtInRoles } from "@kaneo/permissions";

console.log(builtInRoles.admin.statements);
/*
{
  project: ["create","read","update","delete","share"],
  task:    ["create","read","update","delete","assign"],
  label:   ["create","read","update","delete"],
  workspace: ["read","update","manage_settings"],
  // plus default organization statements …
}
*/

Key Files in the Permission System

File Purpose
packages/permissions/src/index.ts Defines the permission map, creates the built‑in roles, and exports default payloads
apps/api/src/utils/require-workspace-permission.ts Middleware that enforces permissions on API routes
apps/api/src/utils/seed-default-workspace-roles.ts Seeds the database with default role rows on workspace creation
apps/web/src/lib/permissions.ts Re‑exports the role constants for the front‑end UI
apps/web/src/hooks/use-workspace-permission.ts Client‑side hook that queries the server’s permission check endpoint

Summary

  • Permissions are defined declaratively in packages/permissions/src/index.ts via a statement map extending better-auth with Kaneo-specific resources like project, task, and workspace
  • Four built-in roles (viewer, member, admin, owner) are generated using createAccessControl and stored in the workspace_role table as JSON via toMutablePayload
  • Automatic seeding occurs during workspace creation through seed-default-workspace-roles.ts, ensuring database consistency with static definitions
  • Server-side enforcement happens through the requireWorkspacePermission middleware, which uses hasWorkspacePermission and the satisfies routine to validate requests against stored JSON permissions
  • Custom roles can be added dynamically by inserting rows with valid JSON payloads, extending the system without code changes

Frequently Asked Questions

Where are permissions defined in the Kaneo codebase?

Permissions are defined in packages/permissions/src/index.ts as a declarative statement object that extends better-auth default statements. This file exports the permission map, built-in roles created via createAccessControl, and default payloads for database seeding.

How does Kaneo store and retrieve user permissions?

Kaneo stores permissions in the workspace_role table as JSON strings matching the role's .statements structure. The hasWorkspacePermission utility retrieves and parses this JSON using parsePermissionStatements, then validates it against required permissions using better-auth's satisfies routine.

Can I create custom roles in Kaneo without modifying the source code?

Yes, you can create custom roles by inserting new rows into the workspace_role table with a JSON payload following the shape { resource: ["action", ...] }. The requireWorkspacePermission middleware automatically respects these custom roles, allowing extensibility without redeploying the application.

How do I check permissions inside an API route handler?

Use the requireWorkspacePermission middleware from apps/api/src/utils/require-workspace-permission.ts. Import it and apply it to your route before the controller function, passing the required permission map (e.g., { task: ["delete"] }) to enforce access control at the endpoint level.

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 →