Когда 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. ПлюсmetadataJSONB для деталей. - Два подхода реализации: NestInterceptor (удобен, автоматически, без доменного контекста) и явный вызов в Handler (когда нужен контекст до/после).
- Audit пишется в той же транзакции, что и бизнес-операция — иначе возможны расхождения.
- Таблица append-only: только INSERT, UPDATE и DELETE отзываются через REVOKE.
- В
metadata— только идентификаторы, не персональные данные напрямую.
Что почитать дальше
- ABAC: владение ресурсом — admin override как типичный случай, требующий audit.
- JWT-валидация — как Principal с ролями попадает в Guard.
- RBAC: маппинг ролей — откуда берётся роль admin.
- Где делается проверка — граница между Guard и Handler.