Skip to content

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

  1. Revised 2026-08-21 โ€” ORG_USER now has real permissions (issue credentials, manual + bulk); ISSUER, RELYING_PARTY, CREDENTIAL_MANAGER still 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 for ORG_USER specifically (see the confirmed-scope table above). The other three remain intentionally empty for MVP โ€” not a gap, the confirmed scope.
  2. โœ… Confirmed 2026-08-20 โ€” revoke. CANDIDATE currently has full create/edit/delete rights on Credential Templates and Employee records โ€” identical access to ORG_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. Remove CANDIDATE from both lists.
  3. RELYING_PARTY stays 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 (validateRoleAssignment in company-admin-user.service.ts doesn'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, and navConfig.tsx:114-126 already reserves a RELYING_PARTY nav 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 wire RELYING_PARTY in now. Leave the frontend dropdown option as-is or hide it; either is fine since the backend already blocks it.
  4. The deprecated ADMIN role (marked @deprecated in the enum, "use PLATFORM_ADMIN instead") is still fully wired everywhere PLATFORM_ADMIN is โ€” Organizations, KYB, Branding, Email Templates, Platform Stats all list both roles side by side. Since when a hardcoded admin@curo.com bypass was removed (Task 32, 2026-08-14), nothing currently issues the ADMIN role to any real user โ€” confirm it's safe to strip ADMIN out of every @Roles() list, or if some existing account still relies on it.
  5. IssuerCapabilityGuard exists 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 ADMIN role and IssuerCapabilityGuard wiring (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 CANDIDATE from Credential Templates and Employee @Roles() lists
  • Add @Roles(ORG_ADMIN) to the credentials-list and candidates-list routes (currently any authenticated user)
  • Confirm SYSTEM_AUDITOR scope and the ADMIN/IssuerCapabilityGuard items with Curo
  • Re-verify with the same live-testing approach used to build this matrix
  • M2-09 โ€” original role enum implementation
  • M2-05 โ€” user CRUD this matrix governs
  • docs/milestones/M9/M9-00-security-encryption-audit.md โ€” broader security hardening pass (not started); the unscoped-controller issue above is exactly the kind of finding that milestone is meant to catch