804 lines
31 KiB
Markdown
804 lines
31 KiB
Markdown
# 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
|