diff --git a/src/controllers/AdminController.ts b/src/controllers/AdminController.ts new file mode 100644 index 0000000..a2ada4d --- /dev/null +++ b/src/controllers/AdminController.ts @@ -0,0 +1,36 @@ +import { Request, Response } from 'express'; +import { UserService } from '../services/UserService'; +import { handleError } from '../utils/helpers'; +import { HTTP_STATUS } from '../config/constants'; + +/** + * Admin-facing user & role management (Issue #100). + */ +export class AdminController { + private static userService = new UserService(); + + /** + * POST /api/admin/users/:id/role — assign a role to a user. + * + * Authorization is enforced by the route (admin-only). The request body is + * validated against {@link AssignRoleDTO} so `role` is always a valid + * {@link UserRole}. + */ + static assignRole = async (req: Request, res: Response) => { + try { + const id = Array.isArray(req.params.id) ? req.params.id[0] : req.params.id; + const { role } = req.body; + + const user = await AdminController.userService.assignRole(id, role); + + return res.status(HTTP_STATUS.OK).json({ + success: true, + message: 'Role assigned successfully', + id: user.id, + role: user.role, + }); + } catch (error) { + handleError(req, res, error); + } + }; +} diff --git a/src/controllers/__tests__/AdminController.test.ts b/src/controllers/__tests__/AdminController.test.ts new file mode 100644 index 0000000..eaac384 --- /dev/null +++ b/src/controllers/__tests__/AdminController.test.ts @@ -0,0 +1,54 @@ +import { AdminController } from '../AdminController'; +import { + createMockRequest, + createMockResponse, + assertSuccessResponse, + assertErrorResponse, +} from '../../../tests/helpers'; +import { UserRole } from '../../entities/User'; + +// handleError -> logRequestError imports the live Redis client, which opens a +// socket that keeps the process alive. Mock it so the suite exits cleanly. +jest.mock('../../config/redis', () => ({ + __esModule: true, + default: { incr: jest.fn(), expire: jest.fn(), get: jest.fn(), set: jest.fn(), del: jest.fn() }, +})); + +// Factory mock so the controller never loads the real UserService (which +// pulls in the DB data source at import time). AdminController instantiates +// UserService at module load, so the factory runs during import — reference +// mockAssignRole lazily through a wrapper to sidestep the const TDZ. +const mockAssignRole = jest.fn(); +jest.mock('../../services/UserService', () => ({ + UserService: jest.fn().mockImplementation(() => ({ + assignRole: (...args: unknown[]) => mockAssignRole(...args), + })), +})); + +describe('AdminController.assignRole (#100)', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('returns 200 with the updated id and role', async () => { + mockAssignRole.mockResolvedValue({ id: 'u1', role: UserRole.MODERATOR }); + const req = createMockRequest({ params: { id: 'u1' }, body: { role: UserRole.MODERATOR } }); + const res = createMockResponse(); + + await AdminController.assignRole(req, res as any); + + expect(mockAssignRole).toHaveBeenCalledWith('u1', UserRole.MODERATOR); + assertSuccessResponse(res, { status: 200, data: { id: 'u1', role: UserRole.MODERATOR } }); + }); + + it('surfaces a not-found service error as an error response', async () => { + const { AppError } = jest.requireActual('../../errors/AppError'); + mockAssignRole.mockRejectedValue(AppError.notFound('User not found')); + const req = createMockRequest({ params: { id: 'missing' }, body: { role: UserRole.ADMIN } }); + const res = createMockResponse(); + + await AdminController.assignRole(req, res as any); + + assertErrorResponse(res, { status: 404, message: 'User not found' }); + }); +}); diff --git a/src/dtos/AssignRoleDTO.ts b/src/dtos/AssignRoleDTO.ts new file mode 100644 index 0000000..7776695 --- /dev/null +++ b/src/dtos/AssignRoleDTO.ts @@ -0,0 +1,8 @@ +import { IsEnum } from 'class-validator'; +import { UserRole } from '../entities/User'; + +/** Body for `POST /api/admin/users/:id/role` — assigns a role to a user (#100). */ +export class AssignRoleDTO { + @IsEnum(UserRole) + role!: UserRole; +} diff --git a/src/entities/User.ts b/src/entities/User.ts index 8af504a..1807775 100644 --- a/src/entities/User.ts +++ b/src/entities/User.ts @@ -6,19 +6,22 @@ import { CreateDateColumn, UpdateDateColumn, Unique, - OneToOne, OneToMany, } from 'typeorm'; import { Song } from './Song'; -import { Transaction } from 'ethers'; import { TransactionLog } from './TransactionLog'; import { Album } from './Album'; import { RoyaltyPayout } from './RoyaltyPayout'; export enum UserRole { + // `listener` is the default role for a regular platform user. LISTENER = 'listener', ARTIST = 'artist', + // `moderator` can act on content (flag/unflag) without full admin rights. + MODERATOR = 'moderator', ADMIN = 'admin', + // `super_admin` holds every permission, including managing other admins. + SUPER_ADMIN = 'super_admin', } @Entity('users') diff --git a/src/middlewares/__tests__/requirePermission.test.ts b/src/middlewares/__tests__/requirePermission.test.ts new file mode 100644 index 0000000..7aca08a --- /dev/null +++ b/src/middlewares/__tests__/requirePermission.test.ts @@ -0,0 +1,74 @@ +import { requirePermission } from '../authMiddleware'; +import { Permission } from '../../types/Permissions'; +import { UserRole } from '../../entities/User'; +import { + createMockRequest, + createAuthenticatedRequest, + createMockResponse, +} from '../../../tests/helpers'; + +// handleError -> logRequestError imports the live Redis client, which opens a +// socket that keeps the process alive. Mock it so the suite exits cleanly. +jest.mock('../../config/redis', () => ({ + __esModule: true, + default: { incr: jest.fn(), expire: jest.fn(), get: jest.fn(), set: jest.fn(), del: jest.fn() }, +})); + +const asRes = (res: ReturnType): any => res; + +describe('requirePermission (#100)', () => { + it('responds 401 when the request is unauthenticated', () => { + const req = createMockRequest(); + const res = createMockResponse(); + const next = jest.fn(); + + requirePermission(Permission.CONTENT_MODERATE)(req, asRes(res), next); + + expect(res.status).toHaveBeenCalledWith(401); + expect(next).not.toHaveBeenCalled(); + }); + + it('responds 403 (not 401) when an authenticated role lacks the permission', () => { + const req = createAuthenticatedRequest({ user: { role: UserRole.LISTENER } }); + const res = createMockResponse(); + const next = jest.fn(); + + requirePermission(Permission.CONTENT_MODERATE)(req, asRes(res), next); + + expect(res.status).toHaveBeenCalledWith(403); + expect(next).not.toHaveBeenCalled(); + }); + + it('calls next() when the role holds the required permission', () => { + const req = createAuthenticatedRequest({ user: { role: UserRole.MODERATOR } }); + const res = createMockResponse(); + const next = jest.fn(); + + requirePermission(Permission.CONTENT_MODERATE)(req, asRes(res), next); + + expect(next).toHaveBeenCalledTimes(1); + expect(res.status).not.toHaveBeenCalled(); + }); + + it('allows super_admin through any permission gate', () => { + const req = createAuthenticatedRequest({ user: { role: UserRole.SUPER_ADMIN } }); + const res = createMockResponse(); + const next = jest.fn(); + + requirePermission(Permission.ROLE_ASSIGN)(req, asRes(res), next); + + expect(next).toHaveBeenCalledTimes(1); + expect(res.status).not.toHaveBeenCalled(); + }); + + it('responds 403 when the token carries no role claim', () => { + const req = createAuthenticatedRequest({ user: {} }); + const res = createMockResponse(); + const next = jest.fn(); + + requirePermission(Permission.JOBS_VIEW)(req, asRes(res), next); + + expect(res.status).toHaveBeenCalledWith(403); + expect(next).not.toHaveBeenCalled(); + }); +}); diff --git a/src/middlewares/authMiddleware.ts b/src/middlewares/authMiddleware.ts index d69986b..96cde1d 100644 --- a/src/middlewares/authMiddleware.ts +++ b/src/middlewares/authMiddleware.ts @@ -1,6 +1,7 @@ import { Request, Response, NextFunction } from 'express'; import jwt from 'jsonwebtoken'; import { UserRole } from '../entities/User'; +import { Permission, roleHasPermission } from '../types/Permissions'; import { AppError } from '../errors/AppError'; import { handleError } from '../utils/helpers'; @@ -30,7 +31,7 @@ export const requireAuth = (req: Request, res: Response, next: NextFunction) => const authHeader = req.headers.authorization; if (!authHeader || !authHeader.startsWith('Bearer ')) { - return handleError(res, AppError.authentication('Unauthorized: No token provided')); + return handleError(req, res, AppError.authentication('Unauthorized: No token provided')); } const token = authHeader.split(' ')[1]; @@ -38,14 +39,14 @@ export const requireAuth = (req: Request, res: Response, next: NextFunction) => const decoded = jwt.verify(token, secret) as JwtPayload; if (!decoded) { - return handleError(res, AppError.authentication('Unauthorized: Invalid token')); + return handleError(req, res, AppError.authentication('Unauthorized: Invalid token')); } (req as any).user = decoded; next(); } catch (error) { console.error('JWT verification error:', error); - return handleError(res, AppError.authentication('Unauthorized: Invalid or expired token')); + return handleError(req, res, AppError.authentication('Unauthorized: Invalid or expired token')); } }; @@ -56,6 +57,7 @@ export const requireRoles = const role = (req as any).user?.role as UserRole | undefined; if (!role || !allowedRoles.includes(role)) { return handleError( + req, res, AppError.authorization( `Forbidden: one of these roles is required: ${allowedRoles.join(', ')}`, @@ -70,14 +72,42 @@ export const requireRoles = export const authArtistMiddleware = requireRoles(UserRole.ARTIST, UserRole.ADMIN); export const authListenerMiddleware = requireRoles(UserRole.LISTENER, UserRole.ADMIN); +/** + * RBAC middleware: authenticates the request, then authorizes it against a + * granular permission (Issue #100). Unlike {@link requireRoles}, callers name + * the capability they need rather than the roles that happen to have it. + * + * Returns 401 when the caller is unauthenticated (no/invalid token) and 403 + * when authenticated but the role lacks the required permission. + */ +export const requirePermission = + (permission: Permission) => (req: Request, res: Response, next: NextFunction) => { + return requireAuth(req, res, () => { + const role = (req as any).user?.role as UserRole | undefined; + if (!role || !roleHasPermission(role, permission)) { + return handleError( + req, + res, + AppError.authorization(`Forbidden: missing required permission: ${permission}`), + ); + } + + return next(); + }); + }; + export const requireEmailVerified = (req: Request, res: Response, next: NextFunction) => { const user = (req as any).user; if (!user) { - return handleError(res, AppError.authentication('Unauthorized: No user in session')); + return handleError(req, res, AppError.authentication('Unauthorized: No user in session')); } if (user.emailVerified === false) { - return handleError(res, AppError.authorization('Email verification required for this action')); + return handleError( + req, + res, + AppError.authorization('Email verification required for this action'), + ); } next(); diff --git a/src/migrations/1751600000000-AddRbacRolesToUser.ts b/src/migrations/1751600000000-AddRbacRolesToUser.ts new file mode 100644 index 0000000..7d7e869 --- /dev/null +++ b/src/migrations/1751600000000-AddRbacRolesToUser.ts @@ -0,0 +1,37 @@ +import { MigrationInterface, QueryRunner, TableColumn } from 'typeorm'; + +/** + * Ensures the RBAC `role` column exists on the users table (Issue #100). + * + * The column was introduced in the initial schema as `varchar(50)` with a + * default of `'listener'`. Because roles are stored as strings, the new + * `moderator` and `super_admin` values require no structural change — this + * migration is an idempotent safety net that (re)creates the column for any + * database provisioned without it, keeping fresh and legacy environments in + * sync with the {@link UserRole} enum. + */ +export class AddRbacRolesToUser1751600000000 implements MigrationInterface { + private readonly table = 'user'; + private readonly column = 'role'; + + public async up(queryRunner: QueryRunner): Promise { + const hasColumn = await queryRunner.hasColumn(this.table, this.column); + if (!hasColumn) { + await queryRunner.addColumn( + this.table, + new TableColumn({ + name: this.column, + type: 'varchar', + length: '50', + default: "'listener'", + }), + ); + } + } + + public async down(): Promise { + // Intentionally a no-op: `role` is a core authentication field and is owned + // by the initial schema migration. Dropping it here would break login for + // every user, so this migration does not remove it on rollback. + } +} diff --git a/src/routes/adminRoutes.ts b/src/routes/adminRoutes.ts index 0f6f592..c65c92e 100644 --- a/src/routes/adminRoutes.ts +++ b/src/routes/adminRoutes.ts @@ -1,25 +1,59 @@ import { Router } from 'express'; -import { requireRoles } from '../middlewares/authMiddleware'; -import { UserRole } from '../entities/User'; +import { requirePermission } from '../middlewares/authMiddleware'; +import { Permission } from '../types/Permissions'; +import { validateDTO } from '../middlewares/validate'; +import { AssignRoleDTO } from '../dtos/AssignRoleDTO'; import { SongController } from '../controllers/SongController'; import { JobController } from '../controllers/JobController'; +import { AdminController } from '../controllers/AdminController'; const router = Router(); -router.use(requireRoles(UserRole.ADMIN)); +// RBAC (Issue #100): each route requires the specific permission it needs +// rather than a blanket admin check. requirePermission returns 401 when the +// caller is unauthenticated and 403 when the role lacks the permission. -router.patch('/song/:id/flag', SongController.flagSong); -router.patch('/song/:id/unflag', SongController.unflagSong); +// Content moderation — moderators and above. +router.patch( + '/song/:id/flag', + requirePermission(Permission.CONTENT_MODERATE), + SongController.flagSong, +); +router.patch( + '/song/:id/unflag', + requirePermission(Permission.CONTENT_MODERATE), + SongController.unflagSong, +); // Manual retry for failed song processing (Issues #123, #125) -router.post('/songs/:id/retry', SongController.retryFailedSong); -router.post('/song/:id/retry', SongController.retryFailedSong); +router.post( + '/songs/:id/retry', + requirePermission(Permission.CONTENT_MODERATE), + SongController.retryFailedSong, +); +router.post( + '/song/:id/retry', + requirePermission(Permission.CONTENT_MODERATE), + SongController.retryFailedSong, +); // Search index maintenance (Issue #135) -router.post('/search/rebuild', SongController.rebuildSearchIndex); +router.post( + '/search/rebuild', + requirePermission(Permission.SEARCH_MANAGE), + SongController.rebuildSearchIndex, +); // Background job queue visibility (Issue #132) -router.get('/jobs', JobController.getJobs); -router.get('/jobs/:id', JobController.getJob); +router.get('/jobs', requirePermission(Permission.JOBS_VIEW), JobController.getJobs); +router.get('/jobs/:id', requirePermission(Permission.JOBS_VIEW), JobController.getJob); + +// Role assignment (Issue #100) — admins and super_admins only. +router.post( + '/users/:id/role', + requirePermission(Permission.ROLE_ASSIGN), + validateDTO(AssignRoleDTO), + AdminController.assignRole, +); export default router; diff --git a/src/services/UserService.ts b/src/services/UserService.ts index 0c8608e..db3c714 100644 --- a/src/services/UserService.ts +++ b/src/services/UserService.ts @@ -1,8 +1,7 @@ import { Repository } from 'typeorm'; import AppDataSource from '../config/db'; -import { User } from '../entities/User'; +import { User, UserRole } from '../entities/User'; import { CreateUserDTO } from '../dtos/CreateUserDTO'; -import { validate } from 'class-validator'; import { verifyMessage } from 'ethers'; import jwt from 'jsonwebtoken'; import dotenv from 'dotenv'; @@ -17,7 +16,6 @@ import { } from '../validators/ServiceValidator'; import { ERROR_MESSAGES, - SUCCESS_MESSAGES, JWT_EXPIRATION, TRANSACTION_ACTIONS, REGEX_PATTERNS, @@ -141,6 +139,28 @@ export class UserService { return await this.userRepo.findOneBy({ id }); } + /** + * Assign a role to a user (Issue #100). Used by the admin role-management + * endpoint to promote/demote users across the RBAC role set. + * + * @param id - The target user's UUID. + * @param role - The role to assign. + * @returns The updated User entity. + * @throws {AppError} If the user does not exist. + */ + async assignRole(id: string, role: UserRole): Promise { + validateRequired(id, 'id'); + validateRequired(role, 'role'); + + const user = await this.userRepo.findOneBy({ id }); + if (!user) { + throw AppError.notFound(ERROR_MESSAGES.USER_NOT_FOUND); + } + + user.role = role; + return await this.userRepo.save(user); + } + /** * Update a user's profile fields. Validates uniqueness constraints for * walletAddress, email, and username before saving. diff --git a/src/services/__tests__/UserService.assignRole.test.ts b/src/services/__tests__/UserService.assignRole.test.ts new file mode 100644 index 0000000..44e7b94 --- /dev/null +++ b/src/services/__tests__/UserService.assignRole.test.ts @@ -0,0 +1,55 @@ +import 'reflect-metadata'; + +jest.mock('../../config/db', () => ({ + __esModule: true, + default: { getRepository: jest.fn() }, +})); +jest.mock('../../config/redis', () => ({ + __esModule: true, + default: { set: jest.fn(), get: jest.fn(), del: jest.fn() }, +})); + +import AppDataSource from '../../config/db'; +import { UserService } from '../UserService'; +import { UserRole } from '../../entities/User'; + +const mockUserRepo = { + findOneBy: jest.fn(), + save: jest.fn(), +}; + +beforeEach(() => { + jest.clearAllMocks(); + // UserService requests both the User and TransactionLog repositories. + (AppDataSource.getRepository as jest.Mock).mockReturnValue(mockUserRepo); +}); + +describe('UserService.assignRole (#100)', () => { + it('throws 404 when the user does not exist', async () => { + mockUserRepo.findOneBy.mockResolvedValue(null); + const svc = new UserService(); + + await expect(svc.assignRole('missing-id', UserRole.MODERATOR)).rejects.toThrow( + 'User not found', + ); + expect(mockUserRepo.save).not.toHaveBeenCalled(); + }); + + it('persists the new role and returns the updated user', async () => { + const user = { id: 'u1', role: UserRole.LISTENER }; + mockUserRepo.findOneBy.mockResolvedValue(user); + mockUserRepo.save.mockImplementation(async (u) => u); + const svc = new UserService(); + + const result = await svc.assignRole('u1', UserRole.MODERATOR); + + expect(user.role).toBe(UserRole.MODERATOR); + expect(mockUserRepo.save).toHaveBeenCalledWith(user); + expect(result.role).toBe(UserRole.MODERATOR); + }); + + it('throws when id is missing', async () => { + const svc = new UserService(); + await expect(svc.assignRole('', UserRole.ADMIN)).rejects.toThrow(); + }); +}); diff --git a/src/types/Permissions.ts b/src/types/Permissions.ts new file mode 100644 index 0000000..6a05365 --- /dev/null +++ b/src/types/Permissions.ts @@ -0,0 +1,49 @@ +import { UserRole } from '../entities/User'; + +/** + * Granular permissions checked by the RBAC middleware (Issue #100). + * + * A permission names a single capability. Roles are mapped to the set of + * permissions they hold in {@link ROLE_PERMISSIONS}, so route handlers can + * require a capability rather than hard-coding which roles happen to have it. + */ +export enum Permission { + // Content moderation + CONTENT_MODERATE = 'content:moderate', + + // Background jobs / operational visibility + JOBS_VIEW = 'jobs:view', + + // Search index maintenance + SEARCH_MANAGE = 'search:manage', + + // User & role administration + USER_MANAGE = 'user:manage', + ROLE_ASSIGN = 'role:assign', +} + +/** + * Maps each role to the permissions it is granted. + * + * Roles are additive but not hierarchical: a role holds exactly the + * permissions listed for it. `super_admin` is granted every permission so + * that adding a new capability does not silently exclude the top role. + */ +export const ROLE_PERMISSIONS: Record = { + [UserRole.LISTENER]: [], + [UserRole.ARTIST]: [], + [UserRole.MODERATOR]: [Permission.CONTENT_MODERATE, Permission.JOBS_VIEW], + [UserRole.ADMIN]: [ + Permission.CONTENT_MODERATE, + Permission.JOBS_VIEW, + Permission.SEARCH_MANAGE, + Permission.USER_MANAGE, + Permission.ROLE_ASSIGN, + ], + [UserRole.SUPER_ADMIN]: Object.values(Permission), +}; + +/** True when `role` is granted `permission`. */ +export function roleHasPermission(role: UserRole, permission: Permission): boolean { + return ROLE_PERMISSIONS[role]?.includes(permission) ?? false; +} diff --git a/src/types/__tests__/Permissions.test.ts b/src/types/__tests__/Permissions.test.ts new file mode 100644 index 0000000..e29b68b --- /dev/null +++ b/src/types/__tests__/Permissions.test.ts @@ -0,0 +1,36 @@ +import { UserRole } from '../../entities/User'; +import { Permission, ROLE_PERMISSIONS, roleHasPermission } from '../Permissions'; + +describe('RBAC permission map (#100)', () => { + it('defines a permission list for every role', () => { + for (const role of Object.values(UserRole)) { + expect(Array.isArray(ROLE_PERMISSIONS[role])).toBe(true); + } + }); + + it('grants no elevated permissions to listeners or artists', () => { + expect(ROLE_PERMISSIONS[UserRole.LISTENER]).toEqual([]); + expect(ROLE_PERMISSIONS[UserRole.ARTIST]).toEqual([]); + }); + + it('lets moderators moderate content but not manage users or roles', () => { + expect(roleHasPermission(UserRole.MODERATOR, Permission.CONTENT_MODERATE)).toBe(true); + expect(roleHasPermission(UserRole.MODERATOR, Permission.USER_MANAGE)).toBe(false); + expect(roleHasPermission(UserRole.MODERATOR, Permission.ROLE_ASSIGN)).toBe(false); + }); + + it('lets admins assign roles and manage users', () => { + expect(roleHasPermission(UserRole.ADMIN, Permission.ROLE_ASSIGN)).toBe(true); + expect(roleHasPermission(UserRole.ADMIN, Permission.USER_MANAGE)).toBe(true); + }); + + it('grants super_admin every defined permission', () => { + for (const permission of Object.values(Permission)) { + expect(roleHasPermission(UserRole.SUPER_ADMIN, permission)).toBe(true); + } + }); + + it('returns false for a role that is not in the map', () => { + expect(roleHasPermission('ghost' as UserRole, Permission.CONTENT_MODERATE)).toBe(false); + }); +}); diff --git a/src/utils/helpers.ts b/src/utils/helpers.ts index 8109e36..a2fa429 100644 --- a/src/utils/helpers.ts +++ b/src/utils/helpers.ts @@ -1,9 +1,17 @@ -import { Response } from 'express'; +import { Request, Response } from 'express'; +import { ValidationError } from 'class-validator'; import crypto from 'crypto'; import { mapToOnChainError } from '../types/OnChainErrorCodes'; import { AppError } from '../errors/AppError'; import { logRequestError } from './errorLogger'; +/** Shape returned by {@link formatValidationErrors}. */ +interface IValidationFormatResult { + success: false; + fields: Record; + message: string[]; +} + export function formatValidationErrors(errors: ValidationError[]): IValidationFormatResult { const fields: Record = {}; const message: string[] = [];