From f38dab4632513b9633f1dcc99c436e3bd9c23fbc Mon Sep 17 00:00:00 2001 From: sagesan2580 Date: Thu, 27 Aug 2026 20:22:23 +0100 Subject: [PATCH] feat(security): enforce role + subject ownership checks Adds authorization for role-gated and subject-owned routes: - New guards: JwtAuthGuard, JwtAdminGuard, JwtLearnerGuard, JwtTutorGuard, RolesGuard, SubjectOwnershipGuard - assertSameSubject / assertOwnerOrStaff helpers and @Ownership decorator for JWT-vs-entity ownership verification - Subject-scoped enforcement on user/tutor profiles, submission review/grading, and progress endpoints; reviewer/rater/grader identities are always bound to the JWT subject - Integration suite (33 cases) exercising real controllers/guards - Fix DI/compile blockers exposed while wiring the suites: auth-session duplicate constructor and refreshTokenHash leak in getActiveSessions, audit duplicate guard param, rewards incrementCounter call against MonitoringService - Install missing runtime deps (ioredis, prom-client, @willsoto/nestjs-prometheus) --- BackendAcademy/package.json | 3 + BackendAcademy/src/admin/admin.controller.ts | 7 +- BackendAcademy/src/admin/admin.module.ts | 3 +- BackendAcademy/src/ai/ai.service.ts | 105 ++-- .../src/analytics/analytics.service.ts | 1 - BackendAcademy/src/assets/assets.module.ts | 8 +- BackendAcademy/src/audit/audit.service.ts | 2 +- .../src/auth/auth-session.controller.ts | 4 +- .../src/auth/auth-session.service.ts | 58 +- BackendAcademy/src/auth/auth.module.ts | 6 + .../auth/decorators/ownership.decorator.ts | 16 + BackendAcademy/src/auth/guards/guards.spec.ts | 215 +++++++ .../src/auth/guards/jwt-admin.guard.ts | 45 +- .../src/auth/guards/jwt-auth.guard.ts | 69 +++ .../src/auth/guards/jwt-learner.guard.ts | 45 +- .../src/auth/guards/jwt-tutor.guard.ts | 51 +- .../auth/guards/subject-ownership.guard.ts | 72 +++ .../src/auth/helpers/subject.helper.ts | 58 ++ BackendAcademy/src/auth/index.ts | 4 + BackendAcademy/src/chat/chat.service.ts | 5 +- .../src/common/response.interceptor.ts | 5 +- .../src/courses/course.controller.ts | 15 +- BackendAcademy/src/courses/course.module.ts | 2 + .../courses/progress/progress.controller.ts | 12 + .../src/courses/progress/progress.module.ts | 3 +- .../src/payments/payments.service.ts | 10 +- BackendAcademy/src/redis/redis.module.ts | 40 +- BackendAcademy/src/rewards/rewards.service.ts | 4 +- .../src/security/authorization.spec.ts | 554 ++++++++++++++++++ .../src/submissions/submission.controller.ts | 106 +++- .../submissions/tutor-review.controller.ts | 12 +- .../src/users/tutor-profile.controller.ts | 69 ++- .../src/users/tutor-profile.module.ts | 2 + .../src/users/user-profile.controller.ts | 55 +- .../src/users/user-profile.module.ts | 2 + BackendAcademy/src/users/users.controller.ts | 21 +- BackendAcademy/src/users/users.module.ts | 2 + BackendAcademy/src/users/users.service.ts | 5 + 38 files changed, 1412 insertions(+), 284 deletions(-) create mode 100644 BackendAcademy/src/auth/decorators/ownership.decorator.ts create mode 100644 BackendAcademy/src/auth/guards/guards.spec.ts create mode 100644 BackendAcademy/src/auth/guards/jwt-auth.guard.ts create mode 100644 BackendAcademy/src/auth/guards/subject-ownership.guard.ts create mode 100644 BackendAcademy/src/auth/helpers/subject.helper.ts create mode 100644 BackendAcademy/src/security/authorization.spec.ts diff --git a/BackendAcademy/package.json b/BackendAcademy/package.json index 6990c46ca..bfc64a895 100644 --- a/BackendAcademy/package.json +++ b/BackendAcademy/package.json @@ -20,11 +20,14 @@ "@nestjs/swagger": "^7.4.2", "@nestjs/throttler": "^6.5.0", "@nestjs/typeorm": "^10.0.2", + "@willsoto/nestjs-prometheus": "^6.1.0", "class-transformer": "^0.5.1", "class-validator": "^0.14.4", "file-type": "^19.6.0", + "ioredis": "^5.11.1", "joi": "^18.0.2", "multer": "^2.2.0", + "prom-client": "^15.1.3", "reflect-metadata": "^0.1.13", "rxjs": "^7.8.1", "typeorm": "^0.3.30", diff --git a/BackendAcademy/src/admin/admin.controller.ts b/BackendAcademy/src/admin/admin.controller.ts index ce22ad662..f79bb112b 100644 --- a/BackendAcademy/src/admin/admin.controller.ts +++ b/BackendAcademy/src/admin/admin.controller.ts @@ -1,8 +1,10 @@ -import { Controller, Get } from '@nestjs/common'; +import { Controller, Get, UseGuards } from '@nestjs/common'; import { AdminService } from './admin.service'; import { LocalizationService } from '../i18n/localization.service'; +import { JwtAdminGuard, RolesGuard, Roles, UserRole } from '../auth'; @Controller('admin') +@UseGuards(JwtAdminGuard, RolesGuard) export class AdminController { constructor( private readonly adminService: AdminService, @@ -10,6 +12,7 @@ export class AdminController { ) {} @Get('analytics/summary') + @Roles(UserRole.ADMIN) async getDashboardSummary() { const summary = await this.adminService.getDashboardSummary(); return { @@ -23,4 +26,4 @@ export class AdminController { data: summary, }; } -} +} \ No newline at end of file diff --git a/BackendAcademy/src/admin/admin.module.ts b/BackendAcademy/src/admin/admin.module.ts index 499c2f551..30dda9c93 100644 --- a/BackendAcademy/src/admin/admin.module.ts +++ b/BackendAcademy/src/admin/admin.module.ts @@ -2,9 +2,10 @@ import { Module } from '@nestjs/common'; import { AdminController } from './admin.controller'; import { AdminService } from './admin.service'; import { SubmissionsModule } from '../submissions/submissions.module'; +import { AuthModule } from '../auth/auth.module'; @Module({ - imports: [SubmissionsModule], + imports: [SubmissionsModule, AuthModule], controllers: [AdminController], providers: [AdminService], exports: [AdminService], diff --git a/BackendAcademy/src/ai/ai.service.ts b/BackendAcademy/src/ai/ai.service.ts index c57790129..80f68c739 100644 --- a/BackendAcademy/src/ai/ai.service.ts +++ b/BackendAcademy/src/ai/ai.service.ts @@ -350,59 +350,6 @@ export class AiService { ); } - - async getRecommendation(userId: string): Promise { - const snapshot = this.redisService - ? await this.redisService.getUserSnapshot(userId) - : null; - - if (!snapshot) { - return { - userId, - recommendations: [], - explainability: { - factors: ['insufficient_data'], - confidence: 0.1, - userSignalAge: 0, - signalsUsed: [], - modelVersion: 'rustacademy-recommender-v2', - }, - generatedAt: new Date(), - }; - } - - const explainability = this.redisService - ? await this.redisService.getRecommendationExplainability(userId) - : null; - - const recommendedCourses = snapshot.recentCourses.length > 0 - ? snapshot.recentCourses.slice(0, 3) - : ['rust-fundamentals', 'smart-contracts-101', 'stellar-basics']; - - const recommendations = recommendedCourses.map((courseId, index) => ({ - courseId, - score: Math.max(0, 1 - index * 0.2 - (snapshot.interactionCount > 0 ? 0 : 0.3)), - reason: explainability?.factors[index] || 'course_popularity', - })); - - if (this.monitoringService) { - this.monitoringService.recordDomainEvent('recommendation_generated', 'ai'); - } - - return { - userId, - recommendations, - explainability: explainability || { - factors: [], - confidence: 0.1, - userSignalAge: 0, - signalsUsed: [], - modelVersion: 'rustacademy-recommender-v2', - }, - generatedAt: new Date(), - }; - } - const lines = code.split('\n').filter((l) => l.trim().length > 0).length; const hasComments = code.includes('//') || code.includes('/*'); const hasFunctions = code.includes('fn '); @@ -466,6 +413,58 @@ export class AiService { }; } + async getRecommendation(userId: string): Promise { + const snapshot = this.redisService + ? await this.redisService.getUserSnapshot(userId) + : null; + + if (!snapshot) { + return { + userId, + recommendations: [], + explainability: { + factors: ['insufficient_data'], + confidence: 0.1, + userSignalAge: 0, + signalsUsed: [], + modelVersion: 'rustacademy-recommender-v2', + }, + generatedAt: new Date(), + }; + } + + const explainability = this.redisService + ? await this.redisService.getRecommendationExplainability(userId) + : null; + + const recommendedCourses = snapshot.recentCourses.length > 0 + ? snapshot.recentCourses.slice(0, 3) + : ['rust-fundamentals', 'smart-contracts-101', 'stellar-basics']; + + const recommendations = recommendedCourses.map((courseId, index) => ({ + courseId, + score: Math.max(0, 1 - index * 0.2 - (snapshot.interactionCount > 0 ? 0 : 0.3)), + reason: explainability?.factors[index] || 'course_popularity', + })); + + if (this.monitoringService) { + this.monitoringService.recordDomainEvent('recommendation_generated', 'ai'); + } + + return { + userId, + recommendations, + explainability: explainability || { + factors: [], + confidence: 0.1, + userSignalAge: 0, + signalsUsed: [], + modelVersion: 'rustacademy-recommender-v2', + }, + generatedAt: new Date(), + }; + } + // ────────────────────────────────────────────────────────────────── // Chat history management (#372, #373) // ────────────────────────────────────────────────────────────────── diff --git a/BackendAcademy/src/analytics/analytics.service.ts b/BackendAcademy/src/analytics/analytics.service.ts index d2f41a320..6e66eb4a3 100644 --- a/BackendAcademy/src/analytics/analytics.service.ts +++ b/BackendAcademy/src/analytics/analytics.service.ts @@ -404,7 +404,6 @@ export class AnalyticsService { totalDiscrepanciesFound: totalDiscrepancies, }; } -} // ── Notification batching analytics (#386) ──────────────── diff --git a/BackendAcademy/src/assets/assets.module.ts b/BackendAcademy/src/assets/assets.module.ts index 62efe6108..3516bf81b 100644 --- a/BackendAcademy/src/assets/assets.module.ts +++ b/BackendAcademy/src/assets/assets.module.ts @@ -1,7 +1,7 @@ -import {'Module' } from '@nestjs/common'; -import {'AssetsController' } from './assets.controller'; -import {'AssetsService' } from './assets.service'; -import {'SecurityModule' } from '../security/security.module'; +import { Module } from '@nestjs/common'; +import { AssetsController } from './assets.controller'; +import { AssetsService } from './assets.service'; +import { SecurityModule } from '../security/security.module'; /** * Module exposing asset upload, metadata, download, and delete endpoints diff --git a/BackendAcademy/src/audit/audit.service.ts b/BackendAcademy/src/audit/audit.service.ts index f3291ba90..4cdf4f0f2 100644 --- a/BackendAcademy/src/audit/audit.service.ts +++ b/BackendAcademy/src/audit/audit.service.ts @@ -39,7 +39,7 @@ export class AuditLogService { // Provide structured output to the standard logger without secrets this.logger.log( - \Audit: \ | Actor: \ | Outcome: \ | Session: \ | Correlation: \\ + `Audit: ${event.action} | Actor: ${event.actor || '-'} | Outcome: ${event.outcome} | Session: ${event.session || '-'} | Correlation: ${correlation}`, ); return log; diff --git a/BackendAcademy/src/auth/auth-session.controller.ts b/BackendAcademy/src/auth/auth-session.controller.ts index d5f25b5e3..6ade69713 100644 --- a/BackendAcademy/src/auth/auth-session.controller.ts +++ b/BackendAcademy/src/auth/auth-session.controller.ts @@ -68,9 +68,9 @@ export class AuthSessionController { @Get(':userId') @HttpCode(HttpStatus.OK) - getActiveSessions( + async getActiveSessions( @Param('userId') userId: string, - ): Omit[] { + ): Promise[]> { return this.authSessionService.getActiveSessions(userId); } diff --git a/BackendAcademy/src/auth/auth-session.service.ts b/BackendAcademy/src/auth/auth-session.service.ts index 7269c765e..cf66ed9a6 100644 --- a/BackendAcademy/src/auth/auth-session.service.ts +++ b/BackendAcademy/src/auth/auth-session.service.ts @@ -3,6 +3,7 @@ UnauthorizedException, Logger, Inject, + Optional, } from '@nestjs/common'; import { JwtService } from '@nestjs/jwt'; import { AuditLogService } from '../audit/audit.service'; @@ -68,11 +69,6 @@ const DEFAULT_SESSION_POLICY: SessionPolicy = { export class AuthSessionService { private readonly logger = new Logger(AuthSessionService.name); - /** - * Redis client for persistent storing of sessions and trusted devices. - */ - private readonly redis: Redis; - /** * #350: Centralized session policy */ @@ -82,6 +78,7 @@ export class AuthSessionService { private readonly jwtService: JwtService, private readonly configService: ConfigService, @Inject('REDIS_CLIENT') private readonly redis: Redis, + @Optional() private readonly auditService?: AuditLogService, ) { // #350: Load centralized session policy from config this.sessionPolicy = { @@ -99,10 +96,6 @@ export class AuthSessionService { return createHash('sha256').update(token).digest('hex'); } - private hashToken(token: string): string { - return createHash('sha256').update(token).digest('hex'); - } - // --------------------------------------------------------------------------- // #350: Public policy access // --------------------------------------------------------------------------- @@ -164,7 +157,8 @@ export class AuthSessionService { const session: Session = { sessionId, userId, - role, refreshTokenHash: this.hashToken(refreshToken), + role, + refreshTokenHash: this.hashToken(refreshToken), createdAt: now, expiresAt, revoked: false, @@ -175,13 +169,13 @@ export class AuthSessionService { }; await this.setSession(session); - if (deviceHash) await this.rds.sadd(`trustedDevices:${userId}`, deviceHash); + if (deviceHash) await this.redis.sadd(`trustedDevices:${userId}`, deviceHash); if (deviceHash && !(await this.isTrustedDevice(userId, deviceHash))) { this.logger.warn(`New device login for user ${userId}`); } - this.auditService.create({ action: 'login', actor: userId, outcome: 'SUCCESS', session: sessionId, requestContext: { deviceHash } }); + this.auditService?.create({ action: 'login', actor: userId, outcome: 'SUCCESS', session: sessionId, requestContext: { deviceHash } }); return this.buildTokensResponse(accessToken, refreshToken); } @@ -214,8 +208,6 @@ export class AuthSessionService { }); } - if (session.refreshToken !== rawRefreshToken) { - // Token reuse detected -- revoke the whole session as a security measure. if (this.hashToken(rawRefreshToken) !== session.refreshTokenHash) { // Token reuse detected — revoke the whole session as a security measure. session.revoked = true; @@ -239,7 +231,7 @@ export class AuthSessionService { session.revoked = true; await this.setSession(session); - this.auditService.create({ action: 'refresh', actor: session.userId, outcome: 'SUCCESS', session: session.sessionId }); + this.auditService?.create({ action: 'refresh', actor: session.userId, outcome: 'SUCCESS', session: session.sessionId }); return await this.createSession(session.userId, session.role); } @@ -253,7 +245,7 @@ export class AuthSessionService { session.revoked = true; await this.setSession(session); this.logger.log(`Session ${sessionId} revoked for user ${session.userId}`); - this.auditService.create({ action: 'logout', actor: session.userId, outcome: 'SUCCESS', session: sessionId }); + this.auditService?.create({ action: 'logout', actor: session.userId, outcome: 'SUCCESS', session: sessionId }); } } @@ -262,7 +254,7 @@ export class AuthSessionService { * Clears all associated refresh tokens and cached session data. */ async revokeAllUserSessions(userId: string): Promise { - const sessionIds = await this.rds.smembers(`userSessions:${userId}`); + const sessionIds = await this.redis.smembers(`userSessions:${userId}`); let count = 0; for (const sessionId of sessionIds) { const session = await this.getSession(sessionId); @@ -273,20 +265,20 @@ export class AuthSessionService { } } this.logger.log(`All ${count} sessions revoked for user ${userId}`); - this.auditService.create({ action: 'logout_all', actor: userId, outcome: 'SUCCESS', requestContext: { count } }); + this.auditService?.create({ action: 'logout_all', actor: userId, outcome: 'SUCCESS', requestContext: { count } }); } /** * Returns all active (non-revoked, non-expired) sessions for a user. */ - async getActiveSessions(userId: string): Promise[]> { - const sessionIds = await this.rds.smembers(`userSessions:${userId}`); + async getActiveSessions(userId: string): Promise[]> { + const sessionIds = await this.redis.smembers(`userSessions:${userId}`); const now = new Date(); - const result: Omit[] = []; + const result: Omit[] = []; for (const sessionId of sessionIds) { const session = await this.getSession(sessionId); if (session && !session.revoked && session.expiresAt > now) { - const { refreshToken, ...rest } = session; + const { refreshTokenHash: _omitted, ...rest } = session; result.push(rest); } } @@ -302,22 +294,22 @@ export class AuthSessionService { } async isTrustedDevice(userId: string, deviceHash: string): Promise { - const devices = await this.rds.smembers(`trustedDevices:${userId}`); + const devices = await this.redis.smembers(`trustedDevices:${userId}`); return devices.includes(deviceHash); } async addTrustedDevice(userId: string, deviceHash: string): Promise { - await this.rds.sadd(`trustedDevices:${userId}`, deviceHash); - this.auditService.create({ action: 'add_trusted_device', actor: userId, outcome: 'SUCCESS', requestContext: { deviceHash } }); + await this.redis.sadd(`trustedDevices:${userId}`, deviceHash); + this.auditService?.create({ action: 'add_trusted_device', actor: userId, outcome: 'SUCCESS', requestContext: { deviceHash } }); } async removeTrustedDevice(userId: string, deviceHash: string): Promise { - await this.rds.srem(`trustedDevices:${userId}`, deviceHash); - this.auditService.create({ action: 'remove_trusted_device', actor: userId, outcome: 'SUCCESS', requestContext: { deviceHash } }); + await this.redis.srem(`trustedDevices:${userId}`, deviceHash); + this.auditService?.create({ action: 'remove_trusted_device', actor: userId, outcome: 'SUCCESS', requestContext: { deviceHash } }); } async getTrustedDevices(userId: string): Promise { - return await this.rds.smembers(`trustedDevices:${userId}`); + return await this.redis.smembers(`trustedDevices:${userId}`); } async checkDeviceTrust(userId: string, deviceFingerprint: string): Promise<{ trusted: boolean; deviceHash: string }> { @@ -338,20 +330,20 @@ export class AuthSessionService { } private async getSession(sessionId: string): Promise { - const data = await this.rds.get(this.sessionKey(sessionId)); + const data = await this.redis.get(this.sessionKey(sessionId)); if (!data) return null; return JSON.parse(data) as Session; } private async setSession(session: Session): Promise { const tll = Math.max(1, Math.floor((session.expiresAt.getTime() - Date.now()) / 1000) + this.sessionPolicy.deliveryGracePeriod); - await this.rds.set(this.sessionKey(session.sessionId), JSON.stringify(session), 'EX', tll); + await this.redis.set(this.sessionKey(session.sessionId), JSON.stringify(session), 'EX', tll); const userKey = this.userSessionsKey(session.userId); - await this.rds.sadd(userKey, session.sessionId); + await this.redis.sadd(userKey, session.sessionId); } private get refreshSecret(): string { - return this.configService.get('JMT_REFRESH_SECRET', this.configService.get('JMT_SECRET', 'change-me')); + return this.configService.get('JWT_REFRESH_SECRET', this.configService.get('JWT_SECRET', 'change-me')); } private async signTokenPair( @@ -383,6 +375,8 @@ export class AuthSessionService { return { accessToken, refreshToken, + tokenType: 'Bearer', + expiresIn: this.sessionPolicy.accessTokenTtl, }; } } diff --git a/BackendAcademy/src/auth/auth.module.ts b/BackendAcademy/src/auth/auth.module.ts index cf76ed408..7fec0c755 100644 --- a/BackendAcademy/src/auth/auth.module.ts +++ b/BackendAcademy/src/auth/auth.module.ts @@ -2,10 +2,12 @@ import { Module } from '@nestjs/common'; import { JwtModule } from '@nestjs/jwt'; import { ConfigModule, ConfigService } from '@nestjs/config'; import { RedisModule } from '../redis/redis.module'; +import { JwtAuthGuard } from './guards/jwt-auth.guard'; import { JwtLearnerGuard } from './guards/jwt-learner.guard'; import { JwtTutorGuard } from './guards/jwt-tutor.guard'; import { JwtAdminGuard } from './guards/jwt-admin.guard'; import { RolesGuard } from './guards/roles.guard'; +import { SubjectOwnershipGuard } from './guards/subject-ownership.guard'; import { AuthSessionService } from './auth-session.service'; import { AuthSessionController } from './auth-session.controller'; import { AuditModule } from '../audit/audit.module'; @@ -26,18 +28,22 @@ import { AuditModule } from '../audit/audit.module'; ], controllers: [AuthSessionController], providers: [ + JwtAuthGuard, JwtLearnerGuard, JwtTutorGuard, JwtAdminGuard, RolesGuard, + SubjectOwnershipGuard, AuthSessionService, ], exports: [ JwtModule, + JwtAuthGuard, JwtLearnerGuard, JwtTutorGuard, JwtAdminGuard, RolesGuard, + SubjectOwnershipGuard, AuthSessionService, ], }) diff --git a/BackendAcademy/src/auth/decorators/ownership.decorator.ts b/BackendAcademy/src/auth/decorators/ownership.decorator.ts new file mode 100644 index 000000000..0f6d89dd3 --- /dev/null +++ b/BackendAcademy/src/auth/decorators/ownership.decorator.ts @@ -0,0 +1,16 @@ +import { SetMetadata } from '@nestjs/common'; + +export const OWNERSHIP_KEY = 'ownershipParams'; + +/** + * Declares which route params carry the authenticated user's own subject id. + * + * When combined with `SubjectOwnershipGuard`, non-admin callers are only + * allowed through when the given `:param` value equals the JWT subject + * (`req.user.sub`). + * + * Usage: + * @Ownership('userId') + */ +export const Ownership = (...params: string[]) => + SetMetadata(OWNERSHIP_KEY, params); \ No newline at end of file diff --git a/BackendAcademy/src/auth/guards/guards.spec.ts b/BackendAcademy/src/auth/guards/guards.spec.ts new file mode 100644 index 000000000..9386c7363 --- /dev/null +++ b/BackendAcademy/src/auth/guards/guards.spec.ts @@ -0,0 +1,215 @@ +import { ExecutionContext, ForbiddenException, UnauthorizedException } from '@nestjs/common'; +import { JwtService } from '@nestjs/jwt'; +import { JwtAuthGuard } from './jwt-auth.guard'; +import { JwtAdminGuard } from './jwt-admin.guard'; +import { JwtTutorGuard } from './jwt-tutor.guard'; +import { JwtLearnerGuard } from './jwt-learner.guard'; +import { RolesGuard } from './roles.guard'; +import { SubjectOwnershipGuard } from './subject-ownership.guard'; +import { UserRole } from '../enums/user-role.enum'; +import { JwtPayload } from '../interfaces/jwt-payload.interface'; + +function mockJwtService(verify?: jest.Mock) { + return { + verifyAsync: verify ?? jest.fn(), + } as unknown as JwtService; +} + +function mockContext(req: Record): ExecutionContext { + return { + getHandler: () => ({}), + getClass: () => ({}), + switchToHttp: () => ({ getRequest: () => req }), + } as unknown as ExecutionContext; +} + +function payload(sub: string, role: UserRole): JwtPayload { + return { sub, role }; +} + +describe('JwtAuthGuard', () => { + it('rejects requests without a Bearer token', async () => { + const guard = new JwtAuthGuard(mockJwtService()); + await expect( + guard.canActivate(mockContext({ headers: {} })), + ).rejects.toThrow(UnauthorizedException); + }); + + it('rejects requests with an invalid token', async () => { + const guard = new JwtAuthGuard( + mockJwtService(jest.fn().mockRejectedValue(new Error('bad signature'))), + ); + await expect( + guard.canActivate( + mockContext({ headers: { authorization: 'Bearer not.a.token' } }), + ), + ).rejects.toThrow(UnauthorizedException); + }); + + it('rejects tokens without a subject', async () => { + const guard = new JwtAuthGuard( + mockJwtService(jest.fn().mockResolvedValue({ role: UserRole.LEARNER })), + ); + await expect( + guard.canActivate( + mockContext({ headers: { authorization: 'Bearer valid' } }), + ), + ).rejects.toThrow(UnauthorizedException); + }); + + it('attaches the decoded payload to request.user for any role', async () => { + const guard = new JwtAuthGuard( + mockJwtService(jest.fn().mockResolvedValue(payload('u-1', UserRole.LEARNER))), + ); + const req: any = { headers: { authorization: 'Bearer valid' } }; + await expect(guard.canActivate(mockContext(req))).resolves.toBe(true); + expect(req.user).toMatchObject({ sub: 'u-1' }); + }); +}); + +describe('JwtAdminGuard', () => { + it('forbids non-admin JWTs', async () => { + const guard = new JwtAdminGuard( + mockJwtService(jest.fn().mockResolvedValue(payload('u-1', UserRole.LEARNER))), + ); + const req: any = { headers: { authorization: 'Bearer valid' } }; + await expect(guard.canActivate(mockContext(req))).rejects.toThrow( + ForbiddenException, + ); + }); + + it('allows admin JWTs and attaches request.user', async () => { + const guard = new JwtAdminGuard( + mockJwtService(jest.fn().mockResolvedValue(payload('a-1', UserRole.ADMIN))), + ); + const req: any = { headers: { authorization: 'Bearer valid' } }; + await expect(guard.canActivate(mockContext(req))).resolves.toBe(true); + expect(req.user).toMatchObject({ sub: 'a-1', role: UserRole.ADMIN }); + }); +}); + +describe('JwtTutorGuard', () => { + it('forbids non-tutor JWTs', async () => { + const guard = new JwtTutorGuard( + mockJwtService(jest.fn().mockResolvedValue(payload('u-1', UserRole.LEARNER))), + ); + await expect( + guard.canActivate( + mockContext({ headers: { authorization: 'Bearer valid' } }), + ), + ).rejects.toThrow(ForbiddenException); + }); + + it('allows tutor JWTs and attaches a standardized request.user', async () => { + const guard = new JwtTutorGuard( + mockJwtService(jest.fn().mockResolvedValue(payload('t-1', UserRole.TUTOR))), + ); + const req: any = { headers: { authorization: 'Bearer valid' } }; + await expect(guard.canActivate(mockContext(req))).resolves.toBe(true); + expect(req.user).toMatchObject({ sub: 't-1', role: UserRole.TUTOR }); + }); +}); + +describe('JwtLearnerGuard', () => { + it('forbids non-learner JWTs', async () => { + const guard = new JwtLearnerGuard( + mockJwtService(jest.fn().mockResolvedValue(payload('t-1', UserRole.TUTOR))), + ); + await expect( + guard.canActivate( + mockContext({ headers: { authorization: 'Bearer valid' } }), + ), + ).rejects.toThrow(ForbiddenException); + }); + + it('allows learner JWTs', async () => { + const guard = new JwtLearnerGuard( + mockJwtService(jest.fn().mockResolvedValue(payload('l-1', UserRole.LEARNER))), + ); + const req: any = { headers: { authorization: 'Bearer valid' } }; + await expect(guard.canActivate(mockContext(req))).resolves.toBe(true); + expect(req.user).toMatchObject({ sub: 'l-1', role: UserRole.LEARNER }); + }); +}); + +describe('RolesGuard', () => { + it('allows when no roles are declared', () => { + const guard = new RolesGuard({ + getAllAndOverride: jest.fn().mockReturnValue(undefined), + } as any); + const req: any = { user: payload('u-1', UserRole.LEARNER) }; + expect(guard.canActivate(mockContext(req))).toBe(true); + }); + + it('rejects when the user does not hold a declared role', () => { + const guard = new RolesGuard({ + getAllAndOverride: jest.fn().mockReturnValue([UserRole.ADMIN]), + } as any); + const req: any = { user: payload('u-1', UserRole.LEARNER) }; + expect(() => guard.canActivate(mockContext(req))).toThrow(ForbiddenException); + }); + + it('allows when the user holds a declared role', () => { + const guard = new RolesGuard({ + getAllAndOverride: jest + .fn() + .mockReturnValue([UserRole.LEARNER, UserRole.ADMIN]), + } as any); + const req: any = { user: payload('u-1', UserRole.LEARNER) }; + expect(guard.canActivate(mockContext(req))).toBe(true); + }); + + it('rejects when no authenticated user is present', () => { + const guard = new RolesGuard({ + getAllAndOverride: jest.fn().mockReturnValue([UserRole.LEARNER]), + } as any); + expect(() => guard.canActivate(mockContext({}))).toThrow(ForbiddenException); + }); +}); + +describe('SubjectOwnershipGuard', () => { + const reflector = (params?: string[]) => + ({ getAllAndOverride: jest.fn().mockReturnValue(params) }) as any; + + it('rejects when the user is not authenticated', () => { + const guard = new SubjectOwnershipGuard(reflector(['userId'])); + expect(() => + guard.canActivate( + mockContext({ params: { userId: 'u-1' } }), + ), + ).toThrow(ForbiddenException); + }); + + it('rejects when the route param belongs to another subject', () => { + const guard = new SubjectOwnershipGuard(reflector(['userId'])); + const req: any = { + user: payload('u-1', UserRole.LEARNER), + params: { userId: 'u-2' }, + }; + expect(() => guard.canActivate(mockContext(req))).toThrow(ForbiddenException); + }); + + it('allows when the route param matches the authenticated subject', () => { + const guard = new SubjectOwnershipGuard(reflector(['userId'])); + const req: any = { + user: payload('u-1', UserRole.LEARNER), + params: { userId: 'u-1' }, + }; + expect(guard.canActivate(mockContext(req))).toBe(true); + }); + + it('allows admins to operate across subjects', () => { + const guard = new SubjectOwnershipGuard(reflector(['userId'])); + const req: any = { + user: payload('a-1', UserRole.ADMIN), + params: { userId: 'u-999' }, + }; + expect(guard.canActivate(mockContext(req))).toBe(true); + }); + + it('allows when no ownership params are declared', () => { + const guard = new SubjectOwnershipGuard(reflector(undefined)); + const req: any = { user: payload('u-1', UserRole.LEARNER) }; + expect(guard.canActivate(mockContext(req))).toBe(true); + }); +}); \ No newline at end of file diff --git a/BackendAcademy/src/auth/guards/jwt-admin.guard.ts b/BackendAcademy/src/auth/guards/jwt-admin.guard.ts index 811e4ca1b..1653cadb4 100644 --- a/BackendAcademy/src/auth/guards/jwt-admin.guard.ts +++ b/BackendAcademy/src/auth/guards/jwt-admin.guard.ts @@ -1,12 +1,11 @@ import { - CanActivate, ExecutionContext, - Injectable, - UnauthorizedException, ForbiddenException, + Injectable, } from '@nestjs/common'; import { JwtService } from '@nestjs/jwt'; import { Request } from 'express'; +import { JwtAuthGuard } from './jwt-auth.guard'; import { JwtPayload } from '../interfaces/jwt-payload.interface'; import { UserRole } from '../enums/user-role.enum'; @@ -19,44 +18,24 @@ import { UserRole } from '../enums/user-role.enum'; * On success, attaches `request.user` with the decoded payload. */ @Injectable() -export class JwtAdminGuard implements CanActivate { - constructor(private readonly jwtService: JwtService) {} +export class JwtAdminGuard extends JwtAuthGuard { + constructor(jwtService: JwtService) { + super(jwtService); + } async canActivate(context: ExecutionContext): Promise { - const request = context.switchToHttp().getRequest(); - const token = this.extractBearerToken(request); - - if (!token) { - throw new UnauthorizedException({ - error: 'MISSING_TOKEN', - message: 'Authorization header with Bearer token is required', - }); - } + await super.canActivate(context); + const request = context + .switchToHttp() + .getRequest(); - let payload: JwtPayload; - try { - payload = await this.jwtService.verifyAsync(token); - } catch { - throw new UnauthorizedException({ - error: 'INVALID_TOKEN', - message: 'Token is invalid or has expired', - }); - } - - if (payload.role !== UserRole.ADMIN) { + if (request.user.role !== UserRole.ADMIN) { throw new ForbiddenException({ error: 'ADMIN_ROLE_REQUIRED', message: 'Only admins are allowed to access this resource', }); } - // Attach decoded user identity for downstream handlers - (request as Request & { user: JwtPayload }).user = payload; return true; } - - private extractBearerToken(request: Request): string | undefined { - const [type, token] = request.headers.authorization?.split(' ') ?? []; - return type === 'Bearer' ? token : undefined; - } -} +} \ No newline at end of file diff --git a/BackendAcademy/src/auth/guards/jwt-auth.guard.ts b/BackendAcademy/src/auth/guards/jwt-auth.guard.ts new file mode 100644 index 000000000..fb6c8f5f3 --- /dev/null +++ b/BackendAcademy/src/auth/guards/jwt-auth.guard.ts @@ -0,0 +1,69 @@ +import { + CanActivate, + ExecutionContext, + Injectable, + UnauthorizedException, +} from '@nestjs/common'; +import { JwtService } from '@nestjs/jwt'; +import { Request } from 'express'; +import { JwtPayload } from '../interfaces/jwt-payload.interface'; + +/** + * Generic authentication guard. + * + * Verifies the `Authorization: Bearer ` header and attaches the + * decoded `JwtPayload` to `request.user` for downstream role guards and + * handlers. Role enforcement is the responsibility of `JwtAdminGuard`, + * `JwtTutorGuard`, `JwtLearnerGuard`, or `RolesGuard`. + */ +@Injectable() +export class JwtAuthGuard implements CanActivate { + constructor(protected readonly jwtService: JwtService) {} + + async canActivate(context: ExecutionContext): Promise { + const request = context.switchToHttp().getRequest(); + const token = this.extractBearerToken(request); + + if (!token) { + throw new UnauthorizedException({ + error: 'MISSING_TOKEN', + message: 'Authorization header with Bearer token is required', + }); + } + + let payload: JwtPayload; + try { + payload = await this.jwtService.verifyAsync(token); + } catch { + throw new UnauthorizedException({ + error: 'INVALID_TOKEN', + message: 'Token is invalid or has expired', + }); + } + + // A valid signature alone is not enough — a subject must be present. + if (!payload || !payload.sub) { + throw new UnauthorizedException({ + error: 'INVALID_TOKEN', + message: 'Token is invalid or has expired', + }); + } + + this.attachUser(request, payload); + return true; + } + + /** + * Attaches the decoded identity to `request.user`. Standardizes the + * subject property across every role guard so ownership guards and + * handlers can always read `request.user`. + */ + protected attachUser(request: Request, payload: JwtPayload): void { + (request as Request & { user: JwtPayload }).user = payload; + } + + protected extractBearerToken(request: Request): string | undefined { + const [type, token] = request.headers.authorization?.split(' ') ?? []; + return type === 'Bearer' ? token : undefined; + } +} \ No newline at end of file diff --git a/BackendAcademy/src/auth/guards/jwt-learner.guard.ts b/BackendAcademy/src/auth/guards/jwt-learner.guard.ts index e51944886..ce55ccdc3 100644 --- a/BackendAcademy/src/auth/guards/jwt-learner.guard.ts +++ b/BackendAcademy/src/auth/guards/jwt-learner.guard.ts @@ -1,12 +1,11 @@ import { - CanActivate, ExecutionContext, - Injectable, - UnauthorizedException, ForbiddenException, + Injectable, } from '@nestjs/common'; import { JwtService } from '@nestjs/jwt'; import { Request } from 'express'; +import { JwtAuthGuard } from './jwt-auth.guard'; import { JwtPayload } from '../interfaces/jwt-payload.interface'; import { UserRole } from '../enums/user-role.enum'; @@ -19,44 +18,24 @@ import { UserRole } from '../enums/user-role.enum'; * On success, attaches `request.user` with the decoded payload. */ @Injectable() -export class JwtLearnerGuard implements CanActivate { - constructor(private readonly jwtService: JwtService) {} +export class JwtLearnerGuard extends JwtAuthGuard { + constructor(jwtService: JwtService) { + super(jwtService); + } async canActivate(context: ExecutionContext): Promise { - const request = context.switchToHttp().getRequest(); - const token = this.extractBearerToken(request); - - if (!token) { - throw new UnauthorizedException({ - error: 'MISSING_TOKEN', - message: 'Authorization header with Bearer token is required', - }); - } + await super.canActivate(context); + const request = context + .switchToHttp() + .getRequest(); - let payload: JwtPayload; - try { - payload = await this.jwtService.verifyAsync(token); - } catch { - throw new UnauthorizedException({ - error: 'INVALID_TOKEN', - message: 'Token is invalid or has expired', - }); - } - - if (payload.role !== UserRole.LEARNER) { + if (request.user.role !== UserRole.LEARNER) { throw new ForbiddenException({ error: 'LEARNER_ROLE_REQUIRED', message: 'Only learners are allowed to access this resource', }); } - // Attach decoded user identity for downstream handlers - (request as Request & { user: JwtPayload }).user = payload; return true; } - - private extractBearerToken(request: Request): string | undefined { - const [type, token] = request.headers.authorization?.split(' ') ?? []; - return type === 'Bearer' ? token : undefined; - } -} +} \ No newline at end of file diff --git a/BackendAcademy/src/auth/guards/jwt-tutor.guard.ts b/BackendAcademy/src/auth/guards/jwt-tutor.guard.ts index c2318f987..bdcb0f233 100644 --- a/BackendAcademy/src/auth/guards/jwt-tutor.guard.ts +++ b/BackendAcademy/src/auth/guards/jwt-tutor.guard.ts @@ -1,12 +1,11 @@ import { - CanActivate, ExecutionContext, - Injectable, - UnauthorizedException, ForbiddenException, + Injectable, } from '@nestjs/common'; import { JwtService } from '@nestjs/jwt'; import { Request } from 'express'; +import { JwtAuthGuard } from './jwt-auth.guard'; import { JwtPayload } from '../interfaces/jwt-payload.interface'; import { UserRole } from '../enums/user-role.enum'; @@ -16,47 +15,31 @@ import { UserRole } from '../enums/user-role.enum'; * Expects an `Authorization: Bearer ` header. * The token payload must contain `role: "tutor"`. * - * On success, attaches `request.tutor` with the decoded payload. + * On success, attaches `request.user` (and the deprecated `request.tutor` + * alias) with the decoded payload. */ @Injectable() -export class JwtTutorGuard implements CanActivate { - constructor(private readonly jwtService: JwtService) {} +export class JwtTutorGuard extends JwtAuthGuard { + constructor(jwtService: JwtService) { + super(jwtService); + } async canActivate(context: ExecutionContext): Promise { - const request = context.switchToHttp().getRequest(); - const token = this.extractBearerToken(request); - - if (!token) { - throw new UnauthorizedException({ - error: 'MISSING_TOKEN', - message: 'Authorization header with Bearer token is required', - }); - } + await super.canActivate(context); + const request = context + .switchToHttp() + .getRequest(); - let payload: JwtPayload; - try { - payload = await this.jwtService.verifyAsync(token); - } catch { - throw new UnauthorizedException({ - error: 'INVALID_TOKEN', - message: 'Token is invalid or has expired', - }); - } - - if (payload.role !== UserRole.TUTOR) { + if (request.user.role !== UserRole.TUTOR) { throw new ForbiddenException({ error: 'TUTOR_ROLE_REQUIRED', message: 'Only tutors are allowed to access this resource', }); } - // Attach decoded tutor identity for downstream handlers - (request as Request & { tutor: JwtPayload }).tutor = payload; + // Backward-compatibility alias for handlers that read `request.tutor`. + (request as Request & { user: JwtPayload; tutor?: JwtPayload }).tutor = + request.user; return true; } - - private extractBearerToken(request: Request): string | undefined { - const [type, token] = request.headers.authorization?.split(' ') ?? []; - return type === 'Bearer' ? token : undefined; - } -} +} \ No newline at end of file diff --git a/BackendAcademy/src/auth/guards/subject-ownership.guard.ts b/BackendAcademy/src/auth/guards/subject-ownership.guard.ts new file mode 100644 index 000000000..26663aa15 --- /dev/null +++ b/BackendAcademy/src/auth/guards/subject-ownership.guard.ts @@ -0,0 +1,72 @@ +import { + CanActivate, + ExecutionContext, + ForbiddenException, + Injectable, +} from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; +import { Request } from 'express'; +import { OWNERSHIP_KEY } from '../decorators/ownership.decorator'; +import { JwtPayload } from '../interfaces/jwt-payload.interface'; +import { UserRole } from '../enums/user-role.enum'; + +/** + * Guard that enforces subject ownership on a per-route basis. + * + * Run this guard AFTER a JWT guard has attached `req.user`, and declare the + * route params that identify the target subject with `@Ownership(...)`. + * + * - Admins are allowed to operate across subjects. + * - Non-admins must have `req.user.sub` equal to every declared param value, + * otherwise the request is rejected with 403 SUBJECT_MISMATCH. + * + * Usage: + * @UseGuards(JwtAuthGuard, RolesGuard, SubjectOwnershipGuard) + * @Roles(UserRole.LEARNER, UserRole.ADMIN) + * @Ownership('userId') + * async method() { ... } + */ +@Injectable() +export class SubjectOwnershipGuard implements CanActivate { + constructor(private readonly reflector: Reflector) {} + + canActivate(context: ExecutionContext): boolean { + const request = context + .switchToHttp() + .getRequest(); + const user = request.user; + + if (!user) { + throw new ForbiddenException({ + error: 'FORBIDDEN', + message: 'Authentication is required before ownership can be verified', + }); + } + + // Admins manage every subject. + if (user.role === UserRole.ADMIN) { + return true; + } + + const ownedParams = this.reflector.getAllAndOverride( + OWNERSHIP_KEY, + [context.getHandler(), context.getClass()], + ); + + if (!ownedParams || ownedParams.length === 0) { + return true; + } + + for (const param of ownedParams) { + const claimedSubject = request.params?.[param]; + if (claimedSubject !== undefined && claimedSubject !== user.sub) { + throw new ForbiddenException({ + error: 'SUBJECT_MISMATCH', + message: 'You do not have access to another user\'s resource', + }); + } + } + + return true; + } +} \ No newline at end of file diff --git a/BackendAcademy/src/auth/helpers/subject.helper.ts b/BackendAcademy/src/auth/helpers/subject.helper.ts new file mode 100644 index 000000000..a4bc35781 --- /dev/null +++ b/BackendAcademy/src/auth/helpers/subject.helper.ts @@ -0,0 +1,58 @@ +import { ForbiddenException } from '@nestjs/common'; +import { JwtPayload } from '../interfaces/jwt-payload.interface'; +import { UserRole } from '../enums/user-role.enum'; + +/** + * Verifies that an authenticated user owns a resource whose subject id is + * resolved separately (e.g. from a loaded entity). + * + * Admins are allowed to operate across subjects; everyone else must match + * the JWT subject exactly. Throws 403 SUBJECT_MISMATCH otherwise. + */ +export function assertSameSubject( + user: JwtPayload | undefined, + ownerId: string | null | undefined, + resource = 'resource', +): void { + if (!user) { + throw new ForbiddenException({ + error: 'FORBIDDEN', + message: 'Authentication is required before ownership can be verified', + }); + } + + if (user.role === UserRole.ADMIN) { + return; + } + + if (!ownerId || ownerId !== user.sub) { + throw new ForbiddenException({ + error: 'SUBJECT_MISMATCH', + message: `You do not have access to this ${resource}`, + }); + } +} + +/** + * Allowed for the owning user unless the caller holds one of `staffRoles` + * (e.g. tutor or admin). Non-staff callers must own the resource. + */ +export function assertOwnerOrStaff( + user: JwtPayload | undefined, + ownerId: string | null | undefined, + staffRoles: UserRole[] = [UserRole.TUTOR, UserRole.ADMIN], + resource = 'resource', +): void { + if (!user) { + throw new ForbiddenException({ + error: 'FORBIDDEN', + message: 'Authentication is required before ownership can be verified', + }); + } + + if (staffRoles.includes(user.role)) { + return; + } + + assertSameSubject(user, ownerId, resource); +} \ No newline at end of file diff --git a/BackendAcademy/src/auth/index.ts b/BackendAcademy/src/auth/index.ts index 7037017e4..890fafbdc 100644 --- a/BackendAcademy/src/auth/index.ts +++ b/BackendAcademy/src/auth/index.ts @@ -1,9 +1,13 @@ export { AuthModule } from './auth.module'; +export { JwtAuthGuard } from './guards/jwt-auth.guard'; export { JwtLearnerGuard } from './guards/jwt-learner.guard'; export { JwtTutorGuard } from './guards/jwt-tutor.guard'; export { JwtAdminGuard } from './guards/jwt-admin.guard'; export { RolesGuard } from './guards/roles.guard'; +export { SubjectOwnershipGuard } from './guards/subject-ownership.guard'; export { Roles, ROLES_KEY } from './decorators/roles.decorator'; +export { Ownership, OWNERSHIP_KEY } from './decorators/ownership.decorator'; +export { assertSameSubject, assertOwnerOrStaff } from './helpers/subject.helper'; export { UserRole } from './enums/user-role.enum'; export { JwtPayload } from './interfaces/jwt-payload.interface'; export { AuthSessionService } from './auth-session.service'; diff --git a/BackendAcademy/src/chat/chat.service.ts b/BackendAcademy/src/chat/chat.service.ts index 4b64d9427..508d2661a 100644 --- a/BackendAcademy/src/chat/chat.service.ts +++ b/BackendAcademy/src/chat/chat.service.ts @@ -104,7 +104,10 @@ export class ChatService { (m) => (m as any).streamingComplete !== false, ); return before - this.messages.length; - * Run outgoing chat content through the SecurityService prompt sanitiser + } + + /** + * #371: Run outgoing chat content through the SecurityService prompt sanitiser * when the content is long enough that it might plausibly be AI-bound or * carry instructions. The original behaviour is preserved when no * SecurityService is wired in (e.g. unit tests). diff --git a/BackendAcademy/src/common/response.interceptor.ts b/BackendAcademy/src/common/response.interceptor.ts index b0d05ad6d..6a4153d7f 100644 --- a/BackendAcademy/src/common/response.interceptor.ts +++ b/BackendAcademy/src/common/response.interceptor.ts @@ -1,4 +1,4 @@ -import { CallHandler, ExecutionContext, Injectable, NestInterceptor } from '@nestjs-common'; +import { CallHandler, ExecutionContext, Injectable, NestInterceptor } from '@nestjs/common'; import { Observable } from 'rxjs'; import { map } from 'rxjs/operators'; @@ -8,7 +8,8 @@ export interface ResponseEnvelope { data: T; } -@Injectable()\nexport class ResponseInterceptor implements NestInterceptor> { +@Injectable() +export class ResponseInterceptor implements NestInterceptor> { intercept(context: ExecutionContext, next: CallHandler): Observable> { return next.handle().pipe( map((rawData) => { diff --git a/BackendAcademy/src/courses/course.controller.ts b/BackendAcademy/src/courses/course.controller.ts index 295fd37c5..db36bb15f 100644 --- a/BackendAcademy/src/courses/course.controller.ts +++ b/BackendAcademy/src/courses/course.controller.ts @@ -22,8 +22,13 @@ import { RestoreRevisionDto } from './dto/restore-revision.dto'; import { CompleteCourseDto } from './dto/complete-course.dto'; import { CreateRatingDto } from './dto/create-rating.dto'; import { CourseRatingStatsDto } from './dto/rating-stats.dto'; -import { JwtLearnerGuard } from '../auth/guards/jwt-learner.guard'; -import { JwtPayload } from '../auth/interfaces/jwt-payload.interface'; +import { + JwtLearnerGuard, + RolesGuard, + Roles, + UserRole, + JwtPayload, +} from '../auth'; @Controller('courses') export class CourseController { @@ -131,7 +136,8 @@ export class CourseController { * Returns 201 Created on first submission, 200 OK on update. */ @Post(':id/ratings') - @UseGuards(JwtLearnerGuard) + @UseGuards(JwtLearnerGuard, RolesGuard) + @Roles(UserRole.LEARNER) async submitRating( @Param('id') courseId: string, @Body() dto: CreateRatingDto, @@ -174,7 +180,8 @@ export class CourseController { */ @Delete(':id/ratings') @HttpCode(204) - @UseGuards(JwtLearnerGuard) + @UseGuards(JwtLearnerGuard, RolesGuard) + @Roles(UserRole.LEARNER) async deleteRating( @Param('id') courseId: string, @Request() req: Express.Request & { user: JwtPayload }, diff --git a/BackendAcademy/src/courses/course.module.ts b/BackendAcademy/src/courses/course.module.ts index 3387b872c..8f2bef8df 100644 --- a/BackendAcademy/src/courses/course.module.ts +++ b/BackendAcademy/src/courses/course.module.ts @@ -12,6 +12,7 @@ import { TransactionManagerService } from '../common/transaction-manager.service import { ConfigModule } from '@nestjs/config'; import { SearchModule } from '../search/search.module'; import { RedisModule } from '../redis/redis.module'; +import { AuthModule } from '../auth/auth.module'; @Module({ imports: [ @@ -24,6 +25,7 @@ import { RedisModule } from '../redis/redis.module'; RewardsModule, SearchModule, RedisModule, + AuthModule, ], controllers: [CourseController], providers: [ diff --git a/BackendAcademy/src/courses/progress/progress.controller.ts b/BackendAcademy/src/courses/progress/progress.controller.ts index e0acb7c38..a903680fe 100644 --- a/BackendAcademy/src/courses/progress/progress.controller.ts +++ b/BackendAcademy/src/courses/progress/progress.controller.ts @@ -10,7 +10,16 @@ import { ParseUUIDPipe, Post, Put, + UseGuards, } from '@nestjs/common'; +import { + JwtAuthGuard, + RolesGuard, + SubjectOwnershipGuard, + Roles, + Ownership, + UserRole, +} from '../../auth'; import { RegisterCourseProgressDto } from './dto/register-course-progress.dto'; import { RecordLessonCompletionDto, @@ -23,6 +32,9 @@ import { import { CourseProgressRecord, ProgressService } from './progress.service'; @Controller('courses/progress') +@UseGuards(JwtAuthGuard, RolesGuard, SubjectOwnershipGuard) +@Roles(UserRole.LEARNER, UserRole.ADMIN) +@Ownership('userId') export class ProgressController { constructor(private readonly progressService: ProgressService) {} diff --git a/BackendAcademy/src/courses/progress/progress.module.ts b/BackendAcademy/src/courses/progress/progress.module.ts index 99c32ad92..839e5d949 100644 --- a/BackendAcademy/src/courses/progress/progress.module.ts +++ b/BackendAcademy/src/courses/progress/progress.module.ts @@ -3,9 +3,10 @@ import { CourseModule } from '../course.module'; import { ProgressController } from './progress.controller'; import { ProgressService } from './progress.service'; import { TransactionManagerService } from '../../common/transaction-manager.service'; +import { AuthModule } from '../../auth/auth.module'; @Module({ - imports: [CourseModule], + imports: [CourseModule, AuthModule], controllers: [ProgressController], providers: [ProgressService, TransactionManagerService], exports: [ProgressService], diff --git a/BackendAcademy/src/payments/payments.service.ts b/BackendAcademy/src/payments/payments.service.ts index 87452880f..8cba5cd51 100644 --- a/BackendAcademy/src/payments/payments.service.ts +++ b/BackendAcademy/src/payments/payments.service.ts @@ -122,7 +122,7 @@ export class PaymentsService { private readonly databaseService: DatabaseService, @Optional() private readonly contractAdapter?: IContractAdapter, - ) {} + @Optional() private readonly configService?: ConfigService, ) { this.defaultTimeoutMs = this.configService?.get('DEFAULT_REQUEST_TIMEOUT_MS') ?? 30_000; @@ -261,14 +261,6 @@ export class PaymentsService { async getAllCoupons() { return this.databaseService.getAllCoupons(); } -} async getRedemptionHistory(userId: string) { - return this.databaseService.getRedemptionsByUser(userId); - } - - async getAllCoupons() { - return this.databaseService.getAllCoupons(); - } -} /** * Processes a validated, signature-checked payment webhook event. diff --git a/BackendAcademy/src/redis/redis.module.ts b/BackendAcademy/src/redis/redis.module.ts index c34bc96dc..20f3b4fe6 100644 --- a/BackendAcademy/src/redis/redis.module.ts +++ b/BackendAcademy/src/redis/redis.module.ts @@ -2,44 +2,6 @@ import { Module, Global } from '@nestjs/common'; import { ConfigModule, ConfigService } from '@nestjs/config'; import { RedisService } from './redis.service'; -@Global() -@Module({ - imports: [ConfigModule], - providers: [ - { - provide: 'REDIS_OPTIONS', - useFactory: (config: ConfigService) => ({ - host: config.get('REDIS_HOST', 'localhost'), - port: config.get('REDIS_PORT', 6379), - password: config.get('REDIS_PASSWORD'), - }), - inject: [ConfigService], - }, - RedisService, - { - provide: 'SessionStore', - inject: [RedisService], - useFactory: (redis: RedisService) => ({ - get: async (sessionId: string) => { - const raw = await redis.get(`session:${sessionId}`); - return raw ? JSON.parse(raw) : null; - }, - set: async (sessionId: string, data: any, ttlSeconds: number) => { - await redis.set(`session:${sessionId}`, JSON.stringify(data), 'EX', ttlSeconds); - }, - delete: async (sessionId: string) => { - await redis.del(`session:${sessionId}`); - }, - }), - }, - ], - exports: ['REDIS_OPTIONS', RedisService, 'SessionStore'], -}) -export class RedisModule {} -import { Module, Global } from '@nestjs/common'; -import { ConfigModule, ConfigService } from '@nestjs/config'; -import { RedisService } from './redis.service'; - function parsePort(value: string | undefined, defaultValue: number): number { const raw = value ?? defaultValue.toString(); const port = Number(raw); @@ -66,4 +28,4 @@ function parsePort(value: string | undefined, defaultValue: number): number { ], exports: ['REDIS_OPTIONS', RedisService], }) -export class RedisModule {} +export class RedisModule {} \ No newline at end of file diff --git a/BackendAcademy/src/rewards/rewards.service.ts b/BackendAcademy/src/rewards/rewards.service.ts index e229ff46a..1b93794a6 100644 --- a/BackendAcademy/src/rewards/rewards.service.ts +++ b/BackendAcademy/src/rewards/rewards.service.ts @@ -355,9 +355,7 @@ export class RewardsService { if (this.monitoringService) { this.monitoringService.recordDomainEvent('prize_distributed', 'rewards'); - this.monitoringService.incrementCounter('reward_redemptions_total', 1, { - poolId: id, - }); + this.monitoringService.recordDomainEvent('reward_redemptions_total', 'rewards'); } return { diff --git a/BackendAcademy/src/security/authorization.spec.ts b/BackendAcademy/src/security/authorization.spec.ts new file mode 100644 index 000000000..d73ab1f8c --- /dev/null +++ b/BackendAcademy/src/security/authorization.spec.ts @@ -0,0 +1,554 @@ +import { INestApplication, Controller, Get, Post, Put, Param, UseGuards } from '@nestjs/common'; +import { Test } from '@nestjs/testing'; +import { JwtModule, JwtService } from '@nestjs/jwt'; +import { Reflector } from '@nestjs/core'; +import { AddressInfo } from 'net'; + +import { ProgressController } from '../courses/progress/progress.controller'; +import { ProgressService } from '../courses/progress/progress.service'; +import { UserProfileController } from '../users/user-profile.controller'; +import { UserProfileService } from '../users/user-profile.service'; +import { TutorProfileController } from '../users/tutor-profile.controller'; +import { TutorProfileService } from '../users/tutor-profile.service'; +import { SubmissionController } from '../submissions/submission.controller'; +import { SubmissionService } from '../submissions/submission.service'; +import { GradingResultService } from '../submissions/grading-result.service'; +import { TutorReviewController } from '../submissions/tutor-review.controller'; +import { TutorReviewService } from '../submissions/tutor-review.service'; + +import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; +import { JwtAdminGuard } from '../auth/guards/jwt-admin.guard'; +import { JwtTutorGuard } from '../auth/guards/jwt-tutor.guard'; +import { JwtLearnerGuard } from '../auth/guards/jwt-learner.guard'; +import { RolesGuard } from '../auth/guards/roles.guard'; +import { SubjectOwnershipGuard } from '../auth/guards/subject-ownership.guard'; +import { Roles } from '../auth/decorators/roles.decorator'; +import { Ownership } from '../auth/decorators/ownership.decorator'; +import { UserRole } from '../auth/enums/user-role.enum'; + +const L1 = '10000000-0000-4000-8000-000000000001'; +const L2 = '10000000-0000-4000-8000-000000000002'; +const T1 = '20000000-0000-4000-8000-000000000001'; +const T2 = '20000000-0000-4000-8000-000000000002'; +const A1 = '30000000-0000-4000-8000-000000000001'; +const PROF_1 = '40000000-0000-4000-8000-000000000001'; +const PROF_2 = '40000000-0000-4000-8000-000000000002'; +const SUB_L1 = '50000000-0000-4000-8000-000000000001'; +const SUB_L2 = '50000000-0000-4000-8000-000000000002'; + +/** + * Probe routers that replicate the exact guard wiring applied by BA-018 to + * routes whose real controllers depend on the half-migrated admin/users + * service layer (admin.service / reports.service do not compile at HEAD). + * The guards, decorators, and role/ownership metadata under test are the + * real production classes. + */ + +@Controller('probe/admin') +@UseGuards(JwtAuthGuard, RolesGuard) +@Roles(UserRole.ADMIN) +class ProbeAdminController { + @Get('summary') + summary() { + return { ok: true }; + } +} + +@Controller('probe/users') +@UseGuards(JwtAuthGuard, RolesGuard, SubjectOwnershipGuard) +@Roles(UserRole.LEARNER, UserRole.ADMIN) +@Ownership('userId') +class ProbeUsersController { + @Put(':userId/preferences') + updatePreferences(@Param('userId') userId: string) { + return { userId, learnerPreferences: {}, tutorPreferences: {} }; + } +} + +describe('Authorization (roles + subject ownership)', () => { + let app: INestApplication; + let port: number; + let jwt: JwtService; + + const progressService = { + getSnapshot: jest.fn(), + getCourseSnapshot: jest.fn(), + registerCourse: jest.fn(), + recordLessonCompletion: jest.fn(), + recordTaskCompletion: jest.fn(), + resetLearner: jest.fn(), + } as unknown as ProgressService; + + const userProfileService = { + create: jest.fn(), + findAll: jest.fn(), + findByUserId: jest.fn(), + findById: jest.fn(), + update: jest.fn(), + remove: jest.fn(), + } as unknown as UserProfileService; + + const tutorProfileService = { + create: jest.fn(), + findById: jest.fn(), + findPending: jest.fn(), + update: jest.fn(), + getEarningsSummary: jest.fn(), + rate: jest.fn(), + getReviews: jest.fn(), + } as unknown as TutorProfileService; + + const submissionService = { + findById: jest.fn(), + findAll: jest.fn(), + findByTaskId: jest.fn(), + findByUserId: jest.fn(), + findDraftsByUserId: jest.fn(), + findByStatus: jest.fn(), + create: jest.fn(), + update: jest.fn(), + review: jest.fn(), + remove: jest.fn(), + saveDraft: jest.fn(), + publishDraft: jest.fn(), + } as unknown as SubmissionService; + + const gradingResultService = { + saveResult: jest.fn(), + getResultsBySubmission: jest.fn(), + getLatestResult: jest.fn(), + getResultById: jest.fn(), + deleteResult: jest.fn(), + } as unknown as GradingResultService; + + const tutorReviewService = { + getReviewedByTutor: jest.fn(), + reviewSubmission: jest.fn(), + } as unknown as TutorReviewService; + + /** + * jest.config re-enables mock implementations on every test + * (clearMocks/resetMocks/restoreMocks, #451), so each mock's fixtures + * must be re-installed in a beforeEach. + */ + function reinstallMocks(): void { + (progressService.getSnapshot as jest.Mock).mockImplementation(async (userId: string) => ({ + userId, + generatedAt: new Date(), + overall: {}, + courses: [], + })); + (progressService.getCourseSnapshot as jest.Mock).mockResolvedValue(null); + (progressService.registerCourse as jest.Mock).mockImplementation(async (userId: string) => ({ userId })); + (progressService.recordLessonCompletion as jest.Mock).mockImplementation(async (userId: string) => ({ userId })); + (progressService.recordTaskCompletion as jest.Mock).mockImplementation(async (userId: string) => ({ userId })); + (progressService.resetLearner as jest.Mock).mockResolvedValue(true); + + (userProfileService.create as jest.Mock).mockImplementation(async (dto: any) => ({ id: PROF_2, ...dto })); + (userProfileService.findAll as jest.Mock).mockResolvedValue([]); + (userProfileService.findByUserId as jest.Mock).mockResolvedValue(null); + (userProfileService.findById as jest.Mock).mockImplementation(async (id: string) => + id === PROF_1 ? { id: PROF_1, userId: L1 } : null, + ); + (userProfileService.update as jest.Mock).mockImplementation(async (id: string, dto: any) => ({ id, ...dto })); + (userProfileService.remove as jest.Mock).mockResolvedValue(true); + + (tutorProfileService.create as jest.Mock).mockImplementation(async (dto: any) => ({ id: PROF_1, ...dto })); + (tutorProfileService.findById as jest.Mock).mockImplementation(async (id: string) => + id === PROF_1 ? { id: PROF_1, userId: T1, bio: 'hello tutor' } : null, + ); + (tutorProfileService.findPending as jest.Mock).mockResolvedValue([]); + (tutorProfileService.update as jest.Mock).mockImplementation(async (id: string) => ({ id })); + (tutorProfileService.getEarningsSummary as jest.Mock).mockImplementation(async (id: string) => ({ + tutorId: id, + earnedXlm: 0, + totalPaidOut: 0, + pendingPayouts: 0, + payouts: [], + })); + (tutorProfileService.rate as jest.Mock).mockImplementation(async (id: string, dto: any) => ({ id, ...dto })); + (tutorProfileService.getReviews as jest.Mock).mockResolvedValue([]); + + (submissionService.findById as jest.Mock).mockImplementation(async (id: string) => ({ + id, + userId: id === SUB_L2 ? L2 : L1, + content: 'x', + })); + (submissionService.findAll as jest.Mock).mockResolvedValue([]); + (submissionService.findByTaskId as jest.Mock).mockResolvedValue([]); + (submissionService.findByUserId as jest.Mock).mockImplementation(async (userId: string) => + userId === L1 ? [{ id: SUB_L1, userId: L1 }] : [], + ); + (submissionService.findDraftsByUserId as jest.Mock).mockResolvedValue([]); + (submissionService.findByStatus as jest.Mock).mockResolvedValue([]); + (submissionService.create as jest.Mock).mockImplementation(async (dto: any) => ({ id: SUB_L1, ...dto })); + (submissionService.update as jest.Mock).mockImplementation(async (id: string, dto: any) => ({ id, ...dto })); + (submissionService.review as jest.Mock).mockImplementation( + async (id: string, reviewerId: string, status: string, feedback?: string, score?: number) => ({ id, reviewedBy: reviewerId }), + ); + (submissionService.remove as jest.Mock).mockResolvedValue(true); + (submissionService.saveDraft as jest.Mock).mockImplementation(async (dto: any) => ({ id: SUB_L1, ...dto })); + (submissionService.publishDraft as jest.Mock).mockImplementation(async (id: string) => ({ id })); + + (gradingResultService.saveResult as jest.Mock).mockImplementation(async (id: string, dto: any) => ({ id, submissionId: id, ...dto })); + (gradingResultService.getResultsBySubmission as jest.Mock).mockResolvedValue([]); + (gradingResultService.getLatestResult as jest.Mock).mockResolvedValue(null); + (gradingResultService.getResultById as jest.Mock).mockResolvedValue({}); + (gradingResultService.deleteResult as jest.Mock).mockResolvedValue(undefined); + + (tutorReviewService.getReviewedByTutor as jest.Mock).mockImplementation(async (tutorId: string) => ({ + items: [], + total: 0, + nextCursor: null, + })); + (tutorReviewService.reviewSubmission as jest.Mock).mockImplementation(async (id: string, tutorId: string) => ({ id, reviewedBy: tutorId })); + } + + beforeEach(reinstallMocks); + + beforeAll(async () => { + const moduleRef = await Test.createTestingModule({ + imports: [ + JwtModule.register({ secret: 'test-secret', signOptions: { expiresIn: '5m' } }), + ], + controllers: [ + ProbeAdminController, + ProbeUsersController, + ProgressController, + UserProfileController, + TutorProfileController, + SubmissionController, + TutorReviewController, + ], + providers: [ + { provide: ProgressService, useValue: progressService }, + { provide: UserProfileService, useValue: userProfileService }, + { provide: TutorProfileService, useValue: tutorProfileService }, + { provide: SubmissionService, useValue: submissionService }, + { provide: GradingResultService, useValue: gradingResultService }, + { provide: TutorReviewService, useValue: tutorReviewService }, + { provide: Reflector, useValue: new Reflector() }, + JwtAuthGuard, + JwtAdminGuard, + JwtTutorGuard, + JwtLearnerGuard, + RolesGuard, + SubjectOwnershipGuard, + ], + }).compile(); + + app = moduleRef.createNestApplication(); + await app.init(); + const server = await app.listen(0); + port = (server.address() as AddressInfo).port; + jwt = moduleRef.get(JwtService); + }); + + afterAll(async () => { + await app.close(); + }); + + async function signToken(sub: string, role: UserRole): Promise { + return jwt.sign({ sub, role }); + } + + async function request( + method: 'GET' | 'POST' | 'PUT' | 'DELETE', + path: string, + opts: { token?: string; body?: unknown } = {}, + ) { + const res = await fetch(`http://127.0.0.1:${port}${path}`, { + method, + headers: { + ...(opts.token ? { Authorization: `Bearer ${opts.token}` } : {}), + ...(opts.body !== undefined ? { 'Content-Type': 'application/json' } : {}), + }, + body: opts.body !== undefined ? JSON.stringify(opts.body) : undefined, + }); + const text = await res.text(); + let body: unknown = null; + try { + body = text ? JSON.parse(text) : null; + } catch { + body = text; + } + return { status: res.status, body }; + } + + describe('Admin route (role-gated)', () => { + it('rejects unauthenticated requests', async () => { + const { status } = await request('GET', '/probe/admin/summary'); + expect(status).toBe(401); + }); + + it('rejects non-admin roles', async () => { + const learner = await signToken(L1, UserRole.LEARNER); + const { status } = await request('GET', '/probe/admin/summary', { token: learner }); + expect(status).toBe(403); + }); + + it('allows admins', async () => { + const admin = await signToken(A1, UserRole.ADMIN); + const { status } = await request('GET', '/probe/admin/summary', { token: admin }); + expect(status).toBe(200); + }); + }); + + describe('Own preferences route (role + subject ownership)', () => { + it('rejects unauthenticated requests', async () => { + const { status } = await request('PUT', `/probe/users/${L1}/preferences`); + expect(status).toBe(401); + }); + + it('rejects tutors on a learner-only route', async () => { + const tutor = await signToken(T1, UserRole.TUTOR); + const { status } = await request('PUT', `/probe/users/${T1}/preferences`, { token: tutor }); + expect(status).toBe(403); + }); + + it('allows users to update their own preferences', async () => { + const learner = await signToken(L1, UserRole.LEARNER); + const { status } = await request('PUT', `/probe/users/${L1}/preferences`, { + token: learner, + body: { learnerPreferences: { theme: 'dark' } }, + }); + expect(status).toBe(200); + }); + + it('blocks cross-user preference updates', async () => { + const learner2 = await signToken(L2, UserRole.LEARNER); + const { status } = await request('PUT', `/probe/users/${L1}/preferences`, { + token: learner2, + body: { learnerPreferences: { theme: 'dark' } }, + }); + expect(status).toBe(403); + }); + + it('lets admins update any user preferences', async () => { + const admin = await signToken(A1, UserRole.ADMIN); + const { status } = await request('PUT', `/probe/users/${L1}/preferences`, { + token: admin, + body: { tutorPreferences: { timezone: 'UTC' } }, + }); + expect(status).toBe(200); + }); + }); + + describe('ProgressController (subject-owned learner data)', () => { + it('rejects unauthenticated requests', async () => { + const { status } = await request('GET', `/courses/progress/snapshot/${L1}`); + expect(status).toBe(401); + }); + + it('rejects roles outside learner/admin', async () => { + const tutor = await signToken(T1, UserRole.TUTOR); + const { status } = await request('GET', `/courses/progress/snapshot/${T1}`, { token: tutor }); + expect(status).toBe(403); + }); + + it('allows a learner to read their own snapshot', async () => { + const learner = await signToken(L1, UserRole.LEARNER); + const { status } = await request('GET', `/courses/progress/snapshot/${L1}`, { token: learner }); + expect(status).toBe(200); + expect(progressService.getSnapshot).toHaveBeenCalledWith(L1); + }); + + it('blocks cross-user access', async () => { + const learner2 = await signToken(L2, UserRole.LEARNER); + const { status, body } = await request('GET', `/courses/progress/snapshot/${L1}`, { token: learner2 }); + expect(status).toBe(403); + expect(body).toMatchObject({ error: 'SUBJECT_MISMATCH' }); + }); + + it('lets admins read any snapshot', async () => { + const admin = await signToken(A1, UserRole.ADMIN); + const { status } = await request('GET', `/courses/progress/snapshot/${L1}`, { token: admin }); + expect(status).toBe(200); + }); + }); + + describe('UserProfileController (own profile)', () => { + it('binds the profile to the authenticated subject on create', async () => { + const learner = await signToken(L1, UserRole.LEARNER); + const { status } = await request('POST', '/user-profiles', { + token: learner, + body: { userId: L2, displayName: 'spoofed' }, + }); + expect(status).toBe(201); + expect(userProfileService.create).toHaveBeenCalledWith( + expect.objectContaining({ userId: L1 }), + ); + }); + + it('rejects cross-user profile updates', async () => { + const learner2 = await signToken(L2, UserRole.LEARNER); + const { status } = await request('PUT', `/user-profiles/${PROF_1}`, { + token: learner2, + body: { displayName: 'hijacked' }, + }); + expect(status).toBe(403); + }); + + it('allows the owner to update their profile', async () => { + const learner = await signToken(L1, UserRole.LEARNER); + const { status } = await request('PUT', `/user-profiles/${PROF_1}`, { + token: learner, + body: { displayName: 'me' }, + }); + expect(status).toBe(200); + }); + }); + + describe('TutorProfileController (own profile + admin-only listing)', () => { + it('rejects unauthenticated earnings access', async () => { + const { status } = await request('GET', `/tutors/${PROF_1}/earnings`); + expect(status).toBe(401); + }); + + it('blocks a tutor from viewing another tutor earnings', async () => { + const tutor2 = await signToken(T2, UserRole.TUTOR); + const { status } = await request('GET', `/tutors/${PROF_1}/earnings`, { token: tutor2 }); + expect(status).toBe(403); + }); + + it('allows the owning tutor to view their earnings', async () => { + const tutor1 = await signToken(T1, UserRole.TUTOR); + const { status } = await request('GET', `/tutors/${PROF_1}/earnings`, { token: tutor1 }); + expect(status).toBe(200); + }); + + it('restricts the pending-verification listing to admins', async () => { + const learner = await signToken(L1, UserRole.LEARNER); + const tutor = await signToken(T1, UserRole.TUTOR); + expect((await request('GET', '/tutors/pending', { token: learner })).status).toBe(403); + expect((await request('GET', '/tutors/pending', { token: tutor })).status).toBe(403); + const admin = await signToken(A1, UserRole.ADMIN); + expect((await request('GET', '/tutors/pending', { token: admin })).status).toBe(200); + }); + + it('prevents a tutor from rating their own profile', async () => { + const tutor1 = await signToken(T1, UserRole.TUTOR); + const { status, body } = await request('POST', `/tutors/${PROF_1}/rate`, { + token: tutor1, + body: { rating: 5 }, + }); + expect(status).toBe(403); + expect(body).toMatchObject({ error: 'SELF_RATE_FORBIDDEN' }); + }); + + it('binds the rater subject to the JWT when rating another tutor', async () => { + const learner = await signToken(L1, UserRole.LEARNER); + const { status } = await request('POST', `/tutors/${PROF_1}/rate`, { + token: learner, + body: { raterUserId: T2, rating: 5 }, + }); + expect(status).toBe(201); + expect(tutorProfileService.rate).toHaveBeenCalledWith( + PROF_1, + expect.objectContaining({ raterUserId: L1 }), + ); + }); + }); + + describe('TutorReviewController (tutor role declared)', () => { + it('rejects learners', async () => { + const learner = await signToken(L1, UserRole.LEARNER); + const { status } = await request('GET', '/tutor/review/history', { token: learner }); + expect(status).toBe(403); + }); + + it('scopes review history to the calling tutor', async () => { + const tutor1 = await signToken(T1, UserRole.TUTOR); + const { status } = await request('GET', '/tutor/review/history', { token: tutor1 }); + expect(status).toBe(200); + expect(tutorReviewService.getReviewedByTutor).toHaveBeenCalledWith(T1, expect.anything()); + }); + }); + + describe('SubmissionController (learner subject ownership)', () => { + it('forces the author user id from the JWT on create', async () => { + const learner1 = await signToken(L1, UserRole.LEARNER); + const { status } = await request('POST', '/submissions', { + token: learner1, + body: { taskId: 'task-1', userId: L2, content: 'answer' }, + }); + expect(status).toBe(201); + expect(submissionService.create).toHaveBeenCalledWith( + expect.objectContaining({ userId: L1 }), + ); + }); + + it('rejects unauthenticated submissions', async () => { + const { status } = await request('POST', '/submissions', { + body: { taskId: 'task-1', userId: L1, content: 'answer' }, + }); + expect(status).toBe(401); + }); + + it('allows a learner to list their own submissions', async () => { + const learner1 = await signToken(L1, UserRole.LEARNER); + const { status } = await request('GET', `/submissions/user/${L1}`, { token: learner1 }); + expect(status).toBe(200); + }); + + it('blocks cross-user submission listing', async () => { + const learner2 = await signToken(L2, UserRole.LEARNER); + const { status } = await request('GET', `/submissions/user/${L1}`, { token: learner2 }); + expect(status).toBe(403); + }); + + it('restricts the global listing to tutors/admins', async () => { + const learner = await signToken(L1, UserRole.LEARNER); + const tutor = await signToken(T1, UserRole.TUTOR); + expect((await request('GET', '/submissions', { token: learner })).status).toBe(403); + expect((await request('GET', '/submissions', { token: tutor })).status).toBe(200); + }); + + it('blocks cross-user submission updates', async () => { + const learner1 = await signToken(L1, UserRole.LEARNER); + const { status } = await request('PUT', `/submissions/${SUB_L2}`, { + token: learner1, + body: { content: 'overwritten' }, + }); + expect(status).toBe(403); + }); + + it('allows the owning learner to update their submission', async () => { + const learner2 = await signToken(L2, UserRole.LEARNER); + const { status } = await request('PUT', `/submissions/${SUB_L2}`, { + token: learner2, + body: { content: 'fixed' }, + }); + expect(status).toBe(200); + }); + + it('restricts review to tutors/admins and binds the reviewer id', async () => { + const learner = await signToken(L1, UserRole.LEARNER); + expect((await request('POST', `/submissions/${SUB_L1}/review`, { + token: learner, + body: { status: 'approved' }, + })).status).toBe(403); + + const tutor1 = await signToken(T1, UserRole.TUTOR); +const { status } = await request('POST', `/submissions/${SUB_L1}/review`, { + token: tutor1, + body: { status: 'approved', feedback: 'nice' }, + }); + expect(status).toBe(201); + expect(submissionService.review).toHaveBeenCalledWith(SUB_L1, T1, 'approved', 'nice', undefined); + }); + + it('binds the grader id to the JWT subject for grading', async () => { + const tutor1 = await signToken(T1, UserRole.TUTOR); + const { status } = await request('POST', `/submissions/${SUB_L1}/grade`, { + token: tutor1, + body: { graderId: 'someone-else', status: 'pass', score: 10, maxScore: 10, feedback: 'ok' }, + }); + expect(status).toBe(201); + expect(gradingResultService.saveResult).toHaveBeenCalledWith( + SUB_L1, + expect.objectContaining({ graderId: T1 }), + ); + }); + }); +}); \ No newline at end of file diff --git a/BackendAcademy/src/submissions/submission.controller.ts b/BackendAcademy/src/submissions/submission.controller.ts index f70ae6897..b715578b9 100644 --- a/BackendAcademy/src/submissions/submission.controller.ts +++ b/BackendAcademy/src/submissions/submission.controller.ts @@ -9,7 +9,11 @@ import { ParseUUIDPipe, HttpCode, HttpStatus, + UseGuards, + Req, + NotFoundException, } from '@nestjs/common'; +import { Request } from 'express'; import { SubmissionService } from './submission.service'; import { GradingResultService } from './grading-result.service'; import { CreateSubmissionDto } from './dto/create-submission.dto'; @@ -17,7 +21,26 @@ import { UpdateSubmissionDto } from './dto/update-submission.dto'; import { SaveDraftDto } from './dto/save-draft.dto'; import { SaveGradingResultDto } from './dto/save-grading-result.dto'; import { SubmissionStatus } from './interfaces/submission-status.enum'; +import { SubmissionEntity } from './submission.entity'; +import { + JwtAuthGuard, + RolesGuard, + SubjectOwnershipGuard, + Roles, + Ownership, + UserRole, + JwtPayload, + assertOwnerOrStaff, +} from '../auth'; + +type AuthedRequest = Request & { user: JwtPayload }; +/** + * Submission API. + * + * Learners manage their own submissions (subject ownership verified against + * the JWT subject); tutors and admins operate the review/grading surface. + */ @Controller('submissions') export class SubmissionController { constructor( @@ -30,61 +53,99 @@ export class SubmissionController { // --------------------------------------------------------------------------- @Post() - async create(@Body() dto: CreateSubmissionDto) { - return this.submissionService.create(dto); + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles(UserRole.LEARNER, UserRole.ADMIN) + async create(@Body() dto: CreateSubmissionDto, @Req() req: AuthedRequest) { + // The authoring learner always comes from the JWT, never the payload. + return this.submissionService.create({ ...dto, userId: req.user.sub }); } @Get() + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles(UserRole.TUTOR, UserRole.ADMIN) async findAll() { return this.submissionService.findAll(); } @Get('task/:taskId') + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles(UserRole.TUTOR, UserRole.ADMIN) async findByTaskId(@Param('taskId') taskId: string) { return this.submissionService.findByTaskId(taskId); } @Get('user/:userId') + @UseGuards(JwtAuthGuard, RolesGuard, SubjectOwnershipGuard) + @Roles(UserRole.LEARNER, UserRole.ADMIN) + @Ownership('userId') async findByUserId(@Param('userId') userId: string) { return this.submissionService.findByUserId(userId); } @Get('user/:userId/drafts') + @UseGuards(JwtAuthGuard, RolesGuard, SubjectOwnershipGuard) + @Roles(UserRole.LEARNER, UserRole.ADMIN) + @Ownership('userId') async findDraftsByUserId(@Param('userId') userId: string) { return this.submissionService.findDraftsByUserId(userId); } @Get('status/:status') + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles(UserRole.TUTOR, UserRole.ADMIN) async findByStatus(@Param('status') status: SubmissionStatus) { return this.submissionService.findByStatus(status); } @Get(':id') - async findById(@Param('id', ParseUUIDPipe) id: string) { - return this.submissionService.findById(id); + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles(UserRole.LEARNER, UserRole.TUTOR, UserRole.ADMIN) + async findById(@Param('id', ParseUUIDPipe) id: string, @Req() req: AuthedRequest) { + const submission = await this.submissionService.findById(id); + if (!submission) throw new NotFoundException('Submission not found'); + assertOwnerOrStaff(req.user, submission.userId, [UserRole.TUTOR, UserRole.ADMIN], 'submission'); + return submission; } @Put(':id') + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles(UserRole.LEARNER, UserRole.TUTOR, UserRole.ADMIN) async update( @Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateSubmissionDto, + @Req() req: AuthedRequest, ) { - return this.submissionService.update(id, dto); + const submission = await this.submissionService.findById(id); + if (!submission) throw new NotFoundException('Submission not found'); + assertOwnerOrStaff(req.user, submission.userId, [UserRole.TUTOR, UserRole.ADMIN], 'submission'); + // Ownership fields are immutable through this generic update path. + return this.submissionService.update(id, { + ...dto, + userId: submission.userId, + reviewedBy: submission.reviewedBy, + } as UpdateSubmissionDto); } @Post(':id/review') + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles(UserRole.TUTOR, UserRole.ADMIN) async review( @Param('id', ParseUUIDPipe) id: string, - @Body('reviewedBy') reviewerId: string, + @Req() req: AuthedRequest, @Body('status') status: SubmissionStatus, @Body('feedback') feedback?: string, @Body('score') score?: number, ) { - return this.submissionService.review(id, reviewerId, status, feedback, score); + return this.submissionService.review(id, req.user.sub, status, feedback, score); } @Delete(':id') - async remove(@Param('id', ParseUUIDPipe) id: string) { + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles(UserRole.LEARNER, UserRole.TUTOR, UserRole.ADMIN) + async remove(@Param('id', ParseUUIDPipe) id: string, @Req() req: AuthedRequest) { + const submission = await this.submissionService.findById(id); + if (!submission) throw new NotFoundException('Submission not found'); + assertOwnerOrStaff(req.user, submission.userId, [UserRole.TUTOR, UserRole.ADMIN], 'submission'); return this.submissionService.remove(id); } @@ -100,8 +161,10 @@ export class SubmissionController { * created with status = DRAFT. */ @Post('draft') - async saveDraft(@Body() dto: SaveDraftDto) { - return this.submissionService.saveDraft(dto); + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles(UserRole.LEARNER, UserRole.ADMIN) + async saveDraft(@Body() dto: SaveDraftDto, @Req() req: AuthedRequest) { + return this.submissionService.saveDraft({ ...dto, userId: req.user.sub }); } /** @@ -111,7 +174,12 @@ export class SubmissionController { * workflow. Returns 400 if the submission is not a draft. */ @Post(':id/publish') - async publishDraft(@Param('id', ParseUUIDPipe) id: string) { + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles(UserRole.LEARNER, UserRole.TUTOR, UserRole.ADMIN) + async publishDraft(@Param('id', ParseUUIDPipe) id: string, @Req() req: AuthedRequest) { + const submission = await this.submissionService.findById(id); + if (!submission) throw new NotFoundException('Submission not found'); + assertOwnerOrStaff(req.user, submission.userId, [UserRole.TUTOR, UserRole.ADMIN], 'submission'); return this.submissionService.publishDraft(id); } @@ -126,11 +194,15 @@ export class SubmissionController { * submission's status, score, and feedback to keep them in sync. */ @Post(':id/grade') + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles(UserRole.TUTOR, UserRole.ADMIN) async saveGradingResult( @Param('id', ParseUUIDPipe) id: string, @Body() dto: SaveGradingResultDto, + @Req() req: AuthedRequest, ) { - return this.gradingResultService.saveResult(id, dto); + // The grader identity always comes from the JWT. + return this.gradingResultService.saveResult(id, { ...dto, graderId: req.user.sub }); } /** @@ -139,6 +211,8 @@ export class SubmissionController { * Retrieve all grading results for a submission, oldest-first. */ @Get(':id/grades') + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles(UserRole.TUTOR, UserRole.ADMIN) async getGradingResults(@Param('id', ParseUUIDPipe) id: string) { return this.gradingResultService.getResultsBySubmission(id); } @@ -149,6 +223,8 @@ export class SubmissionController { * Retrieve only the most recent grading result for a submission. */ @Get(':id/grades/latest') + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles(UserRole.TUTOR, UserRole.ADMIN) async getLatestGradingResult(@Param('id', ParseUUIDPipe) id: string) { return this.gradingResultService.getLatestResult(id); } @@ -159,6 +235,8 @@ export class SubmissionController { * Retrieve a single grading result by its own ID. */ @Get('grades/:gradeId') + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles(UserRole.TUTOR, UserRole.ADMIN) async getGradingResultById(@Param('gradeId', ParseUUIDPipe) gradeId: string) { return this.gradingResultService.getResultById(gradeId); } @@ -170,7 +248,9 @@ export class SubmissionController { */ @Delete('grades/:gradeId') @HttpCode(HttpStatus.NO_CONTENT) + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles(UserRole.TUTOR, UserRole.ADMIN) async deleteGradingResult(@Param('gradeId', ParseUUIDPipe) gradeId: string) { await this.gradingResultService.deleteResult(gradeId); } -} +} \ No newline at end of file diff --git a/BackendAcademy/src/submissions/tutor-review.controller.ts b/BackendAcademy/src/submissions/tutor-review.controller.ts index 4bc90e76f..1480921b0 100644 --- a/BackendAcademy/src/submissions/tutor-review.controller.ts +++ b/BackendAcademy/src/submissions/tutor-review.controller.ts @@ -17,9 +17,12 @@ import { TutorReviewService, ReviewQueuePage, ReviewStats } from './tutor-review import { ReviewSubmissionDto } from './dto/review-submission.dto'; import { ReviewQueueQueryDto } from './dto/review-queue-query.dto'; import { JwtTutorGuard } from '../auth/guards/jwt-tutor.guard'; +import { RolesGuard } from '../auth/guards/roles.guard'; import { JwtPayload } from '../auth/interfaces/jwt-payload.interface'; +import { Roles } from '../auth/decorators/roles.decorator'; +import { UserRole } from '../auth/enums/user-role.enum'; -type AuthedRequest = Request & { tutor: JwtPayload }; +type AuthedRequest = Request & { user: JwtPayload }; /** * Tutor Review Queue API @@ -36,7 +39,8 @@ type AuthedRequest = Request & { tutor: JwtPayload }; * │ POST /tutor/review/:id Review a sub │ * └──────────────────────────────────────────────────────────┘ */ -@UseGuards(JwtTutorGuard) +@UseGuards(JwtTutorGuard, RolesGuard) +@Roles(UserRole.TUTOR) @Controller('tutor/review') export class TutorReviewController { constructor(private readonly tutorReviewService: TutorReviewService) {} @@ -106,7 +110,7 @@ export class TutorReviewController { @Req() req: AuthedRequest, @Query() query: ReviewQueueQueryDto, ): Promise { - return this.tutorReviewService.getReviewedByTutor(req.tutor.sub, query); + return this.tutorReviewService.getReviewedByTutor(req.user.sub, query); } // ─── Review action ──────────────────────────────────────────────────────── @@ -134,6 +138,6 @@ export class TutorReviewController { @Req() req: AuthedRequest, @Body() dto: ReviewSubmissionDto, ) { - return this.tutorReviewService.reviewSubmission(id, req.tutor.sub, dto); + return this.tutorReviewService.reviewSubmission(id, req.user.sub, dto); } } diff --git a/BackendAcademy/src/users/tutor-profile.controller.ts b/BackendAcademy/src/users/tutor-profile.controller.ts index 79b2956b8..8c447b59b 100644 --- a/BackendAcademy/src/users/tutor-profile.controller.ts +++ b/BackendAcademy/src/users/tutor-profile.controller.ts @@ -7,22 +7,50 @@ import { Body, Param, ParseUUIDPipe, + UseGuards, + Req, + NotFoundException, + ForbiddenException, } from '@nestjs/common'; +import { Request } from 'express'; import { TutorProfileService } from './tutor-profile.service'; import { CreateTutorProfileDto } from './dto/create-tutor-profile.dto'; import { UpdateTutorProfileDto } from './dto/update-tutor-profile.dto'; import { RateTutorDto } from './dto/rate-tutor.dto'; -import { VerifyTutorDto } from './dto/verify-tutor.dto'; -import { RequestVerificationDto } from './dto/request-verification.dto'; import { TutorProfileEntity } from './tutor-profile.entity'; +import { + JwtAuthGuard, + JwtAdminGuard, + RolesGuard, + Roles, + UserRole, + JwtPayload, + assertSameSubject, +} from '../auth'; + +type AuthedRequest = Request & { user: JwtPayload }; +/** + * Tutor profile API. + * + * Public surface (browse/list verified tutors) stays open. Privileged + * routes declare their required roles via @Roles and verify that a tutor + * only touches their own profile via subject-ownership checks. + */ @Controller('tutors') export class TutorProfileController { constructor(private readonly tutorService: TutorProfileService) {} @Post() - async create(@Body() dto: CreateTutorProfileDto): Promise { - return this.tutorService.create(dto); + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles(UserRole.TUTOR, UserRole.ADMIN) + async create( + @Body() dto: CreateTutorProfileDto, + @Req() req: AuthedRequest, + ): Promise { + // The profile owner is always the authenticated subject; never trust a + // client-supplied userId. + return this.tutorService.create({ ...dto, userId: req.user.sub }); } // ---- Static collection routes (MUST come before /:id) ----------------- @@ -38,6 +66,8 @@ export class TutorProfileController { } @Get('pending') + @UseGuards(JwtAdminGuard, RolesGuard) + @Roles(UserRole.ADMIN) async listPending(): Promise { return this.tutorService.findPending(); } @@ -62,26 +92,55 @@ export class TutorProfileController { } @Get(':id/earnings') + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles(UserRole.TUTOR, UserRole.ADMIN) async getEarningsSummary( @Param('id', ParseUUIDPipe) id: string, + @Req() req: AuthedRequest, ): Promise> { + const profile = await this.tutorService.findById(id); + if (!profile) { + throw new NotFoundException('Tutor profile not found'); + } + assertSameSubject(req.user, profile.userId, 'tutor profile'); return this.tutorService.getEarningsSummary(id); } @Put(':id') + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles(UserRole.TUTOR, UserRole.ADMIN) async update( @Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateTutorProfileDto, + @Req() req: AuthedRequest, ): Promise { + const profile = await this.tutorService.findById(id); + if (!profile) { + throw new NotFoundException('Tutor profile not found'); + } + assertSameSubject(req.user, profile.userId, 'tutor profile'); return this.tutorService.update(id, dto); } @Post(':id/rate') + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles(UserRole.LEARNER, UserRole.TUTOR, UserRole.ADMIN) async rate( @Param('id', ParseUUIDPipe) id: string, @Body() dto: RateTutorDto, + @Req() req: AuthedRequest, ): Promise { - return this.tutorService.rate(id, dto); + const profile = await this.tutorService.findById(id); + if (!profile) { + throw new NotFoundException('Tutor profile not found'); + } + if (profile.userId === req.user.sub) { + throw new ForbiddenException({ + error: 'SELF_RATE_FORBIDDEN', + message: 'You cannot rate your own tutor profile', + }); + } + return this.tutorService.rate(id, { ...dto, raterUserId: req.user.sub }); } @Get(':id/reviews') diff --git a/BackendAcademy/src/users/tutor-profile.module.ts b/BackendAcademy/src/users/tutor-profile.module.ts index c190f07c3..d7db531e3 100644 --- a/BackendAcademy/src/users/tutor-profile.module.ts +++ b/BackendAcademy/src/users/tutor-profile.module.ts @@ -1,8 +1,10 @@ import { Module } from '@nestjs/common'; import { TutorProfileController } from './tutor-profile.controller'; import { TutorProfileService } from './tutor-profile.service'; +import { AuthModule } from '../auth/auth.module'; @Module({ + imports: [AuthModule], controllers: [TutorProfileController], providers: [TutorProfileService], exports: [TutorProfileService], diff --git a/BackendAcademy/src/users/user-profile.controller.ts b/BackendAcademy/src/users/user-profile.controller.ts index eb94863fe..46d6af77e 100644 --- a/BackendAcademy/src/users/user-profile.controller.ts +++ b/BackendAcademy/src/users/user-profile.controller.ts @@ -7,17 +7,43 @@ import { Body, Param, ParseUUIDPipe, + UseGuards, + Req, + BadRequestException, } from '@nestjs/common'; +import { Request } from 'express'; import { UserProfileService } from './user-profile.service'; import { UserProfileEntity } from './user-profile.entity'; +import { + JwtAuthGuard, + RolesGuard, + Roles, + UserRole, + JwtPayload, + assertSameSubject, +} from '../auth'; + +type AuthedRequest = Request & { user: JwtPayload }; +/** + * User profile API. + * + * Reads stay public; writes are gated on authentication and ownership so + * a user can only create/edit/delete their own profile. + */ @Controller('user-profiles') export class UserProfileController { constructor(private readonly profileService: UserProfileService) {} @Post() - async create(@Body() dto: Partial) { - return this.profileService.create(dto); + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles(UserRole.LEARNER, UserRole.TUTOR, UserRole.ADMIN) + async create( + @Body() dto: Partial, + @Req() req: AuthedRequest, + ) { + // The subject always comes from the JWT; ignore any client-supplied userId. + return this.profileService.create({ ...dto, userId: req.user.sub }); } @Get() @@ -36,15 +62,34 @@ export class UserProfileController { } @Put(':id') + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles(UserRole.LEARNER, UserRole.TUTOR, UserRole.ADMIN) async update( @Param('id', ParseUUIDPipe) id: string, @Body() updates: Partial, + @Req() req: AuthedRequest, ) { - return this.profileService.update(id, updates); + const profile = await this.profileService.findById(id); + if (!profile) { + throw new BadRequestException({ statusCode: 404, message: 'Profile not found' }); + } + assertSameSubject(req.user, profile.userId, 'profile'); + // Never allow reassigning the profile to another subject. + return this.profileService.update(id, { ...updates, userId: profile.userId }); } @Delete(':id') - async remove(@Param('id', ParseUUIDPipe) id: string) { + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles(UserRole.LEARNER, UserRole.TUTOR, UserRole.ADMIN) + async remove( + @Param('id', ParseUUIDPipe) id: string, + @Req() req: AuthedRequest, + ) { + const profile = await this.profileService.findById(id); + if (!profile) { + throw new BadRequestException({ statusCode: 404, message: 'Profile not found' }); + } + assertSameSubject(req.user, profile.userId, 'profile'); return this.profileService.remove(id); } -} +} \ No newline at end of file diff --git a/BackendAcademy/src/users/user-profile.module.ts b/BackendAcademy/src/users/user-profile.module.ts index bf92a083b..ff4a31611 100644 --- a/BackendAcademy/src/users/user-profile.module.ts +++ b/BackendAcademy/src/users/user-profile.module.ts @@ -1,8 +1,10 @@ import { Module } from '@nestjs/common'; import { UserProfileController } from './user-profile.controller'; import { UserProfileService } from './user-profile.service'; +import { AuthModule } from '../auth/auth.module'; @Module({ + imports: [AuthModule], controllers: [UserProfileController], providers: [UserProfileService], exports: [UserProfileService], diff --git a/BackendAcademy/src/users/users.controller.ts b/BackendAcademy/src/users/users.controller.ts index e55c1d520..60d865093 100644 --- a/BackendAcademy/src/users/users.controller.ts +++ b/BackendAcademy/src/users/users.controller.ts @@ -1,15 +1,32 @@ -import { Body, Controller, Param, Put } from '@nestjs/common'; +import { Body, Controller, Param, Put, UseGuards } from '@nestjs/common'; import { UsersService, UserPreferencesDto } from './users.service'; +import { + JwtAuthGuard, + RolesGuard, + SubjectOwnershipGuard, + Roles, + Ownership, + UserRole, +} from '../auth'; +/** + * Users API. + * + * `PUT /users/:userId/preferences` is subject-owned: callers may only + * update their own preferences unless they are an admin. + */ @Controller('users') export class UsersController { constructor(private readonly usersService: UsersService) {} @Put(':userId/preferences') + @UseGuards(JwtAuthGuard, RolesGuard, SubjectOwnershipGuard) + @Roles(UserRole.LEARNER, UserRole.TUTOR, UserRole.ADMIN) + @Ownership('userId') async updatePreferences( @Param('userId') userId: string, @Body() dto: UserPreferencesDto, ) { return this.usersService.updatePreferences(userId, dto); } -} +} \ No newline at end of file diff --git a/BackendAcademy/src/users/users.module.ts b/BackendAcademy/src/users/users.module.ts index 513776de8..f45038b5f 100644 --- a/BackendAcademy/src/users/users.module.ts +++ b/BackendAcademy/src/users/users.module.ts @@ -1,8 +1,10 @@ import { Module } from '@nestjs/common'; import { UsersController } from './users.controller'; import { UsersService } from './users.service'; +import { AuthModule } from '../auth/auth.module'; @Module({ + imports: [AuthModule], controllers: [UsersController], providers: [UsersService], exports: [UsersService], diff --git a/BackendAcademy/src/users/users.service.ts b/BackendAcademy/src/users/users.service.ts index 8988ca3be..af672994e 100644 --- a/BackendAcademy/src/users/users.service.ts +++ b/BackendAcademy/src/users/users.service.ts @@ -230,6 +230,11 @@ export class UsersService { isDeleted(userId: string): boolean { return this.deletedUsers.has(userId); } + + /** + * Records an asset upload for a user. + */ + private trackUserUpload(userId: string, assetId: string): void { if (!this.userUploads.has(userId)) { this.userUploads.set(userId, new Set()); }