← назад к разделу

Когда admin-аккаунт взломан или сотрудник делает что-то не то — без audit log вы об этом узнаете только от клиента, который заметил изменение. Admin-роль обходит обычные ограничения доступа: она нужна для support, ops и compliance-сценариев, но именно поэтому каждое admin-действие должно быть зафиксировано.

Audit log — это таблица, где каждая строка отвечает на вопросы: кто, когда, что сделал и с чем. Без него компрометация admin-аккаунта превращается в невидимый ущерб.

Структура таблицы

Заводят одну таблицу на сервис или отдельную таблицу на агрегат (например, order_audit_log). Минимальная схема:

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);

Поля:

  • actor_id — кто выполнил действие (идентификатор пользователя из токена).
  • occurred_at — когда точно произошло.
  • action — что было сделано (cancel-order, refund-payment).
  • resource_type + resource_id — к какому объекту относится действие.
  • metadata — JSONB-поле для деталей: previousStatus, newStatus, reason. Структуру не фиксируют заранее — добавляют по необходимости.
  • request_id / trace_id — связка с distributed-трассировкой для отладки.

Вариант с отдельной таблицей на агрегат оправдан, когда схема metadata принципиально различается для разных сущностей и нужны типизированные колонки.

Реализация через NestInterceptor

Interceptor — удобная центральная точка: все admin-эндпоинты помечаются декоратором, interceptor пишет строку после того, как запрос успешно обработан.

Сначала декоратор, который описывает действие:

// 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);

Затем interceptor, который читает этот декоратор и пишет в 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] ?? '');
}

Применение на контроллере:

// 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 });
  }
}

Interceptor регистрируют глобально или через UseInterceptors на конкретном контроллере. Преимущество — нельзя случайно забыть: если декоратор есть, audit пишется автоматически.

Реализация через явный вызов в Handler

Иногда для audit нужен доменный контекст — например, статус заказа до и после отмены. Interceptor его не видит: он работает на уровне HTTP, а не бизнес-логики. В таком случае запись делается прямо в Handler-е внутри транзакции:

// 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,
          },
        });
      }
    });
  }
}

Здесь metadata содержит реальный доменный контекст: что было до и что стало после.

Когда что выбирать:

  • Interceptor — для типовых случаев, когда нужна простая запись «кто/что/когда». Ничего не забудешь, логика в одном месте.
  • Явный вызов в Handler — когда нужен конкретный контекст из доменной операции (состояние до/после, причина, связанные объекты).

Одна транзакция с бизнес-операцией

Audit пишется в той же базе данных и в той же транзакции, что и бизнес-операция:

BEGIN
  order.cancel()
  em.save(order)
  audit.appendWithEntityManager(em, ...)
COMMIT

Это даёт важную гарантию: если бизнес-операция откатилась — audit тоже откатился (нет ложных записей). Если commit прошёл — audit commit (нет пропущенных событий).

Частая ошибка — писать audit только в очередь сообщений. Между успешным commit базы и публикацией сообщения есть окно: если процесс упадёт в этот момент, изменение произойдёт, но в audit не попадёт. Если нужна централизованная audit-инфраструктура — пишут дважды: локально в одной транзакции плюс публикация через outbox в audit-поток.

Append-only: только INSERT

Audit-таблица не должна позволять изменять или удалять записи. Если admin может стереть строку о своих действиях — весь смысл audit теряется:

REVOKE UPDATE, DELETE ON admin_audit_log FROM application_role;

Очистка старых записей — отдельный инструмент с привилегиями администратора базы данных, не из кода приложения. Типичный срок хранения — от одного до семи лет в зависимости от требований отрасли.

Что попадает в metadata

В metadata пишут только идентификаторы: customerId, orderId, previousStatus. Персональные данные — email, имя, адрес — туда не кладут. Если audit-лог утечёт или станет доступен широкому кругу, идентификатор не раскрывает человека напрямую.

Коротко

  • Audit log нужен для каждого admin-действия, которое меняет состояние системы. Без него компрометация admin-аккаунта остаётся невидимой.
  • Минимальные поля записи: actor_id, occurred_at, action, resource_type, resource_id. Плюс metadata JSONB для деталей.
  • Два подхода реализации: NestInterceptor (удобен, автоматически, без доменного контекста) и явный вызов в Handler (когда нужен контекст до/после).
  • Audit пишется в той же транзакции, что и бизнес-операция — иначе возможны расхождения.
  • Таблица append-only: только INSERT, UPDATE и DELETE отзываются через REVOKE.
  • В metadata — только идентификаторы, не персональные данные напрямую.

Что почитать дальше