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
36 changes: 36 additions & 0 deletions src/controllers/AdminController.ts
Original file line number Diff line number Diff line change
@@ -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);
}
};
}
54 changes: 54 additions & 0 deletions src/controllers/__tests__/AdminController.test.ts
Original file line number Diff line number Diff line change
@@ -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' });
});
});
8 changes: 8 additions & 0 deletions src/dtos/AssignRoleDTO.ts
Original file line number Diff line number Diff line change
@@ -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;
}
7 changes: 5 additions & 2 deletions src/entities/User.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down
74 changes: 74 additions & 0 deletions src/middlewares/__tests__/requirePermission.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof createMockResponse>): 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();
});
});
40 changes: 35 additions & 5 deletions src/middlewares/authMiddleware.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -30,22 +31,22 @@ 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];
const secret = process.env.JWT_SECRET!;
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'));
}
};

Expand All @@ -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(', ')}`,
Expand All @@ -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();
Expand Down
37 changes: 37 additions & 0 deletions src/migrations/1751600000000-AddRbacRolesToUser.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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<void> {
// 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.
}
}
54 changes: 44 additions & 10 deletions src/routes/adminRoutes.ts
Original file line number Diff line number Diff line change
@@ -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;
Loading
Loading