When an admin account is compromised or an employee does something wrong — without an audit log you will only find out from a client who noticed the change. The admin role bypasses the usual access restrictions: it is needed for support, ops and compliance scenarios, but that is exactly why every admin action must be recorded.
An audit log is a table where each row answers the questions: who, when, what they did and to what. Without it, the compromise of an admin account turns into invisible damage.
Table structure
You set up one table per service or a separate table per aggregate (for example, order_audit_log). The minimal schema:
CREATE TABLE admin_audit_log (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
actor_id text NOT NULL,
action text NOT NULL,
resource_type text NOT NULL,
resource_id text NOT NULL,
occurred_at timestamptz NOT NULL DEFAULT now(),
metadata jsonb NOT NULL DEFAULT '{}',
request_id text,
trace_id text
);
CREATE INDEX ix_admin_audit_actor ON admin_audit_log(actor_id, occurred_at DESC);
CREATE INDEX ix_admin_audit_resource ON admin_audit_log(resource_type, resource_id);
Fields:
actor_id— who performed the action (the user's identifier from the token).occurred_at— exactly when it happened.action— what was done (cancel-order,refund-payment).resource_type+resource_id— which object the action relates to.metadata— a JSONB field for details:previousStatus,newStatus,reason. You don't fix the structure in advance — you add fields as needed.request_id/trace_id— the link to distributed tracing for debugging.
The variant with a separate table per aggregate is justified when the metadata schema differs fundamentally for different entities and you need typed columns.
Implementation via a NestInterceptor
An interceptor is a convenient central point: all admin endpoints are marked with a decorator, and the interceptor writes a row after the request has been successfully handled.
First the decorator that describes the action:
// adapters/in/http/security/admin-action.decorator.ts
import { SetMetadata } from '@nestjs/common';
export const ADMIN_ACTION_KEY = 'adminAction';
export interface AdminActionMeta {
action: string;
resourceType: string;
resourceIdParam: string;
}
export const AdminAction = (meta: AdminActionMeta) =>
SetMetadata(ADMIN_ACTION_KEY, meta);
Then the interceptor that reads this decorator and writes to the audit:
// adapters/in/http/security/admin-audit.interceptor.ts
import {
CallHandler, ExecutionContext, Injectable, Logger, NestInterceptor,
} from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { Observable, tap } from 'rxjs';
import { ClsService } from 'nestjs-cls';
import { AdminAuditLogRepository } from '../../../../core/shared/ports/admin-audit-log.repository';
import { Clock } from '../../../../core/shared/clock';
import { ADMIN_ACTION_KEY, AdminActionMeta } from './admin-action.decorator';
import { Principal } from './principal';
@Injectable()
export class AdminAuditInterceptor implements NestInterceptor {
private readonly logger = new Logger(AdminAuditInterceptor.name);
constructor(
private readonly reflector: Reflector,
private readonly audit: AdminAuditLogRepository,
private readonly clock: Clock,
private readonly cls: ClsService,
) {}
intercept(ctx: ExecutionContext, next: CallHandler): Observable<unknown> {
const meta = this.reflector.get<AdminActionMeta>(ADMIN_ACTION_KEY, ctx.getHandler());
if (!meta) return next.handle();
const req = ctx.switchToHttp().getRequest<{ user: Principal }>();
const { user } = req;
if (!user.roles.includes('admin')) return next.handle();
const resourceId = resolveResourceId(req, meta.resourceIdParam);
return next.handle().pipe(
tap(() => {
void this.audit
.append({
actorId: user.sub,
action: meta.action,
resourceType: meta.resourceType,
resourceId,
occurredAt: this.clock.now(),
metadata: {},
requestId: this.cls.get('requestId'),
traceId: this.cls.get('traceId'),
})
.catch((err) => this.logger.error({ err }, 'audit.append failed'));
}),
);
}
}
function resolveResourceId(req: Record<string, unknown>, param: string): string {
return String((req['params'] as Record<string, unknown>)?.[param] ?? '');
}
Applying it on a controller:
// adapters/in/http/order.admin.controller.ts
@Controller('admin/orders')
@Roles(['admin'])
@UseInterceptors(AdminAuditInterceptor)
export class OrderAdminController {
constructor(private readonly cancelOrder: CancelOrderUseCase) {}
@Delete(':orderId')
@AdminAction({ action: 'cancel-order', resourceType: 'Order', resourceIdParam: 'orderId' })
cancel(@Param('orderId') orderId: string, @CurrentUser() actor: Principal): Promise<void> {
return this.cancelOrder.execute({ orderId, actor });
}
}
The interceptor is registered globally or via UseInterceptors on a specific controller. The advantage — you can't forget by accident: if the decorator is there, the audit is written automatically.
Implementation via an explicit call in the Handler
Sometimes the audit needs domain context — for example, the order status before and after cancellation. The interceptor doesn't see it: it works at the HTTP level, not the business logic level. In that case the record is made right in the Handler, inside the transaction:
// core/order/handlers/cancel-order.handler.ts
@Injectable()
export class CancelOrderHandler {
constructor(
private readonly orders: OrderRepository,
private readonly audit: AdminAuditLogRepository,
private readonly clock: Clock,
) {}
async execute(cmd: CancelOrderCommand, principal: Principal): Promise<void> {
await this.dataSource.transaction(async (em) => {
const order = await em.findOneOrFail(Order, {
where: { id: cmd.orderId },
lock: { mode: 'pessimistic_write' },
});
const previousStatus = order.status;
order.cancel();
await em.save(order);
if (principal.roles.includes('admin')) {
await this.audit.appendWithEntityManager(em, {
actorId: principal.sub,
action: 'cancel-order',
resourceType: 'Order',
resourceId: order.id,
occurredAt: this.clock.now(),
metadata: {
previousStatus,
newStatus: order.status,
ownerCustomerId: order.customerId,
},
});
}
});
}
}
Here metadata contains real domain context: what was before and what became after.
When to choose which:
- Interceptor — for typical cases where a simple "who/what/when" record is needed. You won't forget anything, and the logic is in one place.
- Explicit call in the Handler — when you need specific context from the domain operation (state before/after, reason, related objects).
One transaction with the business operation
The audit is written to the same database and in the same transaction as the business operation:
BEGIN
order.cancel()
em.save(order)
audit.appendWithEntityManager(em, ...)
COMMIT
This gives an important guarantee: if the business operation is rolled back — the audit is rolled back too (no false records). If the commit went through — the audit is committed (no missed events).
A common mistake is writing the audit only to a message queue. Between the successful database commit and publishing the message there is a window: if the process crashes at that moment, the change happens but doesn't make it into the audit. If you need centralized audit infrastructure — you write twice: locally in one transaction plus publishing through an outbox into the audit stream.
Append-only: INSERT only
The audit table must not allow modifying or deleting records. If an admin can erase a row about their own actions, the whole point of the audit is lost:
REVOKE UPDATE, DELETE ON admin_audit_log FROM application_role;
Purging old records is a separate tool with database administrator privileges, not from the application code. A typical retention period is one to seven years depending on industry requirements.
What goes into metadata
In metadata you write only identifiers: customerId, orderId, previousStatus. Personal data — email, name, address — is not put there. If the audit log leaks or becomes accessible to a wide circle, an identifier doesn't reveal the person directly.
In short
- An audit log is needed for every admin action that changes the state of the system. Without it, the compromise of an admin account stays invisible.
- The minimal record fields:
actor_id,occurred_at,action,resource_type,resource_id. PlusmetadataJSONB for details. - Two implementation approaches: a NestInterceptor (convenient, automatic, without domain context) and an explicit call in the Handler (when you need the before/after context).
- The audit is written in the same transaction as the business operation — otherwise discrepancies are possible.
- The table is append-only: only INSERT, and UPDATE and DELETE are revoked via REVOKE.
- In
metadata— only identifiers, not personal data directly.
What to read next
- ABAC: resource ownership — the admin override as a typical case requiring an audit.
- JWT validation — how a Principal with roles reaches the Guard.
- RBAC: role mapping — where the admin role comes from.
- Which check goes where — the boundary between the Guard and the Handler.