39948-vm/documentation/rbac-system.md
2026-07-03 16:11:24 +02:00

804 lines
31 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Role-Based Access Control (RBAC) System
Complete documentation for the Tour Builder Platform's RBAC system including roles, permissions, custom permissions, and entity-level access control.
## Overview
The platform implements a comprehensive RBAC system with:
- **7 Default Roles** - From Administrator to Public (seeded in database)
- **54 Seeded Permissions** - CRUD operations for 13 entities + 2 special permissions
- **Custom Permissions** - Per-user permissions independent of assigned role
- **Middleware-based Enforcement** - Automatic permission checking on all routes
> **Note:** The frontend TypeScript enum may define additional permissions not seeded in the backend. The seeder also assigns role-permissions for legacy entities (PAGE_ELEMENTS, PAGE_LINKS, TRANSITIONS) that were previously stored in separate tables but are now embedded in `tour_pages.ui_schema_json`.
## Architecture
```
┌─────────────────────────────────────────────────────────────────────────┐
│ Request Flow │
│ │
│ Request → JWT Auth → Permission Middleware → Route Handler → Response │
│ │ │
│ ▼ │
│ ┌────────────────────────────┐ │
│ │ Permission Resolution │ │
│ │ │ │
│ │ 1. AccessPolicy │ │
│ │ 2. Custom permissions │ │
│ │ 3. Role permissions │ │
│ │ 4. Public hardening │ │
│ └────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────────────┐
│ Database Schema │
│ │
│ ┌─────────┐ ┌──────────────────────────┐ ┌───────────┐ │
│ │ Users │────────▶│ usersCustom_permissions │◀────────│Permissions│ │
│ └────┬────┘ │ Permissions │ └─────┬─────┘ │
│ │ └──────────────────────────┘ │ │
│ │ app_roleId │ │
│ ▼ │ │
│ ┌─────────┐ ┌──────────────────────────┐ │ │
│ │ Roles │────────▶│ rolesPermissions │◀──────────────┘ │
│ └─────────┘ │ Permissions │ │
│ └──────────────────────────┘ │
└─────────────────────────────────────────────────────────────────────────┘
```
## Database Models
### Roles Model
**File:** `backend/src/db/models/roles.js`
| Field | Type | Required | Notes |
|-------|------|----------|-------|
| id | UUID | Yes | Primary key, auto-generated |
| name | TEXT | Yes | Max 100 chars, role identifier |
| role_customization | TEXT | No | JSON for custom role metadata |
| importHash | STRING(255) | No | Unique, for CSV bulk imports |
**Associations:**
- `belongsToMany permissions` via `rolesPermissionsPermissions` junction table
- `hasMany users` as `users_app_role` (FK: `app_roleId`)
### Permissions Model
**File:** `backend/src/db/models/permissions.js`
| Field | Type | Required | Notes |
|-------|------|----------|-------|
| id | UUID | Yes | Primary key, auto-generated |
| name | TEXT | Yes | Unique, max 100 chars, e.g., `READ_PROJECTS` |
| importHash | STRING(255) | No | Unique, for CSV bulk imports |
### Junction Tables
**rolesPermissionsPermissions:**
| Field | Type | Notes |
|-------|------|-------|
| roles_permissionsId | UUID | FK to roles, part of composite PK |
| permissionId | UUID | FK to permissions, part of composite PK |
**usersCustom_permissionsPermissions:**
| Field | Type | Notes |
|-------|------|-------|
| users_custom_permissionsId | UUID | FK to users, part of composite PK |
| permissionId | UUID | FK to permissions, part of composite PK |
## Default Roles
**File:** `backend/src/db/seeders/20200430130760-user-roles.js`
| Role | Description | Typical Permissions |
|------|-------------|---------------------|
| **Administrator** | Full system access | All 54 seeded permissions + 12 legacy entity role assignments |
| **Platform Owner** | Full CRUD on all entities | All entity CRUD operations |
| **Account Manager** | Manages projects, users, assets | Create/Read/Update on most entities, including user creation and project page element defaults |
| **Tour Designer** | Builds and designs tours | Full access to tour-related entities |
| **Content Reviewer** | Reviews content | Read/Update on content entities |
| **Analytics Viewer** | Views analytics and logs | Read-only access |
| **Public** | Unauthenticated fallback and authenticated customer viewers | No seeded permissions; authenticated Public users can receive private production presentation grants |
> **Note:** The seeder creates 7 roles. The Public role has no seeded permissions. Anonymous public runtime access uses the `RUNTIME_PUBLIC_READ_ENTITIES` bypass, while authenticated Public users can receive explicit private production presentation grants through `production_presentation_access`. Self-registration is disabled; new users are created by authorized staff through the Users flow. The "User" role is not seeded by default but can be created manually.
## Permission Types
### Seeded Entity Permissions (52 total)
Each of the 13 entities has 4 CRUD permissions seeded in the database:
```
CREATE_{ENTITY} - Create new records
READ_{ENTITY} - View/list records
UPDATE_{ENTITY} - Modify existing records
DELETE_{ENTITY} - Remove records
```
**Entities with CRUD permissions (seeded):**
| Category | Entities |
|----------|----------|
| **System** | users, roles, permissions |
| **Projects** | projects, project_memberships |
| **Assets** | assets, asset_variants |
| **Tours** | tour_pages (elements, navigation, transitions stored in ui_schema_json) |
| **Media** | project_audio_tracks |
| **Publishing** | publish_events, pwa_caches |
| **Access** | presigned_url_requests, access_logs |
> **Note:** `element_type_defaults` and `project_element_defaults` permissions are **not seeded** but can be created manually if needed. The frontend enum and documentation may reference these for completeness.
### Legacy Entity Permissions (assigned to roles but not seeded as permission records)
The seeder assigns role-permissions for these legacy entities, which were previously stored in separate tables:
| Entity | Current Status |
|--------|----------------|
| page_elements | Now stored in `tour_pages.ui_schema_json` |
| page_links | Now stored in `tour_pages.ui_schema_json` |
| transitions | Now stored on navigation elements in `ui_schema_json` |
These permission names (e.g., `READ_PAGE_ELEMENTS`) are referenced in role assignments and the `RUNTIME_PUBLIC_READ_ENTITIES` set for backward compatibility. Project element defaults use `PAGE_ELEMENTS` permissions:
| Role | PAGE_ELEMENTS Permissions |
|------|---------------------------|
| Administrator | Create, Read, Update, Delete |
| Platform Owner | Create, Read, Update, Delete |
| Account Manager | Create, Read, Update |
| Tour Designer | Create, Read, Update |
| Content Reviewer | Read, Update |
| Analytics Viewer | Read |
| Public | None |
### Special Permissions (2 seeded)
| Permission | Purpose | Seeded |
|------------|---------|--------|
| `READ_API_DOCS` | Access Swagger API documentation | ✅ Yes |
| `CREATE_SEARCH` | Execute global search queries | ✅ Yes |
**Total seeded permissions:** 13 entities × 4 CRUD + 2 special = **54 permissions**
### Frontend Permission Enum
**File:** `frontend/src/types/permissions.ts`
The frontend enum defines all permissions used in UI permission checks. Note that some permissions in this enum are **not seeded** in the backend but are included for potential future use or manual creation:
```typescript
enum Permission {
// Users
CREATE_USERS = 'CREATE_USERS',
READ_USERS = 'READ_USERS',
UPDATE_USERS = 'UPDATE_USERS',
DELETE_USERS = 'DELETE_USERS',
// Projects
CREATE_PROJECTS = 'CREATE_PROJECTS',
READ_PROJECTS = 'READ_PROJECTS',
UPDATE_PROJECTS = 'UPDATE_PROJECTS',
DELETE_PROJECTS = 'DELETE_PROJECTS',
// ... similar pattern for all seeded entities (13 entities × 4 = 52 permissions)
// Legacy entities (stored in ui_schema_json, permissions assigned to roles)
READ_PAGE_ELEMENTS = 'READ_PAGE_ELEMENTS',
CREATE_PAGE_ELEMENTS = 'CREATE_PAGE_ELEMENTS',
// ... similar for PAGE_LINKS, TRANSITIONS
// UI Elements (NOT seeded in backend)
READ_UI_ELEMENTS = 'READ_UI_ELEMENTS',
CREATE_UI_ELEMENTS = 'CREATE_UI_ELEMENTS',
UPDATE_UI_ELEMENTS = 'UPDATE_UI_ELEMENTS',
DELETE_UI_ELEMENTS = 'DELETE_UI_ELEMENTS',
}
// Helper to get CRUD permissions for an entity
export const getEntityPermissions = (entityName: string) => {
const uppercased = entityName.toUpperCase();
return {
read: `READ_${uppercased}`,
create: `CREATE_${uppercased}`,
update: `UPDATE_${uppercased}`,
delete: `DELETE_${uppercased}`,
};
};
```
**Frontend vs Backend Permissions:**
| Permission Type | Frontend Enum | Backend Seeded |
|-----------------|---------------|----------------|
| 13 core entities | ✅ | ✅ |
| PAGE_ELEMENTS, PAGE_LINKS, TRANSITIONS | ✅ | Role assignments only |
| UI_ELEMENTS | ✅ | ❌ Not seeded |
| READ_API_DOCS, CREATE_SEARCH | ❌ | ✅ |
> **Note:** The frontend `hasPermission` helper bypasses permission checks for the Administrator role (single permission checks only).
## Custom Permissions
Users can have permissions beyond their assigned role through custom permissions.
### User-Permission Association
**File:** `backend/src/db/models/users.js`
```javascript
db.users.belongsToMany(db.permissions, {
as: 'custom_permissions',
foreignKey: { name: 'users_custom_permissionsId' },
through: 'usersCustom_permissionsPermissions',
onDelete: 'CASCADE',
});
```
### Setting Custom Permissions
**Create User with Custom Permissions:**
```javascript
await UsersDBApi.create({
data: {
email: 'user@example.com',
app_role: roleId,
custom_permissions: [permissionId1, permissionId2]
}
}, options);
```
**Update User's Custom Permissions:**
```javascript
await UsersDBApi.update({
id: userId,
data: {
custom_permissions: [permissionId1, permissionId2, permissionId3],
},
...options,
});
```
### Effective Permissions
A user's effective permissions are the **union** of:
1. Permissions from their assigned role (`app_role.permissions`)
2. Their custom permissions (`custom_permissions`)
```
Effective Permissions = Role Permissions Custom Permissions
```
`Public` access is an explicit exception for admin API access: even if stale
role/custom permissions exist in the database, `AccessPolicy.hasPermission`
returns `false` for authenticated `Public` users and
`AccessPolicy.getRolePermissionNames()` ignores permissions on the `Public`
role fallback. Private presentation access for customer viewers is stored
separately in `production_presentation_access`.
## Access Policy
**File:** `backend/src/services/access-policy.ts`
Centralized access helper used by permission middleware and runtime
presentation access:
| Method | Purpose |
|--------|---------|
| `hasPermission(user, permission)` | Checks effective RBAC permission for non-Public users |
| `isPublicUser(user)` | Detects authenticated customer viewer users |
| `isInternalUser(user)` | Detects authenticated non-Public users |
| `isPlatformWideRole(user)` | Checks Administrator, Platform Owner, Account Manager |
| `canUseAdminApi(user)` | Allows only non-Public users with at least one RBAC permission |
| `canViewProductionPresentation(user, projectSlug)` | Allows public presentations, internal staff, or explicit private grants |
### Public Access Hardening Audit
**Service:** `backend/src/services/access-policy-audit.ts`
**CLI:**
```bash
cd backend
npm run check:public-access # read-only audit, exits 1 when violations exist
npm run fix:public-access # removes stale Public grants
```
The audit checks:
- permissions assigned to any role named `Public`
- custom permissions assigned to users whose role is `Public`
- `production_presentation_access` rows assigned to non-Public users
The cleanup command removes only those stale grants. Runtime access remains
controlled by `AccessPolicy` even before cleanup, so stale Public permissions do
not authorize admin API access.
## Permission Middleware
**File:** `backend/src/middlewares/check-permissions.ts`
### Permission Resolution Order
```javascript
const checkPermissions = (permission) => async (req, res, next) => {
const currentUser = req.currentUser;
if (await AccessPolicy.hasPermission(currentUser, permission)) {
return next();
}
// Public role fallback for unauthenticated/no-role reads
const effectiveRole = currentUser?.app_role || publicRoleCache;
const rolePermissionNames = await AccessPolicy.getRolePermissionNames(effectiveRole);
if (rolePermissionNames.has(permission)) {
return next();
}
throw new ForbiddenError();
};
```
Self-access bypass is intentionally narrow: only authenticated users can access
their own `/api/users/:id` for `GET`, `PUT`, and `PATCH`. Other entities always
use normal permission checks, and route params are the canonical id.
### Tests
```bash
cd backend
npm run test
npm run test:integration
```
Unit tests cover effective permission merging, Public admin API denial,
internal admin API access, and platform-wide role detection. Integration tests
use a rollback transaction against a real PostgreSQL database when available;
they cover guest runtime access, granted Public private presentation access,
internal private presentation access, and audit cleanup.
### CRUD Permission Mapping
```javascript
const METHOD_MAP = {
POST: 'CREATE',
GET: 'READ',
PUT: 'UPDATE',
PATCH: 'UPDATE',
DELETE: 'DELETE',
};
// Example: PUT /api/projects/123 → 'UPDATE_PROJECTS'
const permissionName = `${METHOD_MAP[req.method]}_${entityName.toUpperCase()}`;
```
### Public Runtime Access
Certain entities are readable without authentication for published tours:
```javascript
const RUNTIME_PUBLIC_READ_ENTITIES = new Set([
'PROJECTS',
'TOUR_PAGES',
'PAGE_ELEMENTS',
'PAGE_LINKS',
'TRANSITIONS',
'PROJECT_AUDIO_TRACKS'
]);
```
When `req.isRuntimePublicRequest === true` and method is `GET`, these bypass permission checks.
Private production presentations still use `req.isRuntimePublicRequest` after
the user passes staff permission or `production_presentation_access` checks.
This lets Public-role customer users read runtime-safe data without granting
broad permissions such as `READ_PROJECTS` or `READ_TOUR_PAGES`. See
[private-production-presentations.md](./private-production-presentations.md).
## Router Factory Integration
**File:** `backend/src/factories/router.factory.js`
All entity routes automatically apply permission checking:
```javascript
function createEntityRouter(entityName, Service, DBApi, options = {}) {
const router = express.Router();
// Permission entity can be customized
const permissionEntity = options.permissionEntity || entityName;
// Apply permission middleware to all routes
router.use(checkCrudPermissions(permissionEntity));
// CRUD routes automatically protected
router.post('/', ...); // Requires CREATE_ENTITY
router.get('/', ...); // Requires READ_ENTITY
router.get('/:id', ...); // Requires READ_ENTITY
router.put('/:id', ...); // Requires UPDATE_ENTITY
router.delete('/:id', ...); // Requires DELETE_ENTITY
return router;
}
```
## API Endpoints
### Roles Routes
| Method | Endpoint | Permission | Description |
|--------|----------|------------|-------------|
| POST | `/api/roles` | CREATE_ROLES | Create new role |
| GET | `/api/roles` | READ_ROLES | List roles with pagination |
| GET | `/api/roles/:id` | READ_ROLES | Get role by ID |
| PUT | `/api/roles/:id` | UPDATE_ROLES | Update role |
| DELETE | `/api/roles/:id` | DELETE_ROLES | Delete role |
| POST | `/api/roles/deleteByIds` | DELETE_ROLES | Bulk delete |
| POST | `/api/roles/bulk-import` | CREATE_ROLES | CSV import |
| GET | `/api/roles/count` | READ_ROLES | Total count |
| GET | `/api/roles/autocomplete` | READ_ROLES | Autocomplete |
**Create/Update Request:**
```json
{
"data": {
"name": "Custom Role",
"role_customization": "{}",
"permissions": ["permission-uuid-1", "permission-uuid-2"]
}
}
```
**Response:**
```json
{
"id": "uuid",
"name": "Custom Role",
"role_customization": "{}",
"permissions": [
{ "id": "uuid", "name": "CREATE_PROJECTS" },
{ "id": "uuid", "name": "READ_PROJECTS" }
],
"createdAt": "2025-01-01T00:00:00Z",
"updatedAt": "2025-01-01T00:00:00Z"
}
```
### Permissions Routes
| Method | Endpoint | Permission | Description |
|--------|----------|------------|-------------|
| POST | `/api/permissions` | CREATE_PERMISSIONS | Create permission |
| GET | `/api/permissions` | READ_PERMISSIONS | List permissions |
| GET | `/api/permissions/:id` | READ_PERMISSIONS | Get by ID |
| PUT | `/api/permissions/:id` | UPDATE_PERMISSIONS | Update |
| DELETE | `/api/permissions/:id` | DELETE_PERMISSIONS | Delete |
| POST | `/api/permissions/deleteByIds` | DELETE_PERMISSIONS | Bulk delete |
| POST | `/api/permissions/bulk-import` | CREATE_PERMISSIONS | CSV import |
| GET | `/api/permissions/autocomplete` | READ_PERMISSIONS | Autocomplete |
### User Permission Management
**Assign Role and Custom Permissions (via Users API):**
```http
PUT /api/users/:id
Authorization: Bearer {token}
Content-Type: application/json
{
"data": {
"app_role": "role-uuid",
"custom_permissions": ["perm-uuid-1", "perm-uuid-2"]
}
}
```
**Get Current User with Permissions:**
```http
GET /api/auth/me
Authorization: Bearer {token}
```
**Response:**
```json
{
"id": "user-uuid",
"email": "admin@flatlogic.com",
"firstName": "Admin",
"app_role": {
"id": "role-uuid",
"name": "Administrator",
"permissions": [
{ "id": "perm-uuid", "name": "CREATE_USERS" },
{ "id": "perm-uuid", "name": "READ_USERS" }
]
},
"custom_permissions": [
{ "id": "perm-uuid", "name": "CREATE_SEARCH" }
]
}
```
## Frontend Implementation
### Permission Helper
**File:** `frontend/src/helpers/userPermissions.ts`
```typescript
export function hasPermission(
user: UserWithPermissions,
permission_name: string | string[]
): boolean {
if (!user?.app_role?.name) return false;
if (!permission_name) return true;
// Combine custom permissions and role permissions
const permissions = new Set<string>([
...(user?.custom_permissions ?? []).map((p) => p.name),
...(user?.app_role?.permissions ?? []).map((p) => p.name),
]);
if (typeof permission_name === 'string') {
// Single permission: check permission OR Administrator role
return (
permissions.has(permission_name) || user.app_role.name === 'Administrator'
);
} else {
// Multiple permissions: OR logic (Administrator bypass NOT applied here!)
return permission_name.some((p) => permissions.has(p));
}
}
```
> **Important:** The Administrator bypass only applies to **single permission** checks. When checking an array of permissions, the Administrator role does NOT automatically pass - the user must have at least one of the specified permissions.
### Usage Examples
**Single Permission Check:**
```typescript
import { hasPermission } from '@/helpers/userPermissions';
function EditButton({ user, projectId }) {
if (!hasPermission(user, 'UPDATE_PROJECTS')) {
return null;
}
return <button>Edit Project</button>;
}
```
**Multiple Permissions (OR logic):**
```typescript
function AdminPanel({ user }) {
if (!hasPermission(user, ['UPDATE_USERS', 'DELETE_USERS'])) {
return <AccessDenied />;
}
return <UserManagement />;
}
```
**Conditional Rendering:**
```typescript
function ProjectActions({ user, project }) {
return (
<div>
{hasPermission(user, 'READ_PROJECTS') && (
<ViewButton project={project} />
)}
{hasPermission(user, 'UPDATE_PROJECTS') && (
<EditButton project={project} />
)}
{hasPermission(user, 'DELETE_PROJECTS') && (
<DeleteButton project={project} />
)}
</div>
);
}
```
### Redux Integration
**File:** `frontend/src/stores/permissions/permissionsSlice.ts`
```typescript
// Fetch all permissions
dispatch(permissionsActions.fetch({ query: '' }));
// Create new permission
dispatch(permissionsActions.create({
data: { name: 'READ_CUSTOM_ENTITY' }
}));
// Delete permission
dispatch(permissionsActions.deleteItem(permissionId));
```
## Seeding Process
**File:** `backend/src/db/seeders/20200430130760-user-roles.js`
### Step 1: Create Roles
```javascript
await queryInterface.bulkInsert("roles", [
{ id: getId("Administrator"), name: "Administrator" },
{ id: getId("PlatformOwner"), name: "Platform Owner" },
{ id: getId("AccountManager"), name: "Account Manager" },
{ id: getId("TourDesigner"), name: "Tour Designer" },
{ id: getId("ContentReviewer"), name: "Content Reviewer" },
{ id: getId("AnalyticsViewer"), name: "Analytics Viewer" },
{ id: getId("Public"), name: "Public" },
]);
```
### Step 2: Create Permissions
```javascript
const entities = [
"users", "roles", "permissions", "projects", "project_memberships",
"assets", "asset_variants", "presigned_url_requests", "tour_pages",
"project_audio_tracks", "publish_events", "pwa_caches", "access_logs"
];
// Generate CRUD permissions for each entity (13 × 4 = 52 permissions)
const permissions = entities.flatMap(name => [
{ name: `CREATE_${name.toUpperCase()}` },
{ name: `READ_${name.toUpperCase()}` },
{ name: `UPDATE_${name.toUpperCase()}` },
{ name: `DELETE_${name.toUpperCase()}` }
]);
// Add special permissions (+2 = 54 total)
permissions.push(
{ name: 'READ_API_DOCS' },
{ name: 'CREATE_SEARCH' }
);
```
### Step 3: Assign Permissions to Roles
```javascript
// Administrator gets all permissions
// Other roles get specific subsets
const rolePermissionMap = {
"PlatformOwner": ["CREATE_*", "READ_*", "UPDATE_*", "DELETE_*"],
"AccountManager": ["READ_USERS", "UPDATE_USERS", "CREATE_PROJECTS", ...],
"TourDesigner": ["READ_PROJECTS", "UPDATE_TOUR_PAGES", ...],
"ContentReviewer": ["READ_PROJECTS", "UPDATE_PAGE_ELEMENTS", ...],
"AnalyticsViewer": ["READ_*"],
"Public": ["READ_PROJECTS", "READ_TOUR_PAGES", ...],
};
```
## Complete Permission Flow
```
┌─────────────────────────────────────────────────────────────────┐
│ PUT /api/projects/:id │
└─────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────┐
│ Passport JWT Authentication │
│ - Extract Bearer token from Authorization header │
│ - Verify JWT signature and expiry │
│ - Load user from DB with app_role and custom_permissions │
│ - Set req.currentUser │
└─────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────┐
│ checkCrudPermissions('projects') │
│ - Map PUT method → 'UPDATE' │
│ - Generate permission: 'UPDATE_PROJECTS' │
└─────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────┐
│ checkPermissions('UPDATE_PROJECTS') │
│ │
│ Step 1: Self-access check │
│ └── currentUser.id === req.params.id? → PASS │
│ │
│ Step 2: Custom permissions check │
│ └── custom_permissions.includes('UPDATE_PROJECTS')? → PASS │
│ │
│ Step 3: Role permissions check │
│ └── app_role.permissions.includes('UPDATE_PROJECTS')? → PASS │
│ │
│ Step 4: Deny │
│ └── Throw ValidationError('auth.forbidden') → 403 │
└─────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────┐
│ Route Handler │
│ - Execute ProjectsService.update() │
│ - Return updated project │
└─────────────────────────────────────────────────────────────────┘
```
## File Reference
| File | Purpose |
|------|---------|
| `backend/src/db/models/roles.js` | Role model definition |
| `backend/src/db/models/permissions.js` | Permission model definition |
| `backend/src/db/models/users.js` | User-role and user-permission associations |
| `backend/src/db/api/roles.ts` | Roles database operations |
| `backend/src/db/api/permissions.ts` | Permissions database operations |
| `backend/src/db/seeders/20200430130760-user-roles.js` | Default roles/permissions seeder |
| `backend/src/middlewares/check-permissions.ts` | Permission enforcement middleware |
| `backend/src/factories/router.factory.js` | Auto-applies permissions to routes |
| `backend/src/routes/roles.ts` | Roles API routes |
| `backend/src/routes/permissions.ts` | Permissions API routes |
| `frontend/src/helpers/userPermissions.ts` | Client-side permission helper |
| `frontend/src/types/permissions.ts` | TypeScript Permission enum |
| `frontend/src/stores/permissions/permissionsSlice.ts` | Redux state for permissions |
## Best Practices
### Creating New Permissions
1. **Add to seeder** for new installations
2. **Create migration** for existing installations
3. **Update TypeScript enum** in frontend
4. **Assign to appropriate roles**
### Checking Permissions in Code
**Backend:**
```javascript
// Automatic via router factory - no manual check needed
// For custom endpoints:
router.get('/custom', checkPermissions('READ_CUSTOM'), handler);
```
**Frontend:**
```typescript
// Always use hasPermission helper
if (hasPermission(user, 'UPDATE_PROJECTS')) {
// Render UI element
}
```
### Administrator Role
The Administrator role has special handling in the `hasPermission` helper:
```typescript
// Single permission check - Administrator bypass applied
hasPermission(adminUser, 'UPDATE_PROJECTS') // → true (always)
// Multiple permission check - Administrator bypass NOT applied
hasPermission(adminUser, ['UPDATE_USERS', 'DELETE_USERS']) // → depends on actual permissions
```
> **Note:** The Administrator bypass only works for single permission (string) checks. For array checks, Administrator users must have at least one of the permissions in their role or custom permissions.
## Troubleshooting
### "auth.forbidden" Error
1. Check user has required permission via `/api/auth/me`
2. Verify role has permission assigned
3. Check custom_permissions array
4. Ensure JWT token is valid and not expired
### Permission Not Working After Adding
1. Re-run seeder or create migration
2. Clear any caches
3. Re-fetch user data (`dispatch(findMe())`)
4. Verify permission name matches exactly (case-sensitive)
### Public Access Issues
1. Verify entity is in `RUNTIME_PUBLIC_READ_ENTITIES`
2. Check `isRuntimePublicRequest` is set correctly
3. Ensure Public role has required permissions