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
6 changes: 6 additions & 0 deletions src/app.ci.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@ import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { PrismaModule } from './database/prisma/prisma.module';
import { HealthModule } from './health/health.module';
import { SearchModule } from './search/search.module';
import { AuditController } from './audit/audit.controller';
import { AuditModule } from './audit/audit.module';

@Module({
imports: [
Expand All @@ -13,6 +16,9 @@ import { HealthModule } from './health/health.module';
}),
PrismaModule,
HealthModule,
SearchModule,
AuditModule,
],
controllers: [AuditController],
})
export class AppCiModule {}
47 changes: 47 additions & 0 deletions src/audit/audit.controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { Controller, Get, Query } from '@nestjs/common';
import { ApiOperation, ApiQuery, ApiTags } from '@nestjs/swagger';
import { AuditService, AuditReportFilters } from './audit.service';
import { AuditAction } from './entities/audit-log.entity';

@ApiTags('Audit')
@Controller('audit')
export class AuditController {
constructor(private readonly auditService: AuditService) {}

@Get()
@ApiOperation({ summary: 'Paginated audit log' })
@ApiQuery({ name: 'from', required: false, type: String })
@ApiQuery({ name: 'to', required: false, type: String })
@ApiQuery({ name: 'action', required: false, enum: AuditAction })
@ApiQuery({ name: 'actor', required: false, type: String })
@ApiQuery({ name: 'page', required: false, type: Number })
@ApiQuery({ name: 'limit', required: false, type: Number })
findAll(
@Query('from') from = new Date(Date.now() - 30 * 86_400_000).toISOString(),
@Query('to') to = new Date().toISOString(),
@Query('action') action?: AuditAction,
@Query('actor') actor?: string,
@Query('page') page = 1,
@Query('limit') limit = 50,
) {
const filters: AuditReportFilters = {
from: new Date(from),
to: new Date(to),
action,
actorAddress: actor,
};
return this.auditService.findAll(filters, Number(page), Number(limit));
}

@Get('report')
@ApiOperation({ summary: 'Aggregated audit report' })
report(
@Query('from') from = new Date(Date.now() - 30 * 86_400_000).toISOString(),
@Query('to') to = new Date().toISOString(),
) {
return this.auditService.generateReport({
from: new Date(from),
to: new Date(to),
});
}
}
31 changes: 31 additions & 0 deletions src/audit/audit.module.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { Module } from '@nestjs/common';
import { ScheduleModule } from '@nestjs/schedule';
import { TypeOrmModule } from '@nestjs/typeorm';
import { Cron, CronExpression, Injectable as Inj, Logger as Log } from '@nestjs/schedule';
import { AuditService } from './audit.service';
import { AuditController } from './audit.controller';
import { AuditInterceptor } from './interceptors/audit.interceptor';
import { AuditLog } from './entities/audit-log.entity';

@Inj()
class AuditRetentionTask {
private readonly logger = new Log(AuditRetentionTask.name);
constructor(private readonly auditService: AuditService) {}

@Cron(CronExpression.EVERY_DAY_AT_MIDNIGHT)
async run() {
this.logger.log('Running audit retention policy…');
await this.auditService.applyRetentionPolicy();
}
}

@Module({
imports: [
ScheduleModule.forRoot(),
TypeOrmModule.forFeature([AuditLog]),
],
controllers: [AuditController],
providers: [AuditService, AuditInterceptor, AuditRetentionTask],
exports: [AuditService, AuditInterceptor],
})
export class AuditModule {}
151 changes: 151 additions & 0 deletions src/audit/audit.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
import { Injectable, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Between, LessThan, Repository } from 'typeorm';
import { AuditLog, AuditAction } from './audit-log.entity';

export interface AuditEntry {
action: AuditAction;
actorAddress?: string;
targetId?: string;
targetType?: string;
diff?: Record<string, unknown>;
requestMeta?: Record<string, unknown>;
}

export interface AuditReportFilters {
from: Date;
to: Date;
action?: AuditAction;
actorAddress?: string;
targetType?: string;
}

export interface AuditReportRow {
action: string;
count: number;
}

export interface AuditReport {
totalEvents: number;
byAction: AuditReportRow[];
byActor: { actorAddress: string; count: number }[];
retentionDays: number;
}

/** How long audit rows are retained — configurable per environment */
const RETENTION_DAYS = Number(process.env.AUDIT_RETENTION_DAYS ?? 365);

@Injectable()
export class AuditService {
private readonly logger = new Logger(AuditService.name);

constructor(
@InjectRepository(AuditLog)
private readonly auditRepo: Repository<AuditLog>,
) {}

// ─── Write ─────────────────────────────────────────────────────────────────

/**
* Record an audit event. Fire-and-forget safe — errors are logged but
* never bubble up to the caller so a logging failure never breaks a
* business operation.
*/
async log(entry: AuditEntry): Promise<void> {
await this.auditRepo.save(entry).catch((err) =>
this.logger.error('Failed to write audit log', err),
);
}

// ─── Query ─────────────────────────────────────────────────────────────────

async findAll(
filters: AuditReportFilters,
page = 1,
limit = 50,
): Promise<{ data: AuditLog[]; total: number }> {
const where: Record<string, unknown> = {
createdAt: Between(filters.from, filters.to),
};
if (filters.action) where['action'] = filters.action;
if (filters.actorAddress) where['actorAddress'] = filters.actorAddress;
if (filters.targetType) where['targetType'] = filters.targetType;

const [data, total] = await this.auditRepo.findAndCount({
where,
order: { createdAt: 'DESC' },
skip: (page - 1) * limit,
take: limit,
});

return { data, total };
}

// ─── Reporting ──────────────────────────────────────────────────────────────

async generateReport(filters: AuditReportFilters): Promise<AuditReport> {
const base = this.auditRepo
.createQueryBuilder('a')
.where('a.createdAt BETWEEN :from AND :to', { from: filters.from, to: filters.to });

if (filters.action) base.andWhere('a.action = :action', { action: filters.action });
if (filters.actorAddress) base.andWhere('a.actorAddress = :actorAddress', { actorAddress: filters.actorAddress });
if (filters.targetType) base.andWhere('a.targetType = :targetType', { targetType: filters.targetType });

const [byAction, byActor, totalRaw] = await Promise.all([
base.clone()
.select('a.action', 'action')
.addSelect('COUNT(*)', 'count')
.groupBy('a.action')
.orderBy('count', 'DESC')
.getRawMany<{ action: string; count: string }>(),

base.clone()
.select('a.actorAddress', 'actorAddress')
.addSelect('COUNT(*)', 'count')
.where('a.actorAddress IS NOT NULL')
.andWhere('a.createdAt BETWEEN :from AND :to', { from: filters.from, to: filters.to })
.groupBy('a.actorAddress')
.orderBy('count', 'DESC')
.limit(20)
.getRawMany<{ actorAddress: string; count: string }>(),

base.clone()
.select('COUNT(*)', 'total')
.getRawOne<{ total: string }>(),
]);

return {
totalEvents: parseInt(totalRaw?.total ?? '0', 10),
byAction: byAction.map((r) => ({ action: r.action, count: Number(r.count) })),
byActor: byActor.map((r) => ({ actorAddress: r.actorAddress, count: Number(r.count) })),
retentionDays: RETENTION_DAYS,
};
}

// ─── Retention ──────────────────────────────────────────────────────────────

/**
* Delete audit rows older than RETENTION_DAYS.
* Called by a @Cron job in the module — runs nightly.
*/
async applyRetentionPolicy(): Promise<number> {
const cutoff = new Date();
cutoff.setDate(cutoff.getDate() - RETENTION_DAYS);

const result = await this.auditRepo.delete({ createdAt: LessThan(cutoff) });
const deleted = result.affected ?? 0;

if (deleted > 0) {
this.logger.log(`Audit retention: deleted ${deleted} records older than ${RETENTION_DAYS} days`);
// Record the purge itself so there's a meta-audit trail
await this.log({
action: AuditAction.CALL_SETTLED, // re-use closest action or add AUDIT_PURGE to the enum
targetType: 'audit_log',
diff: { deletedCount: deleted, cutoffDate: cutoff.toISOString() },
});
}

return deleted;
}
}
64 changes: 64 additions & 0 deletions src/audit/entities/audit-log.entity.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import {
Column,
CreateDateColumn,
Entity,
Index,
PrimaryGeneratedColumn,
} from 'typeorm';

export enum AuditAction {
// Auth
LOGIN = 'AUTH_LOGIN',
LOGOUT = 'AUTH_LOGOUT',
// Users
USER_UPDATED = 'USER_UPDATED',
USER_FOLLOWED = 'USER_FOLLOWED',
USER_UNFOLLOWED = 'USER_UNFOLLOWED',
// Calls
CALL_CREATED = 'CALL_CREATED',
CALL_RESOLVED = 'CALL_RESOLVED',
CALL_SETTLED = 'CALL_SETTLED',
// Stakes
STAKE_PLACED = 'STAKE_PLACED',
ESCROW_RELEASED = 'ESCROW_RELEASED',
// Admin
ADMIN_CHANGED = 'ADMIN_CHANGED',
FEE_CHANGED = 'FEE_CHANGED',
OUTCOME_MANAGER_CHANGED = 'OUTCOME_MANAGER_CHANGED',
}

@Entity('audit_log')
export class AuditLog {
@PrimaryGeneratedColumn('uuid')
id: string;

@Index()
@Column({ type: 'enum', enum: AuditAction })
action: AuditAction;

/** The wallet address or system identity performing the action */
@Index()
@Column({ nullable: true })
actorAddress?: string;

/** The entity being acted upon (e.g. call ID, user address) */
@Index()
@Column({ nullable: true })
targetId?: string;

/** Entity type: 'call' | 'user' | 'stake' | 'contract' */
@Column({ nullable: true })
targetType?: string;

/** JSON snapshot of what changed — { before, after } */
@Column({ type: 'jsonb', nullable: true })
diff?: Record<string, unknown>;

/** HTTP request metadata — IP, user-agent, etc. */
@Column({ type: 'jsonb', nullable: true })
requestMeta?: Record<string, unknown>;

@Index()
@CreateDateColumn({ type: 'timestamptz' })
createdAt: Date;
}
55 changes: 55 additions & 0 deletions src/audit/interceptors/audit.interceptor.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import {
CallHandler,
ExecutionContext,
Injectable,
NestInterceptor,
} from '@nestjs/common';
import { Request } from 'express';
import { Observable, tap } from 'rxjs';
import { AuditAction } from '../entities/audit-log.entity';
import { AuditService } from '../audit.service';

/** Maps HTTP method + route pattern to an AuditAction */
const ROUTE_ACTION_MAP: Record<string, AuditAction> = {
'POST /users/:address/follow': AuditAction.USER_FOLLOWED,
'POST /users/:address/unfollow': AuditAction.USER_UNFOLLOWED,
'POST /calls': AuditAction.CALL_CREATED,
'POST /calls/:id/resolve': AuditAction.CALL_RESOLVED,
'POST /calls/:id/settle': AuditAction.CALL_SETTLED,
'POST /calls/:id/stake': AuditAction.STAKE_PLACED,
};

@Injectable()
export class AuditInterceptor implements NestInterceptor {
constructor(private readonly auditService: AuditService) {}

intercept(context: ExecutionContext, next: CallHandler): Observable<unknown> {
const req = context.switchToHttp().getRequest<Request>();

// Only audit mutating methods
if (!['POST', 'PUT', 'PATCH', 'DELETE'].includes(req.method)) {
return next.handle();
}

return next.handle().pipe(
tap(() => {
const routeKey = `${req.method} ${req.route?.path ?? req.path}`;
const action = ROUTE_ACTION_MAP[routeKey];

if (!action) return;

this.auditService.log({
action,
actorAddress: (req as any).user?.address,
targetId: req.params?.id ?? req.params?.address,
requestMeta: {
ip: req.ip,
userAgent: req.headers['user-agent'],
method: req.method,
path: req.path,
},
});
}),
);
}
}
Loading
Loading