Skip to main content

ADR-003: Custom Roles and Permissions

  • Status: Accepted
  • Date: 2026-04-16
  • Author: CTO Agent (KALA-141)

Context

The original authorization model used a fixed Prisma Role enum:

  • admin
  • dept_head
  • approver
  • employee

That was enough for early API gates, but not enough for real companies. Tenants need roles like "Legal reviewer", "Finance operator", or "Regional manager" without waiting for a code deployment. At the same time, the product does not yet need a full permission-table many-to-many RBAC system with role hierarchy, resource-specific grants, and policy language.

Decision

Introduce SystemRole as the primary role model:

model SystemRole {
id String
name String
slug String
isSystem Boolean
allDepartments Boolean
departmentIds String[]
permissions String[]
}

Built-in roles are stored as SystemRole rows with isSystem: true. Tenant-created roles use isSystem: false. User.roleId points at the role row, while the legacy enum remains on User.role for compatibility and fallback during migration.

Permissions are string keys from a central catalog. The wildcard * grants all known permissions.

Alternatives Considered

Keep only the enum

Rejected. It would force every tenant-specific role into application code and would require deployments for policy changes.

Permission table many-to-many

Rejected for now. A normalized permission table with role-permission join rows is powerful, but it adds migrations, joins, UI complexity, and policy drift before the product has enough requirements to justify it.

JSON policy language

Rejected. A policy DSL is too flexible for the current admin UI and harder to audit than explicit permission keys.

Consequences

Positive

  • Custom roles can be created and edited at runtime.
  • Built-in roles stay protected through isSystem.
  • Permission checks stay simple: string membership plus wildcard expansion.
  • The legacy enum fallback keeps existing users and tests working during the migration.

Negative

  • Permission keys must stay stable because they are persisted strings.
  • Renaming a permission requires a data migration.
  • There is no resource-level policy language; department scope covers the main isolation axis.

Implementation Notes

  • src/lib/permission-catalog.ts owns the valid key list.
  • src/lib/permissions.ts computes capabilities and effective permission sets.
  • src/routes/roles.ts validates role CRUD and protects system roles.
  • Seed data creates the four built-in SystemRole rows.