From 89826858ac919f21856d85eabb08b79124a96e52 Mon Sep 17 00:00:00 2001 From: Ho Ngoc Hai Date: Sun, 26 Apr 2026 08:23:00 +0700 Subject: [PATCH] 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 --- apps/api/package.json | 3 + apps/api/src/app.module.ts | 6 ++ .../bull-board-auth.middleware.spec.ts | 94 +++++++++++++++++++ .../queues/bull-board-auth.middleware.ts | 48 ++++++++++ apps/api/src/modules/queues/queues.module.ts | 36 +++++++ pnpm-lock.yaml | 80 ++++++++++++++-- 6 files changed, 258 insertions(+), 9 deletions(-) create mode 100644 apps/api/src/modules/queues/__tests__/bull-board-auth.middleware.spec.ts create mode 100644 apps/api/src/modules/queues/bull-board-auth.middleware.ts create mode 100644 apps/api/src/modules/queues/queues.module.ts diff --git a/apps/api/package.json b/apps/api/package.json index b644ef4..a87a1b1 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -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", diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index 3e09bf0..b58601b 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -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('*'); } diff --git a/apps/api/src/modules/queues/__tests__/bull-board-auth.middleware.spec.ts b/apps/api/src/modules/queues/__tests__/bull-board-auth.middleware.spec.ts new file mode 100644 index 0000000..d694740 --- /dev/null +++ b/apps/api/src/modules/queues/__tests__/bull-board-auth.middleware.spec.ts @@ -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; headers: Record }> = {}): any { + return { cookies: overrides.cookies ?? {}, headers: overrides.headers ?? {} }; + } + + function signToken(payload: Record, 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); + }); +}); diff --git a/apps/api/src/modules/queues/bull-board-auth.middleware.ts b/apps/api/src/modules/queues/bull-board-auth.middleware.ts new file mode 100644 index 0000000..e15e4cf --- /dev/null +++ b/apps/api/src/modules/queues/bull-board-auth.middleware.ts @@ -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; + } +} diff --git a/apps/api/src/modules/queues/queues.module.ts b/apps/api/src/modules/queues/queues.module.ts new file mode 100644 index 0000000..ed13ec9 --- /dev/null +++ b/apps/api/src/modules/queues/queues.module.ts @@ -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 }, + ); + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 745eb52..4db4f63 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -87,9 +87,15 @@ importers: '@aws-sdk/s3-request-presigner': specifier: ^3.1026.0 version: 3.1026.0 - '@goodgo/contracts-events': - specifier: workspace:* - version: link:../../libs/contracts/events + '@bull-board/api': + specifier: ^7.0.0 + version: 7.0.0(@bull-board/ui@7.0.0) + '@bull-board/express': + specifier: ^7.0.0 + version: 7.0.0 + '@bull-board/nestjs': + specifier: ^7.0.0 + version: 7.0.0(@bull-board/api@7.0.0(@bull-board/ui@7.0.0))(@nestjs/bull-shared@11.0.4(@nestjs/common@11.1.18(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.18))(@nestjs/common@11.1.18(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.18)(reflect-metadata@0.2.2)(rxjs@7.8.2) '@goodgo/mcp-servers': specifier: workspace:* version: link:../../libs/mcp-servers @@ -189,9 +195,6 @@ importers: ioredis: specifier: ^5.4.0 version: 5.10.1 - jsonwebtoken: - specifier: ^9.0.3 - version: 9.0.3 nodemailer: specifier: ^8.0.5 version: 8.0.5 @@ -265,9 +268,6 @@ importers: '@types/express': specifier: ^5.0.0 version: 5.0.6 - '@types/jsonwebtoken': - specifier: ^9.0.10 - version: 9.0.10 '@types/node': specifier: ^25.5.2 version: 25.5.2 @@ -776,6 +776,27 @@ packages: resolution: {integrity: sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==} hasBin: true + '@bull-board/api@7.0.0': + resolution: {integrity: sha512-ISNspLHVmUWUSq/eLw+wd1FuBBUnqpLbYP2xUNmehpfKhS+NoZWMbBvqjUYVeE/HLfUkRcR1edzMKpl5n9zlSw==} + peerDependencies: + '@bull-board/ui': 7.0.0 + + '@bull-board/express@7.0.0': + resolution: {integrity: sha512-3Tc/EyU5PQMTcTzcafFSrmRDiEbJBEU/EaVQ5OVYcuJ7DZCp5Pkvm0/2VtaCe2uywdtwn0ZaynlSIpB27FKX6A==} + + '@bull-board/nestjs@7.0.0': + resolution: {integrity: sha512-ypXm0eJHIMQzjN+3fjf84cVxugBg/K4Bpo0eYcV4u/AsteR/dnr6e7F79ICRgg1WWoczqmSMl0JhlmykpyhAMg==} + peerDependencies: + '@bull-board/api': ^7.0.0 + '@nestjs/bull-shared': ^10.0.0 || ^11.0.0 + '@nestjs/common': ^9.0.0 || ^10.0.0 || ^11.0.0 + '@nestjs/core': ^9.0.0 || ^10.0.0 || ^11.0.0 + reflect-metadata: ^0.1.13 || ^0.2.0 + rxjs: ^7.8.1 + + '@bull-board/ui@7.0.0': + resolution: {integrity: sha512-AnKeklpDn0iMFgu4ukDU6uTNmw4oudl07G4k2Fh95SknKDrXSiWRV0N1TGUawMqyfG1Yi5P/W/8d7raBq/Uw6w==} + '@colors/colors@1.5.0': resolution: {integrity: sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ==} engines: {node: '>=0.1.90'} @@ -4418,6 +4439,11 @@ packages: effect@3.20.0: resolution: {integrity: sha512-qMLfDJscrNG8p/aw+IkT9W7fgj50Z4wG5bLBy0Txsxz8iUHjDIkOgO3SV0WZfnQbNG2VJYb0b+rDLMrhM4+Krw==} + ejs@5.0.2: + resolution: {integrity: sha512-IpbUaI/CAW86l3f+T8zN0iggSc0LmMZLcIW5eRVStLVNCoTXkE0YlncbbH50fp8Cl6zHIky0sW2uUbhBqGw0Jw==} + engines: {node: '>=0.12.18'} + hasBin: true + electron-to-chromium@1.5.332: resolution: {integrity: sha512-7OOtytmh/rINMLwaFTbcMVvYXO3AUm029X0LcyfYk0B557RlPkdpTpnH9+htMlfu5dKwOmT0+Zs2Aw+lnn6TeQ==} @@ -6313,6 +6339,9 @@ packages: resolution: {integrity: sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==} engines: {node: '>=4'} + redis-info@3.1.0: + resolution: {integrity: sha512-ER4L9Sh/vm63DkIE0bkSjxluQlioBiBgf5w1UuldaW/3vPcecdljVDisZhmnCMvsxHNiARTTDDHGg9cGwTfrKg==} + redis-parser@3.0.0: resolution: {integrity: sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==} engines: {node: '>=4'} @@ -8025,6 +8054,33 @@ snapshots: dependencies: css-tree: 3.2.1 + '@bull-board/api@7.0.0(@bull-board/ui@7.0.0)': + dependencies: + '@bull-board/ui': 7.0.0 + redis-info: 3.1.0 + + '@bull-board/express@7.0.0': + dependencies: + '@bull-board/api': 7.0.0(@bull-board/ui@7.0.0) + '@bull-board/ui': 7.0.0 + ejs: 5.0.2 + express: 5.2.1 + transitivePeerDependencies: + - supports-color + + '@bull-board/nestjs@7.0.0(@bull-board/api@7.0.0(@bull-board/ui@7.0.0))(@nestjs/bull-shared@11.0.4(@nestjs/common@11.1.18(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.18))(@nestjs/common@11.1.18(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.18)(reflect-metadata@0.2.2)(rxjs@7.8.2)': + dependencies: + '@bull-board/api': 7.0.0(@bull-board/ui@7.0.0) + '@nestjs/bull-shared': 11.0.4(@nestjs/common@11.1.18(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.18) + '@nestjs/common': 11.1.18(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 11.1.18(@nestjs/common@11.1.18(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.18)(@nestjs/websockets@11.1.19)(reflect-metadata@0.2.2)(rxjs@7.8.2) + reflect-metadata: 0.2.2 + rxjs: 7.8.2 + + '@bull-board/ui@7.0.0': + dependencies: + '@bull-board/api': 7.0.0(@bull-board/ui@7.0.0) + '@colors/colors@1.5.0': optional: true @@ -11786,6 +11842,8 @@ snapshots: '@standard-schema/spec': 1.1.0 fast-check: 3.23.2 + ejs@5.0.2: {} + electron-to-chromium@1.5.332: {} emoji-regex@10.6.0: {} @@ -13905,6 +13963,10 @@ snapshots: redis-errors@1.2.0: {} + redis-info@3.1.0: + dependencies: + lodash: 4.18.1 + redis-parser@3.0.0: dependencies: redis-errors: 1.2.0