← Back to the section

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. Plus metadata JSONB 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.