From abe97c2d05fe5f7ee39ba93248be92d0fb59c260 Mon Sep 17 00:00:00 2001 From: bigben-7 Date: Sun, 28 Jun 2026 14:43:05 +0100 Subject: [PATCH] feat: add AuditLog, health check, and admin verifications endpoints - #791 BE-67: AuditLog entity with AuditService for tracking sensitive operations - #792 BE-68: GET /api/audit admin-only paginated audit log endpoint - #793 BE-69: GET /api/admin/verifications with filters and pagination - #794 BE-70: GET /api/health with DB, Redis, and disk checks via @nestjs/terminus Closes #791 Closes #792 Closes #793 Closes #794 --- .../admin-verifications.controller.ts | 80 +++++++++++++++++++ .../admin-verifications.module.ts | 10 +++ backend/src/app.module.ts | 6 ++ backend/src/audit/audit-log.entity.ts | 52 ++++++++++++ backend/src/audit/audit-log.service.ts | 55 +++++++++++++ backend/src/audit/audit.controller.ts | 23 ++++++ backend/src/audit/audit.module.ts | 13 +++ backend/src/audit/dto/create-audit-log.dto.ts | 24 ++++++ backend/src/audit/dto/query-audit-log.dto.ts | 36 +++++++++ backend/src/health/health.controller.ts | 39 +++++++++ backend/src/health/health.module.ts | 12 +++ backend/src/health/redis.health.ts | 37 +++++++++ 12 files changed, 387 insertions(+) create mode 100644 backend/src/admin-verifications/admin-verifications.controller.ts create mode 100644 backend/src/admin-verifications/admin-verifications.module.ts create mode 100644 backend/src/audit/audit-log.entity.ts create mode 100644 backend/src/audit/audit-log.service.ts create mode 100644 backend/src/audit/audit.controller.ts create mode 100644 backend/src/audit/audit.module.ts create mode 100644 backend/src/audit/dto/create-audit-log.dto.ts create mode 100644 backend/src/audit/dto/query-audit-log.dto.ts create mode 100644 backend/src/health/health.controller.ts create mode 100644 backend/src/health/health.module.ts create mode 100644 backend/src/health/redis.health.ts diff --git a/backend/src/admin-verifications/admin-verifications.controller.ts b/backend/src/admin-verifications/admin-verifications.controller.ts new file mode 100644 index 0000000..b44df10 --- /dev/null +++ b/backend/src/admin-verifications/admin-verifications.controller.ts @@ -0,0 +1,80 @@ +import { Controller, Get, Query, UseGuards } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository, Between, Like } from 'typeorm'; +import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; +import { RolesGuard } from '../auth/guards/roles.guard'; +import { Roles } from '../auth/guards/roles.decorator'; +import { VerificationRecord } from '../verification/entities/verification-record.entity'; +import { IsOptional, IsString, IsDateString, IsInt, Min } from 'class-validator'; +import { Type } from 'class-transformer'; + +class QueryVerificationsDto { + @IsOptional() + @IsString() + status?: string; + + @IsOptional() + @IsString() + documentId?: string; + + @IsOptional() + @IsString() + stellarTxHash?: string; + + @IsOptional() + @IsDateString() + startDate?: string; + + @IsOptional() + @IsDateString() + endDate?: string; + + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + page?: number = 1; + + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + limit?: number = 20; +} + +@Controller('admin/verifications') +@UseGuards(JwtAuthGuard, RolesGuard) +@Roles('admin') +export class AdminVerificationsController { + constructor( + @InjectRepository(VerificationRecord) + private readonly verificationRepo: Repository, + ) {} + + @Get() + async findAll(@Query() query: QueryVerificationsDto) { + const { status, documentId, stellarTxHash, startDate, endDate, page = 1, limit = 20 } = query; + const where: Record = {}; + + if (status) where.status = status; + if (documentId) where.documentId = documentId; + if (stellarTxHash) where.stellarTxHash = Like(`%${stellarTxHash}%`); + if (startDate && endDate) { + where.createdAt = Between(new Date(startDate), new Date(endDate)); + } else if (startDate) { + where.createdAt = Between(new Date(startDate), new Date('2100-01-01')); + } else if (endDate) { + where.createdAt = Between(new Date('1970-01-01'), new Date(endDate)); + } + + const [data, total] = await this.verificationRepo.findAndCount({ + where, + take: limit, + skip: (page - 1) * limit, + order: { createdAt: 'DESC' }, + relations: ['document'], + }); + + return { data, total, page, limit }; + } +} diff --git a/backend/src/admin-verifications/admin-verifications.module.ts b/backend/src/admin-verifications/admin-verifications.module.ts new file mode 100644 index 0000000..0b980f2 --- /dev/null +++ b/backend/src/admin-verifications/admin-verifications.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { VerificationRecord } from '../verification/entities/verification-record.entity'; +import { AdminVerificationsController } from './admin-verifications.controller'; + +@Module({ + imports: [TypeOrmModule.forFeature([VerificationRecord])], + controllers: [AdminVerificationsController], +}) +export class AdminVerificationsModule {} diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index 49cbfd6..693659c 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -3,9 +3,12 @@ import { ConfigModule, ConfigService } from '@nestjs/config'; import { TypeOrmModule } from '@nestjs/typeorm'; import { WinstonModule } from 'nest-winston'; +import { AdminVerificationsModule } from './admin-verifications/admin-verifications.module'; import { AppController } from './app.controller'; import { AppService } from './app.service'; +import { AuditModule } from './audit/audit.module'; import { AuthModule } from './auth/auth.module'; +import { HealthModule } from './health/health.module'; import { buildWinstonOptions } from './common/logger.config'; import { LoggerMiddleware } from './common/middleware/logger.middleware'; import { DocumentsModule } from './documents/documents.module'; @@ -48,6 +51,9 @@ import { ConfigValidationSchema } from './config/config.validation'; synchronize: true, }), }), + AuditModule, + AdminVerificationsModule, + HealthModule, UsersModule, AuthModule, DocumentsModule, diff --git a/backend/src/audit/audit-log.entity.ts b/backend/src/audit/audit-log.entity.ts new file mode 100644 index 0000000..6540aa9 --- /dev/null +++ b/backend/src/audit/audit-log.entity.ts @@ -0,0 +1,52 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + Index, +} from 'typeorm'; + +export enum AuditAction { + CREATE = 'CREATE', + UPDATE = 'UPDATE', + DELETE = 'DELETE', + VERIFY = 'VERIFY', + EXPORT = 'EXPORT', + SHARE = 'SHARE', + REVOKE = 'REVOKE', + LOGIN = 'LOGIN', + LOGOUT = 'LOGOUT', + APPROVE = 'APPROVE', + REJECT = 'REJECT', +} + +@Entity('audit_logs') +@Index('IDX_AUDIT_USER_ID', ['userId']) +@Index('IDX_AUDIT_ENTITY', ['entity']) +@Index('IDX_AUDIT_ACTION', ['action']) +@Index('IDX_AUDIT_CREATED_AT', ['createdAt']) +export class AuditLog { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'user_id' }) + userId: string; + + @Column({ type: 'enum', enum: AuditAction }) + action: AuditAction; + + @Column() + entity: string; + + @Column({ name: 'entity_id' }) + entityId: string; + + @Column({ name: 'metadata', type: 'json', nullable: true }) + metadata?: Record; + + @Column({ name: 'ip_address', nullable: true }) + ipAddress?: string; + + @CreateDateColumn({ name: 'created_at' }) + createdAt: Date; +} diff --git a/backend/src/audit/audit-log.service.ts b/backend/src/audit/audit-log.service.ts new file mode 100644 index 0000000..ec8fa9b --- /dev/null +++ b/backend/src/audit/audit-log.service.ts @@ -0,0 +1,55 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository, Between, Like } from 'typeorm'; +import { AuditLog, AuditAction } from './audit-log.entity'; +import { CreateAuditLogDto } from './dto/create-audit-log.dto'; +import { QueryAuditLogDto } from './dto/query-audit-log.dto'; + +@Injectable() +export class AuditService { + constructor( + @InjectRepository(AuditLog) + private readonly auditLogRepo: Repository, + ) {} + + async create(dto: CreateAuditLogDto): Promise { + const log = this.auditLogRepo.create({ + userId: dto.userId, + action: dto.action as AuditAction, + entity: dto.entity, + entityId: dto.entityId, + metadata: dto.metadata, + ipAddress: dto.ipAddress, + }); + return this.auditLogRepo.save(log); + } + + async findAll(query: QueryAuditLogDto) { + const { userId, action, entity, startDate, endDate, page = 1, limit = 20 } = query; + const where: Record = {}; + + if (userId) where.userId = userId; + if (action) where.action = action; + if (entity) where.entity = Like(`%${entity}%`); + if (startDate && endDate) { + where.createdAt = Between(new Date(startDate), new Date(endDate)); + } else if (startDate) { + where.createdAt = Between(new Date(startDate), new Date('2100-01-01')); + } else if (endDate) { + where.createdAt = Between(new Date('1970-01-01'), new Date(endDate)); + } + + const [data, total] = await this.auditLogRepo.findAndCount({ + where, + take: limit, + skip: (page - 1) * limit, + order: { createdAt: 'DESC' }, + }); + + return { data, total, page, limit }; + } + + async findOne(id: string): Promise { + return this.auditLogRepo.findOne({ where: { id } }); + } +} diff --git a/backend/src/audit/audit.controller.ts b/backend/src/audit/audit.controller.ts new file mode 100644 index 0000000..9a754d0 --- /dev/null +++ b/backend/src/audit/audit.controller.ts @@ -0,0 +1,23 @@ +import { Controller, Get, Query, Param, UseGuards } from '@nestjs/common'; +import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; +import { RolesGuard } from '../auth/guards/roles.guard'; +import { Roles } from '../auth/guards/roles.decorator'; +import { AuditService } from './audit-log.service'; +import { QueryAuditLogDto } from './dto/query-audit-log.dto'; + +@Controller('audit') +@UseGuards(JwtAuthGuard, RolesGuard) +@Roles('admin') +export class AuditController { + constructor(private readonly auditService: AuditService) {} + + @Get() + findAll(@Query() query: QueryAuditLogDto) { + return this.auditService.findAll(query); + } + + @Get(':id') + findOne(@Param('id') id: string) { + return this.auditService.findOne(id); + } +} diff --git a/backend/src/audit/audit.module.ts b/backend/src/audit/audit.module.ts new file mode 100644 index 0000000..56e2fb2 --- /dev/null +++ b/backend/src/audit/audit.module.ts @@ -0,0 +1,13 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { AuditLog } from './audit-log.entity'; +import { AuditService } from './audit-log.service'; +import { AuditController } from './audit.controller'; + +@Module({ + imports: [TypeOrmModule.forFeature([AuditLog])], + controllers: [AuditController], + providers: [AuditService], + exports: [AuditService], +}) +export class AuditModule {} diff --git a/backend/src/audit/dto/create-audit-log.dto.ts b/backend/src/audit/dto/create-audit-log.dto.ts new file mode 100644 index 0000000..2903485 --- /dev/null +++ b/backend/src/audit/dto/create-audit-log.dto.ts @@ -0,0 +1,24 @@ +import { IsString, IsOptional, IsObject } from 'class-validator'; +import { AuditAction } from '../audit-log.entity'; + +export class CreateAuditLogDto { + @IsString() + userId: string; + + @IsString() + action: AuditAction; + + @IsString() + entity: string; + + @IsString() + entityId: string; + + @IsOptional() + @IsObject() + metadata?: Record; + + @IsOptional() + @IsString() + ipAddress?: string; +} diff --git a/backend/src/audit/dto/query-audit-log.dto.ts b/backend/src/audit/dto/query-audit-log.dto.ts new file mode 100644 index 0000000..41ff1f0 --- /dev/null +++ b/backend/src/audit/dto/query-audit-log.dto.ts @@ -0,0 +1,36 @@ +import { IsOptional, IsString, IsDateString, IsInt, Min } from 'class-validator'; +import { Type } from 'class-transformer'; + +export class QueryAuditLogDto { + @IsOptional() + @IsString() + userId?: string; + + @IsOptional() + @IsString() + action?: string; + + @IsOptional() + @IsString() + entity?: string; + + @IsOptional() + @IsDateString() + startDate?: string; + + @IsOptional() + @IsDateString() + endDate?: string; + + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + page?: number = 1; + + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + limit?: number = 20; +} diff --git a/backend/src/health/health.controller.ts b/backend/src/health/health.controller.ts new file mode 100644 index 0000000..32b1953 --- /dev/null +++ b/backend/src/health/health.controller.ts @@ -0,0 +1,39 @@ +import { Controller, Get } from '@nestjs/common'; +import { + HealthCheckService, + TypeOrmHealthIndicator, + HealthCheck, + DiskHealthIndicator, +} from '@nestjs/terminus'; +import { ConfigService } from '@nestjs/config'; +import { RedisHealthIndicator } from './redis.health'; + +@Controller('health') +export class HealthController { + constructor( + private readonly health: HealthCheckService, + private readonly db: TypeOrmHealthIndicator, + private readonly disk: DiskHealthIndicator, + private readonly config: ConfigService, + private readonly redisHealth: RedisHealthIndicator, + ) {} + + @Get() + @HealthCheck() + check() { + const checks = [ + () => this.db.pingCheck('database', { timeout: 5000 }), + () => + this.disk.checkStorage('disk', { + path: '/', + thresholdPercent: 0.9, + }), + ]; + + if (this.config.get('REDIS_HOST')) { + checks.push(() => this.redisHealth.isHealthy('redis')); + } + + return this.health.check(checks); + } +} diff --git a/backend/src/health/health.module.ts b/backend/src/health/health.module.ts new file mode 100644 index 0000000..4889e5f --- /dev/null +++ b/backend/src/health/health.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common'; +import { TerminusModule } from '@nestjs/terminus'; +import { ConfigModule } from '@nestjs/config'; +import { HealthController } from './health.controller'; +import { RedisHealthIndicator } from './redis.health'; + +@Module({ + imports: [TerminusModule, ConfigModule], + controllers: [HealthController], + providers: [RedisHealthIndicator], +}) +export class HealthModule {} diff --git a/backend/src/health/redis.health.ts b/backend/src/health/redis.health.ts new file mode 100644 index 0000000..b74e80c --- /dev/null +++ b/backend/src/health/redis.health.ts @@ -0,0 +1,37 @@ +import { Injectable } from '@nestjs/common'; +import { + HealthIndicator, + HealthIndicatorResult, + HealthCheckError, +} from '@nestjs/terminus'; +import { ConfigService } from '@nestjs/config'; +import Redis from 'ioredis'; + +@Injectable() +export class RedisHealthIndicator extends HealthIndicator { + private client: Redis | null = null; + + constructor(private readonly config: ConfigService) { + super(); + const host = this.config.get('REDIS_HOST'); + const port = this.config.get('REDIS_PORT') || 6379; + if (host) { + this.client = new Redis({ host, port, lazyConnect: true }); + } + } + + async isHealthy(key: string): Promise { + if (!this.client) { + return this.getStatus(key, false, { message: 'Redis not configured' }); + } + try { + await this.client.ping(); + return this.getStatus(key, true); + } catch (e) { + throw new HealthCheckError( + 'Redis check failed', + this.getStatus(key, false, { message: (e as Error).message }), + ); + } + } +}