# 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([ ...(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 ; } ``` **Multiple Permissions (OR logic):** ```typescript function AdminPanel({ user }) { if (!hasPermission(user, ['UPDATE_USERS', 'DELETE_USERS'])) { return ; } return ; } ``` **Conditional Rendering:** ```typescript function ProjectActions({ user, project }) { return (
{hasPermission(user, 'READ_PROJECTS') && ( )} {hasPermission(user, 'UPDATE_PROJECTS') && ( )} {hasPermission(user, 'DELETE_PROJECTS') && ( )}
); } ``` ### 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