diff --git a/apps/api/.env.example b/apps/api/.env.example index f10135c..94211cc 100644 --- a/apps/api/.env.example +++ b/apps/api/.env.example @@ -17,3 +17,6 @@ DATABASE_URL=postgresql://user:password@localhost:5432/stellar_pay # Redis (for when implemented) REDIS_URL=redis://localhost:6379 + +# Audit Logging +AUDIT_LOG_CHAIN_SECRET=replace-with-a-long-random-secret diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index 72888f2..48f7e2f 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -6,11 +6,13 @@ import { AppService } from './app.service'; import { HealthModule } from './health/health.module'; import { TreasuryModule } from './treasury/treasury.module'; import { AuthModule } from './auth/auth.module'; +import { AuditLogModule } from './audit-log/audit-log.module'; import { JwtAuthGuard } from './auth/guards/jwt-auth.guard'; import { ThrottlerRedisGuard } from './rate-limiter/guards/throttler-redis.guard'; @Module({ imports: [ + AuditLogModule, HealthModule, TreasuryModule, AuthModule, diff --git a/apps/api/src/audit-log/audit-log.controller.ts b/apps/api/src/audit-log/audit-log.controller.ts new file mode 100644 index 0000000..6db702b --- /dev/null +++ b/apps/api/src/audit-log/audit-log.controller.ts @@ -0,0 +1,47 @@ +import { Body, Controller, Get, Headers, Param, Post } from '@nestjs/common'; +import { Public } from '../auth/decorators/public.decorator'; +import type { RecordAuditEventRequest } from './interfaces/audit-event.interface'; +import type { AuditLogEntry, AuditTrailVerificationResult } from './interfaces/audit-log.interface'; +import { AuditLogService } from './audit-log.service'; + +@Public() +@Controller('audit-logs') +export class AuditLogController { + constructor(private readonly auditLogService: AuditLogService) {} + + @Post() + recordEvent( + @Body() body: Omit, + @Headers('x-forwarded-for') forwardedFor?: string, + @Headers('x-real-ip') realIp?: string, + ): AuditLogEntry { + return this.auditLogService.recordEvent({ + ...body, + ip: this.resolveIp(forwardedFor, realIp), + }); + } + + @Get() + listEntries(): AuditLogEntry[] { + return this.auditLogService.listEntries(); + } + + @Get('merchant/:merchantId') + listMerchantEntries(@Param('merchantId') merchantId: string): AuditLogEntry[] { + return this.auditLogService.listEntries(merchantId); + } + + @Get('verify') + verifyTrail(): AuditTrailVerificationResult { + return this.auditLogService.verifyTrail(); + } + + @Get('verify/:merchantId') + verifyMerchantTrail(@Param('merchantId') merchantId: string): AuditTrailVerificationResult { + return this.auditLogService.verifyTrail(merchantId); + } + + private resolveIp(forwardedFor?: string, realIp?: string): string { + return forwardedFor?.split(',')[0]?.trim() || realIp || 'unknown'; + } +} diff --git a/apps/api/src/audit-log/audit-log.module.ts b/apps/api/src/audit-log/audit-log.module.ts new file mode 100644 index 0000000..67553d4 --- /dev/null +++ b/apps/api/src/audit-log/audit-log.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { AuditLogController } from './audit-log.controller'; +import { AuditLogService } from './audit-log.service'; + +@Module({ + controllers: [AuditLogController], + providers: [AuditLogService], + exports: [AuditLogService], +}) +export class AuditLogModule {} diff --git a/apps/api/src/audit-log/audit-log.service.spec.ts b/apps/api/src/audit-log/audit-log.service.spec.ts new file mode 100644 index 0000000..35f2e36 --- /dev/null +++ b/apps/api/src/audit-log/audit-log.service.spec.ts @@ -0,0 +1,88 @@ +import { BadRequestException } from '@nestjs/common'; +import { AuditLogService } from './audit-log.service'; + +describe('AuditLogService', () => { + let service: AuditLogService; + + beforeEach(() => { + service = new AuditLogService(); + process.env.AUDIT_LOG_CHAIN_SECRET = 'chain-secret'; + }); + + afterEach(() => { + delete process.env.AUDIT_LOG_CHAIN_SECRET; + }); + + it('records supported sensitive events with the required fields', () => { + const entry = service.recordEvent( + { + merchant_id: 'merchant_123', + event_type: 'payment.initiated', + metadata: { amount: '100.00', currency: 'USD' }, + ip: '127.0.0.1', + }, + new Date('2026-03-25T10:00:00.000Z'), + ); + + expect(entry).toEqual({ + id: expect.any(String), + merchant_id: 'merchant_123', + event_type: 'payment.initiated', + metadata: { amount: '100.00', currency: 'USD' }, + ip: '127.0.0.1', + timestamp: '2026-03-25T10:00:00.000Z', + previous_hash: null, + entry_hash: expect.any(String), + }); + }); + + it('chains entries together to make the trail tamper-evident', () => { + const first = service.recordEvent({ + merchant_id: 'merchant_123', + event_type: 'auth.login', + metadata: { method: 'password' }, + ip: '127.0.0.1', + }); + const second = service.recordEvent({ + merchant_id: 'merchant_123', + event_type: 'api_key.changed', + metadata: { action: 'rotated' }, + ip: '127.0.0.2', + }); + + expect(second.previous_hash).toBe(first.entry_hash); + expect(service.verifyTrail()).toEqual({ + valid: true, + checked_entries: 2, + }); + }); + + it('filters entries by merchant', () => { + service.recordEvent({ + merchant_id: 'merchant_a', + event_type: 'auth.register', + metadata: { channel: 'web' }, + ip: '127.0.0.1', + }); + service.recordEvent({ + merchant_id: 'merchant_b', + event_type: 'webhook.config_changed', + metadata: { action: 'updated' }, + ip: '127.0.0.2', + }); + + expect(service.listEntries('merchant_b')).toHaveLength(1); + expect(service.listEntries('merchant_b')[0]?.event_type).toBe('webhook.config_changed'); + }); + + it('rejects unsupported audit events', () => { + expect(() => + service.recordEvent({ + merchant_id: 'merchant_123', + event_type: 'payment.failed' as never, + metadata: {}, + ip: '127.0.0.1', + }), + ).toThrow(BadRequestException); + }); +}); diff --git a/apps/api/src/audit-log/audit-log.service.ts b/apps/api/src/audit-log/audit-log.service.ts new file mode 100644 index 0000000..7572a96 --- /dev/null +++ b/apps/api/src/audit-log/audit-log.service.ts @@ -0,0 +1,122 @@ +import { BadRequestException, Injectable } from '@nestjs/common'; +import { createHash, createHmac, randomUUID } from 'crypto'; +import type { RecordAuditEventRequest, AuditEventType } from './interfaces/audit-event.interface'; +import type { AuditLogEntry, AuditTrailVerificationResult } from './interfaces/audit-log.interface'; + +const AUDIT_EVENT_TYPES: AuditEventType[] = [ + 'auth.login', + 'auth.register', + 'api_key.changed', + 'payment.initiated', + 'redemption.created', + 'webhook.config_changed', +]; + +@Injectable() +export class AuditLogService { + private readonly entries: AuditLogEntry[] = []; + + recordEvent(request: RecordAuditEventRequest, date = new Date()): AuditLogEntry { + this.ensureValidEventType(request.event_type); + + if (!request.merchant_id) { + throw new BadRequestException('merchant_id is required'); + } + + if (!request.ip) { + throw new BadRequestException('ip is required'); + } + + const previousEntry = this.entries.at(-1); + const entry: AuditLogEntry = { + id: randomUUID(), + merchant_id: request.merchant_id, + event_type: request.event_type, + metadata: request.metadata ?? {}, + ip: request.ip, + timestamp: date.toISOString(), + previous_hash: previousEntry?.entry_hash ?? null, + entry_hash: '', + }; + + entry.entry_hash = this.computeEntryHash(entry); + this.entries.push(entry); + + return entry; + } + + listEntries(merchantId?: string): AuditLogEntry[] { + const entries = merchantId + ? this.entries.filter((entry) => entry.merchant_id === merchantId) + : this.entries; + + return entries.map((entry) => ({ ...entry, metadata: { ...entry.metadata } })); + } + + verifyTrail(merchantId?: string): AuditTrailVerificationResult { + const entries = merchantId + ? this.entries.filter((entry) => entry.merchant_id === merchantId) + : this.entries; + + let previousHash: string | null = null; + + for (const entry of entries) { + const expectedHash = this.computeEntryHash({ + ...entry, + entry_hash: '', + }); + + if (entry.previous_hash !== previousHash || entry.entry_hash !== expectedHash) { + return { + valid: false, + checked_entries: entries.length, + }; + } + + previousHash = entry.entry_hash; + } + + return { + valid: true, + checked_entries: entries.length, + }; + } + + private ensureValidEventType(eventType: AuditEventType) { + if (!AUDIT_EVENT_TYPES.includes(eventType)) { + throw new BadRequestException(`Unsupported audit event type: ${eventType}`); + } + } + + private computeEntryHash(entry: Omit & { entry_hash: string }) { + const serializedEntry = this.serialize(entry); + const secret = process.env.AUDIT_LOG_CHAIN_SECRET; + + if (secret) { + return createHmac('sha256', secret).update(serializedEntry).digest('hex'); + } + + return createHash('sha256').update(serializedEntry).digest('hex'); + } + + private serialize(value: unknown): string { + return JSON.stringify(this.normalize(value)); + } + + private normalize(value: unknown): unknown { + if (Array.isArray(value)) { + return value.map((item) => this.normalize(item)); + } + + if (value && typeof value === 'object') { + return Object.entries(value as Record) + .sort(([left], [right]) => left.localeCompare(right)) + .reduce>((acc, [key, nestedValue]) => { + acc[key] = this.normalize(nestedValue); + return acc; + }, {}); + } + + return value; + } +} diff --git a/apps/api/src/audit-log/interfaces/audit-event.interface.ts b/apps/api/src/audit-log/interfaces/audit-event.interface.ts new file mode 100644 index 0000000..240d28b --- /dev/null +++ b/apps/api/src/audit-log/interfaces/audit-event.interface.ts @@ -0,0 +1,16 @@ +export type AuditEventType = + | 'auth.login' + | 'auth.register' + | 'api_key.changed' + | 'payment.initiated' + | 'redemption.created' + | 'webhook.config_changed'; + +export type AuditMetadata = Record; + +export interface RecordAuditEventRequest { + merchant_id: string; + event_type: AuditEventType; + metadata: AuditMetadata; + ip: string; +} diff --git a/apps/api/src/audit-log/interfaces/audit-log.interface.ts b/apps/api/src/audit-log/interfaces/audit-log.interface.ts new file mode 100644 index 0000000..b51a855 --- /dev/null +++ b/apps/api/src/audit-log/interfaces/audit-log.interface.ts @@ -0,0 +1,17 @@ +import type { AuditEventType, AuditMetadata } from './audit-event.interface'; + +export interface AuditLogEntry { + id: string; + merchant_id: string; + event_type: AuditEventType; + metadata: AuditMetadata; + ip: string; + timestamp: string; + previous_hash: string | null; + entry_hash: string; +} + +export interface AuditTrailVerificationResult { + valid: boolean; + checked_entries: number; +}