Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,14 @@ DATABASE_URL=postgresql://user:password@localhost:5432/alianStructure.db
JWT_SECRET=your_jwt_secret_key_here
JWT_EXPIRATION=24h

# RBAC bootstrap admin (optional)
# On startup, the account matching ONE of these is promoted to the ADMIN role
# (idempotent — no-op if already admin or if both are unset). Use to seed the
# first admin without a manual DB edit. Prefer the wallet address for wallet
# accounts, or the email for email/password accounts.
ADMIN_BOOTSTRAP_EMAIL=
ADMIN_BOOTSTRAP_WALLET=

# AI Services
OPENAI_API_KEY=your_openai_key
GROK_API_KEY=your_grok_key
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,7 @@ See [SECURITY.md](SECURITY.md) for vulnerability reporting details.
### Security Documentation
- 🔐 [SECURITY.md](SECURITY.md) - Vulnerability reporting policy
- 📋 [SECURITY_AUDIT.md](SECURITY_AUDIT.md) - Pre-production checklist & threat model
- 🛡️ [docs/RBAC.md](docs/RBAC.md) - Role-based access control: roles, guard, token-claim mapping & admin setup

## API Endpoints

Expand Down
137 changes: 137 additions & 0 deletions docs/RBAC.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
# Role-Based Access Control (RBAC)

This service enforces role-based access control on every route through a global
`RolesGuard`. Roles are carried in the JWT and checked against the roles a route
declares with the `@Roles(...)` decorator.

## Roles

The canonical role set lives in [`src/common/guard/roles.enum.ts`](../src/common/guard/roles.enum.ts).
All role values are **UPPERCASE** strings:

| Role | Purpose |
| --------------------- | -------------------------------------------------------------- |
| `USER` | Default, least-privileged role. Read-only baseline access. |
| `OPERATOR` | Elevated operational access (supersedes `USER`). |
| `ADMIN` | Full administrative access. Supersedes every other role. |
| `GOVERNANCE_OPERATOR` | Specialised governance role. Exact-match only (no hierarchy). |
| `KYC_OPERATOR` | Specialised KYC/compliance role. Exact-match only. |

### Hierarchy

`USER → OPERATOR → ADMIN` is a linear hierarchy: a higher role satisfies any
requirement for a lower one. `ADMIN` supersedes everything.

`GOVERNANCE_OPERATOR` and `KYC_OPERATOR` sit **outside** the linear hierarchy —
they require an exact match and do not inherit from or grant each other. Only
`ADMIN` supersedes them. See `hasRole()` in the enum file for the exact logic.

### Conflicting roles

`ADMIN` and `KYC_OPERATOR` are treated as mutually exclusive for separation of
duties: the account that administers the platform must not also sign off on KYC
reviews. `UserService.assignRole` rejects an assignment that would create the
conflicting pair with a `400 Bad Request`. Conflicting pairs are defined in
`CONFLICTING_ROLE_PAIRS` (`user.service.ts`).

## Enforcing roles on a route

Decorate the handler (or controller) with `@Roles(...)` and ensure the request
is authenticated. `RolesGuard` is registered globally in `AppModule`, so you do
not need to add it per-route — but the route must run behind an auth guard that
populates `request.user` (e.g. the global `StrategyAuthGuard`, or an explicit
`@UseGuards(JwtAuthGuard)`).

```ts
import { Roles } from "src/common/guard/roles.decorator";
import { Role } from "src/common/guard/roles.enum";

@Roles(Role.ADMIN)
@Get("admin/dashboard")
getAdminDashboard() { ... }
```

- A route with **no** `@Roles` decorator is not role-restricted.
- A route with `@Roles(Role.ADMIN)` returns **403** for any non-admin principal.
- A missing/unauthenticated principal returns **401**.

`@RequireRole(...)` is an alias of `@Roles(...)` kept for readability.

## How token claims map to roles

Roles are persisted as a single `role` column on the `users` table and signed
into the JWT `role` claim at login/registration. On each request:

1. The auth strategy validates the JWT and puts the principal on `request.user`.
2. `RolesGuard` reads `user.roles` (array) or `user.role` (single) and coerces
each value to a canonical `Role` via `normalizeRole()`.
3. The normalized roles are compared against the route's required roles using
the hierarchy rules above.

### Backwards compatibility

`normalizeRole()` is the single point of backwards compatibility:

- Legacy **lowercase** claims minted before canonicalisation (e.g. `"admin"`,
`"kyc_operator"`) are accepted and mapped to their UPPERCASE canonical form.
- **Unknown, empty, or missing** role claims map to `USER` — the least
privileged role — so tokens issued before roles existed default to read-only
access rather than being rejected or over-privileged.

The same coercion is applied when reading the `role` column off the `User`
entity (via a TypeORM column transformer), so existing lowercase database rows
continue to work without a data migration.

## Admin role-management endpoints

Exposed by [`AdminRoleController`](../src/core/user/admin-role.controller.ts)
under `/admin`. Every route requires an authenticated `ADMIN` whose session is
2FA-verified (`JwtAuthGuard` + `RolesGuard` + `AdminTwoFactorGuard`).

| Method & path | Description |
| ------------------------- | ------------------------------------------------------- |
| `GET /admin/roles` | List the assignable canonical roles. |
| `GET /admin/users/:id/role` | Get a user's current role. |
| `PATCH /admin/users/:id/role` | Assign a role (`{ "role": "OPERATOR" }`). 400 on conflict, 404 if the user is missing. |
| `DELETE /admin/users/:id/role`| Reset the user to the least-privileged `USER` role. |

The assign payload is validated by `AssignRoleDto` (`@IsEnum(Role)`), so any
value outside the canonical role set is rejected with `400` before it reaches
the service — this prevents privilege escalation via malformed input.

## Bootstrapping the first admin

Because every role-management route already requires `ADMIN`, a fresh deployment
needs a way to mint the first admin. `RoleSeederService` handles this on
application start, idempotently and non-destructively.

Set **one** of the following environment variables to an **existing** account:

```bash
ADMIN_BOOTSTRAP_EMAIL=admin@example.com
# or
ADMIN_BOOTSTRAP_WALLET=0xabc...
```

On boot the seeder:

- does nothing if neither variable is set;
- logs a warning and does nothing if no matching user exists (it never creates
phantom accounts or invents credentials — register the account first, then
restart);
- does nothing if the user is already `ADMIN`;
- otherwise promotes the matching user to `ADMIN`.

> The application runs on TypeORM `synchronize: true`, and role data is a single
> column on the `users` table, so there is no separate roles migration. If
> migration infrastructure is added later, this promotion can move into a data
> migration unchanged.

## Testing

- `src/common/guard/normalize-role.spec.ts` — claim coercion and defaults.
- `src/common/guard/roles.guard.spec.ts` — guard behaviour, including legacy
lowercase-claim compatibility.
- `src/core/user/admin-role.controller.spec.ts` — admin endpoint behaviour.
- `src/core/user/user-role-separation.spec.ts` — conflicting-role enforcement.
- `src/core/user/role-seeder.service.spec.ts` — idempotent bootstrap promotion.
28 changes: 28 additions & 0 deletions src/common/guard/normalize-role.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { Role, normalizeRole } from "./roles.enum";

describe("normalizeRole", () => {
it("returns canonical roles unchanged", () => {
expect(normalizeRole(Role.ADMIN)).toBe(Role.ADMIN);
expect(normalizeRole(Role.KYC_OPERATOR)).toBe(Role.KYC_OPERATOR);
});

it("coerces legacy lowercase claims to the canonical UPPERCASE role", () => {
expect(normalizeRole("admin")).toBe(Role.ADMIN);
expect(normalizeRole("operator")).toBe(Role.OPERATOR);
expect(normalizeRole("kyc_operator")).toBe(Role.KYC_OPERATOR);
expect(normalizeRole("governance_operator")).toBe(Role.GOVERNANCE_OPERATOR);
expect(normalizeRole("user")).toBe(Role.USER);
});

it("ignores surrounding whitespace and mixed casing", () => {
expect(normalizeRole(" Admin ")).toBe(Role.ADMIN);
expect(normalizeRole("KyC_oPeRaToR")).toBe(Role.KYC_OPERATOR);
});

it("maps unknown, empty, null and undefined values to USER (read-only default)", () => {
expect(normalizeRole("superuser")).toBe(Role.USER);
expect(normalizeRole("")).toBe(Role.USER);
expect(normalizeRole(null)).toBe(Role.USER);
expect(normalizeRole(undefined)).toBe(Role.USER);
});
});
26 changes: 26 additions & 0 deletions src/common/guard/roles.enum.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,32 @@ export enum Role {
KYC_OPERATOR = "KYC_OPERATOR",
}

/**
* Every role value the system recognises, indexed by its canonical name.
* Used by {@link normalizeRole} to coerce arbitrary input to a canonical Role.
*/
const ROLE_VALUES: Role[] = Object.values(Role);

/**
* Coerce an arbitrary role value into a canonical {@link Role}.
*
* This is the single point of backwards compatibility for RBAC. Historically
* roles were persisted and signed into JWTs in lowercase (e.g. "admin",
* "kyc_operator"); the canonical form is now UPPERCASE. This function accepts
* either casing (and surrounding whitespace) and maps it to the canonical enum.
*
* Unknown, empty, or missing values map to {@link Role.USER} — the least
* privileged role — so tokens minted before roles existed default to
* read-only access rather than being rejected or over-privileged.
*/
export function normalizeRole(value?: string | null): Role {
const normalized = String(value ?? "")
.trim()
.toUpperCase();

return ROLE_VALUES.find((role) => role === normalized) ?? Role.USER;
}

/**
* Linear privilege hierarchy for standard roles.
* GOVERNANCE_OPERATOR and KYC_OPERATOR are intentionally excluded from this
Expand Down
20 changes: 20 additions & 0 deletions src/common/guard/roles.guard.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,26 @@ describe("RolesGuard", () => {
});
});

describe("legacy lowercase claim compatibility", () => {
it("authorizes a legacy lowercase 'admin' claim against @Roles(Role.ADMIN)", () => {
jest.spyOn(reflector, "getAllAndOverride").mockReturnValue([Role.ADMIN]);
const context = createMockContext({ address: "0x123", role: "admin" });
expect(guard.canActivate(context)).toBe(true);
});

it("authorizes a legacy lowercase 'operator' claim via the hierarchy", () => {
jest.spyOn(reflector, "getAllAndOverride").mockReturnValue([Role.USER]);
const context = createMockContext({ address: "0x123", role: "operator" });
expect(guard.canActivate(context)).toBe(true);
});

it("treats an unknown/missing claim as read-only USER and denies admin routes", () => {
jest.spyOn(reflector, "getAllAndOverride").mockReturnValue([Role.ADMIN]);
const context = createMockContext({ address: "0x123", role: "banana" });
expect(() => guard.canActivate(context)).toThrow(ForbiddenException);
});
});

describe("reflector integration", () => {
it("should check both handler and class for roles metadata", () => {
const spy = jest
Expand Down
13 changes: 8 additions & 5 deletions src/common/guard/roles.guard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,14 @@ import {
} from "@nestjs/common";
import { Reflector } from "@nestjs/core";
import { ROLES_KEY } from "./roles.decorator";
import { Role, hasRole } from "./roles.enum";
import { Role, hasRole, normalizeRole } from "./roles.enum";

type AuthenticatedRequest = {
user?: {
id?: string;
address?: string;
role?: Role;
roles?: Role[];
role?: string;
roles?: string[];
};
};

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

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

if (userRoles.length === 0) {
this.logger.warn(`User ${user.id ?? user.address} has no roles assigned`);
Expand Down
11 changes: 11 additions & 0 deletions src/config/env.validation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,17 @@ export class EnvironmentVariables {
@IsNotEmpty()
JWT_SECRET: string;

// RBAC bootstrap: promote the account matching one of these to ADMIN on
// startup so a fresh deployment has an initial administrator. Optional —
// when both are unset no seeding occurs. See docs/RBAC.md.
@IsOptional()
@IsString()
ADMIN_BOOTSTRAP_EMAIL?: string;

@IsOptional()
@IsString()
ADMIN_BOOTSTRAP_WALLET?: string;

@IsString()
@IsNotEmpty()
JWT_EXPIRATION: string = "24h";
Expand Down
7 changes: 4 additions & 3 deletions src/core/auth/enhanced-auth.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import * as qrcode from "qrcode";
import { EmailService } from "./email.service";
import { User } from "src/core/user/entities/user.entity";
import { resolveRateLimitTierFromRole } from "src/config/quota.config";
import { normalizeRole } from "src/common/guard/roles.enum";
import {
RefreshToken,
TwoFactorAuth,
Expand Down Expand Up @@ -124,7 +125,7 @@ export class EnhancedAuthService {
id: user.id,
email: user.email,
username: user.username,
role: user.role,
role: normalizeRole(user.role),
tier: resolveRateLimitTierFromRole(user.role),
kycStatus: user.kycStatus,
},
Expand Down Expand Up @@ -188,7 +189,7 @@ export class EnhancedAuthService {
id: user.id,
email: user.email,
username: user.username,
role: user.role,
role: normalizeRole(user.role),
tier: resolveRateLimitTierFromRole(user.role),
kycStatus: user.kycStatus,
},
Expand Down Expand Up @@ -556,7 +557,7 @@ export class EnhancedAuthService {
sub: user.id,
email: user.email,
username: user.username,
role: user.role,
role: normalizeRole(user.role),
tier: resolveRateLimitTierFromRole(user.role),
twoFactorVerified,
};
Expand Down
13 changes: 10 additions & 3 deletions src/core/auth/jwt.strategy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { ConfigService } from "@nestjs/config";
import { ExtractJwt, Strategy } from "passport-jwt";
import { AuthPayload } from "./wallet-auth.service";
import { TokenBlacklistService } from "./token-blacklist.service";
import { normalizeRole } from "src/common/guard/roles.enum";

interface JwtPayload {
sub?: string; // User ID for traditional auth
Expand Down Expand Up @@ -62,14 +63,20 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
);
}

// Coerce the (possibly legacy lowercase or missing) role claim into the
// canonical UPPERCASE Role so downstream guards compare consistently.
// Missing/unknown claims map to Role.USER (least privilege).
const role = normalizeRole(payload.role);

// Return user object compatible with both auth types
if (isTraditionalAuth) {
return {
id: payload.sub,
sub: payload.sub,
email: payload.email,
username: payload.username,
role: payload.role || "user",
role,
roles: [role],
tier: payload.tier,
jti: payload.jti,
twoFactorVerified: payload.twoFactorVerified ?? false,
Expand All @@ -80,9 +87,9 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
return {
address: payload.address,
email: payload.email,
role: payload.role || "user",
role,
tier: payload.tier,
roles: payload.role ? [payload.role] : ["user"],
roles: [role],
jti: payload.jti,
twoFactorVerified: payload.twoFactorVerified ?? false,
exp: payload.exp,
Expand Down
Loading
Loading