diff --git a/backend/.env.example b/backend/.env.example index 5150c5e6..16dfdb4c 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -41,3 +41,15 @@ ENCRYPTION_KEY=your-32-byte-encryption-key # Rate limiting (ttl in ms, limit = max requests per ttl window) THROTTLE_TTL=60000 THROTTLE_LIMIT=60 + +# Credential audit logging +# Secret used to HMAC-chain audit entries (falls back to JWT_SECRET if unset) +AUDIT_HMAC_SECRET=your-audit-hmac-secret-change-in-production +# Comma-separated wallet addresses allowed to access the audit query interface +ADMIN_WALLETS= +# Retention window in days; entries older than this are purged by the worker +AUDIT_RETENTION_DAYS=365 +# How often the retention worker sweeps, in hours +AUDIT_RETENTION_SWEEP_HOURS=24 +# Set to "false" to disable the background retention worker +AUDIT_RETENTION_ENABLED=true diff --git a/backend/docs/AUDIT_LOGGING.md b/backend/docs/AUDIT_LOGGING.md new file mode 100644 index 00000000..809e26f8 --- /dev/null +++ b/backend/docs/AUDIT_LOGGING.md @@ -0,0 +1,99 @@ +# Credential Audit Logging + +Comprehensive, tamper-evident audit logging for all credential operations +(issuance, verification, revocation, updates, deletion, sharing). + +## Overview + +The `AuditModule` (`src/audit/`) provides: + +- **Automatic capture** of credential operations via an interceptor + decorator, + so handlers don't carry logging boilerplate. +- **Tamper-evident storage** using HMAC hash-chaining across entries. +- **An admin-only query interface** with filtering, pagination, integrity + verification and export (JSON/CSV). +- **A retention worker** that purges entries older than a configurable window. + +## Data model + +`AuditLog` (`audit_logs` table): + +| Field | Description | +| -------------------- | -------------------------------------------------------- | +| `id` | UUID primary key | +| `sequence` | Auto-increment; establishes total chain order | +| `actorId` | Wallet address that performed the operation | +| `operationType` | `issued` \| `revoked` \| `verified` \| `updated` \| `deleted` \| `shared` \| `accessed` | +| `targetCredentialId` | Credential the operation targeted (nullable) | +| `clientIp` | Source IP of the request (nullable) | +| `status` | `success` \| `failure` | +| `metadata` | Non-sensitive JSON context (method, path, error, …) | +| `previousHash` | Hash of the preceding entry (null for genesis) | +| `hash` | HMAC-SHA256 of this entry chained to `previousHash` | +| `createdAt` | Timestamp | + +## Automatic logging + +Annotate a credential handler and attach the interceptor — a record is emitted +for every call, success or failure, with no extra code in the handler body: + +```ts +@Controller('identities') +@UseInterceptors(AuditInterceptor) +export class IdentityController { + @Post() + @Audit(AuditOperation.ISSUED, { credentialIdParam: null }) + create(...) { ... } + + @Patch(':id/revoke') + @Audit(AuditOperation.REVOKED) // targetCredentialId read from the `id` route param + revoke(...) { ... } +} +``` + +The interceptor records the acting wallet (`request.user.walletAddress`), the +client IP, the target credential id (from the configured route param or the +response `id`), and the operation outcome. Auditing failures never interrupt the +underlying operation — they are logged and swallowed. + +## Integrity (hash-chaining) + +Each entry's `hash` is `HMAC-SHA256(AUDIT_HMAC_SECRET, canonicalPayload || previousHash)`. +Because every entry commits to the previous entry's hash, any post-hoc edit, +insertion or deletion of a historical row breaks the chain. + +`GET /audit-logs/verify` recomputes every hash and validates the linkage, +returning `{ valid, checked, brokenAtSequence?, reason? }`. Verification starts +from the earliest surviving entry, so a retention purge of the oldest contiguous +prefix does not raise a false positive. + +## Retention + +`AuditRetentionService` runs a background sweep (native interval, driven by Nest +lifecycle hooks — no extra scheduler dependency) that purges entries older than +`AUDIT_RETENTION_DAYS`. A sweep can also be triggered on demand via +`POST /audit-logs/retention/sweep`. + +## API (admin-only) + +All endpoints require a valid JWT **and** a wallet listed in `ADMIN_WALLETS`. + +| Method & path | Description | +| ------------------------------- | --------------------------------------------- | +| `GET /audit-logs` | Filtered, paginated query | +| `GET /audit-logs/verify` | Integrity report | +| `GET /audit-logs/export` | Export matching entries (`?format=json\|csv`) | +| `POST /audit-logs/retention/sweep` | Trigger a retention sweep | + +Query/filter parameters: `page`, `limit`, `actorId`, `operationType`, `status`, +`targetCredentialId`, `from` (ISO-8601), `to` (ISO-8601). + +## Configuration + +| Variable | Default | Description | +| ----------------------------- | ------- | ---------------------------------------------- | +| `AUDIT_HMAC_SECRET` | `JWT_SECRET` | Secret used to HMAC-chain entries | +| `ADMIN_WALLETS` | (empty) | Comma-separated admin wallet allowlist | +| `AUDIT_RETENTION_DAYS` | `365` | Age threshold for purging entries | +| `AUDIT_RETENTION_SWEEP_HOURS` | `24` | Interval between retention sweeps | +| `AUDIT_RETENTION_ENABLED` | `true` | Set to `false` to disable the retention worker | diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index dbbb3e19..f6529214 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -16,6 +16,7 @@ import { AiModule } from './ai/ai.module'; import { SorobanModule } from './soroban/soroban.module'; import { IndexerModule } from './indexer/indexer.module'; import { HealthModule } from './health/health.module'; +import { AuditModule } from './audit/audit.module'; @Module({ imports: [ @@ -68,6 +69,7 @@ import { HealthModule } from './health/health.module'; SorobanModule, IndexerModule, HealthModule, + AuditModule, ], providers: [ { diff --git a/backend/src/audit/audit-log.entity.ts b/backend/src/audit/audit-log.entity.ts new file mode 100644 index 00000000..ea95ec79 --- /dev/null +++ b/backend/src/audit/audit-log.entity.ts @@ -0,0 +1,90 @@ +import { + Entity, + Column, + PrimaryGeneratedColumn, + CreateDateColumn, + Index, + Generated, +} from 'typeorm'; + +/** + * The type of credential operation that produced an audit entry. + */ +export enum AuditOperation { + ISSUED = 'issued', + REVOKED = 'revoked', + VERIFIED = 'verified', + UPDATED = 'updated', + DELETED = 'deleted', + SHARED = 'shared', + ACCESSED = 'accessed', +} + +/** + * Outcome of the audited operation. + */ +export enum AuditStatus { + SUCCESS = 'success', + FAILURE = 'failure', +} + +/** + * Immutable, append-only record of a single credential operation. + * + * Integrity is guaranteed through HMAC hash-chaining: every entry stores the + * `hash` of the previous entry in `previousHash`, and its own `hash` is an HMAC + * computed over its canonical payload plus that `previousHash`. Any post-hoc + * modification of a historical row breaks the chain and is detected by + * {@link AuditService.verifyIntegrity}. + */ +@Entity('audit_logs') +export class AuditLog { + @PrimaryGeneratedColumn('uuid') + id: string; + + /** + * Monotonically increasing sequence number establishing a total order over + * the chain. Used to link and verify entries deterministically. + */ + @Index({ unique: true }) + @Generated('increment') + @Column({ type: 'bigint' }) + sequence: string; + + /** Identifier of the actor performing the operation (e.g. wallet address). */ + @Index() + @Column() + actorId: string; + + @Index() + @Column({ type: 'enum', enum: AuditOperation }) + operationType: AuditOperation; + + /** Identifier of the credential the operation targeted, when applicable. */ + @Index() + @Column({ nullable: true }) + targetCredentialId: string | null; + + @Column({ nullable: true }) + clientIp: string | null; + + @Index() + @Column({ type: 'enum', enum: AuditStatus, default: AuditStatus.SUCCESS }) + status: AuditStatus; + + /** Non-sensitive JSON context describing the event. */ + @Column({ type: 'json', nullable: true }) + metadata: Record | null; + + /** Hash of the preceding chain entry (null for the genesis entry). */ + @Column({ type: 'varchar', length: 64, nullable: true }) + previousHash: string | null; + + /** HMAC of this entry's canonical payload chained to `previousHash`. */ + @Column({ type: 'varchar', length: 64 }) + hash: string; + + @Index() + @CreateDateColumn() + createdAt: Date; +} diff --git a/backend/src/audit/audit-retention.service.ts b/backend/src/audit/audit-retention.service.ts new file mode 100644 index 00000000..10adb38f --- /dev/null +++ b/backend/src/audit/audit-retention.service.ts @@ -0,0 +1,71 @@ +import { + Injectable, + Logger, + OnModuleDestroy, + OnModuleInit, +} from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { AuditService } from './audit.service'; + +/** + * Background worker that periodically enforces the audit retention policy. + * + * Uses a native interval driven by Nest lifecycle hooks so no additional + * scheduling dependency is required. The interval is configurable via + * `AUDIT_RETENTION_SWEEP_HOURS` (default 24h) and disabled entirely when + * `AUDIT_RETENTION_ENABLED` is set to `false`. + */ +@Injectable() +export class AuditRetentionService implements OnModuleInit, OnModuleDestroy { + private readonly logger = new Logger(AuditRetentionService.name); + private timer: NodeJS.Timeout | null = null; + + constructor( + private readonly auditService: AuditService, + private readonly configService: ConfigService, + ) {} + + onModuleInit(): void { + const enabled = + this.configService.get('AUDIT_RETENTION_ENABLED') !== 'false'; + if (!enabled) { + this.logger.log('Audit retention worker disabled by configuration'); + return; + } + + const sweepHours = + this.configService.get('AUDIT_RETENTION_SWEEP_HOURS') ?? 24; + const intervalMs = sweepHours * 60 * 60 * 1000; + + this.timer = setInterval(() => { + void this.sweep(); + }, intervalMs); + // Do not keep the event loop alive solely for the retention sweep. + this.timer.unref?.(); + + this.logger.log( + `Audit retention worker started (sweep every ${sweepHours}h)`, + ); + } + + onModuleDestroy(): void { + if (this.timer) { + clearInterval(this.timer); + this.timer = null; + } + } + + /** Run a single retention sweep. Exposed for manual triggering and tests. */ + async sweep(): Promise { + try { + return await this.auditService.enforceRetention(); + } catch (error) { + this.logger.error( + `Audit retention sweep failed: ${ + error instanceof Error ? error.message : String(error) + }`, + ); + return 0; + } + } +} diff --git a/backend/src/audit/audit.controller.ts b/backend/src/audit/audit.controller.ts new file mode 100644 index 00000000..e0d733fe --- /dev/null +++ b/backend/src/audit/audit.controller.ts @@ -0,0 +1,57 @@ +import { + Controller, + Get, + Post, + Query, + Res, + UseGuards, +} from '@nestjs/common'; +import { Response } from 'express'; +import { AuditService } from './audit.service'; +import { QueryAuditLogDto } from './dto/query-audit-log.dto'; +import { ExportAuditLogDto } from './dto/export-audit-log.dto'; +import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; +import { AdminGuard } from './guards/admin.guard'; + +/** + * Admin-only interface over the credential audit trail: filtered query, + * integrity verification, retention control and compliance export. + */ +@Controller('audit-logs') +@UseGuards(JwtAuthGuard, AdminGuard) +export class AuditController { + constructor(private readonly auditService: AuditService) {} + + @Get() + query(@Query() query: QueryAuditLogDto) { + return this.auditService.query(query); + } + + @Get('verify') + verify() { + return this.auditService.verifyIntegrity(); + } + + @Post('retention/sweep') + sweep() { + return this.auditService + .enforceRetention() + .then((purged) => ({ purged })); + } + + @Get('export') + async export( + @Query() query: ExportAuditLogDto, + @Res({ passthrough: true }) res: Response, + ): Promise { + const format = query.format ?? 'json'; + const body = await this.auditService.export(query, format); + const contentType = format === 'csv' ? 'text/csv' : 'application/json'; + res.setHeader('Content-Type', contentType); + res.setHeader( + 'Content-Disposition', + `attachment; filename="audit-export.${format}"`, + ); + return body; + } +} diff --git a/backend/src/audit/audit.decorator.ts b/backend/src/audit/audit.decorator.ts new file mode 100644 index 00000000..d8811504 --- /dev/null +++ b/backend/src/audit/audit.decorator.ts @@ -0,0 +1,30 @@ +import { SetMetadata } from '@nestjs/common'; +import { AuditOperation } from './audit-log.entity'; + +export const AUDIT_METADATA_KEY = 'audit:operation'; + +export interface AuditMetadata { + operationType: AuditOperation; + /** + * Name of the route parameter holding the target credential id + * (defaults to `id`). Set to `null` to omit target extraction. + */ + credentialIdParam?: string | null; +} + +/** + * Marks a controller handler as an audited credential operation. Combined with + * {@link AuditInterceptor}, every invocation automatically emits an audit + * record — success or failure — with no per-handler boilerplate. + */ +export const Audit = ( + operationType: AuditOperation, + options: Omit = {}, +) => + SetMetadata(AUDIT_METADATA_KEY, { + operationType, + credentialIdParam: + options.credentialIdParam === undefined + ? 'id' + : options.credentialIdParam, + }); diff --git a/backend/src/audit/audit.interceptor.ts b/backend/src/audit/audit.interceptor.ts new file mode 100644 index 00000000..b7bd786a --- /dev/null +++ b/backend/src/audit/audit.interceptor.ts @@ -0,0 +1,104 @@ +import { + CallHandler, + ExecutionContext, + Injectable, + Logger, + NestInterceptor, +} from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; +import { Observable } from 'rxjs'; +import { catchError, tap } from 'rxjs/operators'; +import { throwError } from 'rxjs'; +import { AuditService } from './audit.service'; +import { + AUDIT_METADATA_KEY, + AuditMetadata, +} from './audit.decorator'; +import { AuditStatus } from './audit-log.entity'; + +/** + * Automatically records an audit entry for any handler annotated with + * {@link Audit}. The record captures the acting wallet, client IP, target + * credential id and operation outcome, then forwards the original result or + * error untouched. + */ +@Injectable() +export class AuditInterceptor implements NestInterceptor { + private readonly logger = new Logger(AuditInterceptor.name); + + constructor( + private readonly reflector: Reflector, + private readonly auditService: AuditService, + ) {} + + intercept( + context: ExecutionContext, + next: CallHandler, + ): Observable { + const meta = this.reflector.getAllAndOverride( + AUDIT_METADATA_KEY, + [context.getHandler(), context.getClass()], + ); + + if (!meta) { + return next.handle(); + } + + const request = context.switchToHttp().getRequest(); + const actorId = request.user?.walletAddress ?? 'anonymous'; + const clientIp = request.ip ?? request.socket?.remoteAddress ?? null; + const paramName = meta.credentialIdParam; + const targetFromParam = + paramName && request.params ? request.params[paramName] : null; + + return next.handle().pipe( + tap((response) => { + void this.emit({ + actorId, + clientIp, + operationType: meta.operationType, + status: AuditStatus.SUCCESS, + targetCredentialId: + targetFromParam ?? this.extractId(response) ?? null, + metadata: { method: request.method, path: request.url }, + }); + }), + catchError((error) => { + void this.emit({ + actorId, + clientIp, + operationType: meta.operationType, + status: AuditStatus.FAILURE, + targetCredentialId: targetFromParam ?? null, + metadata: { + method: request.method, + path: request.url, + error: error?.message ?? 'unknown error', + }, + }); + return throwError(() => error); + }), + ); + } + + private extractId(response: unknown): string | null { + if (response && typeof response === 'object' && 'id' in response) { + const id = (response as { id: unknown }).id; + return typeof id === 'string' ? id : null; + } + return null; + } + + private async emit(input: Parameters[0]) { + try { + await this.auditService.record(input); + } catch (error) { + // Auditing must never break the underlying operation; log and move on. + this.logger.error( + `Failed to record audit entry: ${ + error instanceof Error ? error.message : String(error) + }`, + ); + } + } +} diff --git a/backend/src/audit/audit.module.ts b/backend/src/audit/audit.module.ts new file mode 100644 index 00000000..a2f93f04 --- /dev/null +++ b/backend/src/audit/audit.module.ts @@ -0,0 +1,29 @@ +import { Global, Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { AuditLog } from './audit-log.entity'; +import { AuditService } from './audit.service'; +import { AuditController } from './audit.controller'; +import { AuditInterceptor } from './audit.interceptor'; +import { AuditRetentionService } from './audit-retention.service'; +import { AdminGuard } from './guards/admin.guard'; + +/** + * Credential audit logging. + * + * Declared `@Global` so the {@link AuditInterceptor} and {@link AuditService} + * can be attached to credential operations in any feature module without + * re-importing this module everywhere. + */ +@Global() +@Module({ + imports: [TypeOrmModule.forFeature([AuditLog])], + controllers: [AuditController], + providers: [ + AuditService, + AuditInterceptor, + AuditRetentionService, + AdminGuard, + ], + exports: [AuditService, AuditInterceptor], +}) +export class AuditModule {} diff --git a/backend/src/audit/audit.service.spec.ts b/backend/src/audit/audit.service.spec.ts new file mode 100644 index 00000000..c99e622c --- /dev/null +++ b/backend/src/audit/audit.service.spec.ts @@ -0,0 +1,184 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { DataSource } from 'typeorm'; +import { ConfigService } from '@nestjs/config'; +import { AuditService } from './audit.service'; +import { AuditLog, AuditOperation, AuditStatus } from './audit-log.entity'; + +/** + * In-memory repository backed by a shared array so that `record()` (which + * writes through the transaction manager) and the read/verify paths (which use + * the injected repository) operate on the same chain. + */ +function createMockRepo(store: AuditLog[]) { + return { + create: jest.fn((partial: Partial) => ({ ...partial }) as AuditLog), + save: jest.fn(async (entry: AuditLog) => { + entry.sequence = String(store.length + 1); + entry.id = `id-${entry.sequence}`; + entry.createdAt = new Date(); + store.push(entry); + return entry; + }), + findOne: jest.fn(async ({ order }: any) => { + if (store.length === 0) return null; + const sorted = [...store].sort((a, b) => + order?.sequence === 'DESC' + ? Number(b.sequence) - Number(a.sequence) + : Number(a.sequence) - Number(b.sequence), + ); + return sorted[0]; + }), + find: jest.fn(async ({ order }: any = {}) => { + const sorted = [...store].sort((a, b) => + order?.sequence === 'DESC' + ? Number(b.sequence) - Number(a.sequence) + : Number(a.sequence) - Number(b.sequence), + ); + return sorted; + }), + findAndCount: jest.fn(async ({ where, skip = 0, take = 20 }: any) => { + let rows = [...store]; + if (where?.actorId) rows = rows.filter((r) => r.actorId === where.actorId); + if (where?.operationType) + rows = rows.filter((r) => r.operationType === where.operationType); + if (where?.status) rows = rows.filter((r) => r.status === where.status); + const total = rows.length; + return [rows.slice(skip, skip + take), total]; + }), + delete: jest.fn(async () => ({ affected: 0 })), + }; +} + +describe('AuditService', () => { + let service: AuditService; + let store: AuditLog[]; + let mockRepo: ReturnType; + + const mockDataSource = { + transaction: jest.fn(async (cb: any) => + cb({ + query: jest.fn().mockResolvedValue(undefined), + getRepository: () => mockRepo, + }), + ), + }; + + const mockConfig = { + get: jest.fn((key: string) => { + if (key === 'AUDIT_HMAC_SECRET') return 'test-secret'; + return undefined; + }), + }; + + beforeEach(async () => { + store = []; + mockRepo = createMockRepo(store); + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + AuditService, + { provide: getRepositoryToken(AuditLog), useValue: mockRepo }, + { provide: DataSource, useValue: mockDataSource }, + { provide: ConfigService, useValue: mockConfig }, + ], + }).compile(); + + service = module.get(AuditService); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); + + describe('record', () => { + it('anchors the genesis entry with a null previousHash', async () => { + const entry = await service.record({ + actorId: 'GACTOR1', + operationType: AuditOperation.ISSUED, + }); + expect(entry.previousHash).toBeNull(); + expect(entry.hash).toHaveLength(64); + expect(entry.status).toBe(AuditStatus.SUCCESS); + }); + + it('chains each entry to the previous hash', async () => { + const first = await service.record({ + actorId: 'GACTOR1', + operationType: AuditOperation.ISSUED, + }); + const second = await service.record({ + actorId: 'GACTOR2', + operationType: AuditOperation.REVOKED, + }); + expect(second.previousHash).toBe(first.hash); + expect(second.hash).not.toBe(first.hash); + }); + }); + + describe('verifyIntegrity', () => { + it('reports a valid, intact chain', async () => { + await service.record({ actorId: 'A', operationType: AuditOperation.ISSUED }); + await service.record({ actorId: 'B', operationType: AuditOperation.VERIFIED }); + await service.record({ actorId: 'C', operationType: AuditOperation.REVOKED }); + + const report = await service.verifyIntegrity(); + expect(report.valid).toBe(true); + expect(report.checked).toBe(3); + }); + + it('detects a tampered entry', async () => { + await service.record({ actorId: 'A', operationType: AuditOperation.ISSUED }); + await service.record({ actorId: 'B', operationType: AuditOperation.VERIFIED }); + + // Simulate post-hoc modification of a historical record. + store[0].actorId = 'ATTACKER'; + + const report = await service.verifyIntegrity(); + expect(report.valid).toBe(false); + expect(report.brokenAtSequence).toBe('1'); + }); + }); + + describe('query', () => { + it('filters by operation type and paginates', async () => { + await service.record({ actorId: 'A', operationType: AuditOperation.ISSUED }); + await service.record({ actorId: 'B', operationType: AuditOperation.REVOKED }); + await service.record({ actorId: 'C', operationType: AuditOperation.ISSUED }); + + const result = await service.query({ + operationType: AuditOperation.ISSUED, + page: 1, + limit: 20, + }); + expect(result.total).toBe(2); + expect(result.data.every((r) => r.operationType === AuditOperation.ISSUED)).toBe(true); + }); + }); + + describe('enforceRetention', () => { + it('purges entries older than the retention window', async () => { + mockRepo.delete.mockResolvedValueOnce({ affected: 5 } as any); + const purged = await service.enforceRetention(90); + expect(purged).toBe(5); + expect(mockRepo.delete).toHaveBeenCalled(); + }); + }); + + describe('export', () => { + it('exports JSON', async () => { + await service.record({ actorId: 'A', operationType: AuditOperation.ISSUED }); + const json = await service.export({}, 'json'); + expect(JSON.parse(json)).toHaveLength(1); + }); + + it('exports CSV with a header row', async () => { + await service.record({ actorId: 'A', operationType: AuditOperation.ISSUED }); + const csv = await service.export({}, 'csv'); + const lines = csv.split('\n'); + expect(lines[0]).toContain('sequence'); + expect(lines[0]).toContain('operationType'); + expect(lines).toHaveLength(2); + }); + }); +}); diff --git a/backend/src/audit/audit.service.ts b/backend/src/audit/audit.service.ts new file mode 100644 index 00000000..3aefab58 --- /dev/null +++ b/backend/src/audit/audit.service.ts @@ -0,0 +1,308 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { ConfigService } from '@nestjs/config'; +import { + Between, + DataSource, + LessThan, + MoreThanOrEqual, + LessThanOrEqual, + Repository, +} from 'typeorm'; +import { createHmac } from 'crypto'; +import { + AuditLog, + AuditOperation, + AuditStatus, +} from './audit-log.entity'; +import { QueryAuditLogDto } from './dto/query-audit-log.dto'; + +/** Payload accepted when appending a new audit entry. */ +export interface RecordAuditInput { + actorId: string; + operationType: AuditOperation; + targetCredentialId?: string | null; + clientIp?: string | null; + status?: AuditStatus; + metadata?: Record | null; +} + +export interface PaginatedAuditResult { + data: AuditLog[]; + total: number; + page: number; + lastPage: number; +} + +export interface IntegrityReport { + valid: boolean; + checked: number; + /** Sequence number of the first tampered entry, when `valid` is false. */ + brokenAtSequence?: string; + reason?: string; +} + +/** + * Advisory-lock key used to serialize concurrent chain appends so that the + * `previousHash` linkage is never computed against a stale tail. + */ +const AUDIT_CHAIN_LOCK_KEY = 838150; + +@Injectable() +export class AuditService { + private readonly logger = new Logger(AuditService.name); + private readonly hmacSecret: string; + + constructor( + @InjectRepository(AuditLog) + private readonly auditRepository: Repository, + private readonly dataSource: DataSource, + private readonly configService: ConfigService, + ) { + this.hmacSecret = + this.configService.get('AUDIT_HMAC_SECRET') ?? + this.configService.get('JWT_SECRET') ?? + 'insecure-development-audit-secret'; + } + + /** + * Append a new entry to the audit chain. Serialized via a transaction-scoped + * advisory lock so concurrent writers cannot fork the hash chain. + */ + async record(input: RecordAuditInput): Promise { + return this.dataSource.transaction(async (manager) => { + await manager.query('SELECT pg_advisory_xact_lock($1)', [ + AUDIT_CHAIN_LOCK_KEY, + ]); + + const repo = manager.getRepository(AuditLog); + const last = await repo.findOne({ + where: {}, + order: { sequence: 'DESC' }, + }); + const previousHash = last ? last.hash : null; + + const entry = repo.create({ + actorId: input.actorId, + operationType: input.operationType, + targetCredentialId: input.targetCredentialId ?? null, + clientIp: input.clientIp ?? null, + status: input.status ?? AuditStatus.SUCCESS, + metadata: input.metadata ?? null, + previousHash, + }); + entry.hash = this.computeHash(entry, previousHash); + + return repo.save(entry); + }); + } + + /** + * Paginated, filtered query over the audit trail. Intended for admin use. + */ + async query(dto: QueryAuditLogDto): Promise { + const page = dto.page ?? 1; + const limit = dto.limit ?? 20; + const skip = (page - 1) * limit; + + const where: Record = {}; + if (dto.actorId) where.actorId = dto.actorId; + if (dto.operationType) where.operationType = dto.operationType; + if (dto.status) where.status = dto.status; + if (dto.targetCredentialId) + where.targetCredentialId = dto.targetCredentialId; + + if (dto.from && dto.to) { + where.createdAt = Between(new Date(dto.from), new Date(dto.to)); + } else if (dto.from) { + where.createdAt = MoreThanOrEqual(new Date(dto.from)); + } else if (dto.to) { + where.createdAt = LessThanOrEqual(new Date(dto.to)); + } + + const [data, total] = await this.auditRepository.findAndCount({ + where, + order: { sequence: 'DESC' }, + skip, + take: limit, + }); + + return { data, total, page, lastPage: Math.ceil(total / limit) }; + } + + /** + * Recompute every entry's HMAC and validate the chain linkage to detect any + * post-hoc modification. Verification starts from the earliest surviving + * entry, so a retention purge of a contiguous prefix does not raise a false + * positive. + */ + async verifyIntegrity(): Promise { + const entries = await this.auditRepository.find({ + order: { sequence: 'ASC' }, + }); + + let previous: AuditLog | null = null; + for (const entry of entries) { + const expected = this.computeHash(entry, entry.previousHash); + if (expected !== entry.hash) { + return { + valid: false, + checked: entries.length, + brokenAtSequence: entry.sequence, + reason: 'hash mismatch: entry payload was modified', + }; + } + + if (previous && entry.previousHash !== previous.hash) { + return { + valid: false, + checked: entries.length, + brokenAtSequence: entry.sequence, + reason: 'chain linkage broken: an entry was inserted or removed', + }; + } + previous = entry; + } + + return { valid: true, checked: entries.length }; + } + + /** + * Enforce the retention policy by purging entries older than the configured + * threshold. Only a contiguous prefix (the oldest entries) is removed, which + * preserves the integrity of the remaining chain. + * + * @returns the number of entries purged. + */ + async enforceRetention(retentionDays?: number): Promise { + const days = + retentionDays ?? + this.configService.get('AUDIT_RETENTION_DAYS') ?? + 365; + + const cutoff = new Date(Date.now() - days * 24 * 60 * 60 * 1000); + const { affected } = await this.auditRepository.delete({ + createdAt: LessThan(cutoff), + }); + const purged = affected ?? 0; + if (purged > 0) { + this.logger.log( + `Retention purge removed ${purged} audit entries older than ${days} days`, + ); + } + return purged; + } + + /** + * Export matching audit records for compliance review. + */ + async export( + dto: QueryAuditLogDto, + format: 'json' | 'csv', + ): Promise { + const where: Record = {}; + if (dto.actorId) where.actorId = dto.actorId; + if (dto.operationType) where.operationType = dto.operationType; + if (dto.status) where.status = dto.status; + if (dto.targetCredentialId) + where.targetCredentialId = dto.targetCredentialId; + if (dto.from && dto.to) { + where.createdAt = Between(new Date(dto.from), new Date(dto.to)); + } else if (dto.from) { + where.createdAt = MoreThanOrEqual(new Date(dto.from)); + } else if (dto.to) { + where.createdAt = LessThanOrEqual(new Date(dto.to)); + } + + const records = await this.auditRepository.find({ + where, + order: { sequence: 'ASC' }, + }); + + if (format === 'json') { + return JSON.stringify(records, null, 2); + } + return this.toCsv(records); + } + + /** + * Compute the HMAC-SHA256 of an entry's canonical, app-controlled payload + * chained to the previous entry's hash. DB-generated columns (`sequence`, + * `createdAt`) are intentionally excluded so the hash is deterministic at + * insert time and reproducible at verification time. + */ + private computeHash( + entry: Pick< + AuditLog, + | 'actorId' + | 'operationType' + | 'targetCredentialId' + | 'clientIp' + | 'status' + | 'metadata' + >, + previousHash: string | null, + ): string { + const payload = stableStringify({ + actorId: entry.actorId, + operationType: entry.operationType, + targetCredentialId: entry.targetCredentialId ?? null, + clientIp: entry.clientIp ?? null, + status: entry.status, + metadata: entry.metadata ?? null, + previousHash: previousHash ?? null, + }); + return createHmac('sha256', this.hmacSecret).update(payload).digest('hex'); + } + + private toCsv(records: AuditLog[]): string { + const columns: (keyof AuditLog)[] = [ + 'sequence', + 'createdAt', + 'actorId', + 'operationType', + 'targetCredentialId', + 'clientIp', + 'status', + 'metadata', + 'previousHash', + 'hash', + ]; + const header = columns.join(','); + const rows = records.map((record) => + columns + .map((column) => csvCell(record[column])) + .join(','), + ); + return [header, ...rows].join('\n'); + } +} + +/** Deterministic JSON serialization with recursively sorted object keys. */ +function stableStringify(value: unknown): string { + if (value === null || typeof value !== 'object') { + return JSON.stringify(value); + } + if (Array.isArray(value)) { + return `[${value.map(stableStringify).join(',')}]`; + } + const keys = Object.keys(value as Record).sort(); + const entries = keys.map( + (key) => + `${JSON.stringify(key)}:${stableStringify( + (value as Record)[key], + )}`, + ); + return `{${entries.join(',')}}`; +} + +/** Render a value as a CSV cell, quoting and escaping when required. */ +function csvCell(value: unknown): string { + if (value === null || value === undefined) return ''; + const text = + typeof value === 'object' ? JSON.stringify(value) : String(value); + if (/[",\n]/.test(text)) { + return `"${text.replace(/"/g, '""')}"`; + } + return text; +} diff --git a/backend/src/audit/dto/export-audit-log.dto.ts b/backend/src/audit/dto/export-audit-log.dto.ts new file mode 100644 index 00000000..b554c1c0 --- /dev/null +++ b/backend/src/audit/dto/export-audit-log.dto.ts @@ -0,0 +1,11 @@ +import { IsOptional, IsIn } from 'class-validator'; +import { QueryAuditLogDto } from './query-audit-log.dto'; + +/** + * Query filter plus output format for audit trail exports. + */ +export class ExportAuditLogDto extends QueryAuditLogDto { + @IsOptional() + @IsIn(['json', 'csv']) + format?: 'json' | 'csv' = 'json'; +} diff --git a/backend/src/audit/dto/query-audit-log.dto.ts b/backend/src/audit/dto/query-audit-log.dto.ts new file mode 100644 index 00000000..04ea4ceb --- /dev/null +++ b/backend/src/audit/dto/query-audit-log.dto.ts @@ -0,0 +1,55 @@ +import { + IsOptional, + IsInt, + Min, + Max, + IsEnum, + IsString, + IsISO8601, +} from 'class-validator'; +import { Type } from 'class-transformer'; +import { AuditOperation, AuditStatus } from '../audit-log.entity'; + +/** + * Filter and pagination parameters for the audit query interface. + */ +export class QueryAuditLogDto { + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + page?: number = 1; + + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + @Max(100) + limit?: number = 20; + + @IsOptional() + @IsString() + actorId?: string; + + @IsOptional() + @IsEnum(AuditOperation) + operationType?: AuditOperation; + + @IsOptional() + @IsEnum(AuditStatus) + status?: AuditStatus; + + @IsOptional() + @IsString() + targetCredentialId?: string; + + /** Inclusive lower bound on `createdAt` (ISO-8601). */ + @IsOptional() + @IsISO8601() + from?: string; + + /** Inclusive upper bound on `createdAt` (ISO-8601). */ + @IsOptional() + @IsISO8601() + to?: string; +} diff --git a/backend/src/audit/guards/admin.guard.ts b/backend/src/audit/guards/admin.guard.ts new file mode 100644 index 00000000..404e2221 --- /dev/null +++ b/backend/src/audit/guards/admin.guard.ts @@ -0,0 +1,38 @@ +import { + CanActivate, + ExecutionContext, + ForbiddenException, + Injectable, +} from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; + +/** + * Restricts a route to administrator wallets. The allowlist is supplied via the + * `ADMIN_WALLETS` environment variable (comma-separated wallet addresses). + * + * Must be applied after {@link JwtAuthGuard}, which populates `request.user`. + */ +@Injectable() +export class AdminGuard implements CanActivate { + private readonly admins: Set; + + constructor(private readonly configService: ConfigService) { + const raw = this.configService.get('ADMIN_WALLETS') ?? ''; + this.admins = new Set( + raw + .split(',') + .map((address) => address.trim()) + .filter((address) => address.length > 0), + ); + } + + canActivate(context: ExecutionContext): boolean { + const request = context.switchToHttp().getRequest(); + const walletAddress: string | undefined = request.user?.walletAddress; + + if (!walletAddress || !this.admins.has(walletAddress)) { + throw new ForbiddenException('Administrator access required'); + } + return true; + } +} diff --git a/backend/src/identity/identity.controller.ts b/backend/src/identity/identity.controller.ts index 18142b41..3db5e382 100644 --- a/backend/src/identity/identity.controller.ts +++ b/backend/src/identity/identity.controller.ts @@ -1,16 +1,21 @@ -import { Controller, Get, Post, Body, Patch, Param, Delete, UseGuards, Request, Query } from '@nestjs/common'; +import { Controller, Get, Post, Body, Patch, Param, Delete, UseGuards, UseInterceptors, Request, Query } from '@nestjs/common'; import { IdentityService } from './identity.service'; import { CreateIdentityDto } from './dto/create-identity.dto'; import { UpdateIdentityDto } from './dto/update-identity.dto'; import { PaginateIdentityDto } from './dto/paginate-identity.dto'; import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; +import { AuditInterceptor } from '../audit/audit.interceptor'; +import { Audit } from '../audit/audit.decorator'; +import { AuditOperation } from '../audit/audit-log.entity'; @Controller('identities') @UseGuards(JwtAuthGuard) +@UseInterceptors(AuditInterceptor) export class IdentityController { constructor(private readonly identityService: IdentityService) {} @Post() + @Audit(AuditOperation.ISSUED, { credentialIdParam: null }) create(@Body() createIdentityDto: CreateIdentityDto, @Request() req) { return this.identityService.create({ ...createIdentityDto, @@ -29,16 +34,19 @@ export class IdentityController { } @Patch(':id') + @Audit(AuditOperation.UPDATED) update(@Param('id') id: string, @Body() updateIdentityDto: UpdateIdentityDto) { return this.identityService.update(id, updateIdentityDto); } @Patch(':id/revoke') + @Audit(AuditOperation.REVOKED) revoke(@Param('id') id: string) { return this.identityService.revoke(id); } @Delete(':id') + @Audit(AuditOperation.DELETED) remove(@Param('id') id: string) { return this.identityService.remove(id); } diff --git a/backend/src/verification/verification.controller.ts b/backend/src/verification/verification.controller.ts index db5da0da..30b76df5 100644 --- a/backend/src/verification/verification.controller.ts +++ b/backend/src/verification/verification.controller.ts @@ -1,11 +1,15 @@ -import { Controller, Get, Post, Body, Patch, Param, UseGuards, Request } from '@nestjs/common'; +import { Controller, Get, Post, Body, Patch, Param, UseGuards, UseInterceptors, Request } from '@nestjs/common'; import { VerificationService } from './verification.service'; import { CreateVerificationDto } from './dto/create-verification.dto'; import { UpdateVerificationDto } from './dto/update-verification.dto'; import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; +import { AuditInterceptor } from '../audit/audit.interceptor'; +import { Audit } from '../audit/audit.decorator'; +import { AuditOperation } from '../audit/audit-log.entity'; @Controller('verifications') @UseGuards(JwtAuthGuard) +@UseInterceptors(AuditInterceptor) export class VerificationController { constructor(private readonly verificationService: VerificationService) {} @@ -30,11 +34,13 @@ export class VerificationController { } @Patch(':id/approve') + @Audit(AuditOperation.VERIFIED) approve(@Param('id') id: string) { return this.verificationService.approve(id); } @Patch(':id/reject') + @Audit(AuditOperation.VERIFIED) reject(@Param('id') id: string, @Body('reason') reason: string) { return this.verificationService.reject(id, reason); }