Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 59 additions & 3 deletions Dockerfile.db
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,65 @@ RUN apt-get update && \
# Initialize pg_cron and schedule the job in DB
RUN cat <<EOF > /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
7 changes: 6 additions & 1 deletion docker-compose.dev.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
16 changes: 16 additions & 0 deletions prisma/migrations/20260430073327_add_audit_log_model/migration.sql
Original file line number Diff line number Diff line change
@@ -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")
);
Original file line number Diff line number Diff line change
@@ -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;
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "audit_logs" ADD COLUMN "entity" TEXT;
Original file line number Diff line number Diff line change
@@ -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");
14 changes: 14 additions & 0 deletions prisma/migrations/20260501185440_index_audit_logs/migration.sql
Original file line number Diff line number Diff line change
@@ -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");
Original file line number Diff line number Diff line change
@@ -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
$$;
54 changes: 36 additions & 18 deletions prisma/schema.prisma
Original file line number Diff line number Diff line change
@@ -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"
Expand All @@ -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")
Expand All @@ -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")
}
19 changes: 17 additions & 2 deletions src/app.module.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -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'
Expand Down Expand Up @@ -38,6 +45,7 @@ import { UserModule } from './user/user.module'
}),
}),
ScheduleModule.forRoot(),
AlsModule,
],
controllers: [AppController],
providers: [
Expand All @@ -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 })
}
}
21 changes: 14 additions & 7 deletions src/auth/auth.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,11 @@ describe('AuthService', () => {
const mockPrisma = {
user: {
findUnique: jest.fn(),
update: jest.fn(),
},
withAudit: {
user: {
update: jest.fn(),
},
},
refreshToken: {
deleteMany: jest.fn(),
Expand Down Expand Up @@ -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' })

Expand All @@ -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 } },
})
Expand All @@ -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',
Expand All @@ -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' },
})
Expand Down Expand Up @@ -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 = {
Expand All @@ -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' },
})
Expand Down
6 changes: 3 additions & 3 deletions src/auth/auth.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 },
})
Expand All @@ -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 },
})
Expand Down
Loading
Loading