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
2 changes: 2 additions & 0 deletions src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down Expand Up @@ -62,6 +63,7 @@ import { CorrelationIdMiddleware } from './common/logger/correlation-id.middlewa
TransactionStatusCheckerModule,
NonceCleanupModule,
CreditScoringModule,
AdminModule,
StellarModule,
],
controllers: [],
Expand Down
11 changes: 11 additions & 0 deletions src/common/decorators/audit-action.decorator.ts
Original file line number Diff line number Diff line change
@@ -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);
74 changes: 74 additions & 0 deletions src/common/interceptors/audit.interceptor.ts
Original file line number Diff line number Diff line change
@@ -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<any> {
const auditAction = this.reflector.get<AuditActionOptions>(
AUDIT_ACTION_KEY,
context.getHandler(),
);

if (!auditAction) {
return next.handle();
}

const http = context.switchToHttp();
const request = http.getRequest<FastifyRequest & { user?: { wallet: string } }>();
const user = request.user;
const actorWallet = user?.wallet ?? 'system';
const body = request.body ?? {};
const params = request.params as Record<string, unknown>;
const query = request.query ?? {};
const resourceId =
(params?.id as string) ??
(params?.resourceId as string) ??
(body && typeof body === 'object' && 'id' in body ? (body as Record<string, unknown>).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<string, unknown>)
? (responseBody as Record<string, unknown>).data
: responseBody ?? logEntry.after_state;

this.auditService
.log({
...logEntry,
after_state: afterState as Record<string, unknown> | null,
})
.catch((err: Error) => {
console.error('Failed to persist audit log:', err);
});
}),
);
}
}
11 changes: 11 additions & 0 deletions src/modules/admin/admin.module.ts
Original file line number Diff line number Diff line change
@@ -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 {}
41 changes: 41 additions & 0 deletions src/modules/admin/audit.controller.ts
Original file line number Diff line number Diff line change
@@ -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);
}
}
124 changes: 124 additions & 0 deletions src/modules/admin/audit.service.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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<string, unknown> | null;
afterState: Record<string, unknown> | null;
ipAddress?: string | null;
userAgent?: string | null;
metadata?: Record<string, unknown> | null;
}): Promise<void> {
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<AuditLogListResponseDto> {
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<string, unknown> | null,
afterState: log.after_state as Record<string, unknown> | 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',
};
}
}
45 changes: 45 additions & 0 deletions src/modules/admin/dto/audit-log-query.dto.ts
Original file line number Diff line number Diff line change
@@ -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;
}
45 changes: 45 additions & 0 deletions src/modules/admin/dto/audit-log-response.dto.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown> | null;

@ApiPropertyOptional({ description: 'State after the mutation' })
afterState: Record<string, unknown> | 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<AuditLogItemDto> {
@ApiProperty({ example: true })
success: boolean;

@ApiProperty({ type: [AuditLogItemDto] })
data: AuditLogItemDto[];

@ApiProperty({ type: PaginationMetaDto })
pagination: PaginationMetaDto;

@ApiProperty({ example: 'Audit logs retrieved successfully' })
message: string;
}
11 changes: 11 additions & 0 deletions src/modules/admin/dto/create-audit-log.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
export interface CreateAuditLogDto {
actor_wallet: string;
action: string;
resource: string;
resource_id: string | null;
before_state: Record<string, unknown> | null;
after_state: Record<string, unknown> | null;
ip_address: string | null;
user_agent: string | null;
metadata: Record<string, unknown> | null;
}
Loading
Loading