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.tsvia a statement map extending better-auth with Kaneo-specific resources likeproject,task, andworkspace - Four built-in roles (viewer, member, admin, owner) are generated using
createAccessControland stored in theworkspace_roletable as JSON viatoMutablePayload - Automatic seeding occurs during workspace creation through
seed-default-workspace-roles.ts, ensuring database consistency with static definitions - Server-side enforcement happens through the
requireWorkspacePermissionmiddleware, which useshasWorkspacePermissionand thesatisfiesroutine 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:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →