Skip to content
Open
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
3 changes: 3 additions & 0 deletions apps/api/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
2 changes: 2 additions & 0 deletions apps/api/src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
47 changes: 47 additions & 0 deletions apps/api/src/audit-log/audit-log.controller.ts
Original file line number Diff line number Diff line change
@@ -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<RecordAuditEventRequest, 'ip'>,
@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';
}
}
10 changes: 10 additions & 0 deletions apps/api/src/audit-log/audit-log.module.ts
Original file line number Diff line number Diff line change
@@ -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 {}
88 changes: 88 additions & 0 deletions apps/api/src/audit-log/audit-log.service.spec.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
122 changes: 122 additions & 0 deletions apps/api/src/audit-log/audit-log.service.ts
Original file line number Diff line number Diff line change
@@ -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<AuditLogEntry, 'entry_hash'> & { 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<string, unknown>)
.sort(([left], [right]) => left.localeCompare(right))
.reduce<Record<string, unknown>>((acc, [key, nestedValue]) => {
acc[key] = this.normalize(nestedValue);
return acc;
}, {});
}

return value;
}
}
16 changes: 16 additions & 0 deletions apps/api/src/audit-log/interfaces/audit-event.interface.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>;

export interface RecordAuditEventRequest {
merchant_id: string;
event_type: AuditEventType;
metadata: AuditMetadata;
ip: string;
}
17 changes: 17 additions & 0 deletions apps/api/src/audit-log/interfaces/audit-log.interface.ts
Original file line number Diff line number Diff line change
@@ -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;
}
Loading