feat(api): mount Bull Board behind admin auth (RFC-004 Phase 3 WS3b)

Adds the Bull Board BullMQ dashboard at /api/v1/admin/queues,
guarded by a JWT + ADMIN-role middleware that mirrors the existing
JwtAuthGuard + RolesGuard contract. The dashboard registers all
queues in QUEUE_METRICS_QUEUE_NAMES automatically.

- New QueuesModule with BullBoardModule.forRoot/forFeature wiring
- BullBoardAuthMiddleware (cookie-first JWT extraction, ADMIN-only)
- CSRF exclusion for dashboard routes in AppModule
- 8 unit tests covering auth contract
- Dependencies: @bull-board/api, @bull-board/express, @bull-board/nestjs

Refs: GOO-175

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Ho Ngoc Hai
2026-04-26 08:23:00 +07:00
parent a569765993
commit 89826858ac
6 changed files with 258 additions and 9 deletions

View File

@@ -16,6 +16,9 @@
"@anthropic-ai/sdk": "^0.89.0",
"@aws-sdk/client-s3": "^3.1026.0",
"@aws-sdk/s3-request-presigner": "^3.1026.0",
"@bull-board/api": "^7.0.0",
"@bull-board/express": "^7.0.0",
"@bull-board/nestjs": "^7.0.0",
"@goodgo/mcp-servers": "workspace:*",
"@nest-lab/throttler-storage-redis": "^1.2.0",
"@nestjs/bullmq": "^11.0.4",

View File

@@ -22,6 +22,7 @@ import { HttpMetricsInterceptor, MetricsModule } from '@modules/metrics';
import { NotificationsModule } from '@modules/notifications';
import { PaymentsModule } from '@modules/payments';
import { ProjectsModule } from '@modules/projects';
import { QueuesModule } from '@modules/queues/queues.module';
import { ReportsModule } from '@modules/reports';
import { ReviewsModule } from '@modules/reviews';
import { SearchModule } from '@modules/search';
@@ -70,6 +71,9 @@ import { AppController } from './app.controller';
IndustrialModule,
TransferModule,
// ── Bull Board UI (RFC-004 Phase 3 WS3b) ──
QueuesModule,
// ── Rate Limiting ──
// Default: 60 requests per 60 seconds per IP
// Override per-route with @Throttle() decorator
@@ -144,6 +148,8 @@ export class AppModule implements NestModule {
{ path: 'health/(.*)', method: RequestMethod.GET },
{ path: 'api/v1/web-vitals', method: RequestMethod.POST }, // sendBeacon cannot send CSRF headers
{ path: 'web-vitals', method: RequestMethod.POST }, // middleware exclude uses controller-relative path
{ path: 'api/v1/admin/queues', method: RequestMethod.ALL },
{ path: 'api/v1/admin/queues/(.*)', method: RequestMethod.ALL },
)
.forRoutes('*');
}

View File

@@ -0,0 +1,94 @@
import { ForbiddenException, UnauthorizedException } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { describe, expect, it, beforeEach, afterEach, vi } from 'vitest';
import { BullBoardAuthMiddleware } from '../bull-board-auth.middleware';
describe('BullBoardAuthMiddleware', () => {
const ORIGINAL_SECRET = process.env['JWT_SECRET'];
let jwtService: JwtService;
let middleware: BullBoardAuthMiddleware;
beforeEach(() => {
process.env['JWT_SECRET'] = 'test-secret-for-bull-board-mw';
jwtService = new JwtService({});
middleware = new BullBoardAuthMiddleware(jwtService);
});
afterEach(() => {
if (ORIGINAL_SECRET === undefined) {
delete process.env['JWT_SECRET'];
} else {
process.env['JWT_SECRET'] = ORIGINAL_SECRET;
}
});
function makeReq(overrides: Partial<{ cookies: Record<string, string>; headers: Record<string, string> }> = {}): any {
return { cookies: overrides.cookies ?? {}, headers: overrides.headers ?? {} };
}
function signToken(payload: Record<string, unknown>, opts: { audience?: string; issuer?: string } = {}): string {
return jwtService.sign(payload, {
secret: process.env['JWT_SECRET']!,
audience: opts.audience ?? 'goodgo-api',
issuer: opts.issuer ?? 'goodgo-platform',
});
}
it('rejects when no token is present', () => {
const next = vi.fn();
expect(() => middleware.use(makeReq(), {} as any, next)).toThrow(UnauthorizedException);
expect(next).not.toHaveBeenCalled();
});
it('rejects when JWT signature is invalid', () => {
const next = vi.fn();
const req = makeReq({ headers: { authorization: 'Bearer not-a-valid-token' } });
expect(() => middleware.use(req, {} as any, next)).toThrow(UnauthorizedException);
});
it('rejects when JWT audience does not match', () => {
const token = signToken({ sub: 'u1', role: 'ADMIN' }, { audience: 'wrong-aud' });
const req = makeReq({ headers: { authorization: 'Bearer ' + token } });
const next = vi.fn();
expect(() => middleware.use(req, {} as any, next)).toThrow(UnauthorizedException);
});
it('rejects non-ADMIN role with 403', () => {
const token = signToken({ sub: 'u1', role: 'USER' });
const req = makeReq({ headers: { authorization: 'Bearer ' + token } });
const next = vi.fn();
expect(() => middleware.use(req, {} as any, next)).toThrow(ForbiddenException);
});
it('accepts ADMIN via Authorization header', () => {
const token = signToken({ sub: 'u1', role: 'ADMIN' });
const req = makeReq({ headers: { authorization: 'Bearer ' + token } });
const next = vi.fn();
middleware.use(req, {} as any, next);
expect(next).toHaveBeenCalledOnce();
});
it('accepts ADMIN via access_token cookie', () => {
const token = signToken({ sub: 'u1', role: 'ADMIN' });
const req = makeReq({ cookies: { access_token: token } });
const next = vi.fn();
middleware.use(req, {} as any, next);
expect(next).toHaveBeenCalledOnce();
});
it('prefers cookie over header', () => {
const cookieToken = signToken({ sub: 'admin', role: 'ADMIN' });
const headerToken = signToken({ sub: 'user', role: 'USER' });
const req = makeReq({ cookies: { access_token: cookieToken }, headers: { authorization: 'Bearer ' + headerToken } });
const next = vi.fn();
middleware.use(req, {} as any, next);
expect(next).toHaveBeenCalledOnce();
});
it('fails closed when JWT_SECRET unset', () => {
delete process.env['JWT_SECRET'];
const req = makeReq({ headers: { authorization: 'Bearer anything' } });
const next = vi.fn();
expect(() => middleware.use(req, {} as any, next)).toThrow(UnauthorizedException);
});
});

View File

@@ -0,0 +1,48 @@
import { Injectable, type NestMiddleware, UnauthorizedException, ForbiddenException } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import type { NextFunction, Request, Response } from 'express';
@Injectable()
export class BullBoardAuthMiddleware implements NestMiddleware {
constructor(private readonly jwtService: JwtService) {}
use(req: Request, _res: Response, next: NextFunction): void {
const token = this.extractToken(req);
if (!token) {
throw new UnauthorizedException('Bull Board requires authentication');
}
const secret = process.env['JWT_SECRET'];
if (!secret) {
throw new UnauthorizedException('Server mis-configured');
}
let payload: { sub?: string; role?: string };
try {
payload = this.jwtService.verify<{ sub?: string; role?: string }>(token, {
secret,
audience: 'goodgo-api',
issuer: 'goodgo-platform',
});
} catch {
throw new UnauthorizedException('Invalid or expired token');
}
if (payload.role !== 'ADMIN') {
throw new ForbiddenException('Admin role required');
}
next();
}
private extractToken(req: Request): string | null {
const cookieToken = (req.cookies?.['access_token'] as string | undefined) ?? null;
if (cookieToken) return cookieToken;
const header = req.headers.authorization;
if (header && header.startsWith('Bearer ')) {
return header.slice('Bearer '.length);
}
return null;
}
}

View File

@@ -0,0 +1,36 @@
import { BullModule } from '@nestjs/bullmq';
import { type MiddlewareConsumer, Module, type NestModule, RequestMethod } from '@nestjs/common';
import { JwtModule } from '@nestjs/jwt';
import { BullMQAdapter } from '@bull-board/api/bullMQAdapter';
import { ExpressAdapter } from '@bull-board/express';
import { BullBoardModule } from '@bull-board/nestjs';
import { QUEUE_METRICS_QUEUE_NAMES } from '../metrics/infrastructure/queue-metrics.constants';
import { BullBoardAuthMiddleware } from './bull-board-auth.middleware';
@Module({
imports: [
JwtModule.register({}),
BullBoardModule.forRoot({
route: '/admin/queues',
adapter: ExpressAdapter,
}),
...QUEUE_METRICS_QUEUE_NAMES.map((name) => BullModule.registerQueue({ name })),
BullBoardModule.forFeature(
...QUEUE_METRICS_QUEUE_NAMES.map((name) => ({
name,
adapter: BullMQAdapter,
})),
),
],
providers: [BullBoardAuthMiddleware],
})
export class QueuesModule implements NestModule {
configure(consumer: MiddlewareConsumer): void {
consumer
.apply(BullBoardAuthMiddleware)
.forRoutes(
{ path: 'admin/queues', method: RequestMethod.ALL },
{ path: 'admin/queues/(.*)', method: RequestMethod.ALL },
);
}
}