Skip to content

Commit b5174c4

Browse files
Merge pull request #113 from m-hajjo/feat/RBAC
Feat/rbac
2 parents d275de3 + 620416b commit b5174c4

21 files changed

Lines changed: 678 additions & 46 deletions

.env.example

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,14 @@ DATABASE_URL=postgresql://user:password@localhost:5432/alianStructure.db
1010
JWT_SECRET=your_jwt_secret_key_here
1111
JWT_EXPIRATION=24h
1212

13+
# RBAC bootstrap admin (optional)
14+
# On startup, the account matching ONE of these is promoted to the ADMIN role
15+
# (idempotent — no-op if already admin or if both are unset). Use to seed the
16+
# first admin without a manual DB edit. Prefer the wallet address for wallet
17+
# accounts, or the email for email/password accounts.
18+
ADMIN_BOOTSTRAP_EMAIL=
19+
ADMIN_BOOTSTRAP_WALLET=
20+
1321
# AI Services
1422
OPENAI_API_KEY=your_openai_key
1523
GROK_API_KEY=your_grok_key

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,7 @@ See [SECURITY.md](SECURITY.md) for vulnerability reporting details.
126126
### Security Documentation
127127
- 🔐 [SECURITY.md](SECURITY.md) - Vulnerability reporting policy
128128
- 📋 [SECURITY_AUDIT.md](SECURITY_AUDIT.md) - Pre-production checklist & threat model
129+
- 🛡️ [docs/RBAC.md](docs/RBAC.md) - Role-based access control: roles, guard, token-claim mapping & admin setup
129130

130131
## API Endpoints
131132

docs/RBAC.md

Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
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.
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import { Role, normalizeRole } from "./roles.enum";
2+
3+
describe("normalizeRole", () => {
4+
it("returns canonical roles unchanged", () => {
5+
expect(normalizeRole(Role.ADMIN)).toBe(Role.ADMIN);
6+
expect(normalizeRole(Role.KYC_OPERATOR)).toBe(Role.KYC_OPERATOR);
7+
});
8+
9+
it("coerces legacy lowercase claims to the canonical UPPERCASE role", () => {
10+
expect(normalizeRole("admin")).toBe(Role.ADMIN);
11+
expect(normalizeRole("operator")).toBe(Role.OPERATOR);
12+
expect(normalizeRole("kyc_operator")).toBe(Role.KYC_OPERATOR);
13+
expect(normalizeRole("governance_operator")).toBe(Role.GOVERNANCE_OPERATOR);
14+
expect(normalizeRole("user")).toBe(Role.USER);
15+
});
16+
17+
it("ignores surrounding whitespace and mixed casing", () => {
18+
expect(normalizeRole(" Admin ")).toBe(Role.ADMIN);
19+
expect(normalizeRole("KyC_oPeRaToR")).toBe(Role.KYC_OPERATOR);
20+
});
21+
22+
it("maps unknown, empty, null and undefined values to USER (read-only default)", () => {
23+
expect(normalizeRole("superuser")).toBe(Role.USER);
24+
expect(normalizeRole("")).toBe(Role.USER);
25+
expect(normalizeRole(null)).toBe(Role.USER);
26+
expect(normalizeRole(undefined)).toBe(Role.USER);
27+
});
28+
});

src/common/guard/roles.enum.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,32 @@ export enum Role {
66
KYC_OPERATOR = "KYC_OPERATOR",
77
}
88

9+
/**
10+
* Every role value the system recognises, indexed by its canonical name.
11+
* Used by {@link normalizeRole} to coerce arbitrary input to a canonical Role.
12+
*/
13+
const ROLE_VALUES: Role[] = Object.values(Role);
14+
15+
/**
16+
* Coerce an arbitrary role value into a canonical {@link Role}.
17+
*
18+
* This is the single point of backwards compatibility for RBAC. Historically
19+
* roles were persisted and signed into JWTs in lowercase (e.g. "admin",
20+
* "kyc_operator"); the canonical form is now UPPERCASE. This function accepts
21+
* either casing (and surrounding whitespace) and maps it to the canonical enum.
22+
*
23+
* Unknown, empty, or missing values map to {@link Role.USER} — the least
24+
* privileged role — so tokens minted before roles existed default to
25+
* read-only access rather than being rejected or over-privileged.
26+
*/
27+
export function normalizeRole(value?: string | null): Role {
28+
const normalized = String(value ?? "")
29+
.trim()
30+
.toUpperCase();
31+
32+
return ROLE_VALUES.find((role) => role === normalized) ?? Role.USER;
33+
}
34+
935
/**
1036
* Linear privilege hierarchy for standard roles.
1137
* GOVERNANCE_OPERATOR and KYC_OPERATOR are intentionally excluded from this

src/common/guard/roles.guard.spec.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,26 @@ describe("RolesGuard", () => {
147147
});
148148
});
149149

150+
describe("legacy lowercase claim compatibility", () => {
151+
it("authorizes a legacy lowercase 'admin' claim against @Roles(Role.ADMIN)", () => {
152+
jest.spyOn(reflector, "getAllAndOverride").mockReturnValue([Role.ADMIN]);
153+
const context = createMockContext({ address: "0x123", role: "admin" });
154+
expect(guard.canActivate(context)).toBe(true);
155+
});
156+
157+
it("authorizes a legacy lowercase 'operator' claim via the hierarchy", () => {
158+
jest.spyOn(reflector, "getAllAndOverride").mockReturnValue([Role.USER]);
159+
const context = createMockContext({ address: "0x123", role: "operator" });
160+
expect(guard.canActivate(context)).toBe(true);
161+
});
162+
163+
it("treats an unknown/missing claim as read-only USER and denies admin routes", () => {
164+
jest.spyOn(reflector, "getAllAndOverride").mockReturnValue([Role.ADMIN]);
165+
const context = createMockContext({ address: "0x123", role: "banana" });
166+
expect(() => guard.canActivate(context)).toThrow(ForbiddenException);
167+
});
168+
});
169+
150170
describe("reflector integration", () => {
151171
it("should check both handler and class for roles metadata", () => {
152172
const spy = jest

src/common/guard/roles.guard.ts

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,14 +8,14 @@ import {
88
} from "@nestjs/common";
99
import { Reflector } from "@nestjs/core";
1010
import { ROLES_KEY } from "./roles.decorator";
11-
import { Role, hasRole } from "./roles.enum";
11+
import { Role, hasRole, normalizeRole } from "./roles.enum";
1212

1313
type AuthenticatedRequest = {
1414
user?: {
1515
id?: string;
1616
address?: string;
17-
role?: Role;
18-
roles?: Role[];
17+
role?: string;
18+
roles?: string[];
1919
};
2020
};
2121

@@ -50,8 +50,11 @@ export class RolesGuard implements CanActivate {
5050
throw new UnauthorizedException("No authenticated user found on request");
5151
}
5252

53-
// Normalise: support both `role` (single) and `roles` (array) shapes
54-
const userRoles: Role[] = user.roles ?? (user.role ? [user.role] : []);
53+
// Normalise: support both `role` (single) and `roles` (array) shapes, and
54+
// coerce legacy lowercase claims (e.g. "admin") minted before RBAC
55+
// canonicalisation into the canonical UPPERCASE Role.
56+
const rawRoles: string[] = user.roles ?? (user.role ? [user.role] : []);
57+
const userRoles: Role[] = rawRoles.map((r) => normalizeRole(r));
5558

5659
if (userRoles.length === 0) {
5760
this.logger.warn(`User ${user.id ?? user.address} has no roles assigned`);

src/config/env.validation.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,17 @@ export class EnvironmentVariables {
3939
@IsNotEmpty()
4040
JWT_SECRET: string;
4141

42+
// RBAC bootstrap: promote the account matching one of these to ADMIN on
43+
// startup so a fresh deployment has an initial administrator. Optional —
44+
// when both are unset no seeding occurs. See docs/RBAC.md.
45+
@IsOptional()
46+
@IsString()
47+
ADMIN_BOOTSTRAP_EMAIL?: string;
48+
49+
@IsOptional()
50+
@IsString()
51+
ADMIN_BOOTSTRAP_WALLET?: string;
52+
4253
@IsString()
4354
@IsNotEmpty()
4455
JWT_EXPIRATION: string = "24h";

src/core/auth/enhanced-auth.service.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import * as qrcode from "qrcode";
1919
import { EmailService } from "./email.service";
2020
import { User } from "src/core/user/entities/user.entity";
2121
import { resolveRateLimitTierFromRole } from "src/config/quota.config";
22+
import { normalizeRole } from "src/common/guard/roles.enum";
2223
import {
2324
RefreshToken,
2425
TwoFactorAuth,
@@ -124,7 +125,7 @@ export class EnhancedAuthService {
124125
id: user.id,
125126
email: user.email,
126127
username: user.username,
127-
role: user.role,
128+
role: normalizeRole(user.role),
128129
tier: resolveRateLimitTierFromRole(user.role),
129130
kycStatus: user.kycStatus,
130131
},
@@ -188,7 +189,7 @@ export class EnhancedAuthService {
188189
id: user.id,
189190
email: user.email,
190191
username: user.username,
191-
role: user.role,
192+
role: normalizeRole(user.role),
192193
tier: resolveRateLimitTierFromRole(user.role),
193194
kycStatus: user.kycStatus,
194195
},
@@ -556,7 +557,7 @@ export class EnhancedAuthService {
556557
sub: user.id,
557558
email: user.email,
558559
username: user.username,
559-
role: user.role,
560+
role: normalizeRole(user.role),
560561
tier: resolveRateLimitTierFromRole(user.role),
561562
twoFactorVerified,
562563
};

src/core/auth/jwt.strategy.ts

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { ConfigService } from "@nestjs/config";
44
import { ExtractJwt, Strategy } from "passport-jwt";
55
import { AuthPayload } from "./wallet-auth.service";
66
import { TokenBlacklistService } from "./token-blacklist.service";
7+
import { normalizeRole } from "src/common/guard/roles.enum";
78

89
interface JwtPayload {
910
sub?: string; // User ID for traditional auth
@@ -62,14 +63,20 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
6263
);
6364
}
6465

66+
// Coerce the (possibly legacy lowercase or missing) role claim into the
67+
// canonical UPPERCASE Role so downstream guards compare consistently.
68+
// Missing/unknown claims map to Role.USER (least privilege).
69+
const role = normalizeRole(payload.role);
70+
6571
// Return user object compatible with both auth types
6672
if (isTraditionalAuth) {
6773
return {
6874
id: payload.sub,
6975
sub: payload.sub,
7076
email: payload.email,
7177
username: payload.username,
72-
role: payload.role || "user",
78+
role,
79+
roles: [role],
7380
tier: payload.tier,
7481
jti: payload.jti,
7582
twoFactorVerified: payload.twoFactorVerified ?? false,
@@ -80,9 +87,9 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
8087
return {
8188
address: payload.address,
8289
email: payload.email,
83-
role: payload.role || "user",
90+
role,
8491
tier: payload.tier,
85-
roles: payload.role ? [payload.role] : ["user"],
92+
roles: [role],
8693
jti: payload.jti,
8794
twoFactorVerified: payload.twoFactorVerified ?? false,
8895
exp: payload.exp,

0 commit comments

Comments
 (0)