Skip to content
Merged
4,744 changes: 4,736 additions & 8 deletions package-lock.json

Large diffs are not rendered by default.

32 changes: 32 additions & 0 deletions src/audit/audit.controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { Controller, Get, Query, UseGuards } from '@nestjs/common';
import { AuditService } from './audit.service';
import { JwtAuthGuard } from '../common/guards/jwt-auth.guard';
import { RolesGuard } from '../common/guards/roles.guard';
import { Roles } from '../common/decorators/roles.decorator';
import { Role } from '../common/enums/role.enum';

@Controller('admin/audit')
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles(Role.ADMIN)
export class AuditController {
constructor(private readonly auditService: AuditService) {}

@Get()
async getAuditLogs(
@Query('actor') actor?: string,
@Query('action') action?: string,
@Query('startDate') startDate?: string,
@Query('endDate') endDate?: string,
@Query('page') page?: string,
@Query('limit') limit?: string,
) {
return this.auditService.findAll({
actor,
action,
startDate,
endDate,
page: page ? parseInt(page, 10) : undefined,
limit: limit ? parseInt(limit, 10) : undefined,
});
}
}
168 changes: 168 additions & 0 deletions src/audit/audit.interceptor.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
import { Test, TestingModule } from '@nestjs/testing';
import { ExecutionContext, CallHandler } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { of } from 'rxjs';
import { AuditInterceptor } from './audit.interceptor';
import { AuditService } from './audit.service';
import { AuditController } from './audit.controller';

describe('AuditInterceptor', () => {
let interceptor: AuditInterceptor;
let auditService: AuditService;

const mockAuditService = {
log: jest.fn().mockImplementation(() => new Promise((resolve) => setTimeout(() => resolve({}), 50))),
};

const mockReflector = {
get: jest.fn(),
};

beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
AuditInterceptor,
{
provide: AuditService,
useValue: mockAuditService,
},
{
provide: Reflector,
useValue: mockReflector,
},
],
}).compile();

interceptor = module.get<AuditInterceptor>(AuditInterceptor);
auditService = module.get<AuditService>(AuditService);
jest.clearAllMocks();
});

it('should be defined', () => {
expect(interceptor).toBeDefined();
});

describe('interception filtering', () => {
it('should bypass non-admin routes', async () => {
const mockRequest = { url: '/public/puzzles', method: 'POST', user: { id: 'admin1' } };
const context = createMockExecutionContext(mockRequest);
const next: CallHandler = {
handle: () => of({ test: true }),
};

await interceptor.intercept(context, next).toPromise();

expect(mockAuditService.log).not.toHaveBeenCalled();
});

it('should bypass GET requests on admin routes', async () => {
const mockRequest = { url: '/admin/users', method: 'GET', user: { id: 'admin1' } };
const context = createMockExecutionContext(mockRequest);
const next: CallHandler = {
handle: () => of({ test: true }),
};

await interceptor.intercept(context, next).toPromise();

expect(mockAuditService.log).not.toHaveBeenCalled();
});

it('should log mutating requests on admin routes', async () => {
const mockRequest = {
url: '/admin/users/123/ban',
method: 'PATCH',
params: { id: '123' },
body: { reason: 'spam' },
user: { id: 'admin1' },
};

const context = createMockExecutionContext(mockRequest, 'banUser');
const next: CallHandler = {
handle: () => of({ success: true, id: '123' }),
};

await interceptor.intercept(context, next).toPromise();

expect(mockAuditService.log).toHaveBeenCalledWith(
'BAN_USER',
'admin1',
'123',
'User',
expect.objectContaining({ body: { reason: 'spam' } })
);
});

it('should log even if user is anonymous', async () => {
const mockRequest = {
url: '/admin/users/123/ban',
method: 'PATCH',
params: { id: '123' },
user: undefined,
};

const context = createMockExecutionContext(mockRequest, 'banUser');
const next: CallHandler = {
handle: () => of({ success: true }),
};

await interceptor.intercept(context, next).toPromise();

expect(mockAuditService.log).toHaveBeenCalledWith(
'BAN_USER',
'anonymous',
'123',
'User',
expect.any(Object)
);
});
});

describe('non-blocking writes', () => {
it('should complete interceptor call immediately without awaiting log write', async () => {
const mockRequest = {
url: '/admin/puzzle/approve',
method: 'POST',
params: { puzzleId: 'p1' },
user: { id: 'admin1' },
};
const context = createMockExecutionContext(mockRequest, 'approvePuzzle');
const next: CallHandler = {
handle: () => of({ success: true }),
};

const startTime = Date.now();
await interceptor.intercept(context, next).toPromise();
const endTime = Date.now();

expect(endTime - startTime).toBeLessThan(35);
});
});

describe('immutability checking', () => {
it('should only expose GET route mappings on AuditController', () => {
const methods = Object.getOwnPropertyNames(AuditController.prototype);

for (const method of methods) {
if (method === 'constructor') continue;
const originalMethod = AuditController.prototype[method];
const requestMethodCode = Reflect.getMetadata('method', originalMethod);
if (requestMethodCode !== undefined) {
expect(requestMethodCode).toBe(0);
}
}
});
});

function createMockExecutionContext(req: any, handlerName = 'testHandler'): ExecutionContext {
return {
switchToHttp: () => ({
getRequest: () => req,
getResponse: () => ({}),
}),
getHandler: () => ({
name: handlerName,
}),
getClass: () => ({}),
} as unknown as ExecutionContext;
}
});
79 changes: 79 additions & 0 deletions src/audit/audit.interceptor.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import {
CallHandler,
ExecutionContext,
Injectable,
NestInterceptor,
} from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { Observable } from 'rxjs';
import { tap } from 'rxjs/operators';
import { AuditService } from './audit.service';
import { AUDIT_ACTION_KEY, AUDIT_TARGET_ENTITY_KEY } from './decorators/audit.decorator';

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

intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
const request = context.switchToHttp().getRequest();

const url = request.url || '';
const isAdminRoute = url.startsWith('/admin') || url.startsWith('admin');
const isMutating = ['POST', 'PUT', 'PATCH', 'DELETE'].includes(request.method);

if (!isAdminRoute || !isMutating) {
return next.handle();
}

return next.handle().pipe(
tap({
next: (data) => {
const handler = context.getHandler();
const actorId = request.user?.id || 'anonymous';

let action = this.reflector.get<string>(AUDIT_ACTION_KEY, handler);
if (!action) {
const methodName = handler.name;
action = methodName.replace(/([A-Z])/g, '_$1').toUpperCase();
}

let targetEntity = this.reflector.get<string>(AUDIT_TARGET_ENTITY_KEY, handler);
if (!targetEntity) {
if (url.includes('/users')) {
targetEntity = 'User';
} else if (url.includes('/submissions') || url.includes('/puzzles') || url.includes('/calibration')) {
targetEntity = 'Puzzle';
} else if (url.includes('/rewards')) {
targetEntity = 'Reward';
} else if (url.includes('/nft')) {
targetEntity = 'Nft';
} else if (url.includes('/sessions')) {
targetEntity = 'Session';
}
}

let targetId = request.params?.id || request.params?.puzzleId || request.params?.userId || request.params?.targetId;
if (!targetId && request.body && request.body.id) {
targetId = request.body.id;
}
if (!targetId && data && typeof data === 'object' && data.id) {
targetId = data.id;
}

const payload = {
body: request.body,
query: request.query,
params: request.params,
};

this.auditService.log(action, actorId, targetId, targetEntity, payload).catch((err) => {
console.error('Failed to write audit log:', err);
});
},
}),
);
}
}
12 changes: 11 additions & 1 deletion src/audit/audit.module.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,21 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { APP_INTERCEPTOR } from '@nestjs/core';
import { AuditLog } from './entities/audit-log.entity';
import { AuditService } from './audit.service';
import { AuditController } from './audit.controller';
import { AuditInterceptor } from './audit.interceptor';

@Module({
imports: [TypeOrmModule.forFeature([AuditLog])],
providers: [AuditService],
controllers: [AuditController],
providers: [
AuditService,
{
provide: APP_INTERCEPTOR,
useClass: AuditInterceptor,
},
],
exports: [AuditService],
})
export class AuditModule {}
Loading