Root Cause Analysis — SuperAdmin Access Control Bypass¶
Incident ID: SEC-2026-09-03-01 Status: Fixed and test-covered in code (2026-09-03) — pending deployment to the dev/UAT server and client re-verification Severity: Critical (P0) — per SOW Appendix 3 Defect Severity table: "Prevents operation of a core agreed workflow" / "Blocks acceptance" Reported by: Client UAT (Org Admin testing, tesco_admin account) Investigated by: NeuralRays engineering Date of RCA: 2026-09-03
1. Summary¶
Any authenticated user — regardless of role (Org Admin, Org User, etc.) — can access every SuperAdmin organization-management endpoint by calling it directly, because the authorization check on that controller is silently not enforced. This was discovered as a read-only data exposure (an Org Admin viewing all organizations' data) but investigation shows the same defect also affects the write endpoints: Suspend, Reactivate, Enable Issuer, Delete Organization, and Create Org Admin User are all exploitable the same way. This is a cross-tenant privilege-escalation vulnerability, not a UI display bug.
2. Impact¶
An authenticated user holding any valid session token can, for any organization on the platform (not just their own):
- View full organization profiles, including admin users' names and email addresses (confirmed by client testing — PII exposure across tenants)
- Suspend another organization's issuer capabilities
- Reactivate a suspended organization
- Enable issuer capabilities for another organization
- Delete another organization outright
- Create a new admin user on another organization
- View another organization's admin user list
- Trigger Velocity Network tenant creation/retry for another organization
The client's UAT report only exercised the read paths (list, detail) and deliberately did not test the action buttons — so no data is known to have actually been altered. However, the same root cause applies to the write endpoints with no additional barrier, so this must be treated as if write access is live, not just a theoretical risk.
3. Root Cause¶
3.1 Primary defect — RolesGuard does not honor class-level @Roles()¶
app/backend/src/admin/controllers/superadmin-org.controller.ts declares its authorization once, at the class level:
@Controller('superadmin/organizations')
@ApiBearerAuth()
@UseGuards(AuthGuard('jwt'), RolesGuard)
@Roles(UserRole.PLATFORM_ADMIN) // Only SuperAdmin can access these endpoints
export class SuperAdminOrganizationController {
This is a reasonable, common pattern — "everything in this controller requires Platform Admin" — but the guard that's supposed to enforce it doesn't check for it:
// app/backend/src/auth/guards/roles.guard.ts
canActivate(context: ExecutionContext): boolean {
const requiredRoles = this.reflector.get<UserRole[]>('roles', context.getHandler());
if (!requiredRoles) {
return true; // <-- fails OPEN when no roles metadata is found
}
...
Reflector.get(key, target) only reads metadata attached to the exact target passed in. context.getHandler() returns the specific method being called (e.g. getOrganizations), not the class. Since @Roles(UserRole.PLATFORM_ADMIN) was applied to the class and not repeated on any individual method, reflector.get('roles', context.getHandler()) returns undefined for every single endpoint in this controller. The guard interprets "no roles metadata found" as "this route has no role restriction" and grants access unconditionally.
The correct NestJS pattern is reflector.getAllAndOverride('roles', [context.getHandler(), context.getClass()]), which checks the method first and falls back to the class. This codebase's RolesGuard never checks context.getClass() at all — so any controller that relies on a class-level-only @Roles() decorator has this same silent bypass. A repo-wide check (grep -rn "^@Roles(" app/backend/src --include="*.controller.ts") confirms SuperAdminOrganizationController is currently the only controller written this way — but the underlying guard defect remains latent for any future code written the same (natural, idiomatic) way.
3.2 Contributing factor — no tenant scoping in the underlying queries¶
Independent of the RBAC bug, the queries themselves have no defense in depth. organization.service.ts's getOrganizations() filters out only the platform's own internal org (isPlatformOrg = false) and otherwise returns every tenant organization, paginated, with no caller-identity filter. getOrganizationById() and the suspend/reactivate/enable-issuer service methods all do a bare findOne({ where: { id } }) with no ownership check against the calling user's own organizationId. This means the RBAC guard was the only thing standing between any authenticated user and full cross-tenant read/write access — there was no second layer to catch the failure.
3.3 Contributing factor — no frontend route protection¶
app/frontend/src/pages/superadmin/dashboard.tsx and .../organizations/[id].tsx have no getServerSideProps check, redirect, or guard component of any kind — confirmed by direct inspection, and by the absence of any middleware.ts anywhere in the frontend app. The only role-aware code in the frontend is navConfig.tsx's navForRole(), which decides which sidebar links to show — it has no effect on direct URL navigation. On the organization-detail page, the "Enable Issuer" and "Suspend" buttons are gated only on the target organization's KYB/lifecycle status (organization.status === KYB_APPROVED), not on the viewing user's role at all.
This is a defense-in-depth failure at every layer: the frontend never checked, the queries never scoped, and the one layer that was supposed to check (RolesGuard) failed silently.
4. Why this wasn't caught earlier¶
This controller has been live, unchanged in this respect, since it was first introduced (see Timeline). No automated test exists anywhere in the backend that asserts a non-Platform-Admin request is rejected by this controller — this is consistent with the near-total absence of automated test coverage already flagged separately in our SOW task tracking (M2-14, M3-16, M5-13, M7-07 "Test Evidence" deliverables, all currently unstarted). An integration test asserting expect(response.status).toBe(403) for an org_admin token against any of these routes would have caught this the day the controller was written.
5. Timeline¶
| Date | Event |
|---|---|
2025-10-06 (5ac416d) |
RolesGuard introduced with the handler-only reflector call; defect present from this commit, never modified since |
2026-01-19 (5bf2f07) |
SuperAdminOrganizationController introduced with the class-level-only @Roles() pattern — vulnerability live from this point |
| 2026-01-19 → present | 6 further commits touched this file (KYB removal, spelling, audit logging, lint) — none added method-level @Roles() or otherwise closed the gap |
| 2026-09-03 | Discovered and reported via client UAT testing |
6. Affected Endpoints (full list — broader than originally reported)¶
All under superadmin/organizations, all currently reachable by any authenticated user regardless of role:
| Method | Route | Action |
|---|---|---|
| GET | / |
List all organizations |
| POST | / |
Create organization |
| GET | /:id |
Get organization detail (incl. admin PII) |
| PUT | /:id/enable-issuer |
Enable issuer capabilities |
| PUT | /:id/suspend |
Suspend organization |
| PUT | /:id/reactivate |
Reactivate organization |
| POST | /:id/admin |
Create a Company Admin user for the org |
| GET | /:id/admins |
List an org's admin users |
| DELETE | /:id |
Delete organization |
| POST | /:id/tenant |
Create Velocity Network tenant |
| POST | /:id/tenant/retry |
Retry Velocity tenant creation |
| GET | /:id/tenant |
Get Velocity tenant status |
7. Recommended Remediation¶
Immediate (tactical fix, unblocks acceptance):
Add @Roles(UserRole.PLATFORM_ADMIN) explicitly to every method in SuperAdminOrganizationController, not just the class. This restores the intended protection for this specific controller with a minimal, low-risk change.
Root-cause fix (must happen alongside, not instead of, the tactical fix):
Fix RolesGuard.canActivate() to check both handler and class:
const requiredRoles = this.reflector.getAllAndOverride<UserRole[]>('roles', [
context.getHandler(),
context.getClass(),
]);
@Roles() pattern.
Defense in depth (should follow, not block, the above):
Add explicit organizationId ownership checks in the service layer for any endpoint not genuinely intended to be platform-wide, so a future RBAC misconfiguration doesn't again result in unscoped cross-tenant access on its own.
Frontend:
Add a route guard (e.g. a shared withRole() HOC or a check in getServerSideProps) to /superadmin/* pages that redirects non-Platform-Admin users — this is a UX/defense-in-depth improvement, not a substitute for the backend fix, since the backend must never trust the frontend to enforce authorization.
Verification: Add integration tests asserting 403 for non-Platform-Admin tokens against every route in section 6, before this is marked resolved — this is the concrete test-coverage gap that let it through.
8. Suggested Next Steps¶
- Apply the tactical + root-cause backend fixes together (they're small, low-risk, and should ship as one change). ✅ Done — see section 9.
- Audit the database for any organizations whose
status/kybStatuschanged unexpectedly during the UAT window, in case the tester's "verified twice" step inadvertently triggered a state change before they stopped. Still open — needs a DB check, not a code fix. - Grep the rest of the backend for any other class-level-only
@Roles()usage as a one-time sanity check (none found as of this RCA, per section 3.1). - Add the regression tests from section 7 before closing this out. ✅ Done — see section 9.
- Deploy the fix to the dev/UAT server and ask the client to re-run their original repro steps to confirm closure.
9. Resolution (2026-09-03)¶
Both the tactical and root-cause fixes were applied together, as recommended:
app/backend/src/auth/guards/roles.guard.ts—canActivate()now usesreflector.getAllAndOverride('roles', [context.getHandler(), context.getClass()])instead ofreflector.get('roles', context.getHandler()), so class-level@Roles()decorators are honored everywhere in the codebase, not just this one controller.app/backend/src/admin/controllers/superadmin-org.controller.ts— every one of the 12 route handlers now also carries an explicit@Roles(UserRole.PLATFORM_ADMIN), as defense in depth on top of the class-level decorator.
Test coverage added:
- app/backend/src/auth/guards/roles.guard.spec.ts — 7 unit tests against the guard directly, including the exact class-level-only scenario that caused this incident.
- app/backend/src/admin/controllers/superadmin-org.controller.spec.ts — 36 integration tests (all 12 endpoints × Org Admin / unauthenticated / Platform Admin), using the real guard pipeline via supertest against an in-memory Nest app.
Fix verified two ways:
1. All 43 new tests pass against the fixed code.
2. The fix files were temporarily reverted (via git stash) with the new tests left in place, and re-run — 26 of the 43 tests failed with expected 403, got 200, reproducing the exact UAT symptom automatically. This confirms the tests are a genuine regression guard for this specific incident, not a tautology. The fix was then restored and all 43 tests passed again.
TypeScript build (tsc --noEmit) and ESLint both pass clean on all four changed/added files.
Not yet done: deployment to the dev/UAT server, and the database audit from item 2 above. Nothing has been committed or pushed — changes are in the working tree pending your review.