Опирается на правила:
AUTH-12,AUTH-15из Auth Patterns — раздел 6. Аудит admin-команд.
Важно знать
- Каждая команда от роли
admin, меняющая state агрегата, обязана писать строку в*_audit_log.- Поля:
actor_id(кто),occurred_at(когда),action(что),resource_type+resource_id(к чему),metadataJSONB (детали).- Реализация —
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 без audit | AUTH-15 | audit обязателен при каждом admin-действии |
| Audit только через Kafka, без локального INSERT | AUTH-15 | та же БД + outbox для дубликата |
Audit без actor_id или occurred_at | AUTH-15 | оба поля обязательны |
UPDATE admin_audit_log SET ... | AUTH-15 | append-only, REVOKE UPDATE/DELETE |
Audit без trace_id / request_id | AUTH-15 | для связки с distributed trace |
PII (email, ФИО) в metadata | AUTH-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.