Опирается на правила: AUTH-12, AUTH-15 из Auth Patterns — раздел 6. Аудит admin-команд.

Важно знать

  • Каждая команда от роли admin, меняющая state агрегата, обязана писать строку в *_audit_log.
  • Поля: actor_id (кто), occurred_at (когда), action (что), resource_type + resource_id (к чему), metadata JSONB (детали).
  • РеализацияNestInterceptor на admin-эндпоинтах или явный вызов в Handler.
  • Та же БД, одна транзакция: TypeORM/Drizzle — бизнес-write + audit INSERT либо оба откатываются.
  • При ABAC override (admin отменил чужой заказ) — audit обязателен (AUTH-12).
  • Audit append-only: только INSERT, никаких UPDATE/DELETE.
  • Без audit компрометация admin-аккаунта = невидимый ущерб.
  • PII в metadata — только идентификатор (customerId), не email/ФИО (AUTH-16).

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

Schema audit-таблицы

AUTH-15 — одна таблица на сервис или per-aggregate:

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

metadata JSONB — расширяемое поле для деталей: previousStatus, newStatus, reason, relatedIds. Структуру не фиксируем заранее — добавляем по необходимости.

Вариант per-aggregate (order_audit_log, payment_audit_log) оправдан, когда схема metadata принципиально различается и нужны типизированные колонки.

Реализация — NestInterceptor

Interceptor как точка пересечения: все admin-эндпоинты помечаются декоратором, interceptor пишет строку после успешного next.handle():

// adapters/in/http/security/admin-audit.interceptor.ts
import {
  CallHandler, ExecutionContext, Injectable, 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 {
  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(() =>
        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'),
        }),
      ),
    );
  }
}

function resolveResourceId(req: Record<string, unknown>, param: string): string {
  return String((req['params'] as Record<string, unknown>)?.[param] ?? '');
}
// 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);

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

// 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 на всех admin-маршрутах одновременно.

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

Альтернатива без interceptor — явная запись внутри транзакции Handler-а. Подходит, когда metadata требует доменного контекста (состояние агрегата до и после):

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

      if (!principal.roles.includes('admin') && order.customerId !== principal.sub) {
        throw new ForbiddenError(cmd.orderId);   // AUTH-10
      }

      const previousStatus = order.status;
      order.cancel();
      await em.save(order);

      if (principal.roles.includes('admin')) {
        await this.audit.appendWithEntityManager(em, {   // AUTH-15, та же транзакция
          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-у. Преимущество interceptor-а — DRY: нельзя забыть, и логика не разбросана по Handler-ам.

Выбор: interceptor — для типовых случаев; явный вызов — когда нужен specific context до/после операции.

Одна транзакция

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

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

Это даёт:

  • Бизнес-операция откатилась — audit тоже откатился (нет ложных записей).
  • Бизнес commit — audit commit (нет пропущенных событий).

Не выносим audit исключительно в Kafka — потеря событий между commit и publish даёт пропуски для реальных изменений. Если в проекте нужна централизованная audit-инфра (security team) — пишем дважды: локально + публикация через outbox в audit-стрим.

Append-only

Audit-таблица — только INSERT. Никаких UPDATE/DELETE:

REVOKE UPDATE, DELETE ON admin_audit_log FROM application_role;

Если admin может удалить свою запись о действии — audit теряет смысл. Compliance требует immutability.

Очистка старых записей — отдельный инструмент с DBA-привилегиями, не из application. Retention: обычно 1–7 лет в зависимости от индустрии.

Что запрещено

АнтипаттернПравилоЧто взамен
Admin override без auditAUTH-15audit обязателен при каждом admin-действии
Audit только через Kafka, без локального INSERTAUTH-15та же БД + outbox для дубликата
Audit без actor_id или occurred_atAUTH-15оба поля обязательны
UPDATE admin_audit_log SET ...AUTH-15append-only, REVOKE UPDATE/DELETE
Audit без trace_id / request_idAUTH-15для связки с distributed trace
PII (email, ФИО) в metadataAUTH-16только customerId / actorId
Audit пишется вне транзакции (after commit)AUTH-15внутри той же транзакции
Критичные reads без записи (просмотр PII admin-ом)AUTH-15критичные reads тоже в audit

Куда дальше

  • ABAC: владение ресурсом — admin override как триггер audit.
  • PII и секреты — что допустимо в metadata.
  • JWT-валидация — как Principal с ролями попадает в Guard.
  • RBAC: маппинг ролей — откуда берётся роль admin.
  • Идемпотентность — audit дубликата события = одна строка.
  • Service-to-service — audit при s2s admin-вызовах.
  • Где делается проверка — граница между Guard и Handler.
  • Хранение токенов на клиенте — как admin-сессия попадает в request.