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: 1 addition & 1 deletion src/audit-log/audit-log.entity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ export enum HttpMethod {
@Index(['action', 'timestamp'])
@Index(['category', 'timestamp'])
@Index(['severity', 'timestamp'])
@Index(['entityType', 'entityId'])
@Index(['entityType', 'entityId', 'timestamp'])
@Index(['ipAddress', 'timestamp'])
@Index(['timestamp'])
@Index(['retentionUntil']) // required for efficient retention policy deletes
Expand Down
61 changes: 61 additions & 0 deletions src/audit-log/services/audit-query.service.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import { Test, TestingModule } from '@nestjs/testing';
import { AuditQueryService } from './audit-query.service';
import { getRepositoryToken } from '@nestjs/typeorm';
import { AuditLog } from '../audit-log.entity';

describe('AuditQueryService Pagination Limits', () => {
let service: AuditQueryService;
let mockRepo: any;

beforeEach(async () => {
mockRepo = {
find: jest.fn().mockResolvedValue([]),
};

const module: TestingModule = await Test.createTestingModule({
providers: [
AuditQueryService,
{
provide: getRepositoryToken(AuditLog),
useValue: mockRepo,
},
],
}).compile();

service = module.get<AuditQueryService>(AuditQueryService);
});

it('should enforce a hard maximum limit of 1000 for findAll', async () => {
await service.findAll(0, 999999);
expect(mockRepo.find).toHaveBeenCalledWith(
expect.objectContaining({
take: 1000,
}),
);
});

it('should enforce a hard maximum limit of 1000 for findByUser', async () => {
await service.findByUser('user123', 0, 999999);
expect(mockRepo.find).toHaveBeenCalledWith(
expect.objectContaining({
take: 1000,
}),
);
});

it('should apply a default 30-day time window if dates are not provided', async () => {
await service.findAll(0, 10);
const callArgs = mockRepo.find.mock.calls[0][0];
const whereClause = callArgs.where.timestamp as any;

// We expect a Between() operator
expect(whereClause._type).toEqual('between');
const [start, end] = whereClause._value;

expect(end).toBeInstanceOf(Date);
expect(start).toBeInstanceOf(Date);

const diffDays = Math.round((end.getTime() - start.getTime()) / (1000 * 60 * 60 * 24));
expect(diffDays).toBe(30);
});
});
116 changes: 97 additions & 19 deletions src/audit-log/services/audit-query.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,22 @@ import { clampLimit } from '../../common/utils/pagination.utils';

import { PaginationService } from '../../common/services/pagination.service';

/**
import { PaginationService } from '../../common/services/pagination.service';

const MAX_PAGINATION_LIMIT = 1000;
const DEFAULT_PAGINATION_LIMIT = 100;

function getBoundedTimeWindow(startDate?: Date, endDate?: Date): { startDate: Date, endDate: Date } {
const end = endDate || new Date();
const start = startDate || new Date(end.getTime() - 30 * 24 * 60 * 60 * 1000); // 30 days default
return { startDate: start, endDate: end };
}

function clampPagination(skip?: number, take?: number): { skip: number; take: number } {
const resolvedSkip = skip !== undefined && skip >= 0 ? skip : 0;
const resolvedTake = take !== undefined && take > 0 ? Math.min(take, MAX_PAGINATION_LIMIT) : DEFAULT_PAGINATION_LIMIT;
return { skip: resolvedSkip, take: resolvedTake };
}
* Provides audit log query operations.
* Responsible for searching and retrieving audit logs.
* Single Responsibility: Querying audit logs from the database.
Expand Down Expand Up @@ -102,6 +117,13 @@ export class AuditQueryService {
queryBuilder.andWhere('audit.timestamp >= :startDate', { startDate: filters.startDate });
} else if (filters.endDate) {
queryBuilder.andWhere('audit.timestamp <= :endDate', { endDate: filters.endDate });
} else {
// Default to 30 days if unbounded
const window = getBoundedTimeWindow();
queryBuilder.andWhere('audit.timestamp BETWEEN :startDate AND :endDate', {
startDate: window.startDate,
endDate: window.endDate,
});
}

const clampedLimit = clampLimit(limit);
Expand All @@ -117,34 +139,44 @@ export class AuditQueryService {
}

/**
* Find all logs (with limit)
* Find all logs (with limit and skip)
*/
async findAll(limit: number = 100): Promise<AuditLog[]> {
async findAll(skip: number = 0, limit: number = DEFAULT_PAGINATION_LIMIT, startDate?: Date, endDate?: Date): Promise<AuditLog[]> {
const { skip: clampedSkip, take: clampedTake } = clampPagination(skip, limit);
const window = getBoundedTimeWindow(startDate, endDate);
return this.auditRepo.find({
where: { timestamp: Between(window.startDate, window.endDate) },
order: { timestamp: 'DESC' },
take: limit,
skip: clampedSkip,
take: clampedTake,
});
}

/**
* Find logs by user
*/
async findByUser(userId: string, limit: number = 100): Promise<AuditLog[]> {
async findByUser(userId: string, skip: number = 0, limit: number = DEFAULT_PAGINATION_LIMIT, startDate?: Date, endDate?: Date): Promise<AuditLog[]> {
const { skip: clampedSkip, take: clampedTake } = clampPagination(skip, limit);
const window = getBoundedTimeWindow(startDate, endDate);
return this.auditRepo.find({
where: { userId },
where: { userId, timestamp: Between(window.startDate, window.endDate) },
order: { timestamp: 'DESC' },
take: limit,
skip: clampedSkip,
take: clampedTake,
});
}

/**
* Find logs by action
*/
async findByAction(action: AuditAction, limit: number = 100): Promise<AuditLog[]> {
async findByAction(action: AuditAction, skip: number = 0, limit: number = DEFAULT_PAGINATION_LIMIT, startDate?: Date, endDate?: Date): Promise<AuditLog[]> {
const { skip: clampedSkip, take: clampedTake } = clampPagination(skip, limit);
const window = getBoundedTimeWindow(startDate, endDate);
return this.auditRepo.find({
where: { action },
where: { action, timestamp: Between(window.startDate, window.endDate) },
order: { timestamp: 'DESC' },
take: limit,
skip: clampedSkip,
take: clampedTake,
});
}

Expand All @@ -154,36 +186,82 @@ export class AuditQueryService {
async findByEntity(
entityType: string,
entityId: string,
limit: number = 100,
skip: number = 0,
limit: number = DEFAULT_PAGINATION_LIMIT,
startDate?: Date,
endDate?: Date
): Promise<AuditLog[]> {
const { skip: clampedSkip, take: clampedTake } = clampPagination(skip, limit);
const window = getBoundedTimeWindow(startDate, endDate);
return this.auditRepo.find({
where: { entityType, entityId },
where: { entityType, entityId, timestamp: Between(window.startDate, window.endDate) },
order: { timestamp: 'DESC' },
take: limit,
skip: clampedSkip,
take: clampedTake,
});
}

/**
* Find logs by IP address
*/
async findByIpAddress(ipAddress: string, limit: number = 100): Promise<AuditLog[]> {
async findByIpAddress(ipAddress: string, skip: number = 0, limit: number = DEFAULT_PAGINATION_LIMIT, startDate?: Date, endDate?: Date): Promise<AuditLog[]> {
const { skip: clampedSkip, take: clampedTake } = clampPagination(skip, limit);
const window = getBoundedTimeWindow(startDate, endDate);
return this.auditRepo.find({
where: { ipAddress },
where: { ipAddress, timestamp: Between(window.startDate, window.endDate) },
order: { timestamp: 'DESC' },
take: limit,
skip: clampedSkip,
take: clampedTake,
});
}

/**
* Find logs by date range
*/
async findByDateRange(startDate: Date, endDate: Date, limit: number = 1000): Promise<AuditLog[]> {
async findByDateRange(startDate: Date, endDate: Date, skip: number = 0, limit: number = MAX_PAGINATION_LIMIT): Promise<AuditLog[]> {
const { skip: clampedSkip, take: clampedTake } = clampPagination(skip, limit);
const window = getBoundedTimeWindow(startDate, endDate);
return this.auditRepo.find({
where: {
timestamp: Between(startDate, endDate),
timestamp: Between(window.startDate, window.endDate),
},
order: { timestamp: 'DESC' },
take: limit,
skip: clampedSkip,
take: clampedTake,
});
}

/**
* For genuine bulk export needs, provide a streaming export path
* rather than an unbounded find.
*/
async streamAll(filters: IAuditLogSearchFilters = {}): Promise<any> {
const queryBuilder = this.auditRepo.createQueryBuilder('audit');

if (filters.userId) queryBuilder.andWhere('audit.userId = :userId', { userId: filters.userId });
if (filters.userEmail) queryBuilder.andWhere('audit.userEmail = :userEmail', { userEmail: filters.userEmail });
if (filters.actions && filters.actions.length > 0) queryBuilder.andWhere('audit.action IN (:...actions)', { actions: filters.actions });
if (filters.categories && filters.categories.length > 0) queryBuilder.andWhere('audit.category IN (:...categories)', { categories: filters.categories });
if (filters.severities && filters.severities.length > 0) queryBuilder.andWhere('audit.severity IN (:...severities)', { severities: filters.severities });
if (filters.entityType) queryBuilder.andWhere('audit.entityType = :entityType', { entityType: filters.entityType });
if (filters.entityId) queryBuilder.andWhere('audit.entityId = :entityId', { entityId: filters.entityId });
if (filters.ipAddress) queryBuilder.andWhere('audit.ipAddress = :ipAddress', { ipAddress: filters.ipAddress });
if (filters.sessionId) queryBuilder.andWhere('audit.sessionId = :sessionId', { sessionId: filters.sessionId });
if (filters.tenantId) queryBuilder.andWhere('audit.tenantId = :tenantId', { tenantId: filters.tenantId });

if (filters.startDate && filters.endDate) {
queryBuilder.andWhere('audit.timestamp BETWEEN :startDate AND :endDate', { startDate: filters.startDate, endDate: filters.endDate });
} else if (filters.startDate) {
queryBuilder.andWhere('audit.timestamp >= :startDate', { startDate: filters.startDate });
} else if (filters.endDate) {
queryBuilder.andWhere('audit.timestamp <= :endDate', { endDate: filters.endDate });
} else {
const window = getBoundedTimeWindow();
queryBuilder.andWhere('audit.timestamp BETWEEN :startDate AND :endDate', { startDate: window.startDate, endDate: window.endDate });
}

queryBuilder.orderBy('audit.timestamp', 'DESC');

return await queryBuilder.stream();
}
}
18 changes: 17 additions & 1 deletion src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -253,14 +253,30 @@ async function bootstrapWorker(): Promise<void> {

const contentLength = parseInt(contentLengthValue || '', 10);

if (!Number.isNaN(contentLength) && contentLength > fileUploadMaxBytes) {
if (Number.isNaN(contentLength)) {
const transferEncoding = req.headers['transfer-encoding'];
if (!transferEncoding || !transferEncoding.includes('chunked')) {
res.status(411).json({
message: 'Length Required',
});
return;
}
} else if (contentLength > fileUploadMaxBytes) {
res.status(413).json({
message: 'File upload too large',
maxBytes: fileUploadMaxBytes,
});
return;
}

let bytesRead = 0;
req.on('data', (chunk: Buffer) => {
bytesRead += chunk.length;
if (bytesRead > fileUploadMaxBytes) {
req.destroy(new Error('Payload Too Large'));
}
});

next();
});

Expand Down
9 changes: 7 additions & 2 deletions src/media/validation/upload-validation.util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,14 @@ export interface IUploadValidationFileLike {
export interface IUploadFilterCallback {
(error: Error | null, acceptFile: boolean): void;
}
const fileUploadMaxBytes = parseInt(
process.env.FILE_UPLOAD_MAX_BYTES || `${MAX_UPLOAD_FILE_SIZE}`,
10,
);

export const MEDIA_UPLOAD_INTERCEPTOR_OPTIONS = {
limits: {
fileSize: MAX_UPLOAD_FILE_SIZE,
fileSize: fileUploadMaxBytes,
files: 1,
},
fileFilter: (
Expand Down Expand Up @@ -52,6 +57,6 @@ export function buildUploadValidationDetails() {
return {
allowedTypes: ALL_ALLOWED_FILE_TYPES,
sizeLimits: FILE_SIZE_LIMITS,
maxUploadSize: MAX_UPLOAD_FILE_SIZE,
maxUploadSize: fileUploadMaxBytes,
};
}
Loading
Loading