Milestone: M2 โ Platform and Client Admin | SOW Reference: FR7 (Authorisation) โ extends M2-09 | Requirement Clarity: โ Org-level scope confirmed 2026-08-20 โ Org Admin only for MVP | Dev Status: ๐ก Confirmed scope known; two small code gaps still to close
M2-15 Role Permission Matrix โ Confirmed Scope¶
Why this exists¶
While fixing the reported "Add User / Invite User both errored" bug (2026-08-20), testing surfaced that several roles in the UserRole enum carry no enforced permissions at all, one role is over-privileged in a way that looks like a copy-paste error, and one controller has no role restriction whatsoever. M2-09 defined the role enum โ it never defined what each role should actually be allowed to do, and that gap was never closed. Rather than guess at the intended design, this doc records exactly what's enforced today, in code, verified by direct testing โ so Curo can confirm/correct it before we lock in restrictions.
๐ด Security issue โ fix regardless of the answers below¶
The legacy /api/v1/users/* controller (users.controller.ts) has AuthGuard('jwt') but no RolesGuard/@Roles() at all, and no organization scoping in code. Any authenticated user, of any role โ including a freshly created org_user or issuer โ can currently call this controller to list, create, update, or delete any user on the platform, not just within their own organization. This is separate from the properly-scoped admin/company-admin/users controller the real "Users" page in the UI actually uses. This needs closing (add RolesGuard + org scoping, or remove the controller if it's dead/superseded) independent of how the questions below get answered.
Verified current behavior (code-audited + live-tested 2026-08-20)¶
Split by scope, since that distinction matters more than a flat feature list: platform-level functions operate across every organization and are Platform Admin's job; org-level functions are scoped to one's own organization and are what an org's own staff do day to day.
Legend: ๐ข role-restricted ยท ๐ก any authenticated user (no @Roles(), but AuthGuard('jwt') present) ยท โช fully public ยท โ no access
Platform-level (cross-organization โ Platform Admin's job)¶
| Function | PLATFORM_ADMIN | SYSTEM_AUDITOR |
|---|---|---|
| Create / suspend / delete any organization | ๐ข | โ |
| Approve / reject an organization's KYB | ๐ข | ๐ข view-only |
| Enable issuer capabilities for an org | ๐ข | โ |
| Create org admins for any organization | ๐ข | โ |
| Platform-wide stats dashboard | ๐ข | ๐ข |
| Platform-wide audit logs (all orgs) | ๐ข | ๐ข |
Org-level (scoped to one's own organization)¶
| Function | ORG_ADMIN | ORG_USER | ISSUER | RELYING_PARTY | CREDENTIAL_MANAGER | CANDIDATE |
|---|---|---|---|---|---|---|
| Manage own org's users (create/edit/deactivate/delete) | ๐ข | โ | โ | โ | โ | โ |
| Branding / email templates | ๐ข | โ | โ | โ | โ | โ |
| Submit / view own org's KYB | ๐ข | โ | โ | โ | โ | โ |
| View own org's audit logs | ๐ข | โ | โ | โ | โ | โ |
| Issue credentials (manual + bulk) | ๐ข | โ | โ | โ | โ | โ |
| Manage credential templates | ๐ข | โ | โ | โ | โ | ๐ข โ ๏ธ |
| Manage employees (CRUD, CSV import/export) | ๐ข | โ | โ | โ | โ | ๐ข โ ๏ธ |
| View own org's credentials (list/export/stats/revoke) | ๐ข | ๐ก* | ๐ก* | ๐ก* | ๐ก* | ๐ก* |
| View own org's candidates | ๐ข | ๐ก* | ๐ก* | ๐ก* | ๐ก* | ๐ก* |
* Not blocked by role, but the service layer does at least keep it inside their own org via req.user.organizationId โ different in kind from the unscoped controller below, which has no boundary at all.
Universal (any logged-in user) / Public (no login)¶
Own profile & sessions โ universal, any role. Claiming a credential by token, resolving an org by name pre-login, and credential-type reference data โ public by design, no auth at all.
โ ๏ธ = looks unintentional, flagged below, not a deliberate design choice we're aware of.
โ Confirmed scope for MVP (2026-08-20, revised 2026-08-21)¶
Revised per the fuller "Pelorus UI walkthrough" client feedback (docs/feedback/2026-08-19-pelorus-ui-walkthrough.md, item GEN-5), which superseded the initial Org-Admin-only confirmation below: "Apply the role-based security to the org user UI. The base user will be able to issue (including bulk), request and verify credentials only."
ORG_USER is now a real, implemented, scoped role โ issue credentials (manual + bulk) โ verified live 2026-08-21 (GET /issuer/manual-credentials/credential-types โ 200, POST /issuer/credentials/bulk-issuance/upload โ reaches validation instead of 403, both for an org_user-role token; GET /admin/kyb still correctly 403s for the same token). "Request and verify credentials" (disclosure/verification) will extend to ORG_USER the same way once M5 is built โ tracked in M5-00.
Everything else below still holds: Org Admin remains the sole functional role for every other org-level function, including all of Disclosure & Verification once M5 exists beyond issuance.
| Function | Org Admin | Org User | Issuer | Relying Party | Credential Manager | Candidate |
|---|---|---|---|---|---|---|
| Manage own org's users (create/edit/deactivate/delete) | ๐ข | โ | โ | โ | โ | โ |
| Branding / email templates | ๐ข | โ | โ | โ | โ | โ |
| Submit / view own org's KYB | ๐ข | โ | โ | โ | โ | โ |
| View own org's audit logs | ๐ข | โ | โ | โ | โ | โ |
| Issue credentials (manual + bulk) | ๐ข | ๐ข | โ | โ | โ | โ |
| Manage credential templates | ๐ข | โ | โ | โ | โ | โ |
| Manage employees (CRUD, CSV import/export) | ๐ข | โ | โ | โ | โ | โ |
| View own org's credentials (list/export/stats/revoke) | ๐ข | โ | โ | โ | โ | โ |
| View own org's candidates | ๐ข | โ | โ | โ | โ | โ |
| Raise / track disclosure requests (M5) | ๐ข | ๐ข (once M5 exists) | โ | โ | โ | โ |
| Run / view verification results (M5) | ๐ข | ๐ข (once M5 exists) | โ | โ | โ | โ |
Not yet done, tracked as its own item: "Org users see only credentials they've issued; Org Admins see all" (Pelorus item CRED-2) โ ORG_USER can now issue credentials, but the credentials list/export isn't yet filtered by who issued what. That's separate follow-up work, now unblocked.
Gap between this and the code today¶
Two rows above don't match current code yet โ everything else already is Org-Admin-only:
CANDIDATE's access to Credential Templates and Employee records must be revoked โ confirmed as a copy-paste error (anomaly 2 below), not an intentional grant.- The "any authenticated user" ๐ก access to the credentials and candidates lists must become Org-Admin-only โ today any logged-in user of any role can reach these (still org-scoped by
organizationId, but not role-gated); the confirmed answer removes that entirely, tightening it to@Roles(ORG_ADMIN).
Both are small, contained @Roles() changes โ no new roles, no new guards, just narrowing two decorator lists and removing one from two others.
Specific anomalies to confirm¶
- Revised 2026-08-21 โ
ORG_USERnow has real permissions (issue credentials, manual + bulk);ISSUER,RELYING_PARTY,CREDENTIAL_MANAGERstill get nothing, by design. Originally confirmed 2026-08-20 that all four roles got zero permissions; the fuller Pelorus feedback the next day corrected that forORG_USERspecifically (see the confirmed-scope table above). The other three remain intentionally empty for MVP โ not a gap, the confirmed scope. - โ
Confirmed 2026-08-20 โ revoke.
CANDIDATEcurrently has full create/edit/delete rights on Credential Templates and Employee records โ identical access toORG_ADMIN/PLATFORM_ADMIN. Confirmed as a copy-paste error in the@Roles()decorator list on those two controllers, not an intentional grant โ a candidate is a credential recipient, not org staff. RemoveCANDIDATEfrom both lists. RELYING_PARTYstays unassignable for now โ not a gap, a deliberate MVP simplification. The frontend's Create/Edit User dialog offers "Relying Party" as an assignable role, but the backend rejects it (validateRoleAssignmentincompany-admin-user.service.tsdoesn't include it โ confirmed via live 400 response). Worth knowing for later: the requirements PDF describes the Staffing Company's overall role on the Velocity network as "Relying Party" for Disclosure & Verification, andnavConfig.tsx:114-126already reserves aRELYING_PARTYnav section (/verifier/dashboard,/verifier/verify,/verifier/history) that nothing uses yet โ so if Curo ever wants a separate verifier role, the scaffolding is already half there. But per the confirmed scope above, Org Admin covers this for MVP โ no reason to wireRELYING_PARTYin now. Leave the frontend dropdown option as-is or hide it; either is fine since the backend already blocks it.- The deprecated
ADMINrole (marked@deprecatedin the enum, "use PLATFORM_ADMIN instead") is still fully wired everywherePLATFORM_ADMINis โ Organizations, KYB, Branding, Email Templates, Platform Stats all list both roles side by side. Since when a hardcodedadmin@curo.combypass was removed (Task 32, 2026-08-14), nothing currently issues theADMINrole to any real user โ confirm it's safe to stripADMINout of every@Roles()list, or if some existing account still relies on it. IssuerCapabilityGuardexists as a real, implemented guard class but is never attached to any route โ the "org must be ISSUER_ENABLED before issuing credentials" check isn't enforced by a guard anywhere; whatever protection exists today is service-layer logic elsewhere, not this guard. Worth a deliberate decision on whether this guard should actually be wired in.
What's still genuinely open¶
Org-level scope is resolved (above). Two things remain platform-level, not org-level, and still need Curo's input:
SYSTEM_AUDITOR's exact platform-level scope beyond what's already listed (KYB view-only, stats, audit logs) โ confirm that's the complete list.- The deprecated
ADMINrole andIssuerCapabilityGuardwiring (anomalies 4โ5 below) โ small, independent decisions, not blocked on the org-level matrix.
Next steps¶
- Fix the unscoped legacy
/users/*controller regardless of other answers (tracked here, not blocked on client response) - Revoke
CANDIDATEfrom Credential Templates and Employee@Roles()lists - Add
@Roles(ORG_ADMIN)to the credentials-list and candidates-list routes (currently any authenticated user) - Confirm
SYSTEM_AUDITORscope and theADMIN/IssuerCapabilityGuarditems with Curo - Re-verify with the same live-testing approach used to build this matrix