Deep-Dive: User Management & RBAC System¶
Area: #4 — User Management & RBAC (Full-Stack) Generated: 2026-02-25 | Last Updated: 2026-02-25 | Scan Level: Exhaustive | Workflow: document-project v1.2.0 Files Analyzed: 85+ (38 backend + 47+ frontend) Lines of Code Scanned: ~16,100+ (backend ~4,500 + frontend ~11,600+) Known Issues Found: 76 (11 Critical, 16 High, 26 Medium, 23 Low) Cross-references: Auth Deep-Dive (23 overlapping issues), API Reference (23 undocumented endpoints found) Security Assessment: RBAC Attack Scenarios — Full privilege escalation chain documented (zero-permission user → super_admin in 1 API call) Post-Scan Update: Backend refactored Feb 18-22 —
middleware.tsnow active (was dead code), newpermissionMiddleware.tsadded (third permission system),contextMiddlewarenow attaches permissions toreq.user,auth.tsnow loads userRoles
Table of Contents¶
- Executive Summary
- File Inventory
- Database Schema
- Backend Modules
- Permission System Architecture
- Frontend Architecture
- API Endpoint Reference
- Data Flow & User Journeys
- Dependency Graph
- Known Issues
- Seed Data
- Risks & Contributor Guidance
1. Executive Summary¶
The User Management & RBAC system provides administrative CRUD operations for users, roles, permissions, user settings (2FA), and an invite-only registration flow. It implements a hybrid role model: each user has one primary role (User.roleId → Role) plus zero or more extra roles via a many-to-many join table (user_roles). Permissions are route-string based (dot notation, e.g., user.create) and assigned to roles, not directly to users. A super_admin role bypasses all permission checks.
Key characteristics: - 5 backend modules (User, Role, Permission, InviteUser, UserSetting) + 8 middleware/shared dependency files — 85+ files total - 6 TypeORM entities (User, Role, Permission, UserRole, UserSetting, InviteUser) - Full soft-delete lifecycle: trash → restore → permanent delete (all entities) - Bulk operations: bulk delete, bulk restore, bulk permanent delete - Three parallel permission systems: middleware v1 (broken, 1 route), helper (primary, 30+ services), middleware v2 (new, 1 route) - Frontend: 57 files — 4 API hook files, 9 page components, 3 permission wrappers, sidebars, auth pages, settings, providers - 87 seeded permissions across 11 display groups - 76 issues found (11 Critical, 16 High, 26 Medium, 23 Low) — all attack vectors re-verified STILL VULNERABLE — all claims verified against actual code — expert panel found v2 middleware blocks super_admin (#76)
2. File Inventory¶
2.1 Backend — Entities (6 files, ~310 LOC)¶
| File | LOC | Purpose |
|---|---|---|
src/entity/User.ts |
134 | Core user entity: auth, profile, role binding, soft-delete |
src/entity/Role.ts |
57 | Role definition with M2M permissions and user bindings |
src/entity/Permission.ts |
37 | Route-based permission string with readonly flag |
src/entity/UserRoles.ts |
37 | Explicit join entity for user↔role M2M (partially orphaned) |
src/entity/UserSetting.ts |
64 | 2FA config: provider, secrets, backup codes, language |
src/entity/InviteUser.ts |
38 | Invite-only registration with PENDING/ACCEPTED status |
2.2 Backend — Modules (21 files, ~2,200 LOC)¶
| Module | Files | LOC | Purpose |
|---|---|---|---|
| User | controller, service, routes, validation | ~890 | User CRUD, profile, analytics, trash lifecycle |
| Role | controller, service, routes, validation, repository | ~570 | Role CRUD with permission assignment, caching |
| Permission | controller, service, routes, validation | ~670 | Permission CRUD, role granting, grouped views |
| InviteUser | controller, service, routes, validation | ~470 | Invite-only flow: create/check/resend, email dispatch |
| UserSetting | controller, service, routes, validation | ~450 | 2FA setup (email + Google Authenticator), backup codes |
2.3 Backend — Supporting & Infrastructure Files (14 files, ~1,780 LOC)¶
| File | LOC | Purpose |
|---|---|---|
src/helper/hasPermission.ts |
296 | Core permission checking engine — cache + DB fallback (service-level) |
src/helper/checkPermissionAndThrow.ts |
23 | Wrapper that throws ApiError on permission failure |
src/app/middlewares/auth.ts |
353 | JWT auth + refresh token — loads user with userRoles via QueryBuilder |
src/app/middlewares/contextMiddleware.ts |
93 | AsyncLocalStorage context + permission cache init + attaches Set to req.user |
src/app/middlewares/permissionMiddleware.ts |
39 | NEW (Feb 19) Route-level permission gate using req.user.permissions Set |
src/app/middlewares/hasPermission.ts |
79 | Legacy route-level permission gate — route format, broken (1 route uses it) |
src/app/middlewares/middleware.ts |
95 | Now active (Feb 19) Primary middleware pipeline — security, CORS, parsers, context |
src/shared/requestContext.ts |
122 | AsyncLocalStorage per-request state |
src/shared/permissionCache.ts |
183 | Per-request permission cache (Set-based) |
src/shared/permissionNormalizer.ts |
215 | Dead code — bidirectional format converter, all calls commented out |
src/seed/seed.ts |
100 | Seeds roles, permissions, settings, super admin |
src/seed/data/permission.ts |
454 | 87 seeded permission definitions |
src/seed/role.seed.ts |
36 | Seeds admin, user, super_admin roles |
src/seed/permission.seed.ts |
25 | Bulk-inserts permissions (all-or-nothing) |
2.4 Frontend — API Hooks (4 files, ~1,024 LOC)¶
| File | LOC | Exports |
|---|---|---|
src/api/users/index.js |
273 | 11 hooks: CRUD + bulk ops + profile |
src/api/roles/index.js |
217 | 9 hooks: CRUD + bulk ops |
src/api/permissions/index.js |
279 | 10 hooks: CRUD + bulk ops + 2 list variants |
src/api/invited-users/index.js |
255 | 12 hooks: CRUD + bulk ops + resend + check |
2.5 Frontend — Components (12 files, ~5,905 LOC)¶
| File | LOC | Purpose |
|---|---|---|
dashboard/users/users-page.jsx |
1120 | Users management table with analytics, filters, bulk ops |
dashboard/users/user-form.jsx |
512 | User create/edit modal with role assignment |
dashboard/roles-permissions/roles-page.jsx |
787 | Roles table with user/permission display |
dashboard/roles-permissions/role-form.jsx |
413 | Role create/edit with grouped permission checkboxes |
dashboard/roles-permissions/permissions-page.jsx |
804 | Flat permissions table with full CRUD |
dashboard/roles-permissions/permissions-group-page.jsx |
497 | Grouped permissions view (partially disabled) |
dashboard/roles-permissions/permission-form.jsx |
217 | Permission create/edit modal |
dashboard/invited-users/invited-users-page.jsx |
859 | Invited users table with status tracking |
dashboard/invited-users/invite-form.jsx |
176 | Invite email form |
custom/permission-wrapper.jsx |
67 | Route/component-level permission gate |
custom/nav-permission-wrapper.jsx |
63 | Navigation-aware permission wrapper |
custom/permission-badge-group.jsx |
33 | Truncated permission badge list |
2.6 Frontend — Pages (5 files, ~132 LOC)¶
| Route | File | LOC |
|---|---|---|
/dashboard/users |
page.js |
9 |
/dashboard/roles-permissions/roles |
page.js |
9 |
/dashboard/roles-permissions/permissions |
page.js |
9 |
/dashboard/roles-permissions/permissions-group |
page.js |
— |
/dashboard/invited-users |
page.js |
13 |
/dashboard/permissions |
page.jsx |
92 (debug/test page) |
3. Database Schema¶
3.1 Entity Relationship Diagram¶
┌─────────────┐ 1:N ┌──────────┐ M:M ┌──────────────┐
│ User │───────────────▶│ Role │◀─────────────▶│ Permission │
│ │ (roleId FK) │ │ (role_permission│ │
│ id │ │ id │ join table) │ id │
│ name │ │ name │ │ route │
│ email │ M:M │ displayName │ displayName │
│ password │◀──────────────▶│ isDeleted│ │ readonly │
│ roleId (FK) │ (user_roles │ deletedAt│ │ isDeleted │
│ avatar │ join table) └──────────┘ │ deletedAt │
│ phone │ └──────────────┘
│ address │
│ status │ 1:1 ┌──────────────┐
│ provider │───────────────▶│ UserSetting │
│ inviteOnly │ │ id │
│ isDeleted │ │ userId (FK) │
│ deletedAt │ │ isTwoFactorEnabled
│ stripeCustomerId │ twoFactorProvider
│ lemonSqueezyCustomerId │ twoFactorSecret
└─────────────┘ │ tempTwoFactorSecret
│ backupCodes │
│ language │
│ deletedAt │
└──────────────┘
┌───────────────────┐ ┌──────────────┐
│ UserRole │ │ InviteUser │
│ (entity: mostly │ │ (user_invites│
│ orphaned) │ │ table) │
│ id │ │ id │
│ userId (FK→User) │ │ email │
│ roleId (FK→Role) │ │ status │
│ createdAt │ │ deletedAt │
│ deletedAt │ └──────────────┘
└───────────────────┘
3.2 Join Tables¶
| Table | Columns | Created By |
|---|---|---|
user_roles |
userId, roleId |
User.userRoles @JoinTable (M2M) |
user_role |
id, userId, roleId, createdAt, updatedAt, deletedAt |
UserRole entity @Entity() |
role_permission |
roleId, permissionId |
Role.permissions @JoinTable (M2M) |
3.3 Indexes¶
User: 6 compound indexes for query optimization:
- (status, deletedAt), (email, deletedAt), (roleId, deletedAt), (status, roleId, deletedAt), (provider, email), (inviteOnly, status)
- Individual: status, provider
Role: Partial unique index IDX_ROLE_NAME_ACTIVE on name where deletedAt IS NULL
Permission: Index on route (also has unique: true constraint)
UserSetting: Index on userId, index on twoFactorSecret (unnecessary — secret shouldn't be indexed)
3.4 Enums¶
| Enum | Entity | Values |
|---|---|---|
Provider |
User | credential, google, facebook, github, otp |
Status |
User | active, inactive, suspend |
TwoFactorProvider |
UserSetting | email, google_authenticator, sms |
InviteStatus |
InviteUser | PENDING, ACCEPTED |
4. Backend Modules¶
4.1 User Module¶
Path: src/app/modules/v1/User/
Routes (registered at /api/v1/users):
| Method | Path | Auth | Context | Validation | Handler | Permission |
|---|---|---|---|---|---|---|
| GET | /profile |
auth() | No | — | getProfile | — (own user) |
| PATCH | /profile |
auth() | No | updateUserValidation | updateProfile | — (own user) |
| GET | /my-permissions |
auth() | No | — | myPermissions | — (own user) |
| GET | / |
auth() | Yes | — | getUserList | user.view (route-level via permission() middleware v2 + service-level) |
| POST | / |
auth() | Yes | createUserValidation | createUser | user.create |
| PATCH | /bulk-delete |
auth() | Yes | deleteUsersByIds | deleteUsersByIds | user.delete |
| PATCH | /bulk-restore |
auth() | Yes | bulkUserRestore | bulkUserRestore | user.restore |
| PATCH | /:id |
auth() | Yes | updateUserValidation | updateUser | user.update |
| PATCH | /:id/restore |
auth() | Yes | — | restoreUser | user.restore |
| GET | /:id |
auth() | Yes | — | getUserById | user.read |
| DELETE | /:id |
auth() | Yes | — | deleteUser | user.delete |
| DELETE | /:id/permanent |
auth() | Yes | — | deletePermanentUser | user.delete |
Service exports: createUser, updateUser, deleteUser, getUserList, getProfile, updateProfile, getUserById, deleteUsersByIds, restoreUserFromTrash, deletePermanentUser, userAnalytics, saveLemonSqueezyCustomerId, bulkUserRestore
Key behaviors:
- getUserList filters out super_admin users from results (line 187)
- getUserList returns analytics (total, paid, active, pending) alongside paginated data
- createUser checks for soft-deleted users with same email (but rejects them instead of restoring)
- updateUser has duplicated userRoles validation logic (see Issue #14)
- updateProfile handles avatar upload with local/cloud branching
- deleteUser uses TypeORM softDelete (sets deletedAt)
- deletePermanentUser uses repo.remove() (hard delete)
4.2 Role Module¶
Path: src/app/modules/v1/Role/
Routes (registered at /api/v1/roles):
| Method | Path | Auth | Context | Validation | Handler | Permission |
|---|---|---|---|---|---|---|
| GET | / |
auth() | Yes | — | getRoleList | role.view |
| GET | /:id |
auth() | Yes | — | getRoleById | role.read |
| POST | / |
auth() | Yes | createRoleValidation | createRole | NONE (commented out) |
| PATCH | /bulk-delete |
auth() | Yes | deleteRoleByIds | deleteRoleByIds | role.delete |
| PATCH | /bulk-restore |
auth() | Yes | bulkRoleRestore | bulkRoleRestore | role.restore |
| PATCH | /:id |
auth() | Yes | updateRoleValidation | updateRole | role.update |
| PATCH | /:id/restore |
auth() | Yes | — | restoreFromTrash | role.restore |
| DELETE | /:id |
auth() | Yes | — | deleteRole | role.delete |
| DELETE | /:id/permanent |
auth() | Yes | — | deleteRolePermanent | role.delete |
Key behaviors:
- getRoleList uses in-memory cache (node-cache) with 24-hour TTL, keyed by stringified query
- createRole auto-restores soft-deleted roles with matching name (un-deletes)
- createRole calls PermissionService.updateRolePermissions() to assign initial permissions
- createRole has no permission check (commented out at line 150)
- updateRole validates name uniqueness (excluding self) and displayName uniqueness
- restoreFromTrash checks for name conflicts before restoring
- upsertRole is used by seed only
- Validation forbids reserved names: user, admin, super-admin, super_admin
- All mutations call deleteCacheByPrefix("roles:") to invalidate cache
4.3 Permission Module¶
Path: src/app/modules/v1/Permission/
Routes (registered at /api/v1/permissions):
| Method | Path | Auth | Context | Validation | Handler | Permission |
|---|---|---|---|---|---|---|
| GET | /all |
auth() | Yes | — | getAllPermissions | — (no check) |
| GET | /:permissionId |
auth() | Yes | — | getPermissionById | permission.read |
| GET | / |
auth() | Yes | — | getPermissions | permission.view |
| GET | /user-permissions |
auth() | Yes | — | getUserPermissions | — |
| POST | /check |
auth() | Yes | update schema | hasPermission | — |
| PATCH | /bulk-delete |
auth() | Yes | deletePermissionByIds | deletePermissionByIds | permission.delete |
| PATCH | /bulk-restore |
auth() | Yes | bulkPermissionRestore | bulkPermissionRestore | permission.restore |
| PATCH | /:permissionId |
auth() | Yes | update schema | updatePermissionRoute | permission.update |
| PUT | /grant |
auth() | Yes | grantPermissionToRole | grantPermissionToRole | — (no check) |
| POST | / |
auth() | Yes | create schema | createPermissions | permission.create |
| DELETE | /delete |
auth() | Yes | deletePermissionByIds | deletePermissionByIds | permission.delete |
| DELETE | /:permissionId |
auth() | Yes | — | deletePermissionById | permission.delete |
| PATCH | /:permissionId/restore |
auth() | Yes | — | restorePermissionFromTrash | permission.restore |
| DELETE | /:permissionId/permanent |
auth() | Yes | — | deletePermanentPermissionById | permission.delete |
Key behaviors:
- getPermissions groups by displayName and adds action suffix analysis
- getAllPermissions returns flat paginated list (separate from grouped view)
- createPermission auto-assigns new permission to super_admin role
- createPermission auto-restores soft-deleted permissions with same route
- updatePermissionRoute blocks modification of readonly permissions
- deletePermissionById blocks deletion of readonly permissions
- grantPermissionToRole has no permission check — any authenticated user can call it
- Route ordering issue: GET /all must come before GET /:permissionId — currently correct
- Route conflict: GET /user-permissions after GET /:permissionId — will never match (:permissionId captures "user-permissions")
4.4 InviteUser Module¶
Path: src/app/modules/v1/InviteUser/
Routes (registered at both /api/v1/invite-users AND /api/v1/user-invites):
| Method | Path | Auth | Context | Validation | Handler | Permission |
|---|---|---|---|---|---|---|
| POST | /check-invite |
No | No | checkInviteUserExists | checkIviteUserExists | — (public) |
| GET | / |
auth() | Yes | — | getInviteUserList | invite.view |
| GET | /:id |
auth() | Yes | — | getInviteUserById | invite.read |
| POST | / |
auth() | Yes | createInviteUser | createInviteUser | invite.create |
| PATCH | /:id/restore |
auth() | Yes | — | restoreFromTrash | invite.restore |
| PATCH | /bulk-delete |
auth() | Yes | deleteInviteUserByIds | deleteInviteUserByIds | invite.delete |
| PATCH | /bulk-restore |
auth() | Yes | bulkInviteUserRestore | bulkInviteUserRestore | invite.restore |
| PATCH | /:id |
auth() | Yes | updateInviteUser | updateInviteUser | invite.update |
| DELETE | /:id |
auth() | Yes | — | deleteInviteUser | invite.delete |
| DELETE | /:id/permanent |
auth() | Yes | — | deletePermanentInviteUser | invite.delete |
Key behaviors:
- createInviteUser emits EVENT_TYPES.INVITE_USER_CREATED event → sends invitation email
- checkIviteUserExists is public (no auth) — validates invite link for registration page
- Invite link format: {config.invite_user_link}?email={encodeURIComponent(email)}
- createInviteUser does NOT check for soft-deleted invites (commented-out restore logic)
- updateInviteStatus (called by auth during registration) marks invite as ACCEPTED
- Duplicate route registration: both /invite-users and /user-invites → same handler
4.5 UserSetting Module¶
Path: src/app/modules/v1/UserSetting/
Routes (registered at /api/v1/user-settings):
| Method | Path | Auth | Context | Validation | Handler | Permission |
|---|---|---|---|---|---|---|
| PUT | /2fa/email |
auth() | No | — | enableEmail2fa | — |
| PUT | /2fa/google/setup |
auth() | No | — | start2FASetup | — |
| POST | /2fa/generate-backup-codes |
auth() | No | — | generateBackupCodes | — |
| PATCH | /2fa/google/verify |
auth() | No | verify2faGoogleAuthToken | verifyAnd2FAGoogleSetup | — |
| PATCH | /2fa/google/disable |
auth() | No | verify2faGoogleAuthToken | disableGoogle2fa | — |
| GET | / |
auth() | Yes | — | getUserSetting | — (no check) |
| GET | /me |
auth() | Yes | — | getMySetting | — |
| GET | /:id |
auth() | Yes | — | getSettingById | — (no check) |
Key behaviors:
- Two 2FA providers: Email OTP and Google Authenticator (TOTP via speakeasy)
- Google 2FA flow: start2FASetup → stores temp secret → returns QR code → verify2FAGoogleSetup promotes to permanent
- Backup codes: 10 codes, 10-char alphanumeric with hyphen, SHA-512 hashed, concatenated as single string
- verifyBackupCode does string .includes(hash) to check — vulnerable to substring collisions (see Issue #27)
- Email 2FA setup: creates OTP in database, sends via email
- getUserSetting and getSettingById have no permission checks — any authenticated user can list all settings
5. Permission System Architecture¶
5.1 Permission Format¶
Permissions use dot notation: {domain}.{action}
| Domain | Actions |
|---|---|
user |
view, read, create, update, delete, trash, restore |
role |
view, read, create, update, delete, trash, restore |
permission |
view, read, create, update, delete, trash, restore |
invite |
view, read, create, update, delete, trash, restore |
users.assign |
roles |
users.update |
role |
5.2 Permission Resolution Flow (Updated Feb 25)¶
Request
│
├── middleware.ts pipeline (global):
│ security → cookies → CORS → session → passport → body parsers → contextMiddleware
│ (contextMiddleware: loads user + roles, inits permission cache, attaches Set to req.user)
│
├── Route-level middleware:
│ auth() → [contextMiddleware AGAIN on some routes — double execution]
│ → permission("user.view") [NEW v2 middleware — on GET /users only so far]
│ → Handler
│
└── Service method:
checkPermissionAndThrow(path) → hasPermission(path) [helper version]
│
├── Is super_admin? → return true
├── Check permissionCache (Set<string>) → return cached
└── Cache miss → loadUserRoles() from DB → checkPermissionInRoles()
THREE SYSTEMS (should be consolidated to two — see ADR-2):
[DELETE] middlewares/hasPermission.ts — route format, primary role only (1 route: GET /payments)
[KEEP] middlewares/permissionMiddleware.ts — dot format, req.user.permissions Set (route-level)
[KEEP] helper/hasPermission.ts — dot format, cache + DB (service-level conditional checks)
5.3 Super Admin Bypass¶
The super_admin role has three bypass mechanisms:
1. Helper level: hasPermission() checks isCurrentUserSuperAdmin() → returns true immediately
2. Service level: currentUserPermission() returns ALL permission routes for super_admin
3. Frontend: Super admin gets ['*'] which useCheckPermission would need to handle (but it doesn't — it uses every() with .includes(), which would fail on wildcard)
5.4 Permission Caching¶
- Role list cache:
node-cachewith key prefixroles:, 24-hour TTL - Permission cache:
permissionCache.ts(in-memory, per-request via AsyncLocalStorage) - Cache invalidation: All Role mutations call
deleteCacheByPrefix("roles:") - Gap: Permission mutations do NOT invalidate the role cache, so role permission changes may be stale
6. Frontend Architecture¶
6.1 Permission Checking¶
useCheckPermission(requiredPermissions: string[]): boolean
- Reads from Redux state.auth.user.permissions (flat string array)
- Uses every() — ALL required permissions must be present
- Returns true if requiredPermissions is empty/null
- Does NOT handle wildcard * (super admin gets all routes as individual strings from backend)
PermissionWrapper component:
- Props: permission (string or string[]), children, showLoader, showError, showMessage, redirectTo
- For arrays: uses every() (all required)
- Renders UnauthorizedError component on failure (if showError)
NavPermissionWrapper component:
- Supports recursive sub-item checking for navigation menus
- Modes: requireAll (every) vs default (some)
6.2 State Management¶
User permissions are stored in Redux state.auth.user.permissions — populated on login/profile fetch. The getProfile backend endpoint aggregates permissions from primary role + all extra roles into a flat deduplicated array.
6.3 Frontend Permission Strings Used¶
| Page | Permission Strings |
|---|---|
| Users | user.view, user.create, user.update, user.delete, user.restore, user.trash, role.view, permission.view |
| Roles | role.view, role.create, role.update, role.delete, role.restore, role.trash |
| Permissions | role.view (page gate), permission.create, permission.update, permission.delete, permission.restore, permission.trash |
| Invited Users | invite.view, invite.create, invite.update, invite.delete, invite.restore, invite.trash |
7. API Endpoint Reference¶
7.1 Frontend → Backend Endpoint Mapping Gaps¶
| Frontend Hook | Calls | Backend Route Exists? |
|---|---|---|
useBulkPermanentDeleteUsers |
DELETE /users/bulk-permanent-delete |
NO |
useBulkPermanentDeleteRoles |
DELETE /roles/bulk-permanent-delete |
NO |
useBulkPermanentDeletePermissions |
DELETE /permissions/bulk-permanent-delete |
NO |
useBulkPermanentDeleteInvitedUsers |
DELETE /user-invites/bulk-permanent-delete |
NO |
useResendInvitation |
POST /user-invites/:id/resend |
NO |
All 5 hooks will produce 404 errors when called.
7.2 Payload Key Inconsistencies¶
| Operation | Users payload | Roles payload | Permissions payload | Invite payload |
|---|---|---|---|---|
| Bulk delete | { userIds } |
{ ids } |
{ ids } |
{ ids } |
| Bulk restore | { ids } |
{ ids } |
{ ids } |
{ ids } |
Users bulk delete uses userIds while all others use ids.
8. Data Flow & User Journeys¶
8.1 Admin Creates a User¶
Admin clicks "Add User" → user-form.jsx opens
→ Fills name, email, role, password (or inviteOnly checkbox)
→ Submits → users-page.jsx handleFormSubmit()
→ ⚠️ BUG: calls updateUser.mutate() even for create mode
→ Backend POST /users → user.service.createUser()
→ checkPermissionAndThrow("user.create")
→ Validate role exists
→ Check email uniqueness (including soft-deleted)
→ Hash password
→ Validate extra userRoles if user has "users.assign.roles" permission
→ Save user
8.2 Admin Creates a Role with Permissions¶
Admin clicks "Add Role" → role-form.jsx opens
→ Fills display name, role name
→ Selects permissions via grouped checkboxes
→ Submits { role, displayName, permissionIds }
→ Backend POST /roles → role.service.createRole()
→ ⚠️ No permission check (commented out)
→ Check name uniqueness, displayName uniqueness
→ Create role entity
→ Call PermissionService.updateRolePermissions() to assign permissions
→ Invalidate role cache
8.3 Invite-Only Registration Flow¶
Admin creates invite → POST /user-invites { email }
→ inviteUser.service.createInviteUser()
→ Check email not already registered as User
→ Check email not already invited (active)
→ Create InviteUser with PENDING status
→ Emit INVITE_USER_CREATED event → sends email with invite link
User clicks invite link → Frontend /register?email=xxx
→ Frontend calls POST /user-invites/check-invite { email }
→ Validates invite exists and is PENDING
→ User fills registration form
→ Auth registration flow completes
→ inviteUser.service.updateInviteStatus(email) → marks ACCEPTED
8.4 Soft-Delete Lifecycle¶
All entities follow the same pattern:
Active → softDelete() → sets deletedAt timestamp
→ "Trash" tab shows soft-deleted items
→ restore() → clears deletedAt (with conflict checks)
→ OR repo.remove() → permanent hard delete
9. Dependency Graph¶
9.1 Backend Module Dependencies¶
User Module
├── imports: Role entity, Permission entity, PermissionService
├── uses: hasPermission helper, checkPermissionAndThrow helper
├── uses: eventEmitter (for inviteOnly user creation)
├── depends on: QueryBuilder, getSortQuery, parseBoolean, normalizeImageSrc
└── used by: Auth module, Seed, Analytics
Role Module
├── imports: Permission entity, PermissionService
├── uses: checkPermissionAndThrow helper
├── uses: node-cache (addCache, deleteCache, deleteCacheByPrefix, getCache)
├── depends on: QueryBuilder, getSortQuery, parseBoolean
└── used by: User module (role validation), Seed (upsertRole)
Permission Module
├── imports: Role entity, User entity
├── uses: hasPermission helper, checkPermissionAndThrow helper
├── depends on: QueryBuilder, getSortQuery, parseBoolean
└── used by: Role module (updateRolePermissions), User module (currentUserPermission)
InviteUser Module
├── imports: User entity, InviteUser entity
├── uses: checkPermissionAndThrow helper
├── uses: eventEmitter (INVITE_USER_CREATED event)
├── depends on: inviteUserEmailTemplate
└── used by: Auth module (updateInviteStatus during registration)
UserSetting Module
├── imports: UserSetting entity, Otp entity, User entity
├── uses: speakeasy, qrcode, crypto
├── uses: AuthService.logininfoWithToken (for backup code login)
├── depends on: sendEmail helper, QueryBuilder
└── used by: Auth module (2FA verification during login)
9.2 Frontend Component Tree¶
/dashboard/users/page.js
└── UsersPage
├── useGetUsers (API)
├── useUpdateUser, useDeleteUser, usePermanentDeleteUser, etc.
├── useGetRoles (for role filter dropdown)
├── usePermissions (for permission filter dropdown)
├── PermissionWrapper (gates entire page + individual actions)
└── UserForm (modal)
├── useGetRoles (for role selector)
└── PermissionWrapper
/dashboard/roles-permissions/roles/page.js
└── RolesPage
├── useGetRoles (API)
├── useCreateRole, useUpdateRole, useDeleteRole, etc.
├── PermissionWrapper
└── RoleForm (modal)
├── useGetPermissions (grouped, for checkbox grid)
└── PermissionWrapper
/dashboard/roles-permissions/permissions/page.js
└── PermissionsPage
├── usePermissions (flat list API)
├── useCreatePermission, useUpdatePermission, etc.
├── PermissionWrapper (gates on "role.view" — wrong? should be "permission.view")
└── PermissionForm (modal)
/dashboard/invited-users/page.js
└── InvitedUsersPage
├── useGetInvitedUsers (API)
├── useCreateInvitedUser, useDeleteInvitedUser, etc.
├── PermissionWrapper
└── InviteForm (modal)
10. Known Issues¶
Note: The canonical issue registry is in Section 17 with all 74 issues. This section provides a quick severity summary.
Re-verification (Feb 25): All 6 critical attack vectors confirmed STILL VULNERABLE against the current codebase. The Feb 18-22 backend refactoring did not address any security issues. See Attack Scenarios for details.
| Severity | Count | Key Issues |
|---|---|---|
| Critical | 11 | Privilege escalation (#5b → super_admin after JWT refresh), createRole no check (#4), grantPermissionToRole no check (#5), triple permission systems (#8), format mismatch (#9), v2 middleware blocks super_admin (#76) |
| High | 16 | 2FA secrets exposed (#15-17), debug logs in production (#12-13), wrong HTTP 403→400 (#10), stale JWT role (#20), auth() doesn't check user status, hasAnyPermission leaks function source (#69) |
| Medium | 26 | Duplicate routes (#25), redundant soft-delete (#26), missing seed permissions (#28), backup code includes() (#11), permissions page wrong gate (#30), invite status case mismatch (#75), useGetUsers no gate (#58) |
| Low | 23 | Typos (#47-49, #73), dead code (#51-53, #74), empty files (#51), unreachable /user-permissions route (#3) |
11. Seed Data¶
11.1 Seeded Roles (3)¶
| Role | displayName | Notes |
|---|---|---|
super_admin |
null (not set) | Has all permissions, bypasses checks |
admin |
null (not set) | No permissions auto-assigned |
user |
null (not set) | No permissions auto-assigned |
11.2 Seeded Permissions (87)¶
| Display Group | Count | Permission Routes |
|---|---|---|
| User Management | 7 | user.view, user.read, user.create, user.update, user.delete, user.trash, user.restore |
| Role Management | 7 | role.view, role.read, role.create, role.update, role.delete, role.trash, role.restore |
| Permission Management | 7 | permission.view, permission.read, permission.create, permission.update, permission.delete, permission.trash, permission.restore |
| Blog Management | 14 | blog.*, blog-tag.*, blog-category.* (CRUD + trash/restore) |
| Page Management | 7 | page.* (CRUD + trash/restore) |
| Package Management | 14 | package.*, package-category.* (CRUD + trash/restore) |
| Contact Management | 5 | contact.view, contact.read, contact.delete, contact.trash, contact.restore |
| Setting Management | 4 | setting.view, setting.create, setting.update, setting.delete |
| Menu Builder Management | 8 | menu.*, menu-item.* (CRUD) |
| Dashboard Management | 1 | dashboard.view |
| Global Permission Management | 7 | global.blog-* variants |
| Customizer Management | 1 | customizer.access |
| Article Writer Management | 1 | article-writer.use |
Notable gap: No invite.* permissions are seeded. The InviteUser module requires invite.view, invite.create, invite.update, invite.delete, invite.restore but these must be created manually.
11.3 Seed Execution Order¶
1. roleSeed() → Insert admin, user, super_admin roles
2. permissionSeed() → Insert 87 permissions (skips if any exist)
3. seedSettings() → Insert app settings
4. seedSuperAdmin() → Create super_admin user from env vars
Note: Seed does NOT auto-assign permissions to super_admin. The allPermissionAssignToAdmin() function exists in PermissionService but is never called during seeding. Super_admin bypasses checks anyway, but the /my-permissions endpoint would return empty for super_admin until permissions are explicitly assigned.
12. Risks & Contributor Guidance¶
12.1 Before Modifying These Files¶
- Permission string changes propagate to both backend services AND frontend
PermissionWrapper/useCheckPermissioncalls. Search both codebases. - Role cache (
node-cache) is NOT invalidated by permission changes — only by role mutations. After modifying permissions, the cache may serve stale data for up to 24 hours. isDeletedvsdeletedAtconfusion exists across all entities. When adding soft-delete queries, usedeletedAt(TypeORM's official mechanism), notisDeleted(legacy boolean that is never automatically set).- UserRole entity should not be used — use the
User.userRolesM2M relation instead.
12.2 Verification Steps¶
- After any permission route change: verify the permission string matches in both backend
checkPermissionAndThrow()calls and frontendPermissionWrapper/useCheckPermissionarrays - After role CRUD changes: check that
deleteCacheByPrefix("roles:")is called - After adding new backend routes: ensure they are registered in
routes/v1/index.tsand check for parameter route conflicts (:idbefore named paths) - After modifying user creation: test both direct-create and invite-only flows
- After 2FA changes: test the complete flow — enable → verify → login with 2FA → disable → backup code login
12.3 Suggested Tests¶
- Unit:
hasPermission()with cache hit, cache miss, super_admin, no roles, multiple roles with overlapping permissions - Unit: Backup code generation, encoding, verification (especially hash boundary testing)
- Integration: User CRUD lifecycle: create → update → soft-delete → restore → permanent-delete
- Integration: Role-permission grant flow: create role → assign permissions → verify user gains access
- Integration: Invite flow: create invite → check-invite → register → verify status changes to ACCEPTED
- E2E: Permission propagation: assign permission to role → user with that role can access the gated route
13. Backend Dependency Deep Analysis¶
13.1 Request Context (src/shared/requestContext.ts — 122 LOC)¶
Per-request state via Node.js AsyncLocalStorage. Stores the authenticated user, their role name (as string from JWT), roleId, userRoles (as Role[]), and the permission cache.
getCurrentUser()— throwsContextNotFoundError/UserNotFoundErrorif missingisCurrentUserSuperAdmin()— comparesuser.role === 'super_admin'(hard-coded string literal)setPermissionCache()/getPermissionCache()— validates userId ownership before read/write
Issue: Role name is taken from the JWT, not from DB. If a user's role is changed in the database after their JWT was issued, the context has a stale role name until the token expires.
13.2 Permission Cache (src/shared/permissionCache.ts — 183 LOC)¶
Per-request cache (NOT global). Initialized by contextMiddleware with all permission route strings extracted from the user's roles.
initializePermissionCache(user, roles)— extractspermission.routeinto aSet<string>hasPermissionInCache(permission)— raw string match against the Set (returnsboolean | null)- Cache stores raw dot-notation strings from the database (e.g.,
user.create)
Issue: The hasPermission helper has normalization commented out, so cache lookups work only when the caller uses the exact same format as stored in DB. Route-format queries like /users/create will always miss.
13.3 Permission Normalizer (src/shared/permissionNormalizer.ts — 215 LOC)¶
Designed to convert between route format (/users/create) and dot notation (users.create). Has 13 exports including normalizePermissionString, getPermissionVariants, convertDotToRoute, etc.
Status: Effectively dead infrastructure. The only consumer (src/helper/hasPermission.ts) has the normalization call commented out at lines 57-63. The ACTION_MAPPING constant (update→edit, view→read, etc.) is defined but never used by active code. The middleware has its own inline normalizePath function.
13.4 Context Middleware (src/app/middlewares/contextMiddleware.ts — 93 LOC)¶
Runs after auth(), loads full user with relations (role, userRoles, userRoles.permissions, role.permissions), wraps remaining request in AsyncLocalStorage.run(), and initializes permission cache.
UPDATE (Feb 22): Now also attaches permissions to req.user.permissions as a Set<string>. Previously permissions were only available via AsyncLocalStorage. This enables the new permissionMiddleware.ts (see 13.5b).
Architecture (corrected after expert review): The global registration in middleware.ts runs AFTER passport.session() but BEFORE per-route auth(). This is intentional dual-path design:
- OAuth (Google) flow: passport.session() → deserializeUser sets req.user → global contextMiddleware runs with user → context available
- JWT flow: passport.session() does nothing → global contextMiddleware short-circuits (req.user undefined) → per-route auth() decodes JWT → per-route contextMiddleware runs → context available
Remaining issues:
- Role name set from JWT (req.user.role) not from DB-loaded fullUser.role.name — stale after role changes
- Silently continues on DB errors — request proceeds without context, req.user.permissions never set → v2 middleware throws TypeError
- For OAuth sessions only: runs twice (global + per-route if route also applies it). Not an issue for JWT flows.
13.5 middleware.ts — Now Active (Previously Dead Code)¶
UPDATE (Feb 18-19): middleware.ts was documented as dead code in both the Middleware DD and this deep-dive. It is now the primary middleware pipeline — app.ts calls registerMiddlewares(app) which registers all middleware in order: securityMiddleware (Helmet — re-enabled), cookieParser, request logger, compression, CORS, session, passport, body parsers, timing, contextMiddleware.
Key implications:
- securityMiddleware (Helmet) is active again — invalidates Middleware DD finding
- contextMiddleware runs globally as the last middleware in registerMiddlewares() — this is the first invocation. Many routes add it again per-route, causing double execution.
- registerErrorHandlers() is exported but still NOT called — app.ts still registers error handlers inline
- Webhook routes are now AFTER registerMiddlewares() which includes express.json() — potentially breaks Stripe webhook signature verification (Stripe needs raw body)
- Dead imports in app.ts: cors, passport, cookieParser, session, config, httpLogger, contextMiddleware are all imported but unused (now handled by middleware.ts)
13.5b NEW: Permission Middleware v2 (src/app/middlewares/permissionMiddleware.ts — 39 LOC)¶
Created Feb 19. A third permission-checking system alongside the existing two.
Exports:
- permission(permission: string) — checks req.user.permissions.has(permission) (Set)
- hasAnyPermission(permissions: string[]) — checks if any permission matches
Currently used by: GET /users in user.routes.ts — permission("user.view")
How it differs from existing systems:
- Uses req.user.permissions (Set attached by contextMiddleware) — no DB query, no AsyncLocalStorage
- Uses dot notation (matches DB format) — correct, unlike middleware v1
- Route-level middleware (like v1) but name-based (like the helper)
Bugs:
- hasAnyPermission error message references ${permission} (function object) instead of ${permissions} (array) — leaks function source code to users (#69)
- CRITICAL (#76): No super_admin bypass. After yarn seed, the role_permission table has ZERO entries for super_admin (seed never calls allPermissionAssignToAdmin()). req.user.permissions is an empty Set → has() always returns false → 403 for super_admin on all v2-protected routes. Currently blocks GET /users for super_admin.
- No null-safety: if contextMiddleware fails silently, req.user.permissions is undefined → has() throws TypeError
13.5c Triple hasPermission Implementations (CRITICAL — Updated)¶
Three separate permission-checking systems now exist:
| Aspect | Middleware v1 (hasPermission.ts, 79 LOC) |
Helper (helper/hasPermission.ts, 293 LOC) |
Middleware v2 (permissionMiddleware.ts, 39 LOC) |
|---|---|---|---|
| Type | Express middleware | Async function | Express middleware |
| Format | Route (/users/create) |
Dot (user.create) |
Dot (user.view) |
| Data source | DB query by roleId | Cache → DB fallback | req.user.permissions Set |
| Multi-role? | No (primary only) | Yes | Yes (Set built from all roles) |
| Cache? | No | Yes (AsyncLocalStorage) | No (reads Set directly) |
| Used by | 1 route (GET /payments) |
~30 service methods | 1 route (GET /users) |
Architectural direction: Middleware v2 is the intended replacement. It uses the correct format (dot notation), reads from the permission Set (already computed by contextMiddleware), and works at route level. The helper remains necessary for in-service conditional checks. Middleware v1 should be deleted (ADR-2 still applies).
13.5d Auth Middleware Changes (src/app/middlewares/auth.ts — 353 LOC)¶
UPDATE (Feb 22): The default auth() now uses createQueryBuilder instead of findOne to load the authenticated user. It joins userRoles as extraRole:
Computes allRoleIds = [role.id, ...userRoles.map(r => r.id)] but never uses it (the permission cache code that would consume it is commented out at lines 333-340). The authGuard export (named export) now validates the DB user's role name matches the JWT role name.
Still present:
- user.refreshToken !== refreshToken (line 277) — User entity has no refreshToken column
- Debug message "You are not authorized!33" (line 63)
- 153 lines of commented-out dead code (old auth implementations)
- escapeRegexPath function defined but never called
- secure: true hardcoded for cookies (should be process.env.NODE_ENV === 'production')
13.6 Query Builder (src/app/builder/QueryBuilder.ts — 222 LOC)¶
Generic paginated query builder used by all list endpoints. Supports search, filters, relations, sorting, soft-delete modes (trashOnly, includeDeleted). Redundantly adds deletedAt IS NULL when TypeORM already excludes soft-deleted entities by default.
13.7 Resolved: Routes Controller Removed¶
UPDATE (Feb 18): Commit d021804 removed routerList.routes.ts and routes.controller.ts. The /api/v1/routes endpoint that crashed due to missing routes.json no longer exists. This resolves the Middleware DD Critical issue about the routes endpoint crash.
14. Cross-Reference: Auth Deep-Dive Overlap¶
23 issues from the Auth Deep-Dive overlap with this area. Issues #51-64 were tagged "out-of-scope" in the auth doc and are owned by this deep-dive as their canonical home.
14.1 Issues Owned by This Deep-Dive (from Auth DD #51-64)¶
| Auth DD # | Severity | This DD # | Description |
|---|---|---|---|
| #51 | Critical | #4 | createRole permission check commented out |
| #53 | High | #3 | GET /user-permissions route unreachable |
| #54 | High | #6 | updateProfile returns bare res on password change |
| #55 | High | #14 | Duplicate userRoles validation bypasses permission check |
| #56 | High | — | getAllPermissions has no permission check (new: #40) |
| #57 | Critical | #30 | console.log(code) logs backup codes in plaintext |
| #58 | Medium | #28 | Missing seeded permissions (invite.*, users.assign.roles, etc.) |
| #59 | Medium | — | Email 2FA startEmail2FASetup defined but never exported (new: #41) |
| #61 | Medium | #9 | Backup codes stored without delimiters (substring collision) |
| #62 | Medium | — | createRole cache not invalidated on restore path (new: #42) |
| #64 | High | — | upsertRole inherits missing permission check (new: #43) |
14.2 Issues Cross-Referenced from Auth DD (in-scope there)¶
| Auth DD # | Description | Consistency Note |
|---|---|---|
| #2 | Missing refreshToken column on User entity |
Unchanged, still present |
| #16 | Dual role-assignment tables (user_roles vs user_role) |
Confirmed as this DD #7 |
| #21 | Permission normalization commented out in helper | Confirmed, still commented out |
| #25 | checkPermissionAndThrow uses HTTP 400 not 403 |
Confirmed as this DD #8 |
| #28 | Hardcoded 'super_admin' string in requestContext |
Confirmed, no constant exists |
| #29 | Debug console.log in hasPermission helper | Confirmed as this DD #12 |
| #31 | Unhashed password on user restoration | Relevant to createUser soft-delete handling |
| #38 | verifyLoginOtp auto-registration bypasses invite-only |
Relevant to invite flow |
15. API Documentation Gaps¶
Cross-referencing docs/architecture/api.md against actual routes revealed 23 undocumented endpoints:
15.1 /roles — 4 Undocumented Endpoints¶
| Method | Path | Purpose |
|---|---|---|
| PATCH | /roles/bulk-delete |
Bulk soft-delete roles |
| PATCH | /roles/bulk-restore |
Bulk restore roles from trash |
| PATCH | /roles/:id/restore |
Restore single role from trash |
| DELETE | /roles/:id/permanent |
Permanently delete role |
15.2 /permissions — 12 Undocumented Endpoints (only 2 of 14 documented)¶
| Method | Path | Purpose |
|---|---|---|
| GET | /permissions/all |
Get all permissions (flat list, paginated) |
| GET | /permissions/:permissionId |
Get single permission |
| GET | /permissions/user-permissions |
Get current user's permissions (dead route) |
| POST | /permissions/check |
Check if user has a permission |
| PATCH | /permissions/bulk-delete |
Bulk soft-delete |
| PATCH | /permissions/bulk-restore |
Bulk restore |
| PATCH | /permissions/:permissionId |
Update a permission |
| PUT | /permissions/grant |
Grant permissions to a role |
| DELETE | /permissions/delete |
Delete permissions by IDs (body) |
| DELETE | /permissions/:permissionId |
Delete single permission |
| PATCH | /permissions/:permissionId/restore |
Restore from trash |
| DELETE | /permissions/:permissionId/permanent |
Permanently delete |
15.3 /invite-users — 7 Undocumented Endpoints (only 3 of 10 documented)¶
| Method | Path | Purpose |
|---|---|---|
| POST | /invite-users/check-invite |
Check invite validity (PUBLIC, no auth) |
| GET | /invite-users/:id |
Get single invitation |
| PATCH | /invite-users/:id/restore |
Restore from trash |
| PATCH | /invite-users/bulk-delete |
Bulk soft-delete |
| PATCH | /invite-users/bulk-restore |
Bulk restore |
| PATCH | /invite-users/:id |
Update invitation |
| DELETE | /invite-users/:id/permanent |
Permanently delete |
15.4 Database Documentation Discrepancies¶
| Entity | Issue |
|---|---|
| All entities | createdAt, updatedAt columns missing from docs |
| Role | Partial unique index IDX_ROLE_NAME_ACTIVE not documented |
| UserRoles | Docs say UserRoles (plural), code says UserRole (singular); docs show simple columns, code has full entity with relations |
| UserSetting | onDelete: CASCADE, index on userId, index on twoFactorSecret not documented |
16. Expanded Frontend Coverage¶
The deep scan found 57 frontend files (~13,100 LOC) related to RBAC, organized in 9 categories:
| Category | Files | LOC |
|---|---|---|
| Core RBAC infrastructure (hooks, wrappers) | 7 | 389 |
| Auth state & middleware | 7 | 272 |
| API hooks (users, roles, permissions, invites) | 4 | 1,024 |
| Dashboard page components | 9 | 5,377 |
| App route pages (thin wrappers) | 11 | ~230 |
| Sidebar navigation (permission-gated) | 4 | ~2,200 |
| Auth pages (invite flow) | 3 | 1,192 |
| Settings pages + 2FA | 8 | 1,937 |
| Settings API | 2 | 274 |
16.1 Additional Files Not in Original Scan¶
components/custom/unauthorized-error.jsx(73 LOC) — Access denied page rendered by PermissionWrappercomponents/custom/roles-badge-group.jsx(67 LOC) — Role badge display with color codingcomponents/custom/user-avatar-group.jsx(75 LOC) — Overlapping avatar display for role user listsstore/slices/authSlice.js(47 LOC) — Redux state for user.permissionsservices/authService.js(54 LOC) — Raw API calls including getProfileapi/auth/index.js(153 LOC) — React Query hooks for auth, loads user.permissionsmiddleware.js(23 LOC) — Token-only route protection (no permission/role checks)components/providers/protected-route.jsx(28 LOC) — Client-side auth checkcomponents/providers/auth-initializer.jsx(20 LOC) — Redux auth initializationinterceptors/axiosInstance.js(48 LOC) — Bearer token injection, 401/403 logoutcomponents/pages/auth/register.jsx(424 LOC) — Invite-only registration flow- 3 sidebar variants + top-nav (permission-gated navigation)
settings/profile/page.jsx(375 LOC) — Profile editing pageapi/settings/account-setting.js(123 LOC) — 2FA API hooks- 4 2FA components (742 LOC total)
- 16+ additional consumer files using PermissionWrapper across blogs, packages, products, contacts, etc.
16.2 Additional Frontend Findings¶
useGetUsersdoes NOT check permissions (unlikeuseGetRoles/usePermissions/useGetInvitedUserswhich gate viaenabled: hasPermission)permission-form.jsxupdate is broken —handleUpdatePermissionis commented out (lines 110-119)roles-badge-group.jsxhas duplicate demo ID —demoAssignedRolesarray hasid: 2for both "Moderator" and "User"- Register page (
register.jsx) handles invite-only flow viawebsiteType == 2, checking invite viaPOST /user-invites/check-invite - Dashboard layout (
layout.js) has no permission checking — only usesuseUser()for loading state
17. Complete Issue Registry (75 Total)¶
Critical (10) — includes privilege escalation chain (Attack Scenarios)¶
| # | Location | Description | Auth DD Ref |
|---|---|---|---|
| 1 | users-page.jsx |
Create user calls UPDATE mutation — handleFormSubmit for create mode calls updateUser.mutate() instead of createUser.mutate(). PATCH /users/undefined → 404. Error shown via toast but creation completely fails. |
— |
| 2 | Frontend hooks | Bulk permanent-delete + resend endpoints missing on backend. Users button is commented out (dead code). Invited-users button is active → 404 with error toast. Resend button hidden by case mismatch ("pending" vs "PENDING") — dormant. Roles/permissions bulk-permanent-delete buttons → 404. |
— |
| 4 | role.service.ts:150 |
ESCALATION: createRole has no permission check — checkPermissionAndThrow commented out. Part of privilege escalation chain. |
Auth #51 |
| 5 | permission.service.ts:105 |
ESCALATION: grantPermissionToRole has no permission check — any authenticated user can assign arbitrary permissions. Part of privilege escalation chain. | — |
| 5b | user.service.ts:533 |
ESCALATION: updateProfile allows self-role-change — accepts role in payload, writes to roleId with no authorization. Delayed escalation: roleId changes immediately in DB, but JWT still has old role string. isCurrentUserSuperAdmin() returns false until JWT expires and refreshes. After refresh, new JWT has role: "super_admin" → full bypass. Delay = config.jwt.expires_in. |
— |
| 5c | #4 + #5 + #5b combined | Complete privilege escalation: 0-permission user → super_admin. Immediate partial access (DB-level permissions of new role). Full isCurrentUserSuperAdmin() bypass after JWT refresh. See Attack Scenarios. |
— |
| 6 | user.controller.ts:59-61 |
updateProfile returns bare res on password change — malformed/hanging HTTP response |
Auth #54 |
| 7 | UserRoles.ts + User.ts |
Dual user-role table conflict — user_roles (JoinTable) vs user_role (Entity), UserRole entity's inverse mapping references wrong relation type |
Auth #16 |
| 8 | Dual hasPermission |
Two incompatible permission systems — middleware (route-format, primary role only) vs helper (dot-format, multi-role with cache) produce different results | Auth #21 |
| 9 | Seed + middleware | Permission format mismatch — middleware constructs /users/create (route, plural), seed stores user.create (dot, singular). Non-super_admin middleware checks always fail. |
— |
| 76 | permissionMiddleware.ts + seed |
NEW (expert panel): v2 permission() middleware blocks super_admin. After yarn seed, role_permission table has ZERO entries for super_admin — allPermissionAssignToAdmin() exists but is never called during seed. req.user.permissions is an empty Set → has("user.view") returns false → 403. Currently affects GET /users. Will break ALL routes as v2 expands. Requires: (1) add allPermissionAssignToAdmin() to seed, AND (2) add super_admin bypass to v2 middleware. |
— |
High (16) — Severity-adjusted after code verification + devil's advocate challenge¶
| # | Location | Description | Auth DD Ref |
|---|---|---|---|
| 10 | checkPermissionAndThrow.ts:19 |
Wrong HTTP status: 400 (BAD_REQUEST) instead of 403 (FORBIDDEN) | Auth #25 |
| 12 | user.service.ts:490 |
Debug console.log({ payload }) logs profile updates in production |
Auth #29 |
| 13 | hasPermission.ts:68 |
Debug console.log({cachedResult}) logs every permission check |
Auth #29 |
| 14 | user.service.ts:337-363 |
Duplicate userRoles validation — second unconditional block overwrites permission-gated first block | Auth #55 |
| 15 | user_setting.service.ts |
getUserSetting and getSettingById expose all users' 2FA settings without permission checks |
— |
| 16 | UserSetting.ts:46 |
twoFactorSecret has select: true (default) — secrets selected in every query |
— |
| 17 | user_setting.service.ts:162 |
start2FASetup returns secret: secret.base32 in API response — TOTP secret should never leave server |
— |
| 18 | permission.routes.ts |
Redundant auth(), contextMiddleware on individual routes when already applied globally via router.use() |
— |
| 19 | user.service.ts |
Mixed soft-delete strategies: getUserById checks isDeleted: false (boolean), deleteUser uses softDelete() (sets deletedAt). These are NOT the same check. |
— |
| 20 | contextMiddleware.ts:37 |
Role name from JWT (stale) vs DB (fresh) — role changes not reflected until token expires | — |
| 21 | permissionCache.ts |
Cache stores raw dot-notation; cache lookups miss route-format queries | — |
| 22 | permissionNormalizer.ts |
ACTION_MAPPING dead code, entire normalizer module unused (all calls commented out) | — |
| 23 | permission.service.ts |
getAllPermissions (GET /permissions/all) has no permission check — any authenticated user can list all permissions |
Auth #56 |
| 24 | role.service.ts |
upsertRole calls createRole which has permission check commented out — upsert is also unprotected |
Auth #64 |
| 69 | permissionMiddleware.ts:37 |
Upgraded from Medium: hasAnyPermission error message references ${permission} (function object) instead of ${permissions} (array) — leaks function source code to end users. Information disclosure. |
— |
Medium (26) — Severity-adjusted after code verification + devil's advocate challenge¶
| # | Location | Description | Auth DD Ref |
|---|---|---|---|
| 11 | user_setting.service.ts:74 |
Downgraded from High: Backup code includes() on concatenated hash — theoretically fragile (substring match on concatenated SHA-512) but collision probability is ~2^-512 in practice. The replace() removal at line 82 is the real concern. |
Auth #61 |
| 25 | routes/v1/index.ts:147-151 |
Duplicate InviteUser route registration at both /invite-users and /user-invites |
— |
| 26 | Role.ts, Permission.ts, User.ts |
Redundant isDeleted boolean alongside @DeleteDateColumn |
— |
| 27 | role.validation.ts:23-30 |
Update validation missing displayName field — silently stripped |
— |
| 28 | Seed data | Missing invite.* permissions, users.assign.roles, users.update.role |
Auth #58 |
| 29 | permissions-page.jsx |
Duplicate ConfirmationModal JSX block (lines 773-784 and 786-797 are byte-for-byte identical) | — |
| 31 | api/roles/index.js |
Debug console.log("hasPermission", hasPermission) |
— |
| 32 | permissions-group-page.jsx |
Mostly non-functional (checkboxes, actions, update handler commented out) | — |
| 33 | Payload keys | Inconsistent bulk delete: Users uses { userIds }, all others use { ids } |
— |
| 34 | seed/role.seed.ts:29 |
Seeded roles missing displayName — all have null |
— |
| 35 | user_setting.service.ts:72 |
Debug console.log(code) logs plain-text backup code |
Auth #57 |
| 36 | contextMiddleware.ts |
Silent failure on DB errors — request proceeds without context | — |
| 30 | permissions-page.jsx:309 |
Downgraded from High after challenge: Page gates on role.view instead of permission.view. Edge case — both permissions typically assigned together. Only affects rare "Permission Auditor" role configuration. |
— |
| 37 | contextMiddleware.ts |
CORRECTED: Global contextMiddleware is a no-op for JWT-auth requests (runs before auth(), req.user is undefined → short-circuits). Only runs twice for rare Passport-session users (Google OAuth). Performance impact limited to OAuth flow. |
— |
| 38 | requestContext.ts |
Hard-coded 'super_admin' string — no single source of truth |
Auth #28 |
| 39 | permission.routes.ts |
Redundant DELETE /permissions/delete duplicates PATCH /permissions/bulk-delete |
— |
| 40 | user_setting.service.ts |
startEmail2FASetup defined but never exported/called — email 2FA has no verification step |
Auth #59 |
| 41 | role.service.ts |
createRole cache not invalidated on soft-delete restore path |
Auth #62 |
| 42 | Seed data | readonly field semantics unclear and inconsistent across permissions |
— |
| 43 | Seed data | Redundant read/view permission pairs — view never checked by middleware |
— |
| 44 | seed/permission.seed.ts |
All-or-nothing seed — if any permissions exist, new ones never get seeded | — |
| 45 | api.md |
23 undocumented endpoints across /roles, /permissions, /invite-users | — |
| 46 | database.md |
UserRoles entity docs show wrong class name, missing relations/constraints | — |
| 58 | useGetUsers |
Upgraded from Low: Does NOT gate on useCheckPermission unlike useGetRoles/usePermissions/useGetInvitedUsers. Fires unnecessary API call for users without user.view — visible failed request in browser dev tools. |
— |
| 70 | app.ts + middleware.ts |
NEW (Feb 19): Dead imports in app.ts — cors, passport, cookieParser, session, config, httpLogger, contextMiddleware are imported but no longer used (now handled by middleware.ts) |
— |
| 75 | invited-users-page.jsx:632 |
NEW (challenge finding): Invite status case mismatch — frontend checks invite.status === "pending" (lowercase) but backend returns "PENDING" (uppercase enum). Resend button is never visible. Masks the missing /resend endpoint (dormant double-bug). |
— |
Low (23) — Severity-adjusted after code verification + devil's advocate challenge¶
| # | Location | Description |
|---|---|---|
| 47 | user.service.ts:4 |
Typo: UserSerivce (should be "Service") |
| 48 | invite-user.controller.ts:7 |
Typo: checkIviteUserExists (should be "Invite") |
| 49 | role.service.ts:130 |
Typo: roleDiplayNameExists (should be "Display") |
| 50 | UserSetting.ts:46 |
Unnecessary index on twoFactorSecret column |
| 51 | Role.repository.ts |
Empty file (3 lines of whitespace) |
| 52 | seed.ts:4 |
Dead import: UserRole imported but never used |
| 53 | user_setting.service.ts:7 |
Dead import: { get } from "http" |
| 54 | user_setting.service.ts:162 |
App name hardcoded as "Sass Boilerplate" (typo for "SaaS") |
| 55 | user.validation.ts:9 |
required_error dead code on optional() field |
| 56 | roles-badge-group.jsx |
Duplicate id: 2 in demoAssignedRoles array |
| 57 | permission-form.jsx |
handleUpdatePermission commented out — update is no-op |
| 3 | permission.routes.ts:13 |
Downgraded from Critical after challenge: GET /user-permissions unreachable (shadowed by /:permissionId). But frontend NEVER calls this endpoint — permissions come from GET /users/profile. Dead backend code only. |
| 59 | permissions/page.jsx |
Dev sandbox/test page — not a real management page |
| 60 | requestContext.ts |
RequestUser.role is string not Role — implicit convention |
| 61 | permissionCache.ts |
Excessive redundant userId validation (3-4 times per call chain) |
| 62 | permissionCache.ts |
Misleading timestamp field with no TTL check |
| 63 | permissionNormalizer.ts |
Multi-part permission path segment loss |
| 64 | QueryBuilder.ts |
Double soft-delete filtering (redundant with TypeORM default) |
| 65 | eventTypes.ts |
EVENT_TYPES not typed as const |
| 66 | inviteUserCreate.handler.ts |
Error message says "registration email" not "invite email" |
| 67 | inviteUserCreate.handler.ts |
Failed invite emails silently lost (errors only console.error'd) |
| 68 | seed/setting.seed.ts |
No RBAC configuration settings seeded (all hard-coded) |
| 71 | auth.ts:63 |
Debug message "You are not authorized!33" — leftover debug suffix in error string |
| 72 | auth.ts:13-15 |
Dead code: escapeRegexPath function defined but never called |
| 73 | permission.service.ts:576 |
Typo: getPemissionsByRole (missing "r" in "Permissions") |
| 74 | auth.ts:81-234 |
153 lines of commented-out dead code (two old auth implementations) |
Resolved Issues (confirmed fixed by recent commits)¶
| Former # | Location | Resolution |
|---|---|---|
| Middleware DD | routes.controller.ts / routerList.routes.ts |
REMOVED in commit d021804 (Feb 18). The /api/v1/routes crash no longer occurs. |
| Middleware DD | middleware.ts dead code |
RESOLVED — now active via registerMiddlewares() in app.ts (commit e7117e7, Feb 19) |
| Middleware DD | Helmet/security middleware disabled | RESOLVED — re-enabled in middleware.ts (commit 2922ba8, Feb 18) |
18. Production Risk Assessment¶
Pre-mortem analysis identified 7 production failure scenarios. Full details in RBAC Attack Scenarios — Part 2.
| Priority | Scenario | Business Impact | Root Issues | Fix Effort |
|---|---|---|---|---|
| P0 | Data leak via privilege escalation (user → super_admin → dump all data) | Company-ending | #5b, #15 | 3 lines |
| P0 | Fired employee retains admin access (no status check in middleware) | Legal liability | No status check | Medium |
| P1 | All non-super_admin users blocked (permission format mismatch) | Product unusable | #8, #9 | Medium |
| P1 | Silent user create failure (wrong mutation called in frontend) | Admin productivity destroyed | #1 | 1 line |
| P1 | Invite registration broken (missing backend endpoint + unseeded permissions) | Onboarding blocked | #2, #28 | Small |
| P2 | Intermittent permission failures (dual systems, double middleware) | User confusion | #8, #37 | Medium |
| P2 | Database overload (~9 queries per page load, middleware runs twice) | 10x hosting cost | #37, #18 | Medium |
Minimum Viable Production Readiness (6 fixes, ~30 min)¶
- Strip
rolefromupdateProfileservice + validation (Issues #5b — blocks escalation) - Uncomment
checkPermissionAndThrow("role.create")(Issue #4 — blocks role creation) - Add
checkPermissionAndThrow("permission.grant")tograntPermissionToRole(Issue #5 — blocks permission assignment) - Add user status check in
contextMiddleware(blocks fired employee access) - Fix create-user mutation in
users-page.jsx(Issue #1 —updateUser→createUser) - Seed
invite.*permissions inseed/data/permission.ts(Issue #28 — unblocks invite system)
19. Architecture Decision Records¶
Five ADRs were produced to resolve the systemic architectural issues. Full migration plan in RBAC Attack Scenarios — Part 3.
ADR Summary¶
| ADR | Decision | Rationale |
|---|---|---|
| ADR-1: Permission Format | Standardize on dot notation (user.create) |
Already used by DB, seed data, frontend, helper. Route format only used by 1 dead-code middleware. Zero migration cost. |
| ADR-2: hasPermission Implementation | Keep helper + new middleware v2 (with 3 fixes), delete v1 | v2 uses correct dot format but has 3 CRITICAL prerequisites before expanding: (PRE-1) add allPermissionAssignToAdmin() to seed so super_admin has permissions in role_permission table, (PRE-2) add super_admin bypass to v2 (if (req.user.role === 'super_admin') return next()), (PRE-3) add null-safety for req.user.permissions undefined. Without these, v2 blocks super_admin (#76). Delete v1 after migrating GET /payments to v2. |
| ADR-3: UserRole Entity | Delete entity | Never used by any service code. Only imported (dead) in seed.ts. The @ManyToMany + @JoinTable on User.ts handles the relationship correctly. |
| ADR-4: isDeleted vs deletedAt | Remove isDeleted, use deletedAt only |
TypeORM's softDelete() sets deletedAt but never touches isDeleted. Mixed checks create false negatives. |
| ADR-5: Permission Normalizer | Delete entirely | 215 LOC, 13 exports, zero active callers. All normalization calls commented out. With single format, no normalization needed. |
Migration Phases¶
| Phase | Scope | Effort | Impact |
|---|---|---|---|
| 1. Security | P0 privilege escalation + status check fixes | 30 min | Blocks all 6 attack vectors |
| 2. Unification | Delete middleware v1, keep v2; delete entity, normalizer; dedupe contextMiddleware; fix hasAnyPermission bug |
2-3 hrs | Two-system model (v2 for routes + helper for services) |
| 3. Cleanup | Remove isDeleted, fix HTTP codes, remove console.logs, seed fixes | 2-3 hrs | Consistent data model |
| 4. Frontend | Remove broken hooks, fix permission gates, fix mutations | 1-2 hrs | Match backend fixes |
Target Architecture¶
AFTER (updated for Feb 22 changes):
Route-level: permissionMiddleware.ts (dot notation, req.user.permissions Set)
├── permission("user.view") — single permission check
└── hasAnyPermission(["a","b"]) — any-of check (fix bug in error msg first)
Service-level: helper/hasPermission.ts (dot notation, multi-role, cached)
├── checkPermissionAndThrow() — all services
└── hasPermission() — conditional checks
Both backed by: contextMiddleware → initializePermissionCache → req.user.permissions Set
DELETE: middlewares/hasPermission.ts (v1, route format, broken)
ONE format: dot notation
ONE soft-delete: @DeleteDateColumn
ONE user↔role table: user_roles (@ManyToMany JoinTable)
20. Root Cause Analysis¶
5 Whys analysis traced all 74 issues back to 5 systemic root causes. Full reasoning chains in RBAC Attack Scenarios — Part 4.
The 5 Root Causes¶
| # | Root Cause | Issues It Explains |
|---|---|---|
| RC-1 | Zero automated tests + super_admin bypass — RBAC was never tested with the users it's designed to protect. The super_admin bypass makes permission checks invisible during development. | Format mismatch (#8, #9), unreachable route (#3), silent failures, all permission gaps |
| RC-2 | No separation between internal and external service calls — Seed scripts call service methods that have permission checks, creating a conflict resolved by commenting out security. | Commented-out role.create (#4), upsertRole unprotected (#24) |
| RC-3 | Shared validation schemas between admin and self-service endpoints — updateProfile and updateUser share the same Zod schema, so self-service endpoints accept admin-only fields. |
Self-role-change escalation (#5b), the most severe vulnerability |
| RC-4 | Single developer, no code review — Security-sensitive patterns (field-level access, permission checks) were missed with no second pair of eyes. | Copy-paste bugs (#14, #30), dead code accumulation, debug logs (#12, #13, #31, #35) |
| RC-5 | Incremental development without architectural refactoring — Features were added alongside existing code rather than replacing it, creating parallel systems. | Dual hasPermission (#8), dual tables (#7), dead normalizer (#22), isDeleted + deletedAt (#26) |
Prevention Table¶
| Prevention | Cost | Would Have Caught |
|---|---|---|
| 1 integration test with a non-super_admin user | 30 min | Format mismatch, broken RBAC, most permission issues |
| Separate validation schemas for admin vs self-service | 15 min | Self-role-change vulnerability (most severe issue) |
ESLint rule: no console.log in production |
5 min | All 5 debug log leaks |
// TODO grep in CI that blocks merge |
5 min | Commented-out permission checks |
| Code review checklist: "does this endpoint check permissions?" | 0 cost | Missing permission checks on 4 endpoints |
21. Sprint Plan¶
Cross-functional war room triaged all 70 issues into 4 phases. Full sprint plan with verification checklists, acceptance tests, and sprint board at RBAC Sprint Plan.
| Phase | Items | Effort | Timeline | Exit Criteria |
|---|---|---|---|---|
| Ship Blockers | 14 | ~1 hr | Day 1 | All 6 attack vectors blocked, core features work |
| Sprint 1 | 14 | ~7 hrs | Week 1 | Non-super_admin admin can CRUD all management pages |
| Sprint 2 | 20 | ~12 hrs | Week 2-3 | isDeleted removed, seed works, integration test passes, docs match |
| Backlog | 18 | ~3 hrs | Ongoing | Fix when touching adjacent files |
| Total | 66 | ~23 hrs | ~3 weeks |
4 issues auto-resolved by Sprint 1 deletions (UserRole entity, normalizer).
Generated by BMAD Document Project workflow v1.2.0 — Exhaustive deep-dive, 2026-02-25