Skip to main content

@nest-toolbox/typeorm-audit-log

npm version

Automatic audit logging for TypeORM entities with user attribution, diff tracking, and queryable audit trails.

Installation

npm install @nest-toolbox/typeorm-audit-log

Peer dependencies: typeorm, @nestjs/typeorm, @nestjs/common, reflect-metadata

Quick Start

// app.module.ts
import { Module, NestModule, MiddlewareConsumer } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { AuditLogModule, AuditContextMiddleware } from '@nest-toolbox/typeorm-audit-log';

@Module({
imports: [
TypeOrmModule.forRoot({ /* your database config */ }),
AuditLogModule.forRoot({
retentionDays: 90,
excludeFields: ['password', 'refreshToken'],
}),
],
})
export class AppModule implements NestModule {
configure(consumer: MiddlewareConsumer) {
consumer.apply(AuditContextMiddleware).forRoutes('*');
}
}
// user.entity.ts
import { Entity, Column, PrimaryGeneratedColumn } from 'typeorm';
import { Auditable } from '@nest-toolbox/typeorm-audit-log';

@Entity()
@Auditable()
export class User {
@PrimaryGeneratedColumn()
id: number;

@Column()
name: string;

@Column()
email: string;

@Column()
role: string;
}

That's it — all INSERT, UPDATE, and DELETE operations on User are automatically logged with full user attribution and diffs.

Features

  • 🔄 Automatic tracking — Entity changes logged via TypeORM subscribers
  • 👤 User attribution — Captures who made each change via AsyncLocalStorage
  • 📊 Diff calculation — Shows exactly what changed between old and new values
  • 🔍 Queryable history — Find audit logs by entity, user, or time range
  • 🛡️ Field exclusions — Skip sensitive fields like passwords with @AuditIgnore()
  • 🎭 Field masking — Partially mask sensitive values with @AuditMask()
  • Async mode — Non-blocking audit writes for performance
  • 🎯 Selective auditing — Only track entities decorated with @Auditable()

API Reference

AuditLogModule

AuditLogModule.forRoot(options?)

Register the module globally with static configuration.

AuditLogModule.forRoot({
retentionDays: 90,
excludeFields: ['password', 'token', 'secret'],
excludeEntities: ['Session', 'Cache'],
async: false,
});
OptionTypeDefaultDescription
storage'database' | 'file' | 'webhook''database'Where to store audit logs
tableNamestring'audit_logs'Custom table name for the audit log table
retentionDaysnumber0Days to keep logs (0 = forever)
excludeFieldsstring[][]Fields to globally exclude from all audit logs
excludeEntitiesstring[][]Entity names to skip entirely
asyncbooleanfalseFire-and-forget writes (non-blocking)
batchSizenumber1Batch size for bulk writes
webhookUrlstringWebhook URL (when storage: 'webhook')
filePathstringFile path (when storage: 'file')

AuditLogModule.forRootAsync(options)

Register with async factory injection.

AuditLogModule.forRootAsync({
imports: [ConfigModule],
useFactory: (config: ConfigService) => ({
retentionDays: config.get('AUDIT_RETENTION_DAYS'),
async: config.get('AUDIT_ASYNC') === 'true',
excludeFields: config.get('AUDIT_EXCLUDE_FIELDS')?.split(',') || [],
}),
inject: [ConfigService],
});

Decorators

@Auditable(options?)

Mark an entity class for automatic audit logging.

@Entity()
@Auditable({
entityName: 'UserAccount', // Custom name in audit logs (default: class name)
excludeFields: ['lastLoginAt'], // Fields to exclude for this entity
})
export class User { /* ... */ }
OptionTypeDefaultDescription
entityNamestringClass nameCustom entity name in audit logs
excludeFieldsstring[][]Fields to exclude for this entity only

@AuditIgnore()

Exclude a property from audit logs entirely. Use for sensitive data that should never appear in audit trails.

@Entity()
@Auditable()
export class User {
@Column()
@AuditIgnore()
password: string;

@Column()
@AuditIgnore()
refreshToken: string;
}

@AuditMask(options?)

Partially mask a property value in audit logs (e.g., "john@email.com""jo***l.com").

@Entity()
@Auditable()
export class User {
@Column()
@AuditMask()
email: string;

@Column()
@AuditMask({ maskFn: (v) => v ? '****' : null })
ssn: string;
}
OptionTypeDefaultDescription
maskFn(value: any) => stringBuilt-in partial maskerCustom mask function

The default mask function hides middle characters — e.g., "john@email.com" becomes "jo***l.com".

AuditLogService

Inject AuditLogService to query the audit trail or log manual entries.

log(params: LogParams)

Manually create an audit log entry.

await auditLogService.log({
action: AuditAction.UPDATE,
entity: updatedUser,
entityName: 'User',
entityId: '123',
oldValues: previousUser,
});

LogParams:

FieldTypeDescription
actionAuditActionCREATE, UPDATE, or DELETE
entityanyThe current entity (new values)
entityNamestringName of the entity
entityIdstringPrimary key of the entity
oldValuesanyPrevious entity values (for UPDATE/DELETE)

findByEntity(entityName, entityId, options?)

Get audit history for a specific entity.

const history = await auditLogService.findByEntity('User', '123', {
since: new Date('2024-01-01'),
action: AuditAction.UPDATE,
limit: 20,
});

FindByEntityOptions:

OptionTypeDescription
sinceDateOnly entries after this date
untilDateOnly entries before this date
actionAuditActionFilter by action type
limitnumberMax number of results

findByUser(userId, options?)

Get all changes made by a specific user.

const activity = await auditLogService.findByUser('user-456', {
since: new Date(Date.now() - 24 * 60 * 60 * 1000),
entityName: 'Order',
});

FindByUserOptions:

OptionTypeDescription
sinceDateOnly entries after this date
untilDateOnly entries before this date
entityNamestringFilter by entity type
actionAuditActionFilter by action type
limitnumberMax number of results

findAll(options?)

Paginated search across all audit logs.

const result = await auditLogService.findAll({
entityName: 'User',
action: AuditAction.UPDATE,
since: new Date('2024-01-01'),
page: 1,
limit: 50,
});

console.log(result.items); // AuditLog[]
console.log(result.total); // Total matching entries
console.log(result.totalPages); // Total pages

FindAllOptions:

OptionTypeDefaultDescription
sinceDateOnly entries after this date
untilDateOnly entries before this date
entityNamestringFilter by entity type
entityIdstringFilter by entity ID
userIdstringFilter by user
actionAuditActionFilter by action type
pagenumber1Page number
limitnumber50Items per page

Returns PaginatedResult<AuditLog>:

FieldTypeDescription
itemsAuditLog[]Audit log entries for the current page
totalnumberTotal matching entries
pagenumberCurrent page
limitnumberItems per page
totalPagesnumberTotal number of pages

AuditContextMiddleware

NestJS middleware that captures user context from HTTP requests via AsyncLocalStorage.

// Default: extracts user.id, user.name/email, req.ip, user-agent
consumer.apply(AuditContextMiddleware).forRoutes('*');

createAuditContextMiddleware(extractor)

Factory function to create a middleware with custom context extraction.

import { createAuditContextMiddleware } from '@nest-toolbox/typeorm-audit-log';

const customMiddleware = createAuditContextMiddleware((req) => ({
userId: req.user?.sub,
userName: req.user?.preferred_username,
ip: req.headers['x-forwarded-for'] as string || req.ip,
userAgent: req.headers['user-agent'],
metadata: { tenantId: req.headers['x-tenant-id'] },
}));

consumer.apply(customMiddleware.use.bind(customMiddleware)).forRoutes('*');

AuditContext

Static class for manual context management — use in non-HTTP contexts like queues, cron jobs, or CLI scripts.

AuditContext.run(data, fn)

Run a function with the given audit context.

await AuditContext.run(
{ userId: 'system', userName: 'Order Processor', metadata: { jobId: job.id } },
async () => {
await orderService.updateStatus(orderId, 'processed');
},
);

AuditContext.get()

Get the current audit context (returns undefined outside a run() scope).

AuditContext.set(data)

Merge additional data into the current audit context.

AuditContextData:

FieldTypeDescription
userIdstringUser ID for attribution
userNamestringUser name/email
ipstringIP address
userAgentstringBrowser/client user agent
metadataRecord<string, any>Custom metadata (tenant ID, etc.)

Audit Log Entry Structure

Each AuditLog entity stored in the database:

FieldTypeDescription
idUUIDUnique identifier
entityNamestringName of the audited entity
entityIdstringPrimary key of the entity
actionAuditActionCREATE, UPDATE, or DELETE
userIdstring | nullID of the user who made the change
userNamestring | nullName/email of the user
oldValuesJSON | nullPrevious values (UPDATE/DELETE)
newValuesJSON | nullNew values (CREATE/UPDATE)
diffAuditDiff[] | nullArray of changed fields with old/new values
metadataJSON | nullCustom metadata from context
ipstring | nullIP address of the request
userAgentstring | nullUser agent string
timestampDateWhen the change occurred

Types

enum AuditAction {
CREATE = 'CREATE',
UPDATE = 'UPDATE',
DELETE = 'DELETE',
}

interface AuditDiff {
field: string;
oldValue: any;
newValue: any;
}

Examples

Full entity with field-level control

@Entity()
@Auditable({ entityName: 'UserAccount' })
export class User {
@PrimaryGeneratedColumn()
id: number;

@Column()
name: string;

@Column()
@AuditMask() // Logged as "jo***l.com"
email: string;

@Column()
@AuditIgnore() // Never logged
password: string;

@Column()
role: string;
}

Non-HTTP context (queue worker)

@Injectable()
export class OrderProcessor {
@Process('process-order')
async processOrder(job: Job) {
await AuditContext.run(
{ userId: 'system', userName: 'Order Processor', metadata: { jobId: job.id } },
async () => {
await this.orderService.updateStatus(job.data.orderId, 'processed');
},
);
}
}

Audit log REST endpoint

@Controller('audit')
export class AuditController {
constructor(private auditLogService: AuditLogService) {}

@Get(':entityName/:entityId')
async getEntityHistory(
@Param('entityName') entityName: string,
@Param('entityId') entityId: string,
) {
return this.auditLogService.findByEntity(entityName, entityId);
}

@Get('user/:userId')
async getUserActivity(@Param('userId') userId: string) {
return this.auditLogService.findByUser(userId, {
since: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000),
});
}
}

Database migration (manual)

If you need to create the table manually instead of relying on TypeORM synchronize:

CREATE TABLE audit_logs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
entity_name VARCHAR(255) NOT NULL,
entity_id VARCHAR(255) NOT NULL,
action VARCHAR(10) NOT NULL,
user_id VARCHAR(255),
user_name VARCHAR(255),
old_values JSONB,
new_values JSONB,
diff JSONB,
metadata JSONB,
ip VARCHAR(45),
user_agent VARCHAR(500),
timestamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

CREATE INDEX idx_audit_entity ON audit_logs(entity_name, entity_id);
CREATE INDEX idx_audit_user ON audit_logs(user_id);
CREATE INDEX idx_audit_timestamp ON audit_logs(timestamp);
CREATE INDEX idx_audit_entity_timestamp ON audit_logs(entity_name, timestamp);