From e476b1ff0765433eacda76c7ffa5e61d9f361ed6 Mon Sep 17 00:00:00 2001 From: zakariyaufarida5-wq Date: Mon, 24 Aug 2026 16:54:53 +0100 Subject: [PATCH] refactor(users): add repository pattern, JSDoc, admin IP whitelist, and cleanup - Introduce UsersRepository and delegate persistence from UsersService (closes #561) - Add JSDoc to the users service/repository documenting params and return types (closes #562) - Add IpWhitelistGuard for admin endpoints with ADMIN_IP_WHITELIST config and docs (closes #553) - Remove unused duplicate 'dto copy' files (closes #554) --- docs/security/admin-ip-whitelist.md | 26 ++++++++++++++ src/audit/admin.controller.ts | 3 +- src/auth/dto copy/login.dto.ts | 18 ---------- src/auth/dto copy/register.dto.ts | 36 ------------------- src/common/guards/ip-whitelist.guard.ts | 46 +++++++++++++++++++++++++ src/users/users.module.ts | 3 +- src/users/users.repository.ts | 32 +++++++++++++++++ src/users/users.service.ts | 25 +++++++++++--- 8 files changed, 129 insertions(+), 60 deletions(-) create mode 100644 docs/security/admin-ip-whitelist.md delete mode 100644 src/auth/dto copy/login.dto.ts delete mode 100644 src/auth/dto copy/register.dto.ts create mode 100644 src/common/guards/ip-whitelist.guard.ts create mode 100644 src/users/users.repository.ts diff --git a/docs/security/admin-ip-whitelist.md b/docs/security/admin-ip-whitelist.md new file mode 100644 index 0000000..940995b --- /dev/null +++ b/docs/security/admin-ip-whitelist.md @@ -0,0 +1,26 @@ +# Admin IP Whitelisting + +Admin endpoints (`/admin/*`) are additionally protected by `IpWhitelistGuard` +(`src/common/guards/ip-whitelist.guard.ts`), which runs after JWT and role +checks. + +## Configuration + +Set the `ADMIN_IP_WHITELIST` environment variable to a comma-separated list of +trusted IP addresses: + +``` +ADMIN_IP_WHITELIST=203.0.113.10,198.51.100.4 +``` + +- When the variable is **empty or unset**, the guard allows all requests, so the + control is opt-in and does not interfere with local development or CI. +- When it is set, any request whose client IP is not in the list receives a + `403 Forbidden`. + +## Notes + +- Place the app behind a trusted proxy and ensure the real client IP is + forwarded so the guard evaluates the correct address. +- Combine with the existing `JwtAuthGuard` and `RolesGuard` (role-based access); + IP whitelisting is a defence-in-depth layer, not a replacement for them. diff --git a/src/audit/admin.controller.ts b/src/audit/admin.controller.ts index 078c134..316ed14 100644 --- a/src/audit/admin.controller.ts +++ b/src/audit/admin.controller.ts @@ -7,13 +7,14 @@ import { VerifyArtistDto } from './dto/verify-artist.dto'; import { ResolveDisputeDto } from './dto/resolve-dispute.dto'; import { JwtAuthGuard } from '../auth/sync/jwt.auth.guard'; import { RolesGuard } from '../auth/sync/roles.guard'; +import { IpWhitelistGuard } from '../common/guards/ip-whitelist.guard'; import { Roles } from '../auth/decorators/roles.decorators'; import { Role, CommissionStatus } from '@prisma/client'; import { PaymentsService } from '../payments/payments.service'; @Controller('admin') -@UseGuards(JwtAuthGuard, RolesGuard) +@UseGuards(JwtAuthGuard, RolesGuard, IpWhitelistGuard) export class AdminController { constructor( private auditService: AuditService, diff --git a/src/auth/dto copy/login.dto.ts b/src/auth/dto copy/login.dto.ts deleted file mode 100644 index e05a925..0000000 --- a/src/auth/dto copy/login.dto.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { ApiProperty } from '@nestjs/swagger'; -import { IsEmail, IsString } from 'class-validator'; - -export class LoginDto { - @ApiProperty({ - description: 'User email address', - example: 'user@example.com', - }) - @IsEmail() - email: string; - - @ApiProperty({ - description: 'User password', - example: 'Password123!', - }) - @IsString() - password: string; -} diff --git a/src/auth/dto copy/register.dto.ts b/src/auth/dto copy/register.dto.ts deleted file mode 100644 index 9997e45..0000000 --- a/src/auth/dto copy/register.dto.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { ApiProperty } from '@nestjs/swagger'; -import { IsEmail, IsEnum, IsString, MinLength } from 'class-validator'; -import { Role } from '@prisma/client'; - -export class RegisterDto { - @ApiProperty({ - description: 'User full name', - example: 'John Doe', - }) - @IsString() - name: string; - - @ApiProperty({ - description: 'User email address', - example: 'user@example.com', - }) - @IsEmail() - email: string; - - @ApiProperty({ - description: 'User password', - example: 'Password123!', - minLength: 8, - }) - @IsString() - @MinLength(8) - password: string; - - @ApiProperty({ - enum: Role, - description: 'User role', - example: Role.ARTIST, - }) - @IsEnum(Role) - role: Role; -} diff --git a/src/common/guards/ip-whitelist.guard.ts b/src/common/guards/ip-whitelist.guard.ts new file mode 100644 index 0000000..1cc6ae1 --- /dev/null +++ b/src/common/guards/ip-whitelist.guard.ts @@ -0,0 +1,46 @@ +import { + CanActivate, + ExecutionContext, + ForbiddenException, + Injectable, +} from '@nestjs/common'; + +interface RequestWithIp { + ip?: string; + socket?: { remoteAddress?: string }; +} + +/** + * Restricts access to routes it guards to a configured set of trusted IPs. + * + * The allowlist is read from the `ADMIN_IP_WHITELIST` environment variable as a + * comma-separated list of IP addresses. When the variable is empty or unset the + * guard allows the request, so the control is opt-in and does not break local + * development. See `docs/security/admin-ip-whitelist.md`. + */ +@Injectable() +export class IpWhitelistGuard implements CanActivate { + canActivate(context: ExecutionContext): boolean { + const raw = process.env.ADMIN_IP_WHITELIST; + if (!raw || raw.trim() === '') { + return true; + } + + const allowed = raw + .split(',') + .map((ip) => ip.trim()) + .filter(Boolean); + + const request = context.switchToHttp().getRequest(); + const clientIp = (request.ip || request.socket?.remoteAddress || '').replace( + '::ffff:', + '', + ); + + if (!allowed.includes(clientIp)) { + throw new ForbiddenException('Access denied: IP address is not whitelisted'); + } + + return true; + } +} diff --git a/src/users/users.module.ts b/src/users/users.module.ts index acfee4e..4f02d6a 100644 --- a/src/users/users.module.ts +++ b/src/users/users.module.ts @@ -1,12 +1,13 @@ import { Module } from '@nestjs/common'; import { UsersService } from './users.service'; +import { UsersRepository } from './users.repository'; import { UsersController } from './users.controller'; import { PrismaModule } from '../prisma/prisma.module'; @Module({ imports: [PrismaModule], controllers: [UsersController], - providers: [UsersService], + providers: [UsersService, UsersRepository], exports: [UsersService], }) export class UsersModule {} diff --git a/src/users/users.repository.ts b/src/users/users.repository.ts new file mode 100644 index 0000000..ff9efea --- /dev/null +++ b/src/users/users.repository.ts @@ -0,0 +1,32 @@ +import { Injectable } from '@nestjs/common'; +import { PrismaService } from '../prisma/prisma.service'; + +/** + * Data-access layer for the User entity. + * + * Encapsulates all Prisma queries for users so that services depend on this + * repository rather than on Prisma directly. This keeps persistence concerns in + * one place and makes the service layer easier to test in isolation. + */ +@Injectable() +export class UsersRepository { + constructor(private readonly prisma: PrismaService) {} + + /** + * Find a single user by their unique id. + * @param id - The user's unique identifier. + * @returns The matching user, or `null` if none exists. + */ + findById(id: string) { + return this.prisma.user.findUnique({ where: { id } }); + } + + /** + * Find a single user by their unique email address. + * @param email - The user's email address. + * @returns The matching user, or `null` if none exists. + */ + findByEmail(email: string) { + return this.prisma.user.findUnique({ where: { email } }); + } +} diff --git a/src/users/users.service.ts b/src/users/users.service.ts index 1c6b693..6072ba0 100644 --- a/src/users/users.service.ts +++ b/src/users/users.service.ts @@ -1,15 +1,32 @@ import { Injectable } from '@nestjs/common'; -import { PrismaService } from '../prisma/prisma.service'; +import { UsersRepository } from './users.repository'; +/** + * Business-logic layer for user operations. + * + * Delegates all persistence to {@link UsersRepository} so this service stays + * focused on application logic and remains easy to unit-test with a mocked + * repository. + */ @Injectable() export class UsersService { - constructor(private readonly prisma: PrismaService) {} + constructor(private readonly usersRepository: UsersRepository) {} + /** + * Retrieve a user by their unique id. + * @param id - The user's unique identifier. + * @returns The matching user, or `null` if none exists. + */ async findById(id: string) { - return this.prisma.user.findUnique({ where: { id } }); + return this.usersRepository.findById(id); } + /** + * Retrieve a user by their email address. + * @param email - The user's email address. + * @returns The matching user, or `null` if none exists. + */ async findByEmail(email: string) { - return this.prisma.user.findUnique({ where: { email } }); + return this.usersRepository.findByEmail(email); } }