From d1749ab0d0e6e47edaeb80cfdc4c7ec7682cdef1 Mon Sep 17 00:00:00 2001 From: CHIMA STEPHEN <110719318+teeschima@users.noreply.github.com> Date: Thu, 18 Jun 2026 11:45:14 +0000 Subject: [PATCH] feat: implement audit log for admin operations - Create audit_logs table with immutable INSERT-only design - Add AuditAction decorator for tagging admin routes - Add AuditInterceptor for automatic audit logging - Add AdminModule with AuditService and AuditController - Expose GET /admin/audit-logs endpoint with search/pagination Closes #37 --- src/app.module.ts | 2 + .../decorators/audit-action.decorator.ts | 11 ++ src/common/interceptors/audit.interceptor.ts | 74 +++++++++++ src/modules/admin/admin.module.ts | 11 ++ src/modules/admin/audit.controller.ts | 41 ++++++ src/modules/admin/audit.service.ts | 124 ++++++++++++++++++ src/modules/admin/dto/audit-log-query.dto.ts | 45 +++++++ .../admin/dto/audit-log-response.dto.ts | 45 +++++++ src/modules/admin/dto/create-audit-log.dto.ts | 11 ++ ...20260618000000_create_audit_logs_table.sql | 38 ++++++ 10 files changed, 402 insertions(+) create mode 100644 src/common/decorators/audit-action.decorator.ts create mode 100644 src/common/interceptors/audit.interceptor.ts create mode 100644 src/modules/admin/admin.module.ts create mode 100644 src/modules/admin/audit.controller.ts create mode 100644 src/modules/admin/audit.service.ts create mode 100644 src/modules/admin/dto/audit-log-query.dto.ts create mode 100644 src/modules/admin/dto/audit-log-response.dto.ts create mode 100644 src/modules/admin/dto/create-audit-log.dto.ts create mode 100644 supabase/migrations/20260618000000_create_audit_logs_table.sql diff --git a/src/app.module.ts b/src/app.module.ts index a0a196e..6ff2081 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -23,6 +23,7 @@ import { StellarModule } from './stellar/stellar.module'; import { LoggerModule } from './common/logger/logger.module'; import { MetricsModule } from './modules/metrics/metrics.module'; import { CreditScoringModule } from './modules/credit-scoring/credit-scoring.module'; +import { AdminModule } from './modules/admin/admin.module'; import { CorrelationIdMiddleware } from './common/logger/correlation-id.middleware'; @Module({ @@ -62,6 +63,7 @@ import { CorrelationIdMiddleware } from './common/logger/correlation-id.middlewa TransactionStatusCheckerModule, NonceCleanupModule, CreditScoringModule, + AdminModule, StellarModule, ], controllers: [], diff --git a/src/common/decorators/audit-action.decorator.ts b/src/common/decorators/audit-action.decorator.ts new file mode 100644 index 0000000..f21af7c --- /dev/null +++ b/src/common/decorators/audit-action.decorator.ts @@ -0,0 +1,11 @@ +import { SetMetadata } from '@nestjs/common'; + +export const AUDIT_ACTION_KEY = 'audit_action'; + +export interface AuditActionOptions { + resource: string; + action: string; +} + +export const AuditAction = (resource: string, action: string) => + SetMetadata(AUDIT_ACTION_KEY, { resource, action } as AuditActionOptions); diff --git a/src/common/interceptors/audit.interceptor.ts b/src/common/interceptors/audit.interceptor.ts new file mode 100644 index 0000000..00b19c5 --- /dev/null +++ b/src/common/interceptors/audit.interceptor.ts @@ -0,0 +1,74 @@ +import { + Injectable, + NestInterceptor, + ExecutionContext, + CallHandler, +} from '@nestjs/common'; +import { FastifyRequest } from 'fastify'; +import { Observable } from 'rxjs'; +import { tap } from 'rxjs/operators'; +import { Reflector } from '@nestjs/core'; +import { AuditService } from '../../modules/admin/audit.service'; +import { AUDIT_ACTION_KEY, AuditActionOptions } from '../decorators/audit-action.decorator'; + +@Injectable() +export class AuditInterceptor implements NestInterceptor { + constructor( + private readonly reflector: Reflector, + private readonly auditService: AuditService, + ) {} + + intercept(context: ExecutionContext, next: CallHandler): Observable { + const auditAction = this.reflector.get( + AUDIT_ACTION_KEY, + context.getHandler(), + ); + + if (!auditAction) { + return next.handle(); + } + + const http = context.switchToHttp(); + const request = http.getRequest(); + const user = request.user; + const actorWallet = user?.wallet ?? 'system'; + const body = request.body ?? {}; + const params = request.params as Record; + const query = request.query ?? {}; + const resourceId = + (params?.id as string) ?? + (params?.resourceId as string) ?? + (body && typeof body === 'object' && 'id' in body ? (body as Record).id as string : null); + + const logEntry = { + actor_wallet: actorWallet, + action: auditAction.action, + resource: auditAction.resource, + resource_id: resourceId ?? null, + before_state: null, + after_state: body && typeof body === 'object' && Object.keys(body).length > 0 ? body : null, + ip_address: request.ip ?? null, + user_agent: (request.headers?.['user-agent'] as string) ?? null, + metadata: { params, query }, + }; + + return next.handle().pipe( + tap((responseBody: unknown) => { + const afterState = responseBody + && typeof responseBody === 'object' + && 'data' in (responseBody as Record) + ? (responseBody as Record).data + : responseBody ?? logEntry.after_state; + + this.auditService + .log({ + ...logEntry, + after_state: afterState as Record | null, + }) + .catch((err: Error) => { + console.error('Failed to persist audit log:', err); + }); + }), + ); + } +} diff --git a/src/modules/admin/admin.module.ts b/src/modules/admin/admin.module.ts new file mode 100644 index 0000000..566323e --- /dev/null +++ b/src/modules/admin/admin.module.ts @@ -0,0 +1,11 @@ +import { Module } from '@nestjs/common'; +import { AuditController } from './audit.controller'; +import { AuditService } from './audit.service'; +import { SupabaseService } from '../../database/supabase.client'; + +@Module({ + controllers: [AuditController], + providers: [AuditService, SupabaseService], + exports: [AuditService], +}) +export class AdminModule {} diff --git a/src/modules/admin/audit.controller.ts b/src/modules/admin/audit.controller.ts new file mode 100644 index 0000000..71f7c46 --- /dev/null +++ b/src/modules/admin/audit.controller.ts @@ -0,0 +1,41 @@ +import { Controller, Get, Query, UseGuards, UseInterceptors } from '@nestjs/common'; +import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth, ApiQuery } from '@nestjs/swagger'; +import { AuditService } from './audit.service'; +import { AuditLogQueryDto } from './dto/audit-log-query.dto'; +import { AuditLogListResponseDto } from './dto/audit-log-response.dto'; +import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard'; +import { AuditInterceptor } from '../../common/interceptors/audit.interceptor'; +import { AuditAction } from '../../common/decorators/audit-action.decorator'; + +@ApiTags('admin') +@Controller('admin') +@UseGuards(JwtAuthGuard) +@ApiBearerAuth() +export class AuditController { + constructor(private readonly auditService: AuditService) {} + + @Get('audit-logs') + @UseInterceptors(AuditInterceptor) + @AuditAction('audit_logs', 'VIEW_AUDIT_LOGS') + @ApiOperation({ + summary: 'Search audit logs', + description: + 'Returns paginated audit logs with optional filtering by actor, action, resource, resource ID, and free-text search. Logs are immutable and ordered by creation date descending.', + }) + @ApiQuery({ name: 'actorWallet', required: false, description: 'Filter by actor wallet address' }) + @ApiQuery({ name: 'action', required: false, description: 'Filter by action (e.g. UPDATE_USER)' }) + @ApiQuery({ name: 'resource', required: false, description: 'Filter by resource type (e.g. users)' }) + @ApiQuery({ name: 'resourceId', required: false, description: 'Filter by resource ID' }) + @ApiQuery({ name: 'search', required: false, description: 'Free-text search across actor, action, resource, resource_id' }) + @ApiQuery({ name: 'limit', required: false, type: Number, description: 'Page size (default 20, max 100)' }) + @ApiQuery({ name: 'offset', required: false, type: Number, description: 'Number of records to skip (default 0)' }) + @ApiResponse({ + status: 200, + description: 'Audit logs retrieved successfully', + type: AuditLogListResponseDto, + }) + @ApiResponse({ status: 401, description: 'Unauthorized - missing or invalid admin JWT' }) + async getAuditLogs(@Query() query: AuditLogQueryDto) { + return this.auditService.findMany(query); + } +} diff --git a/src/modules/admin/audit.service.ts b/src/modules/admin/audit.service.ts new file mode 100644 index 0000000..6b4303b --- /dev/null +++ b/src/modules/admin/audit.service.ts @@ -0,0 +1,124 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { SupabaseService } from '../../database/supabase.client'; +import { CreateAuditLogDto } from './dto/create-audit-log.dto'; +import { AuditLogQueryDto } from './dto/audit-log-query.dto'; +import { AuditLogItemDto, AuditLogListResponseDto } from './dto/audit-log-response.dto'; + +@Injectable() +export class AuditService { + private readonly logger = new Logger(AuditService.name); + + constructor(private readonly supabaseService: SupabaseService) {} + + async log(entry: CreateAuditLogDto): Promise { + const client = this.supabaseService.getServiceRoleClient(); + + const { error } = await client.from('audit_logs').insert({ + actor_wallet: entry.actor_wallet, + action: entry.action, + resource: entry.resource, + resource_id: entry.resource_id, + before_state: entry.before_state, + after_state: entry.after_state, + ip_address: entry.ip_address, + user_agent: entry.user_agent, + metadata: entry.metadata, + }); + + if (error) { + this.logger.error(`Failed to write audit log: ${error.message}`, { entry }); + throw error; + } + } + + async logWithBeforeAfter(params: { + actorWallet: string; + action: string; + resource: string; + resourceId: string | null; + beforeState: Record | null; + afterState: Record | null; + ipAddress?: string | null; + userAgent?: string | null; + metadata?: Record | null; + }): Promise { + await this.log({ + actor_wallet: params.actorWallet, + action: params.action, + resource: params.resource, + resource_id: params.resourceId, + before_state: params.beforeState, + after_state: params.afterState, + ip_address: params.ipAddress ?? null, + user_agent: params.userAgent ?? null, + metadata: params.metadata ?? null, + }); + } + + async findMany(query: AuditLogQueryDto): Promise { + const limit = query.limit ?? 20; + const offset = query.offset ?? 0; + const client = this.supabaseService.getServiceRoleClient(); + + let dbQuery = client + .from('audit_logs') + .select('*', { count: 'exact' }); + + if (query.actorWallet) { + dbQuery = dbQuery.eq('actor_wallet', query.actorWallet); + } + + if (query.action) { + dbQuery = dbQuery.eq('action', query.action); + } + + if (query.resource) { + dbQuery = dbQuery.eq('resource', query.resource); + } + + if (query.resourceId) { + dbQuery = dbQuery.eq('resource_id', query.resourceId); + } + + if (query.search) { + const term = `%${query.search}%`; + dbQuery = dbQuery.or( + `actor_wallet.ilike.${term},action.ilike.${term},resource.ilike.${term},resource_id.ilike.${term}`, + ); + } + + dbQuery = dbQuery + .order('created_at', { ascending: false }) + .range(offset, offset + limit - 1); + + const { data: logs, error, count } = await dbQuery; + + if (error) { + this.logger.error(`Failed to fetch audit logs: ${error.message}`); + throw error; + } + + const data: AuditLogItemDto[] = (logs ?? []).map((log) => ({ + id: log.id, + actorWallet: log.actor_wallet, + action: log.action, + resource: log.resource, + resourceId: log.resource_id ?? null, + beforeState: log.before_state as Record | null, + afterState: log.after_state as Record | null, + ipAddress: log.ip_address ?? null, + createdAt: log.created_at, + })); + + return { + success: true, + data, + pagination: { + limit, + offset, + total: count ?? 0, + }, + message: 'Audit logs retrieved successfully', + }; + } +} diff --git a/src/modules/admin/dto/audit-log-query.dto.ts b/src/modules/admin/dto/audit-log-query.dto.ts new file mode 100644 index 0000000..de26c54 --- /dev/null +++ b/src/modules/admin/dto/audit-log-query.dto.ts @@ -0,0 +1,45 @@ +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { IsOptional, IsString, IsInt, Min, Max } from 'class-validator'; +import { Transform } from 'class-transformer'; + +export class AuditLogQueryDto { + @ApiPropertyOptional({ description: 'Filter by actor wallet' }) + @IsOptional() + @IsString() + actorWallet?: string; + + @ApiPropertyOptional({ description: 'Filter by action' }) + @IsOptional() + @IsString() + action?: string; + + @ApiPropertyOptional({ description: 'Filter by resource type' }) + @IsOptional() + @IsString() + resource?: string; + + @ApiPropertyOptional({ description: 'Filter by resource ID' }) + @IsOptional() + @IsString() + resourceId?: string; + + @ApiPropertyOptional({ description: 'Search across actor, action, resource, resource_id' }) + @IsOptional() + @IsString() + search?: string; + + @ApiPropertyOptional({ description: 'Page size (default 20, max 100)', example: 20 }) + @IsOptional() + @Transform(({ value }) => parseInt(value, 10)) + @IsInt() + @Min(1) + @Max(100) + limit?: number = 20; + + @ApiPropertyOptional({ description: 'Number of records to skip (default 0)', example: 0 }) + @IsOptional() + @Transform(({ value }) => parseInt(value, 10)) + @IsInt() + @Min(0) + offset?: number = 0; +} diff --git a/src/modules/admin/dto/audit-log-response.dto.ts b/src/modules/admin/dto/audit-log-response.dto.ts new file mode 100644 index 0000000..998ee57 --- /dev/null +++ b/src/modules/admin/dto/audit-log-response.dto.ts @@ -0,0 +1,45 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { PaginatedResponseDto, PaginationMetaDto } from '../../../common/dto/paginated-response.dto'; + +export class AuditLogItemDto { + @ApiProperty({ example: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890' }) + id: string; + + @ApiProperty({ example: 'GABCDEF1234567890' }) + actorWallet: string; + + @ApiProperty({ example: 'UPDATE_USER' }) + action: string; + + @ApiProperty({ example: 'users' }) + resource: string; + + @ApiPropertyOptional({ example: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890' }) + resourceId: string | null; + + @ApiPropertyOptional({ description: 'State before the mutation' }) + beforeState: Record | null; + + @ApiPropertyOptional({ description: 'State after the mutation' }) + afterState: Record | null; + + @ApiPropertyOptional({ example: '192.168.1.1' }) + ipAddress: string | null; + + @ApiProperty({ example: '2026-06-18T12:00:00.000Z' }) + createdAt: string; +} + +export class AuditLogListResponseDto implements PaginatedResponseDto { + @ApiProperty({ example: true }) + success: boolean; + + @ApiProperty({ type: [AuditLogItemDto] }) + data: AuditLogItemDto[]; + + @ApiProperty({ type: PaginationMetaDto }) + pagination: PaginationMetaDto; + + @ApiProperty({ example: 'Audit logs retrieved successfully' }) + message: string; +} diff --git a/src/modules/admin/dto/create-audit-log.dto.ts b/src/modules/admin/dto/create-audit-log.dto.ts new file mode 100644 index 0000000..b48a141 --- /dev/null +++ b/src/modules/admin/dto/create-audit-log.dto.ts @@ -0,0 +1,11 @@ +export interface CreateAuditLogDto { + actor_wallet: string; + action: string; + resource: string; + resource_id: string | null; + before_state: Record | null; + after_state: Record | null; + ip_address: string | null; + user_agent: string | null; + metadata: Record | null; +} diff --git a/supabase/migrations/20260618000000_create_audit_logs_table.sql b/supabase/migrations/20260618000000_create_audit_logs_table.sql new file mode 100644 index 0000000..afa6cbe --- /dev/null +++ b/supabase/migrations/20260618000000_create_audit_logs_table.sql @@ -0,0 +1,38 @@ +CREATE TABLE public.audit_logs ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + actor_wallet TEXT NOT NULL, + action TEXT NOT NULL, + resource TEXT NOT NULL, + resource_id TEXT, + before_state JSONB, + after_state JSONB, + ip_address TEXT, + user_agent TEXT, + metadata JSONB, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE OR REPLACE FUNCTION public.prevent_audit_log_mutation() +RETURNS TRIGGER +LANGUAGE plpgsql +AS $$ +BEGIN + RAISE EXCEPTION 'audit_logs are immutable - updates and deletes are not allowed'; +END; +$$; + +CREATE TRIGGER trg_prevent_audit_log_update + BEFORE UPDATE ON public.audit_logs + FOR EACH ROW EXECUTE FUNCTION public.prevent_audit_log_mutation(); + +CREATE TRIGGER trg_prevent_audit_log_delete + BEFORE DELETE ON public.audit_logs + FOR EACH ROW EXECUTE FUNCTION public.prevent_audit_log_mutation(); + +ALTER TABLE public.audit_logs ENABLE ROW LEVEL SECURITY; + +CREATE INDEX idx_audit_logs_actor_wallet ON public.audit_logs(actor_wallet); +CREATE INDEX idx_audit_logs_action ON public.audit_logs(action); +CREATE INDEX idx_audit_logs_resource ON public.audit_logs(resource); +CREATE INDEX idx_audit_logs_resource_id ON public.audit_logs(resource_id); +CREATE INDEX idx_audit_logs_created_at ON public.audit_logs(created_at DESC);