diff --git a/.env.example b/.env.example index 28eda4c..26654db 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/README.md b/README.md index b4253c9..b453768 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/docs/RBAC.md b/docs/RBAC.md new file mode 100644 index 0000000..d3dee84 --- /dev/null +++ b/docs/RBAC.md @@ -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. diff --git a/src/common/guard/normalize-role.spec.ts b/src/common/guard/normalize-role.spec.ts new file mode 100644 index 0000000..457052d --- /dev/null +++ b/src/common/guard/normalize-role.spec.ts @@ -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); + }); +}); diff --git a/src/common/guard/roles.enum.ts b/src/common/guard/roles.enum.ts index 744be09..b986725 100644 --- a/src/common/guard/roles.enum.ts +++ b/src/common/guard/roles.enum.ts @@ -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 diff --git a/src/common/guard/roles.guard.spec.ts b/src/common/guard/roles.guard.spec.ts index 22e1cfe..1149091 100644 --- a/src/common/guard/roles.guard.spec.ts +++ b/src/common/guard/roles.guard.spec.ts @@ -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 diff --git a/src/common/guard/roles.guard.ts b/src/common/guard/roles.guard.ts index c8dd728..6256578 100644 --- a/src/common/guard/roles.guard.ts +++ b/src/common/guard/roles.guard.ts @@ -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[]; }; }; @@ -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`); diff --git a/src/config/env.validation.ts b/src/config/env.validation.ts index 0597122..d9f390e 100644 --- a/src/config/env.validation.ts +++ b/src/config/env.validation.ts @@ -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"; diff --git a/src/core/auth/enhanced-auth.service.ts b/src/core/auth/enhanced-auth.service.ts index bdf5faf..447a4c2 100644 --- a/src/core/auth/enhanced-auth.service.ts +++ b/src/core/auth/enhanced-auth.service.ts @@ -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, @@ -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, }, @@ -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, }, @@ -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, }; diff --git a/src/core/auth/jwt.strategy.ts b/src/core/auth/jwt.strategy.ts index 2d3a721..d3e4f21 100644 --- a/src/core/auth/jwt.strategy.ts +++ b/src/core/auth/jwt.strategy.ts @@ -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 @@ -62,6 +63,11 @@ 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 { @@ -69,7 +75,8 @@ export class JwtStrategy extends PassportStrategy(Strategy) { 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, @@ -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, diff --git a/src/core/auth/wallet-auth.service.ts b/src/core/auth/wallet-auth.service.ts index 9176bf8..13997e5 100644 --- a/src/core/auth/wallet-auth.service.ts +++ b/src/core/auth/wallet-auth.service.ts @@ -23,6 +23,7 @@ import { ProvenanceStatus, } from "src/infrastructure/audit/entities/provenance-record.entity"; import { resolveRateLimitTierFromRole } from "src/config/quota.config"; +import { normalizeRole } from "src/common/guard/roles.enum"; export interface AuthPayload { address: string; @@ -125,7 +126,7 @@ export class WalletAuthService { const payload: AuthPayload = { address: normalized, email: user?.emailVerified ? user.email : undefined, - role: user?.role || "user", + role: normalizeRole(user?.role), tier: resolveRateLimitTierFromRole(user?.role), iat: Math.floor(Date.now() / 1000), }; @@ -202,7 +203,7 @@ export class WalletAuthService { const payload: AuthPayload = { address: address.toLowerCase(), email: user?.emailVerified ? user.email : undefined, - role: user?.role || "user", + role: normalizeRole(user?.role), tier: resolveRateLimitTierFromRole(user?.role), twoFactorVerified, iat: Math.floor(Date.now() / 1000), diff --git a/src/core/user/admin-role.controller.spec.ts b/src/core/user/admin-role.controller.spec.ts new file mode 100644 index 0000000..f821a8a --- /dev/null +++ b/src/core/user/admin-role.controller.spec.ts @@ -0,0 +1,99 @@ +import { Test, TestingModule } from "@nestjs/testing"; +import { BadRequestException, NotFoundException } from "@nestjs/common"; +import { AdminRoleController } from "./admin-role.controller"; +import { UserService } from "./user.service"; +import { User } from "./entities/user.entity"; +import { Role } from "src/common/guard/roles.enum"; +import { RolesGuard } from "src/common/guard/roles.guard"; +import { JwtAuthGuard } from "src/core/auth/guards/jwt-auth.guard"; +import { AdminTwoFactorGuard } from "src/core/auth/guards/admin-two-factor.guard"; + +describe("AdminRoleController", () => { + let controller: AdminRoleController; + let userService: jest.Mocked>; + + const makeUser = (role: Role): User => + ({ id: "user-1", role }) as unknown as User; + + beforeEach(async () => { + userService = { + assignRole: jest.fn(), + findOneOrFail: jest.fn(), + }; + + const module: TestingModule = await Test.createTestingModule({ + controllers: [AdminRoleController], + providers: [{ provide: UserService, useValue: userService }], + }) + // These are unit tests for the controller's logic; the guard behaviour + // is covered by their own specs, so override them with pass-throughs + // to avoid resolving their (auth-module) dependencies here. + .overrideGuard(JwtAuthGuard) + .useValue({ canActivate: () => true }) + .overrideGuard(RolesGuard) + .useValue({ canActivate: () => true }) + .overrideGuard(AdminTwoFactorGuard) + .useValue({ canActivate: () => true }) + .compile(); + + controller = module.get(AdminRoleController); + }); + + describe("listRoles", () => { + it("returns every canonical role", () => { + expect(controller.listRoles()).toEqual({ roles: Object.values(Role) }); + }); + }); + + describe("getUserRole", () => { + it("returns the target user's current role", async () => { + userService.findOneOrFail.mockResolvedValue(makeUser(Role.OPERATOR)); + await expect(controller.getUserRole("user-1")).resolves.toEqual({ + id: "user-1", + role: Role.OPERATOR, + }); + }); + + it("propagates NotFoundException for an unknown user", async () => { + userService.findOneOrFail.mockRejectedValue(new NotFoundException()); + await expect(controller.getUserRole("nope")).rejects.toThrow( + NotFoundException, + ); + }); + }); + + describe("assignRole", () => { + it("assigns the requested role and echoes the result", async () => { + userService.assignRole.mockResolvedValue(makeUser(Role.ADMIN)); + await expect( + controller.assignRole("user-1", { role: Role.ADMIN }), + ).resolves.toEqual({ id: "user-1", role: Role.ADMIN }); + expect(userService.assignRole).toHaveBeenCalledWith("user-1", Role.ADMIN); + }); + + it("propagates BadRequestException for a conflicting role", async () => { + userService.assignRole.mockRejectedValue(new BadRequestException()); + await expect( + controller.assignRole("user-1", { role: Role.KYC_OPERATOR }), + ).rejects.toThrow(BadRequestException); + }); + + it("propagates NotFoundException for an unknown user", async () => { + userService.assignRole.mockRejectedValue(new NotFoundException()); + await expect( + controller.assignRole("nope", { role: Role.ADMIN }), + ).rejects.toThrow(NotFoundException); + }); + }); + + describe("resetRole", () => { + it("resets the target user to the least-privileged USER role", async () => { + userService.assignRole.mockResolvedValue(makeUser(Role.USER)); + await expect(controller.resetRole("user-1")).resolves.toEqual({ + id: "user-1", + role: Role.USER, + }); + expect(userService.assignRole).toHaveBeenCalledWith("user-1", Role.USER); + }); + }); +}); diff --git a/src/core/user/admin-role.controller.ts b/src/core/user/admin-role.controller.ts new file mode 100644 index 0000000..bbee0dc --- /dev/null +++ b/src/core/user/admin-role.controller.ts @@ -0,0 +1,93 @@ +import { + Controller, + Get, + Patch, + Delete, + Param, + Body, + UseGuards, +} from "@nestjs/common"; +import { + ApiTags, + ApiOperation, + ApiResponse, + ApiBearerAuth, +} from "@nestjs/swagger"; +import { UserService } from "./user.service"; +import { AssignRoleDto } from "./dto/assign-role.dto"; +import { Role } from "src/common/guard/roles.enum"; +import { Roles } from "src/common/guard/roles.decorator"; +import { RolesGuard } from "src/common/guard/roles.guard"; +import { JwtAuthGuard } from "src/core/auth/guards/jwt-auth.guard"; +import { AdminTwoFactorGuard } from "src/core/auth/guards/admin-two-factor.guard"; + +/** + * AdminRoleController — admin-only role management. + * + * Every route requires an authenticated admin whose session is 2FA-verified: + * - {@link JwtAuthGuard} populates `request.user` from the bearer token. + * - {@link RolesGuard} enforces `@Roles(Role.ADMIN)`. + * - {@link AdminTwoFactorGuard} enforces mandatory 2FA for admins. + * + * Role assignment is delegated to {@link UserService.assignRole}, which + * validates the target user exists and rejects conflicting role pairs. + */ +@ApiTags("Admin — Role Management") +@ApiBearerAuth() +@UseGuards(JwtAuthGuard, RolesGuard, AdminTwoFactorGuard) +@Roles(Role.ADMIN) +@Controller("admin") +export class AdminRoleController { + constructor(private readonly userService: UserService) {} + + @Get("roles") + @ApiOperation({ + summary: "List assignable roles", + description: "Returns the canonical set of roles that can be assigned.", + }) + @ApiResponse({ status: 200, description: "List of assignable roles" }) + listRoles(): { roles: Role[] } { + return { roles: Object.values(Role) }; + } + + @Get("users/:id/role") + @ApiOperation({ summary: "Get a user's current role" }) + @ApiResponse({ status: 200, description: "The user's current role" }) + @ApiResponse({ status: 404, description: "User not found" }) + async getUserRole(@Param("id") id: string): Promise<{ id: string; role: Role }> { + const user = await this.userService.findOneOrFail(id); + return { id: user.id, role: user.role }; + } + + @Patch("users/:id/role") + @ApiOperation({ + summary: "Assign a role to a user", + description: + "Assigns the given canonical role. Rejects conflicting role pairs " + + "(e.g. GOVERNANCE_OPERATOR + KYC_OPERATOR) with 400.", + }) + @ApiResponse({ status: 200, description: "Role assigned" }) + @ApiResponse({ status: 400, description: "Invalid or conflicting role" }) + @ApiResponse({ status: 404, description: "User not found" }) + async assignRole( + @Param("id") id: string, + @Body() dto: AssignRoleDto, + ): Promise<{ id: string; role: Role }> { + const user = await this.userService.assignRole(id, dto.role); + return { id: user.id, role: user.role }; + } + + @Delete("users/:id/role") + @ApiOperation({ + summary: "Reset a user's role", + description: "Resets the user to the least-privileged USER role.", + }) + @ApiResponse({ status: 200, description: "Role reset to USER" }) + @ApiResponse({ status: 404, description: "User not found" }) + async resetRole( + @Param("id") id: string, + ): Promise<{ id: string; role: Role }> { + const user = await this.userService.assignRole(id, Role.USER); + return { id: user.id, role: user.role }; + } +} diff --git a/src/core/user/dto/assign-role.dto.ts b/src/core/user/dto/assign-role.dto.ts new file mode 100644 index 0000000..fb7a3fa --- /dev/null +++ b/src/core/user/dto/assign-role.dto.ts @@ -0,0 +1,21 @@ +import { ApiProperty } from "@nestjs/swagger"; +import { IsEnum } from "class-validator"; +import { Role } from "src/common/guard/roles.enum"; + +/** + * Payload for assigning a role to a user via the admin role-management API. + * + * `@IsEnum(Role)` rejects any value outside the canonical role set, closing the + * privilege-escalation vector of injecting arbitrary role strings. + */ +export class AssignRoleDto { + @ApiProperty({ + description: "The canonical role to assign to the user.", + enum: Role, + example: Role.OPERATOR, + }) + @IsEnum(Role, { + message: `role must be one of: ${Object.values(Role).join(", ")}`, + }) + role: Role; +} diff --git a/src/core/user/entities/user.entity.ts b/src/core/user/entities/user.entity.ts index d09e4d1..0c28486 100644 --- a/src/core/user/entities/user.entity.ts +++ b/src/core/user/entities/user.entity.ts @@ -12,12 +12,18 @@ import { } from "typeorm"; import { ProvenanceRecord } from "src/infrastructure/audit/entities/provenance-record.entity"; import { Wallet } from "src/core/auth/entities/wallet.entity"; - -export enum UserRole { - USER = "user", - KYC_OPERATOR = "kyc_operator", - ADMIN = "admin", -} +import { Role, normalizeRole } from "src/common/guard/roles.enum"; + +/** + * @deprecated Use the canonical {@link Role} enum from + * `src/common/guard/roles.enum` instead. `UserRole` is retained as an alias + * for backwards compatibility with existing imports; it now resolves to the + * same UPPERCASE-valued enum used by RolesGuard and the JWT claims. Legacy + * lowercase values stored in the database are coerced on read via + * {@link normalizeRole}. + */ +export const UserRole = Role; +export type UserRole = Role; export enum KycStatus { UNVERIFIED = "unverified", @@ -49,9 +55,15 @@ export class User { @Column({ type: "varchar", - default: UserRole.USER, + default: Role.USER, + // Coerce legacy lowercase values (e.g. "admin") persisted before the + // RBAC canonicalisation into the canonical UPPERCASE Role on read. + transformer: { + to: (value?: Role) => value ?? Role.USER, + from: (value?: string | null) => normalizeRole(value), + }, }) - role: UserRole; + role: Role; @Column({ type: "varchar", diff --git a/src/core/user/role-seeder.service.spec.ts b/src/core/user/role-seeder.service.spec.ts new file mode 100644 index 0000000..1dcf224 --- /dev/null +++ b/src/core/user/role-seeder.service.spec.ts @@ -0,0 +1,56 @@ +import { RoleSeederService } from "./role-seeder.service"; +import { Role } from "src/common/guard/roles.enum"; + +describe("RoleSeederService", () => { + const makeService = ( + config: Record, + repo: { + findOne: jest.Mock; + save: jest.Mock; + }, + ): RoleSeederService => + new RoleSeederService( + { get: (k: string) => config[k] } as never, + repo as never, + ); + + const makeRepo = () => ({ + findOne: jest.fn(), + save: jest.fn(async (u) => u), + }); + + it("is a no-op when no bootstrap target is configured", async () => { + const repo = makeRepo(); + const service = makeService({}, repo); + await service.seedBootstrapAdmin(); + expect(repo.findOne).not.toHaveBeenCalled(); + expect(repo.save).not.toHaveBeenCalled(); + }); + + it("does not create a user when the configured target does not exist", async () => { + const repo = makeRepo(); + repo.findOne.mockResolvedValue(null); + const service = makeService({ ADMIN_BOOTSTRAP_EMAIL: "a@b.com" }, repo); + await service.seedBootstrapAdmin(); + expect(repo.save).not.toHaveBeenCalled(); + }); + + it("promotes an existing non-admin user to ADMIN", async () => { + const repo = makeRepo(); + const user = { id: "u1", role: Role.USER }; + repo.findOne.mockResolvedValue(user); + const service = makeService({ ADMIN_BOOTSTRAP_EMAIL: "a@b.com" }, repo); + await service.seedBootstrapAdmin(); + expect(repo.save).toHaveBeenCalledWith( + expect.objectContaining({ role: Role.ADMIN }), + ); + }); + + it("is idempotent when the user is already ADMIN", async () => { + const repo = makeRepo(); + repo.findOne.mockResolvedValue({ id: "u1", role: Role.ADMIN }); + const service = makeService({ ADMIN_BOOTSTRAP_WALLET: "0xabc" }, repo); + await service.seedBootstrapAdmin(); + expect(repo.save).not.toHaveBeenCalled(); + }); +}); diff --git a/src/core/user/role-seeder.service.ts b/src/core/user/role-seeder.service.ts new file mode 100644 index 0000000..80e0a10 --- /dev/null +++ b/src/core/user/role-seeder.service.ts @@ -0,0 +1,89 @@ +import { Injectable, Logger, OnModuleInit } from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; +import { InjectRepository } from "@nestjs/typeorm"; +import { Repository } from "typeorm"; +import { User } from "./entities/user.entity"; +import { Role } from "src/common/guard/roles.enum"; + +/** + * RoleSeederService — idempotent RBAC bootstrap. + * + * On application start it ensures a single bootstrap administrator exists, so a + * freshly provisioned deployment has at least one account that can reach the + * admin role-management endpoints. Without this there would be no way to grant + * the first ADMIN role through the API (every role-management route already + * requires ADMIN). + * + * The bootstrap target is resolved from configuration: + * - ADMIN_BOOTSTRAP_EMAIL — promote the user with this email, or + * - ADMIN_BOOTSTRAP_WALLET — promote the user with this wallet address. + * + * Behaviour is fully idempotent and non-destructive: + * - No config set → no-op (nothing is seeded). + * - No matching user → logged warning, no-op (we never create phantom + * accounts or invent credentials). + * - User already ADMIN → no-op. + * - User exists, non-admin → promoted to ADMIN. + * + * There is intentionally no migration here: the app runs on TypeORM + * `synchronize: true` and role data is a single column on the users table, so a + * runtime seeder is the pragmatic seam. If migration infrastructure is added + * later, this promotion can move into a data migration unchanged. + */ +@Injectable() +export class RoleSeederService implements OnModuleInit { + private readonly logger = new Logger(RoleSeederService.name); + + constructor( + private readonly configService: ConfigService, + @InjectRepository(User) + private readonly userRepository: Repository, + ) {} + + async onModuleInit(): Promise { + await this.seedBootstrapAdmin(); + } + + /** + * Promote the configured bootstrap account to ADMIN if it exists and is not + * already an admin. Safe to run on every boot. + */ + async seedBootstrapAdmin(): Promise { + const email = this.configService + .get("ADMIN_BOOTSTRAP_EMAIL") + ?.trim(); + const wallet = this.configService + .get("ADMIN_BOOTSTRAP_WALLET") + ?.trim(); + + if (!email && !wallet) { + // No bootstrap target configured — nothing to seed. + return; + } + + const user = await this.userRepository.findOne({ + where: email + ? { email } + : { walletAddress: wallet!.toLowerCase() }, + }); + + const target = email ?? wallet; + + if (!user) { + this.logger.warn( + `Bootstrap admin target "${target}" not found; skipping. ` + + `Create the account first, then restart to promote it.`, + ); + return; + } + + if (user.role === Role.ADMIN) { + this.logger.log(`Bootstrap admin "${target}" already has ADMIN role.`); + return; + } + + user.role = Role.ADMIN; + await this.userRepository.save(user); + this.logger.log(`Promoted bootstrap admin "${target}" to ADMIN role.`); + } +} diff --git a/src/core/user/user.module.ts b/src/core/user/user.module.ts index 1bc69e3..647f92b 100644 --- a/src/core/user/user.module.ts +++ b/src/core/user/user.module.ts @@ -3,11 +3,15 @@ import { TypeOrmModule } from "@nestjs/typeorm"; import { User } from "./entities/user.entity"; import { UserService } from "./user.service"; import { UserController } from "./user.controller"; +import { AdminRoleController } from "./admin-role.controller"; +import { RoleSeederService } from "./role-seeder.service"; +import { AuthModule } from "src/core/auth/auth.module"; +import { AdminTwoFactorGuard } from "src/core/auth/guards/admin-two-factor.guard"; @Module({ - imports: [TypeOrmModule.forFeature([User])], - controllers: [UserController], - providers: [UserService], + imports: [TypeOrmModule.forFeature([User]), AuthModule], + controllers: [UserController, AdminRoleController], + providers: [UserService, RoleSeederService, AdminTwoFactorGuard], exports: [UserService], }) export class UserModule {} diff --git a/src/core/user/user.service.ts b/src/core/user/user.service.ts index fba53e3..92d4366 100644 --- a/src/core/user/user.service.ts +++ b/src/core/user/user.service.ts @@ -8,11 +8,15 @@ import { Repository } from "typeorm"; import { User } from "./entities/user.entity"; import { CreateUserDto } from "./dto/create-user.dto"; import { UpdateUserDto } from "./dto/update-user.dto"; -import { UserRole } from "./entities/user.entity"; - -/** Pairs of roles that are mutually exclusive */ -const CONFLICTING_ROLE_PAIRS: [UserRole, UserRole][] = [ - [UserRole.ADMIN, UserRole.KYC_OPERATOR], +import { Role } from "src/common/guard/roles.enum"; + +/** + * Pairs of roles that are mutually exclusive and must never be held together. + * ADMIN and KYC_OPERATOR are kept separate to preserve separation of duties: + * the account that manages the platform must not also sign off on KYC reviews. + */ +const CONFLICTING_ROLE_PAIRS: [Role, Role][] = [ + [Role.ADMIN, Role.KYC_OPERATOR], ]; @Injectable() @@ -34,6 +38,18 @@ export class UserService { return this.userRepository.findOne({ where: { id } }); } + /** + * Like {@link findOne} but throws {@link NotFoundException} when the user + * does not exist, so callers can rely on a non-null result. + */ + async findOneOrFail(id: string): Promise { + const user = await this.findOne(id); + if (!user) { + throw new NotFoundException(`User ${id} not found`); + } + return user; + } + async update(id: string, updateUserDto: UpdateUserDto) { await this.userRepository.update(id, updateUserDto); return this.findOne(id); @@ -44,15 +60,12 @@ export class UserService { } /** - * Assign a role to a user. Enforces mutual exclusion between - * GOVERNANCE_OPERATOR and KYC_OPERATOR — assigning one while the - * other is already held throws a BadRequestException. + * Assign a role to a user. Enforces the mutually-exclusive role pairs in + * {@link CONFLICTING_ROLE_PAIRS} — assigning a role that conflicts with the + * user's current role throws a BadRequestException. */ - async assignRole(userId: string, newRole: UserRole): Promise { - const user = await this.findOne(userId); - if (!user) { - throw new NotFoundException(`User ${userId} not found`); - } + async assignRole(userId: string, newRole: Role): Promise { + const user = await this.findOneOrFail(userId); this.assertNoRoleConflict(user.role, newRole); @@ -64,7 +77,7 @@ export class UserService { * Throws BadRequestException if assigning `newRole` to a user that * currently holds `currentRole` would create a conflicting pair. */ - assertNoRoleConflict(currentRole: UserRole, newRole: UserRole): void { + assertNoRoleConflict(currentRole: Role, newRole: Role): void { if (currentRole === newRole) return; const conflict = CONFLICTING_ROLE_PAIRS.some( @@ -76,7 +89,7 @@ export class UserService { if (conflict) { throw new BadRequestException( `Role conflict: a user cannot hold both "${currentRole}" and "${newRole}". ` + - `GOVERNANCE_OPERATOR and KYC_OPERATOR are mutually exclusive roles.`, + `These roles are mutually exclusive to preserve separation of duties.`, ); } } diff --git a/src/infrastructure/audit/guards/compliance-officer.guard.ts b/src/infrastructure/audit/guards/compliance-officer.guard.ts index cf1dec1..eec7154 100644 --- a/src/infrastructure/audit/guards/compliance-officer.guard.ts +++ b/src/infrastructure/audit/guards/compliance-officer.guard.ts @@ -4,21 +4,22 @@ import { ExecutionContext, ForbiddenException, } from "@nestjs/common"; -import { UserRole } from "src/core/user/entities/user.entity"; +import { Role, normalizeRole } from "src/common/guard/roles.enum"; @Injectable() export class ComplianceOfficerGuard implements CanActivate { canActivate(context: ExecutionContext): boolean { const request = context.switchToHttp().getRequest(); - const roles: string[] = request.user?.roles ?? []; const user = request.user; if (!user) { throw new ForbiddenException("No authenticated user found"); } - // assuming that KYC operators are the compliance officers and admins will also have access - if (user.role !== UserRole.ADMIN || user.role !== UserRole.KYC_OPERATOR) { + // KYC operators are the compliance officers; admins also have access. + // Coerce legacy lowercase claims to the canonical Role before comparing. + const role = normalizeRole(user.role); + if (role !== Role.ADMIN && role !== Role.KYC_OPERATOR) { throw new ForbiddenException( "Access to audit logs is restricted to admin and compliance officers", ); diff --git a/src/infrastructure/audit/guards/provenance-access.guard.ts b/src/infrastructure/audit/guards/provenance-access.guard.ts index ce274dd..a5f64e8 100644 --- a/src/infrastructure/audit/guards/provenance-access.guard.ts +++ b/src/infrastructure/audit/guards/provenance-access.guard.ts @@ -4,7 +4,7 @@ import { ExecutionContext, ForbiddenException, } from "@nestjs/common"; -import { UserRole } from "src/core/user/entities/user.entity"; +import { Role, normalizeRole } from "src/common/guard/roles.enum"; /** * Guard that ensures users can only access their own provenance records. @@ -21,8 +21,9 @@ export class ProvenanceAccessGuard implements CanActivate { throw new ForbiddenException("No authenticated user found"); } - // Admins can access all provenance records - if (user.role === UserRole.ADMIN) { + // Admins can access all provenance records. Coerce legacy lowercase + // claims to the canonical Role before comparing. + if (normalizeRole(user.role) === Role.ADMIN) { return true; }