|
| 1 | +# Role-Based Access Control (RBAC) |
| 2 | + |
| 3 | +This service enforces role-based access control on every route through a global |
| 4 | +`RolesGuard`. Roles are carried in the JWT and checked against the roles a route |
| 5 | +declares with the `@Roles(...)` decorator. |
| 6 | + |
| 7 | +## Roles |
| 8 | + |
| 9 | +The canonical role set lives in [`src/common/guard/roles.enum.ts`](../src/common/guard/roles.enum.ts). |
| 10 | +All role values are **UPPERCASE** strings: |
| 11 | + |
| 12 | +| Role | Purpose | |
| 13 | +| --------------------- | -------------------------------------------------------------- | |
| 14 | +| `USER` | Default, least-privileged role. Read-only baseline access. | |
| 15 | +| `OPERATOR` | Elevated operational access (supersedes `USER`). | |
| 16 | +| `ADMIN` | Full administrative access. Supersedes every other role. | |
| 17 | +| `GOVERNANCE_OPERATOR` | Specialised governance role. Exact-match only (no hierarchy). | |
| 18 | +| `KYC_OPERATOR` | Specialised KYC/compliance role. Exact-match only. | |
| 19 | + |
| 20 | +### Hierarchy |
| 21 | + |
| 22 | +`USER → OPERATOR → ADMIN` is a linear hierarchy: a higher role satisfies any |
| 23 | +requirement for a lower one. `ADMIN` supersedes everything. |
| 24 | + |
| 25 | +`GOVERNANCE_OPERATOR` and `KYC_OPERATOR` sit **outside** the linear hierarchy — |
| 26 | +they require an exact match and do not inherit from or grant each other. Only |
| 27 | +`ADMIN` supersedes them. See `hasRole()` in the enum file for the exact logic. |
| 28 | + |
| 29 | +### Conflicting roles |
| 30 | + |
| 31 | +`ADMIN` and `KYC_OPERATOR` are treated as mutually exclusive for separation of |
| 32 | +duties: the account that administers the platform must not also sign off on KYC |
| 33 | +reviews. `UserService.assignRole` rejects an assignment that would create the |
| 34 | +conflicting pair with a `400 Bad Request`. Conflicting pairs are defined in |
| 35 | +`CONFLICTING_ROLE_PAIRS` (`user.service.ts`). |
| 36 | + |
| 37 | +## Enforcing roles on a route |
| 38 | + |
| 39 | +Decorate the handler (or controller) with `@Roles(...)` and ensure the request |
| 40 | +is authenticated. `RolesGuard` is registered globally in `AppModule`, so you do |
| 41 | +not need to add it per-route — but the route must run behind an auth guard that |
| 42 | +populates `request.user` (e.g. the global `StrategyAuthGuard`, or an explicit |
| 43 | +`@UseGuards(JwtAuthGuard)`). |
| 44 | + |
| 45 | +```ts |
| 46 | +import { Roles } from "src/common/guard/roles.decorator"; |
| 47 | +import { Role } from "src/common/guard/roles.enum"; |
| 48 | + |
| 49 | +@Roles(Role.ADMIN) |
| 50 | +@Get("admin/dashboard") |
| 51 | +getAdminDashboard() { ... } |
| 52 | +``` |
| 53 | + |
| 54 | +- A route with **no** `@Roles` decorator is not role-restricted. |
| 55 | +- A route with `@Roles(Role.ADMIN)` returns **403** for any non-admin principal. |
| 56 | +- A missing/unauthenticated principal returns **401**. |
| 57 | + |
| 58 | +`@RequireRole(...)` is an alias of `@Roles(...)` kept for readability. |
| 59 | + |
| 60 | +## How token claims map to roles |
| 61 | + |
| 62 | +Roles are persisted as a single `role` column on the `users` table and signed |
| 63 | +into the JWT `role` claim at login/registration. On each request: |
| 64 | + |
| 65 | +1. The auth strategy validates the JWT and puts the principal on `request.user`. |
| 66 | +2. `RolesGuard` reads `user.roles` (array) or `user.role` (single) and coerces |
| 67 | + each value to a canonical `Role` via `normalizeRole()`. |
| 68 | +3. The normalized roles are compared against the route's required roles using |
| 69 | + the hierarchy rules above. |
| 70 | + |
| 71 | +### Backwards compatibility |
| 72 | + |
| 73 | +`normalizeRole()` is the single point of backwards compatibility: |
| 74 | + |
| 75 | +- Legacy **lowercase** claims minted before canonicalisation (e.g. `"admin"`, |
| 76 | + `"kyc_operator"`) are accepted and mapped to their UPPERCASE canonical form. |
| 77 | +- **Unknown, empty, or missing** role claims map to `USER` — the least |
| 78 | + privileged role — so tokens issued before roles existed default to read-only |
| 79 | + access rather than being rejected or over-privileged. |
| 80 | + |
| 81 | +The same coercion is applied when reading the `role` column off the `User` |
| 82 | +entity (via a TypeORM column transformer), so existing lowercase database rows |
| 83 | +continue to work without a data migration. |
| 84 | + |
| 85 | +## Admin role-management endpoints |
| 86 | + |
| 87 | +Exposed by [`AdminRoleController`](../src/core/user/admin-role.controller.ts) |
| 88 | +under `/admin`. Every route requires an authenticated `ADMIN` whose session is |
| 89 | +2FA-verified (`JwtAuthGuard` + `RolesGuard` + `AdminTwoFactorGuard`). |
| 90 | + |
| 91 | +| Method & path | Description | |
| 92 | +| ------------------------- | ------------------------------------------------------- | |
| 93 | +| `GET /admin/roles` | List the assignable canonical roles. | |
| 94 | +| `GET /admin/users/:id/role` | Get a user's current role. | |
| 95 | +| `PATCH /admin/users/:id/role` | Assign a role (`{ "role": "OPERATOR" }`). 400 on conflict, 404 if the user is missing. | |
| 96 | +| `DELETE /admin/users/:id/role`| Reset the user to the least-privileged `USER` role. | |
| 97 | + |
| 98 | +The assign payload is validated by `AssignRoleDto` (`@IsEnum(Role)`), so any |
| 99 | +value outside the canonical role set is rejected with `400` before it reaches |
| 100 | +the service — this prevents privilege escalation via malformed input. |
| 101 | + |
| 102 | +## Bootstrapping the first admin |
| 103 | + |
| 104 | +Because every role-management route already requires `ADMIN`, a fresh deployment |
| 105 | +needs a way to mint the first admin. `RoleSeederService` handles this on |
| 106 | +application start, idempotently and non-destructively. |
| 107 | + |
| 108 | +Set **one** of the following environment variables to an **existing** account: |
| 109 | + |
| 110 | +```bash |
| 111 | +ADMIN_BOOTSTRAP_EMAIL=admin@example.com |
| 112 | +# or |
| 113 | +ADMIN_BOOTSTRAP_WALLET=0xabc... |
| 114 | +``` |
| 115 | + |
| 116 | +On boot the seeder: |
| 117 | + |
| 118 | +- does nothing if neither variable is set; |
| 119 | +- logs a warning and does nothing if no matching user exists (it never creates |
| 120 | + phantom accounts or invents credentials — register the account first, then |
| 121 | + restart); |
| 122 | +- does nothing if the user is already `ADMIN`; |
| 123 | +- otherwise promotes the matching user to `ADMIN`. |
| 124 | + |
| 125 | +> The application runs on TypeORM `synchronize: true`, and role data is a single |
| 126 | +> column on the `users` table, so there is no separate roles migration. If |
| 127 | +> migration infrastructure is added later, this promotion can move into a data |
| 128 | +> migration unchanged. |
| 129 | +
|
| 130 | +## Testing |
| 131 | + |
| 132 | +- `src/common/guard/normalize-role.spec.ts` — claim coercion and defaults. |
| 133 | +- `src/common/guard/roles.guard.spec.ts` — guard behaviour, including legacy |
| 134 | + lowercase-claim compatibility. |
| 135 | +- `src/core/user/admin-role.controller.spec.ts` — admin endpoint behaviour. |
| 136 | +- `src/core/user/user-role-separation.spec.ts` — conflicting-role enforcement. |
| 137 | +- `src/core/user/role-seeder.service.spec.ts` — idempotent bootstrap promotion. |
0 commit comments