31 KiB
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 permissionsviarolesPermissionsPermissionsjunction tablehasMany usersasusers_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_ENTITIESbypass, while authenticated Public users can receive explicit private production presentation grants throughproduction_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_defaultsandproject_element_defaultspermissions 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:
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
hasPermissionhelper 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
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:
await UsersDBApi.create({
data: {
email: 'user@example.com',
app_role: roleId,
custom_permissions: [permissionId1, permissionId2]
}
}, options);
Update User's Custom Permissions:
await UsersDBApi.update({
id: userId,
data: {
custom_permissions: [permissionId1, permissionId2, permissionId3],
},
...options,
});
Effective Permissions
A user's effective permissions are the union of:
- Permissions from their assigned role (
app_role.permissions) - 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:
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_accessrows 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
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
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
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:
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.
Router Factory Integration
File: backend/src/factories/router.factory.js
All entity routes automatically apply permission checking:
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:
{
"data": {
"name": "Custom Role",
"role_customization": "{}",
"permissions": ["permission-uuid-1", "permission-uuid-2"]
}
}
Response:
{
"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):
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:
GET /api/auth/me
Authorization: Bearer {token}
Response:
{
"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
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:
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):
function AdminPanel({ user }) {
if (!hasPermission(user, ['UPDATE_USERS', 'DELETE_USERS'])) {
return <AccessDenied />;
}
return <UserManagement />;
}
Conditional Rendering:
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
// 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
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
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
// 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
- Add to seeder for new installations
- Create migration for existing installations
- Update TypeScript enum in frontend
- Assign to appropriate roles
Checking Permissions in Code
Backend:
// Automatic via router factory - no manual check needed
// For custom endpoints:
router.get('/custom', checkPermissions('READ_CUSTOM'), handler);
Frontend:
// Always use hasPermission helper
if (hasPermission(user, 'UPDATE_PROJECTS')) {
// Render UI element
}
Administrator Role
The Administrator role has special handling in the hasPermission helper:
// 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
- Check user has required permission via
/api/auth/me - Verify role has permission assigned
- Check custom_permissions array
- Ensure JWT token is valid and not expired
Permission Not Working After Adding
- Re-run seeder or create migration
- Clear any caches
- Re-fetch user data (
dispatch(findMe())) - Verify permission name matches exactly (case-sensitive)
Public Access Issues
- Verify entity is in
RUNTIME_PUBLIC_READ_ENTITIES - Check
isRuntimePublicRequestis set correctly - Ensure Public role has required permissions