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

31 KiB
Raw Blame History

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:

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

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:

  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:

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

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

  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:

// 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

  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