Администратор может делать то, что обычным пользователям недоступно: отменить чужой заказ, заблокировать аккаунт, изменить статус вручную. Если такие действия нигде не фиксируются, при компрометации admin-аккаунта невозможно восстановить ни что было сделано, ни к каким данным получили доступ. Журнал действий решает эту проблему.
Что фиксировать и в какой форме
Каждое действие администратора, которое меняет данные, должно оставлять запись. Минимальный набор полей:
- actor_id — кто сделал (идентификатор из JWT, не email);
- action — что сделал (
"cancel-order","block-customer"); - resource_type и resource_id — к чему применил (
"Order","42"); - occurred_at — когда (временная метка с часовым поясом);
- metadata — детали в свободном JSON: статус до/после, причина, владелец ресурса.
Email и другие персональные данные в metadata не кладут — только идентификатор (sub из токена).
Таблица в базе данных
Одна таблица на сервис (или отдельная на каждый агрегат, если структура метаданных принципиально различается):
CREATE TABLE admin_audit_log (
id bigserial 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);
REVOKE UPDATE, DELETE ON admin_audit_log FROM app_role;
REVOKE UPDATE, DELETE — таблица только для добавления записей. Изменять или удалять строки нельзя. Это гарантирует, что записи не исчезнут даже при ошибке в коде приложения.
metadata JSONB не имеет фиксированной схемы — добавляете туда нужные поля по мере необходимости: previous_status, new_status, reason, owner_customer_id.
SQLAlchemy-модель и репозиторий
# adapters/out/db/audit_models.py
from datetime import datetime, UTC
from sqlalchemy import Column, BigInteger, Text, DateTime, Index
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import DeclarativeBase
class Base(DeclarativeBase):
pass
class AdminAuditLog(Base):
__tablename__ = "admin_audit_log"
id = Column(BigInteger, primary_key=True, autoincrement=True)
actor_id = Column(Text, nullable=False)
action = Column(Text, nullable=False)
resource_type = Column(Text, nullable=False)
resource_id = Column(Text, nullable=False)
occurred_at = Column(DateTime(timezone=True), nullable=False, default=lambda: datetime.now(UTC))
metadata = Column(JSONB, nullable=False, default=dict)
request_id = Column(Text)
trace_id = Column(Text)
__table_args__ = (
Index("ix_admin_audit_actor", "actor_id", "occurred_at"),
Index("ix_admin_audit_resource", "resource_type", "resource_id"),
)
# adapters/out/db/audit_repository.py
from dataclasses import dataclass
from datetime import datetime, UTC
from sqlalchemy.ext.asyncio import AsyncSession
from .audit_models import AdminAuditLog
@dataclass
class AuditRecord:
actor_id: str
action: str
resource_type: str
resource_id: str
metadata: dict
request_id: str | None = None
trace_id: str | None = None
class AdminAuditRepository:
def __init__(self, session: AsyncSession) -> None:
self._session = session
async def append(self, record: AuditRecord) -> None:
self._session.add(AdminAuditLog(
actor_id = record.actor_id,
action = record.action,
resource_type = record.resource_type,
resource_id = record.resource_id,
occurred_at = datetime.now(UTC),
metadata = record.metadata,
request_id = record.request_id,
trace_id = record.trace_id,
))
append не вызывает commit — транзакцией управляет хэндлер или FastAPI-зависимость. Это важно: запись в журнал должна попасть в ту же транзакцию, что и бизнес-операция.
Одна транзакция для бизнес-операции и журнала
Самая распространённая ошибка — писать в журнал уже после commit бизнес-изменения. Тогда при сбое между двумя операциями данные изменятся, но запись не появится. Правильно — держать обе операции в одной транзакции:
# adapters/in/http/dependencies.py
from contextlib import asynccontextmanager
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
@asynccontextmanager
async def db_session(factory: async_sessionmaker[AsyncSession]):
async with factory() as session:
async with session.begin():
yield session
# adapters/in/http/routers/orders.py
@router.delete("/orders/{order_id}")
async def cancel_order(
order_id: uuid.UUID,
principal: Principal = Depends(require_roles("admin", "customer")),
session: AsyncSession = Depends(get_session),
):
handler = CancelOrderHandler(
orders = SqlOrderRepository(session),
audit = AdminAuditRepository(session),
)
return await handler.handle(CancelOrderCommand(order_id=order_id), principal)
session.begin() охватывает и сохранение заказа, и запись в журнал. Если хэндлер бросил исключение — оба откатились. Не выносите журнал в отдельный HTTP-вызов или очередь: событие может потеряться между commit и publish, и изменение окажется незафиксированным.
Способ 1 — декоратор
Для типовых admin-команд удобен декоратор: нельзя случайно забыть добавить журналирование при написании нового хэндлера.
# application/audit.py
import functools
from collections.abc import Callable
from typing import Any
def admin_audit(action: str, resource_type: str, resource_id_attr: str = "id"):
def decorator(fn: Callable) -> Callable:
@functools.wraps(fn)
async def wrapper(self, command, principal, *args, **kwargs) -> Any:
result = await fn(self, command, principal, *args, **kwargs)
if principal.is_admin():
resource_id = str(getattr(command, resource_id_attr))
await self._audit.append(AuditRecord(
actor_id = principal.sub,
action = action,
resource_type = resource_type,
resource_id = resource_id,
metadata = {},
request_id = getattr(command, "request_id", None),
trace_id = getattr(command, "trace_id", None),
))
return result
return wrapper
return decorator
# application/cancel_order_handler.py
class CancelOrderHandler:
def __init__(self, orders: OrderRepository, audit: AdminAuditRepository) -> None:
self._orders = orders
self._audit = audit
@admin_audit(action="cancel-order", resource_type="Order", resource_id_attr="order_id")
async def handle(self, command: CancelOrderCommand, principal: Principal) -> Order:
order = await self._orders.find_by_id(command.order_id)
if order is None:
raise OrderNotFoundError(command.order_id)
if not principal.is_admin() and order.customer_id != principal.sub:
raise ForbiddenError()
order.cancel()
await self._orders.save(order)
return order
Декоратор срабатывает только при principal.is_admin() — обычные пользователи через тот же хэндлер не оставляют записей в журнале.
Способ 2 — явный вызов
Когда в журнале нужны детали, доступные только внутри метода (статус до изменения, владелец ресурса), явный вызов читается нагляднее:
# application/cancel_order_handler.py
class CancelOrderHandler:
def __init__(self, orders: OrderRepository, audit: AdminAuditRepository) -> None:
self._orders = orders
self._audit = audit
async def handle(self, command: CancelOrderCommand, principal: Principal) -> Order:
order = await self._orders.find_by_id(command.order_id)
if order is None:
raise OrderNotFoundError(command.order_id)
if not principal.is_admin() and order.customer_id != principal.sub:
raise ForbiddenError()
previous_status = order.status
order.cancel()
await self._orders.save(order)
if principal.is_admin():
await self._audit.append(AuditRecord(
actor_id = principal.sub,
action = "cancel-order",
resource_type = "Order",
resource_id = str(command.order_id),
metadata = {
"previous_status": previous_status.value,
"new_status": order.status.value,
"owner_customer_id": str(order.customer_id),
},
request_id = command.request_id,
trace_id = command.trace_id,
))
return order
Выбор между декоратором и явным вызовом: если метаданные простые — декоратор; если нужны данные из середины метода (статус до изменения, вычисленные поля) — явный вызов.
Те же правила для других агрегатов
Паттерн одинаков для любой admin-команды — блокировка пользователя, принудительная публикация товара, ручная корректировка баланса:
# application/block_customer_handler.py
class BlockCustomerHandler:
def __init__(self, customers: CustomerRepository, audit: AdminAuditRepository) -> None:
self._customers = customers
self._audit = audit
async def handle(self, command: BlockCustomerCommand, principal: Principal) -> Customer:
customer = await self._customers.find_by_id(command.customer_id)
if customer is None:
raise CustomerNotFoundError(command.customer_id)
previous_status = customer.status
customer.block(reason=command.reason)
await self._customers.save(customer)
await self._audit.append(AuditRecord(
actor_id = principal.sub,
action = "block-customer",
resource_type = "Customer",
resource_id = str(command.customer_id),
metadata = {
"previous_status": previous_status.value,
"reason": command.reason,
},
))
return customer
Частые ошибки
Журнал после commit. Если audit.append вызывается уже после того, как транзакция закрыта, то при любом сбое между ними изменение есть, а записи нет. Используйте одну сессию для обеих операций.
Email вместо идентификатора. В metadata и actor_id кладут только sub из JWT — стабильный идентификатор, не адрес почты. Email — персональные данные, которые меняются и не должны попадать в постоянные записи в открытом виде.
Только деструктивные действия. Критичные операции чтения тоже подлежат фиксации — например, когда администратор просматривает персональные данные чужого аккаунта.
Отдельная очередь для журнала. Событие может потеряться между commit бизнес-операции и отправкой в очередь. Одна транзакция надёжнее.
Изменяемая таблица. Строки журнала нельзя редактировать или удалять — это снижает доверие к записям. REVOKE UPDATE, DELETE закрывает эту возможность на уровне базы.
Коротко
- Каждое изменяющее данные действие администратора фиксируется в
admin_audit_log. - Обязательные поля:
actor_id,action,resource_type,resource_id,occurred_at. - В
metadata— детали (статус до/после, причина); персональные данные туда не кладут. - Таблица только для добавления записей:
REVOKE UPDATE, DELETE FROM app_role. - Запись в журнал и бизнес-операция — в одной транзакции SQLAlchemy.
- Декоратор
@admin_audit— для типовых команд; явный вызов — когда нужны данные из середины метода. - Не выносите журналирование в очередь или отдельный HTTP-вызов.
Что почитать дальше
- ABAC: владение ресурсом — как admin override пересекается с проверкой владельца.
- JWT validation — как из токена получить
principal.subдляactor_id. - RBAC: роли —
require_roles("admin")на endpoint перед хэндлером.