Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions backend/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
99 changes: 99 additions & 0 deletions backend/docs/AUDIT_LOGGING.md
Original file line number Diff line number Diff line change
@@ -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 |
2 changes: 2 additions & 0 deletions backend/src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: [
Expand Down Expand Up @@ -68,6 +69,7 @@ import { HealthModule } from './health/health.module';
SorobanModule,
IndexerModule,
HealthModule,
AuditModule,
],
providers: [
{
Expand Down
90 changes: 90 additions & 0 deletions backend/src/audit/audit-log.entity.ts
Original file line number Diff line number Diff line change
@@ -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<string, any> | 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;
}
71 changes: 71 additions & 0 deletions backend/src/audit/audit-retention.service.ts
Original file line number Diff line number Diff line change
@@ -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<string>('AUDIT_RETENTION_ENABLED') !== 'false';
if (!enabled) {
this.logger.log('Audit retention worker disabled by configuration');
return;
}

const sweepHours =
this.configService.get<number>('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<number> {
try {
return await this.auditService.enforceRetention();
} catch (error) {
this.logger.error(
`Audit retention sweep failed: ${
error instanceof Error ? error.message : String(error)
}`,
);
return 0;
}
}
}
57 changes: 57 additions & 0 deletions backend/src/audit/audit.controller.ts
Original file line number Diff line number Diff line change
@@ -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<string> {
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;
}
}
30 changes: 30 additions & 0 deletions backend/src/audit/audit.decorator.ts
Original file line number Diff line number Diff line change
@@ -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<AuditMetadata, 'operationType'> = {},
) =>
SetMetadata<string, AuditMetadata>(AUDIT_METADATA_KEY, {
operationType,
credentialIdParam:
options.credentialIdParam === undefined
? 'id'
: options.credentialIdParam,
});
Loading
Loading