From 55325736d560de9da7df784b17380eeb828059d5 Mon Sep 17 00:00:00 2001 From: oshowunm Date: Sun, 30 Aug 2026 16:15:43 +0100 Subject: [PATCH 1/2] feat(audit): implement cryptographic hash chain for tamper-evident audit logs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add SHA-256 hash chaining to the audit log system so each entry includes the hash of the preceding record, creating an immutable chain that mathematically guarantees detection of any unauthorized modifications. Closes #124 🤖 Generated with Codebuff Co-Authored-By: Codebuff --- prisma/schema.prisma | 2 + src/modules/audit/audit-export.spec.ts | 207 ++++---- src/modules/audit/audit-hash.service.spec.ts | 518 +++++++++++++++++++ src/modules/audit/audit-hash.service.ts | 235 +++++++++ src/modules/audit/audit.controller.ts | 15 + src/modules/audit/audit.module.ts | 4 +- src/modules/audit/audit.repository.ts | 4 + src/modules/audit/audit.service.ts | 51 +- src/modules/audit/index.ts | 1 + 9 files changed, 934 insertions(+), 103 deletions(-) create mode 100644 src/modules/audit/audit-hash.service.spec.ts create mode 100644 src/modules/audit/audit-hash.service.ts diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 6a2c19a..b2088be 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -489,6 +489,8 @@ model AuditLog { newValue Json? ipAddress String? device String? + previousHash String? // SHA-256 hash of the preceding audit log entry + hash String? // SHA-256 hash of this entry (links to previous) createdAt DateTime @default(now()) organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) diff --git a/src/modules/audit/audit-export.spec.ts b/src/modules/audit/audit-export.spec.ts index 99bda0b..19a81ba 100644 --- a/src/modules/audit/audit-export.spec.ts +++ b/src/modules/audit/audit-export.spec.ts @@ -1,99 +1,110 @@ -import { describe, it, expect, vi } from 'vitest'; -import { AuditService } from './audit.service'; -import { AuditRepository } from './audit.repository'; - -describe('AuditService - Export Compliance', () => { - const mockRepository = { - exportLogs: vi.fn(), - create: vi.fn(), - findManyAndCount: vi.fn(), - findById: vi.fn(), - }; - - const auditService = new AuditService(mockRepository as unknown as AuditRepository); - - it('should export audit logs in JSON format with pagination cursor', async () => { - const mockLogs = [ - { - id: 'log-1', - organizationId: 'org-123', - userId: 'user-1', - action: 'AGENT_PAYMENT_INITIATED', - entity: 'Transaction', - entityId: 'tx-1', - ipAddress: '127.0.0.1', - device: 'AgentRunner/1.0', - oldValue: { amount: 10 }, - newValue: { amount: 20 }, - createdAt: new Date('2026-08-28T10:00:00Z'), - user: { id: 'user-1', email: 'auditor@example.com', name: 'Auditor' }, - }, - ]; - - mockRepository.exportLogs.mockResolvedValueOnce(mockLogs); - - const result = await auditService.export('org-123', { - format: 'json', - limit: 10, - actionType: 'AGENT_PAYMENT_INITIATED', - }); - - expect(result.format).toBe('json'); - expect(result.count).toBe(1); - expect(result.data).toEqual(mockLogs); - expect(mockRepository.exportLogs).toHaveBeenCalledWith( - expect.objectContaining({ - organizationId: 'org-123', - action: 'AGENT_PAYMENT_INITIATED', - }), - 10, - undefined, - ); - }); - - it('should export audit logs in CSV format properly escaped', async () => { - const mockLogs = [ - { - id: 'log-1', - organizationId: 'org-123', - userId: 'user-1', - action: 'POLICY_OVERRIDE,ADMIN', - entity: 'Policy', - entityId: 'pol-1', - ipAddress: '127.0.0.1', - device: 'Desktop', - oldValue: { limit: 500 }, - newValue: { limit: 1000 }, - createdAt: new Date('2026-08-28T10:00:00Z'), - user: { id: 'user-1', email: 'admin@example.com', name: 'Admin' }, - }, - ]; - - mockRepository.exportLogs.mockResolvedValueOnce(mockLogs); - - const result = await auditService.export('org-123', { - format: 'csv', - limit: 10, - }); - - expect(result.format).toBe('csv'); - expect(typeof result.data).toBe('string'); - expect(result.data).toContain('id,organizationId,userId'); - expect(result.data).toContain('"POLICY_OVERRIDE,ADMIN"'); - }); - - it('should handle empty records gracefully', async () => { - mockRepository.exportLogs.mockResolvedValueOnce([]); - - const result = await auditService.export('org-123', { - format: 'csv', - limit: 10, - }); - - expect(result.format).toBe('csv'); - expect(result.count).toBe(0); - expect(result.data).toBe( - 'id,organizationId,userId,userEmail,action,entity,entityId,ipAddress,device,oldValue,newValue,createdAt', - ); - }); +import { describe, it, expect, vi } from 'vitest'; +import { AuditService } from './audit.service'; +import { AuditRepository } from './audit.repository'; +import { AuditHashService } from './audit-hash.service'; + +describe('AuditService - Export Compliance', () => { + const mockRepository = { + exportLogs: vi.fn(), + create: vi.fn(), + findManyAndCount: vi.fn(), + findById: vi.fn(), + }; + + const mockHashService = { + getLatestHash: vi.fn().mockResolvedValue(null), + computeEntryHash: vi.fn().mockReturnValue({ previousHash: null, hash: 'mock-hash' }), + verifyChainIntegrity: vi.fn(), + verifyEntryIntegrity: vi.fn(), + }; + + const auditService = new AuditService( + mockRepository as unknown as AuditRepository, + mockHashService as unknown as AuditHashService, + ); + + it('should export audit logs in JSON format with pagination cursor', async () => { + const mockLogs = [ + { + id: 'log-1', + organizationId: 'org-123', + userId: 'user-1', + action: 'AGENT_PAYMENT_INITIATED', + entity: 'Transaction', + entityId: 'tx-1', + ipAddress: '127.0.0.1', + device: 'AgentRunner/1.0', + oldValue: { amount: 10 }, + newValue: { amount: 20 }, + createdAt: new Date('2026-08-28T10:00:00Z'), + user: { id: 'user-1', email: 'auditor@example.com', name: 'Auditor' }, + }, + ]; + + mockRepository.exportLogs.mockResolvedValueOnce(mockLogs); + + const result = await auditService.export('org-123', { + format: 'json', + limit: 10, + actionType: 'AGENT_PAYMENT_INITIATED', + }); + + expect(result.format).toBe('json'); + expect(result.count).toBe(1); + expect(result.data).toEqual(mockLogs); + expect(mockRepository.exportLogs).toHaveBeenCalledWith( + expect.objectContaining({ + organizationId: 'org-123', + action: 'AGENT_PAYMENT_INITIATED', + }), + 10, + undefined, + ); + }); + + it('should export audit logs in CSV format properly escaped', async () => { + const mockLogs = [ + { + id: 'log-1', + organizationId: 'org-123', + userId: 'user-1', + action: 'POLICY_OVERRIDE,ADMIN', + entity: 'Policy', + entityId: 'pol-1', + ipAddress: '127.0.0.1', + device: 'Desktop', + oldValue: { limit: 500 }, + newValue: { limit: 1000 }, + createdAt: new Date('2026-08-28T10:00:00Z'), + user: { id: 'user-1', email: 'admin@example.com', name: 'Admin' }, + }, + ]; + + mockRepository.exportLogs.mockResolvedValueOnce(mockLogs); + + const result = await auditService.export('org-123', { + format: 'csv', + limit: 10, + }); + + expect(result.format).toBe('csv'); + expect(typeof result.data).toBe('string'); + expect(result.data).toContain('id,organizationId,userId'); + expect(result.data).toContain('"POLICY_OVERRIDE,ADMIN"'); + }); + + it('should handle empty records gracefully', async () => { + mockRepository.exportLogs.mockResolvedValueOnce([]); + + const result = await auditService.export('org-123', { + format: 'csv', + limit: 10, + }); + + expect(result.format).toBe('csv'); + expect(result.count).toBe(0); + expect(result.data).toBe( + 'id,organizationId,userId,userEmail,action,entity,entityId,ipAddress,device,oldValue,newValue,createdAt', + ); + }); }); diff --git a/src/modules/audit/audit-hash.service.spec.ts b/src/modules/audit/audit-hash.service.spec.ts new file mode 100644 index 0000000..2ba9348 --- /dev/null +++ b/src/modules/audit/audit-hash.service.spec.ts @@ -0,0 +1,518 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { AuditHashService, AuditHashInput } from './audit-hash.service'; + +describe('AuditHashService', () => { + let service: AuditHashService; + let mockPrisma: { + auditLog: { + findFirst: ReturnType; + findMany: ReturnType; + }; + }; + + beforeEach(() => { + mockPrisma = { + auditLog: { + findFirst: vi.fn(), + findMany: vi.fn(), + }, + }; + + service = new AuditHashService(mockPrisma as any); + }); + + describe('computeEntryHash', () => { + it('should compute a deterministic hash for the same input', () => { + const input: AuditHashInput = { + organizationId: 'org-123', + userId: 'user-1', + action: 'AGENT_PAYMENT_INITIATED', + entity: 'Transaction', + entityId: 'tx-1', + oldValue: { amount: 100 }, + newValue: { amount: 200 }, + ipAddress: '127.0.0.1', + device: 'AgentRunner/1.0', + createdAt: new Date('2026-08-30T10:00:00Z'), + }; + + const result1 = service.computeEntryHash(input, null); + const result2 = service.computeEntryHash(input, null); + + expect(result1.hash).toBe(result2.hash); + expect(result1.previousHash).toBeNull(); + }); + + it('should produce different hashes when inputs differ', () => { + const input1: AuditHashInput = { + organizationId: 'org-123', + userId: 'user-1', + action: 'AGENT_PAYMENT_INITIATED', + entity: 'Transaction', + entityId: 'tx-1', + oldValue: { amount: 100 }, + newValue: { amount: 200 }, + ipAddress: '127.0.0.1', + device: 'AgentRunner/1.0', + createdAt: new Date('2026-08-30T10:00:00Z'), + }; + + const input2: AuditHashInput = { + ...input1, + newValue: { amount: 300 }, // Different value + }; + + const result1 = service.computeEntryHash(input1, null); + const result2 = service.computeEntryHash(input2, null); + + expect(result1.hash).not.toBe(result2.hash); + }); + + it('should include previous hash in the chain', () => { + const input: AuditHashInput = { + organizationId: 'org-123', + userId: 'user-1', + action: 'AGENT_PAYMENT_INITIATED', + entity: 'Transaction', + entityId: 'tx-1', + oldValue: { amount: 100 }, + newValue: { amount: 200 }, + ipAddress: '127.0.0.1', + device: 'AgentRunner/1.0', + createdAt: new Date('2026-08-30T10:00:00Z'), + }; + + const previousHash = 'abc123def456'; + const result = service.computeEntryHash(input, previousHash); + + expect(result.previousHash).toBe(previousHash); + expect(result.hash).toBeDefined(); + }); + + it('should produce valid SHA-256 hashes', () => { + const input: AuditHashInput = { + organizationId: 'org-123', + userId: 'user-1', + action: 'AGENT_PAYMENT_INITIATED', + entity: 'Transaction', + entityId: 'tx-1', + oldValue: { amount: 100 }, + newValue: { amount: 200 }, + ipAddress: '127.0.0.1', + device: 'AgentRunner/1.0', + createdAt: new Date('2026-08-30T10:00:00Z'), + }; + + const result = service.computeEntryHash(input, null); + + // SHA-256 produces a 64-character hex string + expect(result.hash).toMatch(/^[a-f0-9]{64}$/); + }); + }); + + describe('getLatestHash', () => { + it('should return the latest hash for an organization', async () => { + mockPrisma.auditLog.findFirst.mockResolvedValueOnce({ + hash: 'latest-hash-123', + }); + + const result = await service.getLatestHash('org-123'); + + expect(result).toBe('latest-hash-123'); + expect(mockPrisma.auditLog.findFirst).toHaveBeenCalledWith({ + where: { + organizationId: 'org-123', + hash: { not: null }, + }, + orderBy: { createdAt: 'desc' }, + select: { hash: true }, + }); + }); + + it('should return null when no entries exist', async () => { + mockPrisma.auditLog.findFirst.mockResolvedValueOnce(null); + + const result = await service.getLatestHash('org-123'); + + expect(result).toBeNull(); + }); + }); + + describe('verifyChainIntegrity', () => { + it('should return valid for an empty chain', async () => { + mockPrisma.auditLog.findMany.mockResolvedValueOnce([]); + + const result = await service.verifyChainIntegrity('org-123'); + + expect(result.valid).toBe(true); + expect(result.totalChecked).toBe(0); + expect(result.message).toContain('all 0 entries are valid'); + }); + + it('should return valid for a chain with correct hashes', async () => { + const entry1: AuditHashInput = { + organizationId: 'org-123', + userId: 'user-1', + action: 'AGENT_PAYMENT_INITIATED', + entity: 'Transaction', + entityId: 'tx-1', + oldValue: { amount: 100 }, + newValue: { amount: 200 }, + ipAddress: '127.0.0.1', + device: 'AgentRunner/1.0', + createdAt: new Date('2026-08-30T10:00:00Z'), + }; + + const entry2: AuditHashInput = { + organizationId: 'org-123', + userId: 'user-1', + action: 'AGENT_PAYMENT_COMPLETED', + entity: 'Transaction', + entityId: 'tx-1', + oldValue: { amount: 200 }, + newValue: { amount: 200 }, + ipAddress: '127.0.0.1', + device: 'AgentRunner/1.0', + createdAt: new Date('2026-08-30T10:05:00Z'), + }; + + const hash1 = service.computeEntryHash(entry1, null); + const hash2 = service.computeEntryHash(entry2, hash1.hash); + + mockPrisma.auditLog.findMany.mockResolvedValueOnce([ + { ...entry1, ...hash1 }, + { ...entry2, ...hash2 }, + ]); + + const result = await service.verifyChainIntegrity('org-123'); + + expect(result.valid).toBe(true); + expect(result.totalChecked).toBe(2); + expect(result.message).toContain('all 2 entries are valid'); + }); + + it('should detect tampering when hash is modified', async () => { + const entry1: AuditHashInput = { + organizationId: 'org-123', + userId: 'user-1', + action: 'AGENT_PAYMENT_INITIATED', + entity: 'Transaction', + entityId: 'tx-1', + oldValue: { amount: 100 }, + newValue: { amount: 200 }, + ipAddress: '127.0.0.1', + device: 'AgentRunner/1.0', + createdAt: new Date('2026-08-30T10:00:00Z'), + }; + + const entry2: AuditHashInput = { + organizationId: 'org-123', + userId: 'user-1', + action: 'AGENT_PAYMENT_COMPLETED', + entity: 'Transaction', + entityId: 'tx-1', + oldValue: { amount: 200 }, + newValue: { amount: 200 }, + ipAddress: '127.0.0.1', + device: 'AgentRunner/1.0', + createdAt: new Date('2026-08-30T10:05:00Z'), + }; + + const hash1 = service.computeEntryHash(entry1, null); + const hash2 = service.computeEntryHash(entry2, hash1.hash); + + // Simulate tampering: modify the hash of entry 2 + const tamperedHash2 = 'tampered-hash-value'; + + mockPrisma.auditLog.findMany.mockResolvedValueOnce([ + { id: 'entry-1', ...entry1, ...hash1 }, + { id: 'entry-2', ...entry2, ...hash2, hash: tamperedHash2 }, + ]); + + const result = await service.verifyChainIntegrity('org-123'); + + expect(result.valid).toBe(false); + expect(result.brokenAtEntryId).toBe('entry-2'); + expect(result.brokenAtIndex).toBe(1); + expect(result.message).toContain('hash mismatch'); + }); + + it('should detect tampering when previous hash link is broken', async () => { + const entry1: AuditHashInput = { + organizationId: 'org-123', + userId: 'user-1', + action: 'AGENT_PAYMENT_INITIATED', + entity: 'Transaction', + entityId: 'tx-1', + oldValue: { amount: 100 }, + newValue: { amount: 200 }, + ipAddress: '127.0.0.1', + device: 'AgentRunner/1.0', + createdAt: new Date('2026-08-30T10:00:00Z'), + }; + + const entry2: AuditHashInput = { + organizationId: 'org-123', + userId: 'user-1', + action: 'AGENT_PAYMENT_COMPLETED', + entity: 'Transaction', + entityId: 'tx-1', + oldValue: { amount: 200 }, + newValue: { amount: 200 }, + ipAddress: '127.0.0.1', + device: 'AgentRunner/1.0', + createdAt: new Date('2026-08-30T10:05:00Z'), + }; + + const hash1 = service.computeEntryHash(entry1, null); + const hash2 = service.computeEntryHash(entry2, hash1.hash); + + // Simulate tampering: break the previous hash link + mockPrisma.auditLog.findMany.mockResolvedValueOnce([ + { id: 'entry-1', ...entry1, ...hash1 }, + { id: 'entry-2', ...entry2, ...hash2, previousHash: 'wrong-previous-hash' }, + ]); + + const result = await service.verifyChainIntegrity('org-123'); + + expect(result.valid).toBe(false); + expect(result.brokenAtEntryId).toBe('entry-2'); + expect(result.brokenAtIndex).toBe(1); + expect(result.message).toContain('expected previousHash'); + }); + + it('should skip entries without hash (pre-migration)', async () => { + const entry1: AuditHashInput = { + organizationId: 'org-123', + userId: 'user-1', + action: 'AGENT_PAYMENT_INITIATED', + entity: 'Transaction', + entityId: 'tx-1', + oldValue: { amount: 100 }, + newValue: { amount: 200 }, + ipAddress: '127.0.0.1', + device: 'AgentRunner/1.0', + createdAt: new Date('2026-08-30T10:00:00Z'), + }; + + const entry2: AuditHashInput = { + organizationId: 'org-123', + userId: 'user-1', + action: 'AGENT_PAYMENT_COMPLETED', + entity: 'Transaction', + entityId: 'tx-1', + oldValue: { amount: 200 }, + newValue: { amount: 200 }, + ipAddress: '127.0.0.1', + device: 'AgentRunner/1.0', + createdAt: new Date('2026-08-30T10:05:00Z'), + }; + + const hash2 = service.computeEntryHash(entry2, null); + + mockPrisma.auditLog.findMany.mockResolvedValueOnce([ + { ...entry1, previousHash: null, hash: null }, // Pre-migration entry + { ...entry2, ...hash2 }, + ]); + + const result = await service.verifyChainIntegrity('org-123'); + + expect(result.valid).toBe(true); + expect(result.totalChecked).toBe(2); + }); + }); + + describe('verifyEntryIntegrity', () => { + it('should verify a valid entry', async () => { + const input: AuditHashInput = { + organizationId: 'org-123', + userId: 'user-1', + action: 'AGENT_PAYMENT_INITIATED', + entity: 'Transaction', + entityId: 'tx-1', + oldValue: { amount: 100 }, + newValue: { amount: 200 }, + ipAddress: '127.0.0.1', + device: 'AgentRunner/1.0', + createdAt: new Date('2026-08-30T10:00:00Z'), + }; + + const hashResult = service.computeEntryHash(input, null); + + mockPrisma.auditLog.findFirst.mockResolvedValueOnce({ + id: 'entry-1', + ...input, + ...hashResult, + }); + + const result = await service.verifyEntryIntegrity('entry-1', 'org-123'); + + expect(result.valid).toBe(true); + expect(result.message).toContain('integrity verified'); + }); + + it('should detect tampering in a single entry', async () => { + const input: AuditHashInput = { + organizationId: 'org-123', + userId: 'user-1', + action: 'AGENT_PAYMENT_INITIATED', + entity: 'Transaction', + entityId: 'tx-1', + oldValue: { amount: 100 }, + newValue: { amount: 200 }, + ipAddress: '127.0.0.1', + device: 'AgentRunner/1.0', + createdAt: new Date('2026-08-30T10:00:00Z'), + }; + + const hashResult = service.computeEntryHash(input, null); + + // Simulate tampering: modify the stored hash + mockPrisma.auditLog.findFirst.mockResolvedValueOnce({ + id: 'entry-1', + ...input, + ...hashResult, + hash: 'tampered-hash', + }); + + const result = await service.verifyEntryIntegrity('entry-1', 'org-123'); + + expect(result.valid).toBe(false); + expect(result.message).toContain('hash mismatch'); + }); + + it('should return error for non-existent entry', async () => { + mockPrisma.auditLog.findFirst.mockResolvedValueOnce(null); + + const result = await service.verifyEntryIntegrity('entry-1', 'org-123'); + + expect(result.valid).toBe(false); + expect(result.message).toContain('not found'); + }); + + it('should return error for pre-migration entry without hash', async () => { + mockPrisma.auditLog.findFirst.mockResolvedValueOnce({ + id: 'entry-1', + organizationId: 'org-123', + userId: 'user-1', + action: 'AGENT_PAYMENT_INITIATED', + entity: 'Transaction', + entityId: 'tx-1', + oldValue: { amount: 100 }, + newValue: { amount: 200 }, + ipAddress: '127.0.0.1', + device: 'AgentRunner/1.0', + createdAt: new Date('2026-08-30T10:00:00Z'), + previousHash: null, + hash: null, + }); + + const result = await service.verifyEntryIntegrity('entry-1', 'org-123'); + + expect(result.valid).toBe(false); + expect(result.message).toContain('no hash'); + }); + }); + + describe('hash chain properties', () => { + it('should create a linked chain where each entry depends on the previous', () => { + const entries: AuditHashInput[] = [ + { + organizationId: 'org-123', + userId: 'user-1', + action: 'AGENT_CREATED', + entity: 'Agent', + entityId: 'agent-1', + oldValue: null, + newValue: { name: 'Finance Bot' }, + ipAddress: '127.0.0.1', + device: 'Admin', + createdAt: new Date('2026-08-30T10:00:00Z'), + }, + { + organizationId: 'org-123', + userId: 'user-1', + action: 'AGENT_WALLET_LINKED', + entity: 'Wallet', + entityId: 'wallet-1', + oldValue: null, + newValue: { agentId: 'agent-1' }, + ipAddress: '127.0.0.1', + device: 'Admin', + createdAt: new Date('2026-08-30T10:05:00Z'), + }, + { + organizationId: 'org-123', + userId: 'user-2', + action: 'AGENT_PAYMENT_INITIATED', + entity: 'Transaction', + entityId: 'tx-1', + oldValue: { balance: 1000 }, + newValue: { balance: 800 }, + ipAddress: '10.0.0.1', + device: 'AgentRunner/1.0', + createdAt: new Date('2026-08-30T10:10:00Z'), + }, + ]; + + // Build the chain + let previousHash: string | null = null; + const hashes: string[] = []; + + for (const entry of entries) { + const result = service.computeEntryHash(entry, previousHash); + hashes.push(result.hash); + previousHash = result.hash; + } + + // Verify the chain is linked correctly + expect(hashes).toHaveLength(3); + expect(hashes[0]).not.toBe(hashes[1]); + expect(hashes[1]).not.toBe(hashes[2]); + + // Each entry's hash should depend on the previous + const entry1Hash = service.computeEntryHash(entries[0], null); + expect(entry1Hash.hash).toBe(hashes[0]); + + const entry2Hash = service.computeEntryHash(entries[1], hashes[0]); + expect(entry2Hash.hash).toBe(hashes[1]); + + const entry3Hash = service.computeEntryHash(entries[2], hashes[1]); + expect(entry3Hash.hash).toBe(hashes[2]); + }); + + it('should make the chain immutable by detecting any modification', () => { + const input: AuditHashInput = { + organizationId: 'org-123', + userId: 'user-1', + action: 'AGENT_PAYMENT_INITIATED', + entity: 'Transaction', + entityId: 'tx-1', + oldValue: { amount: 100 }, + newValue: { amount: 200 }, + ipAddress: '127.0.0.1', + device: 'AgentRunner/1.0', + createdAt: new Date('2026-08-30T10:00:00Z'), + }; + + const originalHash = service.computeEntryHash(input, null); + + // Try modifying each field and verify hash changes + const modifications: Partial[] = [ + { action: 'AGENT_PAYMENT_MODIFIED' }, + { entity: 'TransactionModified' }, + { entityId: 'tx-2' }, + { oldValue: { amount: 150 } }, + { newValue: { amount: 250 } }, + { ipAddress: '192.168.1.1' }, + { device: 'ModifiedAgent' }, + ]; + + for (const mod of modifications) { + const modifiedInput = { ...input, ...mod }; + const modifiedHash = service.computeEntryHash(modifiedInput, null); + expect(modifiedHash.hash).not.toBe(originalHash.hash); + } + }); + }); +}); diff --git a/src/modules/audit/audit-hash.service.ts b/src/modules/audit/audit-hash.service.ts new file mode 100644 index 0000000..3890d08 --- /dev/null +++ b/src/modules/audit/audit-hash.service.ts @@ -0,0 +1,235 @@ +import { Injectable } from '@nestjs/common'; +import { sha256 } from '../../utils/crypto.util'; +import { PrismaService } from '../../database/prisma.service'; + +/** + * Cryptographic hash chain service for tamper-evident audit logs. + * Each audit entry includes the SHA-256 hash of the preceding record, + * creating an immutable chain that detects any unauthorized modifications. + */ + +export interface AuditHashInput { + organizationId: string; + userId?: string | null; + action: string; + entity: string; + entityId?: string | null; + oldValue?: unknown; + newValue?: unknown; + ipAddress?: string | null; + device?: string | null; + createdAt?: Date; +} + +export interface AuditHashResult { + previousHash: string | null; + hash: string; +} + +export interface IntegrityCheckResult { + valid: boolean; + totalChecked: number; + brokenAtEntryId: string | null; + brokenAtIndex: number | null; + message: string; +} + +@Injectable() +export class AuditHashService { + constructor(private readonly prisma: PrismaService) {} + + /** + * Computes the hash for an audit log entry based on its payload and the + * previous entry's hash. The hash is deterministic given the same inputs. + */ + computeEntryHash( + input: AuditHashInput, + previousHash: string | null, + ): AuditHashResult { + const canonicalPayload = this.buildCanonicalPayload(input, previousHash); + const hash = sha256(canonicalPayload); + + return { + previousHash, + hash, + }; + } + + /** + * Retrieves the latest audit log entry hash for an organization. + * This is used to chain the next entry. + */ + async getLatestHash(organizationId: string): Promise { + const latest = await this.prisma.auditLog.findFirst({ + where: { + organizationId, + hash: { not: null }, + }, + orderBy: { createdAt: 'desc' }, + select: { hash: true }, + }); + + return latest?.hash ?? null; + } + + /** + * Validates the integrity of the entire audit chain for an organization. + * Traverses all entries and verifies each hash links correctly to the previous. + */ + async verifyChainIntegrity( + organizationId: string, + ): Promise { + const entries = await this.prisma.auditLog.findMany({ + where: { organizationId }, + orderBy: { createdAt: 'asc' }, + select: { + id: true, + organizationId: true, + userId: true, + action: true, + entity: true, + entityId: true, + oldValue: true, + newValue: true, + ipAddress: true, + device: true, + previousHash: true, + hash: true, + createdAt: true, + }, + }); + + let expectedPreviousHash: string | null = null; + + for (let i = 0; i < entries.length; i++) { + const entry = entries[i]; + + // Skip entries that don't have hash chain data (pre-migration) + if (entry.hash === null) { + continue; + } + + // Check that the stored previousHash matches what we expect + if (entry.previousHash !== expectedPreviousHash) { + return { + valid: false, + totalChecked: i, + brokenAtEntryId: entry.id, + brokenAtIndex: i, + message: `Chain broken at entry ${entry.id} (index ${i}): expected previousHash "${expectedPreviousHash}", got "${entry.previousHash}"`, + }; + } + + // Recompute the hash and verify it matches the stored value + const computed = this.computeEntryHash( + { + organizationId: entry.organizationId, + userId: entry.userId, + action: entry.action, + entity: entry.entity, + entityId: entry.entityId, + oldValue: entry.oldValue, + newValue: entry.newValue, + ipAddress: entry.ipAddress, + device: entry.device, + createdAt: entry.createdAt, + }, + entry.previousHash, + ); + + if (computed.hash !== entry.hash) { + return { + valid: false, + totalChecked: i, + brokenAtEntryId: entry.id, + brokenAtIndex: i, + message: `Entry ${entry.id} (index ${i}) hash mismatch: expected "${computed.hash}", got "${entry.hash}"`, + }; + } + + expectedPreviousHash = entry.hash; + } + + return { + valid: true, + totalChecked: entries.length, + brokenAtEntryId: null, + brokenAtIndex: null, + message: `Chain integrity verified: all ${entries.length} entries are valid`, + }; + } + + /** + * Validates a single audit log entry's hash. + */ + async verifyEntryIntegrity( + entryId: string, + organizationId: string, + ): Promise<{ valid: boolean; message: string }> { + const entry = await this.prisma.auditLog.findFirst({ + where: { id: entryId, organizationId }, + }); + + if (!entry) { + return { valid: false, message: `Entry ${entryId} not found` }; + } + + if (entry.hash === null) { + return { + valid: false, + message: `Entry ${entryId} has no hash (pre-migration entry)`, + }; + } + + const computed = this.computeEntryHash( + { + organizationId: entry.organizationId, + userId: entry.userId, + action: entry.action, + entity: entry.entity, + entityId: entry.entityId, + oldValue: entry.oldValue, + newValue: entry.newValue, + ipAddress: entry.ipAddress, + device: entry.device, + createdAt: entry.createdAt, + }, + entry.previousHash, + ); + + if (computed.hash !== entry.hash) { + return { + valid: false, + message: `Entry ${entryId} hash mismatch: expected "${computed.hash}", got "${entry.hash}"`, + }; + } + + return { valid: true, message: `Entry ${entryId} integrity verified` }; + } + + /** + * Builds a canonical string representation of the audit entry for hashing. + * The representation is deterministic and includes all fields that define + * the entry's content, plus the previous hash to create the chain. + */ + private buildCanonicalPayload( + input: AuditHashInput, + previousHash: string | null, + ): string { + const parts: string[] = [ + `org:${input.organizationId}`, + `user:${input.userId ?? ''}`, + `action:${input.action}`, + `entity:${input.entity}`, + `entityId:${input.entityId ?? ''}`, + `old:${JSON.stringify(input.oldValue ?? null)}`, + `new:${JSON.stringify(input.newValue ?? null)}`, + `ip:${input.ipAddress ?? ''}`, + `device:${input.device ?? ''}`, + `ts:${input.createdAt ? input.createdAt.toISOString() : new Date().toISOString()}`, + `prev:${previousHash ?? 'GENESIS'}`, + ]; + + return parts.join('|'); + } +} diff --git a/src/modules/audit/audit.controller.ts b/src/modules/audit/audit.controller.ts index b6ea355..f098322 100644 --- a/src/modules/audit/audit.controller.ts +++ b/src/modules/audit/audit.controller.ts @@ -64,4 +64,19 @@ export class AuditController { findOne(@CurrentUser('organizationId') organizationId: string, @Param('id') id: string) { return this.auditService.findById(organizationId, id); } + + @Get('integrity/verify') + @ApiOperation({ summary: 'Verify the integrity of the entire audit chain' }) + verifyIntegrity(@CurrentUser('organizationId') organizationId: string) { + return this.auditService.verifyIntegrity(organizationId); + } + + @Get(':id/integrity') + @ApiOperation({ summary: 'Verify the integrity of a single audit log entry' }) + verifyEntryIntegrity( + @CurrentUser('organizationId') organizationId: string, + @Param('id') id: string, + ) { + return this.auditService.verifyEntryIntegrity(id, organizationId); + } } diff --git a/src/modules/audit/audit.module.ts b/src/modules/audit/audit.module.ts index e3dc0d5..2871705 100644 --- a/src/modules/audit/audit.module.ts +++ b/src/modules/audit/audit.module.ts @@ -2,16 +2,18 @@ import { Global, Module } from '@nestjs/common'; import { AuditController } from './audit.controller'; import { AuditService } from './audit.service'; import { AuditRepository } from './audit.repository'; +import { AuditHashService } from './audit-hash.service'; import { AuditListener } from './audit.listener'; /** * Audit module. Globally exported so any module can record audit entries * directly; the listener also captures every domain event automatically. + * Includes cryptographic hash chaining for tamper-evident audit history. */ @Global() @Module({ controllers: [AuditController], - providers: [AuditService, AuditRepository, AuditListener], + providers: [AuditService, AuditRepository, AuditHashService, AuditListener], exports: [AuditService], }) export class AuditModule {} diff --git a/src/modules/audit/audit.repository.ts b/src/modules/audit/audit.repository.ts index 1ddf23d..84ecadc 100644 --- a/src/modules/audit/audit.repository.ts +++ b/src/modules/audit/audit.repository.ts @@ -13,6 +13,8 @@ export interface CreateAuditLogData { newValue?: Prisma.InputJsonValue; ipAddress?: string | null; device?: string | null; + previousHash?: string | null; + hash?: string | null; } /** Persistence for the append-only audit log. Writes and reads only — no updates. */ @@ -32,6 +34,8 @@ export class AuditRepository { newValue: data.newValue, ipAddress: data.ipAddress ?? null, device: data.device ?? null, + previousHash: data.previousHash ?? null, + hash: data.hash ?? null, }, }); } diff --git a/src/modules/audit/audit.service.ts b/src/modules/audit/audit.service.ts index 3bdd16c..534bf9d 100644 --- a/src/modules/audit/audit.service.ts +++ b/src/modules/audit/audit.service.ts @@ -1,6 +1,7 @@ import { Injectable } from '@nestjs/common'; import { Prisma } from '@prisma/client'; import { AuditRepository, CreateAuditLogData } from './audit.repository'; +import { AuditHashService } from './audit-hash.service'; import { buildPaginationMeta, PaginationQuery, @@ -18,13 +19,40 @@ type ExportedAuditLog = Prisma.AuditLogGetPayload<{ /** * Writes and queries the immutable audit trail. Records Who / When / Where / * Why / Old / New for every important action. Never updates or deletes. + * Integrates cryptographic hash chaining for tamper-evident audit history. */ @Injectable() export class AuditService { - constructor(private readonly repository: AuditRepository) {} - - record(data: CreateAuditLogData) { - return this.repository.create(data); + constructor( + private readonly repository: AuditRepository, + private readonly hashService: AuditHashService, + ) {} + + async record(data: CreateAuditLogData) { + const previousHash = await this.hashService.getLatestHash(data.organizationId); + const createdAt = new Date(); + + const hashResult = this.hashService.computeEntryHash( + { + organizationId: data.organizationId, + userId: data.userId, + action: data.action, + entity: data.entity, + entityId: data.entityId, + oldValue: data.oldValue, + newValue: data.newValue, + ipAddress: data.ipAddress, + device: data.device, + createdAt, + }, + previousHash, + ); + + return this.repository.create({ + ...data, + previousHash: hashResult.previousHash, + hash: hashResult.hash, + }); } async list(organizationId: string, query: PaginationQuery) { @@ -156,4 +184,19 @@ export class AuditService { findById(organizationId: string, id: string) { return this.repository.findById(organizationId, id); } + + /** + * Verifies the integrity of the entire audit chain for an organization. + * Returns detailed information about chain validity. + */ + async verifyIntegrity(organizationId: string) { + return this.hashService.verifyChainIntegrity(organizationId); + } + + /** + * Verifies the integrity of a single audit log entry. + */ + async verifyEntryIntegrity(entryId: string, organizationId: string) { + return this.hashService.verifyEntryIntegrity(entryId, organizationId); + } } diff --git a/src/modules/audit/index.ts b/src/modules/audit/index.ts index 00bf20c..6513c17 100644 --- a/src/modules/audit/index.ts +++ b/src/modules/audit/index.ts @@ -1,2 +1,3 @@ export * from './audit.service'; +export * from './audit-hash.service'; export * from './audit.module'; From 6759c8645dbf7f80cab73464b217326d58a56e56 Mon Sep 17 00:00:00 2001 From: oshowunm Date: Sun, 30 Aug 2026 16:37:04 +0100 Subject: [PATCH 2/2] fix(audit): fix lint error and controller route ordering for hash chain MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix @typescript-eslint/no-explicit-any in audit-hash.service.spec.ts - Move integrity routes above :id route to prevent parameter shadowing - Change per-entry integrity route to /integrity/:id for consistency 🤖 Generated with Codebuff Co-Authored-By: Codebuff --- src/modules/audit/audit-hash.service.spec.ts | 2 +- src/modules/audit/audit.controller.ts | 14 +++++++------- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/src/modules/audit/audit-hash.service.spec.ts b/src/modules/audit/audit-hash.service.spec.ts index 2ba9348..0943d7a 100644 --- a/src/modules/audit/audit-hash.service.spec.ts +++ b/src/modules/audit/audit-hash.service.spec.ts @@ -18,7 +18,7 @@ describe('AuditHashService', () => { }, }; - service = new AuditHashService(mockPrisma as any); + service = new AuditHashService(mockPrisma as unknown as import('../../database/prisma.service').PrismaService); }); describe('computeEntryHash', () => { diff --git a/src/modules/audit/audit.controller.ts b/src/modules/audit/audit.controller.ts index f098322..6069783 100644 --- a/src/modules/audit/audit.controller.ts +++ b/src/modules/audit/audit.controller.ts @@ -59,19 +59,13 @@ export class AuditController { return this.auditService.list(organizationId, query); } - @Get(':id') - @ApiOperation({ summary: 'Get a single audit log entry' }) - findOne(@CurrentUser('organizationId') organizationId: string, @Param('id') id: string) { - return this.auditService.findById(organizationId, id); - } - @Get('integrity/verify') @ApiOperation({ summary: 'Verify the integrity of the entire audit chain' }) verifyIntegrity(@CurrentUser('organizationId') organizationId: string) { return this.auditService.verifyIntegrity(organizationId); } - @Get(':id/integrity') + @Get('integrity/:id') @ApiOperation({ summary: 'Verify the integrity of a single audit log entry' }) verifyEntryIntegrity( @CurrentUser('organizationId') organizationId: string, @@ -79,4 +73,10 @@ export class AuditController { ) { return this.auditService.verifyEntryIntegrity(id, organizationId); } + + @Get(':id') + @ApiOperation({ summary: 'Get a single audit log entry' }) + findOne(@CurrentUser('organizationId') organizationId: string, @Param('id') id: string) { + return this.auditService.findById(organizationId, id); + } }