diff --git a/Dockerfile.db b/Dockerfile.db index 806a5e2..7a128d6 100644 --- a/Dockerfile.db +++ b/Dockerfile.db @@ -6,9 +6,65 @@ RUN apt-get update && \ # Initialize pg_cron and schedule the job in DB RUN cat < /docker-entrypoint-initdb.d/001-pg-cron.sql CREATE EXTENSION IF NOT EXISTS pg_cron; + +-- ========================= +-- Schedule the cleanup for refresh_tokens (every sunday midnight) +-- ========================= SELECT cron.schedule( - 'refresh-token-cleanup', - '0 0 * * *', - \$\$DELETE FROM refresh_tokens WHERE expires_at < NOW();\$\$ + 'refresh-token-cleanup', + '0 0 * * 0', + \$\$ + DELETE FROM refresh_tokens WHERE expires_at < NOW(); + \$\$ +); + +-- ========================= +-- Schedule the next month Partition Creation for audit_logs (every 1 month) +-- ========================= +SELECT cron.schedule( + 'create-audit-partition', + '0 0 1 * *', + \$job\$ + DO \$do\$ + DECLARE + start_date DATE := date_trunc('month', NOW()); + next_month DATE := start_date + INTERVAL '1 month'; + BEGIN + EXECUTE format( + 'CREATE TABLE IF NOT EXISTS %I PARTITION OF audit_logs + FOR VALUES FROM (%L) TO (%L);', + 'audit_logs_' || to_char(next_month, 'YYYY_MM'), + next_month, + next_month + INTERVAL '1 month' + ); + END + \$do\$; + \$job\$ +); + +-- ========================= +-- Schedule the Partition Drop for audit_logs (every 1 month, 3 month retention) +-- ========================= +SELECT cron.schedule( + 'drop-old-audit-partitions', + '0 1 1 * *', + \$job\$ + DO \$do\$ + DECLARE + cutoff DATE := date_trunc('month', NOW()) - INTERVAL '3 months'; + partition_name TEXT; + BEGIN + FOR partition_name IN + SELECT tablename + FROM pg_tables + WHERE tablename LIKE 'audit_logs_%' + LOOP + IF partition_name < 'audit_logs_' || to_char(cutoff, 'YYYY_MM') THEN + EXECUTE format('DROP TABLE IF EXISTS %I;', partition_name); + END IF; + END LOOP; + END + \$do\$; + \$job\$ ); EOF \ No newline at end of file diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index d76c697..c279269 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -27,8 +27,13 @@ services: networks: - dev_infra_net dev-db: - image: postgres:17 + build: + context: . + dockerfile: Dockerfile.db container_name: dev_db + command: > + postgres -c shared_preload_libraries=pg_cron + -c cron.database_name=auth_system ports: - '5431:5432' env_file: diff --git a/prisma/migrations/20260430073327_add_audit_log_model/migration.sql b/prisma/migrations/20260430073327_add_audit_log_model/migration.sql new file mode 100644 index 0000000..c3cf26e --- /dev/null +++ b/prisma/migrations/20260430073327_add_audit_log_model/migration.sql @@ -0,0 +1,16 @@ +-- CreateTable +CREATE TABLE "audit_logs" ( + "id" TEXT NOT NULL, + "created_at" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + "action" TEXT NOT NULL, + "entity" TEXT NOT NULL, + "entity_id" TEXT NOT NULL, + "old_data" JSONB, + "new_data" JSONB, + "url" TEXT, + "user_id" INTEGER, + "user_email" TEXT, + "ip_address" TEXT, + + CONSTRAINT "audit_logs_pkey" PRIMARY KEY ("id") +); diff --git a/prisma/migrations/20260501054238_modify_audit_log_model/migration.sql b/prisma/migrations/20260501054238_modify_audit_log_model/migration.sql new file mode 100644 index 0000000..87c6a1f --- /dev/null +++ b/prisma/migrations/20260501054238_modify_audit_log_model/migration.sql @@ -0,0 +1,11 @@ +/* + Warnings: + + - You are about to drop the column `entity` on the `audit_logs` table. All the data in the column will be lost. + - You are about to drop the column `entity_id` on the `audit_logs` table. All the data in the column will be lost. + +*/ +-- AlterTable +ALTER TABLE "audit_logs" DROP COLUMN "entity", +DROP COLUMN "entity_id", +ADD COLUMN "method" TEXT; diff --git a/prisma/migrations/20260501063555_add_entity_in_auditlog/migration.sql b/prisma/migrations/20260501063555_add_entity_in_auditlog/migration.sql new file mode 100644 index 0000000..b55fa56 --- /dev/null +++ b/prisma/migrations/20260501063555_add_entity_in_auditlog/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "audit_logs" ADD COLUMN "entity" TEXT; diff --git a/prisma/migrations/20260501175545_audit_logs_partition/migration.sql b/prisma/migrations/20260501175545_audit_logs_partition/migration.sql new file mode 100644 index 0000000..3b876de --- /dev/null +++ b/prisma/migrations/20260501175545_audit_logs_partition/migration.sql @@ -0,0 +1,19 @@ +-- -- DropTable +DROP TABLE IF EXISTS audit_logs; + +-- CreatePartitionTable +CREATE TABLE audit_logs ( + "id" TEXT NOT NULL, + "created_at" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + "action" TEXT NOT NULL, + "entity" TEXT, + "old_data" JSONB, + "new_data" JSONB, + "method" TEXT, + "url" TEXT, + "user_id" INTEGER, + "user_email" TEXT, + "ip_address" TEXT, + + CONSTRAINT "audit_logs_pkey" PRIMARY KEY ("id", "created_at") +) PARTITION BY RANGE ("created_at"); \ No newline at end of file diff --git a/prisma/migrations/20260501185440_index_audit_logs/migration.sql b/prisma/migrations/20260501185440_index_audit_logs/migration.sql new file mode 100644 index 0000000..7482456 --- /dev/null +++ b/prisma/migrations/20260501185440_index_audit_logs/migration.sql @@ -0,0 +1,14 @@ +/* + Warnings: + + - Made the column `entity` on table `audit_logs` required. This step will fail if there are existing NULL values in that column. + +*/ +-- AlterTable +ALTER TABLE "audit_logs" ALTER COLUMN "entity" SET NOT NULL; + +-- CreateIndex +CREATE INDEX "idx_audit_logs_user_id" ON "audit_logs"("user_id"); + +-- CreateIndex +CREATE INDEX "idx_audit_logs_user_email" ON "audit_logs"("user_email"); diff --git a/prisma/migrations/20260502111657_audit_log_initial_partition/migration.sql b/prisma/migrations/20260502111657_audit_log_initial_partition/migration.sql new file mode 100644 index 0000000..eeb626e --- /dev/null +++ b/prisma/migrations/20260502111657_audit_log_initial_partition/migration.sql @@ -0,0 +1,24 @@ +-- Create initial Partitions for audit_logs +DO $$ +DECLARE + start_date DATE := date_trunc('month', NOW()); + next_month DATE := start_date + INTERVAL '1 month'; +BEGIN + -- current month + EXECUTE format( + 'CREATE TABLE IF NOT EXISTS %I PARTITION OF audit_logs + FOR VALUES FROM (%L) TO (%L);', + 'audit_logs_' || to_char(start_date, 'YYYY_MM'), + start_date, + next_month + ); + -- next month + EXECUTE format( + 'CREATE TABLE IF NOT EXISTS %I PARTITION OF audit_logs + FOR VALUES FROM (%L) TO (%L);', + 'audit_logs_' || to_char(next_month, 'YYYY_MM'), + next_month, + next_month + INTERVAL '1 month' + ); +END +$$; \ No newline at end of file diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 9a3769f..320af93 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -1,9 +1,6 @@ // This is your Prisma schema file, // learn more about it in the docs: https://pris.ly/d/prisma-schema -// Looking for ways to speed up your queries, or scale easily with your serverless or edge functions? -// Try Prisma Accelerate: https://pris.ly/cli/accelerate-init - generator client { provider = "prisma-client" output = "../generated/prisma" @@ -15,18 +12,18 @@ datasource db { } model User { - id Int @id @default(autoincrement()) - createdAt DateTime @default(now()) @map("created_at") @db.Timestamp() - updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamp() - deletedAt DateTime? @map("deleted_at") - - email String @unique - password String - firstName String? @map("first_name") - lastName String? @map("last_name") - role Role @default(USER) - tokenVersion Int @default(1) @map("token_version") - + id Int @id @default(autoincrement()) + createdAt DateTime @default(now()) @map("created_at") @db.Timestamp() + updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamp() + deletedAt DateTime? @map("deleted_at") + // + email String @unique + password String + firstName String? @map("first_name") + lastName String? @map("last_name") + role Role @default(USER) + tokenVersion Int @default(1) @map("token_version") + // refreshTokens RefreshToken[] @@map("users") @@ -43,11 +40,32 @@ model RefreshToken { id String @id @db.Uuid userId Int @map("user_id") createdAt DateTime @default(now()) @map("created_at") @db.Timestamp() - + // token String expiresAt DateTime @map("expires_at") @db.Timestamp() - - user User @relation(fields: [userId], references: [id], onDelete: Cascade) + // + user User @relation(references: [id], fields: [userId], onDelete: Cascade) @@map("refresh_tokens") } + +model AuditLog { + id String @default(uuid()) + createdAt DateTime @default(now()) @map("created_at") @db.Timestamp() + // + action String // e.g., "CREATE", "UPDATE", "DELETE" + entity String // e.g., "User", "Product" + oldData Json? @map("old_data") + newData Json? @map("new_data") + // + method String? // api request method + url String? + userId Int? @map("user_id") // ID of the user who performed the action + userEmail String? @map("user_email") // Email of the user who performed the action + ipAddress String? @map("ip_address") + + @@id([id, createdAt]) + @@index([userId], map: "idx_audit_logs_user_id") + @@index([userEmail], map: "idx_audit_logs_user_email") + @@map("audit_logs") +} diff --git a/src/app.module.ts b/src/app.module.ts index 20fb841..53f2f11 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -1,5 +1,10 @@ import { ThrottlerStorageRedisService } from '@nest-lab/throttler-storage-redis' -import { Module } from '@nestjs/common' +import { + MiddlewareConsumer, + Module, + NestModule, + RequestMethod, +} from '@nestjs/common' import { ConfigModule, ConfigService } from '@nestjs/config' import { APP_GUARD } from '@nestjs/core' import { ScheduleModule } from '@nestjs/schedule' @@ -8,7 +13,9 @@ import { AppController } from './app.controller' import { AppService } from './app.service' import { AuthInfrastructureModule } from './auth/auth-infrastructure.module' import { AuthModule } from './auth/auth.module' +import { RequestContextMiddleware } from './common/middleware' import envValidation from './config/env.validation' +import { AlsModule } from './infra/als/als.module' import { PrismaModule } from './infra/prisma/prisma.module' import { RedisModule } from './infra/redis/redis.module' import { RedisService } from './infra/redis/redis.service' @@ -38,6 +45,7 @@ import { UserModule } from './user/user.module' }), }), ScheduleModule.forRoot(), + AlsModule, ], controllers: [AppController], providers: [ @@ -48,4 +56,11 @@ import { UserModule } from './user/user.module' }, ], }) -export class AppModule {} +export class AppModule implements NestModule { + configure(consumer: MiddlewareConsumer) { + consumer + .apply(RequestContextMiddleware) + .exclude({ path: '*path', method: RequestMethod.GET }) + .forRoutes({ path: '*path', method: RequestMethod.ALL }) + } +} diff --git a/src/auth/auth.service.spec.ts b/src/auth/auth.service.spec.ts index b0f78e9..4949f50 100644 --- a/src/auth/auth.service.spec.ts +++ b/src/auth/auth.service.spec.ts @@ -24,7 +24,11 @@ describe('AuthService', () => { const mockPrisma = { user: { findUnique: jest.fn(), - update: jest.fn(), + }, + withAudit: { + user: { + update: jest.fn(), + }, }, refreshToken: { deleteMany: jest.fn(), @@ -137,7 +141,10 @@ describe('AuthService', () => { describe('changeEmail', () => { it('should verify codes, update user and revoke all refresh tokens', async () => { mockOtpService.verifyCode.mockResolvedValue(true) - mockPrisma.user.update.mockResolvedValue({ id: 1, email: 'new@t.com' }) + mockPrisma.withAudit.user.update.mockResolvedValue({ + id: 1, + email: 'new@t.com', + }) mockPrisma.refreshToken.deleteMany.mockResolvedValue({}) mockTokenService.generateToken.mockResolvedValue({ accessToken: 'a' }) @@ -158,7 +165,7 @@ describe('AuthService', () => { 'c2', true, ) - expect(mockPrisma.user.update).toHaveBeenCalledWith({ + expect(mockPrisma.withAudit.user.update).toHaveBeenCalledWith({ where: { email: 'old@t.com' }, data: { email: 'new@t.com', tokenVersion: { increment: 1 } }, }) @@ -183,7 +190,7 @@ describe('AuthService', () => { }) ;(argon.verify as jest.Mock).mockResolvedValue(true) ;(argon.hash as jest.Mock).mockResolvedValue('newHashed') - mockPrisma.user.update.mockResolvedValue({}) + mockPrisma.withAudit.user.update.mockResolvedValue({}) const dto = { oldPassword: 'o', @@ -203,7 +210,7 @@ describe('AuthService', () => { ) expect(argon.verify).toHaveBeenCalledWith('oldHashed', 'o') expect(argon.hash).toHaveBeenCalledWith('n') - expect(mockPrisma.user.update).toHaveBeenCalledWith({ + expect(mockPrisma.withAudit.user.update).toHaveBeenCalledWith({ where: { email: 'test@t.com' }, data: { password: 'newHashed' }, }) @@ -233,7 +240,7 @@ describe('AuthService', () => { mockUsersService.findUserByEmail.mockResolvedValue({ id: 1 }) mockOtpService.verifyCode.mockResolvedValue(true) ;(argon.hash as jest.Mock).mockResolvedValue('newHashed') - mockPrisma.user.update.mockResolvedValue({}) + mockPrisma.withAudit.user.update.mockResolvedValue({}) mockTokenService.generateToken.mockResolvedValue({ accessToken: 'a' }) const dto = { @@ -251,7 +258,7 @@ describe('AuthService', () => { 'code', ) expect(argon.hash).toHaveBeenCalledWith('n') - expect(mockPrisma.user.update).toHaveBeenCalledWith({ + expect(mockPrisma.withAudit.user.update).toHaveBeenCalledWith({ where: { email: 'test@t.com' }, data: { password: 'newHashed' }, }) diff --git a/src/auth/auth.service.ts b/src/auth/auth.service.ts index 7843292..0e56a56 100644 --- a/src/auth/auth.service.ts +++ b/src/auth/auth.service.ts @@ -75,7 +75,7 @@ export class AuthService { false, ) await this.otpService.verifyCode(oldEmail, dto.oldEmailVerifiedCode, true) - const user = await this.prisma.user.update({ + const user = await this.prisma.withAudit.user.update({ where: { email: oldEmail }, data: { email: dto.newEmail, @@ -106,7 +106,7 @@ export class AuthService { // generate the password hash const hashedNewPassword = await argon.hash(dto.newPassword) // update user password - await this.prisma.user.update({ + await this.prisma.withAudit.user.update({ where: { email }, data: { password: hashedNewPassword }, }) @@ -122,7 +122,7 @@ export class AuthService { // generate the password hash const hashedNewPassword = await argon.hash(dto.newPassword) // update user password - await this.prisma.user.update({ + await this.prisma.withAudit.user.update({ where: { email: dto.email }, data: { password: hashedNewPassword }, }) diff --git a/src/auth/guard/auth.guard.spec.ts b/src/auth/guard/auth.guard.spec.ts index 44163ca..340718d 100644 --- a/src/auth/guard/auth.guard.spec.ts +++ b/src/auth/guard/auth.guard.spec.ts @@ -10,6 +10,7 @@ import { Test, TestingModule } from '@nestjs/testing' import { Request } from 'express' import authConfig from 'src/auth/config/auth.config' import { IS_PUBLIC_KEY } from 'src/auth/decorator' +import { AlsService } from 'src/infra/als/als.service' import { PrismaService } from 'src/infra/prisma/prisma.service' import { AuthGuard, REQUEST_USER_KEY } from './auth.guard' @@ -31,6 +32,9 @@ describe('AuthGuard', () => { } const mockAuthConfig = { secret: 'test-secret' } + const mockAlsService = { + getStore: jest.fn(), + } beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ @@ -40,6 +44,7 @@ describe('AuthGuard', () => { { provide: JwtService, useValue: mockJwtService }, { provide: PrismaService, useValue: mockPrisma }, { provide: authConfig.KEY, useValue: mockAuthConfig }, + { provide: AlsService, useValue: mockAlsService }, ], }).compile() @@ -48,6 +53,7 @@ describe('AuthGuard', () => { afterEach(() => { jest.clearAllMocks() + mockAlsService.getStore.mockReturnValue(undefined) }) const mockContext = (headers: Record = {}) => { diff --git a/src/auth/guard/auth.guard.ts b/src/auth/guard/auth.guard.ts index 352d214..6bc0f9c 100644 --- a/src/auth/guard/auth.guard.ts +++ b/src/auth/guard/auth.guard.ts @@ -11,6 +11,7 @@ import type { ConfigType } from '@nestjs/config' import { Reflector } from '@nestjs/core' import { JsonWebTokenError, JwtService } from '@nestjs/jwt' import { Request } from 'express' +import { AlsService } from 'src/infra/als/als.service' import { PrismaService } from 'src/infra/prisma/prisma.service' import authConfig from '../config/auth.config' import { IS_PUBLIC_KEY } from '../decorator' @@ -22,11 +23,12 @@ export const REQUEST_USER_KEY = 'user' @Injectable() export class AuthGuard implements CanActivate { constructor( - private jwtService: JwtService, - private reflector: Reflector, + private readonly jwtService: JwtService, + private readonly reflector: Reflector, @Inject(authConfig.KEY) private readonly authConfiguration: ConfigType, - private prisma: PrismaService, + private readonly prisma: PrismaService, + private readonly als: AlsService, ) {} async canActivate(context: ExecutionContext): Promise { @@ -65,11 +67,20 @@ export class AuthGuard implements CanActivate { throw new UnauthorizedException('Access token revoked') } - request[REQUEST_USER_KEY] = { + const activeUser: ActiveUser = { sub: payload.sub, email: user.email, role: user.role, - } as ActiveUser + } + + request[REQUEST_USER_KEY] = activeUser + + // update the ALS store with the verified user + const store = this.als.getStore() + if (store) { + store.set('userId', activeUser.sub) + store.set('userEmail', activeUser.email) + } } catch (error) { if (error instanceof JsonWebTokenError) { throw new UnauthorizedException(error) diff --git a/src/common/middleware/index.ts b/src/common/middleware/index.ts new file mode 100644 index 0000000..693e176 --- /dev/null +++ b/src/common/middleware/index.ts @@ -0,0 +1 @@ +export * from './request-context.middleware' diff --git a/src/common/middleware/request-context.middleware.ts b/src/common/middleware/request-context.middleware.ts new file mode 100644 index 0000000..af1c177 --- /dev/null +++ b/src/common/middleware/request-context.middleware.ts @@ -0,0 +1,17 @@ +import { Injectable, NestMiddleware } from '@nestjs/common' +import { NextFunction, Request } from 'express' +import { AlsService } from 'src/infra/als/als.service' + +@Injectable() +export class RequestContextMiddleware implements NestMiddleware { + constructor(private readonly als: AlsService) {} + + use(req: Request, _res: Response, next: NextFunction) { + const store: Map = new Map() + store.set('ip', req.ip) + store.set('url', req.url) + store.set('method', req.method) + + this.als.run(store, () => next()) + } +} diff --git a/src/infra/als/als.module.ts b/src/infra/als/als.module.ts new file mode 100644 index 0000000..1976f33 --- /dev/null +++ b/src/infra/als/als.module.ts @@ -0,0 +1,9 @@ +import { Global, Module } from '@nestjs/common' +import { AlsService } from './als.service' + +@Global() +@Module({ + providers: [AlsService], + exports: [AlsService], +}) +export class AlsModule {} diff --git a/src/infra/als/als.service.ts b/src/infra/als/als.service.ts new file mode 100644 index 0000000..8ff9727 --- /dev/null +++ b/src/infra/als/als.service.ts @@ -0,0 +1,5 @@ +import { Injectable } from '@nestjs/common' +import { AsyncLocalStorage } from 'async_hooks' + +@Injectable() +export class AlsService extends AsyncLocalStorage> {} diff --git a/src/infra/prisma/audit.helper.ts b/src/infra/prisma/audit.helper.ts new file mode 100644 index 0000000..cb30874 --- /dev/null +++ b/src/infra/prisma/audit.helper.ts @@ -0,0 +1,13 @@ +import { AlsService } from '../als/als.service' + +export function getAuditContext(als: AlsService) { + const store = als.getStore() + + return { + userId: store?.get('userId') as number | undefined, + userEmail: store?.get('userEmail') as string | undefined, + ipAddress: store?.get('ip') as string | undefined, + url: store?.get('url') as string | undefined, + method: store?.get('method') as string | undefined, + } +} diff --git a/src/infra/prisma/prisma.extension.ts b/src/infra/prisma/prisma.extension.ts new file mode 100644 index 0000000..5a9ca0c --- /dev/null +++ b/src/infra/prisma/prisma.extension.ts @@ -0,0 +1,92 @@ +import { PrismaClient, type Prisma } from 'generated/prisma/client' +import { AlsService } from '../als/als.service' +import { getAuditContext } from './audit.helper' + +type PrismaJsonValue = + | Prisma.InputJsonValue + | Prisma.NullableJsonNullValueInput + | undefined + +const action = { + CREATE: 'CREATE', + UPDATA: 'UPDATE', + DELETE: 'DELETE', +} + +export const auditLogExtension = (client: PrismaClient, als: AlsService) => { + return client.$extends({ + query: { + $allModels: { + // create + async create({ model, args, query }) { + if (model === 'AuditLog') return query(args) + + // Get the data from the ALS pocket + const context = getAuditContext(als) + + return client.$transaction(async (tx) => { + const txModels = tx as unknown as Record< + string, + { findUnique?: (params: { where: unknown }) => Promise } + > + + const result = await query(args) + + const newData = await txModels[model]?.findUnique?.({ + where: { id: result.id }, + }) + + await tx.auditLog.create({ + data: { + ...context, + action: action.CREATE, + entity: model, + oldData: undefined, + newData: newData as PrismaJsonValue, + }, + }) + + return result + }) + }, + // update + async update({ model, args, query }) { + if (model === 'AuditLog') return query(args) + + // Get the data from the ALS pocket + const context = getAuditContext(als) + + return client.$transaction(async (tx) => { + const txModels = tx as unknown as Record< + string, + { findUnique?: (params: { where: unknown }) => Promise } + > + + const oldData = await txModels[model]?.findUnique?.({ + where: args.where, + }) + + const result = await query(args) + + const newData = await txModels[model]?.findUnique?.({ + where: { id: result.id }, + }) + await tx.auditLog.create({ + data: { + ...context, + action: action.UPDATA, + entity: model, + oldData: oldData as PrismaJsonValue, + newData: newData as PrismaJsonValue, + }, + }) + + return result + }) + }, + }, + }, + }) +} + +export type AuditLogPrismaClient = ReturnType diff --git a/src/infra/prisma/prisma.service.ts b/src/infra/prisma/prisma.service.ts index 7bde6a7..78d3210 100644 --- a/src/infra/prisma/prisma.service.ts +++ b/src/infra/prisma/prisma.service.ts @@ -2,16 +2,28 @@ import { Injectable } from '@nestjs/common' import { ConfigService } from '@nestjs/config' import { PrismaPg } from '@prisma/adapter-pg' import { PrismaClient } from 'generated/prisma/client' +import { AlsService } from '../als/als.service' +import { AuditLogPrismaClient, auditLogExtension } from './prisma.extension' @Injectable() export class PrismaService extends PrismaClient { - constructor(config: ConfigService) { + private _audit?: AuditLogPrismaClient + + constructor( + config: ConfigService, + private readonly als: AlsService, + ) { const adapter = new PrismaPg({ connectionString: config.get('DATABASE_URL'), }) super({ adapter }) } + get withAudit(): AuditLogPrismaClient { + if (!this._audit) this._audit = auditLogExtension(this, this.als) + return this._audit + } + cleanDb() { return this.$transaction([ this.refreshToken.deleteMany(), diff --git a/src/user/user.controller.ts b/src/user/user.controller.ts index 61ee0d9..c5162c4 100644 --- a/src/user/user.controller.ts +++ b/src/user/user.controller.ts @@ -66,6 +66,7 @@ export class UserController { return this.userService.reactivateUser(reactivateUserDto) } + // #remove this route (hard-delete), instead create user block and unblock routes (allow only super_admin/admin) @Delete('hard-delete') hardDeleteUser( @User('email') email: string, diff --git a/src/user/user.service.spec.ts b/src/user/user.service.spec.ts index 03353ac..53f6172 100644 --- a/src/user/user.service.spec.ts +++ b/src/user/user.service.spec.ts @@ -21,10 +21,14 @@ describe('UserService', () => { user: { findMany: jest.fn(), findUnique: jest.fn(), - create: jest.fn(), - update: jest.fn(), delete: jest.fn(), }, + withAudit: { + user: { + create: jest.fn(), + update: jest.fn(), + }, + }, } const mockOtpService = { @@ -71,7 +75,10 @@ describe('UserService', () => { describe('createUser', () => { it('should create and return user without password', async () => { ;(argon.hash as jest.Mock).mockResolvedValue('hashed_password') - mockPrisma.user.create.mockResolvedValue({ id: 1, email: 'test@t.com' }) + mockPrisma.withAudit.user.create.mockResolvedValue({ + id: 1, + email: 'test@t.com', + }) const result = await service.createUser({ email: 'test@t.com', password: 'password', @@ -79,7 +86,7 @@ describe('UserService', () => { lastName: 't', }) expect(result).toEqual({ id: 1, email: 'test@t.com' }) - expect(mockPrisma.user.create).toHaveBeenCalledWith({ + expect(mockPrisma.withAudit.user.create).toHaveBeenCalledWith({ data: { email: 'test@t.com', password: 'hashed_password', @@ -96,7 +103,7 @@ describe('UserService', () => { code: 'P2002', clientVersion: '1', }) - mockPrisma.user.create.mockRejectedValue(err) + mockPrisma.withAudit.user.create.mockRejectedValue(err) await expect( service.createUser({ email: 'test@t.com', @@ -133,7 +140,10 @@ describe('UserService', () => { describe('updateUser', () => { it('should return updated user', async () => { - mockPrisma.user.update.mockResolvedValue({ id: 1, firstName: 'A' }) + mockPrisma.withAudit.user.update.mockResolvedValue({ + id: 1, + firstName: 'A', + }) await expect(service.updateUser(1, { firstName: 'A' })).resolves.toEqual({ id: 1, firstName: 'A', @@ -144,7 +154,7 @@ describe('UserService', () => { describe('softDeleteUser', () => { it('should soft delete user and revoke token', async () => { mockOtpService.verifyCode.mockResolvedValue(true) - mockPrisma.user.update.mockResolvedValue({ + mockPrisma.withAudit.user.update.mockResolvedValue({ id: 1, deletedAt: new Date(), }) @@ -167,7 +177,7 @@ describe('UserService', () => { code: 'P2025', clientVersion: '1', }) - mockPrisma.user.update.mockRejectedValue(err) + mockPrisma.withAudit.user.update.mockRejectedValue(err) await expect( service.softDeleteUser('t@t.com', { emailVerifiedCode: 'code' }), @@ -199,7 +209,7 @@ describe('UserService', () => { deletedAt: new Date(), }) mockOtpService.verifyCode.mockResolvedValue(true) - mockPrisma.user.update.mockResolvedValue({ + mockPrisma.withAudit.user.update.mockResolvedValue({ id: 1, email: 'deleted@t.com', deletedAt: null, @@ -219,7 +229,7 @@ describe('UserService', () => { 'deleted@t.com', 'verified-code', ) - expect(mockPrisma.user.update).toHaveBeenCalledWith({ + expect(mockPrisma.withAudit.user.update).toHaveBeenCalledWith({ where: { email: 'deleted@t.com' }, data: { deletedAt: null }, omit: { password: true }, @@ -290,7 +300,7 @@ describe('UserService', () => { it('should change role, increment tokenVersion and return message', async () => { mockPrisma.user.findUnique.mockResolvedValue({ role: Role.USER }) - mockPrisma.user.update.mockResolvedValue({ + mockPrisma.withAudit.user.update.mockResolvedValue({ id: 1, role: Role.ADMIN, email: 't@t.com', @@ -303,7 +313,7 @@ describe('UserService', () => { email: 't@t.com', message: "User role changed from 'USER' to 'ADMIN'", }) - expect(mockPrisma.user.update).toHaveBeenCalledWith({ + expect(mockPrisma.withAudit.user.update).toHaveBeenCalledWith({ where: { id: 1 }, data: { role: Role.ADMIN, tokenVersion: { increment: 1 } }, select: { id: true, role: true, email: true }, diff --git a/src/user/user.service.ts b/src/user/user.service.ts index 8b0ab60..f0d72ce 100644 --- a/src/user/user.service.ts +++ b/src/user/user.service.ts @@ -37,7 +37,7 @@ export class UserService { try { // generate the password hash const hashedPassword = await argon.hash(createUserDto.password) - const createdUser = await this.prisma.user.create({ + const createdUser = await this.prisma.withAudit.user.create({ data: { ...createUserDto, password: hashedPassword }, omit: { password: omitPassword }, }) @@ -77,7 +77,7 @@ export class UserService { async updateUser(userId: number, updateUserDto: UpdateUserDto) { try { - return await this.prisma.user.update({ + return await this.prisma.withAudit.user.update({ data: updateUserDto, where: { id: userId }, omit: { password: true }, @@ -94,7 +94,7 @@ export class UserService { async softDeleteUser(email: string, dto: DeleteUserDto) { try { await this.otpService.verifyCode(email, dto.emailVerifiedCode, true) - const deletedUser = await this.prisma.user.update({ + const deletedUser = await this.prisma.withAudit.user.update({ where: { email }, data: { deletedAt: new Date() }, omit: { password: true }, @@ -116,14 +116,11 @@ export class UserService { } async reactivateUser(dto: ReactivateUserDto) { - const foundUser = await this.prisma.user.findUnique({ - where: { email: dto.email }, - omit: { password: true }, - }) + const foundUser = await this.findUserByEmail(dto.email) if (!foundUser) throw new NotFoundException('User not found') if (!foundUser.deletedAt) throw new ConflictException('User already active') await this.otpService.verifyCode(dto.email, dto.emailVerifiedCode) - const reactivatedUser = await this.prisma.user.update({ + const reactivatedUser = await this.prisma.withAudit.user.update({ where: { email: dto.email }, data: { deletedAt: null }, omit: { password: true }, @@ -171,7 +168,7 @@ export class UserService { `User role is '${changeUserRoleDto.role}' already`, ) // change user role - const updatedUser = await this.prisma.user.update({ + const updatedUser = await this.prisma.withAudit.user.update({ where: { id }, data: { role: changeUserRoleDto.role,